This commit is contained in:
2026-04-28 19:36:39 +08:00
parent a9febe7678
commit f0471a4f8d
206 changed files with 18456 additions and 10133 deletions

View File

@@ -40,7 +40,8 @@ use crate::{
character_animation_assets::{
generate_character_animation, get_character_animation_job, get_character_workflow_cache,
import_character_animation_video, list_character_animation_templates,
publish_character_animation, save_character_workflow_cache,
publish_character_animation, put_role_asset_workflow, resolve_role_asset_workflow,
save_character_workflow_cache,
},
character_visual_assets::{
generate_character_visual, get_character_visual_job, publish_character_visual,
@@ -49,7 +50,8 @@ use crate::{
custom_world::{
create_custom_world_agent_session, delete_custom_world_agent_session,
delete_custom_world_library_profile, execute_custom_world_agent_action,
get_custom_world_agent_card_detail, get_custom_world_agent_operation,
generate_custom_world_profile, get_custom_world_agent_card_detail,
get_custom_world_agent_operation, get_custom_world_agent_result_view,
get_custom_world_agent_session, get_custom_world_gallery_detail,
get_custom_world_gallery_detail_by_code, get_custom_world_library,
get_custom_world_library_detail, get_custom_world_works, list_custom_world_gallery,
@@ -83,8 +85,7 @@ use crate::{
get_puzzle_agent_session, get_puzzle_gallery_detail, get_puzzle_run,
get_puzzle_work_detail, get_puzzle_works, list_puzzle_gallery, put_puzzle_work,
start_puzzle_run, stream_puzzle_agent_message, submit_puzzle_agent_message,
submit_puzzle_leaderboard,
swap_puzzle_pieces,
submit_puzzle_leaderboard, swap_puzzle_pieces,
},
refresh_session::refresh_session,
request_context::{attach_request_context, resolve_request_id},
@@ -93,6 +94,11 @@ use crate::{
delete_runtime_browse_history, get_runtime_browse_history, post_runtime_browse_history,
},
runtime_chat::stream_runtime_npc_chat_turn,
runtime_chat_plain::{
generate_runtime_character_chat_suggestions, generate_runtime_character_chat_summary,
stream_runtime_character_chat_reply, stream_runtime_npc_chat_dialogue,
stream_runtime_npc_recruit_dialogue,
},
runtime_inventory::get_runtime_inventory_state,
runtime_profile::{
create_profile_recharge_order, get_profile_dashboard, get_profile_play_stats,
@@ -105,8 +111,9 @@ use crate::{
},
runtime_settings::{get_runtime_settings, put_runtime_settings},
runtime_story::{
generate_runtime_story_continue, generate_runtime_story_initial, get_runtime_story_state,
resolve_runtime_story_action, resolve_runtime_story_state,
begin_runtime_story_session, generate_runtime_story_continue,
generate_runtime_story_initial, get_runtime_story_state, resolve_runtime_story_action,
resolve_runtime_story_state,
},
state::AppState,
story_battles::{
@@ -249,6 +256,32 @@ pub fn build_router(state: AppState) -> Router {
require_bearer_auth,
)),
)
.route(
"/api/runtime/chat/character/suggestions",
post(generate_runtime_character_chat_suggestions).route_layer(
middleware::from_fn_with_state(state.clone(), require_bearer_auth),
),
)
.route(
"/api/runtime/chat/character/summary",
post(generate_runtime_character_chat_summary).route_layer(
middleware::from_fn_with_state(state.clone(), require_bearer_auth),
),
)
.route(
"/api/runtime/chat/character/reply/stream",
post(stream_runtime_character_chat_reply).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/runtime/chat/npc/dialogue/stream",
post(stream_runtime_npc_chat_dialogue).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/runtime/chat/npc/turn/stream",
post(stream_runtime_npc_chat_turn).route_layer(middleware::from_fn_with_state(
@@ -256,6 +289,13 @@ pub fn build_router(state: AppState) -> Router {
require_bearer_auth,
)),
)
.route(
"/api/runtime/chat/npc/recruit/stream",
post(stream_runtime_npc_recruit_dialogue).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/runtime/creation-agent/document-inputs/parse",
post(parse_creation_agent_document_input).route_layer(middleware::from_fn_with_state(
@@ -398,6 +438,10 @@ pub fn build_router(state: AppState) -> Router {
"/api/assets/character-workflow-cache/{character_id}",
get(get_character_workflow_cache),
)
.route(
"/api/runtime/custom-world/asset-studio/role/{character_id}/workflow",
post(resolve_role_asset_workflow).put(put_role_asset_workflow),
)
.route("/api/assets/read-url", get(get_asset_read_url))
.route(
"/api/assets/history",
@@ -483,6 +527,13 @@ pub fn build_router(state: AppState) -> Router {
require_bearer_auth,
)),
)
.route(
"/api/runtime/custom-world/agent/sessions/{session_id}/result-view",
get(get_custom_world_agent_result_view).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/runtime/custom-world/works",
get(get_custom_world_works).route_layer(middleware::from_fn_with_state(
@@ -681,6 +732,13 @@ pub fn build_router(state: AppState) -> Router {
require_bearer_auth,
)),
)
.route(
"/api/runtime/custom-world/profile",
post(generate_custom_world_profile).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/custom-world/entity",
post(generate_custom_world_entity).route_layer(middleware::from_fn_with_state(
@@ -890,6 +948,13 @@ pub fn build_router(state: AppState) -> Router {
require_bearer_auth,
)),
)
.route(
"/api/runtime/story/sessions",
post(begin_runtime_story_session).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/runtime/story/state/resolve",
post(resolve_runtime_story_state).route_layer(middleware::from_fn_with_state(

View File

@@ -31,9 +31,10 @@ use shared_kernel::{build_prefixed_uuid_id, format_timestamp_micros};
use spacetime_client::{
BigFishAgentMessageRecord, BigFishAnchorItemRecord, BigFishAnchorPackRecord,
BigFishAssetCoverageRecord, BigFishAssetGenerateRecordInput, BigFishAssetSlotRecord,
BigFishBackgroundBlueprintRecord, BigFishGameDraftRecord, BigFishLevelBlueprintRecord,
BigFishMessageSubmitRecordInput, BigFishRuntimeParamsRecord, BigFishSessionCreateRecordInput,
BigFishSessionRecord, BigFishWorkSummaryRecord, SpacetimeClientError,
BigFishBackgroundBlueprintRecord, BigFishDraftCompileRecordInput, BigFishGameDraftRecord,
BigFishLevelBlueprintRecord, BigFishMessageSubmitRecordInput, BigFishRuntimeParamsRecord,
BigFishSessionCreateRecordInput, BigFishSessionRecord, BigFishWorkSummaryRecord,
SpacetimeClientError,
};
use tokio::time::sleep;
@@ -41,6 +42,12 @@ use crate::big_fish_agent_turn::{
BigFishAgentTurnRequest, build_failed_finalize_record_input, build_finalize_record_input,
run_big_fish_agent_turn,
};
use crate::big_fish_draft_compiler::compile_big_fish_draft_with_fallback;
use crate::prompt::big_fish::{
BIG_FISH_DEFAULT_NEGATIVE_PROMPT, BIG_FISH_TRANSPARENT_ASSET_NEGATIVE_PROMPT,
build_big_fish_level_main_image_prompt, build_big_fish_level_motion_prompt,
build_big_fish_stage_background_prompt,
};
use crate::{
ai_generation_drafts::{
AiGenerationDraftContext, AiGenerationDraftSink, AiGenerationDraftWriter,
@@ -48,6 +55,7 @@ use crate::{
api_response::json_success_body,
asset_billing::{consume_asset_operation_points, refund_asset_operation_points},
auth::AuthenticatedAccessToken,
character_visual_assets::try_apply_background_alpha_to_png,
http_error::AppError,
request_context::RequestContext,
state::AppState,
@@ -101,13 +109,13 @@ pub async fn get_big_fish_session(
) -> Result<Json<Value>, Response> {
ensure_non_empty(&request_context, &session_id, "sessionId")?;
let session = state
.spacetime_client()
.get_big_fish_session(session_id, authenticated.claims().user_id().to_string())
.await
.map_err(|error| {
big_fish_error_response(&request_context, map_big_fish_client_error(error))
})?;
let session = load_big_fish_session_with_retry(
&state,
session_id,
authenticated.claims().user_id().to_string(),
)
.await
.map_err(|error| big_fish_error_response(&request_context, map_big_fish_client_error(error)))?;
Ok(json_success_body(
Some(&request_context),
@@ -145,13 +153,22 @@ pub async fn list_big_fish_gallery(
State(state): State<AppState>,
Extension(request_context): Extension<RequestContext>,
) -> Result<Json<Value>, Response> {
let items = state
.spacetime_client()
.list_big_fish_gallery()
.await
.map_err(|error| {
big_fish_error_response(&request_context, map_big_fish_client_error(error))
})?;
let items = match state.spacetime_client().list_big_fish_gallery().await {
Ok(items) => items,
Err(error) if should_soft_fallback_big_fish_gallery(&error) => {
tracing::warn!(
error = %error,
"大鱼吃小鱼公开广场读取失败,已按空广场降级返回"
);
Vec::new()
}
Err(error) => {
return Err(big_fish_error_response(
&request_context,
map_big_fish_client_error(error),
));
}
};
Ok(json_success_body(
Some(&request_context),
@@ -489,13 +506,8 @@ pub async fn execute_big_fish_action(
let session_result = match action.as_str() {
"big_fish_compile_draft" => {
compile_big_fish_draft_with_all_assets(
&state,
session_id.clone(),
owner_user_id.clone(),
now,
)
.await
compile_big_fish_draft_only(&state, session_id.clone(), owner_user_id.clone(), now)
.await
}
"big_fish_generate_level_main_image" => {
let asset_url = generate_big_fish_formal_asset(
@@ -734,9 +746,13 @@ fn map_big_fish_level_response(
level: level.level,
name: level.name,
one_line_fantasy: level.one_line_fantasy,
text_description: level.text_description,
silhouette_direction: level.silhouette_direction,
size_ratio: level.size_ratio,
visual_description: level.visual_description,
visual_prompt_seed: level.visual_prompt_seed,
idle_motion_description: level.idle_motion_description,
move_motion_description: level.move_motion_description,
motion_prompt_seed: level.motion_prompt_seed,
merge_source_level: level.merge_source_level,
prey_window: level.prey_window,
@@ -802,98 +818,88 @@ fn map_big_fish_asset_coverage_response(
}
}
async fn compile_big_fish_draft_with_all_assets(
async fn compile_big_fish_draft_only(
state: &AppState,
session_id: String,
owner_user_id: String,
now: i64,
) -> Result<BigFishSessionRecord, SpacetimeClientError> {
let session = state
.spacetime_client()
.compile_big_fish_draft(session_id.clone(), owner_user_id.clone(), now)
.await?;
let draft = session
.draft
.clone()
.ok_or_else(|| SpacetimeClientError::Runtime("大鱼吃小鱼玩法草稿尚未生成".to_string()))?;
// 点击生成草稿时一次性生成所有首版玩法资产,前端只负责展示进度和最终 session。
for level in &draft.levels {
let asset_url = generate_big_fish_formal_asset(
state,
&owner_user_id,
&session_id,
"level_main_image",
Some(level.level),
None,
current_utc_micros(),
)
.await
.map_err(|error| SpacetimeClientError::Runtime(error.message().to_string()))?;
state
.spacetime_client()
.generate_big_fish_asset(BigFishAssetGenerateRecordInput {
session_id: session_id.clone(),
owner_user_id: owner_user_id.clone(),
asset_kind: "level_main_image".to_string(),
level: Some(level.level),
motion_key: None,
asset_url: Some(asset_url),
generated_at_micros: current_utc_micros(),
})
.await?;
}
for level in &draft.levels {
for motion_key in ["idle_float", "move_swim"] {
let asset_url = generate_big_fish_formal_asset(
state,
&owner_user_id,
&session_id,
"level_motion",
Some(level.level),
Some(motion_key),
current_utc_micros(),
)
.await
.map_err(|error| SpacetimeClientError::Runtime(error.message().to_string()))?;
state
.spacetime_client()
.generate_big_fish_asset(BigFishAssetGenerateRecordInput {
session_id: session_id.clone(),
owner_user_id: owner_user_id.clone(),
asset_kind: "level_motion".to_string(),
level: Some(level.level),
motion_key: Some(motion_key.to_string()),
asset_url: Some(asset_url),
generated_at_micros: current_utc_micros(),
})
.await?;
}
}
let asset_url = generate_big_fish_formal_asset(
state,
&owner_user_id,
&session_id,
"stage_background",
None,
None,
current_utc_micros(),
)
.await
.map_err(|error| SpacetimeClientError::Runtime(error.message().to_string()))?;
// 中文注释:大鱼吃小鱼草稿阶段只负责编译结果页草稿,不在这一步串行生成主图、动作或背景。
// 这些资产统一留在结果页工坊按需触发,避免 compile action 因长耗时资产任务卡在首步等待态。
let session =
load_big_fish_session_with_retry(state, session_id.clone(), owner_user_id.clone()).await?;
let anchor_pack = map_record_anchor_pack_to_domain(&session.anchor_pack);
let compiled_draft =
compile_big_fish_draft_with_fallback(state.llm_client(), &anchor_pack).await;
let draft_json = serde_json::to_string(&compiled_draft).ok();
state
.spacetime_client()
.generate_big_fish_asset(BigFishAssetGenerateRecordInput {
.compile_big_fish_draft(BigFishDraftCompileRecordInput {
session_id,
owner_user_id,
asset_kind: "stage_background".to_string(),
level: None,
motion_key: None,
asset_url: Some(asset_url),
generated_at_micros: current_utc_micros(),
draft_json,
compiled_at_micros: now,
})
.await
}
async fn load_big_fish_session_with_retry(
state: &AppState,
session_id: String,
owner_user_id: String,
) -> Result<BigFishSessionRecord, SpacetimeClientError> {
let mut last_retryable_error = None;
for attempt in 0..2 {
match state
.spacetime_client()
.get_big_fish_session(session_id.clone(), owner_user_id.clone())
.await
{
Ok(session) => return Ok(session),
Err(error @ SpacetimeClientError::Timeout)
| Err(error @ SpacetimeClientError::ConnectDropped) => {
last_retryable_error = Some(error);
if attempt == 0 {
sleep(Duration::from_millis(250)).await;
continue;
}
}
Err(error) => return Err(error),
}
}
Err(last_retryable_error.unwrap_or(SpacetimeClientError::Timeout))
}
fn map_record_anchor_pack_to_domain(
anchor_pack: &BigFishAnchorPackRecord,
) -> module_big_fish::BigFishAnchorPack {
module_big_fish::BigFishAnchorPack {
gameplay_promise: map_record_anchor_item_to_domain(&anchor_pack.gameplay_promise),
ecology_visual_theme: map_record_anchor_item_to_domain(&anchor_pack.ecology_visual_theme),
growth_ladder: map_record_anchor_item_to_domain(&anchor_pack.growth_ladder),
risk_tempo: map_record_anchor_item_to_domain(&anchor_pack.risk_tempo),
}
}
fn map_record_anchor_item_to_domain(
anchor_item: &BigFishAnchorItemRecord,
) -> module_big_fish::BigFishAnchorItem {
module_big_fish::BigFishAnchorItem {
key: anchor_item.key.clone(),
label: anchor_item.label.clone(),
value: anchor_item.value.clone(),
status: match anchor_item.status.as_str() {
"confirmed" => module_big_fish::BigFishAnchorStatus::Confirmed,
"locked" => module_big_fish::BigFishAnchorStatus::Locked,
"inferred" => module_big_fish::BigFishAnchorStatus::Inferred,
_ => module_big_fish::BigFishAnchorStatus::Missing,
},
}
}
fn map_big_fish_agent_message_response(
message: BigFishAgentMessageRecord,
) -> BigFishAgentMessageResponse {
@@ -960,12 +966,11 @@ struct BigFishFormalAssetContext {
asset_object_kind: String,
binding_slot: String,
path_segments: Vec<String>,
apply_transparent_background_post_process: bool,
}
const BIG_FISH_TEXT_TO_IMAGE_MODEL: &str = "wan2.2-t2i-flash";
const BIG_FISH_ENTITY_KIND: &str = "big_fish_session";
const BIG_FISH_DEFAULT_NEGATIVE_PROMPT: &str = "文字水印logoUI界面对话框边框多余肢体畸形鱼体低清晰度模糊压缩噪点现代摄影棚写实照片背景复杂背景";
const BIG_FISH_TRANSPARENT_ASSET_NEGATIVE_PROMPT: &str = "文字水印logoUI界面对话框边框多余肢体畸形鱼体低清晰度模糊压缩噪点现代摄影棚写实照片背景场景背景水草背景气泡背景多只主体阴影地面";
async fn generate_big_fish_formal_asset(
state: &AppState,
@@ -1009,6 +1014,7 @@ async fn generate_big_fish_formal_asset(
&http_client,
generated.image_url.as_str(),
"下载 Big Fish 正式图片失败",
context.apply_transparent_background_post_process,
)
.await?;
@@ -1049,6 +1055,7 @@ fn build_big_fish_formal_asset_context(
level_part,
asset_id,
],
apply_transparent_background_post_process: true,
})
}
"level_motion" => {
@@ -1077,6 +1084,7 @@ fn build_big_fish_formal_asset_context(
sanitize_big_fish_path_segment(motion_key, "motion"),
asset_id,
],
apply_transparent_background_post_process: true,
})
}
"stage_background" => Ok(BigFishFormalAssetContext {
@@ -1091,6 +1099,7 @@ fn build_big_fish_formal_asset_context(
"stage-background".to_string(),
asset_id,
],
apply_transparent_background_post_process: false,
}),
_ => Err(
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
@@ -1123,79 +1132,6 @@ fn find_big_fish_level_blueprint(
})
}
fn build_big_fish_level_main_image_prompt(
draft: &BigFishGameDraftRecord,
level: &BigFishLevelBlueprintRecord,
) -> String {
vec![
format!(
"为竖屏移动游戏《{}》生成一张等级生物主图。",
draft.title
),
format!(
"生态主题:{}。核心乐趣:{}",
draft.ecology_theme, draft.core_fun
),
format!(
"等级Lv.{},名称:{},幻想描述:{}",
level.level, level.name, level.one_line_fantasy
),
format!("轮廓方向:{}", level.silhouette_direction),
format!("视觉提示词种子:{}", level.visual_prompt_seed),
"画面要求:按 RPG 角色资产口径生成单体鱼形游戏生物完整入镜轮廓清晰中心构图2D 高完成度游戏插画,深海发光质感。".to_string(),
"背景要求:透明背景 PNG 风格不出现任何场景、水草、气泡、阴影地面、UI、文字、logo、水印、对话框或边框不要出现多只主体。".to_string(),
]
.join("")
}
fn build_big_fish_level_motion_prompt(
draft: &BigFishGameDraftRecord,
level: &BigFishLevelBlueprintRecord,
motion_key: &str,
) -> String {
let motion_text = match motion_key {
"move_swim" => "向右游动的关键帧预览,身体与尾鳍有清晰推进姿态,带轻微水流拖尾。",
_ => "待机漂浮的关键帧预览,身体轻微摆动,姿态稳定,适合作为 idle 状态。",
};
vec![
format!(
"为竖屏移动游戏《{}》生成一张等级生物动作关键帧静态预览图。",
draft.title
),
format!("生态主题:{}", draft.ecology_theme),
format!(
"等级Lv.{},名称:{},幻想描述:{}",
level.level, level.name, level.one_line_fantasy
),
format!("动作提示词种子:{}", level.motion_prompt_seed),
format!("动作要求:{motion_text}"),
"画面要求:按 RPG 角色动画资产口径生成单体鱼形生物完整入镜轮廓清晰动作方向明确2D 高完成度游戏插画,适合作为 Big Fish 动作槽位的静态 keyframe。".to_string(),
"背景要求:透明背景 PNG 风格不出现任何场景、水草、气泡、阴影地面、UI、文字、logo、水印、对话框或边框不要生成序列帧拼图不要出现多只主体。".to_string(),
]
.join("")
}
fn build_big_fish_stage_background_prompt(draft: &BigFishGameDraftRecord) -> String {
let background = &draft.background;
vec![
format!(
"为竖屏移动游戏《{}》生成一张 9:16 全屏活动区域背景。",
draft.title
),
format!("生态主题:{}", draft.ecology_theme),
format!("背景主题:{}。色彩氛围:{}", background.theme, background.color_mood),
format!("前景提示:{}", background.foreground_hints),
format!("中景构图:{}", background.midground_composition),
format!("背景纵深:{}", background.background_depth),
format!("安全操作区:{}", background.safe_play_area_hint),
format!("出生边缘:{}", background.spawn_edge_hint),
format!("背景提示词种子:{}", background.background_prompt_seed),
"画面要求:竖屏 9:16大场地全屏运行态背景中央 80% 保持开阔清爽,边缘只保留少量出生区环境提示。".to_string(),
"元素要求整体元素少不出现大型主体、密集装饰、鱼群主角、UI、文字、logo、水印、对话框或边框不要把中央操作区画得过暗或过复杂。".to_string(),
]
.join("")
}
fn require_big_fish_dashscope_settings(
state: &AppState,
) -> Result<BigFishDashScopeSettings, AppError> {
@@ -1372,6 +1308,7 @@ async fn download_big_fish_remote_image(
http_client: &reqwest::Client,
image_url: &str,
fallback_message: &str,
apply_transparent_background_post_process: bool,
) -> Result<BigFishDownloadedImage, AppError> {
let response = http_client.get(image_url).send().await.map_err(|error| {
map_big_fish_dashscope_request_error(format!("{fallback_message}{error}"))
@@ -1397,10 +1334,25 @@ async fn download_big_fish_remote_image(
}
let mime_type = normalize_big_fish_downloaded_image_mime_type(content_type.as_str());
let mut normalized_bytes = bytes.to_vec();
let mut normalized_mime_type = mime_type;
let mut extension = big_fish_mime_to_extension(normalized_mime_type.as_str()).to_string();
// 中文注释Big Fish 的等级主图与动作关键帧要和 RPG 角色主图保持同一后处理口径。
// 因此在上游已经输出 PNG 时,统一补一层透明背景 alpha 清理,避免只靠 prompt 约束导致残留底色。
if apply_transparent_background_post_process
&& normalized_mime_type == "image/png"
&& let Some(optimized) = try_apply_background_alpha_to_png(normalized_bytes.as_slice())
{
normalized_bytes = optimized;
normalized_mime_type = "image/png".to_string();
extension = "png".to_string();
}
Ok(BigFishDownloadedImage {
extension: big_fish_mime_to_extension(mime_type.as_str()).to_string(),
mime_type,
bytes: bytes.to_vec(),
extension,
mime_type: normalized_mime_type,
bytes: normalized_bytes,
})
}
@@ -1735,15 +1687,37 @@ fn map_big_fish_client_error(error: SpacetimeClientError) -> AppError {
StatusCode::BAD_REQUEST
}
SpacetimeClientError::Runtime(_) => StatusCode::BAD_REQUEST,
SpacetimeClientError::Timeout => StatusCode::GATEWAY_TIMEOUT,
_ => StatusCode::BAD_GATEWAY,
};
let message = match &error {
SpacetimeClientError::Timeout => "SpacetimeDB 会话读取超时,请稍后重试。".to_string(),
SpacetimeClientError::ConnectDropped => {
"SpacetimeDB 会话连接已断开,请稍后重试。".to_string()
}
_ => error.to_string(),
};
AppError::from_status(status).with_details(json!({
"provider": "spacetimedb",
"message": error.to_string(),
"message": message,
}))
}
fn should_soft_fallback_big_fish_gallery(error: &SpacetimeClientError) -> bool {
match error {
// 公开广场是首页可选数据SpacetimeDB procedure 运行态短暂失败时不应阻断平台首屏。
SpacetimeClientError::Runtime(_) | SpacetimeClientError::ConnectDropped => true,
SpacetimeClientError::Procedure(message) => {
message.contains("list_big_fish_works")
|| message.contains("procedure")
|| message.contains("No such procedure")
}
_ => false,
}
}
fn big_fish_error_response(request_context: &RequestContext, error: AppError) -> Response {
error.into_response_with_context(Some(request_context))
}
@@ -1758,3 +1732,28 @@ fn current_utc_micros() -> i64 {
fn current_timestamp_micros_to_string(value: i64) -> String {
format_timestamp_micros(value)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn big_fish_gallery_soft_fallbacks_for_runtime_errors() {
assert!(should_soft_fallback_big_fish_gallery(
&SpacetimeClientError::Runtime("procedure runtime error".to_string())
));
assert!(should_soft_fallback_big_fish_gallery(
&SpacetimeClientError::ConnectDropped
));
assert!(should_soft_fallback_big_fish_gallery(
&SpacetimeClientError::Procedure("No such procedure: list_big_fish_works".to_string(),)
));
}
#[test]
fn big_fish_gallery_keeps_timeout_errors_visible() {
assert!(!should_soft_fallback_big_fish_gallery(
&SpacetimeClientError::Timeout
));
}
}

View File

@@ -1,18 +1,15 @@
use module_big_fish::{BigFishAnchorPack, BigFishAnchorStatus, BigFishCreationStage};
use platform_llm::LlmClient;
use serde::{Deserialize, Serialize};
use serde_json::{Value as JsonValue, json};
use spacetime_client::{
BigFishAgentMessageRecord, BigFishMessageFinalizeRecordInput, BigFishSessionRecord,
};
use serde_json::Value as JsonValue;
use spacetime_client::{BigFishMessageFinalizeRecordInput, BigFishSessionRecord};
use crate::creation_agent_anchor_templates::{
get_creation_agent_anchor_template, render_anchor_question_block,
};
use crate::creation_agent_chat::render_quick_fill_extra_rules;
use crate::creation_agent_llm_turn::{
CreationAgentLlmTurnErrorMessages, stream_creation_agent_json_turn,
};
use crate::prompt::big_fish::{
BIG_FISH_AGENT_SYSTEM_PROMPT, build_big_fish_agent_prompt, serialize_record_anchor_pack,
};
#[derive(Clone, Debug)]
pub(crate) struct BigFishAgentTurnRequest<'a> {
@@ -60,57 +57,6 @@ struct BigFishAgentModelOutput {
next_anchor_pack: BigFishAnchorPack,
}
const BIG_FISH_AGENT_SYSTEM_PROMPT: &str = r#"你是一个负责和创作者共创“大鱼吃小鱼”竖屏玩法的中文创意策划。
你必须把用户灵感收束成可以编译为可玩草稿的玩法、生态视觉、成长阶梯和风险节奏。
你必须同时输出:
1. 一段直接发给用户的中文回复 replyText
2. 当前进度 progressPercent
3. 下一轮完整可用的 nextAnchorPack
硬约束:
1. 只能输出 JSON不能输出代码块或解释
2. nextAnchorPack 必须是完整对象,不能只输出 patch
3. replyText 必须是自然中文不能提“字段”“锚点”“结构”“JSON”等内部词
4. replyText 一次最多推进一个最关键问题
5. 必须对齐 RPG 共创的体验:先理解玩家幻想,再收束成能进入运行时的可玩效果
6. progressPercent 范围只能是 0 到 100
7. status 只能使用 missing / inferred / confirmed / locked
"#;
const BIG_FISH_AGENT_OUTPUT_CONTRACT: &str = r#"请严格按以下 JSON 输出,不要输出其他文字:
{
"replyText": "",
"progressPercent": 0,
"nextAnchorPack": {
"gameplayPromise": {
"key": "gameplayPromise",
"label": "玩法承诺",
"value": "",
"status": "missing"
},
"ecologyVisualTheme": {
"key": "ecologyVisualTheme",
"label": "生态视觉主题",
"value": "",
"status": "missing"
},
"growthLadder": {
"key": "growthLadder",
"label": "成长阶梯",
"value": "",
"status": "missing"
},
"riskTempo": {
"key": "riskTempo",
"label": "风险节奏",
"value": "",
"status": "missing"
}
}
}"#;
pub(crate) async fn run_big_fish_agent_turn<F>(
request: BigFishAgentTurnRequest<'_>,
on_reply_update: F,
@@ -189,54 +135,6 @@ pub(crate) fn build_failed_finalize_record_input(
}
}
fn build_big_fish_agent_prompt(
session: &BigFishSessionRecord,
quick_fill_requested: bool,
) -> String {
let anchor_question_block = get_creation_agent_anchor_template("big_fish")
.map(render_anchor_question_block)
.unwrap_or_else(|| "模板目标:收束成可玩的竖屏大鱼吃小鱼玩法草稿。".to_string());
let quick_fill_rules = if quick_fill_requested {
format!(
"\n\n{}",
render_quick_fill_extra_rules(
"当前玩法方向里的成长、生态、风险节奏等缺失关键词",
"不要要求用户再提供等级、鱼群、场景或节奏信息",
"输出完整 nextAnchorPack直接补齐 value 为空或 status 为 missing 的项",
"生成结果页",
)
)
} else {
String::new()
};
format!(
"{anchor_question_block}{quick_fill_rules}\n\n当前是第 {turn} 轮,当前进度 {progress}% 。\n\n是否要求自动补充剩余关键字:{quick_fill_requested_text}\n\n当前 anchor pack\n{anchor_pack}\n\n最近聊天记录:\n{chat_history}\n\n{contract}",
anchor_question_block = anchor_question_block,
quick_fill_rules = quick_fill_rules,
turn = session.current_turn.saturating_add(1),
progress = session.progress_percent,
quick_fill_requested_text = if quick_fill_requested { "" } else { "" },
anchor_pack = serialize_record_anchor_pack(&session.anchor_pack),
chat_history =
serde_json::to_string_pretty(&build_chat_history(session.messages.as_slice()))
.unwrap_or_else(|_| "[]".to_string()),
contract = BIG_FISH_AGENT_OUTPUT_CONTRACT,
)
}
fn build_chat_history(messages: &[BigFishAgentMessageRecord]) -> Vec<JsonValue> {
messages
.iter()
.map(|message| {
json!({
"role": message.role,
"kind": message.kind,
"content": message.text,
})
})
.collect()
}
fn parse_big_fish_model_output(
parsed: &JsonValue,
) -> Result<BigFishAgentModelOutput, BigFishAgentTurnError> {
@@ -327,33 +225,6 @@ fn default_big_fish_anchor_label(field_name: &str) -> &'static str {
}
}
fn serialize_record_anchor_pack(anchor_pack: &spacetime_client::BigFishAnchorPackRecord) -> String {
serde_json::to_string_pretty(&map_big_fish_record_anchor_pack(anchor_pack))
.unwrap_or_else(|_| "{}".to_string())
}
fn map_big_fish_record_anchor_pack(
record: &spacetime_client::BigFishAnchorPackRecord,
) -> BigFishAnchorPack {
BigFishAnchorPack {
gameplay_promise: map_big_fish_record_anchor_item(&record.gameplay_promise),
ecology_visual_theme: map_big_fish_record_anchor_item(&record.ecology_visual_theme),
growth_ladder: map_big_fish_record_anchor_item(&record.growth_ladder),
risk_tempo: map_big_fish_record_anchor_item(&record.risk_tempo),
}
}
fn map_big_fish_record_anchor_item(
record: &spacetime_client::BigFishAnchorItemRecord,
) -> module_big_fish::BigFishAnchorItem {
module_big_fish::BigFishAnchorItem {
key: record.key.clone(),
label: record.label.clone(),
value: record.value.clone(),
status: parse_big_fish_anchor_status(record.status.as_str()),
}
}
fn parse_big_fish_anchor_status(value: &str) -> BigFishAnchorStatus {
match value {
"confirmed" => BigFishAnchorStatus::Confirmed,

View File

@@ -0,0 +1,296 @@
use module_big_fish::{
BIG_FISH_MAX_LEVEL_COUNT, BIG_FISH_MIN_LEVEL_COUNT, BigFishAnchorPack, BigFishGameDraft,
BigFishLevelBlueprint, BigFishRuntimeParams, compile_default_draft,
};
use platform_llm::{LlmClient, LlmMessage, LlmTextRequest};
use serde::Deserialize;
use serde_json::Value as JsonValue;
use crate::creation_agent_llm_turn::parse_json_response_text;
const BIG_FISH_DRAFT_JSON_ONLY_SYSTEM_PROMPT: &str = r#"你是一个负责把“大鱼吃小鱼”玩法锚点编译成首版可实现草稿的中文玩法策划。
你必须直接输出单个 JSON 对象,不要输出 Markdown、代码块、解释、前言或补充说明。
硬约束:
1. 所有文案必须是中文。
2. 必须产出 6 到 12 级的连续等级阶梯,默认优先 8 级。
3. 每一级都必须有name、oneLineFantasy、textDescription、visualDescription、idleMotionDescription、moveMotionDescription。
4. 每一级都必须体现等级递进关系:越高等级越大、越强、越有压迫感。
5. `visualPromptSeed` 必须能直接作为主图默认提示词。
6. `motionPromptSeed` 必须是该等级动作方向总提示词摘要,但不能替代具体 idle / move 描述。
7. `preyWindow` 和 `threatWindow` 必须是合法等级数组,围绕当前等级形成可玩窗口。
8. `background` 必须是竖屏 9:16 游戏背景口径,不出现主体和 UI。
9. `runtimeParams.levelCount` 必须与 levels 长度一致,`winLevel` 必须等于最高等级。
"#;
const BIG_FISH_DRAFT_JSON_REPAIR_SYSTEM_PROMPT: &str = "你是 JSON 修复器。\n你会收到一段本应为单个 JSON 对象的文本。\n你的唯一任务是把它修复成能被 JSON.parse 直接解析的单个 JSON 对象。\n不要输出 Markdown、代码块、解释、注释或额外文字。";
#[derive(Debug)]
pub(crate) struct BigFishDraftCompileError(String);
impl BigFishDraftCompileError {
fn new(message: impl Into<String>) -> Self {
Self(message.into())
}
}
impl std::fmt::Display for BigFishDraftCompileError {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
formatter.write_str(&self.0)
}
}
impl std::error::Error for BigFishDraftCompileError {}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct BigFishDraftModelOutput {
title: String,
subtitle: String,
core_fun: String,
ecology_theme: String,
levels: Vec<BigFishLevelBlueprint>,
background: module_big_fish::BigFishBackgroundBlueprint,
runtime_params: BigFishRuntimeParams,
}
pub(crate) async fn compile_big_fish_draft_with_fallback(
llm_client: Option<&LlmClient>,
anchor_pack: &BigFishAnchorPack,
) -> BigFishGameDraft {
let fallback = compile_default_draft(anchor_pack);
let Some(llm_client) = llm_client else {
return fallback;
};
match request_big_fish_draft(llm_client, anchor_pack).await {
Ok(draft) => draft,
Err(error) => {
tracing::warn!(error = %error, "大鱼吃小鱼草稿结构化编译失败,回退到 deterministic fallback");
fallback
}
}
}
async fn request_big_fish_draft(
llm_client: &LlmClient,
anchor_pack: &BigFishAnchorPack,
) -> Result<BigFishGameDraft, BigFishDraftCompileError> {
let user_prompt = build_big_fish_draft_user_prompt(anchor_pack);
let parsed = request_big_fish_json_stage(
llm_client,
user_prompt,
"big-fish-draft-compile",
"大鱼吃小鱼草稿编译没有返回有效内容。",
)
.await?;
let output: BigFishDraftModelOutput = serde_json::from_value(parsed).map_err(|error| {
BigFishDraftCompileError::new(format!("大鱼吃小鱼草稿 JSON 结构非法:{error}"))
})?;
validate_big_fish_draft_output(&output)?;
Ok(BigFishGameDraft {
title: output.title,
subtitle: output.subtitle,
core_fun: output.core_fun,
ecology_theme: output.ecology_theme,
levels: output.levels,
background: output.background,
runtime_params: output.runtime_params,
})
}
async fn request_big_fish_json_stage(
llm_client: &LlmClient,
user_prompt: String,
debug_label: &str,
empty_response_message: &str,
) -> Result<JsonValue, BigFishDraftCompileError> {
let response = llm_client
.request_text(LlmTextRequest::new(vec![
LlmMessage::system(BIG_FISH_DRAFT_JSON_ONLY_SYSTEM_PROMPT),
LlmMessage::user(user_prompt),
]))
.await
.map_err(|error| {
BigFishDraftCompileError::new(format!("{debug_label} LLM 请求失败:{error}"))
})?;
let text = response.content.trim();
if text.is_empty() {
return Err(BigFishDraftCompileError::new(empty_response_message));
}
match parse_json_response_text(text) {
Ok(value) => Ok(value),
Err(_) => {
let repaired = llm_client
.request_text(LlmTextRequest::new(vec![
LlmMessage::system(BIG_FISH_DRAFT_JSON_REPAIR_SYSTEM_PROMPT),
LlmMessage::user(format!(
"请把下面这段文本修复成单个合法 JSON 对象,不要补充额外解释:\n\n{text}"
)),
]))
.await
.map_err(|error| {
BigFishDraftCompileError::new(format!(
"{debug_label} JSON 修复请求失败:{error}"
))
})?;
parse_json_response_text(repaired.content.as_str()).map_err(|error| {
BigFishDraftCompileError::new(format!("{debug_label} JSON 解析失败:{error}"))
})
}
}
}
fn build_big_fish_draft_user_prompt(anchor_pack: &BigFishAnchorPack) -> String {
format!(
r#"请基于下面的大鱼吃小鱼玩法锚点,直接生成首版结果页草稿。
玩法承诺:{gameplay_promise}
生态与视觉母题:{ecology_visual_theme}
成长阶梯:{growth_ladder}
风险节奏:{risk_tempo}
请严格输出下列 JSON 结构:
{{
"title": "",
"subtitle": "",
"coreFun": "",
"ecologyTheme": "",
"levels": [
{{
"level": 1,
"name": "",
"oneLineFantasy": "",
"textDescription": "",
"silhouetteDirection": "",
"sizeRatio": 1.0,
"visualDescription": "",
"visualPromptSeed": "",
"idleMotionDescription": "",
"moveMotionDescription": "",
"motionPromptSeed": "",
"mergeSourceLevel": null,
"preyWindow": [1],
"threatWindow": [2],
"isFinalLevel": false
}}
],
"background": {{
"theme": "",
"colorMood": "",
"foregroundHints": "",
"midgroundComposition": "",
"backgroundDepth": "",
"safePlayAreaHint": "",
"spawnEdgeHint": "",
"backgroundPromptSeed": ""
}},
"runtimeParams": {{
"levelCount": 8,
"mergeCountPerUpgrade": 3,
"spawnTargetCount": 12,
"leaderMoveSpeed": 160,
"followerCatchUpSpeed": 120,
"offscreenCullSeconds": 3,
"preySpawnDeltaLevels": [1, 2],
"threatSpawnDeltaLevels": [1, 2],
"winLevel": 8
}}
}}
补充要求:
1. `title`、`subtitle`、`coreFun` 必须适合结果页直接展示。
2. 每一级 `textDescription` 要解释这一等级在成长链中的定位。
3. `visualDescription` 要能直接填入主图工坊。
4. `idleMotionDescription` 和 `moveMotionDescription` 要分别对应待机动作与移动动作工坊。
5. `visualPromptSeed` 必须是主图生成用的中文提示词,不要只写关键词。
6. `motionPromptSeed` 必须是该等级动作生成的总提示词摘要,要同时覆盖待机和移动方向。
7. 如果锚点没有明确等级数量,默认输出 8 级。
"#,
gameplay_promise = anchor_pack.gameplay_promise.value,
ecology_visual_theme = anchor_pack.ecology_visual_theme.value,
growth_ladder = anchor_pack.growth_ladder.value,
risk_tempo = anchor_pack.risk_tempo.value,
)
}
fn validate_big_fish_draft_output(
output: &BigFishDraftModelOutput,
) -> Result<(), BigFishDraftCompileError> {
let level_count = output.levels.len() as u32;
if !(BIG_FISH_MIN_LEVEL_COUNT..=BIG_FISH_MAX_LEVEL_COUNT).contains(&level_count) {
return Err(BigFishDraftCompileError::new(format!(
"大鱼吃小鱼草稿等级数非法:{level_count}"
)));
}
if output.runtime_params.level_count != level_count {
return Err(BigFishDraftCompileError::new(
"runtimeParams.levelCount 必须与 levels 数量一致",
));
}
if output.runtime_params.win_level != level_count {
return Err(BigFishDraftCompileError::new(
"runtimeParams.winLevel 必须等于最高等级",
));
}
for (index, level) in output.levels.iter().enumerate() {
let expected_level = (index + 1) as u32;
if level.level != expected_level {
return Err(BigFishDraftCompileError::new(format!(
"等级序号不连续:期望 {expected_level},实际 {}",
level.level
)));
}
ensure_non_empty(level.name.as_str(), "level.name")?;
ensure_non_empty(level.one_line_fantasy.as_str(), "level.oneLineFantasy")?;
ensure_non_empty(level.text_description.as_str(), "level.textDescription")?;
ensure_non_empty(level.visual_description.as_str(), "level.visualDescription")?;
ensure_non_empty(
level.idle_motion_description.as_str(),
"level.idleMotionDescription",
)?;
ensure_non_empty(
level.move_motion_description.as_str(),
"level.moveMotionDescription",
)?;
ensure_non_empty(level.visual_prompt_seed.as_str(), "level.visualPromptSeed")?;
ensure_non_empty(level.motion_prompt_seed.as_str(), "level.motionPromptSeed")?;
}
ensure_non_empty(output.title.as_str(), "title")?;
ensure_non_empty(output.subtitle.as_str(), "subtitle")?;
ensure_non_empty(output.core_fun.as_str(), "coreFun")?;
ensure_non_empty(output.ecology_theme.as_str(), "ecologyTheme")?;
Ok(())
}
fn ensure_non_empty(value: &str, field_name: &str) -> Result<(), BigFishDraftCompileError> {
if value.trim().is_empty() {
return Err(BigFishDraftCompileError::new(format!(
"{field_name} 不能为空"
)));
}
Ok(())
}
#[cfg(test)]
mod tests {
use module_big_fish::infer_anchor_pack;
use super::build_big_fish_draft_user_prompt;
#[test]
fn big_fish_draft_prompt_requires_all_level_descriptions() {
let prompt = build_big_fish_draft_user_prompt(&infer_anchor_pack("深海机械鱼", None));
assert!(prompt.contains("textDescription"));
assert!(prompt.contains("visualDescription"));
assert!(prompt.contains("idleMotionDescription"));
assert!(prompt.contains("moveMotionDescription"));
assert!(prompt.contains("visualPromptSeed"));
assert!(prompt.contains("motionPromptSeed"));
}
}

View File

@@ -37,8 +37,9 @@ use shared_contracts::assets::{
CharacterAnimationImportVideoResponse, CharacterAnimationPublishRequest,
CharacterAnimationPublishResponse, CharacterAnimationStrategy,
CharacterAnimationTemplatePayload, CharacterAnimationTemplatesResponse,
CharacterAssetJobStatusPayload, CharacterAssetJobStatusText, CharacterVisualDraftPayload,
CharacterWorkflowCacheGetResponse, CharacterWorkflowCachePayload,
CharacterAssetJobStatusPayload, CharacterAssetJobStatusText,
CharacterRoleAssetWorkflowResolveRequest, CharacterRoleAssetWorkflowResponse,
CharacterVisualDraftPayload, CharacterWorkflowCacheGetResponse, CharacterWorkflowCachePayload,
CharacterWorkflowCacheSaveRequest, CharacterWorkflowCacheSaveResponse,
};
use spacetime_client::SpacetimeClientError;
@@ -49,6 +50,9 @@ use crate::{
build_character_animation_prompt, build_fallback_moderation_safe_animation_prompt,
},
http_error::AppError,
prompt::role_asset_studio::{
build_role_asset_workflow, normalize_animation_prompt_text_by_key,
},
request_context::RequestContext,
state::AppState,
};
@@ -646,6 +650,92 @@ pub async fn save_character_workflow_cache(
))
}
pub async fn resolve_role_asset_workflow(
State(state): State<AppState>,
Extension(request_context): Extension<RequestContext>,
AxumPath(character_id): AxumPath<String>,
payload: Result<Json<CharacterRoleAssetWorkflowResolveRequest>, JsonRejection>,
) -> Result<Json<Value>, Response> {
let character_id = normalize_required_text(character_id.as_str(), "");
if character_id.is_empty() {
return Err(character_animation_error_response(
&request_context,
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": "role-asset-workflow",
"message": "characterId is required.",
})),
));
}
let Json(payload) = payload.map_err(|error| {
character_animation_error_response(
&request_context,
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": "role-asset-workflow",
"message": error.body_text(),
})),
)
})?;
let cache_scope_id = trim_optional_text(payload.cache_scope_id.as_deref());
let cache = load_workflow_cache(&state, character_id.as_str(), cache_scope_id.as_deref())
.await
.map_err(|error| character_animation_error_response(&request_context, error))?;
let workflow = build_role_asset_workflow(payload.role, cache.as_ref());
Ok(json_success_body(
Some(&request_context),
CharacterRoleAssetWorkflowResponse {
ok: true,
cache,
workflow,
},
))
}
pub async fn put_role_asset_workflow(
State(state): State<AppState>,
Extension(request_context): Extension<RequestContext>,
AxumPath(character_id): AxumPath<String>,
payload: Result<Json<CharacterWorkflowCacheSaveRequest>, JsonRejection>,
) -> Result<Json<Value>, Response> {
let character_id = normalize_required_text(character_id.as_str(), "");
if character_id.is_empty() {
return Err(character_animation_error_response(
&request_context,
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": "role-asset-workflow",
"message": "characterId is required.",
})),
));
}
let Json(mut payload) = payload.map_err(|error| {
character_animation_error_response(
&request_context,
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": "role-asset-workflow",
"message": error.body_text(),
})),
)
})?;
payload.character_id = character_id;
let cache = normalize_workflow_cache_payload(payload, current_utc_iso_text());
save_workflow_cache(&state, cache.clone())
.await
.map_err(|error| character_animation_error_response(&request_context, error))?;
Ok(json_success_body(
Some(&request_context),
CharacterWorkflowCacheSaveResponse {
ok: true,
cache,
save_message: "角色资产工坊缓存已更新到 OSS。".to_string(),
},
))
}
fn create_animation_task(
state: &AppState,
task_id: &str,
@@ -1634,6 +1724,9 @@ fn normalize_workflow_cache_payload(
cache_scope_id,
visual_prompt_text: clamp_prompt_seed_text(payload.visual_prompt_text.as_deref()),
animation_prompt_text: clamp_prompt_seed_text(payload.animation_prompt_text.as_deref()),
animation_prompt_text_by_key: normalize_animation_prompt_text_by_key(
payload.animation_prompt_text_by_key,
),
visual_drafts: normalize_visual_drafts(character_id.as_str(), payload.visual_drafts),
selected_visual_draft_id: trim_optional_text(payload.selected_visual_draft_id.as_deref())
.unwrap_or_default(),
@@ -3354,6 +3447,10 @@ mod tests {
cache_scope_id: None,
visual_prompt_text: Some("主形象".to_string()),
animation_prompt_text: Some("待机".to_string()),
animation_prompt_text_by_key: BTreeMap::from([(
"run".to_string(),
"奔跑".to_string(),
)]),
visual_drafts: vec![CharacterVisualDraftPayload {
id: "".to_string(),
label: "".to_string(),
@@ -3373,6 +3470,7 @@ mod tests {
assert_eq!(cache.character_id, "hero");
assert_eq!(cache.selected_animation, "idle");
assert_eq!(cache.animation_prompt_text_by_key["run"], "奔跑");
assert_eq!(cache.visual_drafts[0].id, "hero-draft-1");
assert_eq!(cache.visual_drafts[0].width, 1024);
assert_eq!(cache.image_src, None);

View File

@@ -1154,7 +1154,9 @@ async fn download_generated_image(
})
}
fn try_apply_background_alpha_to_png(source: &[u8]) -> Option<Vec<u8>> {
/// 统一的 PNG 透明背景后处理入口。
/// 目前 RPG 角色主图与其他需要“角色主图口径透明背景”的图片资产都复用这套逻辑。
pub(crate) fn try_apply_background_alpha_to_png(source: &[u8]) -> Option<Vec<u8>> {
let mut image = image::load_from_memory_with_format(source, ImageFormat::Png)
.ok()?
.to_rgba8();

View File

@@ -10,7 +10,8 @@ use axum::{
},
};
use module_custom_world::{
CustomWorldThemeMode, empty_agent_anchor_content_json, empty_agent_asset_coverage_json,
CustomWorldThemeMode, canonicalize_custom_world_profile_before_save,
empty_agent_anchor_content_json, empty_agent_asset_coverage_json,
empty_agent_creator_intent_readiness_json, empty_json_array, empty_json_object,
};
use serde_json::{Map, Value, json};
@@ -18,14 +19,15 @@ use shared_contracts::runtime::{
CreateCustomWorldAgentSessionRequest, CustomWorldAgentCardDetailResponse,
CustomWorldAgentCheckpointResponse, CustomWorldAgentMessageResponse,
CustomWorldAgentOperationResponse, CustomWorldAgentSessionResponse,
CustomWorldAgentSessionSnapshotResponse, CustomWorldDraftCardDetailResponse,
CustomWorldDraftCardDetailSectionResponse, CustomWorldDraftCardSummaryResponse,
CustomWorldGalleryCardResponse, CustomWorldGalleryDetailResponse, CustomWorldGalleryResponse,
CustomWorldLibraryEntryResponse, CustomWorldLibraryMutationResponse,
CustomWorldLibraryResponse, CustomWorldProfileUpsertRequest, CustomWorldPublishGateResponse,
CustomWorldAgentSessionSnapshotResponse, CustomWorldCreationResultViewResponse,
CustomWorldDraftCardDetailResponse, CustomWorldDraftCardDetailSectionResponse,
CustomWorldDraftCardSummaryResponse, CustomWorldGalleryCardResponse,
CustomWorldGalleryDetailResponse, CustomWorldGalleryResponse, CustomWorldLibraryEntryResponse,
CustomWorldLibraryMutationResponse, CustomWorldLibraryResponse,
CustomWorldProfileUpsertRequest, CustomWorldPublishGateResponse,
CustomWorldResultPreviewBlockerResponse, CustomWorldSupportedActionResponse,
CustomWorldWorkSummaryResponse, CustomWorldWorksResponse, ExecuteCustomWorldAgentActionRequest,
SendCustomWorldAgentMessageRequest,
GenerateCustomWorldProfileRequest, SendCustomWorldAgentMessageRequest,
};
use shared_kernel::build_prefixed_uuid_id;
use spacetime_client::{
@@ -62,7 +64,7 @@ use crate::{
custom_world_ai::generate_custom_world_scene_image_for_profile,
custom_world_foundation_draft::{
DraftFoundationPayloadError, build_draft_foundation_action_payload_json,
generate_custom_world_foundation_draft,
generate_custom_world_foundation_draft, stable_ascii_slug,
},
http_error::AppError,
prompt::scene_background::{
@@ -135,6 +137,251 @@ fn reusable_draft_profile_for_asset_generation(
}
}
pub async fn generate_custom_world_profile(
State(state): State<AppState>,
Extension(request_context): Extension<RequestContext>,
Extension(authenticated): Extension<AuthenticatedAccessToken>,
payload: Result<Json<GenerateCustomWorldProfileRequest>, JsonRejection>,
) -> Result<Json<Value>, Response> {
let Json(payload) = payload.map_err(|error| {
custom_world_error_response(
&request_context,
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": "custom-world-profile",
"message": error.body_text(),
})),
)
})?;
let setting_text = payload.setting_text.trim().to_string();
if setting_text.is_empty() {
return Err(custom_world_error_response(
&request_context,
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": "custom-world-profile",
"message": "settingText is required",
})),
));
}
let llm_client = state.llm_client().ok_or_else(|| {
custom_world_error_response(
&request_context,
AppError::from_status(StatusCode::SERVICE_UNAVAILABLE).with_details(json!({
"provider": "custom-world-profile",
"message": "服务端尚未配置可用的 LLM API Key",
})),
)
})?;
let generation_mode = normalize_profile_generation_mode(payload.generation_mode.as_deref());
let creator_intent = payload.creator_intent.unwrap_or(Value::Null);
let session = build_profile_generation_session(
setting_text.clone(),
creator_intent.clone(),
authenticated.claims().user_id().to_string(),
);
// 中文注释profile 生成需要外部 LLM必须留在 Axum/api-serverSpacetimeDB reducer 只接收确定结果。
let result = generate_custom_world_foundation_draft(llm_client, &session, |_| {})
.await
.map_err(|message| {
custom_world_error_response(
&request_context,
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": "custom-world-profile",
"message": message,
})),
)
})?;
let mut profile =
serde_json::from_str::<Value>(&result.draft_profile_json).map_err(|error| {
custom_world_error_response(
&request_context,
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": "custom-world-profile",
"message": format!("profile JSON 解析失败:{error}"),
})),
)
})?;
finalize_generated_custom_world_profile(
&mut profile,
setting_text.as_str(),
generation_mode,
creator_intent,
);
Ok(json_success_body(Some(&request_context), profile))
}
fn normalize_profile_generation_mode(value: Option<&str>) -> &'static str {
if value.is_some_and(|item| item.trim().eq_ignore_ascii_case("fast")) {
"fast"
} else {
"full"
}
}
fn build_profile_generation_session(
setting_text: String,
creator_intent: Value,
owner_user_id: String,
) -> CustomWorldAgentSessionRecord {
CustomWorldAgentSessionRecord {
session_id: build_prefixed_uuid_id("profile-generation-session-"),
seed_text: setting_text,
current_turn: 1,
anchor_content: build_profile_generation_anchor_content(&creator_intent),
progress_percent: 100,
last_assistant_reply: None,
stage: "foundation_review".to_string(),
focus_card_id: None,
creator_intent,
creator_intent_readiness: json!({ "isReady": true }),
anchor_pack: json!({}),
lock_state: json!({}),
draft_profile: Value::Null,
messages: Vec::new(),
draft_cards: Vec::new(),
pending_clarifications: Vec::new(),
suggested_actions: Vec::new(),
recommended_replies: Vec::new(),
quality_findings: Vec::new(),
asset_coverage: json!({}),
checkpoints: Vec::new(),
supported_actions: Vec::new(),
publish_gate: None,
result_preview: None,
updated_at: format!("profile-generation:{owner_user_id}"),
}
}
fn build_profile_generation_anchor_content(creator_intent: &Value) -> Value {
let world_hook = read_value_path_text(creator_intent, &["worldHook"])
.or_else(|| read_value_path_text(creator_intent, &["rawSettingText"]));
let player_premise = read_value_path_text(creator_intent, &["playerPremise"]);
let opening_situation = read_value_path_text(creator_intent, &["openingSituation"]);
let core_conflicts = read_value_string_array(creator_intent, "coreConflicts");
let iconic_elements = read_value_string_array(creator_intent, "iconicElements");
json!({
"worldPromise": {
"hook": world_hook.unwrap_or_default(),
"differentiator": iconic_elements.first().cloned().unwrap_or_default(),
"desiredExperience": read_value_string_array(creator_intent, "toneDirectives").join(""),
},
"playerEntryPoint": {
"openingIdentity": player_premise.unwrap_or_default(),
"openingProblem": opening_situation.unwrap_or_default(),
"entryMotivation": core_conflicts.first().cloned().unwrap_or_default(),
},
"coreConflict": {
"conflicts": core_conflicts,
},
"iconicElements": iconic_elements,
})
}
fn finalize_generated_custom_world_profile(
profile: &mut Value,
setting_text: &str,
generation_mode: &str,
creator_intent: Value,
) {
if !profile.is_object() {
*profile = json!({});
}
let Some(object) = profile.as_object_mut() else {
return;
};
insert_profile_text_if_missing(
object,
"id",
format!("custom-world-{}", stable_ascii_slug(setting_text)).as_str(),
);
insert_profile_text_if_missing(object, "settingText", setting_text);
insert_profile_text_if_missing(object, "templateWorldType", "WUXIA");
if !object
.get("compatibilityTemplateWorldType")
.and_then(Value::as_str)
.map(str::trim)
.is_some_and(|value| !value.is_empty())
{
let template_world_type = object
.get("templateWorldType")
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.unwrap_or("WUXIA")
.to_string();
object.insert(
"compatibilityTemplateWorldType".to_string(),
Value::String(template_world_type),
);
}
if !object.get("items").is_some_and(Value::is_array) {
object.insert("items".to_string(), Value::Array(Vec::new()));
}
object.insert(
"generationMode".to_string(),
Value::String(generation_mode.to_string()),
);
object.insert(
"generationStatus".to_string(),
Value::String(
if generation_mode == "fast" {
"key_only"
} else {
"complete"
}
.to_string(),
),
);
if !matches!(creator_intent, Value::Null) {
object.insert("creatorIntent".to_string(), creator_intent);
}
}
fn insert_profile_text_if_missing(object: &mut Map<String, Value>, key: &str, fallback: &str) {
if object
.get(key)
.and_then(Value::as_str)
.map(str::trim)
.is_some_and(|value| !value.is_empty())
{
return;
}
object.insert(key.to_string(), Value::String(fallback.to_string()));
}
fn read_value_path_text(value: &Value, path: &[&str]) -> Option<String> {
let mut current = value;
for segment in path {
current = current.get(*segment)?;
}
current
.as_str()
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned)
}
fn read_value_string_array(value: &Value, key: &str) -> Vec<String> {
value
.get(key)
.and_then(Value::as_array)
.map(|items| {
items
.iter()
.filter_map(|item| item.as_str().map(str::trim))
.filter(|item| !item.is_empty())
.map(ToOwned::to_owned)
.collect::<Vec<_>>()
})
.unwrap_or_default()
}
fn missing_role_asset_text_report(draft_profile: &Value) -> Option<String> {
let profile_object = draft_profile.as_object()?;
let mut missing_items = Vec::new();
@@ -245,15 +492,16 @@ pub async fn put_custom_world_library_profile(
));
}
let metadata = extract_custom_world_metadata(&payload.profile).map_err(|error| {
custom_world_error_response(
&request_context,
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": "custom-world-library",
"message": error,
})),
)
})?;
let (profile, metadata) = canonicalize_custom_world_library_profile_payload(payload.profile)
.map_err(|error| {
custom_world_error_response(
&request_context,
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": "custom-world-library",
"message": error,
})),
)
})?;
let author_display_name = resolve_author_display_name(&state, &authenticated);
let author_public_user_code =
resolve_author_public_user_code(&state, &authenticated, &request_context)?;
@@ -270,7 +518,7 @@ pub async fn put_custom_world_library_profile(
summary_text: metadata.summary_text,
theme_mode: metadata.theme_mode,
cover_image_src: metadata.cover_image_src,
profile_payload_json: serde_json::to_string(&payload.profile).map_err(|error| {
profile_payload_json: serde_json::to_string(&profile).map_err(|error| {
custom_world_error_response(
&request_context,
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
@@ -600,6 +848,27 @@ pub async fn get_custom_world_agent_session(
))
}
pub async fn get_custom_world_agent_result_view(
State(state): State<AppState>,
Path(session_id): Path<String>,
Extension(request_context): Extension<RequestContext>,
Extension(authenticated): Extension<AuthenticatedAccessToken>,
) -> Result<Json<Value>, Response> {
ensure_non_empty(&request_context, &session_id, "sessionId")?;
let session = state
.spacetime_client()
.get_custom_world_agent_session(session_id, authenticated.claims().user_id().to_string())
.await
.map_err(|error| {
custom_world_error_response(&request_context, map_custom_world_client_error(error))
})?;
log_custom_world_publish_gate_diagnostics("get_result_view", &session);
let result_view = build_custom_world_creation_result_view_response(session);
Ok(json_success_body(Some(&request_context), result_view))
}
pub async fn get_custom_world_works(
State(state): State<AppState>,
Extension(request_context): Extension<RequestContext>,
@@ -1199,6 +1468,16 @@ pub async fn execute_custom_world_agent_action(
})?;
generation_result.payload_json
}
} else if action == "sync_result_profile" {
serialize_sync_result_profile_action_payload(&payload).map_err(|error| {
custom_world_error_response(
&request_context,
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": "custom-world-agent",
"message": error,
})),
)
})?
} else if action == "publish_world" {
let mut publish_payload = serde_json::to_value(&payload).map_err(|error| {
custom_world_error_response(
@@ -1308,6 +1587,27 @@ pub async fn execute_custom_world_agent_action(
))
}
fn serialize_sync_result_profile_action_payload(
payload: &ExecuteCustomWorldAgentActionRequest,
) -> Result<String, String> {
let mut payload_value = serde_json::to_value(payload)
.map_err(|error| format!("action payload JSON 序列化失败:{error}"))?;
if let Some(profile) = payload_value.get_mut("profile") {
canonicalize_custom_world_profile_before_save(profile);
}
serde_json::to_string(&payload_value)
.map_err(|error| format!("action payload JSON 序列化失败:{error}"))
}
fn canonicalize_custom_world_library_profile_payload(
mut profile: Value,
) -> Result<(Value, CustomWorldProfileMetadata), String> {
canonicalize_custom_world_profile_before_save(&mut profile);
let metadata = extract_custom_world_metadata(&profile)?;
Ok((profile, metadata))
}
fn spawn_custom_world_draft_foundation_job(
state: AppState,
session: CustomWorldAgentSessionRecord,
@@ -2456,6 +2756,155 @@ fn map_custom_world_agent_session_response(
}
}
fn build_custom_world_creation_result_view_response(
session: CustomWorldAgentSessionRecord,
) -> CustomWorldCreationResultViewResponse {
let profile_from_preview = session
.result_preview
.as_ref()
.and_then(|preview| preview.get("preview"))
.and_then(normalize_json_object_value);
let profile_from_draft =
if profile_from_preview.is_none() && is_agent_result_stage(session.stage.as_str()) {
normalize_json_object_value(&session.draft_profile)
// 中文注释legacyResultProfile 只在服务端作为历史会话恢复兜底,
// 前端不再直接解释 legacy 字段的真相优先级。
.or_else(|| {
session
.draft_profile
.get("legacyResultProfile")
.and_then(normalize_json_object_value)
})
} else {
None
};
let (profile, profile_source) = match (profile_from_preview, profile_from_draft) {
(Some(profile), _) => (Some(profile), "result_preview"),
(None, Some(profile)) => (Some(profile), "draft_profile"),
(None, None) => (None, "none"),
};
let publish_ready = session
.publish_gate
.as_ref()
.map(|gate| gate.publish_ready)
.or_else(|| {
session
.result_preview
.as_ref()
.and_then(|preview| preview.get("publishReady"))
.and_then(Value::as_bool)
})
.unwrap_or(false);
let can_enter_world = session
.publish_gate
.as_ref()
.map(|gate| gate.can_enter_world)
.or_else(|| {
session
.result_preview
.as_ref()
.and_then(|preview| preview.get("canEnterWorld"))
.and_then(Value::as_bool)
})
.unwrap_or(false);
let blocker_count = session
.publish_gate
.as_ref()
.map(|gate| gate.blocker_count)
.or_else(|| {
session
.result_preview
.as_ref()
.and_then(|preview| preview.get("blockers"))
.and_then(Value::as_array)
.map(|items| items.len() as u32)
})
.unwrap_or(0);
let has_profile = profile.is_some();
let generation_failed = session.stage == "error"
|| session
.messages
.iter()
.any(|message| message.kind == "warning" && message.text.contains("失败"));
let result_stage = is_agent_result_stage(session.stage.as_str());
let (
target_stage,
generation_view_source,
result_view_source,
recovery_action,
recovery_reason,
) = if has_profile && result_stage {
(
"custom-world-result",
None,
Some("agent-draft"),
"open_result",
None,
)
} else if generation_failed {
(
"custom-world-generating",
Some("agent-draft-foundation"),
None,
"resume_generation",
Some("当前草稿生成失败或缺少结果预览,需要回到生成过程页继续处理。"),
)
} else {
(
"agent-workspace",
None,
None,
"continue_agent",
Some("当前会话还没有可打开的结果页真相源。"),
)
};
let can_sync_result_profile = is_agent_result_profile_sync_stage(session.stage.as_str());
CustomWorldCreationResultViewResponse {
session: map_custom_world_agent_session_response(session),
profile,
profile_source: profile_source.to_string(),
target_stage: target_stage.to_string(),
generation_view_source: generation_view_source.map(ToOwned::to_owned),
result_view_source: result_view_source.map(ToOwned::to_owned),
can_autosave_library: has_profile && result_stage,
can_sync_result_profile,
publish_ready,
can_enter_world,
blocker_count,
recovery_action: recovery_action.to_string(),
recovery_reason: recovery_reason.map(ToOwned::to_owned),
}
}
fn is_agent_result_stage(stage: &str) -> bool {
matches!(
stage,
"object_refining"
| "visual_refining"
| "long_tail_review"
| "ready_to_publish"
| "published"
)
}
fn is_agent_result_profile_sync_stage(stage: &str) -> bool {
matches!(
stage,
"object_refining" | "visual_refining" | "long_tail_review" | "ready_to_publish"
)
}
fn normalize_json_object_value(value: &Value) -> Option<Value> {
value.as_object().and_then(|object| {
if object.is_empty() {
None
} else {
Some(Value::Object(object.clone()))
}
})
}
fn log_custom_world_publish_gate_diagnostics(
source: &str,
session: &CustomWorldAgentSessionRecord,
@@ -2918,6 +3367,10 @@ fn current_utc_micros() -> i64 {
#[cfg(test)]
mod tests {
use super::*;
use axum::{Router, body::Body, http::Request};
use tower::ServiceExt;
use crate::{app::build_router, config::AppConfig};
#[test]
fn incomplete_role_asset_text_draft_profile_is_not_reused() {
@@ -2984,6 +3437,173 @@ mod tests {
assert!(reusable_draft_profile_for_asset_generation(&session).is_some());
}
#[test]
fn generated_profile_finalize_adds_required_frontend_fields() {
let mut profile = json!({
"name": "雾港归航",
"summary": "守灯人与群岛议会围绕沉船旧案对峙。",
"playableNpcs": [],
"storyNpcs": [],
"landmarks": []
});
finalize_generated_custom_world_profile(
&mut profile,
"在失真的海图上追查一场被篡改的沉船事故。",
"fast",
json!({ "worldHook": "海图会撒谎" }),
);
assert_eq!(
profile.get("settingText").and_then(Value::as_str),
Some("在失真的海图上追查一场被篡改的沉船事故。")
);
assert_eq!(
profile.get("generationMode").and_then(Value::as_str),
Some("fast")
);
assert_eq!(
profile.get("generationStatus").and_then(Value::as_str),
Some("key_only")
);
assert_eq!(
profile.get("templateWorldType").and_then(Value::as_str),
Some("WUXIA")
);
assert!(profile.get("items").and_then(Value::as_array).is_some());
assert!(
profile
.get("id")
.and_then(Value::as_str)
.is_some_and(|value| value.starts_with("custom-world-"))
);
assert!(profile.get("creatorIntent").is_some());
}
#[test]
fn sync_result_profile_payload_is_canonicalized_on_server() {
let payload = ExecuteCustomWorldAgentActionRequest {
action: "sync_result_profile".to_string(),
profile: Some(json!({
"id": "cwprof_001",
"settingText": "前端保存前不再改写这段文字",
"creatorIntent": {
"worldHook": "海图会在午夜改写群岛航路",
"playerPremise": "玩家是失忆领航员",
"openingSituation": "正在禁航区醒来",
"themeKeywords": ["海雾"],
"toneDirectives": ["悬疑"],
"coreConflicts": ["议会隐瞒沉船真相"],
"keyCharacters": [{
"name": "顾潮音",
"role": "守灯人",
"relationToPlayer": "旧识",
"hiddenHook": "掌握伪造海图"
}],
"iconicElements": ["会说谎的罗盘"]
}
})),
profile_id: None,
draft_profile: None,
legacy_result_profile: None,
setting_text: None,
card_id: None,
sections: None,
count: None,
role_type: None,
prompt_text: None,
anchor_card_ids: None,
role_ids: None,
role_id: None,
scene_ids: None,
portrait_path: None,
generated_visual_asset_id: None,
generated_animation_set_id: None,
animation_map: None,
scene_id: None,
scene_kind: None,
image_src: None,
generated_scene_asset_id: None,
generated_scene_prompt: None,
generated_scene_model: None,
checkpoint_id: None,
};
let payload_json =
serialize_sync_result_profile_action_payload(&payload).expect("payload serializes");
let payload_value: Value =
serde_json::from_str(&payload_json).expect("payload should be valid JSON");
assert_eq!(
payload_value
.get("profile")
.and_then(|profile| profile.get("settingText"))
.and_then(Value::as_str),
Some(
"世界一句话:海图会在午夜改写群岛航路\n玩家开局:玩家是失忆领航员;正在禁航区醒来\n主题气质:海雾 / 悬疑\n核心冲突:议会隐瞒沉船真相\n关键关系:顾潮音 · 守灯人 · 与玩家 旧识 · 暗线 掌握伪造海图\n标志元素:会说谎的罗盘"
)
);
}
#[test]
fn custom_world_library_profile_payload_is_canonicalized_on_server() {
let (profile, metadata) = canonicalize_custom_world_library_profile_payload(json!({
"id": "cwprof_001",
"name": "潮雾列岛",
"summary": "群岛与旧灯塔之间的沉船疑案。",
"settingText": "前端保存前不再改写这段文字",
"playableNpcs": [{"id": "pc-1"}],
"storyNpcs": [{"id": "story-1"}],
"landmarks": [{"id": "scene-1"}],
"creatorIntent": {
"worldHook": "海图会在午夜改写群岛航路",
"playerPremise": "玩家是失忆领航员",
"openingSituation": "正在禁航区醒来",
"themeKeywords": ["海雾"],
"toneDirectives": ["悬疑"],
"coreConflicts": ["议会隐瞒沉船真相"],
"keyCharacters": [{
"name": "顾潮音",
"role": "守灯人",
"relationToPlayer": "旧识",
"hiddenHook": "掌握伪造海图"
}],
"iconicElements": ["会说谎的罗盘"]
}
}))
.expect("profile should be canonicalized");
assert_eq!(metadata.world_name, "潮雾列岛");
assert_eq!(metadata.playable_npc_count, 2);
assert_eq!(metadata.landmark_count, 1);
assert_eq!(
profile.get("settingText").and_then(Value::as_str),
Some(
"世界一句话:海图会在午夜改写群岛航路\n玩家开局:玩家是失忆领航员;正在禁航区醒来\n主题气质:海雾 / 悬疑\n核心冲突:议会隐瞒沉船真相\n关键关系:顾潮音 · 守灯人 · 与玩家 旧识 · 暗线 掌握伪造海图\n标志元素:会说谎的罗盘"
)
);
}
#[tokio::test]
async fn custom_world_profile_generation_requires_authentication() {
let app: Router =
build_router(AppState::new(AppConfig::default()).expect("state should build"));
let response = app
.oneshot(
Request::builder()
.method("POST")
.uri("/api/runtime/custom-world/profile")
.header("content-type", "application/json")
.body(Body::from(r#"{"settingText":"海雾会吞掉记错航线的人。"}"#))
.expect("request should build"),
)
.await
.expect("request should succeed");
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
}
#[test]
fn collect_scene_act_refs_accepts_scene_prompt_text_alias() {
let draft_profile = json!({

View File

@@ -1099,7 +1099,7 @@ fn first_json_string(value: &JsonValue, key: &str) -> Option<String> {
.map(ToOwned::to_owned)
}
fn stable_ascii_slug(value: &str) -> String {
pub(crate) fn stable_ascii_slug(value: &str) -> String {
let mut hash = 0u32;
for character in value.chars() {
hash = hash.wrapping_mul(31).wrapping_add(character as u32);

View File

@@ -1,3 +1,5 @@
#![recursion_limit = "256"]
mod admin;
mod ai_generation_drafts;
mod ai_tasks;
@@ -13,6 +15,7 @@ mod auth_session;
mod auth_sessions;
mod big_fish;
mod big_fish_agent_turn;
mod big_fish_draft_compiler;
mod character_animation_assets;
mod character_visual_assets;
mod config;
@@ -47,6 +50,7 @@ mod request_context;
mod response_headers;
mod runtime_browse_history;
mod runtime_chat;
mod runtime_chat_plain;
mod runtime_inventory;
mod runtime_profile;
mod runtime_save;

View File

@@ -0,0 +1,389 @@
use module_big_fish::BigFishAnchorPack;
use serde_json::Value as JsonValue;
use spacetime_client::{
BigFishAgentMessageRecord, BigFishAnchorPackRecord, BigFishGameDraftRecord,
BigFishLevelBlueprintRecord, BigFishSessionRecord,
};
use crate::creation_agent_anchor_templates::{
get_creation_agent_anchor_template, render_anchor_question_block,
};
use crate::creation_agent_chat::render_quick_fill_extra_rules;
pub(crate) const BIG_FISH_AGENT_SYSTEM_PROMPT: &str = r#"你是一个负责和创作者共创“大鱼吃小鱼”竖屏玩法的中文创意策划。
你必须把用户灵感收束成可以编译为可玩草稿的玩法、生态视觉、成长阶梯和风险节奏。
你必须同时输出:
1. 一段直接发给用户的中文回复 replyText
2. 当前进度 progressPercent
3. 下一轮完整可用的 nextAnchorPack
硬约束:
1. 只能输出 JSON不能输出代码块或解释
2. nextAnchorPack 必须是完整对象,不能只输出 patch
3. replyText 必须是自然中文不能提“字段”“锚点”“结构”“JSON”等内部词
4. replyText 一次最多推进一个最关键问题
5. 必须对齐 RPG 共创的体验:先理解玩家幻想,再收束成能进入运行时的可玩效果
6. progressPercent 范围只能是 0 到 100
7. status 只能使用 missing / inferred / confirmed / locked
"#;
const BIG_FISH_AGENT_OUTPUT_CONTRACT: &str = r#"请严格按以下 JSON 输出,不要输出其他文字:
{
"replyText": "",
"progressPercent": 0,
"nextAnchorPack": {
"gameplayPromise": {
"key": "gameplayPromise",
"label": "玩法承诺",
"value": "",
"status": "missing"
},
"ecologyVisualTheme": {
"key": "ecologyVisualTheme",
"label": "生态视觉主题",
"value": "",
"status": "missing"
},
"growthLadder": {
"key": "growthLadder",
"label": "成长阶梯",
"value": "",
"status": "missing"
},
"riskTempo": {
"key": "riskTempo",
"label": "风险节奏",
"value": "",
"status": "missing"
}
}
}"#;
/// 大鱼吃小鱼草稿生成对话提示词脚本。
///
/// 这里单独承载 Agent 共创阶段的 system prompt 与 user prompt 组装,
/// 避免聊天契约、草稿编译路由和结果页资产生成混在同一个业务文件里。
pub(crate) fn build_big_fish_agent_prompt(
session: &BigFishSessionRecord,
quick_fill_requested: bool,
) -> String {
let anchor_question_block = get_creation_agent_anchor_template("big_fish")
.map(render_anchor_question_block)
.unwrap_or_else(|| "模板目标:收束成可玩的竖屏大鱼吃小鱼玩法草稿。".to_string());
let quick_fill_rules = if quick_fill_requested {
format!(
"\n\n{}",
render_quick_fill_extra_rules(
"当前玩法方向里的成长、生态、风险节奏等缺失关键词",
"不要要求用户再提供等级、鱼群、场景或节奏信息",
"输出完整 nextAnchorPack直接补齐 value 为空或 status 为 missing 的项",
"生成结果页",
)
)
} else {
String::new()
};
format!(
"{anchor_question_block}{quick_fill_rules}\n\n当前是第 {turn} 轮,当前进度 {progress}% 。\n\n是否要求自动补充剩余关键字:{quick_fill_requested_text}\n\n当前 anchor pack\n{anchor_pack}\n\n最近聊天记录:\n{chat_history}\n\n{contract}",
anchor_question_block = anchor_question_block,
quick_fill_rules = quick_fill_rules,
turn = session.current_turn.saturating_add(1),
progress = session.progress_percent,
quick_fill_requested_text = if quick_fill_requested { "" } else { "" },
anchor_pack = serialize_record_anchor_pack(&session.anchor_pack),
chat_history =
serde_json::to_string_pretty(&build_chat_history(session.messages.as_slice()))
.unwrap_or_else(|_| "[]".to_string()),
contract = BIG_FISH_AGENT_OUTPUT_CONTRACT,
)
}
/// 大鱼吃小鱼主图生成提示词脚本。
pub(crate) fn build_big_fish_level_main_image_prompt(
_draft: &BigFishGameDraftRecord,
level: &BigFishLevelBlueprintRecord,
) -> String {
vec![
"生成角色形象图片。".to_string(),
format!(
"等级Lv.{},名称:{},幻想描述:{}",
level.level, level.name, level.one_line_fantasy
),
format!("文字描述:{}", level.text_description),
format!("轮廓方向:{}", level.silhouette_direction),
format!("形象描述:{}", level.visual_description),
format!("主图提示词:{}", level.visual_prompt_seed),
"等级对形象的影响规则等级越高越霸气、有气场、看起来强大、画面细节丰富等级级别越低越弱小、普通。最低等级为1级最高等级可能是6-12级".to_string(),
"画面要求1:1 正方形画布,画面中心构图,角色主体完整置于画面中央,不要裁切主体顶部和底部,不要镜头透视,不要特写。背景固定为纯绿色绿幕,只作为抠像底色,不出现建筑、室内布景、风景、地面道具、漂浮物、烟雾叙事元素、文字或其他角色以外的场景内容。".to_string(),
"背景要求:透明背景 PNG 风格不出现任何场景、水草、气泡、阴影地面、UI、文字、logo、水印、对话框或边框不要出现多只主体。".to_string(),
]
.join("")
}
/// 大鱼吃小鱼动作关键帧生成提示词脚本。
pub(crate) fn build_big_fish_level_motion_prompt(
draft: &BigFishGameDraftRecord,
level: &BigFishLevelBlueprintRecord,
motion_key: &str,
) -> String {
let motion_text = match motion_key {
"move_swim" => format!(
"{} 向右游动的关键帧预览,身体与尾鳍有清晰推进姿态,带轻微水流拖尾。",
level.move_motion_description
),
_ => format!(
"{} 待机漂浮的关键帧预览,身体轻微摆动,姿态稳定,适合作为 idle 状态。",
level.idle_motion_description
),
};
vec![
format!(
"为竖屏移动游戏《{}》生成一张等级生物动作关键帧静态预览图。",
draft.title
),
format!("生态主题:{}", draft.ecology_theme),
format!(
"等级Lv.{},名称:{},幻想描述:{}",
level.level, level.name, level.one_line_fantasy
),
format!("文字描述:{}", level.text_description),
format!("动作提示词种子:{}", level.motion_prompt_seed),
format!("动作要求:{motion_text}"),
"画面要求:按 RPG 角色动画资产口径生成单体鱼形生物完整入镜轮廓清晰动作方向明确2D 高完成度游戏插画,适合作为 Big Fish 动作槽位的静态 keyframe。".to_string(),
"背景要求:透明背景 PNG 风格不出现任何场景、水草、气泡、阴影地面、UI、文字、logo、水印、对话框或边框不要生成序列帧拼图不要出现多只主体。".to_string(),
]
.join("")
}
/// 大鱼吃小鱼场地背景生成提示词脚本。
pub(crate) fn build_big_fish_stage_background_prompt(draft: &BigFishGameDraftRecord) -> String {
let background = &draft.background;
vec![
"生成一张 9:16 的游戏场景背景图。".to_string(),
format!("生态主题:{}", draft.ecology_theme),
format!("背景主题:{}。色彩氛围:{}", background.theme, background.color_mood),
format!("前景提示:{}", background.foreground_hints),
format!("中景构图:{}", background.midground_composition),
format!("背景纵深:{}", background.background_depth),
format!("安全操作区:{}", background.safe_play_area_hint),
format!("出生边缘:{}", background.spawn_edge_hint),
format!("背景提示词种子:{}", background.background_prompt_seed),
"画面要求竖屏9:16大场地全屏运行态背景中央 80% 保持开阔清爽,边缘只保留少量出生区环境提示。".to_string(),
"元素要求画面中不出现任何形象主体、密集装饰、UI、文字、logo、水印、对话框或边框不要把中央操作区画得过暗或过复杂。".to_string(),
]
.join("")
}
/// 大鱼吃小鱼图片生成默认反向提示词脚本。
pub(crate) const BIG_FISH_DEFAULT_NEGATIVE_PROMPT: &str = "文字水印logoUI界面对话框边框多余肢体畸形鱼体低清晰度模糊压缩噪点现代摄影棚写实照片背景复杂背景";
/// 大鱼吃小鱼透明主体类图片生成默认反向提示词脚本。
pub(crate) const BIG_FISH_TRANSPARENT_ASSET_NEGATIVE_PROMPT: &str = "文字水印logoUI界面对话框边框多余肢体畸形鱼体低清晰度模糊压缩噪点现代摄影棚写实照片背景场景背景水草背景气泡背景多只主体阴影地面";
fn build_chat_history(messages: &[BigFishAgentMessageRecord]) -> Vec<JsonValue> {
messages
.iter()
.map(|message| {
serde_json::json!({
"role": message.role,
"kind": message.kind,
"content": message.text,
})
})
.collect()
}
pub(crate) fn serialize_record_anchor_pack(anchor_pack: &BigFishAnchorPackRecord) -> String {
serde_json::to_string_pretty(&map_big_fish_record_anchor_pack(anchor_pack))
.unwrap_or_else(|_| "{}".to_string())
}
fn map_big_fish_record_anchor_pack(record: &BigFishAnchorPackRecord) -> BigFishAnchorPack {
BigFishAnchorPack {
gameplay_promise: map_big_fish_record_anchor_item(&record.gameplay_promise),
ecology_visual_theme: map_big_fish_record_anchor_item(&record.ecology_visual_theme),
growth_ladder: map_big_fish_record_anchor_item(&record.growth_ladder),
risk_tempo: map_big_fish_record_anchor_item(&record.risk_tempo),
}
}
fn map_big_fish_record_anchor_item(
record: &spacetime_client::BigFishAnchorItemRecord,
) -> module_big_fish::BigFishAnchorItem {
module_big_fish::BigFishAnchorItem {
key: record.key.clone(),
label: record.label.clone(),
value: record.value.clone(),
status: match record.status.as_str() {
"confirmed" => module_big_fish::BigFishAnchorStatus::Confirmed,
"locked" => module_big_fish::BigFishAnchorStatus::Locked,
"inferred" => module_big_fish::BigFishAnchorStatus::Inferred,
_ => module_big_fish::BigFishAnchorStatus::Missing,
},
}
}
#[cfg(test)]
mod tests {
use super::{
BIG_FISH_DEFAULT_NEGATIVE_PROMPT, BIG_FISH_TRANSPARENT_ASSET_NEGATIVE_PROMPT,
build_big_fish_agent_prompt, build_big_fish_level_main_image_prompt,
build_big_fish_level_motion_prompt, build_big_fish_stage_background_prompt,
};
fn anchor_item(
key: &str,
label: &str,
value: &str,
status: &str,
) -> spacetime_client::BigFishAnchorItemRecord {
spacetime_client::BigFishAnchorItemRecord {
key: key.to_string(),
label: label.to_string(),
value: value.to_string(),
status: status.to_string(),
}
}
fn empty_session_record() -> spacetime_client::BigFishSessionRecord {
spacetime_client::BigFishSessionRecord {
session_id: "big-fish-session-test".to_string(),
current_turn: 2,
progress_percent: 60,
stage: "collecting_anchors".to_string(),
anchor_pack: spacetime_client::BigFishAnchorPackRecord {
gameplay_promise: anchor_item(
"gameplayPromise",
"玩法承诺",
"微光小鱼逆袭深海巨兽",
"confirmed",
),
ecology_visual_theme: anchor_item(
"ecologyVisualTheme",
"生态视觉主题",
"幽蓝珊瑚海沟",
"confirmed",
),
growth_ladder: anchor_item("growthLadder", "成长阶梯", "", "missing"),
risk_tempo: anchor_item("riskTempo", "风险节奏", "", "missing"),
},
draft: None,
asset_slots: Vec::new(),
asset_coverage: spacetime_client::BigFishAssetCoverageRecord {
level_main_image_ready_count: 0,
level_motion_ready_count: 0,
background_ready: false,
required_level_count: 8,
publish_ready: false,
blockers: Vec::new(),
},
messages: Vec::new(),
last_assistant_reply: None,
publish_ready: false,
updated_at: "2026-04-24T10:00:00.000Z".to_string(),
}
}
fn sample_draft() -> spacetime_client::BigFishGameDraftRecord {
spacetime_client::BigFishGameDraftRecord {
title: "深海逆袭".to_string(),
subtitle: "从微光幼体吞噬到深渊王座".to_string(),
core_fun: "吞噬成长与风险闪避".to_string(),
ecology_theme: "幽蓝海沟珊瑚裂谷".to_string(),
levels: vec![spacetime_client::BigFishLevelBlueprintRecord {
level: 3,
name: "裂潮猎游者".to_string(),
one_line_fantasy: "在电光海沟中疾行收割的中阶猎鱼".to_string(),
text_description: "裂潮猎游者是中阶进化体,已经具备更清晰的猎食轮廓和压迫感。"
.to_string(),
silhouette_direction: "长尾前探、背鳍后掠".to_string(),
size_ratio: 1.8,
visual_description: "深海霓虹风格的中阶猎鱼,长尾锐利,骨质鳍刃明显,轮廓成熟。"
.to_string(),
visual_prompt_seed: "深海霓虹、锐利长尾、骨质鳍刃".to_string(),
idle_motion_description:
"待机时身体轻微悬停,尾鳍保持低频摆动,像是在观察猎物距离。".to_string(),
move_motion_description: "移动时长尾快速摆动,身体前探,形成明显突进巡游姿态。"
.to_string(),
motion_prompt_seed: "突进摆尾、鳍面拉伸、水流拖尾".to_string(),
merge_source_level: Some(2),
prey_window: vec![1, 2],
threat_window: vec![4, 5],
is_final_level: false,
}],
background: spacetime_client::BigFishBackgroundBlueprintRecord {
theme: "裂谷荧光水域".to_string(),
color_mood: "蓝绿冷光、边缘紫雾".to_string(),
foreground_hints: "边角保留细碎水母草和岩脊".to_string(),
midground_composition: "中央留大面积清爽水道".to_string(),
background_depth: "远处海沟层叠透视".to_string(),
safe_play_area_hint: "中央 80% 为操作安全区".to_string(),
spawn_edge_hint: "左右边缘保留出生点环境提示".to_string(),
background_prompt_seed: "荧光裂谷、冷色纵深、轻体积光".to_string(),
},
runtime_params: spacetime_client::BigFishRuntimeParamsRecord {
level_count: 8,
merge_count_per_upgrade: 3,
spawn_target_count: 12,
leader_move_speed: 240.0,
follower_catch_up_speed: 280.0,
offscreen_cull_seconds: 4.5,
prey_spawn_delta_levels: vec![0, 1],
threat_spawn_delta_levels: vec![1, 2],
win_level: 8,
},
}
}
#[test]
fn quick_fill_prompt_forbids_follow_up_questions() {
let prompt = build_big_fish_agent_prompt(&empty_session_record(), true);
assert!(prompt.contains("用户刚刚主动要求你自动补充剩余关键字"));
assert!(prompt.contains("不要再继续提问"));
assert!(prompt.contains("progressPercent 直接输出为 100"));
}
#[test]
fn level_main_image_prompt_keeps_core_constraints() {
let draft = sample_draft();
let prompt = build_big_fish_level_main_image_prompt(&draft, &draft.levels[0]);
assert!(prompt.contains("裂潮猎游者"));
assert!(prompt.contains("形象描述"));
assert!(prompt.contains("透明背景 PNG 风格"));
assert!(prompt.contains("主图提示词"));
}
#[test]
fn level_motion_prompt_varies_with_motion_key() {
let draft = sample_draft();
let move_prompt = build_big_fish_level_motion_prompt(&draft, &draft.levels[0], "move_swim");
let idle_prompt =
build_big_fish_level_motion_prompt(&draft, &draft.levels[0], "idle_float");
assert!(move_prompt.contains("向右游动的关键帧预览"));
assert!(idle_prompt.contains("待机漂浮的关键帧预览"));
assert!(move_prompt.contains("透明背景 PNG 风格"));
}
#[test]
fn stage_background_prompt_keeps_runtime_field_constraints() {
let draft = sample_draft();
let prompt = build_big_fish_stage_background_prompt(&draft);
assert!(prompt.contains("生成一张 9:16 的游戏场景背景图"));
assert!(prompt.contains("中央 80% 保持开阔清爽"));
assert!(prompt.contains("背景提示词种子"));
}
#[test]
fn negative_prompts_keep_text_and_background_blockers() {
assert!(BIG_FISH_DEFAULT_NEGATIVE_PROMPT.contains("文字"));
assert!(BIG_FISH_DEFAULT_NEGATIVE_PROMPT.contains("复杂背景"));
assert!(BIG_FISH_TRANSPARENT_ASSET_NEGATIVE_PROMPT.contains("场景背景"));
assert!(BIG_FISH_TRANSPARENT_ASSET_NEGATIVE_PROMPT.contains("多只主体"));
}
}

View File

@@ -199,7 +199,7 @@ fn build_video_action_prompt(
) -> String {
[
format!("生成有创意细节饱满的角色动作视频,动作英文名是 {}", action_id),
"角色固定为图1同一角色保持右向斜侧身动作视角镜头稳定轮廓清晰禁止退化成完全 90 度纯右视图。".to_string(),
"角色固定为图1同一角色保持右向斜侧身动作视角镜头稳定轮廓清晰禁止退化成完全90度纯右视图。".to_string(),
"画面要求1:1 正方形画布,画面中心构图,角色主体完整置于画面中央,不要裁切主体顶部和底部,不要镜头透视,不要特写。背景固定为纯绿色绿幕,只作为抠像底色,不出现建筑、室内布景、风景等场景内容。".to_string(),
format!("动作结构:{}。结尾要求:动作收束清楚,便于后续抽帧。", action_sequence),
if use_chroma_key {

View File

@@ -47,7 +47,7 @@ fn resolve_original_role_archetype(source: &str) -> &'static str {
/// 角色主图统一提示词骨架,迁移自旧共享 qwenSprite 主链。
fn build_master_prompt(character_brief: &str) -> String {
[
"单人2D像素角色形象头身比必须控制在 1.5 到 2 头身,主体完整可见,底部轮廓完整,身体比例稳定,轮廓清楚,适合后续制作 sprite sheet 动画。".to_string(),
"单人2D像素角色形象头身比必须控制在1.5头身,主体完整可见,底部轮廓完整,身体比例稳定,轮廓清楚,适合后续制作 sprite sheet 动画。".to_string(),
"视角要求:角色采用横版动作素材常用的右向斜侧身站姿,身体整体朝右,但保留少量正面信息,能读到面部轮廓与胸肩结构,不是完全 90 度纯右视图,也不是正面立绘。".to_string(),
"主体要求:画面中只保留单个角色主体,不要额外人物、动物、召唤物、载具或陪体。".to_string(),
"画面要求1:1 正方形画布,画面中心构图,角色主体完整置于画面中央,不要裁切主体顶部和底部,不要镜头透视,不要特写。背景固定为纯绿色绿幕,只作为抠像底色,不出现建筑、室内布景、风景、地面道具、漂浮物、烟雾叙事元素、文字或其他角色以外的场景内容。".to_string(),

View File

@@ -1,7 +1,11 @@
pub(crate) mod agent_chat;
pub(crate) mod big_fish;
pub(crate) mod character_animation;
pub(crate) mod character_visual;
pub(crate) mod foundation_draft;
pub(crate) mod puzzle_image;
pub(crate) mod runtime_chat;
pub(crate) mod rpg;
pub(crate) mod scene_background;
pub(crate) use rpg::agent_chat;
pub(crate) use rpg::foundation_draft;
pub(crate) use rpg::role_asset_studio;
pub(crate) use rpg::runtime_chat;

View File

@@ -47,7 +47,7 @@ pub(crate) fn build_custom_world_framework_prompt(setting_text: &str) -> String
"- majorFactions 保持 2 到 3 个coreConflicts 保持 2 到 3 个。".to_string(),
"- attributeSchema 必须是本世界专属的角色六维属性体系slots 必须恰好 6 个slotId 固定为 axis_a 到 axis_f维度名必须是 2 到 4 个汉字且互不重复。".to_string(),
"- attributeSchema.slots 的 name 禁止使用:生命、法力、护甲、攻击、防御、力量、敏捷、智力、精神;不要写通用 DND 或传统四维属性。".to_string(),
"- 每个属性维度都要同时能服务战斗、社交、探索三种场景definition、combatUseText、socialUseText、explorationUseText 必须贴合本世界主题。".to_string(),
"- 每个属性维度definition都要像RPG游戏属性名同时能服务战斗、社交、探索三种场景definition、combatUseText、socialUseText、explorationUseText 必须贴合本世界主题。".to_string(),
"- 世界设定必须直接源自玩家输入,不要脱离主题乱扩写。".to_string(),
"- 每个字符串尽量简洁subtitle 控制在 8 到 18 个汉字内summary 控制在 16 到 32 个汉字内tone 控制在 6 到 16 个汉字内playerGoal 控制在 16 到 32 个汉字内camp.description 控制在 18 到 40 个汉字内。".to_string(),
"- 返回前自检:必须是一个能被 JSON.parse 直接解析的单个 JSON 对象。".to_string(),

View File

@@ -0,0 +1,4 @@
pub(crate) mod agent_chat;
pub(crate) mod foundation_draft;
pub(crate) mod role_asset_studio;
pub(crate) mod runtime_chat;

View File

@@ -0,0 +1,348 @@
use std::collections::BTreeMap;
use serde_json::Value;
use shared_contracts::assets::{
CharacterAssetRolePromptInput, CharacterRoleAssetWorkflowPayload,
CharacterRolePromptBundlePayload, CharacterWorkflowCachePayload,
};
const CORE_ANIMATION_KEYS: [&str; 4] = ["run", "attack", "idle", "die"];
/// 角色资产工坊默认 prompt 与缓存合并的后端主源。
///
/// 前端只保留输入框中的用户草稿;默认值挑选、旧 prompt 过滤、逐动作缓存继承都在这里统一执行。
pub(crate) fn build_role_asset_workflow(
role: CharacterAssetRolePromptInput,
cache: Option<&CharacterWorkflowCachePayload>,
) -> CharacterRoleAssetWorkflowPayload {
let default_prompt_bundle = build_default_role_prompt_bundle(&role);
let visual_prompt_text =
resolve_visual_prompt_text(&role, cache, &default_prompt_bundle.visual_prompt_text);
let animation_prompt_text_by_key =
resolve_animation_prompt_text_by_key(&role, cache, &default_prompt_bundle);
let animation_prompt_text = animation_prompt_text_by_key
.get("idle")
.cloned()
.unwrap_or_else(|| default_prompt_bundle.animation_prompt_text.clone());
CharacterRoleAssetWorkflowPayload {
role: role.clone(),
default_prompt_bundle,
visual_prompt_text,
animation_prompt_text,
animation_prompt_text_by_key,
visual_drafts: cache
.map(|cache| cache.visual_drafts.clone())
.unwrap_or_default(),
selected_visual_draft_id: cache
.map(|cache| cache.selected_visual_draft_id.clone())
.unwrap_or_default(),
selected_animation: cache
.map(|cache| cache.selected_animation.clone())
.filter(|value| CORE_ANIMATION_KEYS.contains(&value.as_str()))
.unwrap_or_else(|| "run".to_string()),
image_src: cache
.and_then(|cache| cache.image_src.clone())
.or_else(|| trim_optional_text(role.image_src.as_deref())),
generated_visual_asset_id: cache
.and_then(|cache| cache.generated_visual_asset_id.clone())
.or_else(|| trim_optional_text(role.generated_visual_asset_id.as_deref())),
generated_animation_set_id: cache
.and_then(|cache| cache.generated_animation_set_id.clone())
.or_else(|| trim_optional_text(role.generated_animation_set_id.as_deref())),
animation_map: cache
.and_then(|cache| cache.animation_map.clone())
.or_else(|| role.animation_map.clone())
.filter(Value::is_object),
updated_at: cache.and_then(|cache| cache.updated_at.clone()),
}
}
pub(crate) fn build_default_role_prompt_bundle(
role: &CharacterAssetRolePromptInput,
) -> CharacterRolePromptBundlePayload {
CharacterRolePromptBundlePayload {
visual_prompt_text: pick_first_description(
[
role.visual_description.as_deref(),
role.description.as_deref(),
],
220,
),
animation_prompt_text: pick_first_description(
[
role.action_description.as_deref(),
role.combat_style.as_deref(),
],
180,
),
scene_prompt_text: pick_first_description(
[
role.scene_visual_description.as_deref(),
role.backstory.as_deref(),
],
220,
),
}
}
pub(crate) fn normalize_animation_prompt_text_by_key(
prompt_text_by_key: BTreeMap<String, String>,
) -> BTreeMap<String, String> {
prompt_text_by_key
.into_iter()
.filter_map(|(key, value)| {
let key = trim_optional_text(Some(key.as_str()))?;
let value = clamp_seed_text(value.as_str(), 280);
if value.is_empty() {
None
} else {
Some((key, value))
}
})
.collect()
}
fn resolve_visual_prompt_text(
role: &CharacterAssetRolePromptInput,
cache: Option<&CharacterWorkflowCachePayload>,
fallback_text: &str,
) -> String {
if trim_optional_text(role.visual_description.as_deref()).is_none() {
if let Some(cached_text) = cache
.map(|cache| cache.visual_prompt_text.as_str())
.and_then(|value| trim_optional_text(Some(value)))
.filter(|value| !is_legacy_generated_visual_description(value))
{
return cached_text;
}
}
fallback_text.to_string()
}
fn resolve_animation_prompt_text_by_key(
role: &CharacterAssetRolePromptInput,
cache: Option<&CharacterWorkflowCachePayload>,
default_prompt_bundle: &CharacterRolePromptBundlePayload,
) -> BTreeMap<String, String> {
let fallback_text = default_prompt_bundle.animation_prompt_text.as_str();
let prefer_fresh_role_text = trim_optional_text(role.action_description.as_deref()).is_some();
let cached_by_key = cache
.map(|cache| &cache.animation_prompt_text_by_key)
.cloned()
.unwrap_or_default();
let legacy_text = cache
.map(|cache| cache.animation_prompt_text.as_str())
.and_then(|value| trim_optional_text(Some(value)))
.filter(|value| !is_legacy_generated_action_description(value));
CORE_ANIMATION_KEYS
.iter()
.map(|animation| {
let cached_text = cached_by_key
.get(*animation)
.and_then(|value| trim_optional_text(Some(value.as_str())))
.filter(|value| !is_legacy_generated_action_description(value));
let prompt_text = if prefer_fresh_role_text {
fallback_text.to_string()
} else {
cached_text
.or_else(|| legacy_text.clone())
.unwrap_or_else(|| fallback_text.to_string())
};
((*animation).to_string(), prompt_text)
})
.collect()
}
fn pick_first_description<const N: usize>(values: [Option<&str>; N], max_length: usize) -> String {
values
.into_iter()
.filter_map(|value| value.map(|value| clamp_seed_text(value, max_length)))
.find(|value| !value.is_empty())
.unwrap_or_default()
}
fn trim_optional_text(value: Option<&str>) -> Option<String> {
value
.map(|value| value.split_whitespace().collect::<Vec<_>>().join(" "))
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty())
}
fn clamp_seed_text(value: &str, max_length: usize) -> String {
trim_optional_text(Some(value))
.unwrap_or_default()
.chars()
.take(max_length)
.collect()
}
fn is_legacy_generated_visual_description(value: &str) -> bool {
let normalized = value.trim();
!normalized.is_empty()
&& [
"2D 横版 RPG",
"纯绿色绿幕",
"2 到 2.5 头身",
"深色粗轮廓",
"身体整体朝右",
"脚底完整可见",
]
.iter()
.any(|marker| normalized.contains(marker))
}
fn is_legacy_generated_action_description(value: &str) -> bool {
let normalized = value.trim();
!normalized.is_empty()
&& [
"动作气质参考:",
"发力起手明确",
"收招利落",
"动作表现偏向",
"起手克制",
]
.iter()
.any(|marker| normalized.contains(marker))
}
#[cfg(test)]
mod tests {
use super::*;
fn role_input() -> CharacterAssetRolePromptInput {
CharacterAssetRolePromptInput {
id: "hero".to_string(),
name: "沈砺".to_string(),
title: "灰炬向导".to_string(),
role: "边路同行者".to_string(),
visual_description: Some("灰黑短斗篷压着风痕。".to_string()),
action_description: Some("起手先观察风向,再用短弓牵制。".to_string()),
scene_visual_description: Some("边路哨点铺着潮湿石板。".to_string()),
description: Some("熟悉裂潮边路的向导。".to_string()),
backstory: Some("他把旧案痕迹留在边路。".to_string()),
personality: None,
motivation: None,
combat_style: Some("短弓牵制后贴近补刀。".to_string()),
tags: Vec::new(),
image_src: None,
generated_visual_asset_id: None,
generated_animation_set_id: None,
animation_map: None,
}
}
#[test]
fn default_prompt_bundle_keeps_existing_mapping_rules() {
let bundle = build_default_role_prompt_bundle(&role_input());
assert_eq!(bundle.visual_prompt_text, "灰黑短斗篷压着风痕。");
assert_eq!(
bundle.animation_prompt_text,
"起手先观察风向,再用短弓牵制。"
);
assert_eq!(bundle.scene_prompt_text, "边路哨点铺着潮湿石板。");
}
#[test]
fn workflow_prefers_fresh_role_prompt_over_cache() {
let cache = CharacterWorkflowCachePayload {
character_id: "hero".to_string(),
cache_scope_id: None,
visual_prompt_text: "缓存视觉".to_string(),
animation_prompt_text: "缓存动作".to_string(),
animation_prompt_text_by_key: BTreeMap::from([(
"run".to_string(),
"缓存奔跑".to_string(),
)]),
visual_drafts: Vec::new(),
selected_visual_draft_id: String::new(),
selected_animation: "idle".to_string(),
image_src: None,
generated_visual_asset_id: None,
generated_animation_set_id: None,
animation_map: None,
updated_at: None,
};
let workflow = build_role_asset_workflow(role_input(), Some(&cache));
assert_eq!(workflow.visual_prompt_text, "灰黑短斗篷压着风痕。");
assert_eq!(
workflow.animation_prompt_text_by_key["run"],
"起手先观察风向,再用短弓牵制。"
);
}
#[test]
fn workflow_uses_non_legacy_cache_when_role_has_no_fresh_text() {
let mut role = role_input();
role.visual_description = None;
role.action_description = None;
let cache = CharacterWorkflowCachePayload {
character_id: "hero".to_string(),
cache_scope_id: None,
visual_prompt_text: "缓存视觉".to_string(),
animation_prompt_text: "缓存旧动作".to_string(),
animation_prompt_text_by_key: BTreeMap::from([(
"attack".to_string(),
"缓存攻击动作".to_string(),
)]),
visual_drafts: Vec::new(),
selected_visual_draft_id: String::new(),
selected_animation: "attack".to_string(),
image_src: None,
generated_visual_asset_id: None,
generated_animation_set_id: None,
animation_map: None,
updated_at: None,
};
let workflow = build_role_asset_workflow(role, Some(&cache));
assert_eq!(workflow.visual_prompt_text, "缓存视觉");
assert_eq!(
workflow.animation_prompt_text_by_key["attack"],
"缓存攻击动作"
);
assert_eq!(workflow.animation_prompt_text_by_key["run"], "缓存旧动作");
assert_eq!(workflow.selected_animation, "attack");
}
#[test]
fn workflow_filters_legacy_cache_prompts() {
let mut role = role_input();
role.visual_description = None;
role.action_description = None;
let cache = CharacterWorkflowCachePayload {
character_id: "hero".to_string(),
cache_scope_id: None,
visual_prompt_text: "2D 横版 RPG纯绿色绿幕。".to_string(),
animation_prompt_text: "动作气质参考:发力起手明确。".to_string(),
animation_prompt_text_by_key: BTreeMap::from([(
"run".to_string(),
"收招利落,动作表现偏向快速。".to_string(),
)]),
visual_drafts: Vec::new(),
selected_visual_draft_id: String::new(),
selected_animation: "unknown".to_string(),
image_src: None,
generated_visual_asset_id: None,
generated_animation_set_id: None,
animation_map: None,
updated_at: None,
};
let workflow = build_role_asset_workflow(role, Some(&cache));
assert_eq!(workflow.visual_prompt_text, "熟悉裂潮边路的向导。");
assert_eq!(
workflow.animation_prompt_text_by_key["run"],
"短弓牵制后贴近补刀。"
);
assert_eq!(workflow.selected_animation, "run");
}
}

View File

@@ -65,6 +65,48 @@ pub(crate) fn runtime_npc_dialogue_system_prompt() -> &'static str {
"你是游戏运行时 NPC 对话导演。只输出中文正文,不要输出 JSON、Markdown 或规则说明;不要新增系统尚未结算的奖励、任务结果或战斗结果。"
}
#[derive(Clone, Debug)]
pub(crate) struct CharacterChatPromptParams<'a> {
pub world_type: &'a str,
pub player_character: &'a Value,
pub target_character: &'a Value,
pub story_history: &'a [Value],
pub context: &'a Value,
pub conversation_history: &'a [Value],
pub conversation_summary: &'a str,
pub previous_summary: &'a str,
pub player_message: &'a str,
pub target_status: &'a Value,
}
#[derive(Clone, Debug)]
pub(crate) struct NpcRecruitDialoguePromptParams<'a> {
pub world_type: &'a str,
pub character: &'a Value,
pub encounter: &'a Value,
pub monsters: &'a [Value],
pub history: &'a [Value],
pub context: &'a Value,
pub invitation_text: &'a str,
pub recruit_summary: &'a str,
}
pub(crate) fn build_character_chat_reply_system_prompt() -> &'static str {
"你是像素动作 RPG 中正在与玩家私下交谈的同行角色。只输出这名角色此刻会说的话只允许中文不要输出角色名、引号、旁白、动作描写、Markdown、JSON 或解释。"
}
pub(crate) fn build_character_chat_suggestions_system_prompt() -> &'static str {
"你要为玩家生成 3 条下一句可直接发送的中文回复建议。只输出 3 行纯文本不要序号、引号、Markdown 或解释。三条建议要分别偏关心、追问、轻松拉近关系。"
}
pub(crate) fn build_character_chat_summary_system_prompt() -> &'static str {
"你要把玩家与该角色的聊天沉淀成一段后续剧情可用的关系摘要。只输出一段中文摘要不要标题、Markdown、JSON 或解释。"
}
pub(crate) fn build_npc_recruit_dialogue_system_prompt() -> &'static str {
"你是角色扮演 RPG 的招募剧情对话编剧。只输出纯中文对话正文不要输出解释、代码、Markdown、JSON 或额外说明。最后一行必须由对方明确答应加入队伍。"
}
pub(crate) fn build_runtime_npc_dialogue_user_prompt(
npc_name: &str,
params: RuntimeNpcDialoguePromptParams<'_>,
@@ -88,6 +130,76 @@ pub(crate) fn build_runtime_npc_dialogue_user_prompt(
)
}
pub(crate) fn build_character_chat_reply_user_prompt(
params: CharacterChatPromptParams<'_>,
) -> String {
json!({
"worldType": params.world_type,
"playerCharacter": params.player_character,
"targetCharacter": params.target_character,
"storyHistory": params.story_history,
"context": params.context,
"conversationHistory": params.conversation_history,
"conversationSummary": params.conversation_summary,
"playerMessage": params.player_message,
"targetStatus": params.target_status,
})
.to_string()
}
pub(crate) fn build_character_chat_suggestions_user_prompt(
params: CharacterChatPromptParams<'_>,
) -> String {
json!({
"worldType": params.world_type,
"playerCharacter": params.player_character,
"targetCharacter": params.target_character,
"storyHistory": params.story_history,
"context": params.context,
"conversationHistory": params.conversation_history,
"conversationSummary": params.conversation_summary,
"targetStatus": params.target_status,
})
.to_string()
}
pub(crate) fn build_character_chat_summary_user_prompt(
params: CharacterChatPromptParams<'_>,
) -> String {
json!({
"worldType": params.world_type,
"playerCharacter": params.player_character,
"targetCharacter": params.target_character,
"storyHistory": params.story_history,
"context": params.context,
"conversationHistory": params.conversation_history,
"previousSummary": params.previous_summary,
"targetStatus": params.target_status,
})
.to_string()
}
pub(crate) fn build_npc_recruit_dialogue_user_prompt(
npc_name: &str,
params: NpcRecruitDialoguePromptParams<'_>,
) -> String {
let state_prompt = json!({
"worldType": params.world_type,
"character": params.character,
"encounter": params.encounter,
"monsters": params.monsters,
"history": params.history,
"context": params.context,
"invitationText": params.invitation_text,
"recruitSummary": params.recruit_summary,
})
.to_string();
format!(
"请基于以下运行时状态,把“邀请 {npc_name} 入队”这件事写成 4 到 6 行可直接展示的中文对话。最后一行必须由 {npc_name} 明确答应加入。\n{state_prompt}"
)
}
pub(crate) fn runtime_reasoned_story_system_prompt() -> &'static str {
"你是游戏运行时剧情导演。只输出中文剧情正文,不要输出 JSON、Markdown 或规则说明;必须尊重已结算的战斗 outcome、伤害和状态不要发明额外奖励。"
}
@@ -414,6 +526,116 @@ pub(crate) fn build_deterministic_npc_reply(
format!("{npc_name}听完你的话,回应道:“{player_message}。我明白你的意思,我们继续说。”")
}
pub(crate) fn build_character_chat_reply_fallback(
target_character: &Value,
player_message: &str,
conversation_summary: &str,
) -> String {
let target_name =
read_name_field(target_character, "name").unwrap_or_else(|| "对方".to_string());
let focus = if player_message.trim().is_empty() {
"我听见你刚才的话了。".to_string()
} else if player_message.trim().ends_with('。') {
player_message.trim().to_string()
} else {
format!("{}", player_message.trim())
};
if conversation_summary.trim().is_empty() {
format!("{focus}我会认真回答你。既然你愿意直接来问,我们就把这件事说清楚。")
} else {
format!("{focus}{target_name}显然记得你们之前谈过的事,所以这次回答也比先前更直接。")
}
}
pub(crate) fn build_character_chat_suggestions_fallback(target_character: &Value) -> String {
let target_name = read_name_field(target_character, "name").unwrap_or_else(|| "".to_string());
[
"我想先听你把真正担心的事说出来。".to_string(),
format!("{target_name},这件事你还瞒了我什么?"),
"先别谈别的,我想多了解你一点。".to_string(),
]
.join("\n")
}
pub(crate) fn build_character_chat_summary_fallback(
target_character: &Value,
conversation_history: &[Value],
previous_summary: &str,
) -> String {
let target_name =
read_name_field(target_character, "name").unwrap_or_else(|| "这名角色".to_string());
let latest_turns = conversation_history
.iter()
.rev()
.take(4)
.collect::<Vec<_>>()
.into_iter()
.rev()
.filter_map(|item| {
let record = as_record(item)?;
let speaker =
read_string(record.get("speaker")).unwrap_or_else(|| "character".to_string());
let text = read_string(record.get("text"))?;
Some(format!(
"{}{}",
if speaker == "player" {
"玩家"
} else {
target_name.as_str()
},
text
))
})
.collect::<Vec<_>>()
.join(" ");
let current = if latest_turns.is_empty() {
format!("{target_name}愿意继续私下交谈,对玩家的态度正在慢慢松动。")
} else {
format!("{target_name}在私下交谈中比先前更愿意回应。最近交流:{latest_turns}")
};
if previous_summary.trim().is_empty() {
current
} else {
format!("{} {}", previous_summary.trim(), current)
}
}
pub(crate) fn build_npc_chat_dialogue_fallback(encounter: &Value, topic: &str) -> String {
let npc_name = read_name_field(encounter, "npcName")
.or_else(|| read_name_field(encounter, "name"))
.unwrap_or_else(|| "对方".to_string());
[
format!(
"你:{}。我想先听听你的看法。",
if topic.trim().is_empty() {
"这件事我还没看透"
} else {
topic.trim()
}
),
format!("{npc_name}:你问得并不随意,看来是真想弄清这里的底细。"),
"你:前面的局势我还没看透。你若知道什么,就别只说一半。".to_string(),
format!("{npc_name}:我能告诉你的,是这里近来一直不太平。接下来多留神些。"),
]
.join("\n")
}
pub(crate) fn build_npc_recruit_dialogue_fallback(encounter: &Value) -> String {
let npc_name = read_name_field(encounter, "npcName")
.or_else(|| read_name_field(encounter, "name"))
.unwrap_or_else(|| "对方".to_string());
[
"你:这不是客套。我是真心希望你能加入队伍,和我一起走下去。".to_string(),
format!("{npc_name}:你这番话够坦诚,我听得出你不是随口一提。"),
"你:前路不会轻松,但我还是希望你能与我并肩同行。".to_string(),
format!("{npc_name}:好,我答应你。从现在起,我便与你结伴同行。"),
]
.join("\n")
}
pub(crate) fn build_deterministic_chat_suggestions(
npc_name: &str,
player_message: &str,
@@ -794,6 +1016,15 @@ fn read_string_field(value: &Value, field: &str) -> Option<String> {
.map(ToOwned::to_owned)
}
fn read_name_field(value: &Value, field: &str) -> Option<String> {
value
.get(field)
.and_then(Value::as_str)
.map(str::trim)
.filter(|text| !text.is_empty())
.map(ToOwned::to_owned)
}
fn read_string(value: Option<&Value>) -> Option<String> {
value
.and_then(Value::as_str)

View File

@@ -12,7 +12,14 @@ use serde::Deserialize;
use serde_json::{Value, json};
use std::convert::Infallible;
use module_runtime_story_compat::{
RuntimeStoryPromptContextExtras, build_runtime_story_prompt_context, current_world_type,
normalize_required_string, read_array_field, read_field, read_i32_field, read_object_field,
read_optional_string_field, read_runtime_session_id,
};
use crate::{
auth::AuthenticatedAccessToken,
http_error::AppError,
prompt::runtime_chat::{
NPC_CHAT_TURN_REPLY_SYSTEM_PROMPT, NPC_CHAT_TURN_SUGGESTION_SYSTEM_PROMPT,
@@ -28,6 +35,8 @@ use crate::{
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct NpcChatTurnRequest {
#[serde(default)]
session_id: Option<String>,
#[serde(default)]
world_type: String,
#[serde(default)]
@@ -53,14 +62,25 @@ pub struct NpcChatTurnRequest {
#[serde(default)]
npc_initiates_conversation: bool,
#[serde(default)]
quest_offer_context: Option<Value>,
#[serde(default)]
chat_directive: Option<Value>,
}
pub async fn stream_runtime_npc_chat_turn(
State(state): State<AppState>,
Extension(request_context): Extension<RequestContext>,
Json(payload): Json<NpcChatTurnRequest>,
Extension(authenticated): Extension<AuthenticatedAccessToken>,
Json(mut payload): Json<NpcChatTurnRequest>,
) -> Result<Response, Response> {
hydrate_npc_chat_turn_request_from_session(
&state,
&request_context,
authenticated.claims().user_id().to_string(),
&mut payload,
)
.await?;
let npc_name = read_string_field(&payload.encounter, "npcName")
.or_else(|| read_string_field(&payload.encounter, "name"))
.unwrap_or_else(|| "对方".to_string());
@@ -258,6 +278,112 @@ where
Some((npc_reply, suggestions, function_suggestions, force_exit))
}
async fn hydrate_npc_chat_turn_request_from_session(
state: &AppState,
request_context: &RequestContext,
user_id: String,
payload: &mut NpcChatTurnRequest,
) -> Result<(), Response> {
let Some(session_id) = payload
.session_id
.as_deref()
.and_then(normalize_required_string)
else {
// 中文注释:旧调用没有 sessionId 时继续使用请求体字段;正式运行态由后端快照投影上下文。
return Ok(());
};
let record = state
.get_runtime_snapshot_record(user_id)
.await
.map_err(|error| {
runtime_chat_error_response(
request_context,
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": "spacetimedb",
"message": error.to_string(),
})),
)
})?
.ok_or_else(|| {
runtime_chat_error_response(
request_context,
AppError::from_status(StatusCode::CONFLICT).with_details(json!({
"provider": "runtime-chat",
"message": "运行时快照不存在,请先初始化并保存一次游戏",
})),
)
})?;
let game_state = record.game_state;
let snapshot_session_id =
read_runtime_session_id(&game_state).unwrap_or_else(|| session_id.clone());
if snapshot_session_id != session_id {
return Err(runtime_chat_error_response(
request_context,
AppError::from_status(StatusCode::CONFLICT).with_details(json!({
"provider": "runtime-chat",
"message": "请求的运行时会话与服务端快照不一致,请重新进入游戏",
"sessionId": session_id,
"snapshotSessionId": snapshot_session_id,
})),
));
}
payload.world_type = current_world_type(&game_state).unwrap_or_default();
payload.character = read_field(&game_state, "playerCharacter").cloned();
payload.player = payload.character.clone();
payload.encounter = read_field(&game_state, "currentEncounter")
.cloned()
.unwrap_or_else(|| payload.encounter.clone());
payload.monsters = read_array_field(&game_state, "sceneHostileNpcs")
.into_iter()
.cloned()
.collect();
payload.history = read_array_field(&game_state, "storyHistory")
.into_iter()
.rev()
.take(12)
.collect::<Vec<_>>()
.into_iter()
.rev()
.cloned()
.collect();
payload.context = build_runtime_story_prompt_context(
&game_state,
RuntimeStoryPromptContextExtras {
last_function_id: Some("npc_chat".to_string()),
..RuntimeStoryPromptContextExtras::default()
},
);
payload.npc_state =
resolve_current_request_npc_state(&game_state).unwrap_or_else(|| payload.npc_state.clone());
if let Some(quest_context) = payload.quest_offer_context.as_mut() {
if let Some(object) = quest_context.as_object_mut() {
object.insert("state".to_string(), game_state);
}
}
Ok(())
}
fn resolve_current_request_npc_state(game_state: &Value) -> Option<Value> {
let encounter = read_object_field(game_state, "currentEncounter")?;
let npc_name = read_optional_string_field(encounter, "npcName")
.or_else(|| read_optional_string_field(encounter, "name"))
.unwrap_or_else(|| "当前遭遇".to_string());
let npc_id = read_optional_string_field(encounter, "id").unwrap_or_else(|| npc_name.clone());
let state = read_object_field(game_state, "npcStates").and_then(|states| {
states
.get(npc_id.as_str())
.or_else(|| states.get(npc_name.as_str()))
})?;
Some(json!({
"affinity": read_i32_field(state, "affinity").unwrap_or(0),
"chattedCount": read_i32_field(state, "chattedCount").unwrap_or(0),
"recruited": state.get("recruited").and_then(Value::as_bool).unwrap_or(false),
}))
}
fn build_completion_directive(chat_directive: Option<&Value>, force_exit: bool) -> Value {
let Some(directive) = chat_directive else {
return Value::Null;

View File

@@ -0,0 +1,615 @@
use axum::{
Json,
extract::{Extension, State},
http::StatusCode,
response::{
IntoResponse, Response,
sse::{Event, Sse},
},
};
use platform_llm::{LlmMessage, LlmTextRequest};
use serde::Deserialize;
use serde_json::{Value, json};
use std::convert::Infallible;
use crate::{
api_response::json_success_body, auth::AuthenticatedAccessToken, http_error::AppError,
prompt::runtime_chat::*, request_context::RequestContext, state::AppState,
};
use module_runtime_story_compat::{
RuntimeStoryPromptContextExtras, build_runtime_story_prompt_context, current_world_type,
normalize_required_string, read_array_field, read_field, read_runtime_session_id,
};
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct RuntimeCharacterChatRequest {
#[serde(default)]
session_id: Option<String>,
#[serde(default)]
world_type: String,
#[serde(default)]
player_character: Value,
#[serde(default)]
target_character: Value,
#[serde(default)]
story_history: Vec<Value>,
#[serde(default)]
context: Value,
#[serde(default)]
conversation_history: Vec<Value>,
#[serde(default)]
conversation_summary: String,
#[serde(default)]
previous_summary: String,
#[serde(default)]
player_message: String,
#[serde(default)]
target_status: Value,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct RuntimeNpcDialogueRequest {
#[serde(default)]
session_id: Option<String>,
#[serde(default)]
world_type: String,
#[serde(default)]
character: Value,
#[serde(default)]
encounter: Value,
#[serde(default)]
monsters: Vec<Value>,
#[serde(default)]
history: Vec<Value>,
#[serde(default)]
context: Value,
#[serde(default)]
topic: String,
#[serde(default)]
result_summary: String,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct RuntimeNpcRecruitDialogueRequest {
#[serde(default)]
session_id: Option<String>,
#[serde(default)]
world_type: String,
#[serde(default)]
character: Value,
#[serde(default)]
encounter: Value,
#[serde(default)]
monsters: Vec<Value>,
#[serde(default)]
history: Vec<Value>,
#[serde(default)]
context: Value,
#[serde(default)]
invitation_text: String,
#[serde(default)]
recruit_summary: String,
}
pub async fn generate_runtime_character_chat_suggestions(
State(state): State<AppState>,
Extension(request_context): Extension<RequestContext>,
Extension(authenticated): Extension<AuthenticatedAccessToken>,
Json(mut payload): Json<RuntimeCharacterChatRequest>,
) -> Result<Json<Value>, Response> {
hydrate_character_chat_request_from_session(
&state,
&request_context,
authenticated.claims().user_id().to_string(),
&mut payload,
)
.await?;
let text = request_runtime_plain_text(
&state,
build_character_chat_suggestions_system_prompt(),
build_character_chat_suggestions_user_prompt(CharacterChatPromptParams {
world_type: payload.world_type.as_str(),
player_character: &payload.player_character,
target_character: &payload.target_character,
story_history: &payload.story_history,
context: &payload.context,
conversation_history: &payload.conversation_history,
conversation_summary: payload.conversation_summary.as_str(),
previous_summary: payload.previous_summary.as_str(),
player_message: payload.player_message.as_str(),
target_status: &payload.target_status,
}),
Some(build_character_chat_suggestions_fallback(
&payload.target_character,
)),
)
.await;
Ok(json_success_body(
Some(&request_context),
json!({ "text": text }),
))
}
pub async fn generate_runtime_character_chat_summary(
State(state): State<AppState>,
Extension(request_context): Extension<RequestContext>,
Extension(authenticated): Extension<AuthenticatedAccessToken>,
Json(mut payload): Json<RuntimeCharacterChatRequest>,
) -> Result<Json<Value>, Response> {
hydrate_character_chat_request_from_session(
&state,
&request_context,
authenticated.claims().user_id().to_string(),
&mut payload,
)
.await?;
let text = request_runtime_plain_text(
&state,
build_character_chat_summary_system_prompt(),
build_character_chat_summary_user_prompt(CharacterChatPromptParams {
world_type: payload.world_type.as_str(),
player_character: &payload.player_character,
target_character: &payload.target_character,
story_history: &payload.story_history,
context: &payload.context,
conversation_history: &payload.conversation_history,
conversation_summary: payload.conversation_summary.as_str(),
previous_summary: payload.previous_summary.as_str(),
player_message: payload.player_message.as_str(),
target_status: &payload.target_status,
}),
Some(build_character_chat_summary_fallback(
&payload.target_character,
&payload.conversation_history,
payload.previous_summary.as_str(),
)),
)
.await;
Ok(json_success_body(
Some(&request_context),
json!({ "text": text }),
))
}
pub async fn stream_runtime_character_chat_reply(
State(state): State<AppState>,
Extension(request_context): Extension<RequestContext>,
Extension(authenticated): Extension<AuthenticatedAccessToken>,
Json(mut payload): Json<RuntimeCharacterChatRequest>,
) -> Result<Response, Response> {
hydrate_character_chat_request_from_session(
&state,
&request_context,
authenticated.claims().user_id().to_string(),
&mut payload,
)
.await?;
let player_message = payload.player_message.trim().to_string();
if player_message.is_empty() {
return Err(runtime_plain_chat_error_response(
&request_context,
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": "runtime-chat",
"message": "playerMessage 不能为空",
})),
));
}
let stream = stream_plain_text_response(
state.llm_client().cloned(),
state.config.rpg_llm_web_search_enabled,
build_character_chat_reply_system_prompt(),
build_character_chat_reply_user_prompt(CharacterChatPromptParams {
world_type: payload.world_type.as_str(),
player_character: &payload.player_character,
target_character: &payload.target_character,
story_history: &payload.story_history,
context: &payload.context,
conversation_history: &payload.conversation_history,
conversation_summary: payload.conversation_summary.as_str(),
previous_summary: payload.previous_summary.as_str(),
player_message: payload.player_message.as_str(),
target_status: &payload.target_status,
}),
build_character_chat_reply_fallback(
&payload.target_character,
payload.player_message.as_str(),
payload.conversation_summary.as_str(),
),
);
Ok(Sse::new(stream).into_response())
}
pub async fn stream_runtime_npc_chat_dialogue(
State(state): State<AppState>,
Extension(request_context): Extension<RequestContext>,
Extension(authenticated): Extension<AuthenticatedAccessToken>,
Json(mut payload): Json<RuntimeNpcDialogueRequest>,
) -> Result<Response, Response> {
hydrate_npc_dialogue_request_from_session(
&state,
&request_context,
authenticated.claims().user_id().to_string(),
&mut payload,
)
.await?;
let topic = payload.topic.trim().to_string();
if topic.is_empty() {
return Err(runtime_plain_chat_error_response(
&request_context,
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": "runtime-chat",
"message": "topic 不能为空",
})),
));
}
let stream = stream_plain_text_response(
state.llm_client().cloned(),
state.config.rpg_llm_web_search_enabled,
runtime_npc_dialogue_system_prompt(),
{
let npc_name = read_name_field(&payload.encounter, "npcName")
.or_else(|| read_name_field(&payload.encounter, "name"))
.unwrap_or_else(|| "对方".to_string());
build_runtime_npc_dialogue_user_prompt(
npc_name.as_str(),
RuntimeNpcDialoguePromptParams {
world_type: payload.world_type.as_str(),
character: &payload.character,
encounter: &payload.encounter,
monsters: payload.monsters.clone(),
history: payload.history.clone(),
context: payload.context.clone(),
topic: payload.topic.as_str(),
result_summary: payload.result_summary.as_str(),
requested_option: Value::Null,
available_options: Vec::new(),
},
)
},
build_npc_chat_dialogue_fallback(&payload.encounter, payload.topic.as_str()),
);
Ok(Sse::new(stream).into_response())
}
pub async fn stream_runtime_npc_recruit_dialogue(
State(state): State<AppState>,
Extension(request_context): Extension<RequestContext>,
Extension(authenticated): Extension<AuthenticatedAccessToken>,
Json(mut payload): Json<RuntimeNpcRecruitDialogueRequest>,
) -> Result<Response, Response> {
hydrate_npc_recruit_request_from_session(
&state,
&request_context,
authenticated.claims().user_id().to_string(),
&mut payload,
)
.await?;
let invitation_text = payload.invitation_text.trim().to_string();
if invitation_text.is_empty() {
return Err(runtime_plain_chat_error_response(
&request_context,
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": "runtime-chat",
"message": "invitationText 不能为空",
})),
));
}
let npc_name = read_name_field(&payload.encounter, "npcName")
.or_else(|| read_name_field(&payload.encounter, "name"))
.unwrap_or_else(|| "对方".to_string());
let stream = stream_plain_text_response(
state.llm_client().cloned(),
state.config.rpg_llm_web_search_enabled,
build_npc_recruit_dialogue_system_prompt(),
build_npc_recruit_dialogue_user_prompt(
npc_name.as_str(),
NpcRecruitDialoguePromptParams {
world_type: payload.world_type.as_str(),
character: &payload.character,
encounter: &payload.encounter,
monsters: &payload.monsters,
history: &payload.history,
context: &payload.context,
invitation_text: payload.invitation_text.as_str(),
recruit_summary: payload.recruit_summary.as_str(),
},
),
build_npc_recruit_dialogue_fallback(&payload.encounter),
);
Ok(Sse::new(stream).into_response())
}
async fn hydrate_character_chat_request_from_session(
state: &AppState,
request_context: &RequestContext,
user_id: String,
payload: &mut RuntimeCharacterChatRequest,
) -> Result<(), Response> {
let Some(game_state) = resolve_runtime_chat_game_state(
state,
request_context,
user_id,
payload.session_id.as_deref(),
)
.await?
else {
return Ok(());
};
payload.world_type = current_world_type(&game_state).unwrap_or_default();
payload.player_character = read_field(&game_state, "playerCharacter")
.cloned()
.unwrap_or(Value::Null);
payload.story_history = read_array_field(&game_state, "storyHistory")
.into_iter()
.rev()
.take(12)
.collect::<Vec<_>>()
.into_iter()
.rev()
.cloned()
.collect();
payload.context =
build_runtime_story_prompt_context(&game_state, RuntimeStoryPromptContextExtras::default());
Ok(())
}
async fn hydrate_npc_dialogue_request_from_session(
state: &AppState,
request_context: &RequestContext,
user_id: String,
payload: &mut RuntimeNpcDialogueRequest,
) -> Result<(), Response> {
let Some(game_state) = resolve_runtime_chat_game_state(
state,
request_context,
user_id,
payload.session_id.as_deref(),
)
.await?
else {
return Ok(());
};
payload.world_type = current_world_type(&game_state).unwrap_or_default();
payload.character = read_field(&game_state, "playerCharacter")
.cloned()
.unwrap_or(Value::Null);
payload.encounter = read_field(&game_state, "currentEncounter")
.cloned()
.unwrap_or_else(|| payload.encounter.clone());
payload.monsters = read_array_field(&game_state, "sceneHostileNpcs")
.into_iter()
.cloned()
.collect();
payload.history = read_array_field(&game_state, "storyHistory")
.into_iter()
.rev()
.take(12)
.collect::<Vec<_>>()
.into_iter()
.rev()
.cloned()
.collect();
payload.context = build_runtime_story_prompt_context(
&game_state,
RuntimeStoryPromptContextExtras {
last_function_id: Some("npc_chat".to_string()),
..RuntimeStoryPromptContextExtras::default()
},
);
Ok(())
}
async fn hydrate_npc_recruit_request_from_session(
state: &AppState,
request_context: &RequestContext,
user_id: String,
payload: &mut RuntimeNpcRecruitDialogueRequest,
) -> Result<(), Response> {
let Some(game_state) = resolve_runtime_chat_game_state(
state,
request_context,
user_id,
payload.session_id.as_deref(),
)
.await?
else {
return Ok(());
};
payload.world_type = current_world_type(&game_state).unwrap_or_default();
payload.character = read_field(&game_state, "playerCharacter")
.cloned()
.unwrap_or(Value::Null);
payload.encounter = read_field(&game_state, "currentEncounter")
.cloned()
.unwrap_or_else(|| payload.encounter.clone());
payload.monsters = read_array_field(&game_state, "sceneHostileNpcs")
.into_iter()
.cloned()
.collect();
payload.history = read_array_field(&game_state, "storyHistory")
.into_iter()
.rev()
.take(12)
.collect::<Vec<_>>()
.into_iter()
.rev()
.cloned()
.collect();
payload.context = build_runtime_story_prompt_context(
&game_state,
RuntimeStoryPromptContextExtras {
last_function_id: Some("npc_recruit".to_string()),
..RuntimeStoryPromptContextExtras::default()
},
);
Ok(())
}
async fn resolve_runtime_chat_game_state(
state: &AppState,
request_context: &RequestContext,
user_id: String,
session_id: Option<&str>,
) -> Result<Option<Value>, Response> {
let Some(session_id) = session_id.and_then(normalize_required_string) else {
// 中文注释:未携带 sessionId 的旧调用仅保留兼容,后续正式运行态应全部走后端快照。
return Ok(None);
};
let record = state
.get_runtime_snapshot_record(user_id)
.await
.map_err(|error| {
runtime_plain_chat_error_response(
request_context,
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": "spacetimedb",
"message": error.to_string(),
})),
)
})?
.ok_or_else(|| {
runtime_plain_chat_error_response(
request_context,
AppError::from_status(StatusCode::CONFLICT).with_details(json!({
"provider": "runtime-chat",
"message": "运行时快照不存在,请先初始化并保存一次游戏",
})),
)
})?;
let game_state = record.game_state;
let snapshot_session_id =
read_runtime_session_id(&game_state).unwrap_or_else(|| session_id.clone());
if snapshot_session_id != session_id {
return Err(runtime_plain_chat_error_response(
request_context,
AppError::from_status(StatusCode::CONFLICT).with_details(json!({
"provider": "runtime-chat",
"message": "请求的运行时会话与服务端快照不一致,请重新进入游戏",
"sessionId": session_id,
"snapshotSessionId": snapshot_session_id,
})),
));
}
Ok(Some(game_state))
}
async fn request_runtime_plain_text(
state: &AppState,
system_prompt: &'static str,
user_prompt: String,
fallback_text: Option<String>,
) -> String {
let Some(llm_client) = state.llm_client() else {
return fallback_text.unwrap_or_default();
};
let mut request = LlmTextRequest::new(vec![
LlmMessage::system(system_prompt),
LlmMessage::user(user_prompt),
]);
request.max_tokens = Some(400);
request.enable_web_search = state.config.rpg_llm_web_search_enabled;
llm_client
.request_text(request)
.await
.ok()
.map(|response| response.content.trim().to_string())
.filter(|text| !text.is_empty())
.or(fallback_text)
.unwrap_or_default()
}
fn stream_plain_text_response<'a>(
llm_client: Option<platform_llm::LlmClient>,
enable_web_search: bool,
system_prompt: &'static str,
user_prompt: String,
fallback_text: String,
) -> impl tokio_stream::Stream<Item = Result<Event, Infallible>> {
async_stream::stream! {
let Some(llm_client) = llm_client else {
yield Ok::<Event, Infallible>(Event::default().data(runtime_plain_text_sse_payload(fallback_text.as_str())));
yield Ok::<Event, Infallible>(Event::default().data("[DONE]"));
return;
};
let mut request = LlmTextRequest::new(vec![
LlmMessage::system(system_prompt),
LlmMessage::user(user_prompt),
]);
request.max_tokens = Some(700);
request.enable_web_search = enable_web_search;
let response = llm_client
.stream_text(request, |_| {})
.await;
match response {
Ok(response) => {
let final_text = response.content.trim();
let output = if final_text.is_empty() {
fallback_text.as_str()
} else {
final_text
};
yield Ok::<Event, Infallible>(Event::default().data(runtime_plain_text_sse_payload(output)));
}
Err(_) => {
yield Ok::<Event, Infallible>(Event::default().data(runtime_plain_text_sse_payload(fallback_text.as_str())));
}
}
yield Ok::<Event, Infallible>(Event::default().data("[DONE]"));
}
}
fn runtime_plain_text_sse_payload(text: &str) -> String {
json!({
"choices": [{
"delta": {
"content": text,
}
}]
})
.to_string()
}
fn runtime_plain_chat_error_response(
request_context: &RequestContext,
error: AppError,
) -> Response {
error.into_response_with_context(Some(request_context))
}
fn read_name_field(value: &Value, field: &str) -> Option<String> {
value
.get(field)
.and_then(Value::as_str)
.map(str::trim)
.filter(|text| !text.is_empty())
.map(ToOwned::to_owned)
}

View File

@@ -4,12 +4,12 @@ use axum::{
http::StatusCode,
response::Response,
};
use module_runtime::{SAVE_SNAPSHOT_VERSION, format_utc_micros};
use module_runtime::format_utc_micros;
use serde::Deserialize;
use serde_json::{Value, json};
use shared_contracts::runtime::{
BasicOkResponse, ProfileSaveArchiveListResponse, ProfileSaveArchiveResumeResponse,
ProfileSaveArchiveSummaryResponse, PutSavedGameSnapshotRequest, SavedGameSnapshotResponse,
ProfileSaveArchiveSummaryResponse, PutRuntimeSaveCheckpointRequest, SavedGameSnapshotResponse,
};
use shared_kernel::{offset_datetime_to_unix_micros, parse_rfc3339};
use spacetime_client::SpacetimeClientError;
@@ -49,9 +49,29 @@ pub async fn put_runtime_snapshot(
State(state): State<AppState>,
Extension(request_context): Extension<RequestContext>,
Extension(authenticated): Extension<AuthenticatedAccessToken>,
Json(payload): Json<PutSavedGameSnapshotRequest>,
Json(payload): Json<PutRuntimeSaveCheckpointRequest>,
) -> Result<Json<Value>, Response> {
let user_id = authenticated.claims().user_id().to_string();
let session_id = normalize_required_string(payload.session_id.as_str()).ok_or_else(|| {
runtime_save_error_response(
&request_context,
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": "runtime-save",
"field": "sessionId",
"message": "sessionId 不能为空",
})),
)
})?;
let bottom_tab = normalize_required_string(payload.bottom_tab.as_str()).ok_or_else(|| {
runtime_save_error_response(
&request_context,
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": "runtime-save",
"field": "bottomTab",
"message": "bottomTab 不能为空",
})),
)
})?;
let now = OffsetDateTime::now_utc();
let saved_at = payload
.saved_at
@@ -71,30 +91,37 @@ pub async fn put_runtime_snapshot(
let updated_at_micros = offset_datetime_to_unix_micros(now);
let saved_at_micros = offset_datetime_to_unix_micros(saved_at);
let record = if is_non_persistent_runtime_snapshot(&payload.game_state) {
build_transient_runtime_snapshot_record(
let existing = state
.get_runtime_snapshot_record(user_id.clone())
.await
.map_err(|error| {
runtime_save_error_response(&request_context, map_runtime_save_client_error(error))
})?
.ok_or_else(|| {
runtime_save_error_response(
&request_context,
AppError::from_status(StatusCode::CONFLICT).with_details(json!({
"provider": "runtime-save",
"message": "运行时快照不存在,无法创建后端 checkpoint",
})),
)
})?;
validate_checkpoint_snapshot(&request_context, &session_id, &existing.game_state)?;
let game_state = sync_runtime_snapshot_play_time(existing.game_state, updated_at_micros);
let record = state
.put_runtime_snapshot_record(
user_id,
saved_at_micros,
payload.bottom_tab,
payload.game_state,
payload.current_story,
bottom_tab,
game_state,
existing.current_story,
updated_at_micros,
)
} else {
state
.put_runtime_snapshot_record(
user_id,
saved_at_micros,
payload.bottom_tab,
payload.game_state,
payload.current_story,
updated_at_micros,
)
.await
.map_err(|error| {
runtime_save_error_response(&request_context, map_runtime_save_client_error(error))
})?
};
.await
.map_err(|error| {
runtime_save_error_response(&request_context, map_runtime_save_client_error(error))
})?;
Ok(json_success_body(
Some(&request_context),
@@ -196,30 +223,6 @@ fn build_saved_game_snapshot_response(
}
}
fn build_transient_runtime_snapshot_record(
user_id: String,
saved_at_micros: i64,
bottom_tab: String,
game_state: Value,
current_story: Option<Value>,
updated_at_micros: i64,
) -> module_runtime::RuntimeSnapshotRecord {
// 中文注释:预览/测试入口可得到本次响应,但不能覆盖用户正式当前快照。
module_runtime::RuntimeSnapshotRecord {
user_id,
version: SAVE_SNAPSHOT_VERSION,
saved_at: format_utc_micros(saved_at_micros),
saved_at_micros,
bottom_tab,
game_state_json: game_state.to_string(),
current_story_json: current_story.as_ref().map(Value::to_string),
game_state,
current_story,
created_at_micros: updated_at_micros,
updated_at_micros,
}
}
fn is_non_persistent_runtime_snapshot(game_state: &Value) -> bool {
let Some(game_state) = game_state.as_object() else {
return false;
@@ -242,6 +245,110 @@ fn is_non_persistent_runtime_snapshot(game_state: &Value) -> bool {
)
}
fn validate_checkpoint_snapshot(
request_context: &RequestContext,
session_id: &str,
game_state: &Value,
) -> Result<(), Response> {
if is_non_persistent_runtime_snapshot(game_state) {
return Err(runtime_save_error_response(
request_context,
AppError::from_status(StatusCode::CONFLICT).with_details(json!({
"provider": "runtime-save",
"message": "预览或测试运行态不能创建正式 checkpoint",
})),
));
}
let persisted_session_id =
read_string_field(game_state, "runtimeSessionId").ok_or_else(|| {
runtime_save_error_response(
request_context,
AppError::from_status(StatusCode::CONFLICT).with_details(json!({
"provider": "runtime-save",
"message": "服务端运行时快照缺少 runtimeSessionId无法创建 checkpoint",
})),
)
})?;
if persisted_session_id != session_id {
return Err(runtime_save_error_response(
request_context,
AppError::from_status(StatusCode::CONFLICT).with_details(json!({
"provider": "runtime-save",
"message": "checkpoint sessionId 与服务端运行时快照不一致",
"expectedSessionId": persisted_session_id,
"actualSessionId": session_id,
})),
));
}
Ok(())
}
fn sync_runtime_snapshot_play_time(mut game_state: Value, now_micros: i64) -> Value {
let Some(game_state_object) = game_state.as_object_mut() else {
return game_state;
};
let now_text = format_utc_micros(now_micros);
let Some(runtime_stats) = game_state_object
.get_mut("runtimeStats")
.and_then(Value::as_object_mut)
else {
game_state_object.insert(
"runtimeStats".to_string(),
json!({
"playTimeMs": 0,
"lastPlayTickAt": now_text,
"hostileNpcsDefeated": 0,
"questsAccepted": 0,
"itemsUsed": 0,
"scenesTraveled": 0,
}),
);
return game_state;
};
let current_play_time = runtime_stats
.get("playTimeMs")
.and_then(Value::as_f64)
.filter(|value| value.is_finite() && *value >= 0.0)
.unwrap_or(0.0);
let elapsed_ms = runtime_stats
.get("lastPlayTickAt")
.and_then(Value::as_str)
.and_then(|last_tick| parse_rfc3339(last_tick).ok())
.map(offset_datetime_to_unix_micros)
.map(|last_tick_micros| now_micros.saturating_sub(last_tick_micros).max(0) as f64 / 1000.0)
.unwrap_or(0.0);
let next_play_time = (current_play_time + elapsed_ms).floor().max(0.0);
// 中文注释checkpoint 只刷新服务端已有 runtimeStats 的时间水位,
// 不从浏览器接收任何任务、背包、战斗或剧情状态。
runtime_stats.insert("playTimeMs".to_string(), Value::from(next_play_time as i64));
runtime_stats.insert("lastPlayTickAt".to_string(), Value::String(now_text));
game_state
}
fn read_string_field(value: &Value, field: &str) -> Option<String> {
value
.as_object()?
.get(field)?
.as_str()
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned)
}
fn normalize_required_string(value: &str) -> Option<String> {
let normalized = value.trim();
if normalized.is_empty() {
None
} else {
Some(normalized.to_string())
}
}
fn build_profile_save_archive_summary_response(
record: &module_runtime::RuntimeProfileSaveArchiveRecord,
) -> ProfileSaveArchiveSummaryResponse {
@@ -302,7 +409,7 @@ mod tests {
use platform_auth::{
AccessTokenClaims, AccessTokenClaimsInput, AuthProvider, BindingStatus, sign_access_token,
};
use serde_json::Value;
use serde_json::{Value, json};
use time::OffsetDateTime;
use tower::ServiceExt;
@@ -325,6 +432,151 @@ mod tests {
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
}
#[tokio::test]
async fn runtime_snapshot_checkpoint_rejects_legacy_full_snapshot_upload() {
let state = seed_authenticated_state().await;
let token = issue_access_token(&state);
let app = build_router(state);
let response = app
.oneshot(
Request::builder()
.method("PUT")
.uri("/api/runtime/save/snapshot")
.header("authorization", format!("Bearer {token}"))
.header("content-type", "application/json")
.body(Body::from(
json!({
"sessionId": "runtime-main",
"bottomTab": "adventure",
"gameState": {
"runtimeSessionId": "runtime-main"
},
"currentStory": null
})
.to_string(),
))
.expect("request should build"),
)
.await
.expect("request should succeed");
assert_eq!(response.status(), StatusCode::UNPROCESSABLE_ENTITY);
}
#[tokio::test]
async fn runtime_snapshot_checkpoint_requires_existing_server_snapshot() {
let state = seed_authenticated_state().await;
let token = issue_access_token(&state);
let app = build_router(state);
let response = app
.oneshot(
Request::builder()
.method("PUT")
.uri("/api/runtime/save/snapshot")
.header("authorization", format!("Bearer {token}"))
.header("content-type", "application/json")
.body(Body::from(
json!({
"sessionId": "runtime-main",
"bottomTab": "adventure"
})
.to_string(),
))
.expect("request should build"),
)
.await
.expect("request should succeed");
assert_eq!(response.status(), StatusCode::CONFLICT);
}
#[tokio::test]
async fn runtime_snapshot_checkpoint_rejects_session_mismatch() {
let state = seed_authenticated_state().await;
seed_runtime_snapshot(&state, "runtime-server", "adventure").await;
let token = issue_access_token(&state);
let app = build_router(state);
let response = app
.oneshot(
Request::builder()
.method("PUT")
.uri("/api/runtime/save/snapshot")
.header("authorization", format!("Bearer {token}"))
.header("content-type", "application/json")
.body(Body::from(
json!({
"sessionId": "runtime-client",
"bottomTab": "inventory"
})
.to_string(),
))
.expect("request should build"),
)
.await
.expect("request should succeed");
assert_eq!(response.status(), StatusCode::CONFLICT);
}
#[tokio::test]
async fn runtime_snapshot_checkpoint_uses_persisted_server_snapshot() {
let state = seed_authenticated_state().await;
seed_runtime_snapshot(&state, "runtime-main", "adventure").await;
let token = issue_access_token(&state);
let app = build_router(state);
let response = app
.oneshot(
Request::builder()
.method("PUT")
.uri("/api/runtime/save/snapshot")
.header("authorization", format!("Bearer {token}"))
.header("x-genarrative-response-envelope", "v1")
.header("content-type", "application/json")
.body(Body::from(
json!({
"sessionId": "runtime-main",
"bottomTab": "inventory"
})
.to_string(),
))
.expect("request should build"),
)
.await
.expect("request should succeed");
assert_eq!(response.status(), StatusCode::OK);
let payload: Value = serde_json::from_slice(
&response
.into_body()
.collect()
.await
.expect("body should collect")
.to_bytes(),
)
.expect("response body should be valid json");
assert_eq!(payload["data"]["bottomTab"], json!("inventory"));
assert_eq!(
payload["data"]["gameState"]["runtimeSessionId"],
json!("runtime-main")
);
assert_eq!(
payload["data"]["gameState"]["serverOnlyField"],
json!("persisted")
);
assert_eq!(payload["data"]["currentStory"]["text"], json!("服务端故事"));
assert!(
payload["data"]["gameState"]["runtimeStats"]["playTimeMs"]
.as_i64()
.unwrap_or_default()
>= 2000
);
}
#[tokio::test]
async fn profile_save_archives_requires_authentication() {
let app = build_router(AppState::new(AppConfig::default()).expect("state should build"));
@@ -444,6 +696,39 @@ mod tests {
state
}
async fn seed_runtime_snapshot(state: &AppState, session_id: &str, bottom_tab: &str) {
let now = OffsetDateTime::now_utc();
let now_micros = shared_kernel::offset_datetime_to_unix_micros(now);
state
.put_runtime_snapshot_record(
"user_00000001".to_string(),
now_micros - 2_000_000,
bottom_tab.to_string(),
json!({
"runtimeSessionId": session_id,
"runtimeMode": "play",
"runtimePersistenceDisabled": false,
"currentScene": "Story",
"serverOnlyField": "persisted",
"runtimeStats": {
"playTimeMs": 0,
"lastPlayTickAt": module_runtime::format_utc_micros(now_micros - 2_000_000),
"hostileNpcsDefeated": 0,
"questsAccepted": 0,
"itemsUsed": 0,
"scenesTraveled": 0
}
}),
Some(json!({
"text": "服务端故事",
"options": []
})),
now_micros - 2_000_000,
)
.await
.expect("runtime snapshot should seed");
}
fn issue_access_token(state: &AppState) -> String {
let claims = AccessTokenClaims::from_input(
AccessTokenClaimsInput {

View File

@@ -1,6 +1,6 @@
mod compat;
pub use compat::{
generate_runtime_story_continue, generate_runtime_story_initial, get_runtime_story_state,
resolve_runtime_story_action, resolve_runtime_story_state,
begin_runtime_story_session, generate_runtime_story_continue, generate_runtime_story_initial,
get_runtime_story_state, resolve_runtime_story_action, resolve_runtime_story_state,
};

View File

@@ -11,35 +11,38 @@ use module_npc::{
use module_runtime::{RuntimeSnapshotRecord, SAVE_SNAPSHOT_VERSION, format_utc_micros};
use module_runtime_story_compat::{
CONTINUE_ADVENTURE_FUNCTION_ID, CurrentEncounterNpcQuestContext, GeneratedStoryPayload,
PendingQuestOfferContext, RuntimeStoryActionResponseParts, StoryResolution,
add_player_currency, add_player_inventory_items, append_story_history,
PendingQuestOfferContext, RuntimeStoryActionResponseParts, RuntimeStoryPromptContextExtras,
StoryResolution, add_player_currency, add_player_inventory_items, append_story_history,
apply_equipment_loadout_to_state, battle_mode_text, build_battle_runtime_story_options,
build_current_build_toast, build_disabled_runtime_story_option, build_npc_gift_result_text,
build_runtime_story_option_from_story_option, build_runtime_story_view_model,
build_static_runtime_story_option, build_status_patch, build_story_option_from_runtime_option,
clear_encounter_only, clear_encounter_state, clone_inventory_item_with_quantity,
current_encounter_id, current_encounter_name, current_world_type,
ensure_inventory_action_available, ensure_json_object, equipment_slot_label,
find_player_inventory_entry, format_currency_text, format_now_rfc3339,
grant_player_progression_experience, has_giftable_player_inventory, increment_runtime_stat,
normalize_equipment_slot_id, normalize_equipped_item, normalize_required_string,
npc_buyback_price, npc_purchase_price, read_array_field, read_bool_field, read_field,
build_runtime_story_option_from_story_option, build_runtime_story_prompt_context,
build_runtime_story_view_model, build_static_runtime_story_option, build_status_patch,
build_story_option_from_runtime_option, clear_encounter_only, clear_encounter_state,
clone_inventory_item_with_quantity, current_encounter_id, current_encounter_name,
current_world_type, ensure_inventory_action_available, ensure_json_object,
equipment_slot_label, finalize_post_battle_resolution, find_player_inventory_entry,
format_currency_text, format_now_rfc3339, grant_player_progression_experience,
has_giftable_player_inventory, increment_runtime_stat, normalize_equipment_slot_id,
normalize_equipped_item, normalize_required_string, npc_buyback_price, npc_purchase_price,
project_story_engine_after_action, read_array_field, read_bool_field, read_field,
read_i32_field, read_inventory_item_name, read_object_field, read_optional_string_field,
read_player_equipment_item, read_required_string_field, read_runtime_session_id,
read_u32_field, recruit_companion_to_party, remove_player_inventory_item, resolve_action_text,
resolve_battle_action, resolve_current_encounter_npc_state, resolve_equipment_slot_for_item,
resolve_forge_craft_action, resolve_forge_dismantle_action, resolve_forge_reforge_action,
resolve_npc_gift_affinity_gain, restore_player_resource, simple_story_resolution,
trade_quantity_suffix, write_bool_field, write_i32_field, write_null_field,
write_player_equipment_item, write_string_field, write_u32_field,
resolve_npc_gift_affinity_gain, resolve_post_battle_story_options, restore_player_resource,
simple_story_resolution, trade_quantity_suffix, write_bool_field, write_i32_field,
write_null_field, write_player_equipment_item, write_runtime_npc_interaction_view,
write_string_field, write_u32_field,
};
use platform_llm::{LlmClient, LlmMessage, LlmTextRequest};
use serde_json::{Map, Value, json};
use shared_contracts::runtime_story::{
RuntimeBattlePresentation, RuntimeStoryActionRequest, RuntimeStoryActionResponse,
RuntimeStoryAiRequest, RuntimeStoryAiResponse, RuntimeStoryOptionInteraction,
RuntimeStoryOptionView, RuntimeStoryPatch, RuntimeStoryPresentation,
RuntimeStorySnapshotPayload, RuntimeStoryStateResolveRequest,
RuntimeStoryAiRequest, RuntimeStoryAiResponse, RuntimeStoryBootstrapRequest,
RuntimeStoryBootstrapResponse, RuntimeStoryOptionInteraction, RuntimeStoryOptionView,
RuntimeStoryPatch, RuntimeStoryPresentation, RuntimeStorySnapshotPayload,
RuntimeStoryStateResolveRequest,
};
use shared_kernel::{offset_datetime_to_unix_micros, parse_rfc3339};
use spacetime_client::SpacetimeClientError;
@@ -51,12 +54,14 @@ use crate::{
};
mod ai;
mod bootstrap;
mod equipment_actions;
mod game_state;
mod npc_actions;
mod presentation;
mod quest_actions;
pub use self::bootstrap::begin_runtime_story_session;
use self::{
ai::*, equipment_actions::*, game_state::*, npc_actions::*, presentation::*, quest_actions::*,
};
@@ -184,6 +189,7 @@ pub async fn resolve_runtime_story_action(
"运行时版本已变化,请先同步最新快照后再提交动作",
)?;
let previous_game_state = snapshot.game_state.clone();
let current_story_before = snapshot.current_story.clone();
let mut game_state = snapshot.game_state.clone();
let mut resolution = resolve_runtime_story_choice_action(
@@ -229,17 +235,26 @@ pub async fn resolve_runtime_story_action(
.saved_current_story
.take()
.unwrap_or_else(|| build_legacy_current_story(story_text.as_str(), &options));
if let Some(generated_payload) = generate_action_story_payload(
&state,
&game_state,
&payload,
&function_id,
resolution.action_text.as_str(),
resolution.result_text.as_str(),
&options,
let post_battle_finalized = finalize_runtime_story_resolution_for_response(
&mut game_state,
&mut story_text,
&mut history_result_text,
&mut options,
&mut saved_current_story,
resolution.battle.as_ref(),
)
.await
);
if !post_battle_finalized
&& let Some(generated_payload) = generate_action_story_payload(
&state,
&game_state,
&payload,
&function_id,
resolution.action_text.as_str(),
resolution.result_text.as_str(),
&options,
resolution.battle.as_ref(),
)
.await
{
story_text = generated_payload.story_text;
history_result_text = generated_payload.history_result_text;
@@ -251,6 +266,17 @@ pub async fn resolve_runtime_story_action(
resolution.action_text.as_str(),
history_result_text.as_str(),
);
project_story_engine_after_action(
&previous_game_state,
&mut game_state,
resolution.action_text.as_str(),
history_result_text.as_str(),
function_id.as_str(),
resolution
.battle
.as_ref()
.and_then(|battle| battle.outcome.as_deref()),
);
let mut patches = vec![RuntimeStoryPatch::StoryHistoryAppend {
action_text: resolution.action_text.clone(),
@@ -290,9 +316,18 @@ pub async fn resolve_runtime_story_action(
pub async fn generate_runtime_story_initial(
State(state): State<AppState>,
Extension(request_context): Extension<RequestContext>,
Extension(_authenticated): Extension<AuthenticatedAccessToken>,
Extension(authenticated): Extension<AuthenticatedAccessToken>,
Json(payload): Json<RuntimeStoryAiRequest>,
) -> Result<Json<Value>, Response> {
let payload = hydrate_runtime_story_ai_request_from_session(
&state,
&request_context,
authenticated.claims().user_id().to_string(),
payload,
true,
)
.await?;
Ok(json_success_body(
Some(&request_context),
build_runtime_story_ai_response(&state, payload, true).await,
@@ -302,15 +337,97 @@ pub async fn generate_runtime_story_initial(
pub async fn generate_runtime_story_continue(
State(state): State<AppState>,
Extension(request_context): Extension<RequestContext>,
Extension(_authenticated): Extension<AuthenticatedAccessToken>,
Extension(authenticated): Extension<AuthenticatedAccessToken>,
Json(payload): Json<RuntimeStoryAiRequest>,
) -> Result<Json<Value>, Response> {
let payload = hydrate_runtime_story_ai_request_from_session(
&state,
&request_context,
authenticated.claims().user_id().to_string(),
payload,
false,
)
.await?;
Ok(json_success_body(
Some(&request_context),
build_runtime_story_ai_response(&state, payload, false).await,
))
}
async fn hydrate_runtime_story_ai_request_from_session(
state: &AppState,
request_context: &RequestContext,
user_id: String,
mut payload: RuntimeStoryAiRequest,
initial: bool,
) -> Result<RuntimeStoryAiRequest, Response> {
let Some(session_id) = payload
.session_id
.as_deref()
.and_then(normalize_required_string)
else {
// 中文注释:旧测试或兼容入口可能仍传 worldType/character/context
// 没有 sessionId 时只保留反序列化兼容,不作为新主链。
return Ok(payload);
};
let snapshot = resolve_snapshot_for_request(state, request_context, user_id, None).await?;
validate_client_version(
request_context,
payload.client_version,
&snapshot.game_state,
"运行时版本已变化,请先同步最新快照后再生成剧情",
)?;
let snapshot_session_id =
read_runtime_session_id(&snapshot.game_state).unwrap_or_else(|| session_id.clone());
if snapshot_session_id != session_id {
return Err(runtime_story_error_response(
request_context,
AppError::from_status(StatusCode::CONFLICT).with_details(json!({
"provider": "runtime-story",
"message": "请求的运行时会话与服务端快照不一致,请重新进入游戏",
"sessionId": session_id,
"snapshotSessionId": snapshot_session_id,
})),
));
}
let extras = RuntimeStoryPromptContextExtras {
pending_scene_encounter: false,
last_function_id: payload.last_function_id.clone(),
observe_signs_requested: payload.observe_signs_requested,
recent_action_result: payload.recent_action_result.clone(),
opening_camp_background: None,
opening_camp_dialogue: None,
};
payload.world_type = current_world_type(&snapshot.game_state).unwrap_or_default();
payload.character = read_field(&snapshot.game_state, "playerCharacter")
.cloned()
.unwrap_or(Value::Null);
payload.monsters = read_array_field(&snapshot.game_state, "sceneHostileNpcs")
.into_iter()
.cloned()
.collect();
payload.history = if initial {
Vec::new()
} else {
read_array_field(&snapshot.game_state, "storyHistory")
.into_iter()
.rev()
.take(12)
.collect::<Vec<_>>()
.into_iter()
.rev()
.cloned()
.collect()
};
payload.context = build_runtime_story_prompt_context(&snapshot.game_state, extras);
Ok(payload)
}
async fn resolve_snapshot_for_request(
state: &AppState,
request_context: &RequestContext,
@@ -380,22 +497,24 @@ async fn persist_runtime_story_snapshot(
let updated_at_micros = offset_datetime_to_unix_micros(now);
if is_non_persistent_runtime_story_snapshot(&snapshot) {
let game_state = canonicalize_runtime_story_game_state_for_persistence(snapshot.game_state);
return Ok(build_transient_runtime_snapshot_record(
user_id,
saved_at_micros,
snapshot.bottom_tab,
snapshot.game_state,
game_state,
snapshot.current_story,
updated_at_micros,
));
}
let game_state = canonicalize_runtime_story_game_state_for_persistence(snapshot.game_state);
state
.put_runtime_snapshot_record(
user_id,
saved_at_micros,
snapshot.bottom_tab,
snapshot.game_state,
game_state,
snapshot.current_story,
updated_at_micros,
)
@@ -405,6 +524,39 @@ async fn persist_runtime_story_snapshot(
})
}
fn canonicalize_runtime_story_game_state_for_persistence(mut game_state: Value) -> Value {
if let Some(root) = game_state.as_object_mut() {
// 中文注释NPC 交易/赠礼 view 是响应时派生的展示层数据,不能写回正式快照真相。
root.remove("runtimeNpcInteraction");
}
game_state
}
fn finalize_runtime_story_resolution_for_response(
game_state: &mut Value,
story_text: &mut String,
history_result_text: &mut String,
options: &mut Vec<RuntimeStoryOptionView>,
saved_current_story: &mut Value,
battle: Option<&RuntimeBattlePresentation>,
) -> bool {
let battle_outcome = battle.and_then(|battle| battle.outcome.as_deref());
let post_battle_options = resolve_post_battle_story_options(game_state);
if let Some(post_battle) = finalize_post_battle_resolution(
game_state,
story_text.as_str(),
battle_outcome,
post_battle_options,
) {
*story_text = post_battle.story_text;
*history_result_text = story_text.clone();
*options = post_battle.presentation_options;
*saved_current_story = post_battle.saved_current_story;
return true;
}
false
}
fn build_transient_runtime_snapshot_record(
user_id: String,
saved_at_micros: i64,
@@ -472,10 +624,13 @@ fn validate_snapshot_payload(snapshot: &RuntimeStorySnapshotPayload) -> Result<(
fn runtime_snapshot_payload_from_record(
record: &RuntimeSnapshotRecord,
) -> RuntimeStorySnapshotPayload {
let mut game_state = record.game_state.clone();
write_runtime_npc_interaction_view(&mut game_state);
RuntimeStorySnapshotPayload {
saved_at: Some(record.saved_at.clone()),
bottom_tab: record.bottom_tab.clone(),
game_state: record.game_state.clone(),
game_state,
current_story: record.current_story.clone(),
}
}
@@ -562,23 +717,7 @@ fn resolve_runtime_story_choice_action(
"你把呼吸慢下来重新稳住节奏,生命和灵力都回上来一点。",
))
}
"idle_travel_next_scene" => {
clear_encounter_state(game_state);
increment_runtime_stat(game_state, "scenesTraveled", 1);
Ok(StoryResolution {
action_text: resolve_action_text("前往相邻场景", request),
result_text: "你收束了这一段遭遇,顺着路线把故事推进到新的场景段落。".to_string(),
story_text: None,
presentation_options: None,
saved_current_story: None,
patches: vec![
build_status_patch(game_state),
RuntimeStoryPatch::EncounterChanged { encounter_id: None },
],
battle: None,
toast: None,
})
}
"idle_travel_next_scene" => resolve_idle_travel_next_scene_action(game_state, request),
"npc_preview_talk" => resolve_npc_preview_action(game_state, request),
"npc_chat" => resolve_npc_chat_action(game_state, request),
"npc_help" => resolve_npc_help_action(game_state, request),
@@ -662,6 +801,122 @@ fn resolve_continue_adventure_action(
})
}
fn resolve_idle_travel_next_scene_action(
game_state: &mut Value,
request: &RuntimeStoryActionRequest,
) -> Result<StoryResolution, String> {
let previous_scene_name = read_object_field(game_state, "currentScenePreset")
.and_then(|scene| read_optional_string_field(scene, "name"))
.unwrap_or_else(|| "当前位置".to_string());
let target_scene = resolve_next_scene_preset(game_state);
let target_scene_name = target_scene
.as_ref()
.and_then(|scene| read_optional_string_field(scene, "name"))
.unwrap_or_else(|| "相邻场景".to_string());
if let Some(scene) = target_scene {
ensure_json_object(game_state).insert("currentScenePreset".to_string(), scene);
}
clear_encounter_state(game_state);
increment_runtime_stat(game_state, "scenesTraveled", 1);
write_i32_field(game_state, "playerX", 0);
write_i32_field(game_state, "playerOffsetY", 0);
write_string_field(game_state, "playerFacing", "right");
write_string_field(game_state, "animationState", "idle");
write_string_field(game_state, "playerActionMode", "idle");
write_bool_field(game_state, "scrollWorld", false);
write_null_field(game_state, "lastObserveSignsSceneId");
write_null_field(game_state, "lastObserveSignsReport");
write_null_field(game_state, "currentBattleNpcId");
write_null_field(game_state, "currentNpcBattleOutcome");
write_null_field(game_state, "sparReturnEncounter");
write_null_field(game_state, "sparPlayerHpBefore");
write_null_field(game_state, "sparPlayerMaxHpBefore");
write_null_field(game_state, "sparStoryHistoryBefore");
ensure_json_object(game_state).insert("activeCombatEffects".to_string(), Value::Array(vec![]));
ensure_scene_encounter_preview(game_state);
Ok(StoryResolution {
action_text: resolve_action_text(&format!("前往{target_scene_name}"), request),
result_text: format!("你离开{previous_scene_name},前往{target_scene_name}。"),
story_text: None,
presentation_options: None,
saved_current_story: None,
patches: vec![
build_status_patch(game_state),
RuntimeStoryPatch::EncounterChanged {
encounter_id: read_object_field(game_state, "currentEncounter")
.and_then(|encounter| read_optional_string_field(encounter, "id")),
},
],
battle: None,
toast: None,
})
}
fn resolve_next_scene_preset(game_state: &Value) -> Option<Value> {
let current_scene = read_object_field(game_state, "currentScenePreset")?;
let current_scene_id = read_optional_string_field(current_scene, "id");
let target_scene_id =
read_optional_string_field(current_scene, "forwardSceneId").or_else(|| {
read_array_field(current_scene, "connections")
.into_iter()
.find_map(|connection| {
read_optional_string_field(connection, "sceneId")
.filter(|scene_id| Some(scene_id) != current_scene_id.as_ref())
})
})?;
find_scene_preset_in_runtime_profile(game_state, target_scene_id.as_str()).or_else(|| {
let mut scene = json!({
"id": target_scene_id,
"name": "相邻场景",
"description": "你抵达了一处新的区域,周围的动静仍在继续变化。",
"imageSrc": "",
"connectedSceneIds": [current_scene_id.unwrap_or_else(|| "previous-scene".to_string())],
"connections": [],
"treasureHints": [],
"npcs": []
});
if let Some(world_type) = current_world_type(game_state) {
ensure_json_object(&mut scene)
.insert("worldType".to_string(), Value::String(world_type));
}
Some(scene)
})
}
fn find_scene_preset_in_runtime_profile(game_state: &Value, scene_id: &str) -> Option<Value> {
let profile = read_object_field(game_state, "customWorldProfile")?;
bootstrap::build_custom_scene_preset(
profile,
bootstrap::resolve_custom_runtime_scene_id(profile, scene_id).as_str(),
)
}
fn ensure_scene_encounter_preview(game_state: &mut Value) {
if read_bool_field(game_state, "inBattle").unwrap_or(false)
|| !read_array_field(game_state, "sceneHostileNpcs").is_empty()
|| read_object_field(game_state, "currentEncounter").is_some()
{
return;
}
let Some(scene) = read_object_field(game_state, "currentScenePreset") else {
return;
};
let Some(npc) = read_array_field(scene, "npcs").into_iter().find(|npc| {
!read_bool_field(npc, "hostile").unwrap_or(false)
&& read_optional_string_field(npc, "monsterPresetId").is_none()
}) else {
return;
};
let encounter = bootstrap::build_encounter_from_scene_npc(npc);
ensure_json_object(game_state).insert("currentEncounter".to_string(), encounter);
write_bool_field(game_state, "npcInteractionActive", false);
}
fn map_runtime_story_client_error(error: SpacetimeClientError) -> AppError {
let (status, provider) = match error {
SpacetimeClientError::Runtime(_) => (StatusCode::BAD_REQUEST, "runtime-story"),

File diff suppressed because it is too large Load Diff

View File

@@ -110,11 +110,23 @@ pub(super) fn resolve_npc_battle_entry_action(
} else {
"fight"
};
let return_encounter = read_object_field(game_state, "currentEncounter").cloned();
let resolved_formation =
resolve_npc_battle_formation(game_state, return_encounter.as_ref(), battle_mode);
write_bool_field(game_state, "inBattle", true);
write_bool_field(game_state, "npcInteractionActive", false);
write_string_field(game_state, "currentBattleNpcId", npc_id.as_str());
write_string_field(game_state, "currentNpcBattleMode", battle_mode);
write_null_field(game_state, "currentNpcBattleOutcome");
write_null_field(game_state, "currentEncounter");
ensure_json_object(game_state).insert(
"sceneHostileNpcs".to_string(),
Value::Array(resolved_formation),
);
if let Some(return_encounter) = return_encounter {
ensure_json_object(game_state).insert("sparReturnEncounter".to_string(), return_encounter);
}
Ok(StoryResolution {
action_text: resolve_action_text(
@@ -144,6 +156,117 @@ pub(super) fn resolve_npc_battle_entry_action(
})
}
fn resolve_npc_battle_formation(
game_state: &Value,
encounter: Option<&Value>,
battle_mode: &str,
) -> Vec<Value> {
let visible_formation = read_array_field(game_state, "sceneHostileNpcs")
.into_iter()
.cloned()
.collect::<Vec<_>>();
if !visible_formation.is_empty() {
return visible_formation
.into_iter()
.map(|monster| normalize_npc_battle_monster(monster, battle_mode))
.collect();
}
encounter
.map(|encounter| {
vec![build_npc_battle_monster_from_encounter(
game_state,
encounter,
battle_mode,
3.2,
0,
)]
})
.unwrap_or_default()
}
fn normalize_npc_battle_monster(mut monster: Value, battle_mode: &str) -> Value {
let Some(monster_object) = monster.as_object_mut() else {
return monster;
};
monster_object
.entry("animation".to_string())
.or_insert_with(|| Value::String("idle".to_string()));
monster_object
.entry("facing".to_string())
.or_insert_with(|| Value::String("left".to_string()));
monster_object
.entry("renderKind".to_string())
.or_insert_with(|| Value::String("npc".to_string()));
monster_object
.entry("attackRange".to_string())
.or_insert_with(|| json!(1.8));
monster_object
.entry("speed".to_string())
.or_insert_with(|| json!(7));
let max_hp = monster_object
.get("maxHp")
.and_then(Value::as_i64)
.unwrap_or_else(|| if battle_mode == "spar" { 10 } else { 80 });
monster_object
.entry("hp".to_string())
.or_insert_with(|| json!(max_hp));
monster
}
fn build_npc_battle_monster_from_encounter(
game_state: &Value,
encounter: &Value,
battle_mode: &str,
x_meters: f64,
y_offset: i32,
) -> Value {
let npc_id = read_optional_string_field(encounter, "id")
.unwrap_or_else(|| current_encounter_name(game_state));
let npc_name = current_encounter_name(game_state);
let npc_state =
resolve_current_encounter_npc_state(game_state, npc_id.as_str(), npc_name.as_str());
let affinity = npc_state
.and_then(|state| read_i32_field(state, "affinity"))
.or_else(|| read_i32_field(encounter, "initialAffinity"))
.unwrap_or(0);
let base_hp = if battle_mode == "spar" {
10
} else {
(80 + affinity).max(24)
};
let monster_id = read_optional_string_field(encounter, "monsterPresetId")
.unwrap_or_else(|| format!("npc-opponent-{npc_id}"));
let mut battle_encounter = encounter.clone();
if let Some(entry) = battle_encounter.as_object_mut() {
entry.insert("hostile".to_string(), Value::Bool(true));
entry.insert("xMeters".to_string(), json!(x_meters));
}
json!({
"id": monster_id,
"name": npc_name,
"action": if battle_mode == "spar" {
"抱拳行礼,准备点到为止地切磋武艺"
} else {
"摆开架势,随时准备出手"
},
"description": read_optional_string_field(encounter, "npcDescription").unwrap_or_default(),
"animation": "idle",
"xMeters": x_meters,
"yOffset": y_offset,
"facing": "left",
"attackRange": 1.8,
"speed": 7,
"hp": base_hp,
"maxHp": base_hp,
"renderKind": "npc",
"levelProfile": read_field(encounter, "levelProfile").cloned(),
"experienceReward": read_i32_field(encounter, "experienceReward").unwrap_or(0),
"encounter": battle_encounter
})
}
pub(super) fn resolve_npc_recruit_action(
game_state: &mut Value,
request: &RuntimeStoryActionRequest,
@@ -232,8 +355,10 @@ pub(super) fn resolve_npc_trade_action(
.ok_or_else(|| "npc_trade 缺少 itemId".to_string())?;
let quantity = payload
.and_then(|value| read_i32_field(value, "quantity"))
.unwrap_or(1)
.max(1);
.unwrap_or(1);
if quantity <= 0 {
return Err("npc_trade.quantity 必须大于 0".to_string());
}
if mode == "buy" {
let npc_item = read_current_npc_inventory_item(game_state, item_id.as_str())

View File

@@ -6,6 +6,7 @@ pub(super) fn build_runtime_story_state_response(
mut snapshot: RuntimeStorySnapshotPayload,
) -> RuntimeStoryActionResponse {
ensure_runtime_story_bridge_state(&mut snapshot.game_state);
write_runtime_npc_interaction_view(&mut snapshot.game_state);
let session_id = read_runtime_session_id(&snapshot.game_state)
.unwrap_or_else(|| requested_session_id.to_string());
let options =

File diff suppressed because it is too large Load Diff