From f0471a4f8dbacb9d07ec3ac6efa507646124a9d7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=AB=98=E7=89=A9?= <253518756@qq.com> Date: Tue, 28 Apr 2026 19:36:39 +0800 Subject: [PATCH] 1 --- docs/README.md | 1 + docs/audits/README.md | 4 + ...EDIT_AUTOSAVE_OVERRIDE_AUDIT_2026-04-28.md | 233 ++ docs/audits/engineering/README.md | 20 +- ...RIPT_BACKEND_MIGRATION_AUDIT_2026-04-28.md | 650 ++++++ ...D_MIGRATION_COMPLETION_CHECK_2026-04-28.md | 169 ++ ...PLATFORM_BIG_FISH_ENTRY_HIDE_2026-04-28.md | 33 + docs/design/README.md | 1 + ...H_DRAFT_GENERATION_CHAIN_FIX_2026-04-28.md | 80 + ...LE_LOCAL_RUN_LEADERBOARD_FIX_2026-04-28.md | 27 + docs/experience/README.md | 2 + docs/reference/README.md | 2 + ...ME_SCRIPT_RESPONSIBILITY_MAP_2026-04-28.md | 873 ++++++++ ...G_SCRIPT_COMMENTARY_PROGRESS_2026-04-28.md | 115 + ..._MANUAL_VERIFICATION_RUNBOOK_2026-04-23.md | 1 + ...ND_SESSION_TIMEOUT_GUARD_FIX_2026-04-28.md | 57 + ...SPARENT_BACKGROUND_ALIGNMENT_2026-04-28.md | 82 + ...ISH_PROMPT_MODULE_EXTRACTION_2026-04-28.md | 126 ++ ..._AND_AGENT_RESTORE_GUARD_FIX_2026-04-28.md | 63 + ...AFT_PROGRESS_AND_ASSET_CHAIN_2026-04-25.md | 43 +- docs/technical/README.md | 7 + ...GENERATION_BACKEND_MIGRATION_2026-04-28.md | 51 + ...VIEW_BACKEND_TRUTH_MIGRATION_2026-04-28.md | 82 + ...PROMPT_EDITOR_REORGANIZATION_2026-04-28.md | 125 ++ ...FORGE_VIEW_BACKEND_MIGRATION_2026-04-28.md | 99 + ...OVAL_AND_SERVER_RS_MIGRATION_2026-04-28.md | 102 + ...LOW_PROMPT_BACKEND_MIGRATION_2026-04-28.md | 103 + ..._BOOTSTRAP_BACKEND_MIGRATION_2026-04-28.md | 70 + ...OST_BATTLE_BACKEND_MIGRATION_2026-04-28.md | 63 + ...ORY_ENGINE_BACKEND_MIGRATION_2026-04-28.md | 164 ++ ...REATION_AGENT_CHAT_FRAMEWORK_2026-04-22.md | 2 + packages/shared/src/contracts/bigFish.ts | 4 + .../src/contracts/rpgCreationResultView.ts | 40 + .../shared/src/contracts/rpgRuntimeChat.ts | 58 +- .../src/contracts/rpgRuntimeStoryState.ts | 140 +- packages/shared/src/contracts/runtime.ts | 6 + packages/shared/src/index.ts | 1 + server-rs/crates/api-server/src/app.rs | 77 +- server-rs/crates/api-server/src/big_fish.rs | 363 ++-- .../api-server/src/big_fish_agent_turn.rs | 139 +- .../api-server/src/big_fish_draft_compiler.rs | 296 +++ .../src/character_animation_assets.rs | 102 +- .../api-server/src/character_visual_assets.rs | 4 +- .../crates/api-server/src/custom_world.rs | 656 +++++- .../src/custom_world_foundation_draft.rs | 2 +- server-rs/crates/api-server/src/main.rs | 4 + .../crates/api-server/src/prompt/big_fish.rs | 389 ++++ .../src/prompt/character_animation.rs | 2 +- .../api-server/src/prompt/character_visual.rs | 2 +- server-rs/crates/api-server/src/prompt/mod.rs | 10 +- .../src/prompt/{ => rpg}/agent_chat.rs | 0 .../src/prompt/{ => rpg}/foundation_draft.rs | 2 +- .../crates/api-server/src/prompt/rpg/mod.rs | 4 + .../src/prompt/rpg/role_asset_studio.rs | 348 +++ .../src/prompt/{ => rpg}/runtime_chat.rs | 231 ++ .../crates/api-server/src/runtime_chat.rs | 128 +- .../api-server/src/runtime_chat_plain.rs | 615 ++++++ .../crates/api-server/src/runtime_save.rs | 381 +++- .../crates/api-server/src/runtime_story.rs | 4 +- .../api-server/src/runtime_story/compat.rs | 353 ++- .../src/runtime_story/compat/bootstrap.rs | 1101 ++++++++++ .../src/runtime_story/compat/npc_actions.rs | 129 +- .../src/runtime_story/compat/presentation.rs | 1 + .../src/runtime_story/compat/tests.rs | 1005 ++++++++- server-rs/crates/module-big-fish/src/lib.rs | 81 +- .../crates/module-custom-world/src/lib.rs | 219 ++ server-rs/crates/module-puzzle/src/lib.rs | 12 +- .../module-runtime-story-compat/src/battle.rs | 25 +- .../src/battle_tests.rs | 19 +- .../module-runtime-story-compat/src/forge.rs | 199 +- .../module-runtime-story-compat/src/lib.rs | 16 +- .../src/npc_support.rs | 181 +- .../src/post_battle.rs | 903 ++++++++ .../src/prompt_context.rs | 939 ++++++++ .../src/story_engine.rs | 1569 ++++++++++++++ .../src/view_model.rs | 428 +++- .../crates/shared-contracts/src/assets.rs | 97 + .../crates/shared-contracts/src/big_fish.rs | 4 + .../crates/shared-contracts/src/runtime.rs | 37 + .../shared-contracts/src/runtime_story.rs | 335 +++ .../crates/spacetime-client/src/big_fish.rs | 11 +- server-rs/crates/spacetime-client/src/lib.rs | 12 +- .../crates/spacetime-client/src/mapper.rs | 20 +- .../big_fish_draft_compile_input_type.rs | 1 + .../big_fish_level_blueprint_type.rs | 4 + .../src/module_bindings/mod.rs | 4 +- .../crates/spacetime-client/src/puzzle.rs | 9 +- .../spacetime-module/src/big_fish/session.rs | 8 +- .../spacetime-module/src/custom_world/mod.rs | 18 +- server-rs/crates/spacetime-module/src/lib.rs | 18 +- .../crates/spacetime-module/src/puzzle.rs | 41 +- src/components/InventoryPanel.tsx | 49 +- src/components/NpcModals.tsx | 140 +- .../characterAssetWorkflowPersistence.test.ts | 106 + .../characterAssetWorkflowPersistence.ts | 103 +- .../customWorldRolePromptDefaults.test.ts | 51 - .../customWorldRolePromptDefaults.ts | 1 - .../BigFishResultView.test.tsx | 36 + .../big-fish-result/BigFishResultView.tsx | 8 +- .../PlatformEntryCreationTypeModal.tsx | 8 +- .../PlatformEntryFlowShellImpl.tsx | 154 +- .../platformEntryCreationTypes.ts | 3 + .../platform-entry/platformEntryShared.ts | 1 - .../platform-entry/platformEntryTypes.ts | 2 + .../RpgCreationRoleAssetStudioModalImpl.tsx | 155 +- ...gEntryFlowShell.agent.interaction.test.tsx | 185 +- .../rpg-entry/rpgEntryShared.test.ts | 40 + src/components/rpg-entry/rpgEntryShared.ts | 35 +- .../useRpgCreationEnterWorld.test.tsx | 14 +- .../rpg-entry/useRpgCreationEnterWorld.ts | 51 +- .../rpg-entry/useRpgCreationResultAutosave.ts | 185 +- .../useRpgCreationSessionController.ts | 277 ++- .../useRpgEntryAgentDraftRestore.test.tsx | 115 +- .../rpg-entry/useRpgEntryLibraryDetail.ts | 82 +- .../RpgRuntimePanelRouter.tsx | 3 + .../RpgRuntimeOverlayHost.tsx | 3 + .../functionCatalog/functionCatalog.test.ts | 26 +- src/data/functionCatalog/index.ts | 1 + src/data/functionCatalog/npc/npcGift.ts | 17 +- src/data/functionCatalog/npc/npcTrade.ts | 22 +- src/data/functionCatalog/runtimeIndex.ts | 35 + .../functionCatalog/state/battleAllInCrush.ts | 22 +- .../state/battleEscapeBreakout.ts | 19 +- .../functionCatalog/state/battleFeintStep.ts | 15 +- .../state/battleFinisherWindow.ts | 22 +- .../functionCatalog/state/battleGuardBreak.ts | 15 +- .../state/battleProbePressure.ts | 18 +- .../state/battleRecoverBreath.ts | 21 +- src/data/functionCatalog/state/idleCallOut.ts | 23 +- .../state/idleExploreForward.ts | 31 +- .../functionCatalog/state/idleFollowClue.ts | 15 +- .../functionCatalog/state/idleObserveSigns.ts | 15 +- .../functionCatalog/state/idleRestFocus.ts | 20 +- .../state/idleTravelNextScene.ts | 20 +- src/data/functionCatalog/state/index.ts | 9 +- src/data/functionCatalog/types.ts | 37 + src/data/stateFunctions.ts | 212 +- .../rpg-runtime-story/choiceActions.test.ts | 504 ++--- src/hooks/rpg-runtime-story/choiceActions.ts | 41 +- .../rpg-runtime-story/inventoryActions.ts | 213 +- .../rpg-runtime-story/npcInteraction.test.tsx | 323 +++ src/hooks/rpg-runtime-story/npcInteraction.ts | 362 +--- .../rpg-runtime-story/postBattleFlow.test.ts | 327 --- src/hooks/rpg-runtime-story/postBattleFlow.ts | 229 -- .../rpg-runtime-story/progressionActions.ts | 629 +----- .../rpgRuntimeStoryGateway.ts | 315 +-- .../runtimeStoryCoordinator.test.ts | 430 +--- .../rpg-runtime-story/sessionActions.test.ts | 6 +- src/hooks/rpg-runtime-story/sessionActions.ts | 31 +- .../storyChoiceContinuation.ts | 372 +--- .../storyChoiceRuntime.test.ts | 220 +- .../rpg-runtime-story/storyChoiceRuntime.ts | 180 +- .../rpg-runtime-story/storyContextBuilder.ts | 630 +----- .../storyGenerationState.test.ts | 108 +- .../rpg-runtime-story/storyGenerationState.ts | 38 +- .../storyInteractionCoordinator.ts | 6 + .../storyRequestCoordinator.test.ts | 5 +- src/hooks/rpg-runtime-story/uiTypes.ts | 26 +- .../useRpgRuntimeInteractionFlow.ts | 10 + .../useRpgRuntimeNpcInteraction.ts | 132 +- .../rpg-runtime-story/useRpgRuntimeStory.ts | 3 + .../useRpgRuntimeStoryController.test.tsx | 8 +- .../useRpgRuntimeStoryController.ts | 8 + .../useRpgRuntimeStoryFlow.ts | 5 + .../useRpgRuntimeStoryState.ts | 4 + src/hooks/rpg-session/useRpgRuntimeSession.ts | 23 +- .../rpg-session/useRpgSessionBootstrap.ts | 575 +---- .../rpg-session/useRpgSessionPersistence.ts | 33 +- src/hooks/runtimeAuthGuards.test.tsx | 81 +- src/persistence/gameSaveStorage.ts | 8 +- src/prompts/characterChatPrompts.ts | 333 --- src/prompts/customWorldRolePromptDefaults.ts | 94 - src/prompts/storyPromptBuilders.ts | 1884 ----------------- src/services/ai.test.ts | 287 +-- src/services/ai.ts | 712 +------ src/services/aiService.ts | 334 ++- src/services/aiTypes.ts | 2 + .../bigFishGalleryClient.test.ts | 44 + .../big-fish-gallery/bigFishGalleryClient.ts | 5 +- src/services/characterChatPrompt.ts | 1 - src/services/customWorldAgentUiState.test.ts | 43 + src/services/customWorldAgentUiState.ts | 28 +- .../miniGameDraftGenerationProgress.test.ts | 66 + .../miniGameDraftGenerationProgress.ts | 110 +- src/services/prompt.test.ts | 301 --- src/services/prompt.ts | 1 - .../puzzle-runtime/puzzleLocalRuntime.test.ts | 23 + .../puzzle-runtime/puzzleLocalRuntime.ts | 61 +- src/services/rpg-creation/index.ts | 7 +- .../rpg-creation/rpgCreationAgentClient.ts | 12 + .../rpgCreationGenerationClient.node.test.ts | 50 + .../rpgCreationGenerationClient.test.ts | 25 + .../rpgCreationGenerationClient.ts | 18 +- .../rpgCreationPreviewAdapter.test.ts | 38 +- .../rpg-creation/rpgCreationPreviewAdapter.ts | 27 +- src/services/rpg-runtime/index.ts | 23 +- src/services/rpg-runtime/rpgRuntimeRequest.ts | 2 + .../rpg-runtime/rpgRuntimeStoryClient.test.ts | 175 +- .../rpg-runtime/rpgRuntimeStoryClient.ts | 112 +- .../rpg-runtime/rpgSnapshotClient.test.ts | 16 +- src/services/rpg-runtime/rpgSnapshotClient.ts | 27 +- src/services/rpgRuntimeChatTypes.ts | 11 + src/services/storyEngine/echoMemory.test.ts | 45 + src/services/storyEngine/echoMemory.ts | 8 +- src/services/storyEngine/visibilityEngine.ts | 65 +- src/types/game.ts | 35 + 206 files changed, 18456 insertions(+), 10133 deletions(-) create mode 100644 docs/audits/RPG_WORLD_DRAFT_EDIT_AUTOSAVE_OVERRIDE_AUDIT_2026-04-28.md create mode 100644 docs/audits/engineering/RPG_FRONTEND_SCRIPT_BACKEND_MIGRATION_AUDIT_2026-04-28.md create mode 100644 docs/audits/engineering/RPG_FRONTEND_SCRIPT_BACKEND_MIGRATION_COMPLETION_CHECK_2026-04-28.md create mode 100644 docs/design/PLATFORM_BIG_FISH_ENTRY_HIDE_2026-04-28.md create mode 100644 docs/experience/BIG_FISH_DRAFT_GENERATION_CHAIN_FIX_2026-04-28.md create mode 100644 docs/experience/PUZZLE_LOCAL_RUN_LEADERBOARD_FIX_2026-04-28.md create mode 100644 docs/reference/RPG_CREATION_AND_RUNTIME_SCRIPT_RESPONSIBILITY_MAP_2026-04-28.md create mode 100644 docs/reference/RPG_SCRIPT_COMMENTARY_PROGRESS_2026-04-28.md create mode 100644 docs/technical/BIG_FISH_DRAFT_PROGRESS_AND_SESSION_TIMEOUT_GUARD_FIX_2026-04-28.md create mode 100644 docs/technical/BIG_FISH_MAIN_IMAGE_TRANSPARENT_BACKGROUND_ALIGNMENT_2026-04-28.md create mode 100644 docs/technical/BIG_FISH_PROMPT_MODULE_EXTRACTION_2026-04-28.md create mode 100644 docs/technical/CREATION_PUBLIC_GALLERY_AND_AGENT_RESTORE_GUARD_FIX_2026-04-28.md create mode 100644 docs/technical/RPG_CREATION_PROFILE_GENERATION_BACKEND_MIGRATION_2026-04-28.md create mode 100644 docs/technical/RPG_CREATION_RESULT_VIEW_BACKEND_TRUTH_MIGRATION_2026-04-28.md create mode 100644 docs/technical/RPG_FUNCTION_AND_PROMPT_EDITOR_REORGANIZATION_2026-04-28.md create mode 100644 docs/technical/RPG_INVENTORY_EQUIPMENT_FORGE_VIEW_BACKEND_MIGRATION_2026-04-28.md create mode 100644 docs/technical/RPG_PROMPT_FRONTEND_REMOVAL_AND_SERVER_RS_MIGRATION_2026-04-28.md create mode 100644 docs/technical/RPG_ROLE_ASSET_STUDIO_WORKFLOW_PROMPT_BACKEND_MIGRATION_2026-04-28.md create mode 100644 docs/technical/RPG_RUNTIME_BOOTSTRAP_BACKEND_MIGRATION_2026-04-28.md create mode 100644 docs/technical/RPG_RUNTIME_POST_BATTLE_BACKEND_MIGRATION_2026-04-28.md create mode 100644 docs/technical/RPG_RUNTIME_STORY_ENGINE_BACKEND_MIGRATION_2026-04-28.md create mode 100644 packages/shared/src/contracts/rpgCreationResultView.ts create mode 100644 server-rs/crates/api-server/src/big_fish_draft_compiler.rs create mode 100644 server-rs/crates/api-server/src/prompt/big_fish.rs rename server-rs/crates/api-server/src/prompt/{ => rpg}/agent_chat.rs (100%) rename server-rs/crates/api-server/src/prompt/{ => rpg}/foundation_draft.rs (99%) create mode 100644 server-rs/crates/api-server/src/prompt/rpg/mod.rs create mode 100644 server-rs/crates/api-server/src/prompt/rpg/role_asset_studio.rs rename server-rs/crates/api-server/src/prompt/{ => rpg}/runtime_chat.rs (78%) create mode 100644 server-rs/crates/api-server/src/runtime_chat_plain.rs create mode 100644 server-rs/crates/api-server/src/runtime_story/compat/bootstrap.rs create mode 100644 server-rs/crates/module-runtime-story-compat/src/post_battle.rs create mode 100644 server-rs/crates/module-runtime-story-compat/src/prompt_context.rs create mode 100644 server-rs/crates/module-runtime-story-compat/src/story_engine.rs create mode 100644 src/components/asset-studio/characterAssetWorkflowPersistence.test.ts delete mode 100644 src/components/asset-studio/customWorldRolePromptDefaults.test.ts delete mode 100644 src/components/asset-studio/customWorldRolePromptDefaults.ts create mode 100644 src/components/rpg-entry/rpgEntryShared.test.ts create mode 100644 src/data/functionCatalog/runtimeIndex.ts create mode 100644 src/hooks/rpg-runtime-story/npcInteraction.test.tsx delete mode 100644 src/hooks/rpg-runtime-story/postBattleFlow.test.ts delete mode 100644 src/hooks/rpg-runtime-story/postBattleFlow.ts delete mode 100644 src/prompts/characterChatPrompts.ts delete mode 100644 src/prompts/customWorldRolePromptDefaults.ts delete mode 100644 src/prompts/storyPromptBuilders.ts create mode 100644 src/services/big-fish-gallery/bigFishGalleryClient.test.ts delete mode 100644 src/services/characterChatPrompt.ts create mode 100644 src/services/miniGameDraftGenerationProgress.test.ts delete mode 100644 src/services/prompt.test.ts delete mode 100644 src/services/prompt.ts create mode 100644 src/services/rpg-creation/rpgCreationGenerationClient.node.test.ts create mode 100644 src/services/rpgRuntimeChatTypes.ts diff --git a/docs/README.md b/docs/README.md index a77790fa..daa437c6 100644 --- a/docs/README.md +++ b/docs/README.md @@ -10,6 +10,7 @@ - [技术方案](./technical/README.md):动画、服务端、外部产品形态拆解。 - [规划与优先级](./planning/README.md):当前阶段的迭代排序与落地优先级。 - [参考目录](./reference/README.md):脚本/Function 速查入口。 + 重点补充:RPG 创作与运行时脚本职责地图见 [RPG_CREATION_AND_RUNTIME_SCRIPT_RESPONSIBILITY_MAP_2026-04-28.md](./reference/RPG_CREATION_AND_RUNTIME_SCRIPT_RESPONSIBILITY_MAP_2026-04-28.md)。 - [PRD](./prd):产品需求与阶段计划;新增 RPG 开场动画方案见 [AI_NATIVE_RPG_OPENING_ANIMATION_PRD_2026-04-25.md](./prd/AI_NATIVE_RPG_OPENING_ANIMATION_PRD_2026-04-25.md)。 ## 推荐阅读顺序 diff --git a/docs/audits/README.md b/docs/audits/README.md index 1c9b4df8..374d64c4 100644 --- a/docs/audits/README.md +++ b/docs/audits/README.md @@ -17,6 +17,10 @@ - [CUSTOM_WORLD_CREATOR_TOOL_AUDIT_2026-04-08.md](./CUSTOM_WORLD_CREATOR_TOOL_AUDIT_2026-04-08.md):自定义世界创作工具当前问题、体验断层和优化优先级审计。 - [AGENT_TO_DRAFT_TO_WORLD_PIPELINE_AUDIT_2026-04-20.md](./AGENT_TO_DRAFT_TO_WORLD_PIPELINE_AUDIT_2026-04-20.md):Agent 聊天、草稿生成、作品库存储与进入世界之间的断点、多 pipeline、冗余与未实装项审计。 - [CHARACTER_ASSET_PROMPT_CHAIN_AUDIT_2026-04-20.md](./CHARACTER_ASSET_PROMPT_CHAIN_AUDIT_2026-04-20.md):角色资产默认描述文本、正式图像/动作 prompt、共享模板与保留接口的分层与冗余审计。 +- [RPG_RUNTIME_DIRECT_DRAFT_PROFILE_AUDIT_2026-04-25.md](./RPG_RUNTIME_DIRECT_DRAFT_PROFILE_AUDIT_2026-04-25.md):RPG 运行时进入世界时改为直读 Agent session 草稿 profile 的链路检查。 +- [RPG_WORLD_DRAFT_EDIT_AUTOSAVE_OVERRIDE_AUDIT_2026-04-28.md](./RPG_WORLD_DRAFT_EDIT_AUTOSAVE_OVERRIDE_AUDIT_2026-04-28.md):RPG 世界草稿结果页编辑后被旧设定覆盖的前端本地态、session 真相源与自动保存链路审计。 +- [engineering/RPG_FRONTEND_SCRIPT_BACKEND_MIGRATION_AUDIT_2026-04-28.md](./engineering/RPG_FRONTEND_SCRIPT_BACKEND_MIGRATION_AUDIT_2026-04-28.md):RPG 前端脚本中仍应迁到 `server-rs` / SpacetimeDB 的开局、快照、story engine、战斗、NPC/背包规则与创作残留后门审计。 +- [engineering/RPG_FRONTEND_SCRIPT_BACKEND_MIGRATION_COMPLETION_CHECK_2026-04-28.md](./engineering/RPG_FRONTEND_SCRIPT_BACKEND_MIGRATION_COMPLETION_CHECK_2026-04-28.md):RPG 前端脚本后端迁移完成度复核,标明已完成项、已收口的结果页保存 normalize,以及仍需收尾的 `camp_travel_home_scene` 前端专用旅行分支。 - [engineering/ENGINEERING_CLEANUP_AND_BACKEND_BOUNDARY_AUDIT_2026-04-20.md](./engineering/ENGINEERING_CLEANUP_AND_BACKEND_BOUNDARY_AUDIT_2026-04-20.md):对 `2026-04-19` 工程清理审计的当前仓库复核,区分已完成项、仍存边界问题和新的热点迁移。 - [engineering/ENGINEERING_CLEANUP_AND_BACKEND_BOUNDARY_AUDIT_2026-04-19.md](./engineering/ENGINEERING_CLEANUP_AND_BACKEND_BOUNDARY_AUDIT_2026-04-19.md):未引用垃圾、旧入口残留、前后端双份真相与后端迁移项的专项审计。 diff --git a/docs/audits/RPG_WORLD_DRAFT_EDIT_AUTOSAVE_OVERRIDE_AUDIT_2026-04-28.md b/docs/audits/RPG_WORLD_DRAFT_EDIT_AUTOSAVE_OVERRIDE_AUDIT_2026-04-28.md new file mode 100644 index 00000000..4da9f9ae --- /dev/null +++ b/docs/audits/RPG_WORLD_DRAFT_EDIT_AUTOSAVE_OVERRIDE_AUDIT_2026-04-28.md @@ -0,0 +1,233 @@ +# RPG 世界草稿编辑后被旧设定覆盖问题审计 2026-04-28 + +## 1. 问题摘要 + +当前 RPG 世界草稿结果页存在一条明显的“本地编辑态与 Agent session 真相源分叉”问题链: + +1. 用户在结果页编辑世界设定时,前端只更新本地 `generatedCustomWorldProfile`。 +2. Agent 草稿会话里的 `draftProfile / resultPreview.preview` 并不会随着这次编辑同步更新。 +3. 自动保存触发时,系统又会优先重新拉取 session 最新快照,并以 session 返回的 profile 作为最终保存内容。 +4. 如果 session 里仍是编辑前旧设定,那么前端刚刚改过的内容就会被旧快照覆盖回来。 + +所以,这个问题表面看起来像“自动保存覆盖了我刚保存的内容”,本质上是: + +- 结果页编辑写到了前端内存态; +- 自动保存落库前又改为优先相信 session 真相; +- 但 session 真相本身没有承接这次编辑。 + +## 2. 当前主链证据 + +### 2.1 结果页编辑只改前端本地 profile + +`src/components/platform-entry/PlatformEntryFlowShellImpl.tsx:2637-2640` + +- `RpgCreationResultView` 的 `onProfileChange` 只调用: + - `sessionController.setGeneratedCustomWorldProfile(normalizeAgentBackedProfile(profile))` +- 这里没有触发任何 `update_draft_card`、`sync_result_profile` 或其它后端写回动作。 + +这意味着结果页里的“保存修改”目前只是更新前端内存中的 `generatedCustomWorldProfile`。 + +### 2.2 结果页展示数据优先来自 session 预览 + +`src/services/rpg-creation/rpgCreationPreviewAdapter.ts:27-33` + +- `buildCustomWorldProfileFromAgentSession()` 当前优先读取: + - `session.resultPreview.preview` +- 若没有,再回退到: + - `session.draftProfile.legacyResultProfile` + +也就是说,Agent 结果页最终重新打开、重新同步或重新计算时,主数据源仍是 session 快照,而不是用户刚改过的前端本地对象。 + +### 2.3 自动保存前会先刷新 session,并优先保存 session 返回的最新结果 + +`src/components/rpg-entry/useRpgCreationResultAutosave.ts:181-236` + +- `syncAgentDraftResultProfile()` 在 session 与前端 profile 签名不一致时,不再把前端 profile 回写 session。 +- 它只会执行: + - `syncAgentSessionSnapshot(activeAgentSessionId)` +- 然后把拉回来的 session profile 重新塞回: + - `setGeneratedCustomWorldProfile(latestProfile)` + +`src/components/rpg-entry/useRpgCreationResultAutosave.ts:326-340` + +- 自动保存延迟触发后,如果当前是 Agent 草稿结果页: + 1. 先调用 `syncAgentDraftResultProfile(profileToSave)` + 2. 再把 `syncedResult.profile ?? profileToSave` 作为最终要入库的 profile + +也就是: + +- 自动保存不是直接保存用户刚改的前端 profile; +- 它会先“向 session 对齐”; +- 对齐后优先保存 session 返回的新 profile。 + +如果 session 里还是旧设定,那么保存和界面都会一起回滚到旧设定。 + +## 3. 当前测试口径也在强化这条行为 + +### 3.1 单测明确要求“不触发 sync_result_profile,只保存 session 最新草稿” + +`src/components/rpg-entry/useRpgEntryAgentDraftRestore.test.tsx:195-276` + +这条测试验证的是: + +1. 前端当前持有的是 `oldProfile` +2. `syncAgentSessionSnapshot()` 返回的是 `latestSession` +3. 自动保存最终必须保存 `latestSession.draftProfile` +4. 并且明确断言不应触发 `sync_result_profile` + +这说明现有实现不是偶然漏掉了 session 写回,而是被当前测试和实现共同锁定为: + +- “自动保存只刷新 session,不回写前端结果页编辑态” + +### 3.2 交互测试也要求“结果页自动保存优先保存 session 最新快照” + +`src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx:2867-3006` + +这条测试进一步验证: + +1. 结果页最终保存的对象应来自 `syncedSession.resultPreview.preview` +2. 不应触发 `sync_result_profile` + +所以现在的覆盖行为不是单点 bug,而是当前结果页保存架构的直接产物。 + +## 4. 系统层面的根因拆解 + +### 4.1 双真相源并存 + +当前至少同时存在两份“看起来都像真相”的数据: + +1. 前端结果页本地 `generatedCustomWorldProfile` +2. Agent session 内的 `draftProfile / resultPreview.preview` + +结果页编辑写前者,自动保存与恢复又优先读后者,所以只要两边没有同事务同步,就一定会出现覆盖风险。 + +### 4.2 编辑动作没有接入正式后端写回动作 + +仓库契约和 Rust 模块实际上已经存在两条正式动作: + +- `update_draft_card` +- `sync_result_profile` + +证据: + +- `packages/shared/src/contracts/rpgAgentActions.ts` +- `server-rs/crates/spacetime-module/src/custom_world/mod.rs` + +其中: + +- `update_draft_card` 适合基于卡片/section 的结构化修改写回 `draftProfile` +- `sync_result_profile` 适合把结果页完整 profile 回写 session 并重建 preview + +但当前结果页 `onProfileChange` 没有接入这两条正式链路,导致“编辑成功”只停留在本地内存态。 + +### 4.3 自动保存策略与编辑链路不一致 + +自动保存的当前设计目标是正确的: + +- 不再轻易把旧前端快照反向污染 session +- 优先保存 session 最新快照,避免把陈旧 preview 写进作品库 + +但前提应该是: + +- 结果页编辑本身已经先进入 session 真相源 + +现在前提不成立,于是自动保存的“防旧快照污染”策略,反过来变成了“用 session 旧值覆盖用户刚编辑的新值”。 + +### 4.4 结果页定位混乱 + +当前结果页同时承担了两种角色: + +1. 预览/发布页 +2. 可深度编辑的草稿编辑器 + +但现有数据链路更偏向第 1 种: + +- 数据来自 session preview +- 自动保存优先对齐 session preview + +而 UI 行为却提供了第 2 种能力: + +- 用户可以直接改世界、角色、场景等内容 + +两种定位没有统一,最终表现就是“能编辑,但编辑不能稳定进入主真相源”。 + +## 5. 用户视角下的实际故障表现 + +从用户体感看,当前问题会表现为以下几种: + +1. 刚改完字段,短暂显示新内容,随后自动回弹为旧内容。 +2. 页面提示“已自动保存”,但重新进入草稿页后仍然是旧设定。 +3. 某些字段改了能停留一会儿,触发自动保存或 session 刷新后又被覆盖。 +4. 用户会误以为“保存按钮坏了”或“自动保存有缓存问题”,但真实原因是保存目标和展示真相源不一致。 + +## 6. 影响范围 + +这个问题不只影响“世界简介文本”,而是整个结果页编辑体系: + +1. 世界基础信息编辑 +2. 角色编辑 +3. 场景编辑 +4. 封面/派生内容依赖当前 profile 的场景 +5. 结果页返回、恢复、自动打开、发布前预览一致性 + +只要编辑发生在 `generatedCustomWorldProfile`,但没有进入 session 真相源,就都有同类风险。 + +## 7. 建议修复方向 + +### 7.1 第一优先级:统一结果页编辑的唯一真相源 + +推荐二选一,不要继续混用: + +1. 结果页只做预览,不允许直接编辑复杂设定 +2. 结果页继续允许编辑,但每次保存必须先落到 session 真相源,再更新本地显示 + +按当前产品形态,更合理的是第 2 条。 + +### 7.2 如果保留结果页编辑,建议采用的正式链路 + +1. 世界/角色/场景编辑保存时,优先调用 Rust/SpacetimeDB 的正式动作写回 session。 +2. 能用 `update_draft_card` 的地方尽量走结构化 section 更新。 +3. 对无法被 card section 覆盖的完整 profile 级编辑,再评估是否保留 `sync_result_profile`,或新增更细粒度 reducer。 +4. session 写回完成后,再重新读取 session 并刷新结果页。 +5. 自动保存只负责“把已经写进 session 的最新真相同步到作品库”,不要再承担“猜测该保存前端还是 session”的职责。 + +### 7.3 自动保存策略需要降级为“作品库存档层”,不要兼任“session 真相协调层” + +当前自动保存同时承担: + +1. 防止旧前端快照污染 session +2. 选择最终保存哪份 profile +3. 刷新结果页显示 + +职责太重,也导致覆盖问题难以定位。 + +建议改为: + +1. 编辑保存阶段:负责写 session +2. 自动保存阶段:只负责把 session 已确认真相落作品库 +3. 结果页渲染阶段:只读 session 最新快照 + +### 7.4 需要补的测试口径 + +当前测试主要在保护“不要再触发 `sync_result_profile` 污染 session”。 + +后续修复后,至少要补以下测试: + +1. 用户编辑世界基础字段后,session `draftProfile / resultPreview` 会同步更新。 +2. 自动保存不会把 session 旧值覆盖到刚编辑的新值上。 +3. 刷新页面或重新进入结果页后,看到的是编辑后的新设定。 +4. 进入世界、发布世界、继续扩展时,消费的是同一份最新 session 真相。 + +## 8. 结论 + +这次问题的核心结论是: + +1. 你的判断“像是自动保存问题”是对的,但更准确地说,是“自动保存对齐 session 时,覆盖了未写回 session 的本地编辑态”。 +2. 当前结果页编辑没有接上正式的 session 写回链路,这是第一根断点。 +3. 当前自动保存被设计成优先信任 session 最新快照,这是第二根断点。 +4. 两个断点叠加后,就形成了“编辑后又自动变回原设定”的现象。 + +如果后续要真正修掉这个问题,重点不该是单独调 debounce 时间或加一个“防抖保存中”提示,而是: + +- 把结果页编辑动作重新接回 Rust/SpacetimeDB 的 session 真相源; +- 让自动保存只负责作品库存档,不再替代编辑写回链路做裁决。 diff --git a/docs/audits/engineering/README.md b/docs/audits/engineering/README.md index db0cbbc1..d41e9f58 100644 --- a/docs/audits/engineering/README.md +++ b/docs/audits/engineering/README.md @@ -10,21 +10,25 @@ 这一版是第六批落地记录,聚焦删除无入口 `questDirector`、旧观察文案 helper、一次性硬编码同步脚本,并补齐后端运行时 function catalog 契约覆盖。 3. [ENGINEERING_DEAD_CODE_CLEANUP_BATCH_E_2026-04-21.md](./ENGINEERING_DEAD_CODE_CLEANUP_BATCH_E_2026-04-21.md) 这一版是第五批落地记录,聚焦旧命名 re-export、空路由骨架、旧发布服务、前端 prompt 镜像与无入口编辑器壳层的物理删除。 -4. [FRONTEND_LOGIC_BACKEND_MIGRATION_AUDIT_2026-04-21.md](./FRONTEND_LOGIC_BACKEND_MIGRATION_AUDIT_2026-04-21.md) +4. [RPG_FRONTEND_SCRIPT_BACKEND_MIGRATION_AUDIT_2026-04-28.md](./RPG_FRONTEND_SCRIPT_BACKEND_MIGRATION_AUDIT_2026-04-28.md) + 这一版专项扫描 `src/` 下 RPG 开头脚本,明确运行时开局、快照、story engine、战斗后处理、NPC/背包规则和创作链残留后门中应迁到 `server-rs` 的逻辑。 +5. [RPG_FRONTEND_SCRIPT_BACKEND_MIGRATION_COMPLETION_CHECK_2026-04-28.md](./RPG_FRONTEND_SCRIPT_BACKEND_MIGRATION_COMPLETION_CHECK_2026-04-28.md) + 这一版复核 RPG 前端脚本后端迁移完成度,确认开局、快照、存档、NPC、背包/锻造、结果页保存前 normalize 与角色资产 prompt 主链已收口,同时标出 `camp_travel_home_scene` 前端专用旅行分支仍未完全迁完。 +6. [FRONTEND_LOGIC_BACKEND_MIGRATION_AUDIT_2026-04-21.md](./FRONTEND_LOGIC_BACKEND_MIGRATION_AUDIT_2026-04-21.md) 这一版是本轮前端越界逻辑专项审计,专门汇总当前仍应继续迁到 `server-rs` 的运行时、鉴权、生成编排与本地真相残留。 -5. [ENGINEERING_DEAD_CODE_CLEANUP_BATCH_D_2026-04-21.md](./ENGINEERING_DEAD_CODE_CLEANUP_BATCH_D_2026-04-21.md) +7. [ENGINEERING_DEAD_CODE_CLEANUP_BATCH_D_2026-04-21.md](./ENGINEERING_DEAD_CODE_CLEANUP_BATCH_D_2026-04-21.md) 这一版是第四批落地记录,聚焦未接入业务的数据生成产物、测试专用 stub 与对应配置残留出清。 -6. [ENGINEERING_DEAD_CODE_CLEANUP_BATCH_C_2026-04-21.md](./ENGINEERING_DEAD_CODE_CLEANUP_BATCH_C_2026-04-21.md) +8. [ENGINEERING_DEAD_CODE_CLEANUP_BATCH_C_2026-04-21.md](./ENGINEERING_DEAD_CODE_CLEANUP_BATCH_C_2026-04-21.md) 这一版是第三批落地记录,聚焦鉴权真相收口,先移除前端保存自动登录用户名/密码的本地真相,并明确运行时快照前置写入为什么当前还不能硬砍。 -7. [ENGINEERING_DEAD_CODE_CLEANUP_BATCH_B_2026-04-21.md](./ENGINEERING_DEAD_CODE_CLEANUP_BATCH_B_2026-04-21.md) +9. [ENGINEERING_DEAD_CODE_CLEANUP_BATCH_B_2026-04-21.md](./ENGINEERING_DEAD_CODE_CLEANUP_BATCH_B_2026-04-21.md) 这一版是第二批落地记录,聚焦旧主流程壳层、旧 bootstrap 和旧 inventory / forge / equipment flow Hook 的正式出清。 -8. [ENGINEERING_DEAD_CODE_CLEANUP_BATCH_A_2026-04-21.md](./ENGINEERING_DEAD_CODE_CLEANUP_BATCH_A_2026-04-21.md) +10. [ENGINEERING_DEAD_CODE_CLEANUP_BATCH_A_2026-04-21.md](./ENGINEERING_DEAD_CODE_CLEANUP_BATCH_A_2026-04-21.md) 这一版是第一批落地记录,聚焦高置信度小型孤岛、prompt 壳子、stub 和无入口 modal 的首轮清理。 -9. [CURRENT_ENGINEERING_OPTIMIZATION_OPPORTUNITIES_2026-04-20.md](./CURRENT_ENGINEERING_OPTIMIZATION_OPPORTUNITIES_2026-04-20.md) +11. [CURRENT_ENGINEERING_OPTIMIZATION_OPPORTUNITIES_2026-04-20.md](./CURRENT_ENGINEERING_OPTIMIZATION_OPPORTUNITIES_2026-04-20.md) 这一版是面向当前仓库状态的优化点盘点,适合直接拿来排优先级和拆执行批次。 -10. [ENGINEERING_CLEANUP_AND_BACKEND_BOUNDARY_AUDIT_2026-04-20.md](./ENGINEERING_CLEANUP_AND_BACKEND_BOUNDARY_AUDIT_2026-04-20.md) +12. [ENGINEERING_CLEANUP_AND_BACKEND_BOUNDARY_AUDIT_2026-04-20.md](./ENGINEERING_CLEANUP_AND_BACKEND_BOUNDARY_AUDIT_2026-04-20.md) 这一版是对 `2026-04-19` 基线的当前仓库复核,明确哪些问题已经处理、哪些表述需要纠正、热点又迁移到了哪里。 -11. [ENGINEERING_CLEANUP_AND_BACKEND_BOUNDARY_AUDIT_2026-04-19.md](./ENGINEERING_CLEANUP_AND_BACKEND_BOUNDARY_AUDIT_2026-04-19.md) +13. [ENGINEERING_CLEANUP_AND_BACKEND_BOUNDARY_AUDIT_2026-04-19.md](./ENGINEERING_CLEANUP_AND_BACKEND_BOUNDARY_AUDIT_2026-04-19.md) 这一版保留原始问题快照和执行回填,适合回看“为什么会有这轮清理与边界收口”。 ## 融合结论 diff --git a/docs/audits/engineering/RPG_FRONTEND_SCRIPT_BACKEND_MIGRATION_AUDIT_2026-04-28.md b/docs/audits/engineering/RPG_FRONTEND_SCRIPT_BACKEND_MIGRATION_AUDIT_2026-04-28.md new file mode 100644 index 00000000..7ca7e503 --- /dev/null +++ b/docs/audits/engineering/RPG_FRONTEND_SCRIPT_BACKEND_MIGRATION_AUDIT_2026-04-28.md @@ -0,0 +1,650 @@ +# RPG 前端脚本后端迁移审计(2026-04-28) + +## 0. 审计目标 + +这份文档只回答一个问题: + +**当前 `src/` 下 RPG 开头或 RPG 目录内的前端脚本中,哪些逻辑已经越过“前端只做表现”的边界,应该继续迁移到 `server-rs` / SpacetimeDB 后端。** + +本轮只做审计与迁移拆分,不改业务代码。 + +## 1. 审计范围 + +本轮扫描范围: + +1. `src/RpgRuntimeApp.tsx` +2. `src/components/rpg-*/*.ts` +3. `src/components/rpg-*/*.tsx` +4. `src/hooks/rpg-*/*.ts` +5. `src/services/rpg-*/*.ts` +6. `src/services/rpgRuntimeChatTypes.ts` + +不把测试文件作为迁移对象,但会参考测试暴露的当前行为。 + +本轮依据: + +1. `docs/reference/RPG_CREATION_AND_RUNTIME_SCRIPT_RESPONSIBILITY_MAP_2026-04-28.md` +2. `docs/audits/engineering/FRONTEND_LOGIC_BACKEND_MIGRATION_AUDIT_2026-04-21.md` +3. `docs/technical/RPG_PROMPT_FRONTEND_REMOVAL_AND_SERVER_RS_MIGRATION_2026-04-28.md` +4. `docs/experience/PROJECT_WORK_EXPERIENCE_PLAYBOOK.md` +5. `spacetimedb-concepts` / `spacetimedb-rust` / `spacetimedb-typescript` skill 约束 + +## 2. 判断标准 + +### 2.1 应迁后端 + +只要逻辑满足下面任一项,就不应继续以浏览器前端作为正式真相: + +1. 生成或解释 `GameState`、任务、背包、装备、NPC 状态、战斗状态、剧情记忆。 +2. 决定开局初始状态、场景落点、遭遇、初始物品、初始装备。 +3. 决定 action 是否合法、价格、数量、奖励、掉落、复活、场景推进。 +4. 组装正式 AI prompt / story context / fallback generation。 +5. 持久化完整运行时快照,或让前端上传整份 `GameState` 作为后端写入依据。 +6. 对服务端返回的快照做业务补丁,补齐战斗阵型、场景跳转、任务推进等正式状态。 +7. 解释 Agent session / draft / result preview 哪份才是创作真相。 + +### 2.2 可留前端 + +下面这些可以继续留在前端: + +1. 面板开关、tab、modal、loading、error、按钮禁用展示。 +2. 动画播放、镜头、过场、打字机效果、临时视觉态。 +3. API client、请求封装、SSE 文本消费。 +4. 纯展示 view model 适配,但不能改变正式业务含义。 +5. 用户正在编辑的表单草稿,但保存、校验、合并、发布门禁以后端为准。 + +## 3. 结论先行 + +当前 RPG 前端脚本中仍有 9 类逻辑应该迁移到后端: + +1. `P0` 运行时开局 `GameState` 装配。 +2. `P0` runtime story 网关中的“客户端带快照解析”和快照补丁。 +3. `P0` 前端自动保存整份运行时快照。 +4. `P0` 前端 story engine / chapter / world mutation / prompt context 编排。 +5. `P0` 战斗胜负后处理、死亡复活、战斗后章节推进。 +6. `P1` NPC 交易、送礼、价格、数量与库存校验。 +7. `P1` 背包、装备、锻造可用性与配方视图。 +8. `P1` RPG 创作 profile 生成的非浏览器 legacy AI 回退。 +9. `P1` RPG 创作结果页自动保存、session/result preview 真相优先级与 legacy preview fallback。 + +一句话判断: + +**前端已经大面积开始调用 server-rs,但仍保留了“后端兼容不完整时由前端补真相”的模式;这部分需要继续收口,否则 SpacetimeDB 表和 reducer 永远无法成为唯一运行时真相源。** + +## 4. 高优先级迁移项 + +## 4.1 `P0` 运行时开局 `GameState` 装配仍在前端 + +### 代码证据 + +主要文件: + +1. `src/hooks/rpg-session/useRpgSessionBootstrap.ts` + +关键逻辑: + +1. `PLAYER_BASE_MAX_HP = 180` +2. `mergeStarterInventoryItems(...)` +3. `normalizeExplicitStarterCategory(...)` +4. `inferExplicitStarterSlot(...)` +5. `buildExplicitCustomWorldRoleStarterState(...)` +6. `createInitialGameState()` +7. `handleCustomWorldSelect(...)` +8. `handleCharacterSelect(...)` + +对应代码表现: + +1. 第 `58` 行到第 `151` 行:前端把角色初始物品编译成 `InventoryItem`,推断装备槽,并直接生成 `runtimeMetadata`。 +2. 第 `185` 行到第 `235` 行:前端创建完整初始 `GameState`。 +3. 第 `517` 行到第 `563` 行:前端选择世界时重置运行时上下文、progression、story memory、战斗态。 +4. 第 `572` 行到第 `710` 行:前端选角时决定开局场景、开局 NPC、初始 NPC state、初始装备、血蓝、货币、背包、任务、队伍、战斗字段。 + +### 为什么应迁 + +这些不是表现层逻辑,而是“新开局正式状态”的创建权。只要开局状态仍由浏览器本地装配,后端就只能被动接收一份客户端快照,无法成为 runtime session 的唯一真相。 + +### 后端落点 + +建议收口到: + +1. `server-rs/crates/api-server/src/runtime_story.rs` +2. `server-rs/crates/api-server/src/story_sessions.rs` +3. `server-rs/crates/module-runtime/src/lib.rs` +4. `server-rs/crates/module-story/src/lib.rs` +5. `server-rs/crates/module-inventory/src/lib.rs` +6. `server-rs/crates/module-progression/src/lib.rs` +7. `server-rs/crates/module-runtime-story-compat/src/game_state.rs` + +SpacetimeDB 方向: + +1. 新增或扩展 `begin_rpg_runtime_session` reducer。 +2. 由 reducer 基于 `ctx.sender()`、作品 profile id、角色 id 创建 runtime session。 +3. 在表中写入初始场景、角色状态、背包、装备、NPC 状态、任务、progression。 +4. 前端只调用 reducer / API 并订阅或读取后端返回的 session view model。 + +注意:外部 AI / 网络调用不能放进 reducer;如果开局需要 LLM,应由 `api-server` / `platform-llm` 完成生成,再把确定结果写入 SpacetimeDB。 + +## 4.2 `P0` runtime story 网关仍要求客户端携带快照解析 + +### 代码证据 + +主要文件: + +1. `src/hooks/rpg-runtime-story/rpgRuntimeStoryGateway.ts` + +关键逻辑: + +1. 第 `112` 行到第 `123` 行:`buildRuntimeSnapshotRequest(...)` 把本地 `gameState` 和 `currentStory` 一并提交给后端。 +2. 第 `125` 行到第 `213` 行:`bridgeServerSceneTravelSnapshot(...)` 在前端补齐服务端旅行快照。 +3. 第 `216` 行到第 `317` 行:`bridgeServerNpcBattleSnapshot(...)` 在前端补齐 NPC 战斗阵型。 +4. 第 `323` 行到第 `341` 行:拉取 option catalog 时仍基于客户端快照。 +5. 第 `387` 行到第 `430` 行:正式动作结算后继续由前端对服务端快照做桥接修补。 + +### 为什么应迁 + +这类桥接逻辑意味着服务端返回结果不是完整业务真相,前端还在做二次裁决: + +1. 推断下一场景。 +2. 补齐场景 preset。 +3. 补齐遭遇预览。 +4. 补齐任务推进。 +5. 补齐 NPC 战斗阵型。 +6. 改写 `GameState` 中的战斗、场景、任务字段。 + +这些都应该由后端 runtime action resolver 在同一个事务边界内完成。 + +### 后端落点 + +建议收口到: + +1. `server-rs/crates/api-server/src/runtime_story/compat/game_state.rs` +2. `server-rs/crates/api-server/src/runtime_story/compat/npc_actions.rs` +3. `server-rs/crates/api-server/src/runtime_story/compat/equipment_actions.rs` +4. `server-rs/crates/api-server/src/runtime_story/compat/quest_actions.rs` +5. `server-rs/crates/module-runtime-story-compat/src/options.rs` +6. `server-rs/crates/module-runtime-story-compat/src/battle.rs` +7. `server-rs/crates/module-runtime-story-compat/src/view_model.rs` + +迁移后前端应该只提交: + +1. `sessionId` +2. `functionId` +3. `runtimePayload` +4. 当前 UI 所需的弱表现参数 + +后端应该基于已存 session state 解析动作,不再要求前端上传完整 `GameState`。 + +### 本轮落地口径(2026-04-28) + +本轮先收口 `runtime story` 网关这条 P0 主链: + +1. 前端 `rpgRuntimeStoryGateway.ts` 不再构造或上传 `snapshot.gameState / currentStory`。 +2. 前端 `rpgRuntimeStoryClient.ts` 的状态读取统一走 `GET /api/runtime/story/state/:sessionId`,动作结算统一走 `POST /api/runtime/story/actions/resolve` 且请求体只包含 `sessionId / clientVersion / action`。 +3. `idle_travel_next_scene` 的下一场景、场景 preset、遭遇清理、战斗清理与旅行计数由 `server-rs/crates/api-server/src/runtime_story/compat.rs` 在持久化快照上完成。 +4. `npc_fight / npc_spar` 的战斗阵型、`sceneHostileNpcs`、战前 encounter 归档与战斗字段由 `server-rs/crates/api-server/src/runtime_story/compat/npc_actions.rs` 在后端完成。 +5. 前端只消费后端返回的 hydrated snapshot 和 presentation,不再保留 `bridgeServer*Snapshot` 业务补丁。 + +### 本轮落地验收(2026-04-28) + +1. `rpgRuntimeStoryGateway.ts` 已删除 `buildRuntimeSnapshotRequest`、`bridgeServerSceneTravelSnapshot`、`bridgeServerNpcBattleSnapshot` 以及 NPC 战斗/旅行快照补丁依赖;option catalog 和 action resolve 都只提交 `sessionId / clientVersion / action / payload`。 +2. `rpgRuntimeStoryClient.ts` 已移除前端 `RuntimeStorySnapshotRequest` 入参;状态读取固定为 `GET /api/runtime/story/state/:sessionId`,动作结算请求体不再带 `snapshot` 字段。 +3. `server-rs` 已覆盖 `idle_travel_next_scene`、`npc_fight`、`npc_spar` 的后端快照补齐测试,并将 route 级测试改为从服务端持久化 session 读取,不再通过旧 save 端点上传整份前端快照铺垫。 +4. 已执行验收:`cargo check -p api-server --message-format short`、`cargo test -p api-server runtime_story_ -- --nocapture`、`npm run test -- src/services/rpg-runtime/rpgRuntimeStoryClient.test.ts src/hooks/rpg-runtime-story/runtimeStoryCoordinator.test.ts`。 +5. 搜索确认:`src/hooks/rpg-runtime-story/rpgRuntimeStoryGateway.ts` 与 `src/services/rpg-runtime/rpgRuntimeStoryClient.ts` 不再包含 `/state/resolve`、`buildRuntimeSnapshotRequest`、`bridgeServer*`、`params.snapshot` 或动作请求上传 `snapshot.gameState/currentStory` 的路径。 + +## 4.3 `P0` 自动保存仍由前端上传整份运行时快照 + +### 代码证据 + +主要文件: + +1. `src/hooks/rpg-session/useRpgSessionPersistence.ts` +2. `src/services/rpg-runtime/rpgSnapshotClient.ts` + +关键逻辑: + +1. `useRpgSessionPersistence.ts` 第 `12` 行到第 `23` 行:前端判断哪些运行态可以入正式存档。 +2. 第 `90` 行到第 `150` 行:前端把 `gameState / bottomTab / currentStory` 作为 payload 写入远端。 +3. 第 `216` 行到第 `234` 行:前端监听本地 `gameState` 变化并自动保存。 +4. 第 `236` 行到第 `263` 行:手动存档也上传整份本地快照。 +5. `rpgSnapshotClient.ts` 第 `31` 行到第 `48` 行:`putRpgSaveSnapshot(...)` 直接发送 `SavedGameSnapshotInput`。 + +### 为什么应迁 + +运行时存档不是普通表单保存,它是玩家进度、任务、背包、战斗、NPC、故事状态的正式真相。当前模式仍是“前端本地状态变化 -> 上传整份快照 -> 后端持久化”,后端缺少对状态版本、动作来源、事务一致性的最终解释权。 + +### 后端落点 + +建议改成: + +1. 后端 action resolver 每次动作结算时事务性写入 session state。 +2. 自动保存只变成后端已有 session state 的归档或 checkpoint。 +3. 前端最多提交 `sessionId`、`saveSlot`、`bottomTab` 等非业务 UI 状态。 +4. `runtime_save.rs` 负责快照归档,不再信任浏览器传入的整份 `GameState`。 + +涉及表结构时同步检查并更新 `migration.rs`。 + +## 4.4 `P0` story engine / chapter / world mutation 仍在前端编排 + +### 代码证据 + +主要文件: + +1. `src/hooks/rpg-runtime-story/progressionActions.ts` +2. `src/hooks/rpg-runtime-story/storyContextBuilder.ts` +3. `src/hooks/rpg-runtime-story/useRpgRuntimeStoryController.ts` + +关键逻辑: + +1. `progressionActions.ts` 第 `193` 行到第 `293` 行:前端生成章节任务并写入 `quests`。 +2. 第 `295` 行到第 `395` 行:前端收集 story signals、推进 thread、同伴反应、章节、旅程 beat、阵营/世界 mutation、setpiece。 +3. 第 `644` 行到第 `782` 行:前端提交生成后状态、追加 story history、执行 AI 后再做 recovery。 +4. `storyContextBuilder.ts` 第 `1` 行到第 `51` 行:前端引入大量 story engine director / graph / memory / prompt context 组件。 +5. `useRpgRuntimeStoryController.ts` 第 `80` 行到第 `103` 行:前端把 `generateInitialStory / generateNextStep` 注入本地 story request runtime。 + +### 为什么应迁 + +这些逻辑不是 UI,而是叙事引擎状态机: + +1. 章节任务发放。 +2. 线索和 thread 更新。 +3. 同伴关系变化。 +4. 营地事件与 setpiece 触发。 +5. 世界 mutation。 +6. 生成上下文组织。 +7. fallback / recovery。 + +这些都应作为后端 story session 的 reducer / domain service,而不是 React hook 的本地副作用。 + +### 后端落点 + +建议收口到: + +1. `server-rs/crates/api-server/src/runtime_story/compat/ai.rs` +2. `server-rs/crates/api-server/src/prompt/rpg/runtime_chat.rs` +3. `server-rs/crates/module-story/src/lib.rs` +4. `server-rs/crates/module-progression/src/lib.rs` +5. `server-rs/crates/module-runtime-story-compat/src/core.rs` +6. `server-rs/crates/module-runtime-story-compat/src/view_model.rs` + +前端保留: + +1. `currentStory` 展示。 +2. loading / error。 +3. SSE 增量文本。 +4. 视觉过场。 + +## 4.5 `P0` 战斗后处理、死亡复活、章节推进仍在前端补真相 + +### 代码证据 + +主要文件: + +1. `src/hooks/rpg-runtime-story/storyChoiceRuntime.ts` +2. `src/hooks/rpg-runtime-story/postBattleFlow.ts` + +关键逻辑: + +1. `storyChoiceRuntime.ts` 第 `1` 行到第 `6` 行仍引用掉落、背包合并等本地规则。 +2. 第 `304` 行到第 `390` 行:服务端动作返回后,前端仍判断死亡、等待复活、构造复活状态、构造战斗胜利后状态与 story。 +3. `postBattleFlow.ts` 第 `86` 行到第 `105` 行:前端构造战斗胜利后的 `GameState`。 +4. 第 `107` 行到第 `158` 行:前端推进 scene act 并构造战斗后 story / deferred options。 +5. 第 `161` 行到第 `205` 行:前端决定复活回第一场景、恢复血蓝、清战斗态、重建首幕 encounter preview。 + +### 为什么应迁 + +死亡、胜利、切磋完成、复活、掉落、战斗后章节推进都属于正式玩法结果。前端可以播死亡动画和过场,但不能决定: + +1. 玩家是否死亡。 +2. 玩家在哪里复活。 +3. 复活后血蓝。 +4. 战斗是否结束。 +5. 任务或章节是否推进。 +6. deferred options 里有哪些正式可选动作。 + +### 后端落点 + +建议收口到: + +1. `server-rs/crates/module-combat/src/lib.rs` +2. `server-rs/crates/module-runtime-story-compat/src/battle.rs` +3. `server-rs/crates/module-runtime-story-compat/src/view_model.rs` +4. `server-rs/crates/api-server/src/story_battles.rs` +5. `server-rs/crates/api-server/src/runtime_story/compat/presentation.rs` + +前端保留: + +1. 根据后端返回的 `presentation.battle` 播动画。 +2. 根据后端返回的 `nextStory` 展示文本和选项。 +3. 根据后端返回的 `GameState` 或 view model 渲染血条、角色、敌人。 + +## 5. 中优先级迁移项 + +## 5.1 `P1` NPC 交易、送礼的价格和库存校验仍在前端 + +### 代码证据 + +主要文件: + +1. `src/hooks/rpg-runtime-story/npcInteraction.ts` + +关键逻辑: + +1. 第 `186` 行到第 `218` 行:前端读取 NPC / 玩家物品,计算交易单价、最大数量、数量 clamp。 +2. 第 `629` 行到第 `683` 行:前端按本地价格和库存判断买入 / 卖出是否允许,再提交后端。 +3. 第 `685` 行到第 `702` 行:前端检查礼物是否存在,并构造送礼 action。 + +### 当前判断 + +这条链已经比早期更好:正式交易和送礼会调用 `resolveRpgRuntimeChoice(...)` 走后端。但前端仍在承担“能不能买、能不能卖、价格是多少、数量是否合法”的第一层裁决。 + +### 迁移建议 + +后端应该: + +1. 计算价格。 +2. 校验库存和货币。 +3. 校验 NPC 亲和度影响。 +4. 原子更新玩家库存、NPC 库存、货币和 NPC state。 +5. 返回可展示的交易 view model 与错误原因。 + +前端可以保留: + +1. 当前选择的 itemId / quantity。 +2. 后端返回价格的展示。 +3. modal 开关和数量输入。 + +### 本次落地设计(2026-04-28) + +为避免“前端只是少算了一处价格,但仍在提交前裁决库存”的半迁移,本次按下面边界收口: + +1. `server-rs` 在 runtime story compat 状态桥中编译 `gameState.runtimeNpcInteraction` 派生 view,包含当前 NPC、玩家货币、购买列表、出售列表、赠礼列表、服务端单价、服务端库存上限、赠礼好感增益与不可选原因。 +2. `resolve_npc_trade_action(...)` 与 `resolve_npc_gift_action(...)` 仍是唯一正式结算入口,提交后重新校验 mode、itemId、quantity、NPC 库存、玩家背包数量与玩家货币;前端展示的 view 只作为 UI 提示,不作为可信输入。 +3. 前端 `npcInteraction.ts` 不再读取本地 NPC / 玩家物品计算单价、总价、最大数量,也不再因为本地库存或货币不足提前阻断提交;确认时只提交 `{ mode, itemId, quantity }`,后端返回错误即展示错误。 +4. 前端 `NpcModals.tsx` 只渲染 `runtimeNpcInteraction` 中的价格、库存和赠礼增益;按钮禁用仅保留“未选择条目 / 服务端 view 标记不可选 / 当前操作正在提交”的表现态,不再用浏览器本地价格和货币重新裁决。 +5. 验收测试必须覆盖:后端购买成功、NPC 库存不足拒绝、玩家货币不足拒绝、出售成功、玩家背包数量不足拒绝、赠礼成功、礼物不存在拒绝;前端测试覆盖确认交易/赠礼在本地库存或货币不满足时仍提交后端,由后端错误接管。 + +## 5.2 `P1` 背包、装备、锻造可用性与配方视图仍在前端 + +### 代码证据 + +主要文件: + +1. `src/hooks/rpg-runtime-story/inventoryActions.ts` + +关键逻辑: + +1. 第 `44` 行到第 `52` 行:前端基于 `playerInventory / playerCurrency / worldType` 计算 forge recipe views。 +2. 第 `97` 行到第 `180` 行:前端先查找物品 / 装备 / 配方,再提交后端 reducer 风格 action。 + +### 当前判断 + +正式动作已经走后端,这是正确方向。但 `forge recipe view` 和本地物品存在性判断仍会让前端表现出“它知道业务规则”的形态。 + +### 迁移建议 + +后端应该提供: + +1. 当前玩家背包 view。 +2. 当前装备 view。 +3. 当前可锻造配方 view。 +4. 每个动作的 disabled / reason。 + +前端只渲染后端 view,并把用户选择提交给后端。 + +## 5.3 `P1` RPG 创作 profile 生成仍保留非浏览器 legacy AI 回退 + +### 迁移回填(2026-04-28) + +已完成本项迁移: + +1. `src/services/rpg-creation/rpgCreationGenerationClient.ts` 删除 `LegacyAiModule`、`loadLegacyAiModule` 与 `typeof window === 'undefined'` 分支。 +2. `src/services/rpg-creation/rpgCreationGenerationClient.node.test.ts` 锁定 node 环境也只 mock `requestJson`,不再触发前端 legacy AI 模块。 +3. `src/services/rpg-creation/index.ts` 删除 `generateLegacyCustomWorldProfile` 旧命名导出。 +4. `server-rs/crates/api-server/src/app.rs` 挂载 `POST /api/runtime/custom-world/profile`。 +5. `server-rs/crates/api-server/src/custom_world.rs` 复用 `generate_custom_world_foundation_draft(...)` 生成 profile,并补齐前端结果页所需稳定字段。 +6. 迁移说明见 `docs/technical/RPG_CREATION_PROFILE_GENERATION_BACKEND_MIGRATION_2026-04-28.md`。 + +### 迁移前历史证据 + +主要文件: + +1. `src/services/rpg-creation/rpgCreationGenerationClient.ts` + +关键逻辑: + +1. 第 `9` 行到第 `19` 行:前端 client 仍定义 `LegacyAiModule = typeof import('../ai')` 并动态加载旧 AI 模块。 +2. 第 `32` 行到第 `35` 行:非浏览器环境直接调用 `aiClient.generateCustomWorldProfile(...)`。 +3. 第 `43` 行到第 `51` 行:浏览器环境才请求 `/api/runtime/custom-world/profile`。 + +### 为什么应迁 + +这与 `RPG_PROMPT_FRONTEND_REMOVAL_AND_SERVER_RS_MIGRATION_2026-04-28.md` 的目标冲突。即使该分支主要服务测试或 SSR,它仍保留了“前端可持有 RPG 生成逻辑和 prompt”的技术后门。 + +### 迁移建议 + +1. 删除 `import('../ai')` 回退。 +2. 非浏览器环境也应通过 `server-rs` client / test mock 调用后端 contract。 +3. 若测试需要离线能力,应 mock `requestJson`,不要恢复前端生成链。 + +后端落点: + +1. `server-rs/crates/api-server/src/custom_world.rs` +2. `server-rs/crates/api-server/src/custom_world_foundation_draft.rs` +3. `server-rs/crates/api-server/src/prompt/rpg/foundation_draft.rs` +4. `server-rs/crates/platform-llm/` + +## 5.4 `P1` 创作结果页保存与 Agent session 真相优先级仍在前端编排 + +### 代码证据 + +主要文件: + +1. `src/components/rpg-entry/useRpgCreationResultAutosave.ts` +2. `src/components/rpg-entry/useRpgEntryLibraryDetail.ts` +3. `src/services/rpg-creation/rpgCreationPreviewAdapter.ts` + +关键逻辑: + +1. `useRpgCreationResultAutosave.ts` 负责 profile signature、自动保存请求去重、`upsertRpgWorldProfile(...)`、Agent session refresh。 +2. `useRpgEntryLibraryDetail.ts` 负责判断 draft work 应打开 Agent workspace、生成过程页还是结果页。 +3. `rpgCreationPreviewAdapter.ts` 在 `resultPreview` 缺失时回退读取 `draftProfile.legacyResultProfile`。 + +### 当前判断 + +这部分大多是创作 UI 编排,不能简单全迁。但下面三件事属于后端真相: + +1. 保存前 profile normalize。 +2. Agent session / result preview / legacyResultProfile 的优先级。 +3. 发布门禁与草稿是否可进入结果页。 + +### 迁移建议 + +后端应该提供一个稳定的 `creation_result_view` 或 `work_detail_view`: + +1. 已标准化 profile。 +2. 当前 session / work 状态。 +3. 是否可发布。 +4. 应打开的前端 stage。 +5. 缺失或失败时的恢复指令。 + +前端只根据 view model 切页面,不再自行解释 session 阶段。 + +## 5.5 `P1` 角色资产工坊默认 prompt 与缓存合并规则仍在前端 + +### 本轮落地状态(2026-04-28) + +已完成默认 prompt 与缓存合并规则迁移: + +1. 新增 `server-rs/crates/api-server/src/prompt/rpg/role_asset_studio.rs`,作为角色资产工坊默认 prompt、legacy prompt 过滤、逐动作 prompt 缓存合并的后端主源。 +2. 新增 `POST /api/runtime/custom-world/asset-studio/role/{character_id}/workflow`,由前端提交当前正在编辑的角色快照,后端返回合并后的 workflow view。 +3. 新增 `PUT /api/runtime/custom-world/asset-studio/role/{character_id}/workflow`,复用 OSS JSON 缓存保存,并补齐 `animationPromptTextByKey` 持久化。 +4. `RpgCreationRoleAssetStudioModalImpl.tsx` 不再调用 `buildDefaultRolePromptBundle`,也不再包含 legacy prompt 判断和缓存合并函数。 +5. 删除 `src/prompts/customWorldRolePromptDefaults.ts` 与旧兼容 re-export,避免 `src/` 继续持有角色资产默认 prompt 主源。 + +### 代码证据 + +主要文件: + +1. `src/components/rpg-creation-asset-studio/RpgCreationRoleAssetStudioModalImpl.tsx` +2. `src/components/rpg-creation-asset-studio/useRoleVisualCandidateWorkflow.ts` +3. `src/components/rpg-creation-asset-studio/useRoleAnimationWorkflow.ts` + +关键逻辑: + +1. `RpgCreationRoleAssetStudioModalImpl.tsx` 第 `54` 行到第 `85` 行:前端合并默认动画 prompt、缓存 prompt、legacy prompt。 +2. 第 `564` 行到第 `591` 行:前端调用 `buildDefaultRolePromptBundle(baseRole)` 生成默认视觉 / 动作 prompt。 +3. 第 `807` 行到第 `830` 行:前端保存工作流缓存,包括 prompt、草稿、asset id、animation map。 +4. `useRoleVisualCandidateWorkflow.ts` 和 `useRoleAnimationWorkflow.ts` 把 prompt 文本直接作为生成 payload。 + +### 当前判断 + +用户正在编辑的 prompt 草稿可以留在前端表单里,但默认 prompt 生成、legacy prompt 判断、缓存合并、生成参数默认值不应继续散落在 UI modal 内。 + +### 迁移建议 + +后端应该提供: + +1. `GET /api/runtime/custom-world/asset-studio/role/:id/workflow` +2. `PUT /api/runtime/custom-world/asset-studio/role/:id/workflow` +3. `POST /api/runtime/custom-world/assets/role-visual-candidates` +4. `POST /api/runtime/custom-world/assets/role-animation` + +并在 `server-rs/crates/api-server/src/prompt/rpg/` 或资产 prompt 模块中统一默认 prompt 生成。 + +前端只保留: + +1. prompt 文本框。 +2. 参考图上传 UI。 +3. 候选图 / 动画预览。 +4. 用户点击生成、应用、保存的交互入口。 + +## 6. 可保留在前端的 RPG 脚本 + +以下脚本当前主要承担表现层或 request client 职责,可以暂时保留: + +1. `src/components/rpg-runtime-shell/*` +2. `src/components/rpg-runtime-panels/RpgAdventurePanel.tsx` +3. `src/components/rpg-runtime-panels/RpgAdventurePanelOverlays.tsx` +4. `src/components/rpg-entry/RpgEntryHomeView.tsx` +5. `src/components/rpg-entry/RpgEntryWorldDetailView.tsx` +6. `src/services/rpg-runtime/rpgRuntimeRequest.ts` +7. `src/services/rpg-runtime/rpgRuntimeStoryClient.ts` +8. `src/services/rpg-runtime/rpgSnapshotClient.ts` +9. `src/services/rpg-creation/rpgCreationAgentClient.ts` +10. `src/services/rpg-creation/rpgCreationAssetClient.ts` +11. `src/services/rpg-creation/rpgCreationLibraryClient.ts` +12. `src/services/rpg-creation/rpgCreationRuntimeClient.ts` + +但要注意: + +1. client 文件可以保留请求封装,但不应继续加入 fallback 生成逻辑。 +2. UI 文件可以根据后端 view model 展示 disabled / reason,但不能本地重算业务可用性。 +3. snapshot client 可以保留读取 / 删除 / 归档入口,但不应让前端上传整份 `GameState` 作为正式真相。 + +## 7. 推荐迁移顺序 + +### 第一阶段:收运行时真相 + +优先迁移: + +1. `useRpgSessionBootstrap.ts` +2. `rpgRuntimeStoryGateway.ts` +3. `useRpgSessionPersistence.ts` + +目标: + +1. 后端创建 session。 +2. 后端保存 session state。 +3. runtime action 不再依赖客户端完整快照。 +4. 前端不再补战斗 / 旅行快照。 + +### 第二阶段:收 story engine 与战斗后处理 + +优先迁移: + +1. `progressionActions.ts` +2. `storyContextBuilder.ts` +3. `storyChoiceRuntime.ts` +4. `postBattleFlow.ts` + +目标: + +1. 后端统一处理 story history、thread、chapter、companion reaction、world mutation。 +2. 后端统一处理死亡、复活、胜利、切磋完成、战斗后选项。 +3. 前端只播放 presentation。 + +### 第三阶段:收 NPC / 背包 / 锻造可用性 + +优先迁移: + +1. `npcInteraction.ts` +2. `inventoryActions.ts` + +目标: + +1. 后端提供交易 / 礼物 / 背包 / 锻造 view model。 +2. 前端不再重算价格、数量、配方、动作合法性。 + +### 第四阶段:收创作链残留后门 + +优先迁移: + +1. `rpgCreationGenerationClient.ts` +2. `useRpgCreationResultAutosave.ts` +3. `useRpgEntryLibraryDetail.ts` +4. `rpgCreationPreviewAdapter.ts` +5. `RpgCreationRoleAssetStudioModalImpl.tsx` + +目标: + +1. 移除 `import('../ai')`。 +2. 后端输出稳定 result/work view。 +3. 后端统一角色资产工坊默认 prompt 和缓存合并规则。 + +## 8. 后端实现注意事项 + +### 4.3 落地记录:自动保存 checkpoint 化(2026-04-28) + +本轮已完成 `自动保存仍由前端上传整份运行时快照` 的迁移: + +1. `src/hooks/rpg-session/useRpgSessionPersistence.ts` 自动保存与手动保存不再构造 `gameState / currentStory` 上传体,只提交 `sessionId / bottomTab` checkpoint 元数据。 +2. `src/services/rpg-runtime/rpgSnapshotClient.ts` 的 `PUT /api/runtime/save/snapshot` 请求体改为 `RuntimeSaveCheckpointInput`,前端保存链路只请求后端刷新 checkpoint。 +3. `server-rs/crates/api-server/src/runtime_save.rs` 改为读取已存在的服务端 `runtime_snapshot`,校验 `runtimeSessionId` 一致后刷新 `savedAt / bottomTab / runtimeStats.playTimeMs / lastPlayTickAt`,不再信任浏览器传入完整运行态。 +4. 旧式 `gameState / currentStory` 上传体会被 `PutRuntimeSaveCheckpointRequest` 的 `deny_unknown_fields` 拒绝;无服务端快照、session 不一致、preview/test 快照都会返回冲突。 +5. 本轮不涉及 SpacetimeDB 表结构变更,因此 `server-rs/crates/spacetime-module/src/migration.rs` 无需调整。 + +对应测试: + +1. `src/services/rpg-runtime/rpgSnapshotClient.test.ts` 覆盖保存请求体不含 `gameState / currentStory`。 +2. `src/hooks/runtimeAuthGuards.test.tsx` 覆盖自动保存只提交 checkpoint。 +3. `server-rs/crates/api-server/src/runtime_save.rs` 覆盖旧式整快照上传拒绝、缺少服务端快照冲突、session 不一致冲突、成功 checkpoint 沿用服务端快照真相。 + +按 SpacetimeDB 约束,后续落地时要遵守: + +1. reducer 不返回数据,前端通过订阅 / 查询读取 view model。 +2. reducer 使用 `ctx.sender()` 做鉴权,不信任前端传入身份。 +3. reducer 必须确定性,不能访问网络、文件、外部随机。 +4. LLM、OSS、图片生成等外部 I/O 放在 `api-server` / `platform-*` crate 中,再把确定结果写回 SpacetimeDB。 +5. 前端调用 reducer 使用生成绑定和对象参数,不编辑生成代码。 +6. 涉及表结构修改时同步更新 `migration.rs`。 +7. 修改后端代码后统一执行 `npm run api-server:maincloud`,并跑对应自动测试。 + +## 9. 最小验收标准 + +后续迁移完成后,前端 RPG 脚本应满足: + +1. 搜索 `src/hooks/rpg-*` 不再出现创建完整 `GameState` 的逻辑。 +2. runtime action 请求不再携带完整 `gameState`。 +3. 前端不再出现 `bridgeServer*Snapshot` 这类业务补丁函数。 +4. 前端不再 `putSnapshot({ gameState, currentStory })`。 +5. 前端不再动态 `import('../ai')`。 +6. NPC 交易、送礼、背包、锻造的价格、合法性、结果都以后端 view model 为准。 +7. 战斗死亡、复活、胜利后状态都以后端返回为准。 +8. 创作结果页打开哪个 stage 由后端 work/session view 指示。 + +## 10. 一句话结论 + +当前 RPG 前端脚本最需要迁移的不是 UI 组件,而是仍残留在 hooks / gateway / client 里的: + +**开局造状态、运行时带快照解析、前端补服务端快照、自动保存整份 GameState、story engine 编排、战斗后处理、NPC/背包/锻造规则裁决,以及 RPG 创作生成和结果预览的 legacy 后门。** diff --git a/docs/audits/engineering/RPG_FRONTEND_SCRIPT_BACKEND_MIGRATION_COMPLETION_CHECK_2026-04-28.md b/docs/audits/engineering/RPG_FRONTEND_SCRIPT_BACKEND_MIGRATION_COMPLETION_CHECK_2026-04-28.md new file mode 100644 index 00000000..129cdfda --- /dev/null +++ b/docs/audits/engineering/RPG_FRONTEND_SCRIPT_BACKEND_MIGRATION_COMPLETION_CHECK_2026-04-28.md @@ -0,0 +1,169 @@ +# RPG 前端脚本后端迁移完成度核验(2026-04-28) + +## 1. 核验结论 + +本次按 `RPG_FRONTEND_SCRIPT_BACKEND_MIGRATION_AUDIT_2026-04-28.md` 中列出的应迁后端项逐项检查当前代码。 + +结论:**应迁移项尚未全部迁移完成。** + +当前状态: + +1. `已完成`:9 项。 +2. `部分完成`:1 项。 +3. `未发现完全未启动`:0 项。 + +本轮重新核查的变化: + +1. 上次残留的 `RPG 创作结果页` 保存前 profile normalize 已完成后端化。 +2. 新发现 `camp_travel_home_scene` 已登记为服务端 runtime function id,但正式点击仍会被前端专用旅行分支提前拦截并本地拼装场景迁移状态。 + +## 2. 核验口径 + +### 2.1 判定为已完成 + +满足以下条件才记为已完成: + +1. 前端不再构造正式业务状态。 +2. 前端不再上传完整 `GameState` 作为后端写入依据。 +3. 前端不再用本地规则裁决价格、库存、掉落、复活、章节推进、结果页真相优先级。 +4. 前端只保留请求封装、UI 展示、表单草稿、loading/error、动画表现和按钮禁用展示。 + +### 2.2 判定为部分完成 + +满足以下任一情况记为部分完成: + +1. 主链已经迁到 `server-rs`,但旧前端分支仍可被正式入口调用。 +2. 后端已经提供 view/action,但前端仍保留影响业务真相的 normalize、fallback 或 AI context 编排。 +3. 后端只覆盖部分状态机,前端仍负责另一部分正式状态推进。 + +## 3. 逐项结果 + +| 原审计项 | 当前状态 | 核验结论 | +| --- | --- | --- | +| `P0` 运行时开局 `GameState` 装配 | 已完成 | 正式开局状态由 `server-rs/crates/api-server/src/runtime_story/compat/bootstrap.rs` 创建并持久化;前端 `useRpgSessionBootstrap.ts` 只保留选择页占位态和 `beginRpgRuntimeStorySession(...)` 调用。 | +| `P0` runtime story 网关客户端快照解析/补丁 | 已完成 | `rpgRuntimeStoryGateway.ts` 不再有 `buildRuntimeSnapshotRequest` / `bridgeServer*Snapshot`;`rpgRuntimeStoryClient.ts` 读取 `/state/:sessionId`,动作提交 `/actions/resolve`,不再上传完整 `snapshot.gameState/currentStory`。 | +| `P0` 自动保存整份运行时快照 | 已完成 | `useRpgSessionPersistence.ts` / `rpgSnapshotClient.ts` 保存链路只提交 `sessionId/bottomTab` checkpoint;`runtime_save.rs` 从服务端已有快照刷新 checkpoint,并测试拒绝旧式完整快照上传。 | +| `P0` story engine / chapter / world mutation / prompt context 编排 | 部分完成 | 后端已有 `project_story_engine_after_action(...)` 与 `build_runtime_story_prompt_context(...)`,`/story/initial`、`/story/continue` 带 `sessionId` 时只从服务端 snapshot 投影 world / character / history / prompt context;但 `camp_travel_home_scene` 已是服务端 function id,前端仍先走本地 `runCampTravelHomeChoice(...)` 拼装场景迁移、encounter preview 和 runtimeStats。 | +| `P0` 战斗胜负后处理、死亡复活、战斗后章节推进 | 已完成 | `battle_* / inventory_use` 正式点击统一走 `runServerRuntimeChoiceAction(...)` 与后端 `/actions/resolve`;`storyChoiceContinuation.ts` 对战斗 / 逃脱 / 物品动作加硬保护,不再裁决掉落、任务推进、死亡复活或战后 story;旧 `postBattleFlow.ts` 正式状态构造函数已删除。 | +| `P1` NPC 交易/送礼价格数量库存校验 | 已完成 | 前端 `npcInteraction.ts` 已改为消费 `runtimeNpcInteraction` view 并只提交 `{ mode, itemId, quantity }`;后端 `npc_actions.rs` / `npc_support.rs` 负责价格、库存、货币、赠礼好感和原子更新。 | +| `P1` 背包/装备/锻造可用性与配方视图 | 已完成 | 前端 `inventoryActions.ts` 读取 `loadRpgRuntimeInventoryView(...)`,根据后端 action/view 提交;后端 `view_model.rs` / `forge.rs` 生成背包、装备槽、配方、`canCraft/enabled/reason`。 | +| `P1` RPG 创作 profile 生成 legacy AI 回退 | 已完成 | `rpgCreationGenerationClient.ts` 只调用 `/api/runtime/custom-world/profile`,不再动态 `import('../ai')` 或导出 `generateLegacyCustomWorldProfile`。 | +| `P1` 创作结果页保存与 Agent session/result preview 真相优先级 | 已完成 | 后端已提供 `GET /api/runtime/custom-world/agent/sessions/:sessionId/result-view`,统一 `targetStage/profileSource/canAutosaveLibrary/canSyncResultProfile`,前端不再直接读取 `legacyResultProfile`;保存前 canonicalize 已迁到 `server-rs`,`normalizeRpgEntryAgentBackedProfile(...)` 现在只透传兼容旧导入。 | +| `P1` 角色资产工坊默认 prompt 与缓存合并规则 | 已完成 | 默认 prompt、legacy prompt 过滤、逐动作缓存合并已在 `server-rs/crates/api-server/src/prompt/rpg/role_asset_studio.rs`;前端 modal 只调用 workflow API、保存用户草稿和发起生成/发布。 | + +## 4. 仍未完成的具体收尾点 + +### 4.1 story engine / prompt context 主链已完成,但 `camp_travel_home_scene` 仍残留前端正式分支 + +当前后端已经处理动作结算后的确定性 story projector: + +1. `server-rs/crates/module-runtime-story-compat/src/story_engine.rs` +2. `server-rs/crates/api-server/src/runtime_story/compat.rs` + +本轮已补齐 prompt context 后端 projector: + +1. `server-rs/crates/module-runtime-story-compat/src/prompt_context.rs` +2. `server-rs/crates/api-server/src/runtime_story/compat.rs` +3. `server-rs/crates/api-server/src/runtime_chat.rs` +4. `server-rs/crates/api-server/src/runtime_chat_plain.rs` + +完成状态: + +1. 前端不再决定 `conversationSituation`、`conversationPressure`、NPC 对话上下文、party relationship notes、scene pressure 文本等正式 prompt context。 +2. 前端 story initial / continue 在有 `runtimeSessionId` 时只提交 `sessionId / clientVersion / choice / lastFunctionId / requestOptions` 等轻量字段。 +3. 后端从已持久化 runtime snapshot 投影 `worldType / playerCharacter / sceneHostileNpcs / storyHistory / context`,旧 payload 字段只保留兼容。 +4. 角色私聊、NPC 对话、NPC 单轮聊天、NPC 招募对话已支持 `sessionId`,有 session 时上下文同样以后端 snapshot 为准。 +5. 前端奖励领取、NPC 聊天闭合与旧 `deferredRuntimeState` 兼容分支不再写入 `storyEngineMemory`,章节、scene act、thread、world mutation 等正式叙事记忆只以后端快照为准。 + +本轮验收: + +1. `cargo test -p module-runtime-story-compat prompt_context --manifest-path server-rs\Cargo.toml` 覆盖后端 prompt context projector 对场景、NPC 披露阶段、对话压力和关系态度的投影。 +2. `cargo test -p shared-contracts runtime_story_ai_request --manifest-path server-rs\Cargo.toml` 覆盖 story AI 请求可只携带 `sessionId` 的共享契约。 +3. `cargo test -p api-server runtime_story_initial_uses_server_snapshot_prompt_context_when_session_id_present --manifest-path server-rs\Cargo.toml` 覆盖 `/story/initial` 在 session 模式下以后端快照覆盖浏览器传入的 world / character / context。 +4. `npm run test -- src/services/ai.test.ts src/hooks/rpg-runtime-story/storyRequestCoordinator.test.ts src/hooks/rpg-runtime-story/useRpgRuntimeStoryController.test.tsx` 覆盖前端 story / chat 请求在 session 模式下只发送轻量 payload。 +5. `npm run test -- src/hooks/rpg-runtime-story/sessionActions.test.ts src/hooks/rpg-runtime-story/choiceActions.test.ts src/hooks/rpg-runtime-story/npcEncounterActions.test.ts` 覆盖前端旧 UI 分支不再回写后端拥有的 `storyEngineMemory`。 + +重新核查新增残留: + +1. `packages/shared/src/contracts/rpgRuntimeStoryAction.ts` 已把 `camp_travel_home_scene` 纳入 `TASK5_RUNTIME_FUNCTION_IDS` / `SERVER_RUNTIME_FUNCTION_IDS`。 +2. `server-rs/crates/api-server/src/runtime_story/compat.rs` 已有 `camp_travel_home_scene` resolver 分支,但当前只清理 encounter,并未承接前端旧分支里的目标场景、encounter preview 与完整离营故事提交。 +3. `src/hooks/rpg-runtime-story/choiceActions.ts` 仍在 `isRpgRuntimeServerFunctionId(...)` 之前判断 `isCampTravelHomeOption(...)`,并调用 `runCampTravelHomeChoice(...)`。 +4. `src/hooks/rpg-runtime-story/storyChoiceRuntime.ts` 的 `runCampTravelHomeChoice(...)` 会在浏览器中决定目标场景、清理战斗/遭遇、递增 `scenesTraveled`、构造 encounter preview,并通过 `commitGeneratedStateWithEncounterEntry(...)` 写入后续故事。 + +影响: + +1. 这条链不是纯动画表现,而是正式场景迁移、运行时统计、遭遇预览和后续故事提交。 +2. 它已经具备服务端 function id 身份,却没有统一走 `/api/runtime/story/actions/resolve`,因此仍不满足“前端只提交 action,后端返回 hydrated snapshot”的边界。 + +### 4.2 本地战斗 continuation 已收口到后端 + +当前后端已经有战斗终局收口: + +1. `server-rs/crates/module-runtime-story-compat/src/post_battle.rs` +2. `server-rs/crates/module-runtime-story-compat/src/battle.rs` +3. `server-rs/crates/api-server/src/runtime_story/compat.rs` + +本轮已完成前端旧正式分支收口: + +1. `src/hooks/rpg-runtime-story/choiceActions.ts` 不再保留 `shouldResolveCombatChoiceLocally(...)`,所有服务端 function id 都统一进入 `runServerRuntimeChoiceAction(...)`。 +2. `src/hooks/rpg-runtime-story/storyChoiceContinuation.ts` 对 `battle_* / inventory_use` 以及被分类为 `battle / escape` 的动作加硬保护,误入时只报错回退,不会写掉落、任务、复活或战后 story。 +3. `src/hooks/rpg-runtime-story/storyChoiceRuntime.ts` 删除本地敌对 NPC 掉落 reward helper,不再调用 `rollHostileNpcLoot(...)` / `addInventoryItems(...)` 构造正式战斗奖励。 +4. `src/hooks/rpg-runtime-story/postBattleFlow.ts` 与对应测试删除,前端不再保留 `buildPostBattleVictoryState(...)`、`buildPostBattleVictoryStory(...)`、`buildRevivedFirstSceneState(...)`、`buildDeathStory(...)` 作为正式状态构造入口。 + +已消除风险: + +1. `battle_* / inventory_use` 不再因 `inBattle`、`currentBattleNpcId`、可见 story option 等状态回落到本地结算。 +2. 本地 continuation 不再调用敌对 NPC 掉落、背包合并、敌对 NPC 任务推进。 +3. 前端不再构造死亡复活状态、胜利后 story、deferred options 和章节推进。 + +本轮验收: + +1. `choiceActions.test.ts` 覆盖 `battle_use_skill`、`battle_attack_basic` stale option、`inventory_use` 均只调用后端 resolver,不触发 `buildResolvedChoiceState(...)` / `playResolvedChoice(...)`。 +2. `storyChoiceRuntime.test.ts` 保留服务端 battle presentation 验收,确认胜利 / 失败最终采用服务端 hydrated snapshot。 +3. 搜索确认 `src/hooks/rpg-runtime-story` 不再包含 `shouldResolveCombatChoiceLocally`、`buildPostBattleVictory*`、`buildRevivedFirstSceneState`、`buildDeathStory`、`buildHostileNpcBattleReward`。 + +### 4.3 创作结果页保存前 normalize 已完成后端化 + +后端已经负责 Agent result-view: + +1. `server-rs/crates/api-server/src/custom_world.rs` +2. `packages/shared/src/contracts/rpgCreationResultView.ts` +3. `src/services/rpg-creation/rpgCreationAgentClient.ts` + +重新核查结果: + +1. `src/components/rpg-entry/rpgEntryShared.ts` +2. `src/components/rpg-entry/useRpgCreationResultAutosave.ts` +3. `server-rs/crates/api-server/src/custom_world.rs` + +当前完成状态: + +1. `normalizeRpgEntryAgentBackedProfile(...)` 现在直接返回原始 `profile`,注释明确保存前 canonicalize 已迁到 `server-rs`。 +2. `stringifyRpgEntryAgentBackedProfile(...)` 现在只做 `JSON.stringify(profile)`,不再触发前端 normalize。 +3. `put_custom_world_library_profile(...)` 写入作品库前调用 `canonicalize_custom_world_library_profile_payload(payload.profile)`。 +4. `serialize_sync_result_profile_action_payload(...)` 会在 Agent `sync_result_profile` action payload 中对 `profile` 执行 `canonicalize_custom_world_profile_before_save(...)`。 +5. 后端测试 `sync_result_profile_payload_is_canonicalized_on_server` 与 `custom_world_library_profile_payload_is_canonicalized_on_server` 已覆盖保存前 canonicalize。 + +本项不再计为未迁移残留。 + +## 5. 已完成项的保留边界 + +以下前端残留可以保留,不视为未迁移: + +1. `useRpgSessionBootstrap.ts` 的 `createSelectionGameState()`:只服务选择页占位,正式开局不使用它造运行时真相。 +2. `NpcModals.tsx` 的数量 stepper、价格展示和按钮禁用:只消费后端 view,不重新裁决价格或库存。 +3. `inventoryActions.ts` 的 `submitInventoryAction(...)`:只读取后端 action 的 `enabled/reason`,不本地计算规则。 +4. 角色资产工坊 modal 中的 prompt 输入框与缓存保存:这是用户正在编辑的 UI 草稿,默认 prompt 和合并规则已由后端 workflow 输出。 +5. `playServerBattlePresentation(...)`:只播放临时动画态,最终 `GameState/currentStory` 仍以服务端 snapshot 为准。 + +## 6. 下一步建议 + +推荐按风险顺序继续: + +1. 将 `camp_travel_home_scene` 点击链统一改为 `runServerRuntimeChoiceAction(...)` / `/api/runtime/story/actions/resolve`。 +2. 扩展 `server-rs/crates/api-server/src/runtime_story/compat.rs` 中的 `camp_travel_home_scene` resolver,让目标场景、encounter preview、`scenesTraveled`、故事提交和快照持久化全部由后端完成。 +3. 补齐前端测试,锁定 `camp_travel_home_scene` 不再调用 `runCampTravelHomeChoice(...)`;补齐后端 route 级测试,覆盖离营后 hydrated snapshot 字段。 + +## 7. 一句话结论 + +**当前迁移已经完成了开局、快照、存档、story engine / prompt context 主链、NPC、背包/锻造、战斗后处理、profile 生成、创作结果页 normalize 和角色资产 prompt 主链;但 `camp_travel_home_scene` 仍由前端专用分支拼装正式场景迁移状态,所以不能判定“应迁移项已全部迁移完成”。** diff --git a/docs/design/PLATFORM_BIG_FISH_ENTRY_HIDE_2026-04-28.md b/docs/design/PLATFORM_BIG_FISH_ENTRY_HIDE_2026-04-28.md new file mode 100644 index 00000000..bfe41790 --- /dev/null +++ b/docs/design/PLATFORM_BIG_FISH_ENTRY_HIDE_2026-04-28.md @@ -0,0 +1,33 @@ +# 平台入口隐藏大鱼吃小鱼创作入口设计 + +日期:`2026-04-28` + +## 1. 变更背景 + +平台当前“选择创作类型”弹层同时暴露 RPG、大鱼吃小鱼、拼图三类入口。 + +本轮需求只要求在平台里隐藏“大鱼吃小鱼”的创作入口,不要求删除已有玩法实现、运行时路由、作品数据或后台能力。 + +## 2. 落地边界 + +- 只调整平台入口层展示,不修改大鱼吃小鱼已有前后端链路。 +- 不删除 `big-fish` 相关路由、服务、作品详情、运行时与数据结构。 +- 隐藏策略应收敛到统一配置层,避免首页、弹层、后续复用入口出现显示状态漂移。 + +## 3. 实现方案 + +1. 在 `src/components/platform-entry/platformEntryCreationTypes.ts` 的创作类型元数据中增加 `hidden` 字段。 +2. 将 `big-fish` 类型标记为 `hidden: true`。 +3. 平台创作类型弹层渲染前统一过滤 `hidden` 项。 + +这样可以保证: + +- 平台用户看不到“大鱼吃小鱼”创作入口。 +- 若后续重新开放,只需改回配置,不必再拆 UI 逻辑。 +- 不影响既有直达路由、历史作品数据和开发中的玩法链路。 + +## 4. 验收点 + +- 平台“选择创作类型”弹层不再显示“大鱼吃小鱼”卡片。 +- RPG、拼图、“敬请期待”类卡片顺序与交互保持稳定。 +- 代码层不引入对 Big Fish 运行时或结果页的额外耦合修改。 diff --git a/docs/design/README.md b/docs/design/README.md index e365c5e8..2e39dc9e 100644 --- a/docs/design/README.md +++ b/docs/design/README.md @@ -11,6 +11,7 @@ - [CUSTOM_WORLD_SELF_OWNED_SETTING_LAYER_OPTIMIZATION_2026-04-08.md](./CUSTOM_WORLD_SELF_OWNED_SETTING_LAYER_OPTIMIZATION_2026-04-08.md):把模板依赖逐步迁成自定义世界自有设定层,并保证不破坏当前生成流程的优化方案。 - [MOBILE_CREATION_NEW_WORK_COMPACT_LAYOUT_2026-04-24.md](./MOBILE_CREATION_NEW_WORK_COMPACT_LAYOUT_2026-04-24.md):移动端创作页新建作品模块最多占用首屏约 1/3 高度的紧凑布局设计。 - [PLATFORM_CATEGORY_AND_CREATE_TAB_DESIGN_2026-04-24.md](./PLATFORM_CATEGORY_AND_CREATE_TAB_DESIGN_2026-04-24.md):平台入口新增分类 Tab、登录态导航裁剪与创作 Tab 视觉强化设计。 +- [PLATFORM_BIG_FISH_ENTRY_HIDE_2026-04-28.md](./PLATFORM_BIG_FISH_ENTRY_HIDE_2026-04-28.md):平台入口暂时隐藏大鱼吃小鱼创作卡片,但保留现有玩法链路。 - [UNIFIED_MODAL_WINDOW_DESIGN_2026-04-25.md](./UNIFIED_MODAL_WINDOW_DESIGN_2026-04-25.md):统一平台风与 RPG 像素风模态窗口外壳、交互边界和迁移顺序。 - [AI_NATIVE_RUNTIME_ITEM_SYSTEM_REDESIGN_2026-04-02.md](./AI_NATIVE_RUNTIME_ITEM_SYSTEM_REDESIGN_2026-04-02.md):运行时物品生成系统重设计。 - [LEVEL_PROGRESS_AND_CHAPTER_NPC_AUTO_SCALING_DESIGN_2026-04-20.md](./LEVEL_PROGRESS_AND_CHAPTER_NPC_AUTO_SCALING_DESIGN_2026-04-20.md):等级成长、章节经验节奏与 NPC 自动定级设计。 diff --git a/docs/experience/BIG_FISH_DRAFT_GENERATION_CHAIN_FIX_2026-04-28.md b/docs/experience/BIG_FISH_DRAFT_GENERATION_CHAIN_FIX_2026-04-28.md new file mode 100644 index 00000000..e49d3d05 --- /dev/null +++ b/docs/experience/BIG_FISH_DRAFT_GENERATION_CHAIN_FIX_2026-04-28.md @@ -0,0 +1,80 @@ +# 大鱼吃小鱼草稿生成链路修复 2026-04-28 + +## 背景 + +大鱼吃小鱼玩法的结果页已经具备等级卡、主图工坊、动作工坊和背景工坊,但当前 `big_fish_compile_draft` 只是把锚点交给 `module-big-fish` 的 `compile_default_draft(...)` 做静态模板拼装。 + +这会导致两个直接问题: + +1. 草稿编译虽然能成功进入结果页,但每一级实体只会拿到非常概括的模板文本,无法真正产出“实体名称、文字描述、形象描述、待机动作描述、移动动作描述”这一组首稿。 +2. 主图和动作工坊默认提示词没有绑定到一份足够细的草稿真相源,动作面板只能看到合并后的 `motionPromptSeed`,会表现成“草稿生成一带而过,所有内容都没有正常生成”。 + +## 本次修复口径 + +### 1. 每级等级蓝图必须补齐的文本字段 + +大鱼吃小鱼每一级 `level blueprint` 在保留原有字段的同时,新增并持久化下面这些文本真相: + +1. `textDescription` + - 当前等级实体的正文文字描述。 + - 用于结果页等级卡和后续重生成时的人类可读设定底稿。 +2. `visualDescription` + - 当前等级实体的形象描述。 + - 主图工坊默认输入内容直接取这份字段。 +3. `idleMotionDescription` + - 当前等级待机动作描述。 + - `idle_float` 动作工坊默认输入内容直接取这份字段。 +4. `moveMotionDescription` + - 当前等级移动动作描述。 + - `move_swim` 动作工坊默认输入内容直接取这份字段。 + +### 2. 默认提示词流转规则 + +草稿生成、结果页工坊和正式资产生成统一按下面口径流转: + +1. 草稿编译阶段先产出上述结构化文本字段。 +2. 主图工坊默认文案: + - 优先显示 `visualDescription` + - `visualPromptSeed` 作为主图正式生图提示词的冻结快照,可由 `visualDescription` 组合生成 +3. 动作工坊默认文案: + - `idle_float` 优先显示 `idleMotionDescription` + - `move_swim` 优先显示 `moveMotionDescription` + - `motionPromptSeed` 继续保留为动作方向总提示词摘要,但具体动作正式生图必须显式拼入动作位对应描述 +4. 草稿阶段生成的正式主图、动作图和后续重生成,都只能读取同一份 `draft.levels[*]` 真相,前端不得本地拼接新的设定文案。 + +### 3. 编译策略 + +`big_fish_compile_draft` 需要升级为: + +1. `api-server` 先调用 LLM 做结构化草稿编译。 +2. 若 LLM 成功,则把完整 `draft_json` 写回 SpacetimeDB。 +3. 若 LLM 不可用、返回非法 JSON 或字段缺失,则退回 `compile_default_draft(...)` 的 deterministic fallback。 + +这样可以同时保证: + +1. 正常环境下草稿不再只是模板壳。 +2. 模型偶发失败时不会打断结果页主链。 +3. SpacetimeDB reducer 不承担外部网络调用,仍然符合后端边界。 + +## 落地范围 + +本次修复涉及: + +1. `server-rs/crates/module-big-fish` +2. `server-rs/crates/spacetime-module` +3. `server-rs/crates/spacetime-client` +4. `server-rs/crates/shared-contracts` +5. `server-rs/crates/api-server` +6. `packages/shared/src/contracts/bigFish.ts` +7. `src/components/big-fish-result/BigFishResultView.tsx` + +## 验收口径 + +修复后需要满足下面这些观察结果: + +1. 点击“生成草稿”后,`draft.levels[*]` 不再只有空泛模板,而是每级都带名称、文字描述、形象描述、待机动作描述、移动动作描述。 +2. 打开主图工坊时,默认文本来自当前等级的 `visualDescription`。 +3. 打开待机动作工坊时,默认文本来自当前等级的 `idleMotionDescription`。 +4. 打开移动动作工坊时,默认文本来自当前等级的 `moveMotionDescription`。 +5. 资产槽位 `promptSnapshot` 与对应动作位 / 主图位的默认提示词一致。 +6. LLM 不可用时仍然能生成一版可用 fallback 草稿,而不是直接报错或写入空草稿。 diff --git a/docs/experience/PUZZLE_LOCAL_RUN_LEADERBOARD_FIX_2026-04-28.md b/docs/experience/PUZZLE_LOCAL_RUN_LEADERBOARD_FIX_2026-04-28.md new file mode 100644 index 00000000..84820802 --- /dev/null +++ b/docs/experience/PUZZLE_LOCAL_RUN_LEADERBOARD_FIX_2026-04-28.md @@ -0,0 +1,27 @@ +# 拼图本地运行态通关排行榜误请求修复记录 + +## 问题现象 + +拼图关卡完成后,右下角会弹出错误提示,内容表现为拼图 `run` 不存在。 + +## 根因 + +当前拼图玩法仍有一条前端本地兜底链路: + +1. 进入拼图测试或公开作品体验时,前端先创建 `local-puzzle-run-*` 形式的本地运行态。 +2. 这类 `run` 只存在于前端内存,不存在后端持久化记录。 +3. 通关副作用里却统一调用了后端 `submitPuzzleLeaderboard(runId, payload)`。 +4. 后端拿到本地 `runId` 后无法找到真实记录,于是返回“run 不存在”,最终在运行时右下角暴露成错误提示。 + +## 修复口径 + +本次不改后端接口,也不把本地兜底 run 强行持久化到后端,而是先把边界收口到前端: + +1. 显式识别 `local-puzzle-run-*` 这类本地 run。 +2. 本地 run 通关后不再请求后端排行榜接口。 +3. 直接在前端本地生成只包含当前玩家成绩的排行榜数据,保证结算弹窗仍可展示成绩。 +4. 真实后端 run 仍继续走正式排行榜提交流程,不影响后续 Rust / SpacetimeDB 版本的统一收口。 + +## 经验结论 + +只要某条玩法链路还保留“本地 run / 本地快照”兜底,就不能在通关、副作用、排行榜、下一关等后置动作里默认把它当成后端真 run 使用。必须先做运行态来源分流,再决定是否调用依赖真实 runId 的接口。 diff --git a/docs/experience/README.md b/docs/experience/README.md index 76c9f614..9c15925a 100644 --- a/docs/experience/README.md +++ b/docs/experience/README.md @@ -32,3 +32,5 @@ - [AGENT_EMPTY_SESSION_DRAFT_VISIBILITY_2026-04-26.md](./AGENT_EMPTY_SESSION_DRAFT_VISIBILITY_2026-04-26.md):记录 Agent 空会话不应进入作品草稿列表的后端判定规则。 - [BIG_FISH_PUBLISH_FEEDBACK_FIX_2026-04-26.md](./BIG_FISH_PUBLISH_FEEDBACK_FIX_2026-04-26.md):记录大鱼吃小鱼发布成功后结果页反馈与作品列表刷新的修复口径。 - [BIG_FISH_WORKS_JSON_COMPAT_FIX_2026-04-28.md](./BIG_FISH_WORKS_JSON_COMPAT_FIX_2026-04-28.md):记录大鱼作品列表 `items_json` 字段升级后的向后兼容修复口径,避免旧 JSON 直接打崩 works 接口。 +- [BIG_FISH_DRAFT_GENERATION_CHAIN_FIX_2026-04-28.md](./BIG_FISH_DRAFT_GENERATION_CHAIN_FIX_2026-04-28.md):记录大鱼吃小鱼草稿生成从结构化内容产出到主图/动作默认提示词回填的修复口径。 +- [PUZZLE_LOCAL_RUN_LEADERBOARD_FIX_2026-04-28.md](./PUZZLE_LOCAL_RUN_LEADERBOARD_FIX_2026-04-28.md):记录拼图本地 run 通关后误请求后端排行榜、导致“run 不存在”报错的边界修复口径。 diff --git a/docs/reference/README.md b/docs/reference/README.md index b8e4dae8..3dacf4b5 100644 --- a/docs/reference/README.md +++ b/docs/reference/README.md @@ -4,11 +4,13 @@ - [BUSINESS_PROMPT_INVENTORY_2026-04-19.md](./BUSINESS_PROMPT_INVENTORY_2026-04-19.md):业务中现存提示词的总清单,覆盖后端主链、前端遗留、自定义世界、角色形象生成、场景背景生成与工具链 prompt。 - [FUNCTION_SCRIPT_CATALOG_2026-04-04.md](./FUNCTION_SCRIPT_CATALOG_2026-04-04.md):Function 独立脚本目录与分类速查。 +- [RPG_CREATION_AND_RUNTIME_SCRIPT_RESPONSIBILITY_MAP_2026-04-28.md](./RPG_CREATION_AND_RUNTIME_SCRIPT_RESPONSIBILITY_MAP_2026-04-28.md):RPG 创作功能脚本与 RPG 运行时脚本的职责地图,覆盖前端入口、编排、表现、client、`server-rs` 与 SpacetimeDB 侧落点。 - [TASK_GENERATION_TRACE_2026-04-08.md](./TASK_GENERATION_TRACE_2026-04-08.md):任务描述、达成条件与奖励生成链路梳理。 - [CUSTOM_WORLD_TEMPLATE_DEPENDENCY_INVENTORY_2026-04-08.md](./CUSTOM_WORLD_TEMPLATE_DEPENDENCY_INVENTORY_2026-04-08.md):自定义世界当前仍依赖哪些模板世界设定的清单。 ## 使用建议 - 需要快速定位 Function 脚本,而不是阅读长篇方案时,优先看这里。 +- 需要快速判断“RPG 创作链和 RPG 运行时链分别该改哪些脚本”时,优先看 RPG 脚本职责地图。 - 需要判断“武侠 / 仙侠模板层”哪些还能删、哪些不能删时,优先看自定义世界模板依赖清单。 - 如果要评估 Function 分层是否合理,再配合 `docs/audits/FUNCTION_DESIGN_AUDIT_2026-04-03.md` 一起看。 diff --git a/docs/reference/RPG_CREATION_AND_RUNTIME_SCRIPT_RESPONSIBILITY_MAP_2026-04-28.md b/docs/reference/RPG_CREATION_AND_RUNTIME_SCRIPT_RESPONSIBILITY_MAP_2026-04-28.md new file mode 100644 index 00000000..60aad456 --- /dev/null +++ b/docs/reference/RPG_CREATION_AND_RUNTIME_SCRIPT_RESPONSIBILITY_MAP_2026-04-28.md @@ -0,0 +1,873 @@ +# RPG 创作功能与运行时脚本职责地图(2026-04-28) + +## 1. 文档目的 + +这份文档只做一件事: + +**把当前仓库里 RPG 创作功能相关脚本,以及 RPG 运行时游戏相关脚本,按真实职责整理成一份可检索的地图。** + +这里的“脚本职责”强调的是: + +1. 哪个脚本是入口。 +2. 哪个脚本负责状态编排。 +3. 哪个脚本只负责表现层。 +4. 哪个脚本负责请求后端。 +5. 哪个脚本在 `server-rs` 中承接正式业务真相。 +6. 哪些脚本仍处于兼容桥接层,不应继续扩张。 + +--- + +## 2. 当前口径 + +### 2.1 唯一后端口径 + +按当前工程基线,正式后端以 `server-rs` 为准: + +1. HTTP / SSE 门面:`server-rs/crates/api-server/src/` +2. 共享契约:`server-rs/crates/shared-contracts/src/` +3. SpacetimeDB 模块与领域表/过程:`server-rs/crates/module-*/src/` +4. 客户端绑定与调用封装:`server-rs/crates/spacetime-client/src/` + +### 2.2 当前前端口径 + +前端已基本按 RPG 域拆成三条主链: + +```text +平台入口与创作入口 +-> RPG 创作链 +-> RPG 运行时链 +``` + +但当前仍存在一些兼容桥接脚本,例如: + +1. `src/components/rpg-entry/RpgEntryFlowShell.tsx` +2. `src/services/rpg-runtime/rpgRuntimeChatClient.ts` +3. `server-rs/crates/api-server/src/runtime_story.rs` + +这些文件的职责主要是**兼容旧入口**,不是长期承载复杂逻辑的主战场。 + +--- + +## 3. 全局总图 + +```text +平台入口层 +-> 创作入口 / 运行时入口分流 + +RPG 创作链: +平台入口 +-> 创作工作台 / 共创会话 +-> 结果页 +-> 实体编辑器 / 角色资产工坊 +-> 创作域 client +-> server-rs custom_world / custom_world_ai / prompt/rpg +-> SpacetimeDB custom-world 相关表与过程 + +RPG 运行时链: +平台入口 / 世界详情 / 继续游戏 +-> session bootstrap / persistence +-> runtime shell / panel router / adventure panel +-> runtime story hooks / gateway / client +-> functionCatalog / prompt/rpg +-> server-rs runtime_story / runtime_chat / runtime_save / story_battles / story_sessions +-> shared-contracts + module-runtime + module-story + module-runtime-story-compat +``` + +--- + +## 4. RPG 创作功能脚本职责 + +## 4.1 创作入口与平台分流 + +### `src/components/platform-entry/PlatformEntryFlowShell.tsx` + +职责: + +1. 平台入口通用壳层。 +2. 统一分流 RPG、Big Fish、Puzzle 等不同玩法入口。 +3. 作为多玩法并列入口的稳定门面。 + +说明: + +1. 这是当前真正的平台入口壳层。 +2. RPG 创作和 RPG 运行时入口都先经过这里。 + +### `src/components/platform-entry/PlatformEntryFlowShellImpl.tsx` + +职责: + +1. 平台首页、详情页、创作类型选择、RPG 创作恢复、结果页进入等总编排。 +2. 协调 RPG 创作 Agent、Big Fish、Puzzle 的进入和返回。 +3. 连接平台级导航、作品库、公开广场、详情页和创作工作流。 + +说明: + +1. 这是当前“平台级大编排器”。 +2. 它不是纯 RPG 文件,但当前 RPG 创作入口真实在这里收口。 + +### `src/components/custom-world-home/CustomWorldCreationHub.tsx` + +职责: + +1. 展示 RPG 创作工作台。 +2. 负责草稿、已发布作品、跨玩法作品卡片的列表层表现。 +3. 负责“创建新作品”“打开草稿”“进入已发布作品”“体验作品”“删除作品”的入口分发。 + +说明: + +1. 这是 RPG 创作库的主表现层。 +2. 它不负责正式生成逻辑,只负责把用户动作抛给上层控制器。 + +--- + +## 4.2 RPG 共创会话链 + +### `src/components/custom-world-agent/CustomWorldAgentWorkspace.tsx` + +职责: + +1. 把 RPG 世界共创会话映射成通用 `CreationAgentWorkspace` 可消费的视图模型。 +2. 组织世界承诺、玩家幻想、主题边界、核心冲突等锚点展示。 +3. 提供“发送消息”“快捷动作”“生成底稿”等交互入口。 + +说明: + +1. 这是 RPG 创作 Agent 会话的前端展示壳层。 +2. 它负责把领域数据翻译成工作台视图,不负责真正的网络请求。 + +### `src/services/rpg-creation/rpgCreationAgentClient.ts` + +职责: + +1. 创建 RPG 创作会话。 +2. 读取会话快照。 +3. 发送消息、流式发送消息。 +4. 执行创作动作。 +5. 读取操作进度和草稿卡详情。 + +说明: + +1. 这是 RPG 创作 Agent 的主 client 入口。 +2. 前端所有“会话级创作行为”最终都应该走这里,而不是继续回流到通用 `aiService.ts`。 + +### `server-rs/crates/api-server/src/custom_world.rs` + +职责: + +1. 承接 RPG 世界库、世界详情、作品发布、取消发布、作品删除等 HTTP 接口。 +2. 承接创作 Agent session、message、action、operation、card detail 等接口。 +3. 协调底稿写回、资产生成、结果预览、发布门禁等后端业务。 + +说明: + +1. 这是当前 RPG 创作后端最大入口文件之一。 +2. 前端创作会话和作品库的大部分正式请求都在这里收口。 + +### `server-rs/crates/api-server/src/custom_world_agent_turn.rs` + +职责: + +1. 承接 RPG 共创聊天单轮执行。 +2. 负责把用户消息、会话状态、锚点内容和输出结构组织成一轮 Agent turn。 +3. 负责回写消息、操作、阶段进度和结果。 + +### `server-rs/crates/api-server/src/custom_world_foundation_draft.rs` + +职责: + +1. 负责 RPG 世界底稿生成链。 +2. 组织底稿结构、草稿写回、阶段进度推进和失败恢复。 + +### `server-rs/crates/api-server/src/custom_world_ai.rs` + +职责: + +1. 提供 RPG 创作过程中的场景图、场景 NPC、角色、地标等 AI 衍生生成接口。 +2. 把前端“补实体”“补图”“补角色”动作收口到后端。 + +--- + +## 4.3 RPG 创作结果页与编辑链 + +### `src/components/rpg-creation-result/RpgCreationResultView.tsx` + +职责: + +1. RPG 创作结果页 façade。 +2. 只桥接到真实实现,不承载复杂逻辑。 + +说明: + +1. 这是稳定入口。 +2. 真正逻辑在 `RpgCreationResultViewImpl.tsx` 及其配套 hooks 内。 + +### `src/components/rpg-creation-result/useRpgCreationResultActions.ts` + +职责: + +1. 管理结果页上的实体新增、删除、局部重生成、最近新增实体高亮等交互。 +2. 调用 `rpgCreationAssetClient` 生成 playable / story / landmark。 +3. 协调结果页与编辑器、资产工坊之间的动作状态。 + +说明: + +1. 这是结果页最核心的动作编排脚本之一。 +2. “结果页做什么”主要在这里定义,不在纯展示组件里定义。 + +### `src/components/rpg-creation-editor/RpgCreationEntityEditorModal.tsx` + +职责: + +1. RPG 实体编辑器 modal 的稳定入口。 +2. 把复杂表单实现桥接到 `RpgCreationEntityEditorModalImpl.tsx`。 + +说明: + +1. 这是编辑器 façade。 +2. 后续编辑器拆 section 时应继续改 impl,不要把复杂逻辑塞回 façade。 + +### `src/components/rpg-creation-asset-studio/RpgCreationRoleAssetStudioModal.tsx` + +职责: + +1. 角色资产工坊 modal 的稳定入口。 +2. 把角色形象、动作、候选图、动画等细节桥接到真实实现。 + +### `src/components/rpg-creation-result/RpgCreationAssetDebugPanel.tsx` + +职责: + +1. 用于查看创作资产链路状态、调试结果页资产问题。 +2. 服务于结果页调试与排查,而不是普通玩家主流程。 + +--- + +## 4.4 RPG 创作域 client 分层 + +### `src/services/rpg-creation/rpgCreationGenerationClient.ts` + +职责: + +1. 负责“生成 RPG 世界底稿 / profile”。 +2. 浏览器、SSR、Vitest node 环境统一请求 `/api/runtime/custom-world/profile`。 +3. 不再动态导入 `src/services/ai.ts`,测试离线能力只能 mock API client。 + +说明: + +1. 这是“世界底稿生成”的入口 client。 +2. profile 生成 prompt 与 LLM 编排已经收口到 `server-rs`,前端只保留请求 contract。 + +### `src/services/rpg-creation/rpgCreationAssetClient.ts` + +职责: + +1. 负责场景图、角色、地标、场景 NPC、封面图等资产请求。 +2. 负责历史资产列表读取。 +3. 给结果页和资产工坊提供统一资产接口。 + +### `src/services/rpg-creation/rpgCreationLibraryClient.ts` + +职责: + +1. 负责世界库、作品广场、作品详情、保存、删除、发布、下架。 +2. 承接 RPG 创作结果进入库与进入广场的正式请求。 + +### `src/services/rpg-creation/rpgCreationPreviewAdapter.ts` + +职责: + +1. 把 Agent session / result preview 转成结果页可消费预览模型。 +2. 是“生成链结果”和“结果页展示模型”之间的适配层。 + +### `src/services/rpg-creation/rpgCreationWorkClient.ts` + +职责: + +1. 负责作品工作台列表和会话删除等工作流外围请求。 + +--- + +## 4.5 RPG 创作提示词与内容编排脚本 + +### 前端提示词目录 `src/prompts/` + +与 RPG 创作直接相关的脚本: + +1. `src/prompts/customWorldPrompts.ts` + 负责自定义世界生成相关提示词。 +2. `src/prompts/customWorldEntityActionPrompts.ts` + 负责实体生成与实体动作提示词。 + +说明: + +1. 角色资产工坊默认 prompt 与缓存合并规则已经迁入 Rust,前端不再保留 `customWorldRolePromptDefaults.ts` 主源。 + +### Rust 提示词目录 `server-rs/crates/api-server/src/prompt/rpg/` + +与 RPG 创作直接相关的脚本: + +1. `foundation_draft.rs` + 负责 RPG 世界底稿生成提示词。 +2. `agent_chat.rs` + 负责 RPG 共创聊天提示词。 +3. `role_asset_studio.rs` + 负责角色资产工坊默认 prompt、legacy prompt 过滤与缓存合并 workflow view。 + +说明: + +1. RPG 创作 prompt 已在 Rust 侧按 `rpg/` 子目录收口。 +2. 创作语义的正式后端提示词应该优先在这里改,而不是散改到路由或 service 中。 + +--- + +## 4.6 RPG 创作在 SpacetimeDB / 契约层的职责 + +### `server-rs/crates/shared-contracts/src/runtime.rs` + +职责: + +1. 提供 RPG 创作请求/响应需要复用的共享运行时契约。 + +### `server-rs/crates/module-custom-world/src/lib.rs` + +职责: + +1. 承接自定义世界作品、会话、发布、草稿等正式模块能力。 +2. 作为 RPG 创作在 SpacetimeDB 的主要领域模块之一。 + +### `server-rs/crates/spacetime-client/src/custom_world.rs` + +职责: + +1. 为 `api-server` 提供自定义世界相关过程调用和数据读取封装。 +2. 把 API 层和 SpacetimeDB 模块调用隔开。 + +--- + +## 5. RPG 运行时游戏脚本职责 + +## 5.1 运行时入口与 Session 初始化 + +### `src/components/rpg-entry/RpgEntryFlowShell.tsx` + +职责: + +1. 兼容旧 RPG 入口导入路径。 +2. 真实实现已桥接到 `PlatformEntryFlowShell`。 + +说明: + +1. 这是兼容入口,不是当前主逻辑实现点。 +2. 如果要改多玩法入口或 RPG 入口主链,应优先看 `platform-entry/`。 + +### `src/hooks/rpg-session/useRpgSessionBootstrap.ts` + +职责: + +1. 负责 RPG 新开局初始化。 +2. 负责世界选择、角色确认、初始场景、初始遭遇、初始库存、初始装备、初始 progression 的装配。 +3. 负责把自定义世界 profile 编译成可运行 `GameState`。 + +说明: + +1. 这是“开始游戏”最关键的前端 session 装配脚本。 +2. 进入运行态前的本地初始态主要在这里成型。 + +### `src/hooks/rpg-session/useRpgSessionPersistence.ts` + +职责: + +1. 负责远端快照读取。 +2. 负责自动存档。 +3. 负责继续游戏恢复。 +4. 负责在恢复快照后刷新 runtime story 状态。 + +说明: + +1. 这是运行时持久化主入口。 +2. 继续游戏、保存退出、自动存档主要都在这里编排。 + +### `src/hooks/rpg-session/useRpgRuntimeSession.ts` + +职责: + +1. 作为 RPG 主运行态装配器。 +2. 组合 bootstrap、persistence、combat、npc interaction、runtime story、背景音乐等能力。 +3. 最终输出 `RpgRuntimeShell` 所需完整 props。 + +说明: + +1. 这是前端运行时主装配入口。 +2. 查“为什么运行态拿到这些状态和事件回调”时,应优先看这里。 + +--- + +## 5.2 运行时 UI 壳层与面板层 + +### `src/components/rpg-runtime-shell/RpgRuntimeShell.tsx` + +职责: + +1. 承接 RPG 运行态总外壳。 +2. 装配画布舞台、阶段路由、overlay host、运行时级 UI chrome。 +3. 保持平台壳层和 RPG 壳层之间的显示切换。 + +### `src/components/rpg-runtime-shell/RpgRuntimeStageRouter.tsx` + +职责: + +1. 在平台入口态、选角态、冒险态之间路由。 +2. 把 session / story / entry 的装配结果分发给对应页面。 + +### `src/components/rpg-runtime-panels/RpgRuntimePanelRouter.tsx` + +职责: + +1. 在冒险、角色、背包等主面板间切换。 +2. 管运行态主 tab 的表现层分发。 + +### `src/components/rpg-runtime-panels/RpgAdventurePanel.tsx` + +职责: + +1. 作为 RPG 冒险主面板。 +2. 负责展示剧情文本、选项列表、任务状态、战斗状态、资源状态、存档入口等主玩法信息。 +3. 负责把运行态 story 选项真正落成可点击 UI。 + +说明: + +1. 这是当前运行时前台最核心的展示脚本之一。 +2. 它负责表现和 UI 交互,不负责正式状态真相裁决。 + +--- + +## 5.3 运行时剧情主链 + +### `src/hooks/rpg-runtime-story/useRpgRuntimeStory.ts` + +职责: + +1. 作为 RPG runtime story 顶层装配入口。 +2. 组合角色聊天流、story controller、story flow。 +3. 向运行态主链输出 `currentStory`、`displayedOptions`、战斗奖励 UI、NPC UI、任务 UI 等完整剧情交互能力。 + +说明: + +1. 前端剧情主链的总入口是它。 +2. 如果要理解运行时故事层如何被装配,先看这里。 + +### `src/hooks/rpg-runtime-story/useRpgRuntimeStoryController.ts` + +职责: + +1. 管理当前故事、加载态、错误态和故事请求控制。 +2. 是前端 story 状态层的核心控制器。 + +### `src/hooks/rpg-runtime-story/useRpgRuntimeStoryFlow.ts` + +职责: + +1. 负责真正的剧情流编排。 +2. 负责选项刷新、选项点击、NPC 交互、地图旅行、战斗奖励 UI、任务 UI、目标 UI 的组合。 + +### `src/hooks/rpg-runtime-story/useRpgRuntimeInteractionFlow.ts` + +职责: + +1. 负责把不同类型的运行时交互分发到对应流程。 +2. 是运行时“动作入口分发层”。 + +### `src/hooks/rpg-runtime-story/useRpgRuntimeNpcInteraction.ts` + +职责: + +1. 负责 NPC 聊天、送礼、招募、委托查看等 NPC 交互链。 +2. 组织 NPC 相关 UI 状态和动作入口。 + +### `src/hooks/rpg-runtime-story/rpgRuntimeStoryGateway.ts` + +职责: + +1. 负责前端 runtime story 与后端 runtime story 的网关衔接。 +2. 负责加载 option catalog、恢复 runtime story、提交 choice。 +3. 负责把服务端快照桥接回前端可消费状态。 + +说明: + +1. 这是前端到后端 runtime story 主链的关键边界文件。 +2. 正式动作解析应该尽量经过这里,而不是在面板里直接组请求。 + +### `src/services/rpg-runtime/rpgRuntimeStoryClient.ts` + +职责: + +1. 请求 `/api/runtime/story` 的 state 和 action 接口。 +2. 把服务端 `RuntimeStoryOptionView` 适配成前端 `StoryOption`。 +3. 负责 sessionId、clientVersion、snapshot 请求结构和响应快照反序列化。 + +说明: + +1. 这是运行时故事 HTTP client 的主入口。 +2. “服务端动作解析”和“服务端状态读取”最终都要走这里。 + +### `src/services/rpg-runtime/rpgRuntimeChatClient.ts` + +职责: + +1. 提供角色私聊、NPC 单轮聊天、招募对话等聊天能力。 +2. 当前仍桥接旧 `aiService`。 + +说明: + +1. 这是运行时聊天 client 的兼容收口层。 +2. 当前属于过渡桥接脚本,后续不应继续扩大旧 `aiService` 的依赖面。 + +### `src/services/rpg-runtime/rpgSnapshotClient.ts` + +职责: + +1. 提供运行时快照读取、写入、删除能力。 +2. 给 session persistence 层提供正式持久化接口。 + +--- + +## 5.4 运行时选项函数与本地规则脚本 + +### `src/data/functionCatalog/` + +职责: + +1. 维护运行时 function 的独立定义脚本。 +2. 按 `state / npc / treasure / flow / panel` 分类收口。 +3. 统一管理 functionId、动作标题、说明、部分 helper 和运行时定义。 + +关键目录: + +1. `src/data/functionCatalog/flow/` + 负责剧情流程控制型 function。 +2. `src/data/functionCatalog/npc/` + 负责 NPC 交互型 function。 +3. `src/data/functionCatalog/runtimeIndex.ts` + 负责运行时 function 的统一索引;该文件只依赖各分目录入口,不反向依赖 `functionCatalog/index.ts`,避免模块初始化循环。 + +### `src/data/stateFunctions.ts` + +职责: + +1. 聚合基础状态 function。 +2. 对运行时 option 做优先级、排序和过滤支撑。 + +### `docs/reference/FUNCTION_SCRIPT_CATALOG_2026-04-04.md` + +职责: + +1. 作为 function 脚本目录的专项速查文档。 +2. 如果只想查某个运行时 function 的脚本落点,优先看它。 + +--- + +## 5.5 运行时提示词脚本 + +### 前端 `src/prompts/rpg/` + +关键脚本: + +1. `runtimeStoryPrompts.ts` + 负责 RPG 运行时剧情和运行时叙事提示词。 +2. `characterChatPrompts.ts` + 负责角色私聊提示词。 + +### Rust `server-rs/crates/api-server/src/prompt/rpg/` + +关键脚本: + +1. `runtime_chat.rs` + 负责运行时剧情、NPC 对话、运行时聊天相关提示词。 + +说明: + +1. 运行时 prompt 的正式后端组织应优先看 Rust 侧 `prompt/rpg/`。 +2. 前端 prompt 更多承担适配和兼容角色。 + +--- + +## 5.6 `server-rs` 中的 RPG 运行时后端职责 + +### `server-rs/crates/api-server/src/runtime_story.rs` + +职责: + +1. 当前作为 RPG runtime story 的后端门面模块。 +2. 对外导出 `compat` 中的状态读取、动作解析、初始剧情和继续剧情能力。 + +说明: + +1. 当前文件本身很薄。 +2. 真正逻辑在 `runtime_story/compat/` 里。 + +### `server-rs/crates/api-server/src/runtime_story/compat/` + +职责: + +1. 承接当前 RPG runtime story 的兼容实现。 +2. 负责 AI、装备动作、任务动作、NPC 动作、表现层 view model 和测试支撑。 + +关键脚本: + +1. `ai.rs` + 负责运行时叙事 AI 相关兼容逻辑。 +2. `npc_actions.rs` + 负责 NPC 动作解析。 +3. `quest_actions.rs` + 负责任务动作解析。 +4. `equipment_actions.rs` + 负责装备和面板动作解析。 +5. `presentation.rs` + 负责运行时表现层 view model 编译。 +6. `game_state.rs` + 负责兼容态下的状态组织。 + +### `server-rs/crates/api-server/src/runtime_chat.rs` + +职责: + +1. 提供运行时 NPC 单轮聊天 SSE 接口。 +2. 负责构建 NPC 对话 prompt。 +3. 负责 deterministic fallback 回复、建议选项和 function suggestion 回退。 + +### `server-rs/crates/api-server/src/runtime_save.rs` + +职责: + +1. 提供当前快照读取、写入、删除接口。 +2. 提供存档归档列表和恢复接口。 +3. 负责区分正式快照与 preview / test 的临时快照写入语义。 + +### `server-rs/crates/api-server/src/runtime_inventory.rs` + +职责: + +1. 承接运行时背包状态读取。 + +### `server-rs/crates/api-server/src/runtime_profile.rs` + +职责: + +1. 承接运行时相关玩家资料、统计、充值中心、钱包流水等外围接口。 + +### `server-rs/crates/api-server/src/story_battles.rs` + +职责: + +1. 承接故事战斗状态创建、NPC 战斗创建、战斗结算与战斗状态查询。 + +### `server-rs/crates/api-server/src/story_sessions.rs` + +职责: + +1. 承接 story session 的 begin / continue / state 查询。 +2. 是运行时故事会话层的重要接口之一。 + +### `server-rs/crates/api-server/src/app.rs` + +职责: + +1. 统一挂接 `api-server` 的 Axum 路由树。 +2. 把 runtime story、runtime chat、runtime save、story battle、story session 等接口注册到 HTTP 层。 + +说明: + +1. 它是路由总装配文件,不是 RPG 运行时业务细节实现文件。 + +--- + +## 5.7 SpacetimeDB 模块与共享契约层职责 + +### `server-rs/crates/shared-contracts/src/runtime_story.rs` + +职责: + +1. 提供 RPG 运行时故事域共享契约。 + +### `server-rs/crates/shared-contracts/src/runtime.rs` + +职责: + +1. 提供运行时快照、创作、资料等通用契约。 + +### `server-rs/crates/module-runtime/src/lib.rs` + +职责: + +1. 提供运行时快照、保存相关模块基础能力。 + +### `server-rs/crates/module-story/src/lib.rs` + +职责: + +1. 提供故事会话、故事状态相关模块能力。 + +### `server-rs/crates/module-combat/src/lib.rs` + +职责: + +1. 提供战斗状态相关模块能力。 + +### `server-rs/crates/module-quest/src/lib.rs` + +职责: + +1. 提供任务状态、任务推进相关模块能力。 + +### `server-rs/crates/module-inventory/src/lib.rs` + +职责: + +1. 提供背包和物品库存相关模块能力。 + +### `server-rs/crates/module-runtime-story-compat/src/` + +职责: + +1. 承接当前 runtime story compat 领域逻辑。 +2. 把战斗、锻造、选项、NPC 支撑、view model 等兼容逻辑收在独立 crate 中。 + +关键脚本: + +1. `battle.rs` +2. `forge.rs` +3. `forge_actions.rs` +4. `game_state.rs` +5. `npc_support.rs` +6. `options.rs` +7. `view_model.rs` + +说明: + +1. 这是当前 RPG 运行时兼容逻辑的重要承载层。 +2. 如果要继续把前端旧本地规则向 Rust 收口,这里是关键迁移落点之一。 + +--- + +## 6. 支撑 RPG 创作与运行时的工具脚本职责 + +这些脚本不直接参与玩法,但直接支撑开发、发布、绑定和检查: + +### `scripts/api-server-maincloud.mjs` + +职责: + +1. 启动当前 Rust API server 主链路开发入口。 +2. 按仓库约束,后端联调应优先通过它启动。 + +### `scripts/generate-spacetime-bindings.mjs` + +职责: + +1. 生成 SpacetimeDB 绑定。 +2. 支撑前后端契约同步。 + +### `scripts/check-encoding.mjs` + +职责: + +1. 检查仓库中文文件编码是否被写坏。 +2. 修改中文文档或中文注释后应优先运行。 + +### `scripts/validate-content.ts` + +职责: + +1. 做内容结构校验。 + +### `scripts/validate-overrides.ts` + +职责: + +1. 做覆盖项和配置项校验。 + +### `scripts/smoke-content.ts` + +职责: + +1. 做内容层 smoke 验证。 + +--- + +## 7. 当前最值得优先记住的入口脚本 + +如果只想快速建立脑图,建议优先记住下面这些文件: + +### RPG 创作链 + +1. `src/components/platform-entry/PlatformEntryFlowShellImpl.tsx` +2. `src/components/custom-world-agent/CustomWorldAgentWorkspace.tsx` +3. `src/components/rpg-creation-result/useRpgCreationResultActions.ts` +4. `src/services/rpg-creation/rpgCreationAgentClient.ts` +5. `src/services/rpg-creation/rpgCreationAssetClient.ts` +6. `src/services/rpg-creation/rpgCreationLibraryClient.ts` +7. `server-rs/crates/api-server/src/custom_world.rs` +8. `server-rs/crates/api-server/src/custom_world_ai.rs` + +### RPG 运行时链 + +1. `src/hooks/rpg-session/useRpgRuntimeSession.ts` +2. `src/hooks/rpg-session/useRpgSessionBootstrap.ts` +3. `src/hooks/rpg-session/useRpgSessionPersistence.ts` +4. `src/components/rpg-runtime-shell/RpgRuntimeShell.tsx` +5. `src/components/rpg-runtime-panels/RpgAdventurePanel.tsx` +6. `src/hooks/rpg-runtime-story/useRpgRuntimeStory.ts` +7. `src/hooks/rpg-runtime-story/rpgRuntimeStoryGateway.ts` +8. `src/services/rpg-runtime/rpgRuntimeStoryClient.ts` +9. `server-rs/crates/api-server/src/runtime_story.rs` +10. `server-rs/crates/api-server/src/runtime_chat.rs` +11. `server-rs/crates/api-server/src/runtime_save.rs` + +--- + +## 8. 当前兼容层与后续阅读建议 + +### 8.1 兼容层识别 + +下面这些脚本当前更多是兼容门面: + +1. `src/components/rpg-entry/RpgEntryFlowShell.tsx` +2. `src/services/rpg-runtime/rpgRuntimeChatClient.ts` +3. `server-rs/crates/api-server/src/runtime_story.rs` + +阅读这些文件时要注意: + +1. 如果文件很薄,往往真实逻辑在它桥接到的 impl / compat / platform-entry 目录里。 +2. 不要把复杂新逻辑继续堆回这些 façade。 + +### 8.2 推荐阅读顺序 + +如果要继续开发 RPG 创作: + +1. 先看 `PlatformEntryFlowShellImpl.tsx` +2. 再看 `CustomWorldAgentWorkspace.tsx` +3. 再看 `rpgCreationAgentClient.ts` +4. 再看 `custom_world.rs` + +如果要继续开发 RPG 运行时: + +1. 先看 `useRpgRuntimeSession.ts` +2. 再看 `useRpgRuntimeStory.ts` +3. 再看 `rpgRuntimeStoryGateway.ts` +4. 再看 `rpgRuntimeStoryClient.ts` +5. 最后看 `server-rs/crates/api-server/src/runtime_story/compat/` + +--- + +## 9. 结论 + +当前仓库里的 RPG 主链已经基本形成两套脚本地图: + +1. **RPG 创作链**:以平台入口分流、共创会话、结果页编辑、资产工坊、创作域 client、`server-rs` 自定义世界接口为主。 +2. **RPG 运行时链**:以 session bootstrap、session persistence、runtime shell、runtime story hook、runtime story client、`server-rs` runtime story / runtime chat / runtime save 为主。 + +如果后续继续做职责收口,优先方向应该是: + +1. 继续减少 façade 承载业务。 +2. 继续把前端兼容桥接逻辑向 `server-rs` 和 SpacetimeDB 正式域收口。 +3. 继续让“创作链”和“运行时链”各自维持清晰入口,而不是重新回到通用大文件。 diff --git a/docs/reference/RPG_SCRIPT_COMMENTARY_PROGRESS_2026-04-28.md b/docs/reference/RPG_SCRIPT_COMMENTARY_PROGRESS_2026-04-28.md new file mode 100644 index 00000000..9ab18be3 --- /dev/null +++ b/docs/reference/RPG_SCRIPT_COMMENTARY_PROGRESS_2026-04-28.md @@ -0,0 +1,115 @@ +# RPG 脚本中文注释补充进度(2026-04-28) + +## 1. 文档目的 + +这份文档用于记录当前仓库里 RPG 相关脚本的中文注释补充进度,避免后续“挨个补充”时重复扫描、重复改同一批文件,或者遗漏运行时主链上的关键脚本。 + +当前原则: + +1. 先补职责最核心、状态流最复杂、后续最常被继续修改的脚本。 +2. 每一批都尽量按完整链路补,不只补单点文件。 +3. 注释以解释“为什么这样编排”“这一层负责什么边界”为主,不堆砌逐行翻译式废话。 + +--- + +## 2. 本轮已补充的脚本 + +### 2.1 RPG 运行时 session 主链 + +1. `src/hooks/rpg-session/useRpgRuntimeSession.ts` +2. `src/hooks/rpg-session/useRpgSessionBootstrap.ts` +3. `src/hooks/rpg-session/useRpgSessionPersistence.ts` + +本轮重点: + +1. 说明 session 装配器如何组合 bootstrap、story、combat、persistence。 +2. 说明自定义世界开局场景、首遇 NPC、初始装备与初始物品的装配原因。 +3. 说明自动存档、继续游戏、手动保存退出的状态边界。 + +### 2.2 RPG 运行时 story 主链 + +1. `src/hooks/rpg-runtime-story/useRpgRuntimeStory.ts` +2. `src/hooks/rpg-runtime-story/useRpgRuntimeStoryController.ts` +3. `src/hooks/rpg-runtime-story/useRpgRuntimeStoryFlow.ts` +4. `src/hooks/rpg-runtime-story/useRpgRuntimeInteractionFlow.ts` +5. `src/hooks/rpg-runtime-story/useRpgRuntimeStoryState.ts` +6. `src/hooks/rpg-runtime-story/storyInteractionCoordinator.ts` +7. `src/hooks/rpg-runtime-story/rpgRuntimeStoryGateway.ts` + +本轮重点: + +1. 说明 controller、flow、interaction、state 四层的职责切分。 +2. 说明 NPC 遭遇自动进入交互态、NPC 战斗快照桥接、地图旅行桥接的原因。 +3. 说明 story reset、story hydration、服务端动作结算的编排边界。 + +### 2.3 RPG 运行时 service / client 主链 + +1. `src/services/rpg-runtime/rpgRuntimeRequest.ts` +2. `src/services/rpg-runtime/rpgRuntimeStoryClient.ts` +3. `src/services/rpg-runtime/rpgSnapshotClient.ts` + +本轮重点: + +1. 说明 runtime 请求的统一重试策略。 +2. 说明服务端 `RuntimeStoryOptionView` 到前端 `StoryOption` 的适配原因。 +3. 说明远端快照读取、写入后为什么要先 rehydrate。 + +--- + +## 3. 建议的后续补充顺序 + +为了保持“按链路读得通”,下一轮建议继续按下面顺序推进: + +### 3.1 运行时 story 子模块 + +1. `src/hooks/rpg-runtime-story/storyChoiceCoordinator.ts` +2. `src/hooks/rpg-runtime-story/storyChoiceRuntime.ts` +3. `src/hooks/rpg-runtime-story/storyRequestCoordinator.ts` +4. `src/hooks/rpg-runtime-story/storyRequestRuntime.ts` +5. `src/hooks/rpg-runtime-story/storyGenerationState.ts` +6. `src/hooks/rpg-runtime-story/storyEncounterState.ts` +7. `src/hooks/rpg-runtime-story/storyPresentation.ts` +8. `src/hooks/rpg-runtime-story/sessionActions.ts` +9. `src/hooks/rpg-runtime-story/progressionActions.ts` +10. `src/hooks/rpg-runtime-story/npcInteraction.ts` +11. `src/hooks/rpg-runtime-story/inventoryActions.ts` +12. `src/hooks/rpg-runtime-story/goalFlow.ts` + +原因: + +1. 这些文件已经紧贴本轮完成的主编排层。 +2. 它们包含大量“局部规则 + 状态迁移 + UI 结果”的细节,最需要注释解释。 + +### 3.2 运行时 UI 与入口层 + +1. `src/components/rpg-runtime-shell/RpgRuntimeShell.tsx` +2. `src/components/rpg-runtime-shell/RpgRuntimeStageRouter.tsx` +3. `src/components/rpg-runtime-panels/RpgRuntimePanelRouter.tsx` +4. `src/components/rpg-runtime-panels/RpgAdventurePanel.tsx` +5. `src/hooks/rpg-session/useRpgSessionBootstrap.ts` 周边引用组件 +6. `src/components/rpg-entry/` 目录里的 RPG 运行时入口桥接脚本 + +原因: + +1. 主链编排层补完后,再补表现层会更容易写出真正有用的注释。 +2. 入口层里有兼容 façade,需要明确标出“不要继续堆复杂逻辑”的边界。 + +### 3.3 RPG 创作链 + +1. `src/services/rpg-creation/` 目录主 client +2. `src/components/rpg-creation-result/` 主动作脚本 +3. `src/components/rpg-creation-editor/` 主编辑链 +4. `src/components/rpg-creation-asset-studio/` 角色资产工坊链 + +原因: + +1. 创作链文件也很多,但当前运行时主链更核心、更常改。 +2. 等运行时链注释连续后,再切创作链更不容易打断理解。 + +--- + +## 4. 本轮备注 + +1. 本轮以局部补丁方式补注释,没有整文件重写。 +2. 本轮没有改业务逻辑,只补中文注释和进度文档。 +3. 后续每补完一批,建议同步更新本文件,保持可追踪。 diff --git a/docs/technical/ASSET_EXTERNAL_GENERATION_MANUAL_VERIFICATION_RUNBOOK_2026-04-23.md b/docs/technical/ASSET_EXTERNAL_GENERATION_MANUAL_VERIFICATION_RUNBOOK_2026-04-23.md index 13edd5ea..a64ba216 100644 --- a/docs/technical/ASSET_EXTERNAL_GENERATION_MANUAL_VERIFICATION_RUNBOOK_2026-04-23.md +++ b/docs/technical/ASSET_EXTERNAL_GENERATION_MANUAL_VERIFICATION_RUNBOOK_2026-04-23.md @@ -192,6 +192,7 @@ npm run dev:rust 1. 资源 URL 不再是 `/generated-big-fish/...` 2. 而是 `/generated-big-fish-assets/...` 3. 结果页状态显示为 `已生成`,而不是 `占位已生成` +4. `Lv.x 主图` 与 `idle_float / move_swim` 正式图若下载结果为 PNG,后端会在写 OSS 前复用 RPG 角色主图透明背景 alpha 后处理;`生成背景` 不走该处理 ### 7.2 Custom World 场景图 diff --git a/docs/technical/BIG_FISH_DRAFT_PROGRESS_AND_SESSION_TIMEOUT_GUARD_FIX_2026-04-28.md b/docs/technical/BIG_FISH_DRAFT_PROGRESS_AND_SESSION_TIMEOUT_GUARD_FIX_2026-04-28.md new file mode 100644 index 00000000..726a3e04 --- /dev/null +++ b/docs/technical/BIG_FISH_DRAFT_PROGRESS_AND_SESSION_TIMEOUT_GUARD_FIX_2026-04-28.md @@ -0,0 +1,57 @@ +# 大鱼吃小鱼草稿进度与会话超时兜底修复 2026-04-28 + +## 背景 + +大鱼吃小鱼在 `2026-04-28` 完成草稿结构化升级后,结果页草稿已经不再是单纯模板壳,而是会生成等级文本、形象描述、动作描述与运行参数。 + +但当前链路仍暴露出两个直接体验问题: + +1. 前端草稿进度页仍把大鱼吃小鱼展示成单个 `compile` 步骤,用户会感觉“整个生成过程只有一步,而且一直卡在第一步”。 +2. 前端在打开大鱼草稿或结果页时,会通过 `GET /api/runtime/big-fish/agent/sessions/:sessionId` 拉取完整会话;当 Maincloud 上游偶发抖动时,Rust `spacetime-client` 统一 10 秒超时会直接映射成 `502`,用户会看到反复报错。 + +## 修复口径 + +### 1. 草稿进度页改为多阶段感知 + +大鱼吃小鱼的 `big_fish_compile_draft` 仍然保持为一次后端 compile action,不拆成多个新的后端接口。 + +但前端进度读模型不再把它渲染成单步,而是拆成下面三段: + +1. `整理玩法骨架` + - 收拢玩法承诺、成长阶梯与风险节奏。 +2. `编译等级蓝图` + - 生成每级角色描述、形象描述与动作描述。 +3. `校准场地与参数` + - 整理背景蓝图与运行参数,准备结果页。 + +这样做的边界是: + +1. 不把动作正式出图重新塞回 compile action。 +2. 只增强生成中的阶段反馈,不改动现有结果页资产工坊分工。 +3. 进度阶段属于前端展示语义,不要求后端额外维护细粒度 procedure 状态。 + +### 2. 会话读取增加短重试与超时语义收口 + +大鱼会话读取现在补充两层守卫: + +1. `api-server` 在读取大鱼 session 时,对 `SpacetimeClientError::Timeout` 和 `SpacetimeClientError::ConnectDropped` 做一次短重试。 +2. 若最终仍然超时,则错误状态码从泛化 `502` 收口为更准确的 `504 Gateway Timeout`。 + +这样可以覆盖两类常见情况: + +1. Maincloud 连接偶发抖动,第一次 procedure 超时但第二次马上恢复。 +2. 用户打开草稿页时碰到短暂断链,不再被立即判定成稳定的坏网关故障。 + +## 落地范围 + +1. `src/services/miniGameDraftGenerationProgress.ts` +2. `src/services/miniGameDraftGenerationProgress.test.ts` +3. `src/components/platform-entry/PlatformEntryFlowShellImpl.tsx` +4. `server-rs/crates/api-server/src/big_fish.rs` + +## 验收口径 + +1. 用户点击大鱼吃小鱼“生成草稿”后,进度页至少能看到三个结构化阶段,而不是单个 compile 步骤。 +2. 这三个阶段都只描述草稿编译,不出现“生成动作素材”之类结果页资产动作。 +3. `GET /api/runtime/big-fish/agent/sessions/:sessionId` 遇到短暂 SpacetimeDB 抖动时,会先做一次短重试。 +4. 如果最终仍超时,接口返回语义应体现为超时,而不是继续统一落成泛化 `502`。 diff --git a/docs/technical/BIG_FISH_MAIN_IMAGE_TRANSPARENT_BACKGROUND_ALIGNMENT_2026-04-28.md b/docs/technical/BIG_FISH_MAIN_IMAGE_TRANSPARENT_BACKGROUND_ALIGNMENT_2026-04-28.md new file mode 100644 index 00000000..f5b76970 --- /dev/null +++ b/docs/technical/BIG_FISH_MAIN_IMAGE_TRANSPARENT_BACKGROUND_ALIGNMENT_2026-04-28.md @@ -0,0 +1,82 @@ +# 大鱼吃小鱼角色主图透明背景后处理对齐说明 2026-04-28 + +## 背景 + +当前大鱼吃小鱼的等级主图与动作关键帧 prompt 已经明确要求“按 RPG 角色资产口径生成透明背景”,但正式图片链实际仍主要依赖供应商出图结果本身。 + +这会带来一个问题: + +1. prompt 约束只能提高透明背景命中率,不能保证每次都没有残留底色。 +2. RPG 角色主图链已经在 Rust 后端落了一层 PNG alpha 后处理,大鱼链没有对齐,导致两条“角色主图口径”资产在最终成品一致性上仍有差异。 + +## 本次目标 + +把“大鱼吃小鱼生成的角色主图后处理流程”对齐到 RPG 角色主图链: + +1. 等级主图正式图生成后,若下载结果为 PNG,则复用 RPG 现有透明背景 alpha 后处理。 +2. `idle_float` / `move_swim` 动作关键帧静态图同样复用这套处理。 +3. 场地背景图不走这套处理,避免误把 9:16 场景背景做成透明底。 + +## 落地方案 + +### 1. 复用 RPG 透明背景后处理能力 + +`server-rs/crates/api-server/src/character_visual_assets.rs` + +冻结现有 `try_apply_background_alpha_to_png(...)` 为 `pub(crate)` 复用入口,继续由 RPG 主图链维护这套“绿底/白底/软边缘”透明背景清理逻辑。 + +### 2. Big Fish 正式图链按资产类型决定是否启用后处理 + +`server-rs/crates/api-server/src/big_fish.rs` + +在 `BigFishFormalAssetContext` 中新增: + +1. `apply_transparent_background_post_process` + +映射规则如下: + +1. `level_main_image`:`true` +2. `level_motion`:`true` +3. `stage_background`:`false` + +### 3. 下载完成后、写 OSS 前执行统一处理 + +`download_big_fish_remote_image(...)` 新增布尔开关参数。 + +当满足以下条件时执行后处理: + +1. 当前资产槽位需要透明背景后处理 +2. 上游下载结果 `mime_type == image/png` + +执行顺序冻结为: + +1. DashScope 出图 +2. 下载远端 PNG +3. 复用 RPG `try_apply_background_alpha_to_png(...)` +4. 再写入 Big Fish 正式 OSS 对象 +5. 确认 `asset_object` +6. 绑定到 Big Fish 槽位 + +## 为什么这样做 + +1. 这次需求说的是“生成后处理流程和 RPG 角色主图一致”,因此不能只继续加强 prompt,必须把后处理链对齐。 +2. 直接复用 RPG 已有实现,比在 Big Fish 再复制一份抠图算法更稳,也更符合仓库“默认复用现有系统”的约束。 +3. 背景图是环境资产,不属于“角色主图口径”,如果也启用透明背景后处理,会造成错误裁底风险。 + +## 验收口径 + +1. 在 Big Fish 结果页点击 `生成并应用正式图 -> Lv.x 主图` 后,若 DashScope 返回 PNG,正式落库前会执行和 RPG 主图相同的透明背景 alpha 处理。 +2. 在 Big Fish 动作工坊点击 `生成并应用正式图` 后,`idle_float` / `move_swim` 的静态关键帧图同样执行该处理。 +3. `生成背景` 仍保持完整场景图,不走透明背景后处理。 +4. 编码检查通过,Rust `api-server` 定向编译通过。 + +## 影响范围 + +1. `server-rs/crates/api-server/src/character_visual_assets.rs` +2. `server-rs/crates/api-server/src/big_fish.rs` + +## 风险与边界 + +1. 当前后处理只在下载结果本身是 PNG 时生效;若供应商返回 JPEG/WebP,则仍按原始格式入库。 +2. 本次不新增新的 Big Fish 专属抠图算法,不改变 DashScope prompt 和 OSS 绑定协议。 +3. 本次不修改 SpacetimeDB schema,也不涉及 `migration.rs` 变更。 diff --git a/docs/technical/BIG_FISH_PROMPT_MODULE_EXTRACTION_2026-04-28.md b/docs/technical/BIG_FISH_PROMPT_MODULE_EXTRACTION_2026-04-28.md new file mode 100644 index 00000000..aea275f9 --- /dev/null +++ b/docs/technical/BIG_FISH_PROMPT_MODULE_EXTRACTION_2026-04-28.md @@ -0,0 +1,126 @@ +# 大鱼吃小鱼提示词脚本拆分 2026-04-28 + +## 背景 + +大鱼吃小鱼当前在 `server-rs/crates/api-server/src/big_fish.rs` 与 `server-rs/crates/api-server/src/big_fish_agent_turn.rs` 中同时承载了三类不同职责的提示词: + +1. Agent 聊天阶段的草稿生成提示词。 +2. 结果页主图 / 生图提示词。 +3. 结果页动作关键帧提示词。 + +这会带来两个直接问题: + +1. 聊天共创脚本和正式资产脚本混在路由业务文件中,后续继续调词时很容易顺手改到状态编排逻辑。 +2. 大鱼吃小鱼已经明确要求“草稿编译”和“结果页资产工坊”分离,如果提示词仍散落在业务实现里,后续很容易再次把动作资产逻辑误塞回 compile action。 + +## 本轮目标 + +把下面三类提示词显式拆到独立 prompt 脚本中: + +1. 草稿生成提示词。 +2. 生图提示词。 +3. 动作提示词。 + +并保持以下边界不变: + +1. 不改变 Big Fish 的会话表、草稿表、资产表结构。 +2. 不改变 compile action 只编译草稿、不串行生成资产的现有口径。 +3. 不改写当前中文提示词语义,只做脚本落位和调用收口。 + +## 落位方案 + +新增文件: + +```text +server-rs/crates/api-server/src/prompt/big_fish.rs +``` + +该文件统一收口: + +1. `BIG_FISH_AGENT_SYSTEM_PROMPT` +2. `build_big_fish_agent_prompt(...)` +3. `build_big_fish_level_main_image_prompt(...)` +4. `build_big_fish_level_motion_prompt(...)` +5. `build_big_fish_stage_background_prompt(...)` +6. `BIG_FISH_DEFAULT_NEGATIVE_PROMPT` +7. `BIG_FISH_TRANSPARENT_ASSET_NEGATIVE_PROMPT` + +同时把 `prompt/mod.rs` 补齐为正式导出入口,和现有: + +1. `puzzle_image.rs` +2. `character_visual.rs` +3. `character_animation.rs` +4. `scene_background.rs` + +保持同一层级。 + +## 调用边界 + +### 1. 草稿生成 + +`server-rs/crates/api-server/src/big_fish_agent_turn.rs` + +改为只负责: + +1. 调用公共 LLM turn 执行器。 +2. 解析 `replyText / progressPercent / nextAnchorPack`。 +3. 组装 finalize input。 + +不再内联维护大段 system prompt / output contract / chat prompt 拼接逻辑。 + +### 2. 生图与动作 + +`server-rs/crates/api-server/src/big_fish.rs` + +改为只负责: + +1. 读取当前 session 与 draft。 +2. 根据 `asset_kind` 构造正式资产上下文。 +3. 调用 DashScope 出图。 +4. 下载、后处理、持久化并写入资产绑定。 + +主图、动作关键帧、背景图的正式提示词脚本都从 `crate::prompt::big_fish` 引入,不再内联在路由业务脚本中。 + +## 为什么三类脚本要继续分开 + +### 草稿生成提示词 + +它的职责是把玩法灵感收束成: + +1. 玩法承诺 +2. 生态视觉主题 +3. 成长阶梯 +4. 风险节奏 + +它面向的是 LLM 的结构化共创,不面向图片模型。 + +### 生图提示词 + +它的职责是把已经落库的等级蓝图翻译成“单体鱼形主图”的正式图片提示词。 + +它面向的是透明背景主体资产,需要强调: + +1. 单体主体 +2. 透明背景 +3. 中心构图 +4. 不出现 UI / 场景 / 多主体 + +### 动作提示词 + +它的职责是把等级蓝图和动作槽位翻译成“静态关键帧预览图”的正式图片提示词。 + +它和主图区别在于: + +1. 需要显式带入 `motion_key` +2. 需要区分 `idle_float / move_swim` +3. 需要强调动作方向和关键帧姿态 + +因此不能继续复用同一段文本拼接后靠 if 分支临时改句子。 + +## 本轮验收 + +1. 大鱼吃小鱼草稿生成提示词已从 `big_fish_agent_turn.rs` 抽离。 +2. 大鱼吃小鱼主图、动作、背景提示词已从 `big_fish.rs` 抽离。 +3. 路由业务文件只保留编排、鉴权、调用与错误映射职责。 +4. 新增 prompt 文件具备最小测试覆盖。 +5. `npm run check:encoding` 通过,确保新增中文文档与 Rust 注释未被写坏。 diff --git a/docs/technical/CREATION_PUBLIC_GALLERY_AND_AGENT_RESTORE_GUARD_FIX_2026-04-28.md b/docs/technical/CREATION_PUBLIC_GALLERY_AND_AGENT_RESTORE_GUARD_FIX_2026-04-28.md new file mode 100644 index 00000000..f0bf5551 --- /dev/null +++ b/docs/technical/CREATION_PUBLIC_GALLERY_AND_AGENT_RESTORE_GUARD_FIX_2026-04-28.md @@ -0,0 +1,63 @@ +# 创作页公开广场与 Agent 恢复指针兜底修复 2026-04-28 + +## 1. 问题现象 + +浏览器控制台同时出现两类请求错误: + +1. `GET /api/runtime/custom-world/agent/sessions/:sessionId` 返回 `404`。 +2. `GET /api/runtime/big-fish/gallery` 返回 `400`。 + +第一类错误发生在平台页尝试恢复 RPG / Custom World Agent 旧工作区时。旧 URL 或旧 sessionStorage 指针里可能只有 `customWorldSessionId`,没有本机保存的 `ownerUserId`,登录后前端仍会直接读取受保护 session,后端按 `owner_user_id + session_id` 查不到后返回 `404`。 + +第二类错误发生在首页读取大鱼吃小鱼公开广场时。公开广场是平台首屏的可选展示数据,即使 SpacetimeDB procedure 暂未就绪、连接短暂断开或旧环境缺少对应 procedure,也不应该阻断平台主界面。 + +## 2. 落地原则 + +1. URL 中的 `customWorldSessionId` 只用于深链恢复,不作为鉴权凭据。 +2. 受保护 Agent session 恢复必须能确认本机 `ownerUserId` 与当前登录用户一致。 +3. 未登录状态仍保留登录弹窗流程,不提前丢弃深链;登录完成后若仍无法确认归属,再清空恢复指针。 +4. Big Fish 公开广场只展示 `published` 作品;读取失败时允许空态降级,不把错误写成 UI 主错误。 +5. 私有作品列表、会话详情、发布、删除仍保持严格错误,不复用公开广场的软降级策略。 + +## 3. 本次修改 + +### 3.1 RPG Agent 恢复指针 + +`src/services/customWorldAgentUiState.ts` 读取 URL query 时,会尝试从 sessionStorage 中匹配同一个 `activeSessionId` 的 `ownerUserId`。 + +如果 URL 指针和本机存储匹配: + +1. 返回 `activeSessionId`。 +2. 同时带回本机 `ownerUserId`。 + +如果 URL 指针没有对应本机归属: + +1. 只返回 session 指针。 +2. 登录后 `useRpgCreationSessionController` 会清空指针。 +3. 不调用 `getRpgCreationSession()`,避免向后端发起必然 404 的失效恢复请求。 + +### 3.2 Big Fish 公开广场 + +前端 `listBigFishGallery()` 对 `400 / 404` 返回 `{ items: [] }`,让平台首页可以继续渲染空广场。 + +Rust `api-server` 的 `list_big_fish_gallery()` 对以下 SpacetimeDB 读取问题做服务端空态降级: + +1. `SpacetimeClientError::Runtime(_)` +2. `SpacetimeClientError::ConnectDropped` +3. 明确指向 `list_big_fish_works` procedure 缺失或不可用的 procedure 错误 + +服务端会保留 `warn` 日志,便于部署环境继续排查 schema / publish 状态。`Timeout` 不降级,仍按网关超时暴露,避免长时间卡死被误认为正常空广场。 + +## 4. 验收标准 + +1. 已登录用户打开只带旧 `customWorldSessionId`、但本机没有匹配 `ownerUserId` 的页面时,不再请求 `GET /api/runtime/custom-world/agent/sessions/:sessionId`。 +2. 未登录用户打开带 `customWorldSessionId` 的深链时,仍先打开登录弹窗。 +3. Big Fish 公开广场返回 `400 / 404` 时,前端展示空列表,不把“读取大鱼吃小鱼广场失败”写入主错误态。 +4. 服务端遇到 Big Fish gallery 可降级 SpacetimeDB 错误时返回成功 envelope,`items` 为空,并记录 warn 日志。 + +## 5. 回归范围 + +1. `src/services/customWorldAgentUiState.test.ts` +2. `src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx` +3. `src/services/big-fish-gallery/bigFishGalleryClient.test.ts` +4. `cargo test -p api-server big_fish_gallery` diff --git a/docs/technical/PUZZLE_BIG_FISH_DRAFT_PROGRESS_AND_ASSET_CHAIN_2026-04-25.md b/docs/technical/PUZZLE_BIG_FISH_DRAFT_PROGRESS_AND_ASSET_CHAIN_2026-04-25.md index f368eba0..caa07f13 100644 --- a/docs/technical/PUZZLE_BIG_FISH_DRAFT_PROGRESS_AND_ASSET_CHAIN_2026-04-25.md +++ b/docs/technical/PUZZLE_BIG_FISH_DRAFT_PROGRESS_AND_ASSET_CHAIN_2026-04-25.md @@ -2,14 +2,27 @@ ## 背景 -RPG 在点击生成草稿后会离开聊天工作区,进入独立的生成进度页,并在该页展示生成链路的阶段、锚点与最终草稿内容。拼图与大鱼吃小鱼此前点击“生成结果页”后直接跳到结果页,正式图片、动作与背景仍分散在结果页工坊里逐个生成,导致用户无法看到“正在一次性准备完整草稿”的过程。 +RPG 在点击生成草稿后会离开聊天工作区,进入独立的生成进度页,并在该页展示生成链路的阶段、锚点与最终草稿内容。拼图与大鱼吃小鱼此前点击“生成结果页”后直接跳到结果页,缺少一个明确的“草稿编译中”过渡态。 + +但在 `2026-04-28` 的大鱼吃小鱼链路修正后,产品口径进一步收紧为: + +1. 拼图仍然保留“生成草稿时一并补齐结果页主资产”的收口方式。 +2. 大鱼吃小鱼的“生成草稿”只负责把玩法锚点编译成结果页草稿。 +3. 大鱼吃小鱼的主图、动作、背景都留在结果页工坊按需生成,不再塞进草稿编译动作里串行执行。 + +这样做的原因是: + +1. 大鱼吃小鱼草稿阶段的核心目标是先稳定产出等级蓝图、背景蓝图和运行参数,而不是在这一刻把整套资产都做完。 +2. 动作素材生成耗时最长,把它塞进草稿 action 会让用户长时间停留在首步等待态,形成“卡在第一步”的体感。 +3. 草稿阶段不需要配置动作,动作应当属于结果页资产精修阶段。 ## 落地边界 - 前端只负责展示生成进度与触发已有后端动作,不新增 server-node 或 PostgreSQL 链路。 -- 后端继续沿用 `server-rs` + SpacetimeDB 的会话、草稿与资产写入能力;“一次性生成所有需要的东西”必须由 `server-rs` 的 compile action 承担,前端只发起一次 action 并展示进度页。 -- 拼图生成草稿链路必须包含:结果页草稿、候选图生成、正式图确认。 -- 大鱼吃小鱼生成草稿链路必须包含:玩法草稿、关卡主图、动作素材、场地背景。 +- 后端继续沿用 `server-rs` + `SpacetimeDB` 的会话、草稿与资产写入能力。 +- 拼图生成草稿链路仍包含:结果页草稿、候选图生成、正式图确认。 +- 大鱼吃小鱼生成草稿链路只包含:玩法草稿、等级蓝图、背景蓝图与运行参数编译。 +- 大鱼吃小鱼的主图、动作、背景都在结果页工坊单独触发,不再属于草稿编译阶段。 - 生成过程中展示的“角色描述、角色图片、动作”等,统一映射为锚点、草稿蓝图与资产步骤,不把规则说明类文本写成默认 UI 文案。 ## 交互设计 @@ -17,8 +30,9 @@ RPG 在点击生成草稿后会离开聊天工作区,进入独立的生成进 1. 用户在拼图或大鱼吃小鱼 Agent 工作区点击生成按钮。 2. 页面立即切换到独立生成进度页,同时只向 `server-rs` 发起一次 compile action,返回按钮在生成中禁用,避免中途回退造成状态漂移。 3. 进度页左侧展示阶段进度、步骤卡片与错误信息;右侧展示当前锚点与已成形的草稿结构。 -4. 全量生成成功后自动进入对应结果页,结果页直接展示已生成的资产。 -5. 生成失败时停留在进度页,用户可返回工作区补充设定,或点击重试重新执行完整草稿链路。 +4. 生成成功后自动进入对应结果页。 +5. 拼图结果页直接展示已生成的正式图;大鱼结果页则展示刚编译完成的玩法草稿,后续资产由结果页工坊继续生成。 +6. 生成失败时停留在进度页,用户可返回工作区补充设定,或点击重试重新执行完整草稿链路。 ## 阶段映射 @@ -32,11 +46,14 @@ RPG 在点击生成草稿后会离开聊天工作区,进入独立的生成进 ### 大鱼吃小鱼 - `big_fish_compile_draft`:在 `server-rs` 内生成玩法草稿、关卡角色描述、背景蓝图与运行参数。 -- `big_fish_compile_draft`:同一次后端 action 内按每个关卡生成主角色/鱼群图片。 -- `big_fish_compile_draft`:同一次后端 action 内按每个关卡生成 `idle_float` 与 `move_swim` 动作素材。 -- `big_fish_compile_draft`:同一次后端 action 内生成玩法场地背景。 - `ready`:进入大鱼吃小鱼结果页。 +补充冻结: + +- 大鱼吃小鱼草稿阶段不展示“生成动作素材”步骤。 +- `big_fish_generate_level_main_image`、`big_fish_generate_level_motion`、`big_fish_generate_stage_background` 继续保留为结果页中的独立资产动作。 +- 如果后续需要扩展大鱼草稿生成进度,也只能扩展“草稿结构编译”相关阶段,不能再把动作生成塞回 compile action。 + ## 前端流程收口 - 拼图与大鱼吃小鱼共用 `usePlatformCreationAgentFlowController` 管理会话、流式回复、忙碌态、错误态和草稿恢复,页面层不再重复手写两套 submit/action 流程。 @@ -48,8 +65,10 @@ RPG 在点击生成草稿后会离开聊天工作区,进入独立的生成进 ## 验收点 - 拼图和大鱼吃小鱼点击生成草稿后不再直接停留在聊天工作区等待。 -- 生成中可看到独立进度页,且进度步骤随 action 完成逐步推进。 -- 拼图结果页打开时已有正式图;大鱼结果页打开时主图、动作和背景资产均已写入 `assetSlots`。 -- 前端点击生成草稿时不串行调用多个资产 action;多阶段业务编排收敛在 `server-rs`。 +- 生成中可看到独立进度页。 +- 拼图结果页打开时已有正式图。 +- 大鱼结果页打开时至少已有完整玩法草稿,不要求主图、动作和背景资产在草稿阶段写入 `assetSlots`。 +- 大鱼吃小鱼草稿生成进度中不再出现“生成动作素材”步骤。 +- 前端点击生成草稿时不串行调用多个大鱼资产 action;大鱼资产生成留在结果页独立触发。 - 返回 Agent 工作区后,聊天区不出现“拼图结果页草稿已生成。”“本级主图已正式生成,可在结果页继续预览。”这类生成进度页状态消息。 - 不新增 server-node 依赖,不复活 legacy public 静态资产路径。 diff --git a/docs/technical/README.md b/docs/technical/README.md index 84a9a38b..f1e92a4c 100644 --- a/docs/technical/README.md +++ b/docs/technical/README.md @@ -4,6 +4,13 @@ ## 文档列表 +- [RPG_PROMPT_FRONTEND_REMOVAL_AND_SERVER_RS_MIGRATION_2026-04-28.md](./RPG_PROMPT_FRONTEND_REMOVAL_AND_SERVER_RS_MIGRATION_2026-04-28.md):冻结 RPG 提示词禁止存在前端的边界,明确前端只保留 API client,角色私聊/NPC 对话/剧情续写等 prompt 统一收口到 `server-rs`。 +- [RPG_CREATION_RESULT_VIEW_BACKEND_TRUTH_MIGRATION_2026-04-28.md](./RPG_CREATION_RESULT_VIEW_BACKEND_TRUTH_MIGRATION_2026-04-28.md):冻结 RPG 创作结果页保存、Agent session/result preview 真相优先级和结果页入口裁决迁移到后端 result-view 的落地边界。 +- [RPG_CREATION_PROFILE_GENERATION_BACKEND_MIGRATION_2026-04-28.md](./RPG_CREATION_PROFILE_GENERATION_BACKEND_MIGRATION_2026-04-28.md):记录 RPG 创作 profile 生成移除非浏览器 legacy AI 回退,统一通过 `server-rs` 的 `/api/runtime/custom-world/profile` 生成世界底稿。 +- [CREATION_PUBLIC_GALLERY_AND_AGENT_RESTORE_GUARD_FIX_2026-04-28.md](./CREATION_PUBLIC_GALLERY_AND_AGENT_RESTORE_GUARD_FIX_2026-04-28.md):记录 RPG Agent 旧 URL 恢复指针必须有本机用户归属才读取受保护 session,以及 Big Fish 公开广场读取失败按空广场降级的修复口径。 +- [BIG_FISH_DRAFT_PROGRESS_AND_SESSION_TIMEOUT_GUARD_FIX_2026-04-28.md](./BIG_FISH_DRAFT_PROGRESS_AND_SESSION_TIMEOUT_GUARD_FIX_2026-04-28.md):记录大鱼吃小鱼草稿进度页从单步 compile 改为多阶段感知展示,以及大鱼会话读取在 Maincloud 抖动时增加短重试与超时语义收口的修复口径。 +- [BIG_FISH_PROMPT_MODULE_EXTRACTION_2026-04-28.md](./BIG_FISH_PROMPT_MODULE_EXTRACTION_2026-04-28.md):记录大鱼吃小鱼草稿生成、生图、动作三类提示词从业务脚本中抽离到独立 `prompt/big_fish.rs` 模块的边界与职责划分。 +- [BIG_FISH_MAIN_IMAGE_TRANSPARENT_BACKGROUND_ALIGNMENT_2026-04-28.md](./BIG_FISH_MAIN_IMAGE_TRANSPARENT_BACKGROUND_ALIGNMENT_2026-04-28.md):记录大鱼吃小鱼等级主图与动作关键帧正式图在 Rust 后端复用 RPG 角色主图透明背景 alpha 后处理的对齐口径,并明确场地背景不走该处理。 - [PUZZLE_RESULT_AUTOSAVE_AND_TAG_GATE_FIX_2026-04-28.md](./PUZZLE_RESULT_AUTOSAVE_AND_TAG_GATE_FIX_2026-04-28.md):记录拼图结果页名称与标签编辑自动保存、发布门槛统一到 `3~6` 标签,以及前端发布校验不再被旧 session blocker 卡死的修复口径。 - [SPACETIMEDB_START_SH_EARLY_EXIT_DIAGNOSTICS_2026-04-27.md](./SPACETIMEDB_START_SH_EARLY_EXIT_DIAGNOSTICS_2026-04-27.md):记录发布包 `start.sh` 只输出“SpacetimeDB 进程在就绪前退出”时的诊断补强,启动失败或超时时自动回显 `logs/spacetimedb.log`、`server ping`、端口监听和 root-dir 相关进程。 - [RPG_AND_AGENT_CHAT_TRUE_SSE_STREAMING_2026-04-26.md](./RPG_AND_AGENT_CHAT_TRUE_SSE_STREAMING_2026-04-26.md):记录 RPG 运行时 NPC 聊天、RPG/自定义世界 Agent 与大鱼 Agent 从“拼完整 SSE 字符串后一次性返回”改为 `mpsc + Sse` 真流式输出的后端落地口径。 diff --git a/docs/technical/RPG_CREATION_PROFILE_GENERATION_BACKEND_MIGRATION_2026-04-28.md b/docs/technical/RPG_CREATION_PROFILE_GENERATION_BACKEND_MIGRATION_2026-04-28.md new file mode 100644 index 00000000..cf5a90ad --- /dev/null +++ b/docs/technical/RPG_CREATION_PROFILE_GENERATION_BACKEND_MIGRATION_2026-04-28.md @@ -0,0 +1,51 @@ +# RPG 创作 profile 生成后端迁移(2026-04-28) + +## 1. 背景 + +`docs/audits/engineering/RPG_FRONTEND_SCRIPT_BACKEND_MIGRATION_AUDIT_2026-04-28.md` 的 5.3 指出,`src/services/rpg-creation/rpgCreationGenerationClient.ts` 在非浏览器环境仍会动态 `import('../ai')`,让 RPG 创作 profile 生成继续保留前端 legacy AI 后门。 + +这与当前边界冲突: + +1. 前端只负责表现和 API client。 +2. RPG 创作 prompt 与 LLM 编排只能在 `server-rs/crates/api-server/src/prompt/rpg/` 与 `api-server` 侧出现。 +3. 外部 LLM 调用不能进入 SpacetimeDB reducer,必须由 Axum / `platform-llm` 完成后再把确定结果交给后续持久化链。 + +## 2. 本轮落地 + +### 2.1 前端 + +`src/services/rpg-creation/rpgCreationGenerationClient.ts` 现在不再判断 `typeof window`,也不再动态导入 `src/services/ai.ts`。 + +无论浏览器、SSR 还是 Vitest node 环境,`generateRpgWorldProfile(...)` 都只调用: + +```text +POST /api/runtime/custom-world/profile +``` + +测试如需离线运行,应 mock `requestJson`,不能恢复本地 AI 生成链。 + +### 2.2 后端 + +`server-rs/crates/api-server/src/app.rs` 新增: + +```text +POST /api/runtime/custom-world/profile +``` + +handler 落在 `server-rs/crates/api-server/src/custom_world.rs`: + +1. 校验 `settingText`。 +2. 要求 Bearer 鉴权。 +3. 要求 `platform-llm` 可用。 +4. 复用 `generate_custom_world_foundation_draft(...)` 生成 profile 草稿。 +5. 补齐结果页需要的 `id / settingText / templateWorldType / compatibilityTemplateWorldType / items / generationMode / generationStatus / creatorIntent`。 +6. 直接返回 `CustomWorldProfile` JSON,保持前端旧 client contract 不变。 + +本轮不新增 SpacetimeDB 表,不修改 `migration.rs`。 + +## 3. 验收 + +1. `src/services/rpg-creation/**` 不再出现 `import('../ai')`、`LegacyAiModule`、`loadLegacyAiModule`。 +2. `src/services/rpg-creation/index.ts` 不再导出 `generateLegacyCustomWorldProfile`。 +3. node 环境测试确认 profile 生成只走 `requestJson` mock。 +4. Rust `api-server` 测试确认 `/api/runtime/custom-world/profile` 未登录返回 `401`。 diff --git a/docs/technical/RPG_CREATION_RESULT_VIEW_BACKEND_TRUTH_MIGRATION_2026-04-28.md b/docs/technical/RPG_CREATION_RESULT_VIEW_BACKEND_TRUTH_MIGRATION_2026-04-28.md new file mode 100644 index 00000000..6dc3dcd9 --- /dev/null +++ b/docs/technical/RPG_CREATION_RESULT_VIEW_BACKEND_TRUTH_MIGRATION_2026-04-28.md @@ -0,0 +1,82 @@ +# RPG 创作结果页后端真相视图迁移方案(2026-04-28) + +## 1. 本次落地边界 + +本次只迁移 `RPG_FRONTEND_SCRIPT_BACKEND_MIGRATION_AUDIT_2026-04-28.md` 中 5.4 对应链路: + +1. 创作结果页自动保存前的 profile normalize 与 session 同步顺序。 +2. Agent session / result preview / legacyResultProfile 的真相优先级。 +3. 作品草稿点击后应进入 Agent workspace、生成过程页还是结果页的裁决。 + +不在本轮处理运行时 GameState、战斗、NPC、背包和锻造规则。 + +## 2. 后端读模型 + +新增稳定读模型: + +```text +GET /api/runtime/custom-world/agent/sessions/:sessionId/result-view +``` + +响应字段: + +1. `session`:最新 Agent session snapshot。 +2. `profile`:服务端按优先级选出的结果页 profile。 +3. `profileSource`:`result_preview` / `draft_profile` / `none`。 +4. `targetStage`:前端应打开的 stage。 +5. `generationViewSource` / `resultViewSource`:前端视图来源。 +6. `canAutosaveLibrary`:作品库自动保存是否可执行。 +7. `canSyncResultProfile`:结果页编辑是否允许回写 session。 +8. `recoveryAction`:缺失或失败时的恢复指令。 + +## 3. 真相优先级 + +服务端统一执行以下优先级,前端不再自己解释: + +1. 首选 `session.resultPreview.preview`。 +2. 若没有 result preview,但 `draftProfile` 是已可打开结果页的完整 profile,则使用 `draftProfile`。 +3. `draftProfile.legacyResultProfile` 只作为后端兼容恢复来源,不再由前端直接读取。 +4. 没有可用 profile 时,服务端返回 `targetStage` 指示前端回生成过程页或 Agent workspace。 + +## 4. 保存链路 + +结果页编辑仍允许前端持有临时表单态,但保存必须按顺序: + +1. 前端调用 `sync_result_profile` action,把编辑后的 profile 写回 Agent session。 +2. 前端读取 `result-view`,以服务端返回的 `profile` 刷新界面。 +3. 自动保存作品库只保存 `result-view.profile`,不再自己决定 session/profile 优先级。 +4. Agent 结果页保存成功后,作品库响应只刷新列表、详情与自动保存签名;当前编辑界面仍以 `result-view.profile` 为准,避免兼容响应缺少角色、地标等完整字段时覆盖正在编辑的结果页。 + +### 4.1 保存前 profile canonicalize + +`creatorIntent -> settingText` 的保存前归一必须在后端执行: + +1. `sync_result_profile` action 入站时,后端基于 `payload.profile.creatorIntent` 生成 canonical `settingText` 后再写入 Agent session 与 `resultPreview`。 +2. `PUT /api/runtime/custom-world-library/:profileId` 入站时,后端对 `payload.profile` 执行同一规则后再抽取 metadata 与写入作品库。 +3. 前端结果页、作品详情页、平台壳层只能保存用户当前编辑草稿,不再调用 `normalizeRpgEntryAgentBackedProfile(...)` 改写正式字段。 +4. 前端自动保存去重签名使用草稿 JSON 本身;保存成功后以服务端返回的 canonical entry/result-view 刷新界面。 + +该规则的唯一语义是:当 `creatorIntent` 含有有效锚点时,按“世界一句话 / 玩家开局 / 主题气质 / 核心冲突 / 关键关系 / 标志元素”的固定顺序生成 foundation text,并覆盖保存入库或 session 的 `settingText`。没有有效锚点时不改写用户草稿。 + +## 5. 前端职责 + +前端只保留: + +1. 页面切换。 +2. loading / error / autosave 状态。 +3. 用户正在编辑的临时 profile。 +4. 调用后端 action 和 result-view。 + +前端禁止继续: + +1. 直接读取 `draftProfile.legacyResultProfile`。 +2. 自行判断草稿应打开 Agent workspace、生成过程页还是结果页。 +3. 自动保存前只刷新 session 后用 session 旧快照覆盖本地编辑。 + +## 6. 验收 + +1. `rpgCreationPreviewAdapter` 不再读取 `legacyResultProfile`。 +2. `useRpgCreationResultAutosave` 对 Agent 草稿结果页会先执行 `sync_result_profile`,再读取后端 result-view。 +3. `useRpgEntryLibraryDetail` 根据 result-view 的 `targetStage` 切页。 +4. 测试覆盖编辑后不会被旧 session 覆盖、无 result preview 时由后端决定恢复入口。 +5. 测试覆盖 `sync_result_profile` 与作品库 upsert 入站时由后端 canonicalize `settingText`,前端 autosave 不再保存前 normalize。 diff --git a/docs/technical/RPG_FUNCTION_AND_PROMPT_EDITOR_REORGANIZATION_2026-04-28.md b/docs/technical/RPG_FUNCTION_AND_PROMPT_EDITOR_REORGANIZATION_2026-04-28.md new file mode 100644 index 00000000..17ed7fba --- /dev/null +++ b/docs/technical/RPG_FUNCTION_AND_PROMPT_EDITOR_REORGANIZATION_2026-04-28.md @@ -0,0 +1,125 @@ +# RPG 选项函数与提示词编辑面整理方案(2026-04-28) + +## 背景 + +当前 RPG 运行时已经把不少选项 function 的定义拆到了 `src/data/functionCatalog/`,但仍存在两个影响编辑效率的问题: + +1. `src/data/stateFunctions.ts` 里还残留一批按 `functionId` 分支的运行时文案、优先级与细节逻辑,导致“定义在独立文件,行为还混在总文件里”。 +2. RPG 运行时提示词虽然已经有独立模块,但前端 `src/prompts/` 与 Rust `server-rs/crates/api-server/src/prompt/` 里仍然缺少按 `rpg` 维度统一收口的子目录,编辑提示词时仍要在多个平铺文件里来回找。 + +用户目标是: + +1. RPG 中不同选项 function 拆成独立函数,并且能在同一个脚本中看到所有选项 function 的代码入口。 +2. RPG 中运行时提示词都整理进 `prompt` 文件夹,并把 RPG prompt 脚本整理到更适合专注编辑提示词的结构中。 + +## 本次落地边界 + +1. 只整理 RPG 相关的前端运行时 function 与 prompt 结构。 +2. Rust 侧只整理 `server-rs` 的 prompt 模块结构,不兼容 `server-node`。 +3. 不改玩法语义,不重写大段中文提示词正文;优先移动文件、补兼容导出、增加聚合入口。 +4. 不在 UI 里增加说明文案。 + +## 目标结构 + +### 前端 RPG prompt + +整理为: + +```text +src/prompts/ +├─ customWorldEntityActionPrompts.ts +├─ customWorldPrompts.ts +└─ rpg/ + ├─ index.ts + ├─ runtimeStoryPrompts.ts + └─ characterChatPrompts.ts +``` + +说明: + +1. `runtimeStoryPrompts.ts` 承载原 `storyPromptBuilders.ts` 的 RPG 运行时剧情导演、NPC 对话导演、招募对话等提示词。 +2. `characterChatPrompts.ts` 承载原角色面板私聊提示词。 +3. 旧入口 `src/services/prompt.ts` 与 `src/services/characterChatPrompt.ts` 保留兼容转发,避免一次性改调用方。 +4. 角色资产工坊默认 prompt 与缓存合并规则不再放在前端 prompt 目录,统一迁到 `server-rs/crates/api-server/src/prompt/rpg/role_asset_studio.rs`。 + +### Rust 侧 RPG prompt + +整理为: + +```text +server-rs/crates/api-server/src/prompt/ +├─ big_fish.rs +├─ character_animation.rs +├─ character_visual.rs +├─ puzzle_image.rs +├─ scene_background.rs +├─ mod.rs +└─ rpg/ + ├─ mod.rs + ├─ agent_chat.rs + ├─ foundation_draft.rs + ├─ role_asset_studio.rs + └─ runtime_chat.rs +``` + +说明: + +1. `prompt/rpg/agent_chat.rs` 承载 RPG 共创聊天提示词。 +2. `prompt/rpg/foundation_draft.rs` 承载 RPG 草稿生成提示词。 +3. `prompt/rpg/role_asset_studio.rs` 承载角色资产工坊默认 prompt、legacy prompt 过滤与缓存合并 workflow view。 +4. `prompt/rpg/runtime_chat.rs` 承载 RPG 运行时剧情、NPC 对话、战斗结果叙事等提示词。 +5. 顶层 `prompt/mod.rs` 继续向外导出 RPG 子模块,保证原调用点只做最小修改。 + +### RPG function 总览 + +新增一个面向编辑者的聚合入口,用来同时暴露: + +1. 所有 RPG function 文档项。 +2. 所有状态类 function source。 +3. 每个状态类 function 的运行时行为处理器入口。 + +目标是让后续查看时可以先打开一个总览文件,再跳到对应 function 文件,而不是先从 `stateFunctions.ts` 的大 `switch` 里反查。 + +## 代码落地策略 + +### 1. function 运行时逻辑继续拆分 + +把 `src/data/stateFunctions.ts` 中这些按 `functionId` 写死的逻辑继续拆出: + +1. 建议 actionText 生成。 +2. detailText 生成。 +3. function priority 计算。 +4. 必要的运行时 definition 微调。 + +每个状态类 function 文件在保留 `definition + documentation + promptDescription` 的基础上,追加该 function 的运行时处理器。 + +### 2. 总览脚本 + +新增聚合入口文件,统一导出: + +1. 各域 function 文档。 +2. 状态类 function runtime source。 +3. 便于编辑时查找的数组/映射。 + +这样“同一个脚本看到所有选项 function 的代码”具体落地为: + +1. 先看总览脚本知道有哪些 function。 +2. 每个 function 仍在独立文件维护,避免再次回到一个巨型文件。 +3. 总览脚本只能依赖 `state / npc / treasure / flow / panel` 等分目录入口,不能从 `src/data/functionCatalog/index.ts` 反向导入聚合常量,避免浏览器 ESM 初始化时出现 `Cannot access before initialization`。 + +## 验证 + +1. `npm run check:encoding` +2. `npm run test -- src/services/prompt.test.ts src/hooks/rpg-runtime-story/storyResponseOptions.test.ts` +3. `npm run typecheck` +4. 如涉及 Rust prompt 模块编译错误,再补 `cargo check -p api-server` + +## 后续编辑约定 + +1. 想改 RPG 运行时提示词时,优先进入: + - 前端:`src/prompts/rpg/` + - Rust:`server-rs/crates/api-server/src/prompt/rpg/` +2. 想改 RPG 选项 function 时,优先进入: + - 总览:`src/data/functionCatalog/index.ts` + - 状态类分项:`src/data/functionCatalog/state/*.ts` +3. 后续不要再把 RPG prompt 正文重新塞回 `services`、路由或运行时编排文件。 diff --git a/docs/technical/RPG_INVENTORY_EQUIPMENT_FORGE_VIEW_BACKEND_MIGRATION_2026-04-28.md b/docs/technical/RPG_INVENTORY_EQUIPMENT_FORGE_VIEW_BACKEND_MIGRATION_2026-04-28.md new file mode 100644 index 00000000..07594d9a --- /dev/null +++ b/docs/technical/RPG_INVENTORY_EQUIPMENT_FORGE_VIEW_BACKEND_MIGRATION_2026-04-28.md @@ -0,0 +1,99 @@ +# RPG 背包 / 装备 / 锻造视图后端迁移落地方案(2026-04-28) + +## 0. 本次目标 + +依据 `docs/audits/engineering/RPG_FRONTEND_SCRIPT_BACKEND_MIGRATION_AUDIT_2026-04-28.md` 的 `5.2 P1` 项,本次只收口一类职责: + +**背包、装备、锻造面板的可用性、禁用原因、配方视图由 `server-rs` 计算,前端只渲染后端 view,并提交用户选择。** + +本次不顺手迁移以下 P0 链路: + +1. runtime action 仍携带完整 `GameState` 快照。 +2. 战斗胜负后处理与旅行桥接仍在既有阶段迁移。 +3. `inventory_slot` 表真相与 compat `GameState` 快照仍按现状共存。 + +## 1. 后端落点 + +本阶段采用最小后端落点: + +1. `server-rs/crates/shared-contracts/src/runtime_story.rs` + - 扩展 `RuntimeStoryViewModel`,新增 `inventory` 字段。 + - 定义背包物品、装备槽、物品动作、锻造配方、配方材料需求的 view contract。 +2. `server-rs/crates/module-runtime-story-compat/src/forge.rs` + - 公开确定性的配方定义与需求统计能力。 + - 配方定义补齐前端已有 `forgeSystem.ts` 的三条 forge 配方。 +3. `server-rs/crates/module-runtime-story-compat/src/view_model.rs` + - 从 `GameState` 快照编译 `inventory` view model。 + - 输出每个动作的 `enabled / reason`,由后端统一说明为什么不可用。 +4. `server-rs/crates/api-server/src/runtime_story/compat/*` + - 原 action resolver 复用同一套 forge 定义和可用性判断。 + +## 2. 前端落点 + +1. `packages/shared/src/contracts/rpgRuntimeStoryState.ts` + - 与 Rust contract 对齐新增 `inventory` view 类型。 +2. `src/services/rpg-runtime/rpgRuntimeStoryClient.ts` + - 提供 `loadRpgRuntimeInventoryView`,通过 runtime story session state 获取后端 view。 + - 当前 runtime story 主链已完成不上传完整 `GameState` 的迁移,因此该读取入口只按 `runtimeSessionId` 请求后端持久化状态。 +3. `src/hooks/rpg-runtime-story/inventoryActions.ts` + - 删除 `getForgeRecipeViews(...)` 本地配方计算。 + - 不再通过本地 `playerInventory.find(...)` / `playerEquipment[...]` 作为正式动作门禁。 + - 使用后端 view 的 `actions` 与 `forgeRecipes` 判断按钮可用性和 action 文案。 +4. `src/components/InventoryPanel.tsx` + - 继续只展示传入的 view。 + - 支持展示后端 `disabledReason`,不再自行解释配方规则。 + - 背包列表优先使用后端 `backpackItems`,货币文案优先使用后端 `currencyText`。 + +## 3. 可用性规则 + +后端 `inventory` view 应至少输出: + +1. `backpackItems` + - 背包里的物品快照。 + - `actions.use / equip / dismantle / reforge`。 +2. `equipmentSlots` + - `weapon / armor / relic` 三槽。 + - 每槽当前装备与 `actions.unequip`。 +3. `forgeRecipes` + - 配方 id、名称、类型、说明、产物、货币花费。 + - 每项需求的 `owned / quantity`。 + - `canCraft` 与 `disabledReason`。 +4. `currencyText` + - 由后端按 `worldType` 格式化。 + +禁用规则: + +1. 缺少玩家角色时,所有正式动作不可用。 +2. 战斗中,装备 / 卸装 / 锻造 / 拆解 / 重铸不可用;可用物品仍可由战斗动作链处理。 +3. 不可使用的物品返回 `use.enabled=false`。 +4. 非装备物品返回 `equip.enabled=false`。 +5. 无 buildProfile 且非装备的物品不可拆解。 +6. 非装备或材料不足 / 货币不足的物品不可重铸。 +7. 材料或货币不足的配方不可制作,并返回原因。 + +## 4. 验收 + +1. 前端 `inventoryActions.ts` 不再引用 `getForgeRecipeViews`。 +2. 前端配方按钮使用后端 `forgeRecipes`。 +3. 后端 `RuntimeStoryViewModel` JSON 中存在 `inventory`。 +4. Rust contract / compat view model 有单元测试覆盖: + - 配方 `canCraft` 与需求数量。 + - 装备、卸装、拆解、重铸禁用原因。 +5. TypeScript client 测试覆盖后端 inventory view 获取与保留。 +6. 修改后执行: + - Rust 相关测试。 + - TypeScript 相关测试。 + - `npm run api-server:maincloud`。 + +## 5. 本次实现结果 + +1. `server-rs` 已在 `RuntimeStoryViewModel.inventory` 输出背包、装备槽、锻造配方、动作 payload 与禁用原因。 +2. `module-runtime-story-compat` 的锻造配方定义已补齐 `forgeSystem.ts` 中仍留在前端的合成 / 锻造配方,并对 `mana / 法力` 标签做后端匹配兼容。 +3. `src/hooks/rpg-runtime-story/inventoryActions.ts` 已改为: + - 读取 `loadRpgRuntimeInventoryView(...)`。 + - 用户动作只使用后端 action view 的 `functionId / actionText / payload`。 + - 缺失或禁用时展示后端 `reason / disabledReason`。 +4. `src/components/InventoryPanel.tsx` 已改为: + - 背包格子优先渲染后端 `backpackItems`。 + - 工坊列表渲染后端 `forgeRecipes`。 + - 禁用配方展示后端原因。 diff --git a/docs/technical/RPG_PROMPT_FRONTEND_REMOVAL_AND_SERVER_RS_MIGRATION_2026-04-28.md b/docs/technical/RPG_PROMPT_FRONTEND_REMOVAL_AND_SERVER_RS_MIGRATION_2026-04-28.md new file mode 100644 index 00000000..9c825702 --- /dev/null +++ b/docs/technical/RPG_PROMPT_FRONTEND_REMOVAL_AND_SERVER_RS_MIGRATION_2026-04-28.md @@ -0,0 +1,102 @@ +# RPG 提示词前端禁存与 server-rs 收口方案(2026-04-28) + +## 背景 + +当前 RPG 运行时虽然已经大面积切到 `server-rs` 的 `/api/runtime/**`,但前端仍残留以下错误边界: + +1. `src/services/ai.ts` 仍保留 RPG 剧情、角色私聊、NPC 对话、招募对话的本地 prompt 生成与浏览器侧 LLM fallback。 +2. `src/prompts/rpg/`、`src/prompts/storyPromptBuilders.ts`、`src/prompts/characterChatPrompts.ts` 仍存放 RPG 提示词正文。 +3. `src/services/aiService.ts` 在非浏览器环境下仍会回退到 `./ai`,等价于保留“前端可持有 RPG prompt”的技术后门。 + +这与仓库约束“前端只负责表现,逻辑、数据放后端”直接冲突,也会让提示词编辑入口继续分裂。 + +## 本次强约束 + +1. RPG 提示词禁止存在前端工程。 +2. RPG 提示词唯一允许存在于 `server-rs/crates/api-server/src/prompt/rpg/`。 +3. 前端只允许保留: + - 运行时请求 contract + - API client + - UI 展示与交互状态 +4. 旧 `src/services/ai.ts` 不再承担 RPG 剧情/聊天 prompt 生成职责。 + +## 收口目标 + +### 后端唯一 prompt 目录 + +RPG 运行时提示词统一收口到: + +```text +server-rs/crates/api-server/src/prompt/rpg/ +├─ mod.rs +├─ agent_chat.rs +├─ foundation_draft.rs +└─ runtime_chat.rs +``` + +其中: + +1. `agent_chat.rs` 负责创作态 RPG Agent prompt。 +2. `foundation_draft.rs` 负责 RPG 草稿生成 prompt。 +3. `runtime_chat.rs` 负责运行时剧情、角色私聊、NPC 聊天、招募对话等 prompt。 + +### 前端职责缩减 + +前端保留: + +1. `src/services/aiService.ts` + - 只负责请求 `/api/runtime/**` + - 不再回退到本地 RPG prompt 构造 +2. `src/services/rpg-runtime/*` + - 只负责按运行时域转发 client +3. `src/hooks/rpg-runtime-story/*` + - 只消费 API 回包并驱动 UI + +前端移除: + +1. `src/prompts/rpg/*` +2. `src/prompts/storyPromptBuilders.ts` +3. `src/prompts/characterChatPrompts.ts` +4. `src/services/prompt.ts` +5. `src/services/characterChatPrompt.ts` +6. `src/services/ai.ts` 中全部 RPG prompt / RPG 本地 LLM fallback 逻辑 + +## 运行时接口对齐 + +为彻底去掉前端 prompt,`server-rs` 必须承接以下接口: + +1. `POST /api/runtime/story/initial` +2. `POST /api/runtime/story/continue` +3. `POST /api/runtime/chat/character/suggestions` +4. `POST /api/runtime/chat/character/summary` +5. `POST /api/runtime/chat/character/reply/stream` +6. `POST /api/runtime/chat/npc/dialogue/stream` +7. `POST /api/runtime/chat/npc/turn/stream` +8. `POST /api/runtime/chat/npc/recruit/stream` + +其中: + +1. 非流式接口统一返回 `{ text: string }` +2. 流式接口统一返回可被前端直接消费的纯文本 SSE 增量 +3. NPC turn 仍保留当前带 `suggestions / functionSuggestions / chatDirective` 的专用 SSE 结构 + +## 前端代码落地要求 + +1. `aiService.ts` 在 RPG 相关方法中禁止再动态 `import('./ai')` +2. 若在非浏览器环境误调用 RPG 运行时能力,应直接报错,明确提示必须走 `api-server` +3. 角色私聊目标状态类型等纯类型定义可以留在前端,但必须与 prompt 文件彻底解耦 + +## 验证 + +1. `npm run check:encoding` +2. `npm run test -- src/services/ai.test.ts src/hooks/rpg-runtime-story/storyResponseOptions.test.ts` +3. `cargo check -p api-server` +4. `npm run api-server:maincloud` + +## 后续编辑约定 + +之后如果要改 RPG 提示词: + +1. 只进入 `server-rs/crates/api-server/src/prompt/rpg/` +2. 不允许在 `src/` 下新增任何 RPG prompt、system prompt、prompt builder +3. 前端若出现“为了临时 fallback 先放一个 prompt”的需求,视为越界,必须改为补后端接口 diff --git a/docs/technical/RPG_ROLE_ASSET_STUDIO_WORKFLOW_PROMPT_BACKEND_MIGRATION_2026-04-28.md b/docs/technical/RPG_ROLE_ASSET_STUDIO_WORKFLOW_PROMPT_BACKEND_MIGRATION_2026-04-28.md new file mode 100644 index 00000000..b1dc110e --- /dev/null +++ b/docs/technical/RPG_ROLE_ASSET_STUDIO_WORKFLOW_PROMPT_BACKEND_MIGRATION_2026-04-28.md @@ -0,0 +1,103 @@ +# RPG 角色资产工坊默认 Prompt 与缓存合并后端迁移(2026-04-28) + +## 背景 + +`docs/audits/engineering/RPG_FRONTEND_SCRIPT_BACKEND_MIGRATION_AUDIT_2026-04-28.md` 已明确指出:角色资产工坊中用户正在编辑的 prompt 草稿可以留在前端表单,但默认 prompt 生成、legacy prompt 判断、缓存合并和工作流初始态不应继续散落在 `RpgCreationRoleAssetStudioModalImpl.tsx`。 + +本次迁移只处理这一项边界,不扩大到角色生图、动作生成的模型参数默认值重构。 + +## 落地边界 + +1. 后端新增角色资产工坊 workflow view,负责输出: + - 从角色字段挑选出的默认视觉 / 动作 / 场景 prompt 种子。 + - 过滤 legacy 旧生成 prompt 后的视觉 prompt。 + - 按动作 key 合并后的动作 prompt map。 + - 从缓存回填的候选图、选中候选、选中动作、形象资产和动作 map。 +2. 前端只把当前正在编辑的角色快照传给后端解析 workflow view。 +3. 前端保存缓存时只保存用户当前表单草稿和资产结果,不再计算合并规则。 +4. 现有 OSS JSON 缓存继续复用,不新增 SpacetimeDB 表结构,因此本轮不修改 `migration.rs`。 + +## 接口设计 + +新增解析接口: + +```text +POST /api/runtime/custom-world/asset-studio/role/{character_id}/workflow +``` + +请求体: + +```json +{ + "cacheScopeId": "world-id", + "role": { + "id": "role-id", + "name": "角色名", + "title": "头衔", + "role": "世界身份", + "visualDescription": "角色视觉描述", + "actionDescription": "角色动作描述", + "sceneVisualDescription": "场景描述", + "description": "通用描述", + "backstory": "背景", + "combatStyle": "战斗风格" + } +} +``` + +响应体: + +```json +{ + "ok": true, + "cache": {}, + "workflow": { + "defaultPromptBundle": { + "visualPromptText": "", + "animationPromptText": "", + "scenePromptText": "" + }, + "visualPromptText": "", + "animationPromptTextByKey": { + "run": "", + "attack": "", + "idle": "", + "die": "" + } + } +} +``` + +新增保存接口: + +```text +PUT /api/runtime/custom-world/asset-studio/role/{character_id}/workflow +``` + +它复用原 `POST /api/assets/character-workflow-cache` 的 OSS JSON 缓存保存逻辑,并补齐 `animationPromptTextByKey` 持久化。 + +## 合并规则主源 + +后端主源: + +```text +server-rs/crates/api-server/src/prompt/rpg/role_asset_studio.rs +``` + +规则保持现有语义: + +1. `visualPromptText` 默认优先取 `visualDescription`,其次 `description`,长度上限 220。 +2. `animationPromptText` 默认优先取 `actionDescription`,其次 `combatStyle`,长度上限 180。 +3. `scenePromptText` 默认优先取 `sceneVisualDescription`,其次 `backstory`,长度上限 220。 +4. 角色存在新的 `visualDescription` 时,不使用缓存视觉 prompt 覆盖默认值。 +5. 角色存在新的 `actionDescription` 时,所有动作 prompt 使用新的默认动作 prompt。 +6. 角色没有新的动作描述时,逐动作优先使用 `animationPromptTextByKey`,再回退旧 `animationPromptText`,最后回退默认动作 prompt。 +7. 命中历史生成模板标记的视觉 / 动作 prompt 不再作为可继承缓存。 + +## 验收 + +1. `src/` 不再引用 `buildDefaultRolePromptBundle`。 +2. `RpgCreationRoleAssetStudioModalImpl.tsx` 不再包含 legacy prompt 判断与缓存合并函数。 +3. `CharacterWorkflowCachePayload` 能读写 `animationPromptTextByKey`。 +4. Rust 单测覆盖默认 prompt、legacy 过滤、逐动作缓存合并。 +5. 前端 client 单测覆盖 workflow 解析接口和 PUT 保存接口。 diff --git a/docs/technical/RPG_RUNTIME_BOOTSTRAP_BACKEND_MIGRATION_2026-04-28.md b/docs/technical/RPG_RUNTIME_BOOTSTRAP_BACKEND_MIGRATION_2026-04-28.md new file mode 100644 index 00000000..3e77edc7 --- /dev/null +++ b/docs/technical/RPG_RUNTIME_BOOTSTRAP_BACKEND_MIGRATION_2026-04-28.md @@ -0,0 +1,70 @@ +# RPG 运行时开局 GameState 后端迁移落地(2026-04-28) + +## 目标 + +本次只收口 `RPG_FRONTEND_SCRIPT_BACKEND_MIGRATION_AUDIT_2026-04-28.md` 的 `4.1 P0 运行时开局 GameState 装配仍在前端`。 + +## 边界 + +前端保留: + +1. 选择世界、选择角色、切换 tab、地图弹层等 UI 状态。 +2. 世界选择后的“尚未选角”中间态,用于展示角色选择页面。 +3. 调用后端开局接口并接收快照。 + +后端负责: + +1. 生成 `runtimeSessionId` 与 `runtimeActionVersion`。 +2. 装配正式初始 `GameState`。 +3. 装配初始场景、opening act、首遇 NPC、NPC state。 +4. 装配初始背包、初始装备、血蓝、货币、技能冷却。 +5. 写入 runtime snapshot,成为后续 runtime story 的读取来源。 + +## 接口 + +新增: + +```text +POST /api/runtime/story/sessions +``` + +请求: + +```json +{ + "worldType": "CUSTOM", + "customWorldProfile": {}, + "character": {}, + "runtimeMode": "play", + "disablePersistence": false +} +``` + +响应: + +```json +{ + "sessionId": "runtime-...", + "serverVersion": 1, + "snapshot": { + "version": 2, + "savedAt": "...", + "bottomTab": "adventure", + "gameState": {}, + "currentStory": null + } +} +``` + +## 验收 + +1. `useRpgSessionBootstrap.ts` 不再在 `handleCharacterSelect` 中本地构造完整初始 `GameState`。 +2. 开局后 `gameState.runtimeSessionId` 来自后端。 +3. 开局后 `gameState.currentScene === "Story"`。 +4. 自定义世界 opening act 能写入 `storyEngineMemory.currentSceneActState`。 +5. 自定义世界角色 `initialItems` 能进入背包并自动装配可推断槽位。 +6. 后端测试覆盖 opening act、首遇 NPC、初始物品、装备。 + +## 后续 + +本次仍沿用 runtime story compat 的 JSON `GameState` 桥接形态。后续阶段应继续把 `runtime_story` action 从“快照桥接”推进为 SpacetimeDB 表级状态读写。 diff --git a/docs/technical/RPG_RUNTIME_POST_BATTLE_BACKEND_MIGRATION_2026-04-28.md b/docs/technical/RPG_RUNTIME_POST_BATTLE_BACKEND_MIGRATION_2026-04-28.md new file mode 100644 index 00000000..c5fb52fd --- /dev/null +++ b/docs/technical/RPG_RUNTIME_POST_BATTLE_BACKEND_MIGRATION_2026-04-28.md @@ -0,0 +1,63 @@ +# RPG 运行时战斗后处理后端迁移落地方案(2026-04-28) + +## 目标 + +本方案承接 `docs/audits/engineering/RPG_FRONTEND_SCRIPT_BACKEND_MIGRATION_AUDIT_2026-04-28.md` 的 4.5 项,专门收口三类仍由前端补真相的逻辑: + +1. 战斗胜利 / 切磋完成后的正式 `GameState` 清理。 +2. 玩家死亡后的复活场景、血蓝恢复与首场景 act 状态。 +3. 战斗结束后章节 act 推进与 `currentStory.deferredOptions` 编排。 + +## 边界 + +后端负责: + +1. 根据 battle resolver 的 `outcome` 决定 `victory`、`spar_complete`、`defeat`、`escaped` 的最终状态。 +2. 写回 `inBattle`、`currentEncounter`、`sceneHostileNpcs`、`currentNpcBattleOutcome`、`playerHp`、`playerMana`、`storyEngineMemory.currentSceneActState`。 +3. 为死亡复活构造回到首场景的快照,并恢复 `playerHp = playerMaxHp`、`playerMana = playerMaxMana`。 +4. 为胜利 / 切磋完成构造只含 `story_continue_adventure` 的当前 story,并把真实后续 options 放入 `deferredOptions`。 +5. 在最后一幕或无需等待继续按钮时直接返回场景旅行 / 常规 fallback options。 + +前端只负责: + +1. 播放 `presentation.battle` 对应动画。 +2. 使用 `response.snapshot.gameState` 与 `response.snapshot.currentStory` 渲染。 +3. 不再调用 `buildPostBattleVictoryState`、`buildPostBattleVictoryStory`、`buildRevivedFirstSceneState`、`buildDeathStory` 作为服务端动作后的正式状态。 + +## 后端落点 + +1. `server-rs/crates/module-runtime-story-compat/src/post_battle.rs` + - 增加纯 JSON helper,迁移战斗后状态、复活和 scene act 推进。 +2. `server-rs/crates/api-server/src/runtime_story/compat.rs` + - 在 `resolve_battle_action` 之后、生成 AI fallback 之前统一调用 post-battle finalizer。 +3. `server-rs/crates/api-server/src/runtime_story/compat/presentation.rs` + - 复用现有 option / current story 构造函数。 +4. `packages/shared/src/contracts/rpgRuntimeStoryState.ts` + - battle outcome 增加 `defeat`,避免前端类型层把失败误判成非战斗终局。 + +## 落地补充 + +1. 后端 post-battle finalizer 在 `resolve_battle_action` 之后、LLM fallback 之前执行,终局战斗不再生成额外 AI 文本。 +2. 胜利 / 切磋完成会清理战斗态并推进当前场景 act;非最后一幕只展示 `story_continue_adventure`,真实后续动作写入 `deferredOptions`。 +3. 败北复活会先写回首场景、回满血蓝、重置首场景 act,再基于复活后的场景重新生成 `deferredOptions`,避免沿用战斗前旧场景选项。 +4. story engine 投影额外接收 battle outcome;只有 `victory / spar_complete` 会记录胜利信号,`defeat` 不会被“战斗态从 true 变 false”误判成胜利。 +5. 前端 `runServerRuntimeChoiceAction` 的服务端路径不再调用 `postBattleFlow` 构造正式状态;死亡动画仍可短暂播放,但最终 `GameState/currentStory` 只采用后端 hydrated snapshot。 + +## 本轮收口记录 + +1. `choiceActions.ts` 删除 `shouldResolveCombatChoiceLocally(...)`,`battle_* / inventory_use` 不再因战斗可见态回落到本地 continuation。 +2. `storyChoiceContinuation.ts` 对 `battle_* / inventory_use` 以及被分类为 `battle / escape` 的动作加硬保护,误入时不会裁决掉落、复活、任务推进或战后 story。 +3. `storyChoiceRuntime.ts` 删除本地敌对 NPC 战斗奖励 helper,前端不再调用 `rollHostileNpcLoot(...)` 与 `addInventoryItems(...)` 生成正式战利品。 +4. 删除 `postBattleFlow.ts` 与其测试,前端不再保留死亡复活、胜利后 story、deferred options、章节推进的正式构造函数。 +5. `choiceActions.test.ts` 覆盖 `battle_use_skill`、stale `battle_attack_basic`、`inventory_use` 全部进入后端 resolver;`storyChoiceRuntime.test.ts` 继续覆盖服务端胜利 / 失败 snapshot 被直接采用。 + +## 验收 + +1. Rust 单测覆盖: + - 服务端 battle victory 返回后,`currentEncounter = null`、`inBattle = false`、`currentStory.options = [story_continue_adventure]`、`deferredOptions` 存在。 + - 服务端 battle defeat 返回后,玩家复活到首场景,`playerHp/playerMana` 回满,`currentStory` 为死亡复活故事。 +2. 前端单测覆盖: + - `runServerRuntimeChoiceAction` 对 `victory` 和 `defeat` 都直接采用服务端 snapshot/story,不再本地构造 post battle / revive 状态。 +3. 搜索确认 `src/hooks/rpg-runtime-story` 不再出现 `shouldResolveCombatChoiceLocally`、`buildPostBattleVictory*`、`buildRevivedFirstSceneState`、`buildDeathStory`、`buildHostileNpcBattleReward`。 +4. Rust 单测覆盖: + - story engine 对 `defeat` outcome 不写入 `win_battle` 信号和敌压 mutation。 diff --git a/docs/technical/RPG_RUNTIME_STORY_ENGINE_BACKEND_MIGRATION_2026-04-28.md b/docs/technical/RPG_RUNTIME_STORY_ENGINE_BACKEND_MIGRATION_2026-04-28.md new file mode 100644 index 00000000..dc06ebb6 --- /dev/null +++ b/docs/technical/RPG_RUNTIME_STORY_ENGINE_BACKEND_MIGRATION_2026-04-28.md @@ -0,0 +1,164 @@ +# RPG 运行时 Story Engine 后端迁移落地方案(2026-04-28) + +## 0. 本轮目标 + +本轮只收口 `RPG_FRONTEND_SCRIPT_BACKEND_MIGRATION_AUDIT_2026-04-28.md` 中 `4.4 P0 story engine / chapter / world mutation` 这条。 + +目标不是新增一套前端规则,而是让运行时动作完成后由 `server-rs` 统一写回: + +1. `storyHistory` +2. `storyEngineMemory` +3. `chapterState` +4. `currentScenePreset.mutationStateText / currentPressureLevel / description` + +前端只能展示这些后端字段,不能继续在 hook 中运行 `chapterDirector / threadSignalRouter / worldMutationRouter` 等正式状态机。 + +## 1. 后端落点 + +### 1.1 `module-runtime-story-compat` + +新增 `story_engine.rs`,作为无 HTTP、无 `AppState` 的纯 JSON projector。 + +职责: + +1. 确保 `storyEngineMemory` 最小结构存在。 +2. 按上一帧与下一帧快照生成 story signals。 +3. 基于信号推进 active thread、recent signal。 +4. 基于当前场景和任务生成 `ChapterState`。 +5. 基于章节和信号生成 `WorldMutation`。 +6. 把 mutation 投影到当前场景展示字段。 +7. 追加最小 chronicle、journey beat、continue digest。 + +### 1.2 `api-server runtime_story compat` + +`resolve_runtime_story_action` 在动作确定性结算和 `storyHistory` 写入后,统一调用 projector,再持久化快照。 + +这样即使前端只提交 `functionId/payload`,正式叙事记忆也由后端结果生成。 + +## 2. 前端收口 + +### 2.1 `progressionActions.ts` + +保留: + +1. 展示层 loading/error。 +2. encounter 入场动画。 +3. 调用后端生成 story 或 fallback story。 + +移除: + +1. `applyStoryEngineEchoes` +2. 本地章节任务补发。 +3. 本地 thread signal、companion reaction、chapter、journey beat、world mutation、QA、release gate 等编排。 + +### 2.2 `storyContextBuilder.ts` + +保留 prompt context 适配职责,但只能读取后端已存在的字段: + +1. `state.chapterState` +2. `state.storyEngineMemory.currentChapter` +3. `state.storyEngineMemory.currentJourneyBeat` +4. `state.storyEngineMemory.worldMutations` +5. `state.currentScenePreset` + +禁止继续导入并运行 story engine director。 + +## 3. 验收标准 + +1. `src/hooks/rpg-runtime-story/progressionActions.ts` 不再导入 `services/storyEngine/*`。 +2. `src/hooks/rpg-runtime-story/storyContextBuilder.ts` 不再导入 `services/storyEngine/*`。 +3. `resolve_runtime_story_action` 返回的 snapshot 中包含后端写入的 `storyEngineMemory.currentChapter`。 +4. 场景动作后 `currentScenePreset.mutationStateText` 由后端 projector 写入。 +5. `cargo test -p module-runtime-story-compat story_engine --manifest-path server-rs/Cargo.toml` 通过。 +6. `cargo test -p api-server runtime_story --manifest-path server-rs/Cargo.toml` 通过。 +7. 前端相关 vitest 与编码检查通过。 + +## 4. 本轮落地记录 + +### 4.1 后端已落地 + +1. `server-rs/crates/module-runtime-story-compat/src/story_engine.rs` 新增确定性 projector。 +2. `server-rs/crates/api-server/src/runtime_story/compat.rs` 在 action resolve 写入 `storyHistory` 后调用 projector,再保存 snapshot。 +3. `server-rs/crates/api-server/src/runtime_story/compat/tests.rs` 新增 route 边界测试,覆盖响应 snapshot 中的: + - `chapterState.id` + - `storyEngineMemory.currentChapter.id` + - `quests[].chapterId` + - `currentScenePreset.mutationStateText` + - `storyEngineMemory.worldMutations` + +### 4.2 前端已收口 + +1. `src/hooks/rpg-runtime-story/progressionActions.ts` 不再执行本地 story engine echo、chapter、journey beat、world mutation 编排。 +2. `src/hooks/rpg-runtime-story/storyContextBuilder.ts` 不再导入 `services/storyEngine/*`,只读取后端快照中已有的章节、旅程、mutation、chronicle、companion reaction 等字段。 +3. prompt context 中 `visibilitySlice / sceneNarrativeDirective / goalStack / activeScenarioPack / activeCampaignPack` 暂不在前端重建,等待后端后续模块正式写入后直接透传。 + +### 4.3 验证结果 + +已通过: + +1. `cargo test -p module-runtime-story-compat story_engine --manifest-path server-rs\Cargo.toml` +2. `cargo test -p api-server runtime_story --manifest-path server-rs\Cargo.toml` +3. `cargo test -p api-server runtime_story_route_boundary_projects_story_engine_state --manifest-path server-rs\Cargo.toml` +4. `npm run test -- src/hooks/rpg-runtime-story/storyRequestCoordinator.test.ts src/hooks/rpg-runtime-story/storyRequestRuntime.test.ts src/hooks/rpg-runtime-story/useRpgRuntimeStoryController.test.tsx src/hooks/rpg-runtime-story/choiceActions.test.ts src/hooks/rpg-runtime-story/storyInteractionCoordinator.test.ts` +5. `npx eslint src/hooks/rpg-runtime-story/storyContextBuilder.ts src/hooks/rpg-runtime-story/progressionActions.ts --max-warnings 0` +6. `cargo fmt --manifest-path server-rs\Cargo.toml --all --check` + +已发现的非本轮阻塞: + +1. `npm run typecheck` 当前被既有 NPC 交易、背包/锻造 UI、测试 fixture、`src/services/ai.ts` 缺 import 等错误拦截。 +2. `npm run test -- src/hooks/rpg-runtime-story` 当前有 1 个 `storyChoiceRuntime.test.ts` 战斗死亡/复活断言失败,属于审计后续 `4.5` post-battle 迁移范围。 + +### 4.4 NPC 聊天半量快照容错补丁 + +用户复测角色聊天时,点击 NPC 聊天选项后触发: + +`Cannot read properties of undefined (reading 'length')` + +复查调用链确认,后端 story engine projector 已经成为 `storyEngineMemory` 的主写入方,但部分快照或旧存档可能只携带 `currentChapter / worldMutations` 等增量字段,没有补齐 `activeThreadIds / recentCarrierIds / discoveredFactIds` 等数组字段。前端在 `syncNpcNarrativeState()` 中把半量对象当完整 `StoryEngineMemoryState` 消费,直接读取 `activeThreadIds.length`,导致 NPC 选项点击后的好感与叙事记忆同步中断。 + +本轮只做消费边界容错,不恢复前端 story engine 状态机: + +1. `visibilityEngine.ts` 增加 `normalizeStoryEngineMemoryState()`,以 `createEmptyStoryEngineMemoryState()` 为基底补齐数组字段,同时保留后端快照已有字段。 +2. `syncNpcNarrativeState()` 与 `appendStoryEngineCarrierMemory()` 在读写叙事记忆前统一归一化,避免半量快照在 NPC 聊天、物品回声等路径里崩溃。 +3. `buildEncounterVisibilitySlice()` 与 `buildQuestVisibilitySlice()` 直接消费外部 memory 时也先归一化,保证 visibility 层独立调用时口径一致。 +4. 新增 `echoMemory.test.ts` 回归用例,覆盖只有 `currentChapter`、缺少 `activeThreadIds` 的后端投影快照。 + +验证: + +1. `npm run test -- src/services/storyEngine/echoMemory.test.ts src/services/storyEngine/visibilityEngine.test.ts` +2. `npm run check:encoding -- src/services/storyEngine/echoMemory.ts src/services/storyEngine/visibilityEngine.ts src/services/storyEngine/echoMemory.test.ts docs/technical/RPG_RUNTIME_STORY_ENGINE_BACKEND_MIGRATION_2026-04-28.md` + +### 4.5 story prompt context 后端 projector 收口 + +本轮继续收口 `RPG_FRONTEND_SCRIPT_BACKEND_MIGRATION_COMPLETION_CHECK_2026-04-28.md` 中仍未完成的 `story engine / prompt context / AI story 请求编排`: + +1. `server-rs/crates/module-runtime-story-compat/src/prompt_context.rs` 新增 `build_runtime_story_prompt_context(...)`,基于后端持久化 `gameState` 投影: + - 场景描述、mutation、压力等级。 + - encounter / NPC 好感、披露阶段、可谈话题、首次接触姿态。 + - conversationSituation / conversationPressure / talkPriority。 + - chapter、journey beat、worldMutations、chronicle、party relationship notes。 +2. `POST /api/runtime/story/initial` 与 `POST /api/runtime/story/continue` 支持新主链 payload: + - `sessionId` + - `clientVersion` + - `choice` + - `lastFunctionId` + - `observeSignsRequested` + - `recentActionResult` + - `requestOptions` +3. 后端收到 `sessionId` 后只从服务端 runtime snapshot 读取 `worldType / playerCharacter / sceneHostileNpcs / storyHistory / prompt context`;旧 `worldType / character / history / context` 字段仅保留兼容,不作为正式主链来源。 +4. `runtime_chat_plain.rs` 与 `runtime_chat.rs` 同步支持 `sessionId`,角色私聊、NPC 对话、NPC 单轮聊天、招募对话的 prompt context 也由后端快照投影;前端只继续提交对话草稿、目标角色、玩家发言和必要 UI 临时项。 +5. `src/hooks/rpg-runtime-story/storyContextBuilder.ts` 缩减为 session 元信息适配器,不再推导 `conversationSituation / conversationPressure / NPC disclosure / partyRelationshipNotes / scene pressure` 等正式上下文。 +6. `src/services/aiService.ts` 在存在 `runtimeSessionId` 时,story initial/continue 只提交 session 轻量 payload;聊天接口只附带 `sessionId` 与对话输入,不再上传完整 `StoryGenerationContext`。 +7. `src/hooks/rpg-runtime-story/sessionActions.ts` 领取任务奖励时不再运行前端 `chapterDirector / echoMemory`,只保留旧 UI 层奖励展示所需的本地字段;章节和 `storyEngineMemory.currentChapter` 等正式叙事字段等待后端 action snapshot 刷新。 +8. `src/hooks/rpg-runtime-story/useRpgRuntimeNpcInteraction.ts` NPC 聊天闭合后不再调用前端 scene act runtime 推进 `storyEngineMemory.currentSceneActState`,也不再把 `deferredRuntimeState.storyEngineMemory` 写回正式 `GameState`。 +9. `src/hooks/rpg-runtime-story/choiceActions.ts` 兼容旧 `deferredRuntimeState` 时只允许采用场景字段,不再从 story moment 写入 `storyEngineMemory`。 + +新增验证: + +1. `cargo test -p module-runtime-story-compat prompt_context --manifest-path server-rs\Cargo.toml` +2. `cargo test -p shared-contracts runtime_story_ai_request --manifest-path server-rs\Cargo.toml` +3. `cargo test -p api-server runtime_story_initial_uses_server_snapshot_prompt_context_when_session_id_present --manifest-path server-rs\Cargo.toml` +4. `cargo check -p api-server --manifest-path server-rs\Cargo.toml --message-format short` +5. `npm run test -- src/services/ai.test.ts src/hooks/rpg-runtime-story/storyRequestCoordinator.test.ts src/hooks/rpg-runtime-story/useRpgRuntimeStoryController.test.tsx` +6. `npm run test -- src/hooks/rpg-runtime-story/sessionActions.test.ts src/hooks/rpg-runtime-story/choiceActions.test.ts src/hooks/rpg-runtime-story/npcEncounterActions.test.ts` +7. `npx eslint src/hooks/rpg-runtime-story/sessionActions.ts src/hooks/rpg-runtime-story/sessionActions.test.ts src/hooks/rpg-runtime-story/useRpgRuntimeNpcInteraction.ts src/hooks/rpg-runtime-story/choiceActions.ts src/hooks/rpg-runtime-story/choiceActions.test.ts --max-warnings 0` diff --git a/docs/technical/UNIFIED_CREATION_AGENT_CHAT_FRAMEWORK_2026-04-22.md b/docs/technical/UNIFIED_CREATION_AGENT_CHAT_FRAMEWORK_2026-04-22.md index 8fe6bbc2..30048b7a 100644 --- a/docs/technical/UNIFIED_CREATION_AGENT_CHAT_FRAMEWORK_2026-04-22.md +++ b/docs/technical/UNIFIED_CREATION_AGENT_CHAT_FRAMEWORK_2026-04-22.md @@ -112,6 +112,8 @@ src/services/creation-agent/ 1. 4 个玩法锚点映射。 2. 输入框占位提示。 3. 生成结果页 action:`big_fish_compile_draft`。 +4. `big_fish_compile_draft` 只负责编译玩法草稿并进入结果页,不在草稿阶段串行生成动作素材。 +5. 大鱼吃小鱼的主图、动作、背景都在结果页工坊独立触发;统一进度组件里不再为其草稿阶段展示“生成动作素材”步骤。 ### 4.3 拼图 diff --git a/packages/shared/src/contracts/bigFish.ts b/packages/shared/src/contracts/bigFish.ts index 4bedd2d1..dff40dd3 100644 --- a/packages/shared/src/contracts/bigFish.ts +++ b/packages/shared/src/contracts/bigFish.ts @@ -55,9 +55,13 @@ export type BigFishLevelBlueprintResponse = { level: number; name: string; oneLineFantasy: string; + textDescription: string; silhouetteDirection: string; sizeRatio: number; + visualDescription: string; visualPromptSeed: string; + idleMotionDescription: string; + moveMotionDescription: string; motionPromptSeed: string; mergeSourceLevel?: number | null; preyWindow: number[]; diff --git a/packages/shared/src/contracts/rpgCreationResultView.ts b/packages/shared/src/contracts/rpgCreationResultView.ts new file mode 100644 index 00000000..853a3eeb --- /dev/null +++ b/packages/shared/src/contracts/rpgCreationResultView.ts @@ -0,0 +1,40 @@ +import type { CustomWorldProfileRecord } from './runtime'; +import type { RpgAgentSessionSnapshot } from './rpgAgentSession'; + +export type RpgCreationResultProfileSource = + | 'result_preview' + | 'draft_profile' + | 'none'; + +export type RpgCreationResultTargetStage = + | 'agent-workspace' + | 'custom-world-generating' + | 'custom-world-result'; + +export type RpgCreationResultGenerationViewSource = + | 'agent-draft-foundation' + | null; + +export type RpgCreationResultViewSource = 'agent-draft' | null; + +export type RpgCreationResultRecoveryAction = + | 'continue_agent' + | 'resume_generation' + | 'open_result' + | 'missing_session'; + +export interface RpgCreationResultView { + session: RpgAgentSessionSnapshot; + profile: CustomWorldProfileRecord | null; + profileSource: RpgCreationResultProfileSource; + targetStage: RpgCreationResultTargetStage; + generationViewSource: RpgCreationResultGenerationViewSource; + resultViewSource: RpgCreationResultViewSource; + canAutosaveLibrary: boolean; + canSyncResultProfile: boolean; + publishReady: boolean; + canEnterWorld: boolean; + blockerCount: number; + recoveryAction: RpgCreationResultRecoveryAction; + recoveryReason?: string | null; +} diff --git a/packages/shared/src/contracts/rpgRuntimeChat.ts b/packages/shared/src/contracts/rpgRuntimeChat.ts index 57ae004a..6cc7b1ad 100644 --- a/packages/shared/src/contracts/rpgRuntimeChat.ts +++ b/packages/shared/src/contracts/rpgRuntimeChat.ts @@ -47,11 +47,12 @@ export type CharacterChatReplyRequest< TConversationTurn = unknown, TTargetStatus = unknown, > = { - worldType: string; - playerCharacter: TCharacter; + sessionId?: string; + worldType?: string; + playerCharacter?: TCharacter; targetCharacter: TCharacter; - storyHistory: TStoryMoment[]; - context: TContext; + storyHistory?: TStoryMoment[]; + context?: TContext; conversationHistory: TConversationTurn[]; conversationSummary: string; playerMessage: string; @@ -65,11 +66,12 @@ export type CharacterChatSuggestionsRequest< TConversationTurn = unknown, TTargetStatus = unknown, > = { - worldType: string; - playerCharacter: TCharacter; + sessionId?: string; + worldType?: string; + playerCharacter?: TCharacter; targetCharacter: TCharacter; - storyHistory: TStoryMoment[]; - context: TContext; + storyHistory?: TStoryMoment[]; + context?: TContext; conversationHistory: TConversationTurn[]; conversationSummary: string; targetStatus: TTargetStatus; @@ -82,11 +84,12 @@ export type CharacterChatSummaryRequest< TConversationTurn = unknown, TTargetStatus = unknown, > = { - worldType: string; - playerCharacter: TCharacter; + sessionId?: string; + worldType?: string; + playerCharacter?: TCharacter; targetCharacter: TCharacter; - storyHistory: TStoryMoment[]; - context: TContext; + storyHistory?: TStoryMoment[]; + context?: TContext; conversationHistory: TConversationTurn[]; previousSummary: string; targetStatus: TTargetStatus; @@ -99,12 +102,13 @@ export type NpcChatDialogueRequest< TStoryMoment = unknown, TContext = unknown, > = { - worldType: string; - character: TCharacter; + sessionId?: string; + worldType?: string; + character?: TCharacter; encounter: TEncounter; - monsters: TMonster[]; - history: TStoryMoment[]; - context: TContext; + monsters?: TMonster[]; + history?: TStoryMoment[]; + context?: TContext; topic: string; resultSummary: string; npcInitiatesConversation?: boolean; @@ -123,13 +127,14 @@ export type NpcChatTurnRequest< TQuestOfferEncounter = unknown, TChatDirective = NpcChatTurnDirective, > = { - worldType: string; + sessionId?: string; + worldType?: string; character?: TCharacter; player?: TCharacter; encounter: TEncounter; - monsters: TMonster[]; - history: TStoryMoment[]; - context: TContext; + monsters?: TMonster[]; + history?: TStoryMoment[]; + context?: TContext; conversationHistory?: TConversationTurn[]; dialogue?: TConversationTurn[]; combatContext?: TCombatContext | null; @@ -171,12 +176,13 @@ export type NpcRecruitDialogueRequest< TStoryMoment = unknown, TContext = unknown, > = { - worldType: string; - character: TCharacter; + sessionId?: string; + worldType?: string; + character?: TCharacter; encounter: TEncounter; - monsters: TMonster[]; - history: TStoryMoment[]; - context: TContext; + monsters?: TMonster[]; + history?: TStoryMoment[]; + context?: TContext; invitationText: string; recruitSummary: string; }; diff --git a/packages/shared/src/contracts/rpgRuntimeStoryState.ts b/packages/shared/src/contracts/rpgRuntimeStoryState.ts index ab6b4fe9..90dbfc25 100644 --- a/packages/shared/src/contracts/rpgRuntimeStoryState.ts +++ b/packages/shared/src/contracts/rpgRuntimeStoryState.ts @@ -52,7 +52,98 @@ export type RuntimeStoryStatusViewModel = { inBattle: boolean; npcInteractionActive: boolean; currentNpcBattleMode: 'fight' | 'spar' | null; - currentNpcBattleOutcome: 'fight_victory' | 'spar_complete' | null; + currentNpcBattleOutcome: 'fight_victory' | 'fight_defeat' | 'spar_complete' | null; +}; + +export type RuntimeStoryInventoryActionView = { + functionId: string; + actionText: string; + payload?: JsonObject; + enabled: boolean; + reason?: string | null; +}; + +export type RuntimeStoryInventoryItemActionsView = { + use: RuntimeStoryInventoryActionView; + equip: RuntimeStoryInventoryActionView; + dismantle: RuntimeStoryInventoryActionView; + reforge: RuntimeStoryInventoryActionView; +}; + +export type RuntimeStoryInventoryItemView = { + item: JsonObject; + actions: RuntimeStoryInventoryItemActionsView; +}; + +export type RuntimeStoryEquipmentSlotView = { + slotId: string; + label: string; + item?: JsonObject | null; + unequip: RuntimeStoryInventoryActionView; +}; + +export type RuntimeStoryForgeRequirementView = { + id: string; + label: string; + quantity: number; + owned: number; +}; + +export type RuntimeStoryForgeRecipeView = { + id: string; + name: string; + kind: string; + description: string; + resultLabel: string; + currencyCost: number; + currencyText: string; + requirements: RuntimeStoryForgeRequirementView[]; + canCraft: boolean; + disabledReason?: string | null; + action: RuntimeStoryInventoryActionView; +}; + +export type RuntimeStoryInventoryViewModel = { + playerCurrency: number; + currencyText: string; + inBattle: boolean; + backpackItems: RuntimeStoryInventoryItemView[]; + equipmentSlots: RuntimeStoryEquipmentSlotView[]; + forgeRecipes: RuntimeStoryForgeRecipeView[]; +}; + +export type RuntimeNpcTradeMode = 'buy' | 'sell'; + +export type RuntimeNpcTradeItemView = { + itemId: string; + item: JsonObject; + mode: RuntimeNpcTradeMode; + unitPrice: number; + maxQuantity: number; + canSubmit: boolean; + reason?: string | null; +}; + +export type RuntimeNpcGiftItemView = { + itemId: string; + item: JsonObject; + affinityGain: number; + canSubmit: boolean; + reason?: string | null; +}; + +export type RuntimeNpcInteractionView = { + npcId: string; + npcName: string; + playerCurrency: number; + currencyName: string; + trade: { + buyItems: RuntimeNpcTradeItemView[]; + sellItems: RuntimeNpcTradeItemView[]; + }; + gift: { + items: RuntimeNpcGiftItemView[]; + }; }; export type RuntimeBattlePresentation = { @@ -60,15 +151,17 @@ export type RuntimeBattlePresentation = { targetName?: string; damageDealt?: number; damageTaken?: number; - outcome?: 'ongoing' | 'victory' | 'spar_complete' | 'escaped'; + outcome?: 'ongoing' | 'victory' | 'spar_complete' | 'defeat' | 'escaped'; }; export type RuntimeStoryViewModel = { player: RuntimeStoryPlayerViewModel; encounter: RuntimeStoryEncounterViewModel | null; companions: RuntimeStoryCompanionViewModel[]; + inventory: RuntimeStoryInventoryViewModel; availableOptions: RuntimeStoryOptionView[]; status: RuntimeStoryStatusViewModel; + npcInteraction?: RuntimeNpcInteractionView | null; }; export type RuntimeStoryPresentation = { @@ -98,14 +191,14 @@ export type RuntimeStoryPatch = targetId?: string; damageDealt?: number; damageTaken?: number; - outcome: 'ongoing' | 'victory' | 'spar_complete' | 'escaped'; + outcome: 'ongoing' | 'victory' | 'spar_complete' | 'defeat' | 'escaped'; } | { type: 'status_changed'; inBattle: boolean; npcInteractionActive: boolean; currentNpcBattleMode: 'fight' | 'spar' | null; - currentNpcBattleOutcome: 'fight_victory' | 'spar_complete' | null; + currentNpcBattleOutcome: 'fight_victory' | 'fight_defeat' | 'spar_complete' | null; } | { type: 'encounter_changed'; @@ -117,6 +210,21 @@ export type RuntimeStoryActionRequest = snapshot?: SavedGameSnapshotInput; }; +export type RuntimeStoryAiRequestOptions = { + availableOptions?: JsonObject[]; + optionCatalog?: JsonObject[]; +}; + +export type RuntimeStoryAiRequest = { + sessionId: string; + clientVersion?: number; + choice?: string; + lastFunctionId?: string | null; + observeSignsRequested?: boolean; + recentActionResult?: string | null; + requestOptions?: RuntimeStoryAiRequestOptions; +}; + export type RuntimeStoryStateRequest< TSnapshotGameState = JsonObject, TSnapshotCurrentStory = JsonObject, @@ -130,6 +238,30 @@ export type RuntimeStoryStateRequest< >; }; +export type RuntimeStoryBootstrapRequest< + TProfile = JsonObject, + TCharacter = JsonObject, +> = { + worldType: string; + customWorldProfile?: TProfile | null; + character: TCharacter; + runtimeMode?: 'play' | 'preview' | 'test'; + disablePersistence?: boolean; +}; + +export type RuntimeStoryBootstrapResponse< + TSnapshotGameState = JsonObject, + TSnapshotCurrentStory = JsonObject, +> = { + sessionId: string; + serverVersion: number; + snapshot: SavedGameSnapshot< + TSnapshotGameState, + string, + TSnapshotCurrentStory + >; +}; + export type RuntimeStoryActionResponse< TSnapshotGameState = JsonObject, TSnapshotCurrentStory = JsonObject, diff --git a/packages/shared/src/contracts/runtime.ts b/packages/shared/src/contracts/runtime.ts index e11beb06..31d84df6 100644 --- a/packages/shared/src/contracts/runtime.ts +++ b/packages/shared/src/contracts/runtime.ts @@ -29,6 +29,12 @@ export type SavedGameSnapshotInput< savedAt?: string; }; +export type RuntimeSaveCheckpointInput = { + sessionId: string; + bottomTab: TBottomTab; + savedAt?: string; +}; + export type RuntimeSettings = { musicVolume: number; platformTheme: PlatformTheme; diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 70e84388..0744e6f2 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -9,6 +9,7 @@ export * from './contracts/rpgAgentDraft'; export * from './contracts/rpgAgentSession'; export * from './contracts/rpgCreationFixtures'; export * from './contracts/rpgCreationPreview'; +export * from './contracts/rpgCreationResultView'; export * from './contracts/rpgCreationWorkSummary'; export * from './contracts/puzzleAgentActions'; export * from './contracts/puzzleAgentDraft'; diff --git a/server-rs/crates/api-server/src/app.rs b/server-rs/crates/api-server/src/app.rs index d732ca65..703e3527 100644 --- a/server-rs/crates/api-server/src/app.rs +++ b/server-rs/crates/api-server/src/app.rs @@ -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( diff --git a/server-rs/crates/api-server/src/big_fish.rs b/server-rs/crates/api-server/src/big_fish.rs index b5f804e3..f31a9dec 100644 --- a/server-rs/crates/api-server/src/big_fish.rs +++ b/server-rs/crates/api-server/src/big_fish.rs @@ -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, 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, Extension(request_context): Extension, ) -> Result, 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 { - 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 { + 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, + 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 = "文字,水印,logo,UI界面,对话框,边框,多余肢体,畸形鱼体,低清晰度,模糊,压缩噪点,现代摄影棚,写实照片背景,复杂背景"; -const BIG_FISH_TRANSPARENT_ASSET_NEGATIVE_PROMPT: &str = "文字,水印,logo,UI界面,对话框,边框,多余肢体,畸形鱼体,低清晰度,模糊,压缩噪点,现代摄影棚,写实照片背景,场景背景,水草背景,气泡背景,多只主体,阴影地面"; 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 { @@ -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 { 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 + )); + } +} diff --git a/server-rs/crates/api-server/src/big_fish_agent_turn.rs b/server-rs/crates/api-server/src/big_fish_agent_turn.rs index b8d9c8a7..3bf6c070 100644 --- a/server-rs/crates/api-server/src/big_fish_agent_turn.rs +++ b/server-rs/crates/api-server/src/big_fish_agent_turn.rs @@ -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( 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 { - messages - .iter() - .map(|message| { - json!({ - "role": message.role, - "kind": message.kind, - "content": message.text, - }) - }) - .collect() -} - fn parse_big_fish_model_output( parsed: &JsonValue, ) -> Result { @@ -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, diff --git a/server-rs/crates/api-server/src/big_fish_draft_compiler.rs b/server-rs/crates/api-server/src/big_fish_draft_compiler.rs new file mode 100644 index 00000000..89e6e984 --- /dev/null +++ b/server-rs/crates/api-server/src/big_fish_draft_compiler.rs @@ -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) -> 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, + 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 { + 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 { + 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")); + } +} diff --git a/server-rs/crates/api-server/src/character_animation_assets.rs b/server-rs/crates/api-server/src/character_animation_assets.rs index 288eafee..471bbd18 100644 --- a/server-rs/crates/api-server/src/character_animation_assets.rs +++ b/server-rs/crates/api-server/src/character_animation_assets.rs @@ -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, + Extension(request_context): Extension, + AxumPath(character_id): AxumPath, + payload: Result, JsonRejection>, +) -> Result, 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, + Extension(request_context): Extension, + AxumPath(character_id): AxumPath, + payload: Result, JsonRejection>, +) -> Result, 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); diff --git a/server-rs/crates/api-server/src/character_visual_assets.rs b/server-rs/crates/api-server/src/character_visual_assets.rs index 88657eb7..f5033f67 100644 --- a/server-rs/crates/api-server/src/character_visual_assets.rs +++ b/server-rs/crates/api-server/src/character_visual_assets.rs @@ -1154,7 +1154,9 @@ async fn download_generated_image( }) } -fn try_apply_background_alpha_to_png(source: &[u8]) -> Option> { +/// 统一的 PNG 透明背景后处理入口。 +/// 目前 RPG 角色主图与其他需要“角色主图口径透明背景”的图片资产都复用这套逻辑。 +pub(crate) fn try_apply_background_alpha_to_png(source: &[u8]) -> Option> { let mut image = image::load_from_memory_with_format(source, ImageFormat::Png) .ok()? .to_rgba8(); diff --git a/server-rs/crates/api-server/src/custom_world.rs b/server-rs/crates/api-server/src/custom_world.rs index a0caaebf..29e3f076 100644 --- a/server-rs/crates/api-server/src/custom_world.rs +++ b/server-rs/crates/api-server/src/custom_world.rs @@ -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, + Extension(request_context): Extension, + Extension(authenticated): Extension, + payload: Result, JsonRejection>, +) -> Result, 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-server;SpacetimeDB 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::(&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, 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 { + 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 { + 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::>() + }) + .unwrap_or_default() +} + fn missing_role_asset_text_report(draft_profile: &Value) -> Option { 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, + Path(session_id): Path, + Extension(request_context): Extension, + Extension(authenticated): Extension, +) -> Result, 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, Extension(request_context): Extension, @@ -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 { + 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.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!({ diff --git a/server-rs/crates/api-server/src/custom_world_foundation_draft.rs b/server-rs/crates/api-server/src/custom_world_foundation_draft.rs index e5bb9c21..83a5dbe2 100644 --- a/server-rs/crates/api-server/src/custom_world_foundation_draft.rs +++ b/server-rs/crates/api-server/src/custom_world_foundation_draft.rs @@ -1099,7 +1099,7 @@ fn first_json_string(value: &JsonValue, key: &str) -> Option { .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); diff --git a/server-rs/crates/api-server/src/main.rs b/server-rs/crates/api-server/src/main.rs index 5358a69d..4a3c767a 100644 --- a/server-rs/crates/api-server/src/main.rs +++ b/server-rs/crates/api-server/src/main.rs @@ -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; diff --git a/server-rs/crates/api-server/src/prompt/big_fish.rs b/server-rs/crates/api-server/src/prompt/big_fish.rs new file mode 100644 index 00000000..a1e28fe8 --- /dev/null +++ b/server-rs/crates/api-server/src/prompt/big_fish.rs @@ -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 = "文字,水印,logo,UI界面,对话框,边框,多余肢体,畸形鱼体,低清晰度,模糊,压缩噪点,现代摄影棚,写实照片背景,复杂背景"; + +/// 大鱼吃小鱼透明主体类图片生成默认反向提示词脚本。 +pub(crate) const BIG_FISH_TRANSPARENT_ASSET_NEGATIVE_PROMPT: &str = "文字,水印,logo,UI界面,对话框,边框,多余肢体,畸形鱼体,低清晰度,模糊,压缩噪点,现代摄影棚,写实照片背景,场景背景,水草背景,气泡背景,多只主体,阴影地面"; + +fn build_chat_history(messages: &[BigFishAgentMessageRecord]) -> Vec { + 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("多只主体")); + } +} diff --git a/server-rs/crates/api-server/src/prompt/character_animation.rs b/server-rs/crates/api-server/src/prompt/character_animation.rs index acb108b5..7246e141 100644 --- a/server-rs/crates/api-server/src/prompt/character_animation.rs +++ b/server-rs/crates/api-server/src/prompt/character_animation.rs @@ -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 { diff --git a/server-rs/crates/api-server/src/prompt/character_visual.rs b/server-rs/crates/api-server/src/prompt/character_visual.rs index 78f3fde6..b35b140c 100644 --- a/server-rs/crates/api-server/src/prompt/character_visual.rs +++ b/server-rs/crates/api-server/src/prompt/character_visual.rs @@ -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(), diff --git a/server-rs/crates/api-server/src/prompt/mod.rs b/server-rs/crates/api-server/src/prompt/mod.rs index c1c332be..e00f1991 100644 --- a/server-rs/crates/api-server/src/prompt/mod.rs +++ b/server-rs/crates/api-server/src/prompt/mod.rs @@ -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; diff --git a/server-rs/crates/api-server/src/prompt/agent_chat.rs b/server-rs/crates/api-server/src/prompt/rpg/agent_chat.rs similarity index 100% rename from server-rs/crates/api-server/src/prompt/agent_chat.rs rename to server-rs/crates/api-server/src/prompt/rpg/agent_chat.rs diff --git a/server-rs/crates/api-server/src/prompt/foundation_draft.rs b/server-rs/crates/api-server/src/prompt/rpg/foundation_draft.rs similarity index 99% rename from server-rs/crates/api-server/src/prompt/foundation_draft.rs rename to server-rs/crates/api-server/src/prompt/rpg/foundation_draft.rs index 6efce9af..ac1c8565 100644 --- a/server-rs/crates/api-server/src/prompt/foundation_draft.rs +++ b/server-rs/crates/api-server/src/prompt/rpg/foundation_draft.rs @@ -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(), diff --git a/server-rs/crates/api-server/src/prompt/rpg/mod.rs b/server-rs/crates/api-server/src/prompt/rpg/mod.rs new file mode 100644 index 00000000..80b47849 --- /dev/null +++ b/server-rs/crates/api-server/src/prompt/rpg/mod.rs @@ -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; diff --git a/server-rs/crates/api-server/src/prompt/rpg/role_asset_studio.rs b/server-rs/crates/api-server/src/prompt/rpg/role_asset_studio.rs new file mode 100644 index 00000000..80b12af2 --- /dev/null +++ b/server-rs/crates/api-server/src/prompt/rpg/role_asset_studio.rs @@ -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, +) -> BTreeMap { + 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 { + 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(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 { + value + .map(|value| value.split_whitespace().collect::>().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"); + } +} diff --git a/server-rs/crates/api-server/src/prompt/runtime_chat.rs b/server-rs/crates/api-server/src/prompt/rpg/runtime_chat.rs similarity index 78% rename from server-rs/crates/api-server/src/prompt/runtime_chat.rs rename to server-rs/crates/api-server/src/prompt/rpg/runtime_chat.rs index e5c984a2..4e8e6ea7 100644 --- a/server-rs/crates/api-server/src/prompt/runtime_chat.rs +++ b/server-rs/crates/api-server/src/prompt/rpg/runtime_chat.rs @@ -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::>() + .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::>() + .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 { .map(ToOwned::to_owned) } +fn read_name_field(value: &Value, field: &str) -> Option { + 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 { value .and_then(Value::as_str) diff --git a/server-rs/crates/api-server/src/runtime_chat.rs b/server-rs/crates/api-server/src/runtime_chat.rs index 17cc223b..8f713229 100644 --- a/server-rs/crates/api-server/src/runtime_chat.rs +++ b/server-rs/crates/api-server/src/runtime_chat.rs @@ -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, #[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, + #[serde(default)] chat_directive: Option, } pub async fn stream_runtime_npc_chat_turn( State(state): State, Extension(request_context): Extension, - Json(payload): Json, + Extension(authenticated): Extension, + Json(mut payload): Json, ) -> Result { + 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::>() + .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 { + 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; diff --git a/server-rs/crates/api-server/src/runtime_chat_plain.rs b/server-rs/crates/api-server/src/runtime_chat_plain.rs new file mode 100644 index 00000000..622418f3 --- /dev/null +++ b/server-rs/crates/api-server/src/runtime_chat_plain.rs @@ -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, + #[serde(default)] + world_type: String, + #[serde(default)] + player_character: Value, + #[serde(default)] + target_character: Value, + #[serde(default)] + story_history: Vec, + #[serde(default)] + context: Value, + #[serde(default)] + conversation_history: Vec, + #[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, + #[serde(default)] + world_type: String, + #[serde(default)] + character: Value, + #[serde(default)] + encounter: Value, + #[serde(default)] + monsters: Vec, + #[serde(default)] + history: Vec, + #[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, + #[serde(default)] + world_type: String, + #[serde(default)] + character: Value, + #[serde(default)] + encounter: Value, + #[serde(default)] + monsters: Vec, + #[serde(default)] + history: Vec, + #[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, + Extension(request_context): Extension, + Extension(authenticated): Extension, + Json(mut payload): Json, +) -> Result, 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, + Extension(request_context): Extension, + Extension(authenticated): Extension, + Json(mut payload): Json, +) -> Result, 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, + Extension(request_context): Extension, + Extension(authenticated): Extension, + Json(mut payload): Json, +) -> Result { + 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, + Extension(request_context): Extension, + Extension(authenticated): Extension, + Json(mut payload): Json, +) -> Result { + 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, + Extension(request_context): Extension, + Extension(authenticated): Extension, + Json(mut payload): Json, +) -> Result { + 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::>() + .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::>() + .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::>() + .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, 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 { + 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, + enable_web_search: bool, + system_prompt: &'static str, + user_prompt: String, + fallback_text: String, +) -> impl tokio_stream::Stream> { + async_stream::stream! { + let Some(llm_client) = llm_client else { + yield Ok::(Event::default().data(runtime_plain_text_sse_payload(fallback_text.as_str()))); + yield Ok::(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::default().data(runtime_plain_text_sse_payload(output))); + } + Err(_) => { + yield Ok::(Event::default().data(runtime_plain_text_sse_payload(fallback_text.as_str()))); + } + } + + yield Ok::(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 { + value + .get(field) + .and_then(Value::as_str) + .map(str::trim) + .filter(|text| !text.is_empty()) + .map(ToOwned::to_owned) +} diff --git a/server-rs/crates/api-server/src/runtime_save.rs b/server-rs/crates/api-server/src/runtime_save.rs index 7a6277db..5f1fca17 100644 --- a/server-rs/crates/api-server/src/runtime_save.rs +++ b/server-rs/crates/api-server/src/runtime_save.rs @@ -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, Extension(request_context): Extension, Extension(authenticated): Extension, - Json(payload): Json, + Json(payload): Json, ) -> Result, 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, - 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 { + 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 { + 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 { diff --git a/server-rs/crates/api-server/src/runtime_story.rs b/server-rs/crates/api-server/src/runtime_story.rs index 9e991994..b0bda7ce 100644 --- a/server-rs/crates/api-server/src/runtime_story.rs +++ b/server-rs/crates/api-server/src/runtime_story.rs @@ -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, }; diff --git a/server-rs/crates/api-server/src/runtime_story/compat.rs b/server-rs/crates/api-server/src/runtime_story/compat.rs index ce9e931f..d2181cd5 100644 --- a/server-rs/crates/api-server/src/runtime_story/compat.rs +++ b/server-rs/crates/api-server/src/runtime_story/compat.rs @@ -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, Extension(request_context): Extension, - Extension(_authenticated): Extension, + Extension(authenticated): Extension, Json(payload): Json, ) -> Result, 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, Extension(request_context): Extension, - Extension(_authenticated): Extension, + Extension(authenticated): Extension, Json(payload): Json, ) -> Result, 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 { + 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::>() + .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, + 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 { + 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 { + 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 { + 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"), diff --git a/server-rs/crates/api-server/src/runtime_story/compat/bootstrap.rs b/server-rs/crates/api-server/src/runtime_story/compat/bootstrap.rs new file mode 100644 index 00000000..c9f7cbb0 --- /dev/null +++ b/server-rs/crates/api-server/src/runtime_story/compat/bootstrap.rs @@ -0,0 +1,1101 @@ +use super::*; + +const PLAYER_BASE_MAX_HP: i32 = 180; +const DEFAULT_PLAYER_MAX_MANA: i32 = 999; +pub(super) const RESOLVED_ENTITY_X_METERS: f64 = 12.0; + +pub async fn begin_runtime_story_session( + State(state): State, + Extension(request_context): Extension, + Extension(authenticated): Extension, + Json(payload): Json, +) -> Result, Response> { + let actor_user_id = authenticated.claims().user_id().to_string(); + let now = OffsetDateTime::now_utc(); + let now_micros = offset_datetime_to_unix_micros(now); + let session_id = build_runtime_session_id( + actor_user_id.as_str(), + payload.custom_world_profile.as_ref(), + &payload.character, + now_micros, + ); + let game_state = + build_initial_runtime_game_state(&payload, session_id.as_str()).map_err(|message| { + runtime_story_error_response( + &request_context, + AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ + "provider": "runtime-story", + "message": message, + })), + ) + })?; + let snapshot = RuntimeStorySnapshotPayload { + saved_at: Some(format_now_rfc3339()), + bottom_tab: "adventure".to_string(), + game_state, + current_story: None, + }; + let persisted = + persist_runtime_story_snapshot(&state, &request_context, actor_user_id, snapshot).await?; + let persisted_snapshot = runtime_snapshot_payload_from_record(&persisted); + + Ok(json_success_body( + Some(&request_context), + RuntimeStoryBootstrapResponse { + session_id, + server_version: 1, + snapshot: persisted_snapshot, + }, + )) +} + +fn build_runtime_session_id( + actor_user_id: &str, + custom_world_profile: Option<&Value>, + character: &Value, + now_micros: i64, +) -> String { + let profile_id = custom_world_profile + .and_then(|profile| read_optional_string_field(profile, "id")) + .or_else(|| { + custom_world_profile.and_then(|profile| read_optional_string_field(profile, "name")) + }) + .unwrap_or_else(|| "builtin".to_string()); + let character_id = read_optional_string_field(character, "id") + .or_else(|| read_optional_string_field(character, "name")) + .unwrap_or_else(|| "character".to_string()); + + format!( + "runtime-{}-{}-{}-{now_micros}", + sanitize_id_segment(actor_user_id), + sanitize_id_segment(profile_id.as_str()), + sanitize_id_segment(character_id.as_str()) + ) +} + +fn sanitize_id_segment(value: &str) -> String { + let normalized = value + .trim() + .chars() + .filter(|ch| ch.is_ascii_alphanumeric() || *ch == '-' || *ch == '_') + .take(36) + .collect::(); + if normalized.is_empty() { + "unknown".to_string() + } else { + normalized + } +} + +fn build_initial_runtime_game_state( + payload: &RuntimeStoryBootstrapRequest, + session_id: &str, +) -> Result { + let world_type = normalize_required_string(payload.world_type.as_str()) + .ok_or_else(|| "worldType 不能为空".to_string())?; + if world_type == "CUSTOM" && payload.custom_world_profile.is_none() { + return Err("自定义世界开局必须提供 customWorldProfile".to_string()); + } + if !payload.character.is_object() { + return Err("character 必须是 JSON object".to_string()); + } + + let runtime_mode = normalize_runtime_mode(payload.runtime_mode.as_deref()); + let custom_world_profile = payload.custom_world_profile.clone().unwrap_or(Value::Null); + let character = payload.character.clone(); + let initial_scene_preset = + resolve_initial_scene_preset(world_type.as_str(), payload.custom_world_profile.as_ref()); + let initial_encounter = resolve_initial_encounter( + world_type.as_str(), + payload.custom_world_profile.as_ref(), + &character, + initial_scene_preset.as_ref(), + ); + let initial_npc_state = initial_encounter + .as_ref() + .map(build_initial_npc_state_value) + .unwrap_or(Value::Null); + let player_max_hp = resolve_character_max_hp(&character); + let player_max_mana = resolve_character_max_mana(&character); + let initial_inventory = build_initial_player_inventory( + world_type.as_str(), + payload.custom_world_profile.as_ref(), + &character, + ); + let initial_equipment = build_initial_player_equipment( + world_type.as_str(), + payload.custom_world_profile.as_ref(), + &character, + &initial_inventory, + ); + let equipment_bonuses = read_equipment_total_bonuses(&initial_equipment); + let player_max_hp_with_equipment = player_max_hp + equipment_bonuses.max_hp_bonus; + let story_engine_memory = build_opening_story_engine_memory( + payload.custom_world_profile.as_ref(), + &initial_scene_preset, + ); + + let mut npc_states = Map::new(); + if let (Some(encounter), Value::Object(npc_state)) = (&initial_encounter, initial_npc_state) { + let npc_id = read_optional_string_field(encounter, "id") + .unwrap_or_else(|| current_encounter_name(encounter)); + npc_states.insert(npc_id, Value::Object(npc_state)); + } + + let mut game_state = json!({ + "worldType": world_type, + "customWorldProfile": custom_world_profile, + "playerCharacter": character, + "runtimeSessionId": session_id, + "runtimeActionVersion": 1, + "runtimeMode": runtime_mode, + "runtimePersistenceDisabled": payload.disable_persistence.unwrap_or(false), + "runtimeStats": { + "playTimeMs": 0, + "lastPlayTickAt": Value::Null, + "hostileNpcsDefeated": 0, + "questsAccepted": 0, + "itemsUsed": 0, + "scenesTraveled": 0 + }, + "playerProgression": { + "level": 1, + "currentLevelXp": 0, + "totalXp": 0, + "xpToNextLevel": 100, + "pendingLevelUps": 0, + "lastGrantedSource": Value::Null + }, + "currentScene": "Story", + "storyHistory": [], + "storyEngineMemory": story_engine_memory, + "chapterState": Value::Null, + "campaignState": Value::Null, + "activeScenarioPackId": payload.custom_world_profile.as_ref().and_then(|profile| read_optional_string_field(profile, "scenarioPackId")), + "activeCampaignPackId": payload.custom_world_profile.as_ref().and_then(|profile| read_optional_string_field(profile, "campaignPackId")), + "characterChats": {}, + "lastObserveSignsSceneId": Value::Null, + "lastObserveSignsReport": Value::Null, + "animationState": "idle", + "currentEncounter": initial_encounter, + "npcInteractionActive": false, + "currentScenePreset": initial_scene_preset, + "sceneHostileNpcs": [], + "playerX": 0, + "playerOffsetY": 0, + "playerFacing": "right", + "playerActionMode": "idle", + "scrollWorld": false, + "inBattle": false, + "playerHp": player_max_hp_with_equipment, + "playerMaxHp": player_max_hp_with_equipment, + "playerMana": player_max_mana, + "playerMaxMana": player_max_mana, + "playerSkillCooldowns": {}, + "activeBuildBuffs": [], + "activeCombatEffects": [], + "playerCurrency": resolve_initial_player_currency(world_type.as_str(), payload.custom_world_profile.as_ref()), + "playerInventory": initial_inventory, + "playerEquipment": initial_equipment, + "npcStates": npc_states, + "quests": [], + "roster": [], + "companions": [], + "currentBattleNpcId": Value::Null, + "currentNpcBattleMode": Value::Null, + "currentNpcBattleOutcome": Value::Null, + "sparReturnEncounter": Value::Null, + "sparPlayerHpBefore": Value::Null, + "sparPlayerMaxHpBefore": Value::Null, + "sparStoryHistoryBefore": Value::Null + }); + ensure_json_object(&mut game_state).insert( + "playerSkillCooldowns".to_string(), + build_character_skill_cooldowns(&payload.character), + ); + Ok(game_state) +} + +fn normalize_runtime_mode(value: Option<&str>) -> &'static str { + match value.map(str::trim) { + Some("preview") => "preview", + Some("test") => "test", + _ => "play", + } +} + +fn resolve_initial_scene_preset(world_type: &str, profile: Option<&Value>) -> Option { + if world_type == "CUSTOM" { + let profile = profile?; + let scene_id = + resolve_opening_scene_id(profile).unwrap_or_else(|| "custom-scene-camp".to_string()); + return build_custom_scene_preset(profile, scene_id.as_str()); + } + + Some(build_builtin_camp_scene_preset(world_type)) +} + +fn resolve_opening_scene_id(profile: &Value) -> Option { + let opening_chapter = read_array_field(profile, "sceneChapterBlueprints") + .into_iter() + .next()?; + let opening_act = read_array_field(opening_chapter, "acts").into_iter().next(); + [ + opening_act.and_then(|act| read_optional_string_field(act, "sceneId")), + read_optional_string_field(opening_chapter, "sceneId"), + read_array_field(opening_chapter, "linkedLandmarkIds") + .into_iter() + .find_map(Value::as_str) + .map(str::to_string), + ] + .into_iter() + .flatten() + .map(|scene_id| resolve_custom_runtime_scene_id(profile, scene_id.as_str())) + .find(|scene_id| !scene_id.trim().is_empty()) +} + +pub(super) fn resolve_custom_runtime_scene_id(profile: &Value, scene_id: &str) -> String { + let normalized = scene_id.trim(); + if normalized.is_empty() + || normalized == "custom-scene-camp" + || read_object_field(profile, "camp") + .and_then(|camp| read_optional_string_field(camp, "id")) + .as_deref() + == Some(normalized) + { + return "custom-scene-camp".to_string(); + } + + for (index, landmark) in read_array_field(profile, "landmarks") + .into_iter() + .enumerate() + { + if read_optional_string_field(landmark, "id").as_deref() == Some(normalized) { + return format!("custom-scene-landmark-{}", index + 1); + } + } + + normalized.to_string() +} + +fn build_builtin_camp_scene_preset(world_type: &str) -> Value { + let is_xianxia = world_type == "XIANXIA"; + json!({ + "id": if is_xianxia { "xianxia-star-vessel" } else { "wuxia-border-camp" }, + "name": if is_xianxia { "星槎泊台" } else { "边城营地" }, + "description": if is_xianxia { "星槎停泊在云海边缘,远处灵潮微明。" } else { "边城营地炊烟未散,旧路与山影在前方交错。" }, + "imageSrc": "", + "worldType": world_type, + "forwardSceneId": Value::Null, + "connectedSceneIds": [], + "connections": [], + "npcs": [], + "treasureHints": [], + "narrativeResidues": [] + }) +} + +pub(super) fn build_custom_scene_preset(profile: &Value, scene_id: &str) -> Option { + if scene_id == "custom-scene-camp" { + let camp = read_object_field(profile, "camp"); + let name = camp + .and_then(|value| read_optional_string_field(value, "name")) + .unwrap_or_else(|| "开局归处".to_string()); + let description = camp + .and_then(|value| read_optional_string_field(value, "description")) + .unwrap_or_else(|| read_optional_string_field(profile, "summary").unwrap_or_default()); + let connected_scene_ids = read_array_field(profile, "landmarks") + .into_iter() + .take(3) + .enumerate() + .map(|(index, _)| Value::String(format!("custom-scene-landmark-{}", index + 1))) + .collect::>(); + let npcs = build_custom_scene_npcs(profile, scene_id); + return Some(json!({ + "id": "custom-scene-camp", + "name": name, + "description": description, + "imageSrc": camp.and_then(|value| read_optional_string_field(value, "imageSrc")).unwrap_or_default(), + "worldType": "CUSTOM", + "forwardSceneId": connected_scene_ids.first().cloned().unwrap_or(Value::Null), + "connectedSceneIds": connected_scene_ids, + "connections": [], + "npcs": npcs, + "treasureHints": [], + "narrativeResidues": camp.and_then(|value| read_field(value, "narrativeResidues")).cloned().unwrap_or(Value::Array(Vec::new())) + })); + } + + let landmark_index = scene_id + .strip_prefix("custom-scene-landmark-") + .and_then(|value| value.parse::().ok()) + .and_then(|value| value.checked_sub(1)) + .unwrap_or(0); + let landmark = *read_array_field(profile, "landmarks").get(landmark_index)?; + let npcs = build_custom_scene_npcs(profile, scene_id); + Some(json!({ + "id": scene_id, + "name": read_optional_string_field(landmark, "name").unwrap_or_else(|| format!("地标{}", landmark_index + 1)), + "description": read_optional_string_field(landmark, "description").unwrap_or_default(), + "imageSrc": read_optional_string_field(landmark, "imageSrc").unwrap_or_default(), + "worldType": "CUSTOM", + "forwardSceneId": Value::Null, + "connectedSceneIds": ["custom-scene-camp"], + "connections": [], + "npcs": npcs, + "treasureHints": [], + "narrativeResidues": read_field(landmark, "narrativeResidues").cloned().unwrap_or(Value::Array(Vec::new())) + })) +} + +pub(super) fn build_custom_scene_npcs(profile: &Value, scene_id: &str) -> Vec { + let mut npc_ids = Vec::new(); + if scene_id == "custom-scene-camp" { + read_object_field(profile, "camp") + .map(|camp| read_array_field(camp, "sceneNpcIds")) + .unwrap_or_default() + .into_iter() + .filter_map(Value::as_str) + .for_each(|id| push_unique_string(&mut npc_ids, id)); + } else if let Some(landmark_index) = scene_id + .strip_prefix("custom-scene-landmark-") + .and_then(|value| value.parse::().ok()) + .and_then(|value| value.checked_sub(1)) + { + if let Some(landmark) = read_array_field(profile, "landmarks").get(landmark_index) { + read_array_field(landmark, "sceneNpcIds") + .into_iter() + .filter_map(Value::as_str) + .for_each(|id| push_unique_string(&mut npc_ids, id)); + } + } + + collect_scene_act_npc_ids(profile, scene_id) + .into_iter() + .for_each(|id| push_unique_string(&mut npc_ids, id.as_str())); + + npc_ids + .into_iter() + .filter_map(|npc_id| find_custom_world_role_by_reference(profile, npc_id.as_str())) + .map(build_custom_scene_npc) + .collect() +} + +fn collect_scene_act_npc_ids(profile: &Value, scene_id: &str) -> Vec { + let aliases = custom_scene_aliases(profile, scene_id); + let mut npc_ids = Vec::new(); + for chapter in read_array_field(profile, "sceneChapterBlueprints") { + let chapter_scene_ids = [ + read_optional_string_field(chapter, "sceneId"), + Some( + read_array_field(chapter, "linkedLandmarkIds") + .into_iter() + .filter_map(Value::as_str) + .map(str::to_string) + .collect::>() + .join("|"), + ), + ]; + let mut matches_scene = chapter_scene_ids + .into_iter() + .flatten() + .flat_map(|entry| entry.split('|').map(str::to_string).collect::>()) + .any(|id| aliases.contains(&resolve_custom_runtime_scene_id(profile, id.as_str()))); + for act in read_array_field(chapter, "acts") { + if aliases.contains(&resolve_custom_runtime_scene_id( + profile, + read_optional_string_field(act, "sceneId") + .unwrap_or_default() + .as_str(), + )) { + matches_scene = true; + } + if matches_scene { + [ + read_optional_string_field(act, "primaryNpcId"), + read_optional_string_field(act, "oppositeNpcId"), + ] + .into_iter() + .flatten() + .for_each(|id| { + let resolved = resolve_custom_role_id_reference(profile, id.as_str()); + push_unique_string(&mut npc_ids, resolved.as_str()); + }); + read_array_field(act, "encounterNpcIds") + .into_iter() + .filter_map(Value::as_str) + .for_each(|id| { + let resolved = resolve_custom_role_id_reference(profile, id); + push_unique_string(&mut npc_ids, resolved.as_str()); + }); + } + } + } + npc_ids +} + +fn custom_scene_aliases(profile: &Value, scene_id: &str) -> Vec { + let runtime_id = resolve_custom_runtime_scene_id(profile, scene_id); + let mut aliases = vec![runtime_id.clone()]; + if runtime_id == "custom-scene-camp" { + if let Some(camp_id) = read_object_field(profile, "camp") + .and_then(|camp| read_optional_string_field(camp, "id")) + { + aliases.push(camp_id); + } + } + aliases +} + +fn push_unique_string(values: &mut Vec, value: &str) { + let normalized = value.trim(); + if !normalized.is_empty() && !values.iter().any(|entry| entry == normalized) { + values.push(normalized.to_string()); + } +} + +fn build_custom_scene_npc(role: Value) -> Value { + let role_id = read_optional_string_field(&role, "id").unwrap_or_default(); + let name = read_optional_string_field(&role, "name").unwrap_or_else(|| role_id.clone()); + let initial_affinity = read_i32_field(&role, "initialAffinity").unwrap_or(18); + let hostile = initial_affinity < 0; + json!({ + "id": role_id, + "characterId": read_optional_string_field(&role, "id"), + "name": name, + "title": read_optional_string_field(&role, "title"), + "role": read_optional_string_field(&role, "role").unwrap_or_default(), + "avatar": read_optional_string_field(&role, "imageSrc").unwrap_or_else(|| name.chars().next().map(|ch| ch.to_string()).unwrap_or_else(|| "?".to_string())), + "description": read_optional_string_field(&role, "description").unwrap_or_default(), + "gender": "unknown", + "initialAffinity": initial_affinity, + "hostile": hostile, + "recruitable": !hostile, + "functions": if hostile { json!(["fight"]) } else { json!(["trade", "fight", "spar", "help", "chat", "recruit", "gift"]) }, + "backstory": read_optional_string_field(&role, "backstory"), + "personality": read_optional_string_field(&role, "personality"), + "motivation": read_optional_string_field(&role, "motivation"), + "combatStyle": read_optional_string_field(&role, "combatStyle"), + "relationshipHooks": read_field(&role, "relationshipHooks").cloned().unwrap_or(Value::Array(Vec::new())), + "tags": read_field(&role, "tags").cloned().unwrap_or(Value::Array(Vec::new())), + "backstoryReveal": read_field(&role, "backstoryReveal").cloned(), + "skills": read_field(&role, "skills").cloned().unwrap_or(Value::Array(Vec::new())), + "initialItems": read_field(&role, "initialItems").cloned().unwrap_or(Value::Array(Vec::new())), + "imageSrc": read_optional_string_field(&role, "imageSrc"), + "visual": read_field(&role, "visual").cloned(), + "narrativeProfile": read_field(&role, "narrativeProfile").cloned(), + "attributeProfile": read_field(&role, "attributeProfile").cloned() + }) +} + +fn resolve_initial_encounter( + world_type: &str, + profile: Option<&Value>, + character: &Value, + scene_preset: Option<&Value>, +) -> Option { + if world_type == "CUSTOM" { + let profile = profile?; + if let Some(role_id) = resolve_opening_act_encounter_role_id(profile, character) { + if let Some(scene_npc) = scene_preset.and_then(|scene| { + read_array_field(scene, "npcs").into_iter().find(|npc| { + do_role_references_match( + profile, + read_optional_string_field(npc, "id").as_deref(), + Some(role_id.as_str()), + ) + }) + }) { + return Some(build_encounter_from_scene_npc(scene_npc)); + } + return find_custom_world_role_by_reference(profile, role_id.as_str()) + .map(build_opening_encounter_from_custom_role); + } + return None; + } + + scene_preset.and_then(|scene| { + read_array_field(scene, "npcs") + .into_iter() + .find(|npc| { + read_optional_string_field(npc, "characterId") + != read_optional_string_field(character, "id") + }) + .map(build_encounter_from_scene_npc) + }) +} + +fn resolve_opening_act_encounter_role_id(profile: &Value, character: &Value) -> Option { + let opening_chapter = read_array_field(profile, "sceneChapterBlueprints") + .into_iter() + .next()?; + let opening_act = read_array_field(opening_chapter, "acts") + .into_iter() + .next()?; + let references = [ + read_optional_string_field(opening_act, "oppositeNpcId"), + read_optional_string_field(opening_act, "primaryNpcId"), + ] + .into_iter() + .flatten() + .chain( + read_array_field(opening_act, "encounterNpcIds") + .into_iter() + .filter_map(Value::as_str) + .map(str::to_string), + ); + for reference in references { + let role_id = resolve_custom_role_id_reference(profile, reference.as_str()); + if do_role_references_match( + profile, + Some(role_id.as_str()), + read_optional_string_field(character, "id").as_deref(), + ) || do_role_references_match( + profile, + Some(role_id.as_str()), + read_optional_string_field(character, "name").as_deref(), + ) { + continue; + } + if !role_id.trim().is_empty() { + return Some(role_id); + } + } + None +} + +pub(super) fn build_encounter_from_scene_npc(npc: &Value) -> Value { + let name = read_optional_string_field(npc, "name").unwrap_or_else(|| "当前遭遇".to_string()); + json!({ + "id": read_optional_string_field(npc, "id"), + "kind": "npc", + "characterId": read_optional_string_field(npc, "characterId"), + "npcName": name, + "npcDescription": read_optional_string_field(npc, "description").unwrap_or_default(), + "npcAvatar": read_optional_string_field(npc, "avatar").unwrap_or_else(|| name.chars().next().map(|ch| ch.to_string()).unwrap_or_else(|| "?".to_string())), + "context": read_optional_string_field(npc, "role").unwrap_or_default(), + "gender": read_optional_string_field(npc, "gender").unwrap_or_else(|| "unknown".to_string()), + "xMeters": RESOLVED_ENTITY_X_METERS, + "initialAffinity": read_i32_field(npc, "initialAffinity"), + "hostile": read_bool_field(npc, "hostile").unwrap_or(false) || read_i32_field(npc, "initialAffinity").unwrap_or(0) < 0, + "title": read_optional_string_field(npc, "title"), + "backstory": read_optional_string_field(npc, "backstory"), + "personality": read_optional_string_field(npc, "personality"), + "motivation": read_optional_string_field(npc, "motivation"), + "combatStyle": read_optional_string_field(npc, "combatStyle"), + "relationshipHooks": read_field(npc, "relationshipHooks").cloned().unwrap_or(Value::Array(Vec::new())), + "tags": read_field(npc, "tags").cloned().unwrap_or(Value::Array(Vec::new())), + "backstoryReveal": read_field(npc, "backstoryReveal").cloned(), + "skills": read_field(npc, "skills").cloned().unwrap_or(Value::Array(Vec::new())), + "initialItems": read_field(npc, "initialItems").cloned().unwrap_or(Value::Array(Vec::new())), + "imageSrc": read_optional_string_field(npc, "imageSrc"), + "visual": read_field(npc, "visual").cloned(), + "narrativeProfile": read_field(npc, "narrativeProfile").cloned(), + "attributeProfile": read_field(npc, "attributeProfile").cloned() + }) +} + +fn build_opening_encounter_from_custom_role(role: Value) -> Value { + let scene_npc = build_custom_scene_npc(role); + build_encounter_from_scene_npc(&scene_npc) +} + +fn build_initial_npc_state_value(encounter: &Value) -> Value { + let affinity = read_i32_field(encounter, "initialAffinity").unwrap_or_else(|| { + if read_bool_field(encounter, "hostile").unwrap_or(false) { + -40 + } else { + 18 + } + }); + json!({ + "affinity": affinity, + "chattedCount": 0, + "helpUsed": false, + "giftsGiven": 0, + "inventory": [], + "recruited": false, + "relationState": build_runtime_story_relation_state_value(affinity), + "revealedFacts": [], + "knownAttributeRumors": [], + "firstMeaningfulContactResolved": false, + "seenBackstoryChapterIds": [], + "tradeStockSignature": Value::Null, + "stanceProfile": build_runtime_story_stance_profile_value( + affinity, + false, + read_bool_field(encounter, "hostile").unwrap_or(false), + read_optional_string_field(encounter, "context").as_deref(), + None, + ) + }) +} + +fn build_opening_story_engine_memory( + profile: Option<&Value>, + scene_preset: &Option, +) -> Value { + let current_scene_act_state = profile + .and_then(|profile| { + scene_preset.as_ref().and_then(|scene| { + read_optional_string_field(scene, "id").map(|scene_id| (profile, scene_id)) + }) + }) + .and_then(|(profile, scene_id)| { + build_initial_scene_act_runtime_state(profile, scene_id.as_str()) + }); + json!({ + "visibleFacts": [], + "hiddenFacts": [], + "threadStates": {}, + "companionMemory": {}, + "worldMutations": [], + "currentSceneActState": current_scene_act_state + }) +} + +fn build_initial_scene_act_runtime_state(profile: &Value, scene_id: &str) -> Option { + let aliases = custom_scene_aliases(profile, scene_id); + for chapter in read_array_field(profile, "sceneChapterBlueprints") { + let chapter_scene_id = read_optional_string_field(chapter, "sceneId") + .map(|id| resolve_custom_runtime_scene_id(profile, id.as_str())); + let chapter_matches = chapter_scene_id + .as_ref() + .is_some_and(|id| aliases.contains(id)) + || read_array_field(chapter, "linkedLandmarkIds") + .into_iter() + .filter_map(Value::as_str) + .any(|id| aliases.contains(&resolve_custom_runtime_scene_id(profile, id))); + if !chapter_matches { + continue; + } + let Some(first_act) = read_array_field(chapter, "acts").into_iter().next() else { + continue; + }; + return Some(json!({ + "sceneId": read_optional_string_field(chapter, "sceneId").unwrap_or_else(|| scene_id.to_string()), + "chapterId": read_optional_string_field(chapter, "id").unwrap_or_default(), + "currentActId": read_optional_string_field(first_act, "id").unwrap_or_default(), + "currentActIndex": 0, + "completedActIds": [], + "visitedActIds": [read_optional_string_field(first_act, "id").unwrap_or_default()] + })); + } + None +} + +fn build_character_skill_cooldowns(character: &Value) -> Value { + let mut cooldowns = Map::new(); + read_array_field(character, "skills") + .into_iter() + .filter_map(|skill| read_optional_string_field(skill, "id")) + .for_each(|skill_id| { + cooldowns.insert(skill_id, json!(0)); + }); + Value::Object(cooldowns) +} + +fn resolve_character_max_hp(character: &Value) -> i32 { + read_object_field(character, "resourceProfile") + .and_then(|profile| read_i32_field(profile, "maxHp")) + .unwrap_or_else(|| { + let strength = read_object_field(character, "attributes") + .and_then(|attributes| read_i32_field(attributes, "strength")) + .unwrap_or(6); + let spirit = read_object_field(character, "attributes") + .and_then(|attributes| read_i32_field(attributes, "spirit")) + .unwrap_or(4); + PLAYER_BASE_MAX_HP.max(90 + strength * 10 + spirit * 4) + }) +} + +fn resolve_character_max_mana(character: &Value) -> i32 { + read_object_field(character, "resourceProfile") + .and_then(|profile| read_i32_field(profile, "maxMana")) + .unwrap_or(DEFAULT_PLAYER_MAX_MANA) +} + +fn resolve_initial_player_currency(world_type: &str, profile: Option<&Value>) -> i32 { + profile + .and_then(|profile| read_object_field(profile, "ownedSettingLayers")) + .and_then(|layers| read_object_field(layers, "ruleProfile")) + .and_then(|rule| read_object_field(rule, "economyProfile")) + .and_then(|economy| read_i32_field(economy, "initialCurrency")) + .unwrap_or_else(|| if world_type == "XIANXIA" { 140 } else { 160 }) +} + +fn build_initial_player_inventory( + world_type: &str, + profile: Option<&Value>, + character: &Value, +) -> Vec { + let mut items = Vec::new(); + if world_type == "CUSTOM" { + if let Some(profile) = profile { + if let Some(role) = resolve_custom_character_role(profile, character) { + read_array_field(&role, "initialItems") + .into_iter() + .enumerate() + .map(|(index, item)| build_explicit_role_inventory_item(&role, item, index)) + .for_each(|item| merge_inventory_item(&mut items, item)); + } + } + } + + read_array_field(character, "inventory") + .into_iter() + .enumerate() + .map(|(index, item)| normalize_character_inventory_item(character, item, index)) + .for_each(|item| merge_inventory_item(&mut items, item)); + + if items.is_empty() { + items.push(json!({ + "id": format!("starter:{}:supply", read_optional_string_field(character, "id").unwrap_or_else(|| "character".to_string())), + "category": "消耗品", + "name": if world_type == "XIANXIA" { "回灵散" } else { "行囊补给" }, + "quantity": 2, + "rarity": "common", + "tags": ["healing", "supply"], + "description": "开局随身携带的基础补给。" + })); + } + + items +} + +fn build_initial_player_equipment( + world_type: &str, + profile: Option<&Value>, + character: &Value, + inventory: &[Value], +) -> Value { + let mut equipment = json!({ + "weapon": Value::Null, + "armor": Value::Null, + "relic": Value::Null + }); + + for item in inventory { + let Some(slot) = read_optional_string_field(item, "equipmentSlotId") else { + continue; + }; + if ["weapon", "armor", "relic"].contains(&slot.as_str()) + && read_field(&equipment, slot.as_str()).is_some_and(Value::is_null) + { + write_field(&mut equipment, slot.as_str(), item.clone()); + } + } + + for (slot, label) in [("weapon", "武器"), ("armor", "护甲"), ("relic", "饰品")] { + if read_field(&equipment, slot).is_some_and(Value::is_null) { + write_field( + &mut equipment, + slot, + build_fallback_equipment_item(world_type, profile, character, slot, label), + ); + } + } + + equipment +} + +fn build_fallback_equipment_item( + world_type: &str, + _profile: Option<&Value>, + character: &Value, + slot: &str, + label: &str, +) -> Value { + let character_id = + read_optional_string_field(character, "id").unwrap_or_else(|| "character".to_string()); + let character_name = + read_optional_string_field(character, "name").unwrap_or_else(|| "旅人".to_string()); + let name = match (world_type, slot) { + ("XIANXIA", "weapon") => format!("{character_name}的灵刃"), + ("XIANXIA", "armor") => format!("{character_name}的护行法衣"), + ("XIANXIA", "relic") => format!("{character_name}的行旅护符"), + (_, "weapon") => format!("{character_name}的短刃"), + (_, "armor") => format!("{character_name}的护行短甲"), + _ => format!("{character_name}的旧信物"), + }; + json!({ + "id": format!("starter:{character_id}:{slot}"), + "category": label, + "name": name, + "quantity": 1, + "rarity": "common", + "tags": [slot], + "equipmentSlotId": slot + }) +} + +fn normalize_character_inventory_item(character: &Value, item: &Value, index: usize) -> Value { + let category = + read_optional_string_field(item, "category").unwrap_or_else(|| "消耗品".to_string()); + json!({ + "id": read_optional_string_field(item, "id").unwrap_or_else(|| format!("starter:{}:inventory:{}", read_optional_string_field(character, "id").unwrap_or_else(|| "character".to_string()), index + 1)), + "category": category, + "name": read_optional_string_field(item, "name").or_else(|| read_optional_string_field(item, "item")).unwrap_or_else(|| "随身物品".to_string()), + "quantity": read_i32_field(item, "quantity").unwrap_or(1).max(1), + "rarity": read_optional_string_field(item, "rarity").unwrap_or_else(|| "common".to_string()), + "tags": read_field(item, "tags").cloned().unwrap_or(Value::Array(Vec::new())), + "description": read_optional_string_field(item, "description"), + "equipmentSlotId": infer_explicit_starter_slot(category.as_str()) + }) +} + +fn build_explicit_role_inventory_item(role: &Value, item: &Value, index: usize) -> Value { + let category = normalize_explicit_starter_category( + read_optional_string_field(item, "category") + .unwrap_or_else(|| "专属物品".to_string()) + .as_str(), + ); + let role_id = read_optional_string_field(role, "id").unwrap_or_else(|| "role".to_string()); + let role_name = read_optional_string_field(role, "name").unwrap_or_else(|| "角色".to_string()); + json!({ + "id": format!("custom-role-item:{role_id}:{}", index + 1), + "category": category, + "name": read_optional_string_field(item, "name").unwrap_or_else(|| "初始物品".to_string()), + "quantity": read_i32_field(item, "quantity").unwrap_or(1).max(1), + "rarity": read_optional_string_field(item, "rarity").unwrap_or_else(|| "common".to_string()), + "tags": read_field(item, "tags").cloned().unwrap_or(Value::Array(Vec::new())), + "description": read_optional_string_field(item, "description"), + "equipmentSlotId": infer_explicit_starter_slot(category.as_str()), + "runtimeMetadata": { + "origin": "ai_compiled", + "generationChannel": "discovery", + "seedKey": format!("{role_id}:{}", index + 1), + "relationAnchor": { + "type": "npc", + "npcId": role_id, + "npcName": role_name, + "roleText": read_optional_string_field(role, "role").unwrap_or_default() + }, + "sourceReason": format!("{role_name}在自定义世界开局时自带的初始物品。") + } + }) +} + +fn normalize_explicit_starter_category(category: &str) -> String { + let normalized = category.trim(); + if normalized == "专属物" { + "专属物品".to_string() + } else { + normalized.to_string() + } +} + +fn infer_explicit_starter_slot(category: &str) -> Value { + match normalize_explicit_starter_category(category).as_str() { + "武器" => json!("weapon"), + "护甲" => json!("armor"), + "饰品" | "稀有品" | "专属物品" => json!("relic"), + _ => Value::Null, + } +} + +fn merge_inventory_item(items: &mut Vec, item: Value) { + let key = format!( + "{}:{}", + read_optional_string_field(&item, "category").unwrap_or_default(), + read_optional_string_field(&item, "name").unwrap_or_default() + ); + if items.iter().any(|entry| { + format!( + "{}:{}", + read_optional_string_field(entry, "category").unwrap_or_default(), + read_optional_string_field(entry, "name").unwrap_or_default() + ) == key + }) { + return; + } + items.push(item); +} + +fn resolve_custom_character_role(profile: &Value, character: &Value) -> Option { + read_optional_string_field(character, "id") + .and_then(|id| find_custom_world_role_by_reference(profile, id.as_str())) + .or_else(|| { + read_optional_string_field(character, "name") + .and_then(|name| find_custom_world_role_by_reference(profile, name.as_str())) + }) +} + +fn find_custom_world_role_by_reference(profile: &Value, reference: &str) -> Option { + let normalized_reference = normalize_role_reference(reference); + if normalized_reference.is_empty() { + return None; + } + read_array_field(profile, "storyNpcs") + .into_iter() + .chain(read_array_field(profile, "playableNpcs")) + .find(|role| role_reference_aliases(role).contains(&normalized_reference)) + .cloned() +} + +fn resolve_custom_role_id_reference(profile: &Value, reference: &str) -> String { + find_custom_world_role_by_reference(profile, reference) + .and_then(|role| read_optional_string_field(&role, "id")) + .unwrap_or_else(|| reference.trim().to_string()) +} + +fn do_role_references_match(profile: &Value, left: Option<&str>, right: Option<&str>) -> bool { + let left = left.map(|value| resolve_custom_role_id_reference(profile, value)); + let right = right.map(|value| resolve_custom_role_id_reference(profile, value)); + matches!((left, right), (Some(left), Some(right)) if !left.is_empty() && left == right) +} + +fn role_reference_aliases(role: &Value) -> Vec { + let name = read_optional_string_field(role, "name").unwrap_or_default(); + let title = read_optional_string_field(role, "title").unwrap_or_default(); + let role_text = read_optional_string_field(role, "role").unwrap_or_default(); + [ + read_optional_string_field(role, "id").unwrap_or_default(), + name.clone(), + title.clone(), + format!("{name}{title}"), + format!("{title}{name}"), + format!("{role_text}{name}"), + format!("{name}{role_text}"), + ] + .into_iter() + .map(|value| normalize_role_reference(value.as_str())) + .filter(|value| !value.is_empty()) + .collect() +} + +fn normalize_role_reference(value: &str) -> String { + value + .trim() + .replace("character-npc-", "") + .replace("character-npc:", "") + .replace("playable-", "") + .replace("story-", "") + .replace("role-", "") + .replace("npc-", "") + .replace([' ', '(', ')', '(', ')'], "") +} + +fn read_equipment_total_bonuses(equipment: &Value) -> EquipmentBonusSummary { + let mut summary = EquipmentBonusSummary::default(); + for slot in ["weapon", "armor", "relic"] { + let Some(item) = read_field(equipment, slot) else { + continue; + }; + let Some(item_object) = item.as_object() else { + continue; + }; + if let Some(stat_profile) = item_object.get("statProfile") { + summary.max_hp_bonus += read_i32_field(stat_profile, "maxHpBonus").unwrap_or(0); + } else if slot == "armor" { + summary.max_hp_bonus += 14; + } + } + summary +} + +#[derive(Default)] +struct EquipmentBonusSummary { + max_hp_bonus: i32, +} + +fn write_field(target: &mut Value, key: &str, value: Value) { + let object = ensure_json_object(target); + object.insert(key.to_string(), value); +} + +#[cfg(test)] +mod bootstrap_tests { + use super::*; + + #[test] + fn custom_world_bootstrap_builds_opening_act_state_on_server() { + let payload = RuntimeStoryBootstrapRequest { + world_type: "CUSTOM".to_string(), + runtime_mode: Some("play".to_string()), + disable_persistence: Some(true), + character: json!({ + "id": "player-1", + "name": "沈砺", + "resourceProfile": { "maxHp": 188, "maxMana": 999 }, + "skills": [{ "id": "skill-1" }] + }), + custom_world_profile: Some(json!({ + "id": "profile-1", + "name": "回潮群岛", + "summary": "潮雾里有旧账。", + "camp": { + "id": "camp-1", + "name": "回潮暂栖所", + "description": "一间靠海的暂栖所。", + "sceneNpcIds": ["story-act-only"] + }, + "landmarks": [], + "playableNpcs": [{ + "id": "player-1", + "name": "沈砺", + "role": "主角", + "initialItems": [{ + "name": "旧潮短刃", + "category": "武器", + "quantity": 1, + "rarity": "rare", + "tags": ["weapon"], + "description": "旧账留下的短刃。" + }] + }], + "storyNpcs": [{ + "id": "story-act-only", + "name": "陆衡", + "title": "账房", + "role": "守账人", + "description": "守着账本的人。", + "backstory": "", + "personality": "", + "motivation": "", + "combatStyle": "", + "initialAffinity": 12, + "relationshipHooks": [], + "tags": [], + "initialItems": [], + "skills": [], + "backstoryReveal": { "publicSummary": "", "chapters": [] } + }], + "sceneChapterBlueprints": [{ + "id": "chapter-1", + "sceneId": "camp-1", + "title": "开局", + "linkedLandmarkIds": [], + "acts": [{ + "id": "act-1", + "sceneId": "camp-1", + "title": "对账", + "primaryNpcId": "story-primary-only", + "oppositeNpcId": "character-npc-story-act-only", + "encounterNpcIds": [] + }] + }] + })), + }; + + let state = build_initial_runtime_game_state(&payload, "runtime-test") + .expect("bootstrap should build state"); + + assert_eq!(state["runtimeSessionId"], json!("runtime-test")); + assert_eq!(state["currentScene"], json!("Story")); + assert_eq!( + state["currentScenePreset"]["id"], + json!("custom-scene-camp") + ); + assert_eq!( + state["storyEngineMemory"]["currentSceneActState"]["currentActId"], + json!("act-1") + ); + assert_eq!(state["currentEncounter"]["id"], json!("story-act-only")); + assert_eq!(state["playerInventory"][0]["name"], json!("旧潮短刃")); + assert_eq!( + state["playerEquipment"]["weapon"]["name"], + json!("旧潮短刃") + ); + } +} diff --git a/server-rs/crates/api-server/src/runtime_story/compat/npc_actions.rs b/server-rs/crates/api-server/src/runtime_story/compat/npc_actions.rs index 9a53ce76..d662054b 100644 --- a/server-rs/crates/api-server/src/runtime_story/compat/npc_actions.rs +++ b/server-rs/crates/api-server/src/runtime_story/compat/npc_actions.rs @@ -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 { + let visible_formation = read_array_field(game_state, "sceneHostileNpcs") + .into_iter() + .cloned() + .collect::>(); + 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()) diff --git a/server-rs/crates/api-server/src/runtime_story/compat/presentation.rs b/server-rs/crates/api-server/src/runtime_story/compat/presentation.rs index edaacef0..413c0bea 100644 --- a/server-rs/crates/api-server/src/runtime_story/compat/presentation.rs +++ b/server-rs/crates/api-server/src/runtime_story/compat/presentation.rs @@ -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 = diff --git a/server-rs/crates/api-server/src/runtime_story/compat/tests.rs b/server-rs/crates/api-server/src/runtime_story/compat/tests.rs index b76f3155..23b0299b 100644 --- a/server-rs/crates/api-server/src/runtime_story/compat/tests.rs +++ b/server-rs/crates/api-server/src/runtime_story/compat/tests.rs @@ -94,31 +94,16 @@ async fn runtime_story_action_resolve_requires_authentication() { async fn runtime_story_routes_resolve_through_rust_route_boundary() { let state = seed_authenticated_state().await; let token = issue_access_token(&state); - let app = build_router(state); - let snapshot_payload = json!({ - "bottomTab": "adventure", - "gameState": build_runtime_story_boundary_game_state_fixture(), - "currentStory": { + seed_runtime_story_snapshot( + &state, + build_runtime_story_boundary_game_state_fixture(), + Some(json!({ "text": "巡路人看着你,像在等一句开口。", "options": [] - } - }); - - let put_response = app - .clone() - .oneshot( - Request::builder() - .method("PUT") - .uri("/api/runtime/save/snapshot") - .header("authorization", format!("Bearer {token}")) - .header("content-type", "application/json") - .header("x-genarrative-response-envelope", "v1") - .body(Body::from(snapshot_payload.to_string())) - .expect("request should build"), - ) - .await - .expect("request should succeed"); - assert_eq!(put_response.status(), StatusCode::OK); + })), + ) + .await; + let app = build_router(state); let state_response = app .clone() @@ -195,42 +180,25 @@ async fn runtime_story_routes_resolve_through_rust_route_boundary() { async fn runtime_story_preview_snapshot_returns_transient_response_without_overwriting_save() { let state = seed_authenticated_state().await; let token = issue_access_token(&state); + seed_runtime_story_snapshot( + &state, + json!({ + "runtimeSessionId": "runtime-main", + "runtimeActionVersion": 1, + "worldType": "WUXIA", + "playerCharacter": { "id": "hero" }, + "currentScene": "Story", + "runtimeStats": { "playTimeMs": 0 }, + "storyHistory": [] + }), + Some(json!({ + "text": "正式存档里的故事。", + "options": [] + })), + ) + .await; let app = build_router(state); - let formal_response = app - .clone() - .oneshot( - Request::builder() - .method("PUT") - .uri("/api/runtime/save/snapshot") - .header("authorization", format!("Bearer {token}")) - .header("content-type", "application/json") - .header("x-genarrative-response-envelope", "v1") - .body(Body::from( - json!({ - "bottomTab": "adventure", - "gameState": { - "runtimeSessionId": "runtime-main", - "runtimeActionVersion": 1, - "worldType": "WUXIA", - "playerCharacter": { "id": "hero" }, - "currentScene": "Story", - "runtimeStats": { "playTimeMs": 0 }, - "storyHistory": [] - }, - "currentStory": { - "text": "正式存档里的故事。", - "options": [] - } - }) - .to_string(), - )) - .expect("request should build"), - ) - .await - .expect("request should succeed"); - assert_eq!(formal_response.status(), StatusCode::OK); - let preview_response = app .clone() .oneshot( @@ -322,42 +290,25 @@ async fn runtime_story_preview_snapshot_returns_transient_response_without_overw async fn runtime_story_action_resolve_rejects_client_version_conflict() { let state = seed_authenticated_state().await; let token = issue_access_token(&state); + seed_runtime_story_snapshot( + &state, + json!({ + "runtimeSessionId": "runtime-main", + "runtimeActionVersion": 5, + "playerHp": 20, + "playerMaxHp": 30, + "playerMana": 4, + "playerMaxMana": 12, + "storyHistory": [] + }), + Some(json!({ + "text": "旧局势仍然悬着。", + "options": [] + })), + ) + .await; let app = build_router(state); - let put_response = app - .clone() - .oneshot( - Request::builder() - .method("PUT") - .uri("/api/runtime/save/snapshot") - .header("authorization", format!("Bearer {token}")) - .header("content-type", "application/json") - .header("x-genarrative-response-envelope", "v1") - .body(Body::from( - json!({ - "bottomTab": "adventure", - "gameState": { - "runtimeSessionId": "runtime-main", - "runtimeActionVersion": 5, - "playerHp": 20, - "playerMaxHp": 30, - "playerMana": 4, - "playerMaxMana": 12, - "storyHistory": [] - }, - "currentStory": { - "text": "旧局势仍然悬着。", - "options": [] - } - }) - .to_string(), - )) - .expect("request should build"), - ) - .await - .expect("request should succeed"); - assert_eq!(put_response.status(), StatusCode::OK); - let response = app .oneshot( Request::builder() @@ -452,6 +403,91 @@ async fn runtime_story_initial_returns_fallback_without_llm() { ); } +#[tokio::test] +async fn runtime_story_initial_uses_server_snapshot_prompt_context_when_session_id_present() { + let state = seed_authenticated_state().await; + let token = issue_access_token(&state); + let mut game_state = build_runtime_story_boundary_game_state_fixture(); + ensure_json_object(&mut game_state).insert( + "playerCharacter".to_string(), + json!({ + "id": "hero-story", + "name": "后端角色", + "title": "试剑客", + "description": "站在桥口的人。", + "personality": "谨慎", + "skills": [] + }), + ); + ensure_json_object(&mut game_state).insert( + "currentScenePreset".to_string(), + json!({ + "id": "server-scene", + "name": "后端场景", + "description": "这段描述只存在于服务端快照。", + "mutationStateText": "风里有新近留下的脚印。", + "currentPressureLevel": "high", + "npcs": [], + "treasureHints": [] + }), + ); + seed_runtime_story_snapshot(&state, game_state, None).await; + let app = build_router(state); + + let response = app + .oneshot( + Request::builder() + .method("POST") + .uri("/api/runtime/story/initial") + .header("authorization", format!("Bearer {token}")) + .header("content-type", "application/json") + .header("x-genarrative-response-envelope", "v1") + .body(Body::from( + json!({ + "sessionId": "runtime-main", + "clientVersion": 0, + "worldType": "browser-world", + "character": { "name": "浏览器角色" }, + "context": { "sceneName": "浏览器场景" }, + "requestOptions": { + "availableOptions": [{ + "functionId": "idle_observe_signs", + "actionText": "观察周围迹象" + }] + } + }) + .to_string(), + )) + .expect("request should build"), + ) + .await + .expect("request should succeed"); + + assert_eq!(response.status(), StatusCode::OK); + let body = response + .into_body() + .collect() + .await + .expect("body should collect") + .to_bytes(); + let payload: Value = serde_json::from_slice(&body).expect("response body should be valid json"); + + assert!( + payload["data"]["storyText"] + .as_str() + .is_some_and(|text| text.contains("后端角色") && text.contains("后端场景")) + ); + assert!( + payload["data"]["storyText"] + .as_str() + .is_some_and(|text| !text.contains("浏览器角色") && !text.contains("浏览器场景")) + ); + assert_eq!( + payload["data"]["options"][0]["functionId"], + json!("idle_observe_signs") + ); +} + #[test] fn runtime_story_state_compiler_prefers_dialogue_deferred_options() { let response = build_runtime_story_state_response( @@ -883,6 +919,140 @@ fn runtime_story_inventory_use_writes_item_consumption_and_victory_rewards() { ); } +#[test] +fn runtime_story_post_battle_finalizer_builds_server_victory_story_and_act_state() { + let request = RuntimeStoryActionRequest { + session_id: "runtime-main".to_string(), + client_version: Some(0), + action: shared_contracts::runtime_story::RuntimeStoryChoiceAction { + action_type: "story_choice".to_string(), + function_id: "battle_attack_basic".to_string(), + target_id: None, + payload: Some(json!({ "optionText": "普通攻击" })), + }, + snapshot: None, + }; + let mut game_state = build_runtime_story_post_battle_custom_state_fixture(); + write_bool_field(&mut game_state, "inBattle", true); + write_bool_field(&mut game_state, "npcInteractionActive", false); + write_string_field(&mut game_state, "currentNpcBattleMode", "fight"); + let root = ensure_json_object(&mut game_state); + root.insert( + "currentEncounter".to_string(), + json!({ + "kind": "npc", + "id": "npc-rival", + "npcName": "潮线看守", + "hostile": true, + "hp": 6, + "maxHp": 30 + }), + ); + root.insert( + "sceneHostileNpcs".to_string(), + json!([{ + "id": "npc-rival", + "name": "潮线看守", + "hp": 6, + "maxHp": 30 + }]), + ); + + let mut resolution = + resolve_runtime_story_choice_action(&mut game_state, None, &request, "battle_attack_basic") + .expect("battle should resolve"); + let mut options = resolution + .presentation_options + .take() + .unwrap_or_else(|| build_fallback_runtime_story_options(&game_state)); + let mut story_text = resolution.result_text.clone(); + let mut history_result_text = resolution.result_text.clone(); + let mut saved_current_story = build_legacy_current_story(story_text.as_str(), &options); + + let 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(), + ); + + assert!(finalized); + assert_eq!( + read_field(&game_state, "currentEncounter"), + Some(&Value::Null) + ); + assert_eq!(read_bool_field(&game_state, "inBattle"), Some(false)); + assert_eq!( + read_field(&game_state, "storyEngineMemory") + .and_then(|memory| read_field(memory, "currentSceneActState")) + .and_then(|act| read_optional_string_field(act, "currentActId")), + Some("act-2".to_string()) + ); + assert_eq!(options[0].function_id, "story_continue_adventure"); + assert_eq!( + read_array_field(&saved_current_story, "deferredOptions") + .first() + .and_then(|option| read_optional_string_field(option, "functionId")), + Some("idle_travel_next_scene".to_string()) + ); +} + +#[test] +fn runtime_story_post_battle_finalizer_revives_player_on_server() { + let mut game_state = build_runtime_story_post_battle_custom_state_fixture(); + write_bool_field(&mut game_state, "inBattle", false); + write_i32_field(&mut game_state, "playerHp", 0); + write_i32_field(&mut game_state, "playerMana", 0); + write_string_field(&mut game_state, "currentNpcBattleOutcome", "fight_defeat"); + let mut story_text = "你在与潮线看守的交锋中被压制倒下。".to_string(); + let mut history_result_text = story_text.clone(); + let mut options = build_fallback_runtime_story_options(&game_state); + let mut saved_current_story = build_legacy_current_story(story_text.as_str(), &options); + let battle = RuntimeBattlePresentation { + target_id: Some("npc-rival".to_string()), + target_name: Some("潮线看守".to_string()), + damage_dealt: Some(8), + damage_taken: Some(30), + outcome: Some("defeat".to_string()), + }; + + let finalized = finalize_runtime_story_resolution_for_response( + &mut game_state, + &mut story_text, + &mut history_result_text, + &mut options, + &mut saved_current_story, + Some(&battle), + ); + + assert!(finalized); + assert_eq!(read_i32_field(&game_state, "playerHp"), Some(60)); + assert_eq!(read_i32_field(&game_state, "playerMana"), Some(20)); + assert_eq!( + read_object_field(&game_state, "currentScenePreset") + .and_then(|scene| read_optional_string_field(scene, "id")), + Some("custom-scene-camp".to_string()) + ); + assert_eq!( + read_object_field(&game_state, "currentEncounter") + .and_then(|encounter| read_optional_string_field(encounter, "id")), + Some("npc-rival".to_string()) + ); + assert_eq!(options[0].function_id, "story_continue_adventure"); + assert!( + read_optional_string_field(&saved_current_story, "text") + .is_some_and(|text| text.contains("重新醒来")) + ); + assert_eq!( + read_field(&game_state, "storyEngineMemory") + .and_then(|memory| read_field(memory, "currentSceneActState")) + .and_then(|act| read_optional_string_field(act, "currentActId")), + Some("act-1".to_string()) + ); +} + #[test] fn runtime_story_state_compiler_builds_active_npc_options_with_trade_gift_and_help_lock() { let mut game_state = build_runtime_story_boundary_game_state_fixture(); @@ -966,6 +1136,22 @@ fn runtime_story_state_compiler_builds_active_npc_options_with_trade_gift_and_he response.view_model.available_options[5].interaction, Some(RuntimeStoryOptionInteraction::Npc { ref action, .. }) if action == "gift" )); + let npc_interaction = response + .view_model + .npc_interaction + .as_ref() + .expect("active npc interaction view should compile"); + assert_eq!(npc_interaction.npc_id, "npc_merchant_01"); + assert_eq!(npc_interaction.currency_name, "铜钱"); + assert_eq!(npc_interaction.trade.buy_items[0].unit_price, 29); + assert_eq!(npc_interaction.trade.buy_items[0].max_quantity, 3); + assert!(npc_interaction.trade.buy_items[0].can_submit); + assert_eq!(npc_interaction.trade.sell_items[0].unit_price, 28); + assert_eq!(npc_interaction.gift.items[0].affinity_gain, 16); + assert_eq!( + response.snapshot.game_state["runtimeNpcInteraction"]["trade"]["buyItems"][0]["unitPrice"], + json!(29) + ); } #[test] @@ -1486,6 +1672,149 @@ fn runtime_story_npc_trade_buy_bootstraps_missing_npc_state() { assert!(resolution.result_text.contains("回气散")); } +#[test] +fn runtime_story_npc_trade_buy_rejects_stock_currency_and_invalid_quantity() { + let build_request = |quantity: i32| RuntimeStoryActionRequest { + session_id: "runtime-main".to_string(), + client_version: Some(0), + action: shared_contracts::runtime_story::RuntimeStoryChoiceAction { + action_type: "story_choice".to_string(), + function_id: "npc_trade".to_string(), + target_id: None, + payload: Some(json!({ + "mode": "buy", + "itemId": "merchant-essence", + "quantity": quantity + })), + }, + snapshot: None, + }; + let build_state = |player_currency: i32| { + let mut game_state = build_runtime_story_boundary_game_state_fixture(); + write_bool_field(&mut game_state, "npcInteractionActive", true); + write_i32_field(&mut game_state, "playerCurrency", player_currency); + let root = ensure_json_object(&mut game_state); + root.insert( + "npcStates".to_string(), + json!({ + "npc_merchant_01": { + "affinity": 58, + "chattedCount": 0, + "helpUsed": false, + "giftsGiven": 0, + "inventory": [{ + "id": "merchant-essence", + "category": "消耗品", + "name": "回气散", + "quantity": 3, + "rarity": "uncommon", + "tags": ["mana"] + }], + "recruited": false + } + }), + ); + game_state + }; + + let mut stock_state = build_state(120); + let stock_error = + resolve_runtime_story_choice_action(&mut stock_state, None, &build_request(4), "npc_trade"); + assert_eq!( + assert_runtime_story_error(stock_error, "stock shortage should be rejected"), + "目标商品不存在或库存不足。" + ); + assert_eq!(read_i32_field(&stock_state, "playerCurrency"), Some(120)); + + let mut currency_state = build_state(28); + let currency_error = resolve_runtime_story_choice_action( + &mut currency_state, + None, + &build_request(1), + "npc_trade", + ); + assert_eq!( + assert_runtime_story_error(currency_error, "currency shortage should be rejected"), + "当前钱币不足,无法完成购买。" + ); + assert_eq!(read_i32_field(¤cy_state, "playerCurrency"), Some(28)); + + let mut invalid_quantity_state = build_state(120); + let quantity_error = resolve_runtime_story_choice_action( + &mut invalid_quantity_state, + None, + &build_request(0), + "npc_trade", + ); + assert_eq!( + assert_runtime_story_error(quantity_error, "zero quantity should be rejected"), + "npc_trade.quantity 必须大于 0" + ); +} + +#[test] +fn runtime_story_npc_trade_sell_updates_currency_inventory_and_rejects_shortage() { + let request = RuntimeStoryActionRequest { + session_id: "runtime-main".to_string(), + client_version: Some(0), + action: shared_contracts::runtime_story::RuntimeStoryChoiceAction { + action_type: "story_choice".to_string(), + function_id: "npc_trade".to_string(), + target_id: None, + payload: Some(json!({ + "mode": "sell", + "itemId": "player-ingot", + "quantity": 2 + })), + }, + snapshot: None, + }; + let mut game_state = build_runtime_story_boundary_game_state_fixture(); + write_bool_field(&mut game_state, "npcInteractionActive", true); + write_i32_field(&mut game_state, "playerCurrency", 90); + ensure_json_object(&mut game_state).insert( + "playerInventory".to_string(), + json!([{ + "id": "player-ingot", + "category": "材料", + "name": "精炼锭材", + "quantity": 3, + "rarity": "rare", + "tags": ["material"], + "value": 50 + }]), + ); + + let resolution = + resolve_runtime_story_choice_action(&mut game_state, None, &request, "npc_trade") + .expect("sell trade should resolve"); + + assert_eq!(read_i32_field(&game_state, "playerCurrency"), Some(136)); + assert_eq!( + read_array_field(&game_state, "playerInventory") + .first() + .and_then(|item| read_i32_field(item, "quantity")), + Some(1) + ); + assert_eq!( + read_field(&game_state, "npcStates") + .and_then(|states| read_field(states, "npc_merchant_01")) + .map(|state| read_array_field(state, "inventory")) + .and_then(|items| items.first().copied()) + .and_then(|item| read_i32_field(item, "quantity")), + Some(2) + ); + assert!(resolution.result_text.contains("精炼锭材 x2")); + + let mut shortage_state = game_state; + let shortage_error = + resolve_runtime_story_choice_action(&mut shortage_state, None, &request, "npc_trade"); + assert_eq!( + assert_runtime_story_error(shortage_error, "selling more than owned should be rejected"), + "背包里没有足够数量的目标物品。" + ); +} + #[test] fn runtime_story_npc_gift_updates_affinity_inventory_and_patch() { let request = RuntimeStoryActionRequest { @@ -1567,11 +1896,41 @@ fn runtime_story_npc_gift_updates_affinity_inventory_and_patch() { )); } +#[test] +fn runtime_story_npc_gift_rejects_missing_item() { + let request = RuntimeStoryActionRequest { + session_id: "runtime-main".to_string(), + client_version: Some(0), + action: shared_contracts::runtime_story::RuntimeStoryChoiceAction { + action_type: "story_choice".to_string(), + function_id: "npc_gift".to_string(), + target_id: None, + payload: Some(json!({ + "itemId": "missing-gift" + })), + }, + snapshot: None, + }; + let mut game_state = build_runtime_story_boundary_game_state_fixture(); + write_bool_field(&mut game_state, "npcInteractionActive", true); + + let error = resolve_runtime_story_choice_action(&mut game_state, None, &request, "npc_gift"); + assert_eq!( + assert_runtime_story_error(error, "missing gift should be rejected"), + "背包里没有这件可赠送的物品。" + ); + assert_eq!( + read_field(&game_state, "npcStates") + .and_then(|states| read_field(states, "npc_merchant_01")) + .and_then(|state| read_i32_field(state, "affinity")), + Some(46) + ); +} + #[tokio::test] async fn runtime_story_route_boundary_persists_equipment_equip_snapshot_updates() { let state = seed_authenticated_state().await; let token = issue_access_token(&state); - let app = build_router(state); let mut game_state = build_runtime_story_boundary_game_state_fixture(); let root = ensure_json_object(&mut game_state); root.insert( @@ -1591,30 +1950,16 @@ async fn runtime_story_route_boundary_persists_equipment_equip_snapshot_updates( } }]), ); - let snapshot_payload = json!({ - "bottomTab": "adventure", - "gameState": game_state, - "currentStory": { + seed_runtime_story_snapshot( + &state, + game_state, + Some(json!({ "text": "你低头检查身上的旧甲。", "options": [] - } - }); - - let put_response = app - .clone() - .oneshot( - Request::builder() - .method("PUT") - .uri("/api/runtime/save/snapshot") - .header("authorization", format!("Bearer {token}")) - .header("content-type", "application/json") - .header("x-genarrative-response-envelope", "v1") - .body(Body::from(snapshot_payload.to_string())) - .expect("request should build"), - ) - .await - .expect("request should succeed"); - assert_eq!(put_response.status(), StatusCode::OK); + })), + ) + .await; + let app = build_router(state); let action_response = app .clone() @@ -1694,6 +2039,108 @@ async fn runtime_story_route_boundary_persists_equipment_equip_snapshot_updates( ); } +#[tokio::test] +async fn runtime_story_route_boundary_projects_story_engine_state() { + let state = seed_authenticated_state().await; + let token = issue_access_token(&state); + let mut game_state = build_runtime_story_boundary_game_state_fixture(); + let root = ensure_json_object(&mut game_state); + root.insert("currentScene".to_string(), json!("Story")); + root.insert("currentEncounter".to_string(), Value::Null); + root.insert( + "currentScenePreset".to_string(), + json!({ + "id": "scene-bridge", + "name": "断桥口", + "description": "风从桥下吹上来。", + "imageSrc": "", + "npcs": [{ + "id": "npc_merchant_01", + "name": "沈七", + "description": "腰间挂着药囊的行商", + "hostile": false + }] + }), + ); + root.insert( + "storyEngineMemory".to_string(), + json!({ + "activeThreadIds": ["thread-bridge"] + }), + ); + seed_runtime_story_snapshot( + &state, + game_state, + Some(json!({ + "text": "断桥口的风还没有停。", + "options": [] + })), + ) + .await; + let app = build_router(state); + + let action_response = app + .oneshot( + Request::builder() + .method("POST") + .uri("/api/runtime/story/actions/resolve") + .header("authorization", format!("Bearer {token}")) + .header("content-type", "application/json") + .header("x-genarrative-response-envelope", "v1") + .body(Body::from( + json!({ + "sessionId": "runtime-main", + "clientVersion": 0, + "action": { + "type": "story_choice", + "functionId": "idle_observe_signs", + "payload": { + "optionText": "观察周围迹象" + } + } + }) + .to_string(), + )) + .expect("request should build"), + ) + .await + .expect("request should succeed"); + assert_eq!(action_response.status(), StatusCode::OK); + let action_payload: Value = serde_json::from_slice( + &action_response + .into_body() + .collect() + .await + .expect("body should collect") + .to_bytes(), + ) + .expect("response should be json"); + let projected_state = &action_payload["data"]["snapshot"]["gameState"]; + + assert_eq!( + projected_state["chapterState"]["id"], + json!("chapter:scene:scene-bridge") + ); + assert_eq!( + projected_state["storyEngineMemory"]["currentChapter"]["id"], + json!("chapter:scene:scene-bridge") + ); + assert_eq!( + projected_state["quests"][0]["chapterId"], + json!("chapter:scene:scene-bridge") + ); + assert!( + projected_state["currentScenePreset"]["mutationStateText"] + .as_str() + .is_some_and(|text| text.contains("断桥口")) + ); + assert!( + projected_state["storyEngineMemory"]["worldMutations"] + .as_array() + .is_some_and(|items| !items.is_empty()) + ); +} + #[test] fn runtime_story_npc_help_is_one_shot_and_restores_resources() { let request = RuntimeStoryActionRequest { @@ -1729,6 +2176,199 @@ fn runtime_story_npc_help_is_one_shot_and_restores_resources() { } } +#[test] +fn runtime_story_idle_travel_next_scene_resolves_backend_snapshot_fields() { + let request = RuntimeStoryActionRequest { + session_id: "runtime-main".to_string(), + client_version: Some(0), + action: shared_contracts::runtime_story::RuntimeStoryChoiceAction { + action_type: "story_choice".to_string(), + function_id: "idle_travel_next_scene".to_string(), + target_id: None, + payload: Some(json!({ "optionText": "前往相邻场景" })), + }, + snapshot: None, + }; + let mut game_state = build_runtime_story_boundary_game_state_fixture(); + let root = ensure_json_object(&mut game_state); + root.insert( + "currentScenePreset".to_string(), + json!({ + "id": "wuxia-bamboo-road", + "name": "竹林古道", + "forwardSceneId": "wuxia-rain-street", + "connections": [{ + "sceneId": "wuxia-rain-street", + "relativePosition": "forward", + "summary": "沿石板路继续前行" + }], + "npcs": [] + }), + ); + root.insert( + "currentEncounter".to_string(), + json!({ + "kind": "npc", + "id": "npc_merchant_01", + "npcName": "沈七", + "hostile": false + }), + ); + root.insert( + "sceneHostileNpcs".to_string(), + json!([{ + "id": "old-hostile", + "name": "旧敌人", + "hp": 1, + "maxHp": 1 + }]), + ); + write_bool_field(&mut game_state, "inBattle", true); + write_bool_field(&mut game_state, "npcInteractionActive", true); + write_string_field(&mut game_state, "currentBattleNpcId", "npc_merchant_01"); + write_string_field(&mut game_state, "currentNpcBattleMode", "fight"); + write_string_field(&mut game_state, "currentNpcBattleOutcome", "ongoing"); + + let resolution = resolve_runtime_story_choice_action( + &mut game_state, + None, + &request, + "idle_travel_next_scene", + ) + .expect("travel action should resolve"); + + assert!(resolution.result_text.contains("竹林古道")); + assert_eq!( + read_object_field(&game_state, "currentScenePreset") + .and_then(|scene| read_optional_string_field(scene, "id")), + Some("wuxia-rain-street".to_string()) + ); + assert_eq!( + read_object_field(&game_state, "currentScenePreset") + .and_then(|scene| read_optional_string_field(scene, "name")), + Some("相邻场景".to_string()) + ); + assert_eq!( + read_object_field(&game_state, "runtimeStats") + .and_then(|stats| read_i32_field(stats, "scenesTraveled")), + Some(1) + ); + assert_eq!(read_bool_field(&game_state, "inBattle"), Some(false)); + assert_eq!( + read_bool_field(&game_state, "npcInteractionActive"), + Some(false) + ); + assert_eq!( + read_field(&game_state, "currentEncounter"), + Some(&Value::Null) + ); + assert!(read_array_field(&game_state, "sceneHostileNpcs").is_empty()); + assert_eq!( + read_field(&game_state, "currentBattleNpcId"), + Some(&Value::Null) + ); + assert_eq!( + read_field(&game_state, "currentNpcBattleMode"), + Some(&Value::Null) + ); + assert_eq!( + read_field(&game_state, "currentNpcBattleOutcome"), + Some(&Value::Null) + ); +} + +#[test] +fn runtime_story_npc_fight_resolves_battle_snapshot_without_frontend_bridge() { + let request = RuntimeStoryActionRequest { + session_id: "runtime-main".to_string(), + client_version: Some(0), + action: shared_contracts::runtime_story::RuntimeStoryChoiceAction { + action_type: "story_choice".to_string(), + function_id: "npc_fight".to_string(), + target_id: None, + payload: Some(json!({ "optionText": "直接开战" })), + }, + snapshot: None, + }; + let mut game_state = build_runtime_story_boundary_game_state_fixture(); + + let resolution = + resolve_runtime_story_choice_action(&mut game_state, None, &request, "npc_fight") + .expect("npc fight should resolve"); + + assert!(resolution.result_text.contains("战斗节奏")); + assert_eq!(read_bool_field(&game_state, "inBattle"), Some(true)); + assert_eq!( + read_bool_field(&game_state, "npcInteractionActive"), + Some(false) + ); + assert_eq!( + read_optional_string_field(&game_state, "currentBattleNpcId"), + Some("npc_merchant_01".to_string()) + ); + assert_eq!( + read_optional_string_field(&game_state, "currentNpcBattleMode"), + Some("fight".to_string()) + ); + assert_eq!( + read_field(&game_state, "currentEncounter"), + Some(&Value::Null) + ); + assert_eq!( + read_object_field(&game_state, "sparReturnEncounter") + .and_then(|encounter| read_optional_string_field(encounter, "id")), + Some("npc_merchant_01".to_string()) + ); + let formation = read_array_field(&game_state, "sceneHostileNpcs"); + assert_eq!(formation.len(), 1); + assert_eq!( + read_optional_string_field(formation[0], "renderKind"), + Some("npc".to_string()) + ); + assert_eq!( + read_object_field(formation[0], "encounter") + .and_then(|encounter| read_optional_string_field(encounter, "id")), + Some("npc_merchant_01".to_string()) + ); +} + +#[test] +fn runtime_story_npc_spar_resolves_lightweight_battle_snapshot() { + let request = RuntimeStoryActionRequest { + session_id: "runtime-main".to_string(), + client_version: Some(0), + action: shared_contracts::runtime_story::RuntimeStoryChoiceAction { + action_type: "story_choice".to_string(), + function_id: "npc_spar".to_string(), + target_id: None, + payload: Some(json!({ "optionText": "点到为止切磋" })), + }, + snapshot: None, + }; + let mut game_state = build_runtime_story_boundary_game_state_fixture(); + + resolve_runtime_story_choice_action(&mut game_state, None, &request, "npc_spar") + .expect("npc spar should resolve"); + + assert_eq!(read_bool_field(&game_state, "inBattle"), Some(true)); + assert_eq!( + read_optional_string_field(&game_state, "currentNpcBattleMode"), + Some("spar".to_string()) + ); + assert_eq!( + read_field(&game_state, "currentEncounter"), + Some(&Value::Null) + ); + let formation = read_array_field(&game_state, "sceneHostileNpcs"); + assert_eq!(formation.len(), 1); + assert_eq!(read_i32_field(formation[0], "maxHp"), Some(10)); + assert_eq!( + read_object_field(&game_state, "sparReturnEncounter") + .and_then(|encounter| read_optional_string_field(encounter, "id")), + Some("npc_merchant_01".to_string()) + ); +} + #[test] fn runtime_story_npc_recruit_requires_threshold_and_release_target_when_party_full() { let request = RuntimeStoryActionRequest { @@ -2119,6 +2759,36 @@ async fn seed_authenticated_state() -> AppState { state } +async fn seed_runtime_story_snapshot( + state: &AppState, + game_state: Value, + current_story: Option, +) { + let now = OffsetDateTime::now_utc(); + let micros = offset_datetime_to_unix_micros(now); + state + .put_runtime_snapshot_record( + "user_00000001".to_string(), + micros, + "adventure".to_string(), + game_state, + current_story, + micros, + ) + .await + .expect("runtime story snapshot should seed"); +} + +fn assert_runtime_story_error( + result: Result, + expectation: &str, +) -> String { + match result { + Ok(_) => panic!("{expectation}"), + Err(message) => message, + } +} + fn issue_access_token(state: &AppState) -> String { let claims = AccessTokenClaims::from_input( AccessTokenClaimsInput { @@ -2231,6 +2901,113 @@ fn build_runtime_story_boundary_game_state_fixture() -> Value { .expect("runtime story boundary game state fixture should parse") } +fn build_runtime_story_post_battle_custom_state_fixture() -> Value { + let mut game_state = build_runtime_story_boundary_game_state_fixture(); + let root = ensure_json_object(&mut game_state); + root.insert("worldType".to_string(), json!("CUSTOM")); + root.insert( + "currentScenePreset".to_string(), + json!({ + "id": "custom-scene-camp", + "name": "回潮营地", + "description": "潮雾里暂时安全的营地。", + "imageSrc": "", + "connectedSceneIds": ["custom-scene-landmark-1"], + "connections": [{ + "sceneId": "custom-scene-landmark-1", + "relativePosition": "forward", + "summary": "沿着潮线继续前进" + }], + "forwardSceneId": "custom-scene-landmark-1", + "treasureHints": [], + "npcs": [] + }), + ); + root.insert( + "customWorldProfile".to_string(), + json!({ + "id": "profile-post-battle", + "name": "回潮群岛", + "summary": "潮雾里有旧账。", + "camp": { + "id": "camp-1", + "name": "回潮营地", + "description": "潮雾里暂时安全的营地。", + "connections": [{ + "targetLandmarkId": "landmark-1", + "relativePosition": "forward", + "summary": "沿着潮线继续前进" + }], + "sceneNpcIds": ["npc-rival"] + }, + "landmarks": [{ + "id": "landmark-1", + "name": "潮线码头", + "description": "旧码头仍有看守巡行。", + "connections": [], + "sceneNpcIds": [] + }], + "playableNpcs": [], + "storyNpcs": [{ + "id": "npc-rival", + "name": "潮线看守", + "title": "巡潮者", + "role": "守住码头的对手", + "description": "盯着每一个靠近旧码头的人。", + "backstory": "", + "personality": "", + "motivation": "", + "combatStyle": "", + "initialAffinity": -20, + "relationshipHooks": [], + "tags": [], + "initialItems": [], + "skills": [], + "backstoryReveal": { "publicSummary": "", "chapters": [] } + }], + "sceneChapterBlueprints": [{ + "id": "chapter-1", + "sceneId": "camp-1", + "title": "回潮开局", + "linkedLandmarkIds": ["landmark-1"], + "acts": [ + { + "id": "act-1", + "sceneId": "camp-1", + "title": "营地复苏", + "primaryNpcId": "npc-rival", + "oppositeNpcId": "npc-rival", + "encounterNpcIds": ["npc-rival"] + }, + { + "id": "act-2", + "sceneId": "landmark-1", + "title": "码头追索", + "primaryNpcId": "npc-rival", + "oppositeNpcId": "npc-rival", + "encounterNpcIds": ["npc-rival"] + } + ] + }] + }), + ); + root.insert( + "storyEngineMemory".to_string(), + json!({ + "currentSceneActState": { + "chapterId": "chapter-1", + "currentActId": "act-1", + "sceneId": "camp-1", + "completedActIds": [], + "enteredAtStoryIndex": 0 + } + }), + ); + root.insert("playerMaxHp".to_string(), json!(60)); + root.insert("playerMaxMana".to_string(), json!(20)); + game_state +} + fn build_runtime_story_boundary_quest_fixture(quest_id: &str, title: &str) -> Value { json!({ "id": quest_id, diff --git a/server-rs/crates/module-big-fish/src/lib.rs b/server-rs/crates/module-big-fish/src/lib.rs index adeec338..aae78472 100644 --- a/server-rs/crates/module-big-fish/src/lib.rs +++ b/server-rs/crates/module-big-fish/src/lib.rs @@ -91,9 +91,13 @@ pub struct BigFishLevelBlueprint { pub level: u32, pub name: String, pub one_line_fantasy: String, + pub text_description: String, pub silhouette_direction: String, pub size_ratio: f32, + pub visual_description: String, pub visual_prompt_seed: String, + pub idle_motion_description: String, + pub move_motion_description: String, pub motion_prompt_seed: String, pub merge_source_level: Option, pub prey_window: Vec, @@ -293,6 +297,7 @@ pub struct BigFishMessageFinalizeInput { pub struct BigFishDraftCompileInput { pub session_id: String, pub owner_user_id: String, + pub draft_json: Option, pub compiled_at_micros: i64, } @@ -693,24 +698,72 @@ fn build_level_blueprint(level: u32, level_count: u32, theme: &str) -> BigFishLe .rev() .collect(); let threat_window = ((level + 1)..=(level + 2).min(level_count)).collect::>(); + let size_ratio = 1.0 + (level.saturating_sub(1) as f32 * 0.22); + let name = format!("{theme} L{level}"); + let one_line_fantasy = if level == level_count { + "终局巨兽形态,获得即可通关".to_string() + } else { + format!("第 {level} 阶实体,继续吞噬同级和低级个体成长") + }; + let text_description = if level == 1 { + format!( + "{name} 是这套 {theme} 等级阶梯的起点个体,体型最小、动作轻盈,会在谨慎试探中寻找第一个可吞噬目标。" + ) + } else if level == level_count { + format!( + "{name} 是这套 {theme} 生态中的终局霸主形态,体格巨大、压迫感最强,一旦成型就代表本局成长链已经完成。" + ) + } else { + format!( + "{name} 是 {theme} 生态里的第 {level} 阶进化体,已经具备更鲜明的轮廓、猎食性和压迫感,会继续通过吞并同级与低级实体向上跃迁。" + ) + }; + let visual_description = if level == 1 { + format!( + "{theme} 风格的小型初始鱼形生物,体态轻巧,轮廓圆润,局部带少量发光纹路或主题特征,明显呈现弱小但灵动的开局形象。" + ) + } else if level == level_count { + format!( + "{theme} 风格的终局巨型鱼形霸主,体长与鳍面明显扩张,轮廓锋利或威严,层次细节最丰富,拥有一眼可辨识的终局统治感。" + ) + } else { + format!( + "{theme} 风格的第 {level} 级进化鱼形生物,相比上一阶段更大、更强、更成熟,身体主轮廓更清晰,局部装饰、鳍面结构和主题特征都更明显。" + ) + }; + let idle_motion_description = if level == level_count { + "待机时缓慢悬停,身体主体保持稳定,尾鳍与侧鳍做低频摆动,呈现强者从容压场的漂浮感。" + .to_string() + } else { + format!( + "待机时保持轻微漂浮与呼吸感摆动,尾鳍和侧鳍以小幅度节奏晃动,体现 Lv.{level} 生物在水中蓄势观察的状态。" + ) + }; + let move_motion_description = if level == level_count { + "移动时身体前倾,尾鳍和背鳍形成强力推进姿态,带出稳定而有压迫感的高速巡游动势。".to_string() + } else { + format!( + "移动时身体向前游动,尾鳍形成清晰摆尾推进,整体节奏比待机更主动,体现 Lv.{level} 生物追逐猎物时的连续游动感。" + ) + }; BigFishLevelBlueprint { level, - name: format!("{theme} L{level}"), - one_line_fantasy: if level == level_count { - "终局巨兽形态,获得即可通关".to_string() - } else { - format!("第 {level} 阶实体,继续吞噬同级和低级个体成长") - }, + name, + one_line_fantasy, + text_description, silhouette_direction: format!( "体型约为初始的 {:.1} 倍,轮廓更清晰", 1.0 + level as f32 * 0.22 ), - size_ratio: 1.0 + (level.saturating_sub(1) as f32 * 0.22), + size_ratio, + visual_description: visual_description.clone(), visual_prompt_seed: format!( - "{theme} 第 {level} 级鱼形实体主图,RPG 角色资产口径,透明背景,单体完整入镜,清晰轮廓" + "{visual_description} 透明背景,单体完整入镜,适合作为竖屏吞噬成长玩法的等级主图。" ), + idle_motion_description: idle_motion_description.clone(), + move_motion_description: move_motion_description.clone(), motion_prompt_seed: format!( - "{theme} 第 {level} 级鱼形实体 idle_float 与 move_swim 动作,RPG 角色动画资产口径,透明背景" + "待机动作:{idle_motion_description} 移动动作:{move_motion_description}" ), merge_source_level: if level == 1 { None } else { Some(level - 1) }, prey_window, @@ -743,9 +796,14 @@ fn build_asset_prompt_snapshot( .find(|item| item.level == level) .ok_or(BigFishFieldError::InvalidLevel)?; let motion_key = motion_key.ok_or(BigFishFieldError::InvalidAssetKind)?; + let motion_description = match motion_key { + "idle_float" => blueprint.idle_motion_description.as_str(), + "move_swim" => blueprint.move_motion_description.as_str(), + _ => return Err(BigFishFieldError::InvalidAssetKind), + }; Ok(format!( - "{},动作位:{},透明背景,单体完整入镜", - blueprint.motion_prompt_seed, motion_key + "{} 动作位:{}。{} 透明背景,单体完整入镜。", + blueprint.motion_prompt_seed, motion_key, motion_description )) } BigFishAssetKind::StageBackground => Ok(draft.background.background_prompt_seed.clone()), @@ -861,5 +919,4 @@ mod tests { ); assert!(coverage.blockers.iter().any(|item| item.contains("背景图"))); } - } diff --git a/server-rs/crates/module-custom-world/src/lib.rs b/server-rs/crates/module-custom-world/src/lib.rs index 14f50b85..2898856b 100644 --- a/server-rs/crates/module-custom-world/src/lib.rs +++ b/server-rs/crates/module-custom-world/src/lib.rs @@ -1417,6 +1417,31 @@ pub fn build_custom_world_published_profile_compile_snapshot( }) } +pub fn canonicalize_custom_world_profile_before_save(profile: &mut Value) -> bool { + let Some(object) = profile.as_object_mut() else { + return false; + }; + let foundation_text = build_creator_intent_foundation_text(object.get("creatorIntent")) + .trim() + .to_string(); + if foundation_text.is_empty() { + return false; + } + let current_setting_text = object + .get("settingText") + .and_then(Value::as_str) + .map(str::trim) + .unwrap_or_default(); + if current_setting_text == foundation_text { + return false; + } + + // 中文注释:保存与 session 同步前统一以后端 creatorIntent 锚点重建 settingText, + // 避免浏览器继续持有正式 profile canonicalize 规则。 + object.insert("settingText".to_string(), Value::String(foundation_text)); + true +} + pub fn empty_agent_anchor_content_json() -> String { r#"{"worldPromise":null,"playerFantasy":null,"themeBoundary":null,"playerEntryPoint":null,"coreConflict":null,"keyRelationships":null,"hiddenLines":null,"iconicElements":null}"#.to_string() } @@ -1514,6 +1539,154 @@ fn to_object(value: Option<&Value>) -> Option> { } } +fn build_creator_intent_foundation_text(value: Option<&Value>) -> String { + let Some(intent) = value.and_then(Value::as_object) else { + return String::new(); + }; + if !has_meaningful_creator_intent(intent) { + return String::new(); + } + + let relationship_text = intent + .get("keyCharacters") + .and_then(Value::as_array) + .and_then(|items| items.first()) + .and_then(Value::as_object) + .map(build_creator_intent_relationship_text) + .unwrap_or_default(); + let player_opening_text = [ + read_text(intent, "playerPremise"), + read_text(intent, "openingSituation"), + ] + .into_iter() + .flatten() + .collect::>() + .join(";"); + let theme_tone_text = [ + read_string_list(intent, "themeKeywords").join("、"), + read_string_list(intent, "toneDirectives").join("、"), + ] + .into_iter() + .filter(|value| !value.is_empty()) + .collect::>() + .join(" / "); + + [ + build_anchor_line( + "世界一句话", + read_text(intent, "worldHook").unwrap_or_default(), + ), + build_anchor_line("玩家开局", player_opening_text), + build_anchor_line("主题气质", theme_tone_text), + build_anchor_line( + "核心冲突", + read_string_list(intent, "coreConflicts").join(";"), + ), + build_anchor_line("关键关系", relationship_text), + build_anchor_line( + "标志元素", + read_string_list(intent, "iconicElements").join("、"), + ), + ] + .into_iter() + .filter(|value| !value.is_empty()) + .collect::>() + .join("\n") +} + +fn has_meaningful_creator_intent(intent: &Map) -> bool { + [ + "rawSettingText", + "worldHook", + "playerPremise", + "openingSituation", + ] + .iter() + .any(|key| read_text(intent, key).is_some()) + || [ + "themeKeywords", + "toneDirectives", + "coreConflicts", + "iconicElements", + "forbiddenDirectives", + ] + .iter() + .any(|key| !read_string_list(intent, key).is_empty()) + || ["keyFactions", "keyCharacters", "keyLandmarks"] + .iter() + .any(|key| has_meaningful_creator_seed_array(intent.get(*key))) +} + +fn build_creator_intent_relationship_text(character: &Map) -> String { + [ + read_text(character, "name"), + read_text(character, "role"), + read_text(character, "relationToPlayer").map(|value| format!("与玩家 {value}")), + read_text(character, "hiddenHook").map(|value| format!("暗线 {value}")), + ] + .into_iter() + .flatten() + .collect::>() + .join(" · ") +} + +fn build_anchor_line(label: &str, content: String) -> String { + if content.is_empty() { + String::new() + } else { + format!("{label}:{content}") + } +} + +fn read_text(object: &Map, key: &str) -> Option { + object + .get(key) + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(ToOwned::to_owned) +} + +fn read_string_list(object: &Map, key: &str) -> Vec { + object + .get(key) + .and_then(Value::as_array) + .map(|items| { + items + .iter() + .filter_map(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(ToOwned::to_owned) + .collect::>() + }) + .unwrap_or_default() +} + +fn has_meaningful_creator_seed_array(value: Option<&Value>) -> bool { + value.and_then(Value::as_array).is_some_and(|items| { + items.iter().any(|item| { + item.as_object().is_some_and(|object| { + [ + "name", + "publicGoal", + "tension", + "notes", + "role", + "publicMask", + "hiddenHook", + "relationToPlayer", + "purpose", + "mood", + "secret", + ] + .iter() + .any(|key| read_text(object, key).is_some()) + }) + }) + }) +} + fn resolve_text_field( draft: &Map, legacy: &Map, @@ -1794,6 +1967,52 @@ mod tests { assert_eq!(error, CustomWorldFieldError::MissingAuthorDisplayName); } + #[test] + fn canonicalize_profile_before_save_rebuilds_setting_text_from_creator_intent() { + let mut profile = serde_json::json!({ + "id": "cwprof_001", + "settingText": "前端旧草稿文案", + "creatorIntent": { + "rawSettingText": "早期输入", + "worldHook": "海图会在午夜改写群岛航路", + "themeKeywords": ["海雾", "旧灯塔"], + "toneDirectives": ["克制", "悬疑"], + "playerPremise": "玩家是失忆领航员", + "openingSituation": "正在禁航区醒来", + "coreConflicts": ["议会隐瞒沉船真相"], + "keyCharacters": [{ + "name": "顾潮音", + "role": "守灯人", + "relationToPlayer": "旧识", + "hiddenHook": "掌握伪造海图" + }], + "iconicElements": ["会说谎的罗盘"] + } + }); + + assert!(canonicalize_custom_world_profile_before_save(&mut profile)); + assert_eq!( + profile.get("settingText").and_then(Value::as_str), + Some( + "世界一句话:海图会在午夜改写群岛航路\n玩家开局:玩家是失忆领航员;正在禁航区醒来\n主题气质:海雾、旧灯塔 / 克制、悬疑\n核心冲突:议会隐瞒沉船真相\n关键关系:顾潮音 · 守灯人 · 与玩家 旧识 · 暗线 掌握伪造海图\n标志元素:会说谎的罗盘" + ) + ); + } + + #[test] + fn canonicalize_profile_before_save_keeps_profile_without_creator_intent() { + let mut profile = serde_json::json!({ + "id": "cwprof_001", + "settingText": "用户手写设定" + }); + + assert!(!canonicalize_custom_world_profile_before_save(&mut profile)); + assert_eq!( + profile.get("settingText").and_then(Value::as_str), + Some("用户手写设定") + ); + } + #[test] fn profile_list_input_requires_owner_user_id() { let error = validate_custom_world_profile_list_input(&CustomWorldProfileListInput { diff --git a/server-rs/crates/module-puzzle/src/lib.rs b/server-rs/crates/module-puzzle/src/lib.rs index 448db2c6..28c7ddf7 100644 --- a/server-rs/crates/module-puzzle/src/lib.rs +++ b/server-rs/crates/module-puzzle/src/lib.rs @@ -1964,14 +1964,18 @@ fn with_next_board(run: &PuzzleRunSnapshot, next_board: PuzzleBoardSnapshot) -> if current_level.status != PuzzleRuntimeLevelStatus::Cleared && is_cleared { let cleared_at_ms = current_unix_ms(); current_level.cleared_at_ms = Some(cleared_at_ms); - current_level.elapsed_ms = - Some(cleared_at_ms.saturating_sub(current_level.started_at_ms).max(1_000)); + current_level.elapsed_ms = Some( + cleared_at_ms + .saturating_sub(current_level.started_at_ms) + .max(1_000), + ); } current_level.status = next_level_status; } - if is_cleared && run.current_level.as_ref().map(|level| level.status) - != Some(PuzzleRuntimeLevelStatus::Cleared) + if is_cleared + && run.current_level.as_ref().map(|level| level.status) + != Some(PuzzleRuntimeLevelStatus::Cleared) { next_run.cleared_level_count += 1; } diff --git a/server-rs/crates/module-runtime-story-compat/src/battle.rs b/server-rs/crates/module-runtime-story-compat/src/battle.rs index 39739529..24a23066 100644 --- a/server-rs/crates/module-runtime-story-compat/src/battle.rs +++ b/server-rs/crates/module-runtime-story-compat/src/battle.rs @@ -42,7 +42,7 @@ struct BattleSkillView { build_buffs: Vec, } -struct BattleInventoryUseProfile { +pub struct BattleInventoryUseProfile { hp_restore: i32, mana_restore: i32, cooldown_reduction: i32, @@ -515,6 +515,29 @@ fn read_player_inventory_items(game_state: &Value) -> Vec Option { + read_field(item, "useProfile").map(|profile| BattleInventoryUseProfile { + hp_restore: read_i32_field(profile, "hpRestore").unwrap_or(0).max(0), + mana_restore: read_i32_field(profile, "manaRestore").unwrap_or(0).max(0), + cooldown_reduction: read_i32_field(profile, "cooldownReduction") + .unwrap_or(0) + .max(0), + build_buffs: read_array_field(profile, "buildBuffs") + .into_iter() + .cloned() + .collect(), + }) +} + +pub fn inventory_item_has_usable_effect(item: &Value) -> bool { + read_inventory_item_use_profile(item).is_some_and(|effect| { + effect.hp_restore > 0 + || effect.mana_restore > 0 + || effect.cooldown_reduction > 0 + || !effect.build_buffs.is_empty() + }) +} + fn find_player_inventory_item( game_state: &Value, item_id: &str, diff --git a/server-rs/crates/module-runtime-story-compat/src/battle_tests.rs b/server-rs/crates/module-runtime-story-compat/src/battle_tests.rs index 4d6c1263..31ff8838 100644 --- a/server-rs/crates/module-runtime-story-compat/src/battle_tests.rs +++ b/server-rs/crates/module-runtime-story-compat/src/battle_tests.rs @@ -1,12 +1,11 @@ use serde_json::json; -use shared_contracts::runtime_story::{RuntimeStoryActionRequest, RuntimeStoryChoiceAction, RuntimeStoryPatch}; +use shared_contracts::runtime_story::{ + RuntimeStoryActionRequest, RuntimeStoryChoiceAction, RuntimeStoryPatch, +}; use crate::{ - battle::resolve_battle_action, - build_status_patch, - read_bool_field, - read_i32_field, + battle::resolve_battle_action, build_status_patch, read_bool_field, read_i32_field, read_optional_string_field, }; @@ -82,6 +81,12 @@ fn battle_resolution_prefers_player_defeat_when_both_sides_fall_in_same_turn() { resolution.patches.first(), Some(RuntimeStoryPatch::BattleResolved { outcome, .. }) if outcome == "defeat" )); - assert_eq!(resolution.patches.get(1), Some(&build_status_patch(&game_state))); - assert_eq!(resolution.battle.and_then(|battle| battle.outcome), Some("defeat".to_string())); + assert_eq!( + resolution.patches.get(1), + Some(&build_status_patch(&game_state)) + ); + assert_eq!( + resolution.battle.and_then(|battle| battle.outcome), + Some("defeat".to_string()) + ); } diff --git a/server-rs/crates/module-runtime-story-compat/src/forge.rs b/server-rs/crates/module-runtime-story-compat/src/forge.rs index 36c3a949..536f110a 100644 --- a/server-rs/crates/module-runtime-story-compat/src/forge.rs +++ b/server-rs/crates/module-runtime-story-compat/src/forge.rs @@ -10,18 +10,25 @@ use crate::{ /// /// 当前仍然保持旧快照态结算口径,不引入 HTTP / AppState / 持久化边界。 pub(crate) struct ForgeRequirementDefinition { + pub(crate) id: &'static str, + pub(crate) label: &'static str, pub(crate) quantity: i32, pub(crate) matcher: ForgeRequirementMatcher, } +#[derive(Clone, Copy)] pub(crate) enum ForgeRequirementMatcher { Named(&'static str), + TaggedMaterial(&'static str), AnyMaterial, } pub(crate) struct ForgeRecipeDefinition { pub(crate) id: &'static str, pub(crate) name: &'static str, + pub(crate) kind: &'static str, + pub(crate) description: &'static str, + pub(crate) result_label: &'static str, pub(crate) currency_cost: i32, pub(crate) requirements: Vec, } @@ -32,33 +39,134 @@ pub(crate) struct ReforgeCostDefinition { } pub(crate) fn forge_recipe_definition(recipe_id: &str) -> Option { - match recipe_id { - "synthesis-refined-ingot" => Some(ForgeRecipeDefinition { + forge_recipe_definitions() + .into_iter() + .find(|recipe| recipe.id == recipe_id) +} + +pub(crate) fn forge_recipe_definitions() -> Vec { + vec![ + ForgeRecipeDefinition { id: "synthesis-refined-ingot", name: "压炼锭材", + kind: "synthesis", + description: "把零散残片和基础材料压成稳定可用的金属锭材。", + result_label: "精炼锭材", currency_cost: 18, requirements: vec![ForgeRequirementDefinition { + id: "material:any", + label: "任意材料", quantity: 3, matcher: ForgeRequirementMatcher::AnyMaterial, }], - }), - "forge-duelist-blade" => Some(ForgeRecipeDefinition { + }, + ForgeRecipeDefinition { + id: "synthesis-condensed-silk", + name: "凝光纺丝", + kind: "synthesis", + description: "用灵性残材与粉末纺出适合饰品锻造的凝光纱。", + result_label: "凝光纱", + currency_cost: 24, + requirements: vec![ + ForgeRequirementDefinition { + id: "material:any", + label: "任意材料", + quantity: 2, + matcher: ForgeRequirementMatcher::AnyMaterial, + }, + ForgeRequirementDefinition { + id: "tag:mana", + label: "含法力标签材料", + quantity: 1, + matcher: ForgeRequirementMatcher::TaggedMaterial("mana"), + }, + ], + }, + ForgeRecipeDefinition { id: "forge-duelist-blade", name: "锻造 百炼追风剑", + kind: "forge", + description: "围绕快剑、突进、追击构筑的轻灵主武器。", + result_label: "百炼追风剑", currency_cost: 72, requirements: vec![ ForgeRequirementDefinition { + id: "name:精炼锭材", + label: "精炼锭材", quantity: 2, matcher: ForgeRequirementMatcher::Named("精炼锭材"), }, ForgeRequirementDefinition { + id: "name:快剑精粹", + label: "快剑精粹", quantity: 1, matcher: ForgeRequirementMatcher::Named("快剑精粹"), }, + ForgeRequirementDefinition { + id: "name:突进精粹", + label: "突进精粹", + quantity: 1, + matcher: ForgeRequirementMatcher::Named("突进精粹"), + }, ], - }), - _ => None, - } + }, + ForgeRecipeDefinition { + id: "forge-ward-armor", + name: "锻造 镇岳护甲", + kind: "forge", + description: "面向前排承压的护甲,适合守御与护体构筑。", + result_label: "镇岳护甲", + currency_cost: 78, + requirements: vec![ + ForgeRequirementDefinition { + id: "name:精炼锭材", + label: "精炼锭材", + quantity: 2, + matcher: ForgeRequirementMatcher::Named("精炼锭材"), + }, + ForgeRequirementDefinition { + id: "name:守御精粹", + label: "守御精粹", + quantity: 1, + matcher: ForgeRequirementMatcher::Named("守御精粹"), + }, + ForgeRequirementDefinition { + id: "name:护体精粹", + label: "护体精粹", + quantity: 1, + matcher: ForgeRequirementMatcher::Named("护体精粹"), + }, + ], + }, + ForgeRecipeDefinition { + id: "forge-thunder-relic", + name: "锻造 雷纹灵坠", + kind: "forge", + description: "为法修、雷法、过载 build 提供资源与爆发补强。", + result_label: "雷纹灵坠", + currency_cost: 88, + requirements: vec![ + ForgeRequirementDefinition { + id: "name:凝光纱", + label: "凝光纱", + quantity: 2, + matcher: ForgeRequirementMatcher::Named("凝光纱"), + }, + ForgeRequirementDefinition { + id: "name:法力精粹", + label: "法力精粹", + quantity: 1, + matcher: ForgeRequirementMatcher::Named("法力精粹"), + }, + ForgeRequirementDefinition { + id: "name:雷法精粹", + label: "雷法精粹", + quantity: 1, + matcher: ForgeRequirementMatcher::Named("雷法精粹"), + }, + ], + }, + ] } pub(crate) fn reforge_cost_definition(slot_id: Option<&str>) -> ReforgeCostDefinition { @@ -66,6 +174,8 @@ pub(crate) fn reforge_cost_definition(slot_id: Option<&str>) -> ReforgeCostDefin return ReforgeCostDefinition { currency_cost: 52, requirements: vec![ForgeRequirementDefinition { + id: "name:凝光纱", + label: "凝光纱", quantity: 1, matcher: ForgeRequirementMatcher::Named("凝光纱"), }], @@ -74,6 +184,8 @@ pub(crate) fn reforge_cost_definition(slot_id: Option<&str>) -> ReforgeCostDefin ReforgeCostDefinition { currency_cost: 46, requirements: vec![ForgeRequirementDefinition { + id: "name:精炼锭材", + label: "精炼锭材", quantity: 1, matcher: ForgeRequirementMatcher::Named("精炼锭材"), }], @@ -85,17 +197,28 @@ fn forge_requirement_matches(item: &Value, requirement: &ForgeRequirementDefinit ForgeRequirementMatcher::Named(name) => { read_optional_string_field(item, "name").as_deref() == Some(name) } - ForgeRequirementMatcher::AnyMaterial => { - read_array_field(item, "tags") - .into_iter() - .filter_map(Value::as_str) - .any(|tag| tag == "material") - || read_optional_string_field(item, "category") - .is_some_and(|category| category.contains("材料")) + ForgeRequirementMatcher::TaggedMaterial(tag) => { + is_material_item(item) + && read_array_field(item, "tags") + .into_iter() + .filter_map(Value::as_str) + .any(|item_tag| forge_tag_matches(item_tag, tag)) } + ForgeRequirementMatcher::AnyMaterial => is_material_item(item), } } +pub(crate) fn count_matching_forge_requirement( + inventory: &[Value], + requirement: &ForgeRequirementDefinition, +) -> i32 { + inventory + .iter() + .filter(|item| forge_requirement_matches(item, requirement)) + .map(|item| read_i32_field(item, "quantity").unwrap_or(0).max(0)) + .sum() +} + pub(crate) fn apply_forge_requirements_if_possible( inventory: &[Value], requirements: &[ForgeRequirementDefinition], @@ -125,6 +248,19 @@ pub(crate) fn apply_forge_requirements_if_possible( Some(next_inventory) } +fn is_material_item(item: &Value) -> bool { + read_array_field(item, "tags") + .into_iter() + .filter_map(Value::as_str) + .any(|tag| tag == "material") + || read_optional_string_field(item, "category") + .is_some_and(|category| category.contains("材料")) +} + +fn forge_tag_matches(item_tag: &str, expected_tag: &str) -> bool { + item_tag == expected_tag || (expected_tag == "mana" && item_tag == "法力") +} + pub fn build_runtime_material_item( game_state: &Value, name: &str, @@ -196,6 +332,9 @@ pub(crate) fn build_forge_recipe_result_item( "synthesis-refined-ingot" => { build_runtime_material_item(game_state, "精炼锭材", 1, &["工巧", "守御"], "rare") } + "synthesis-condensed-silk" => { + build_runtime_material_item(game_state, "凝光纱", 1, &["工巧", "法力"], "rare") + } "forge-duelist-blade" => build_runtime_equipment_item( game_state, "百炼追风剑", @@ -210,6 +349,38 @@ pub(crate) fn build_forge_recipe_result_item( "outgoingDamageBonus": 0.20 }), ), + "forge-ward-armor" => build_runtime_equipment_item( + game_state, + "镇岳护甲", + "armor", + "epic", + "厚重但稳定的护甲套件,适合顶住正面压力后再伺机反打。", + "守御", + &["守御", "护体", "先锋"], + &["守御", "护体", "先锋"], + json!({ + "maxHpBonus": 56, + "maxManaBonus": 8, + "outgoingDamageBonus": 0.08, + "incomingDamageMultiplier": 0.84 + }), + ), + "forge-thunder-relic" => build_runtime_equipment_item( + game_state, + "雷纹灵坠", + "relic", + "epic", + "内封雷纹与灵引回路的饰品,能在短窗口内快速放大法术节奏。", + "法修", + &["法修", "雷法", "过载"], + &["法修", "雷法", "过载"], + json!({ + "maxHpBonus": 8, + "maxManaBonus": 42, + "outgoingDamageBonus": 0.14, + "incomingDamageMultiplier": 0.92 + }), + ), _ => build_runtime_material_item(game_state, "临时锻造产物", 1, &["工巧"], "common"), } } diff --git a/server-rs/crates/module-runtime-story-compat/src/lib.rs b/server-rs/crates/module-runtime-story-compat/src/lib.rs index b3f4f161..f1baf3c3 100644 --- a/server-rs/crates/module-runtime-story-compat/src/lib.rs +++ b/server-rs/crates/module-runtime-story-compat/src/lib.rs @@ -13,10 +13,14 @@ pub mod forge_actions; pub mod game_state; pub mod npc_support; pub mod options; +pub mod post_battle; +pub mod prompt_context; +pub mod story_engine; pub mod view_model; pub use battle::{ - build_battle_runtime_story_options, resolve_battle_action, restore_player_resource, + build_battle_runtime_story_options, inventory_item_has_usable_effect, resolve_battle_action, + restore_player_resource, }; pub use core::{ MAX_PLAYER_LEVEL, add_player_currency, add_player_inventory_items, append_active_build_buffs, @@ -47,14 +51,20 @@ pub use game_state::{ write_runtime_equipment_bonus_cache, }; pub use npc_support::{ - build_npc_gift_result_text, npc_buyback_price, npc_purchase_price, recruit_companion_to_party, - resolve_npc_gift_affinity_gain, trade_quantity_suffix, + build_npc_gift_result_text, build_runtime_npc_interaction_view, npc_buyback_price, + npc_purchase_price, recruit_companion_to_party, resolve_npc_gift_affinity_gain, + trade_quantity_suffix, write_runtime_npc_interaction_view, }; pub use options::{ build_disabled_runtime_story_option, build_runtime_story_option_from_story_option, build_runtime_story_option_interaction, build_runtime_story_option_with_payload, build_static_runtime_story_option, build_story_option_from_runtime_option, infer_option_scope, }; +pub use post_battle::{ + finalize_post_battle_resolution, is_terminal_battle_outcome, resolve_post_battle_story_options, +}; +pub use prompt_context::{RuntimeStoryPromptContextExtras, build_runtime_story_prompt_context}; +pub use story_engine::project_story_engine_after_action; pub use view_model::{ build_runtime_story_companions, build_runtime_story_encounter, build_runtime_story_view_model, resolve_current_encounter_npc_state, diff --git a/server-rs/crates/module-runtime-story-compat/src/npc_support.rs b/server-rs/crates/module-runtime-story-compat/src/npc_support.rs index 1980bc4f..75a54fff 100644 --- a/server-rs/crates/module-runtime-story-compat/src/npc_support.rs +++ b/server-rs/crates/module-runtime-story-compat/src/npc_support.rs @@ -1,8 +1,14 @@ -use serde_json::{Value, json}; +use serde_json::{Map, Value, json}; + +use shared_contracts::runtime_story::{ + RuntimeNpcGiftItemView, RuntimeNpcGiftView, RuntimeNpcInteractionView, RuntimeNpcTradeItemView, + RuntimeNpcTradeView, +}; use crate::{ MAX_TASK5_COMPANIONS, ensure_json_object, item_rarity_key, normalize_required_string, - read_array_field, read_i32_field, read_inventory_item_name, read_optional_string_field, + read_array_field, read_bool_field, read_i32_field, read_inventory_item_name, read_object_field, + read_optional_string_field, read_required_string_field, }; pub fn resolve_npc_gift_affinity_gain(item: &Value) -> i32 { @@ -142,6 +148,177 @@ pub fn trade_quantity_suffix(quantity: i32) -> String { } } +fn currency_name_for_world(world_type: Option<&str>) -> String { + match world_type { + Some("XIANXIA") => "灵石", + Some("WUXIA") => "铜钱", + _ => "钱币", + } + .to_string() +} + +fn read_runtime_npc_state<'a>( + game_state: &'a Value, + encounter_id: &str, + npc_name: &str, +) -> Option<&'a Value> { + let npc_states = read_object_field(game_state, "npcStates")?; + + npc_states + .get(encounter_id) + .or_else(|| npc_states.get(npc_name)) +} + +fn read_item_id(item: &Value) -> Option { + read_required_string_field(item, "id") +} + +fn sanitize_item_for_view(item: &Value) -> Value { + let mut item = item.clone(); + if let Some(object) = item.as_object_mut() { + object.retain(|key, _| key != "__internal"); + } + item +} + +fn build_trade_item_view(params: BuildTradeItemViewParams<'_>) -> RuntimeNpcTradeItemView { + let quantity = read_i32_field(params.item, "quantity").unwrap_or(0).max(0); + let unit_price = match params.mode { + "buy" => npc_purchase_price(params.item, params.affinity), + _ => npc_buyback_price(params.item, params.affinity), + }; + let mut reason = None; + if quantity <= 0 { + reason = Some(if params.mode == "buy" { + "NPC 库存不足。".to_string() + } else { + "背包数量不足。".to_string() + }); + } else if params.mode == "buy" && params.player_currency < unit_price { + reason = Some("当前钱币不足。".to_string()); + } + + RuntimeNpcTradeItemView { + item_id: params.item_id.to_string(), + item: sanitize_item_for_view(params.item), + mode: params.mode.to_string(), + unit_price, + max_quantity: quantity, + can_submit: reason.is_none(), + reason, + } +} + +struct BuildTradeItemViewParams<'a> { + item_id: &'a str, + item: &'a Value, + mode: &'a str, + affinity: i32, + player_currency: i32, +} + +/// 编译 NPC 交易 / 送礼展示用 view。 +/// +/// 中文注释:这份 view 只服务前端展示与按钮状态,正式结算仍会在 +/// `resolve_npc_trade_action` / `resolve_npc_gift_action` 中重新校验。 +pub fn build_runtime_npc_interaction_view(game_state: &Value) -> Option { + if read_bool_field(game_state, "inBattle").unwrap_or(false) { + return None; + } + if !read_bool_field(game_state, "npcInteractionActive").unwrap_or(false) { + return None; + } + + let encounter = read_object_field(game_state, "currentEncounter")?; + if read_required_string_field(encounter, "kind").as_deref() != Some("npc") { + return None; + } + 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 npc_state = read_runtime_npc_state(game_state, npc_id.as_str(), npc_name.as_str())?; + let affinity = read_i32_field(npc_state, "affinity").unwrap_or(0); + let player_currency = read_i32_field(game_state, "playerCurrency") + .unwrap_or(0) + .max(0); + let currency_name = + currency_name_for_world(read_optional_string_field(game_state, "worldType").as_deref()); + + let buy_items = read_array_field(npc_state, "inventory") + .into_iter() + .filter_map(|item| { + let item_id = read_item_id(item)?; + Some(build_trade_item_view(BuildTradeItemViewParams { + item_id: item_id.as_str(), + item, + mode: "buy", + affinity, + player_currency, + })) + }) + .collect::>(); + let sell_items = read_array_field(game_state, "playerInventory") + .into_iter() + .filter_map(|item| { + let item_id = read_item_id(item)?; + Some(build_trade_item_view(BuildTradeItemViewParams { + item_id: item_id.as_str(), + item, + mode: "sell", + affinity, + player_currency, + })) + }) + .collect::>(); + let gift_items = read_array_field(game_state, "playerInventory") + .into_iter() + .filter_map(|item| { + let item_id = read_item_id(item)?; + let quantity = read_i32_field(item, "quantity").unwrap_or(0).max(0); + let reason = if quantity <= 0 { + Some("背包里没有这件可赠送的物品。".to_string()) + } else { + None + }; + Some(RuntimeNpcGiftItemView { + item_id, + item: sanitize_item_for_view(item), + affinity_gain: resolve_npc_gift_affinity_gain(item), + can_submit: reason.is_none(), + reason, + }) + }) + .collect::>(); + + Some(RuntimeNpcInteractionView { + npc_id, + npc_name, + player_currency, + currency_name, + trade: RuntimeNpcTradeView { + buy_items, + sell_items, + }, + gift: RuntimeNpcGiftView { items: gift_items }, + }) +} + +/// 将 NPC 交互 view 写入快照 JSON,方便旧前端在 hydrated snapshot 上直接读取。 +pub fn write_runtime_npc_interaction_view(game_state: &mut Value) { + let view = build_runtime_npc_interaction_view(game_state); + let root = ensure_json_object(game_state); + match view { + Some(view) => { + let value = serde_json::to_value(view).unwrap_or_else(|_| Value::Object(Map::new())); + root.insert("runtimeNpcInteraction".to_string(), value); + } + None => { + root.remove("runtimeNpcInteraction"); + } + } +} + fn add_companion_if_absent( game_state: &mut Value, npc_id: &str, diff --git a/server-rs/crates/module-runtime-story-compat/src/post_battle.rs b/server-rs/crates/module-runtime-story-compat/src/post_battle.rs new file mode 100644 index 00000000..f804bfa4 --- /dev/null +++ b/server-rs/crates/module-runtime-story-compat/src/post_battle.rs @@ -0,0 +1,903 @@ +use serde_json::{Value, json}; +use shared_contracts::runtime_story::RuntimeStoryOptionView; + +use crate::{ + CONTINUE_ADVENTURE_FUNCTION_ID, build_static_runtime_story_option, + build_story_option_from_runtime_option, ensure_json_object, read_array_field, read_bool_field, + read_field, read_i32_field, read_object_field, read_optional_string_field, write_bool_field, + write_i32_field, write_null_field, write_string_field, +}; + +const WUXIA_FIRST_SCENE_ID: &str = "wuxia-bamboo-road"; +const WUXIA_FIRST_SCENE_NAME: &str = "竹林古道"; +const WUXIA_FIRST_SCENE_DESCRIPTION: &str = + "风过竹叶如刀鸣,窄道蜿蜒向深处,最适合藏伏毒物和游侠。"; +const XIANXIA_FIRST_SCENE_ID: &str = "xianxia-cloud-gate"; +const XIANXIA_FIRST_SCENE_NAME: &str = "云海仙门"; +const XIANXIA_FIRST_SCENE_DESCRIPTION: &str = + "云阶在脚下翻涌,门阙后方灵光不断,来客与守门异物都极显眼。"; + +#[derive(Clone, Debug)] +pub struct PostBattleFinalization { + pub story_text: String, + pub presentation_options: Vec, + pub saved_current_story: Value, +} + +/// 战斗终局统一由后端收口,前端只负责播放 presentation。 +pub fn finalize_post_battle_resolution( + game_state: &mut Value, + result_text: &str, + outcome: Option<&str>, + fallback_options: Vec, +) -> Option { + let outcome = outcome?; + if !is_terminal_battle_outcome(outcome) { + return None; + } + + if outcome == "defeat" { + return Some(finalize_defeat_revive(game_state, fallback_options)); + } + + if outcome == "victory" || outcome == "spar_complete" { + return Some(finalize_victory_or_spar( + game_state, + result_text, + fallback_options, + )); + } + + None +} + +pub fn is_terminal_battle_outcome(outcome: &str) -> bool { + matches!(outcome, "victory" | "spar_complete" | "defeat") +} + +/// 后端战斗后故事选项只返回可展示 DTO,不再让前端重算章节推进结果。 +pub fn resolve_post_battle_story_options(game_state: &Value) -> Vec { + build_scene_travel_options(game_state) +} + +fn finalize_victory_or_spar( + game_state: &mut Value, + result_text: &str, + fallback_options: Vec, +) -> PostBattleFinalization { + clear_post_battle_state(game_state); + let is_last_act = is_current_scene_act_last(game_state); + let next_act_state = if is_last_act { + None + } else { + resolve_next_scene_act_runtime_state(game_state) + }; + if let Some(next_act_state) = next_act_state { + write_current_scene_act_state(game_state, next_act_state); + } + + let deferred_options = if fallback_options.is_empty() { + build_scene_travel_options(game_state) + } else { + fallback_options + }; + let options = if is_last_act { + deferred_options.clone() + } else { + vec![continue_adventure_option()] + }; + let saved_current_story = if is_last_act { + build_plain_current_story(result_text, &deferred_options) + } else { + build_deferred_current_story( + result_text, + &deferred_options, + current_scene_act_state(game_state), + ) + }; + + PostBattleFinalization { + story_text: result_text.to_string(), + presentation_options: options, + saved_current_story, + } +} + +fn finalize_defeat_revive( + game_state: &mut Value, + _fallback_options: Vec, +) -> PostBattleFinalization { + let first_scene = resolve_first_scene(game_state); + write_first_scene(game_state, &first_scene); + write_null_field(game_state, "currentEncounter"); + write_bool_field(game_state, "npcInteractionActive", false); + ensure_json_object(game_state).insert("sceneHostileNpcs".to_string(), Value::Array(Vec::new())); + write_i32_field(game_state, "playerX", 0); + write_string_field(game_state, "playerFacing", "right"); + let player_max_hp = read_i32_field(game_state, "playerMaxHp") + .unwrap_or(1) + .max(1); + let player_max_mana = read_i32_field(game_state, "playerMaxMana") + .unwrap_or(0) + .max(0); + write_i32_field(game_state, "playerHp", player_max_hp); + write_i32_field(game_state, "playerMana", player_max_mana); + write_bool_field(game_state, "inBattle", false); + write_null_field(game_state, "currentBattleNpcId"); + write_null_field(game_state, "currentNpcBattleMode"); + 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"); + write_string_field(game_state, "animationState", "idle"); + write_string_field(game_state, "playerActionMode", "idle"); + ensure_json_object(game_state) + .insert("activeCombatEffects".to_string(), Value::Array(Vec::new())); + write_bool_field(game_state, "scrollWorld", false); + + if let Some(first_act_state) = + build_initial_scene_act_runtime_state(game_state, &first_scene.id) + { + write_current_scene_act_state(game_state, first_act_state); + } + ensure_first_scene_encounter_preview(game_state); + + let story_text = if first_scene.name.is_empty() { + "你在战斗中倒下,随后重新醒来。".to_string() + } else { + format!("你在战斗中倒下,随后在{}重新醒来。", first_scene.name) + }; + // 中文注释:败北复活后的正式选项必须基于复活后的首场景重新生成, + // 不能沿用战斗结算前旧场景的 fallback options。 + let deferred_options = build_scene_travel_options(game_state); + let saved_current_story = build_death_current_story(story_text.as_str(), &deferred_options); + + PostBattleFinalization { + story_text, + presentation_options: vec![continue_adventure_option()], + saved_current_story, + } +} + +fn clear_post_battle_state(game_state: &mut Value) { + write_null_field(game_state, "currentEncounter"); + write_bool_field(game_state, "npcInteractionActive", false); + ensure_json_object(game_state).insert("sceneHostileNpcs".to_string(), Value::Array(Vec::new())); + write_bool_field(game_state, "inBattle", false); + write_null_field(game_state, "currentBattleNpcId"); + write_null_field(game_state, "currentNpcBattleMode"); + 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"); + write_string_field(game_state, "animationState", "idle"); + write_string_field(game_state, "playerActionMode", "idle"); + ensure_json_object(game_state) + .insert("activeCombatEffects".to_string(), Value::Array(Vec::new())); + write_bool_field(game_state, "scrollWorld", false); +} + +fn continue_adventure_option() -> RuntimeStoryOptionView { + build_static_runtime_story_option(CONTINUE_ADVENTURE_FUNCTION_ID, "继续前进", "story") +} + +fn build_plain_current_story(text: &str, options: &[RuntimeStoryOptionView]) -> Value { + json!({ + "text": text, + "options": options.iter().map(build_story_option_from_runtime_option).collect::>(), + "streaming": false + }) +} + +fn build_deferred_current_story( + text: &str, + deferred_options: &[RuntimeStoryOptionView], + deferred_act_state: Option, +) -> Value { + let mut story = json!({ + "text": text, + "options": vec![build_story_option_from_runtime_option(&continue_adventure_option())], + "deferredOptions": deferred_options + .iter() + .map(build_story_option_from_runtime_option) + .collect::>(), + "streaming": false + }); + if let Some(deferred_act_state) = deferred_act_state { + if let Some(object) = story.as_object_mut() { + object.insert( + "deferredRuntimeState".to_string(), + json!({ + "storyEngineMemory": { + "currentSceneActState": deferred_act_state + } + }), + ); + } + } + story +} + +fn build_death_current_story(text: &str, deferred_options: &[RuntimeStoryOptionView]) -> Value { + let mut story = json!({ + "text": text, + "options": vec![build_story_option_from_runtime_option(&continue_adventure_option())], + "streaming": false + }); + if !deferred_options.is_empty() { + if let Some(object) = story.as_object_mut() { + object.insert( + "deferredOptions".to_string(), + Value::Array( + deferred_options + .iter() + .map(build_story_option_from_runtime_option) + .collect::>(), + ), + ); + } + } + story +} + +#[derive(Clone, Debug)] +struct RuntimeScene { + id: String, + name: String, + description: String, + image_src: String, + connected_scene_ids: Vec, + connections: Vec, + forward_scene_id: Option, + treasure_hints: Vec, + npcs: Vec, +} + +fn resolve_first_scene(game_state: &Value) -> RuntimeScene { + if let Some(profile) = read_object_field(game_state, "customWorldProfile") { + return build_custom_first_scene(profile); + } + + match read_optional_string_field(game_state, "worldType").as_deref() { + Some("XIANXIA") => RuntimeScene { + id: XIANXIA_FIRST_SCENE_ID.to_string(), + name: XIANXIA_FIRST_SCENE_NAME.to_string(), + description: XIANXIA_FIRST_SCENE_DESCRIPTION.to_string(), + image_src: read_object_field(game_state, "currentScenePreset") + .and_then(|scene| read_optional_string_field(scene, "imageSrc")) + .unwrap_or_default(), + connected_scene_ids: vec![ + "xianxia-floating-isle".to_string(), + "xianxia-celestial-corridor".to_string(), + "xianxia-star-vessel".to_string(), + ], + connections: vec![ + json!({ + "sceneId": "xianxia-celestial-corridor", + "relativePosition": "forward", + "summary": "沿主路继续深入前方区域" + }), + json!({ + "sceneId": "xianxia-floating-isle", + "relativePosition": "left", + "summary": "这里分出一条支路" + }), + json!({ + "sceneId": "xianxia-star-vessel", + "relativePosition": "right", + "summary": "这里还能转向另一条路" + }), + ], + forward_scene_id: Some("xianxia-celestial-corridor".to_string()), + treasure_hints: vec![ + "云阶尽头的灵符匣".to_string(), + "门阙阴影里的玉牌".to_string(), + ], + npcs: Vec::new(), + }, + _ => RuntimeScene { + id: WUXIA_FIRST_SCENE_ID.to_string(), + name: WUXIA_FIRST_SCENE_NAME.to_string(), + description: WUXIA_FIRST_SCENE_DESCRIPTION.to_string(), + image_src: read_object_field(game_state, "currentScenePreset") + .and_then(|scene| read_optional_string_field(scene, "imageSrc")) + .unwrap_or_default(), + connected_scene_ids: vec![ + "wuxia-mountain-gate".to_string(), + "wuxia-mist-woods".to_string(), + "wuxia-ferry-bridge".to_string(), + ], + connections: vec![ + json!({ + "sceneId": "wuxia-mountain-gate", + "relativePosition": "forward", + "summary": "沿主路继续深入前方区域" + }), + json!({ + "sceneId": "wuxia-mist-woods", + "relativePosition": "left", + "summary": "这里分出一条支路" + }), + json!({ + "sceneId": "wuxia-ferry-bridge", + "relativePosition": "right", + "summary": "这里还能转向另一条路" + }), + ], + forward_scene_id: Some("wuxia-mountain-gate".to_string()), + treasure_hints: vec!["竹根旁半埋的刀鞘".to_string(), "倒竹间的旧药囊".to_string()], + npcs: Vec::new(), + }, + } +} + +fn build_custom_first_scene(profile: &Value) -> RuntimeScene { + let camp = read_object_field(profile, "camp"); + let scene_id = camp + .and_then(|camp| read_optional_string_field(camp, "id")) + .unwrap_or_else(|| "custom-scene-camp".to_string()); + let scene_name = camp + .and_then(|camp| read_optional_string_field(camp, "name")) + .or_else(|| read_optional_string_field(profile, "name").map(|name| format!("{name}营地"))) + .unwrap_or_else(|| "开局营地".to_string()); + let description = camp + .and_then(|camp| read_optional_string_field(camp, "description")) + .or_else(|| read_optional_string_field(profile, "summary")) + .unwrap_or_else(|| "你重新回到了旅途起点。".to_string()); + let connections = if let Some(camp) = camp { + read_array_field(camp, "connections") + .into_iter() + .filter_map(|connection| { + let target_landmark_id = + read_optional_string_field(connection, "targetLandmarkId")?; + let scene_id = + custom_landmark_runtime_scene_id(profile, target_landmark_id.as_str())?; + Some(json!({ + "sceneId": scene_id, + "relativePosition": read_optional_string_field(connection, "relativePosition") + .unwrap_or_else(|| "forward".to_string()), + "summary": read_optional_string_field(connection, "summary").unwrap_or_default() + })) + }) + .collect::>() + } else { + Vec::new() + }; + let connected_scene_ids = connections + .iter() + .filter_map(|connection| read_optional_string_field(connection, "sceneId")) + .collect::>(); + let forward_scene_id = connections + .iter() + .find(|connection| { + read_optional_string_field(connection, "relativePosition").as_deref() == Some("forward") + }) + .and_then(|connection| read_optional_string_field(connection, "sceneId")) + .or_else(|| connected_scene_ids.first().cloned()); + + RuntimeScene { + id: "custom-scene-camp".to_string(), + name: scene_name, + description, + image_src: camp + .and_then(|camp| read_optional_string_field(camp, "imageSrc")) + .unwrap_or_default(), + connected_scene_ids, + connections, + forward_scene_id, + treasure_hints: vec![format!( + "{}地图残页", + read_optional_string_field(profile, "name").unwrap_or_else(|| "当前世界".to_string()) + )], + npcs: build_custom_scene_npcs_for_scene(profile, scene_id.as_str()), + } +} + +fn custom_landmark_runtime_scene_id(profile: &Value, landmark_id: &str) -> Option { + read_array_field(profile, "landmarks") + .into_iter() + .position(|landmark| { + read_optional_string_field(landmark, "id").as_deref() == Some(landmark_id) + }) + .map(|index| format!("custom-scene-landmark-{}", index + 1)) +} + +fn write_first_scene(game_state: &mut Value, scene: &RuntimeScene) { + ensure_json_object(game_state).insert( + "currentScenePreset".to_string(), + json!({ + "id": scene.id, + "name": scene.name, + "description": scene.description, + "imageSrc": scene.image_src, + "connectedSceneIds": scene.connected_scene_ids, + "connections": scene.connections, + "forwardSceneId": scene.forward_scene_id, + "treasureHints": scene.treasure_hints, + "npcs": scene.npcs, + }), + ); +} + +fn ensure_first_scene_encounter_preview(game_state: &mut Value) { + if read_bool_field(game_state, "inBattle").unwrap_or(false) { + return; + } + if !read_array_field(game_state, "sceneHostileNpcs").is_empty() + || read_field(game_state, "currentEncounter").is_some_and(|value| !value.is_null()) + { + return; + } + + let Some(profile) = read_object_field(game_state, "customWorldProfile") else { + return; + }; + let scene_id = read_object_field(game_state, "currentScenePreset") + .and_then(|scene| read_optional_string_field(scene, "id")); + let focus_npc_id = resolve_active_scene_act_focus_npc_id(profile, scene_id.as_deref()); + let Some(focus_npc_id) = focus_npc_id else { + return; + }; + let Some(npc) = find_custom_world_role(profile, focus_npc_id.as_str()) else { + return; + }; + + ensure_json_object(game_state).insert( + "currentEncounter".to_string(), + build_encounter_from_role(&npc, 12.0), + ); +} + +fn build_scene_travel_options(game_state: &Value) -> Vec { + let Some(current_scene) = read_object_field(game_state, "currentScenePreset") else { + return vec![build_static_runtime_story_option( + "idle_explore_forward", + "继续向前探索", + "story", + )]; + }; + let current_scene_id = read_optional_string_field(current_scene, "id"); + let mut options = read_array_field(current_scene, "connections") + .into_iter() + .filter_map(|connection| { + let scene_id = read_optional_string_field(connection, "sceneId")?; + if current_scene_id.as_deref() == Some(scene_id.as_str()) { + return None; + } + let relative_position = read_optional_string_field(connection, "relativePosition") + .unwrap_or_else(|| "forward".to_string()); + let scene_name = resolve_scene_name(game_state, scene_id.as_str()) + .unwrap_or_else(|| scene_id.clone()); + Some(RuntimeStoryOptionView { + payload: Some(json!({ "targetSceneId": scene_id })), + ..build_static_runtime_story_option( + "idle_travel_next_scene", + format!( + "{},前往{}", + direction_text(relative_position.as_str()), + scene_name + ) + .as_str(), + "story", + ) + }) + }) + .collect::>(); + + if options.is_empty() { + options.push(build_static_runtime_story_option( + "idle_explore_forward", + "继续向前探索", + "story", + )); + } + + options +} + +fn resolve_scene_name(game_state: &Value, scene_id: &str) -> Option { + if read_object_field(game_state, "currentScenePreset") + .and_then(|scene| read_optional_string_field(scene, "id")) + .as_deref() + == Some(scene_id) + { + return read_object_field(game_state, "currentScenePreset") + .and_then(|scene| read_optional_string_field(scene, "name")); + } + + let profile = read_object_field(game_state, "customWorldProfile")?; + if scene_id == "custom-scene-camp" + || read_object_field(profile, "camp") + .and_then(|camp| read_optional_string_field(camp, "id")) + .as_deref() + == Some(scene_id) + { + return read_object_field(profile, "camp") + .and_then(|camp| read_optional_string_field(camp, "name")) + .or_else(|| { + read_optional_string_field(profile, "name").map(|name| format!("{name}营地")) + }); + } + read_array_field(profile, "landmarks") + .into_iter() + .enumerate() + .find_map(|(index, landmark)| { + let runtime_id = format!("custom-scene-landmark-{}", index + 1); + if runtime_id == scene_id + || read_optional_string_field(landmark, "id").as_deref() == Some(scene_id) + { + read_optional_string_field(landmark, "name") + } else { + None + } + }) +} + +fn direction_text(relative_position: &str) -> &'static str { + match relative_position { + "north" => "向北走", + "south" => "向南走", + "east" => "向东走", + "west" => "向西走", + "left" => "向左走", + "right" => "向右走", + "back" => "往回走", + "up" => "向上走", + "down" => "向下走", + "inside" => "向内走", + "outside" => "向外走", + "portal" => "穿过通路", + _ => "向前走", + } +} + +fn resolve_next_scene_act_runtime_state(game_state: &Value) -> Option { + let profile = read_object_field(game_state, "customWorldProfile")?; + let scene_id = read_object_field(game_state, "currentScenePreset") + .and_then(|scene| read_optional_string_field(scene, "id")); + let scene_id_text = scene_id.as_deref()?; + let chapter = resolve_scene_chapter_blueprint(profile, Some(scene_id_text))?; + let acts = read_array_field(chapter, "acts"); + if acts.is_empty() { + return None; + } + let runtime_state = build_initial_scene_act_runtime_state(game_state, scene_id_text)?; + let current_act_id = read_optional_string_field(&runtime_state, "currentActId"); + let current_index = acts + .iter() + .position(|act| { + read_optional_string_field(act, "id").as_deref() == current_act_id.as_deref() + }) + .unwrap_or_else(|| { + read_i32_field(&runtime_state, "currentActIndex") + .unwrap_or(0) + .clamp(0, acts.len().saturating_sub(1) as i32) as usize + }); + let active_act = acts[current_index]; + let next_act = acts.get(current_index + 1)?; + let active_act_id = read_optional_string_field(active_act, "id")?; + let next_act_id = read_optional_string_field(next_act, "id")?; + let completed = append_unique_string( + read_string_array_field(&runtime_state, "completedActIds"), + active_act_id, + ); + let visited = append_unique_string( + read_string_array_field(&runtime_state, "visitedActIds"), + next_act_id.clone(), + ); + + Some(json!({ + "sceneId": read_optional_string_field(chapter, "sceneId") + .unwrap_or_else(|| scene_id_text.to_string()), + "chapterId": read_optional_string_field(chapter, "id").unwrap_or_default(), + "currentActId": next_act_id, + "currentActIndex": current_index + 1, + "completedActIds": completed, + "visitedActIds": visited, + })) +} + +fn current_scene_act_state(game_state: &Value) -> Option { + read_object_field(game_state, "storyEngineMemory") + .and_then(|memory| read_object_field(memory, "currentSceneActState")) + .cloned() +} + +fn is_current_scene_act_last(game_state: &Value) -> bool { + let Some(profile) = read_object_field(game_state, "customWorldProfile") else { + return false; + }; + let Some(scene_id) = read_object_field(game_state, "currentScenePreset") + .and_then(|scene| read_optional_string_field(scene, "id")) + else { + return false; + }; + let Some(chapter) = resolve_scene_chapter_blueprint(profile, Some(scene_id.as_str())) else { + return false; + }; + let acts = read_array_field(chapter, "acts"); + if acts.is_empty() { + return false; + } + let Some(runtime_state) = build_initial_scene_act_runtime_state(game_state, scene_id.as_str()) + else { + return false; + }; + let current_act_id = read_optional_string_field(&runtime_state, "currentActId"); + let current_index = acts + .iter() + .position(|act| { + read_optional_string_field(act, "id").as_deref() == current_act_id.as_deref() + }) + .unwrap_or_else(|| { + read_i32_field(&runtime_state, "currentActIndex") + .unwrap_or(0) + .clamp(0, acts.len().saturating_sub(1) as i32) as usize + }); + + current_index + 1 >= acts.len() +} + +fn write_current_scene_act_state(game_state: &mut Value, act_state: Value) { + let root = ensure_json_object(game_state); + let memory = root + .entry("storyEngineMemory".to_string()) + .or_insert_with(|| { + json!({ + "discoveredFactIds": [], + "activeThreadIds": [], + "resolvedScarIds": [], + "recentCarrierIds": [] + }) + }); + if !memory.is_object() { + *memory = json!({ + "discoveredFactIds": [], + "activeThreadIds": [], + "resolvedScarIds": [], + "recentCarrierIds": [] + }); + } + memory + .as_object_mut() + .expect("storyEngineMemory should be object") + .insert("currentSceneActState".to_string(), act_state); +} + +fn build_initial_scene_act_runtime_state(game_state: &Value, scene_id: &str) -> Option { + let profile = read_object_field(game_state, "customWorldProfile")?; + let chapter = resolve_scene_chapter_blueprint(profile, Some(scene_id))?; + let acts = read_array_field(chapter, "acts"); + if acts.is_empty() { + return None; + } + let runtime_state = current_scene_act_state(game_state); + if let Some(runtime_state) = runtime_state { + let chapter_id = read_optional_string_field(chapter, "id"); + let current_act_id = read_optional_string_field(&runtime_state, "currentActId"); + if read_optional_string_field(&runtime_state, "chapterId") == chapter_id + && acts.iter().any(|act| { + read_optional_string_field(act, "id").as_deref() == current_act_id.as_deref() + }) + { + return Some(json!({ + "sceneId": read_optional_string_field(&runtime_state, "sceneId") + .unwrap_or_else(|| read_optional_string_field(chapter, "sceneId").unwrap_or_default()), + "chapterId": read_optional_string_field(&runtime_state, "chapterId").unwrap_or_default(), + "currentActId": current_act_id.unwrap_or_default(), + "currentActIndex": read_i32_field(&runtime_state, "currentActIndex").unwrap_or(0).max(0), + "completedActIds": read_string_array_field(&runtime_state, "completedActIds"), + "visitedActIds": read_string_array_field(&runtime_state, "visitedActIds"), + })); + } + } + + let first_act = acts[0]; + let first_act_id = read_optional_string_field(first_act, "id")?; + Some(json!({ + "sceneId": read_optional_string_field(chapter, "sceneId").unwrap_or_else(|| scene_id.to_string()), + "chapterId": read_optional_string_field(chapter, "id").unwrap_or_default(), + "currentActId": first_act_id, + "currentActIndex": 0, + "completedActIds": [], + "visitedActIds": [read_optional_string_field(first_act, "id").unwrap_or_default()], + })) +} + +fn resolve_scene_chapter_blueprint<'a>( + profile: &'a Value, + scene_id: Option<&str>, +) -> Option<&'a Value> { + let scene_id = scene_id?; + read_array_field(profile, "sceneChapterBlueprints") + .into_iter() + .find(|chapter| does_scene_match_chapter(profile, scene_id, chapter)) +} + +fn does_scene_match_chapter(profile: &Value, scene_id: &str, chapter: &Value) -> bool { + let aliases = resolve_scene_aliases(profile, scene_id); + let mut chapter_scene_ids = Vec::new(); + if let Some(value) = read_optional_string_field(chapter, "sceneId") { + chapter_scene_ids.push(value); + } + chapter_scene_ids.extend(read_string_array_field(chapter, "linkedLandmarkIds")); + for act in read_array_field(chapter, "acts") { + if let Some(value) = read_optional_string_field(act, "sceneId") { + chapter_scene_ids.push(value); + } + } + aliases + .iter() + .any(|alias| chapter_scene_ids.iter().any(|id| id == alias)) +} + +fn resolve_scene_aliases(profile: &Value, scene_id: &str) -> Vec { + let mut aliases = vec![scene_id.to_string()]; + let camp_id = read_object_field(profile, "camp") + .and_then(|camp| read_optional_string_field(camp, "id")) + .unwrap_or_else(|| "custom-scene-camp".to_string()); + if scene_id == "custom-scene-camp" || scene_id == camp_id { + aliases.push(camp_id); + aliases.push("custom-scene-camp".to_string()); + } + for (index, landmark) in read_array_field(profile, "landmarks") + .into_iter() + .enumerate() + { + let runtime_scene_id = format!("custom-scene-landmark-{}", index + 1); + if scene_id == runtime_scene_id + || read_optional_string_field(landmark, "id").as_deref() == Some(scene_id) + { + aliases.push(runtime_scene_id); + if let Some(id) = read_optional_string_field(landmark, "id") { + aliases.push(id); + } + } + } + dedupe_strings(aliases) +} + +fn resolve_active_scene_act_focus_npc_id( + profile: &Value, + scene_id: Option<&str>, +) -> Option { + let chapter = resolve_scene_chapter_blueprint(profile, scene_id)?; + let act_state = read_array_field(chapter, "acts").first().copied()?; + read_optional_string_field(act_state, "oppositeNpcId") + .or_else(|| read_optional_string_field(act_state, "primaryNpcId")) + .or_else(|| { + read_array_field(act_state, "encounterNpcIds") + .first() + .and_then(|id| id.as_str().map(str::to_string)) + }) +} + +fn build_custom_scene_npcs_for_scene(profile: &Value, scene_id: &str) -> Vec { + let Some(chapter) = resolve_scene_chapter_blueprint(profile, Some(scene_id)) else { + return Vec::new(); + }; + let Some(first_act) = read_array_field(chapter, "acts").first().copied() else { + return Vec::new(); + }; + let mut role_ids = Vec::new(); + if let Some(id) = read_optional_string_field(first_act, "primaryNpcId") { + role_ids.push(id); + } + if let Some(id) = read_optional_string_field(first_act, "oppositeNpcId") { + role_ids.push(id); + } + role_ids.extend(read_string_array_field(first_act, "encounterNpcIds")); + dedupe_strings(role_ids) + .into_iter() + .filter_map(|role_id| find_custom_world_role(profile, role_id.as_str())) + .map(|role| build_scene_npc_from_role(&role)) + .collect() +} + +fn find_custom_world_role(profile: &Value, role_id: &str) -> Option { + read_array_field(profile, "storyNpcs") + .into_iter() + .chain(read_array_field(profile, "playableNpcs")) + .find(|role| { + read_optional_string_field(role, "id").as_deref() == Some(role_id) + || read_optional_string_field(role, "name").as_deref() == Some(role_id) + || read_optional_string_field(role, "title").as_deref() == Some(role_id) + }) + .cloned() +} + +fn build_scene_npc_from_role(role: &Value) -> Value { + json!({ + "id": read_optional_string_field(role, "id").unwrap_or_else(|| read_optional_string_field(role, "name").unwrap_or_else(|| "npc".to_string())), + "name": read_optional_string_field(role, "name").unwrap_or_else(|| "当前角色".to_string()), + "description": read_optional_string_field(role, "description").unwrap_or_default(), + "avatar": read_optional_string_field(role, "name") + .and_then(|name| name.chars().next().map(|ch| ch.to_string())) + .unwrap_or_else(|| "角".to_string()), + "role": read_optional_string_field(role, "role").unwrap_or_default(), + "title": read_optional_string_field(role, "title"), + "characterId": read_optional_string_field(role, "id"), + "initialAffinity": read_i32_field(role, "initialAffinity").unwrap_or(0), + "hostile": read_i32_field(role, "initialAffinity").unwrap_or(0) < 0, + "functions": ["trade", "fight", "spar", "help", "chat", "recruit", "gift"], + "recruitable": true, + "backstory": read_optional_string_field(role, "backstory"), + "personality": read_optional_string_field(role, "personality"), + "motivation": read_optional_string_field(role, "motivation"), + "combatStyle": read_optional_string_field(role, "combatStyle"), + "relationshipHooks": read_field(role, "relationshipHooks").cloned().unwrap_or_else(|| json!([])), + "tags": read_field(role, "tags").cloned().unwrap_or_else(|| json!([])), + "backstoryReveal": read_field(role, "backstoryReveal").cloned(), + "skills": read_field(role, "skills").cloned().unwrap_or_else(|| json!([])), + "initialItems": read_field(role, "initialItems").cloned().unwrap_or_else(|| json!([])), + "imageSrc": read_optional_string_field(role, "imageSrc"), + "visual": read_field(role, "visual").cloned(), + "narrativeProfile": read_field(role, "narrativeProfile").cloned(), + "levelProfile": read_field(role, "levelProfile").cloned(), + }) +} + +fn build_encounter_from_role(role: &Value, x_meters: f64) -> Value { + json!({ + "id": read_optional_string_field(role, "id").unwrap_or_else(|| read_optional_string_field(role, "name").unwrap_or_else(|| "npc".to_string())), + "kind": "npc", + "characterId": read_optional_string_field(role, "id"), + "npcName": read_optional_string_field(role, "name").unwrap_or_else(|| "当前角色".to_string()), + "npcDescription": read_optional_string_field(role, "description").unwrap_or_default(), + "npcAvatar": read_optional_string_field(role, "name") + .and_then(|name| name.chars().next().map(|ch| ch.to_string())) + .unwrap_or_else(|| "角".to_string()), + "context": read_optional_string_field(role, "role").unwrap_or_default(), + "xMeters": x_meters, + "initialAffinity": read_i32_field(role, "initialAffinity").unwrap_or(0), + "hostile": read_i32_field(role, "initialAffinity").unwrap_or(0) < 0, + "title": read_optional_string_field(role, "title"), + "backstory": read_optional_string_field(role, "backstory"), + "personality": read_optional_string_field(role, "personality"), + "motivation": read_optional_string_field(role, "motivation"), + "combatStyle": read_optional_string_field(role, "combatStyle"), + "relationshipHooks": read_field(role, "relationshipHooks").cloned().unwrap_or_else(|| json!([])), + "tags": read_field(role, "tags").cloned().unwrap_or_else(|| json!([])), + "backstoryReveal": read_field(role, "backstoryReveal").cloned(), + "skills": read_field(role, "skills").cloned().unwrap_or_else(|| json!([])), + "initialItems": read_field(role, "initialItems").cloned().unwrap_or_else(|| json!([])), + "imageSrc": read_optional_string_field(role, "imageSrc"), + "visual": read_field(role, "visual").cloned(), + "narrativeProfile": read_field(role, "narrativeProfile").cloned(), + "levelProfile": read_field(role, "levelProfile").cloned(), + }) +} + +fn read_string_array_field(value: &Value, key: &str) -> Vec { + read_field(value, key) + .and_then(Value::as_array) + .map(|items| { + items + .iter() + .filter_map(Value::as_str) + .map(str::trim) + .filter(|item| !item.is_empty()) + .map(str::to_string) + .collect() + }) + .unwrap_or_default() +} + +fn append_unique_string(mut values: Vec, value: String) -> Vec { + if !values.iter().any(|entry| entry == &value) { + values.push(value); + } + values +} + +fn dedupe_strings(values: Vec) -> Vec { + let mut result = Vec::new(); + for value in values { + if !value.trim().is_empty() && !result.iter().any(|entry| entry == &value) { + result.push(value); + } + } + result +} diff --git a/server-rs/crates/module-runtime-story-compat/src/prompt_context.rs b/server-rs/crates/module-runtime-story-compat/src/prompt_context.rs new file mode 100644 index 00000000..17d945d4 --- /dev/null +++ b/server-rs/crates/module-runtime-story-compat/src/prompt_context.rs @@ -0,0 +1,939 @@ +use serde_json::{Map, Value, json}; + +use crate::{ + current_encounter_id, current_encounter_name, read_array_field, read_bool_field, read_field, + read_i32_field, read_object_field, read_optional_string_field, +}; + +#[derive(Clone, Debug, Default)] +pub struct RuntimeStoryPromptContextExtras { + pub pending_scene_encounter: bool, + pub last_function_id: Option, + pub observe_signs_requested: bool, + pub recent_action_result: Option, + pub opening_camp_background: Option, + pub opening_camp_dialogue: Option, +} + +/// 基于后端持久化的运行时快照生成 LLM 所需 prompt context。 +/// 前端只能提交 session / choice 等轻量请求参数,正式上下文统一在这里投影。 +pub fn build_runtime_story_prompt_context( + game_state: &Value, + extras: RuntimeStoryPromptContextExtras, +) -> Value { + let scene = read_object_field(game_state, "currentScenePreset"); + let encounter = read_object_field(game_state, "currentEncounter"); + let npc_state = encounter.and_then(|_encounter| { + let npc_name = current_encounter_name(game_state); + let npc_id = current_encounter_id(game_state).unwrap_or_else(|| npc_name.clone()); + read_object_field(game_state, "npcStates").and_then(|states| { + states + .get(npc_id.as_str()) + .or_else(|| states.get(npc_name.as_str())) + }) + }); + let conversation_situation = infer_conversation_situation(game_state, &extras); + let conversation_pressure = infer_conversation_pressure(game_state, conversation_situation); + let encounter_narrative_profile = resolve_encounter_narrative_profile(game_state, encounter); + let story_engine_memory = read_object_field(game_state, "storyEngineMemory"); + let chapter_state = read_field(game_state, "chapterState") + .or_else(|| story_engine_memory.and_then(|memory| read_field(memory, "currentChapter"))); + let journey_beat = + story_engine_memory.and_then(|memory| read_field(memory, "currentJourneyBeat")); + let active_thread_ids = read_string_array( + story_engine_memory.and_then(|memory| read_field(memory, "activeThreadIds")), + ) + .into_iter() + .take(4) + .collect::>(); + let active_thread_ids = if active_thread_ids.is_empty() { + read_string_array( + encounter_narrative_profile.and_then(|profile| read_field(profile, "relatedThreadIds")), + ) + .into_iter() + .take(4) + .collect::>() + } else { + active_thread_ids + }; + + let recruited = npc_state + .and_then(|state| read_bool_field(state, "recruited")) + .unwrap_or(false); + let affinity = npc_state.and_then(|state| read_i32_field(state, "affinity")); + let disclosure = affinity.map(|value| disclosure_stage(value, recruited)); + + let mut context = Map::new(); + insert_base_context(&mut context, game_state, scene, &extras); + insert_encounter_context( + &mut context, + game_state, + encounter, + npc_state, + encounter_narrative_profile, + affinity, + disclosure, + recruited, + ); + insert_narrative_context( + &mut context, + game_state, + story_engine_memory, + chapter_state, + journey_beat, + active_thread_ids, + conversation_situation, + conversation_pressure, + ); + context.insert( + "openingCampBackground".to_string(), + extras.opening_camp_background.into(), + ); + context.insert( + "openingCampDialogue".to_string(), + extras.opening_camp_dialogue.into(), + ); + + Value::Object(context) +} + +fn insert_base_context( + context: &mut Map, + game_state: &Value, + scene: Option<&Value>, + extras: &RuntimeStoryPromptContextExtras, +) { + context.insert( + "playerHp".to_string(), + read_i32_field(game_state, "playerHp").unwrap_or(0).into(), + ); + context.insert( + "playerMaxHp".to_string(), + read_i32_field(game_state, "playerMaxHp") + .unwrap_or(1) + .max(1) + .into(), + ); + context.insert( + "playerMana".to_string(), + read_i32_field(game_state, "playerMana").unwrap_or(0).into(), + ); + context.insert( + "playerMaxMana".to_string(), + read_i32_field(game_state, "playerMaxMana") + .unwrap_or(1) + .max(1) + .into(), + ); + context.insert( + "inBattle".to_string(), + read_bool_field(game_state, "inBattle") + .unwrap_or(false) + .into(), + ); + context.insert( + "playerX".to_string(), + read_i32_field(game_state, "playerX").unwrap_or(0).into(), + ); + context.insert( + "playerFacing".to_string(), + read_optional_string_field(game_state, "playerFacing") + .unwrap_or_else(|| "right".to_string()) + .into(), + ); + context.insert( + "playerAnimation".to_string(), + read_optional_string_field(game_state, "animationState") + .unwrap_or_else(|| "idle".to_string()) + .into(), + ); + context.insert( + "skillCooldowns".to_string(), + read_field(game_state, "playerSkillCooldowns") + .cloned() + .unwrap_or_else(|| json!({})), + ); + context.insert( + "sceneId".to_string(), + scene + .and_then(|scene| read_optional_string_field(scene, "id")) + .into(), + ); + context.insert( + "sceneName".to_string(), + scene + .and_then(|scene| read_optional_string_field(scene, "name")) + .or_else(|| read_optional_string_field(game_state, "currentScene")) + .into(), + ); + context.insert( + "sceneDescription".to_string(), + build_scene_description(game_state, extras.observe_signs_requested).into(), + ); + context.insert( + "pendingSceneEncounter".to_string(), + extras.pending_scene_encounter.into(), + ); + context.insert( + "lastFunctionId".to_string(), + extras.last_function_id.clone().into(), + ); + context.insert( + "observeSignsRequested".to_string(), + extras.observe_signs_requested.into(), + ); + context.insert( + "recentActionResult".to_string(), + extras.recent_action_result.clone().into(), + ); + context.insert( + "lastObserveSignsReport".to_string(), + resolve_last_observe_report(game_state, scene).into(), + ); +} + +#[allow(clippy::too_many_arguments)] +fn insert_encounter_context( + context: &mut Map, + game_state: &Value, + encounter: Option<&Value>, + npc_state: Option<&Value>, + encounter_narrative_profile: Option<&Value>, + affinity: Option, + disclosure: Option<&'static str>, + recruited: bool, +) { + context.insert( + "encounterKind".to_string(), + encounter + .and_then(|encounter| read_optional_string_field(encounter, "kind")) + .into(), + ); + context.insert( + "encounterName".to_string(), + encounter.and_then(read_encounter_name).into(), + ); + context.insert( + "encounterDescription".to_string(), + encounter + .and_then(|encounter| { + read_optional_string_field(encounter, "npcDescription") + .or_else(|| read_optional_string_field(encounter, "description")) + }) + .into(), + ); + context.insert( + "encounterContext".to_string(), + encounter + .and_then(|encounter| read_optional_string_field(encounter, "context")) + .into(), + ); + context.insert( + "encounterId".to_string(), + current_encounter_id(game_state).into(), + ); + context.insert( + "encounterCharacterId".to_string(), + encounter + .and_then(|encounter| read_optional_string_field(encounter, "characterId")) + .into(), + ); + context.insert( + "encounterGender".to_string(), + encounter + .and_then(|encounter| read_optional_string_field(encounter, "gender")) + .into(), + ); + context.insert( + "encounterCustomProfile".to_string(), + encounter.cloned().unwrap_or(Value::Null), + ); + context.insert("encounterAffinity".to_string(), affinity.into()); + context.insert( + "encounterAffinityText".to_string(), + affinity.map(describe_npc_affinity).into(), + ); + context.insert( + "encounterStanceProfile".to_string(), + npc_state + .and_then(|state| read_field(state, "stanceProfile")) + .cloned() + .unwrap_or(Value::Null), + ); + context.insert( + "encounterConversationStyle".to_string(), + encounter + .and_then(|encounter| read_field(encounter, "conversationStyle")) + .cloned() + .unwrap_or_else(default_conversation_style), + ); + context.insert("encounterDisclosureStage".to_string(), disclosure.into()); + context.insert( + "encounterWarmthStage".to_string(), + affinity.map(|value| warmth_stage(value, recruited)).into(), + ); + context.insert( + "encounterAnswerMode".to_string(), + disclosure.map(answer_mode).into(), + ); + context.insert( + "encounterAllowedTopics".to_string(), + disclosure.map(allowed_topics).into(), + ); + context.insert( + "encounterBlockedTopics".to_string(), + disclosure.map(blocked_topics).into(), + ); + context.insert( + "isFirstMeaningfulContact".to_string(), + is_first_meaningful_contact(npc_state).into(), + ); + context.insert( + "firstContactRelationStance".to_string(), + first_contact_relation_stance(npc_state).into(), + ); + context.insert( + "encounterNarrativeProfile".to_string(), + encounter_narrative_profile.cloned().unwrap_or(Value::Null), + ); + context.insert( + "encounterRelationshipSummary".to_string(), + encounter + .and_then(|encounter| read_optional_string_field(encounter, "characterId")) + .and_then(|character_id| read_character_chat_summary(game_state, character_id.as_str())) + .into(), + ); +} + +#[allow(clippy::too_many_arguments)] +fn insert_narrative_context( + context: &mut Map, + game_state: &Value, + story_engine_memory: Option<&Value>, + chapter_state: Option<&Value>, + journey_beat: Option<&Value>, + active_thread_ids: Vec, + conversation_situation: &str, + conversation_pressure: &str, +) { + context.insert( + "conversationSituation".to_string(), + conversation_situation.into(), + ); + context.insert( + "conversationPressure".to_string(), + conversation_pressure.into(), + ); + context.insert( + "recentSharedEvent".to_string(), + build_recent_shared_event(game_state) + .unwrap_or_else(|| describe_conversation_situation(conversation_situation).to_string()) + .into(), + ); + context.insert( + "talkPriority".to_string(), + describe_conversation_talk_priority(conversation_situation).into(), + ); + context.insert("visibilitySlice".to_string(), Value::Null); + context.insert("sceneNarrativeDirective".to_string(), Value::Null); + context.insert( + "campaignState".to_string(), + read_field(game_state, "campaignState") + .or_else(|| story_engine_memory.and_then(|memory| read_field(memory, "campaignState"))) + .cloned() + .unwrap_or(Value::Null), + ); + context.insert( + "actState".to_string(), + story_engine_memory + .and_then(|memory| read_field(memory, "actState")) + .cloned() + .unwrap_or(Value::Null), + ); + context.insert( + "chapterState".to_string(), + chapter_state.cloned().unwrap_or(Value::Null), + ); + context.insert( + "journeyBeat".to_string(), + journey_beat.cloned().unwrap_or(Value::Null), + ); + context.insert("goalStack".to_string(), Value::Null); + context.insert( + "currentCampEvent".to_string(), + story_engine_memory + .and_then(|memory| read_field(memory, "currentCampEvent")) + .cloned() + .unwrap_or(Value::Null), + ); + context.insert( + "setpieceDirective".to_string(), + story_engine_memory + .and_then(|memory| read_field(memory, "currentSetpieceDirective")) + .cloned() + .unwrap_or(Value::Null), + ); + context.insert("activeScenarioPack".to_string(), Value::Null); + context.insert("activeCampaignPack".to_string(), Value::Null); + context.insert( + "knowledgeFacts".to_string(), + read_object_field(game_state, "customWorldProfile") + .and_then(|profile| read_field(profile, "knowledgeFacts")) + .cloned() + .unwrap_or_else(|| json!([])), + ); + context.insert("activeThreadIds".to_string(), active_thread_ids.into()); + context.insert( + "companionArcStates".to_string(), + story_engine_memory + .and_then(|memory| read_field(memory, "companionArcStates")) + .cloned() + .unwrap_or_else(|| json!([])), + ); + context.insert( + "companionResolutions".to_string(), + story_engine_memory + .and_then(|memory| read_field(memory, "companionResolutions")) + .cloned() + .unwrap_or_else(|| json!([])), + ); + context.insert( + "consequenceLedger".to_string(), + story_engine_memory + .and_then(|memory| read_field(memory, "consequenceLedger")) + .cloned() + .unwrap_or_else(|| json!([])), + ); + context.insert( + "authorialConstraintPack".to_string(), + story_engine_memory + .and_then(|memory| read_field(memory, "authorialConstraintPack")) + .cloned() + .unwrap_or(Value::Null), + ); + context.insert( + "playerStyleProfile".to_string(), + story_engine_memory + .and_then(|memory| read_field(memory, "playerStyleProfile")) + .cloned() + .unwrap_or(Value::Null), + ); + context.insert( + "recentCompanionReactions".to_string(), + story_engine_memory + .and_then(|memory| read_field(memory, "recentCompanionReactions")) + .cloned() + .unwrap_or_else(|| json!([])), + ); + context.insert("recentCarrierEchoes".to_string(), json!([])); + context.insert( + "recentWorldMutations".to_string(), + story_engine_memory + .and_then(|memory| read_field(memory, "worldMutations")) + .cloned() + .unwrap_or_else(|| json!([])), + ); + context.insert( + "recentFactionTensionStates".to_string(), + story_engine_memory + .and_then(|memory| read_field(memory, "factionTensionStates")) + .cloned() + .unwrap_or_else(|| json!([])), + ); + context.insert( + "recentChronicleSummary".to_string(), + build_recent_chronicle_summary(game_state).into(), + ); + context.insert( + "narrativeQaReport".to_string(), + story_engine_memory + .and_then(|memory| read_field(memory, "narrativeQaReport")) + .cloned() + .unwrap_or(Value::Null), + ); + context.insert( + "releaseGateReport".to_string(), + story_engine_memory + .and_then(|memory| read_field(memory, "releaseGateReport")) + .cloned() + .unwrap_or(Value::Null), + ); + context.insert( + "simulationRunResults".to_string(), + story_engine_memory + .and_then(|memory| read_field(memory, "simulationRunResults")) + .cloned() + .unwrap_or_else(|| json!([])), + ); + context.insert( + "branchBudgetPressure".to_string(), + story_engine_memory + .and_then(|memory| read_field(memory, "branchBudgetStatus")) + .and_then(|status| read_optional_string_field(status, "pressure")) + .into(), + ); + context.insert( + "partyRelationshipNotes".to_string(), + build_party_relationship_notes(game_state).into(), + ); + context.insert( + "customWorldProfile".to_string(), + read_field(game_state, "customWorldProfile") + .cloned() + .unwrap_or(Value::Null), + ); +} + +fn build_scene_description(game_state: &Value, observe_signs_requested: bool) -> String { + let scene = read_object_field(game_state, "currentScenePreset"); + let base = scene + .and_then(|scene| read_optional_string_field(scene, "description")) + .or_else(|| read_optional_string_field(game_state, "sceneDescription")) + .unwrap_or_else(|| "周围气氛仍在继续变化。".to_string()); + let mutation_text = + scene.and_then(|scene| read_optional_string_field(scene, "mutationStateText")); + let pressure_text = scene + .and_then(|scene| read_optional_string_field(scene, "currentPressureLevel")) + .and_then(|level| describe_scene_pressure_level(level.as_str()).map(str::to_string)); + let entity_catalog = if observe_signs_requested { + Some(build_scene_entity_catalog_text(scene)) + } else { + None + }; + + [ + Some(base), + mutation_text.map(|text| format!("最新世界变化:{text}")), + pressure_text.map(|text| format!("当前区域压力等级:{text}")), + entity_catalog, + ] + .into_iter() + .flatten() + .filter(|text| !text.trim().is_empty()) + .collect::>() + .join("\n") +} + +fn build_scene_entity_catalog_text(scene: Option<&Value>) -> String { + let Some(scene) = scene else { + return "当前可观察实体池:暂无显式实体。".to_string(); + }; + let npc_names = read_array_field(scene, "npcs") + .into_iter() + .filter_map(read_encounter_name) + .take(8) + .collect::>(); + let treasure_hints = read_array_field(scene, "treasureHints") + .into_iter() + .filter_map(|item| { + read_optional_string_field(item, "title") + .or_else(|| read_optional_string_field(item, "name")) + .or_else(|| read_optional_string_field(item, "hint")) + }) + .take(6) + .collect::>(); + let mut lines = vec!["当前可观察实体池:".to_string()]; + if !npc_names.is_empty() { + lines.push(format!("- 角色:{}", npc_names.join("、"))); + } + if !treasure_hints.is_empty() { + lines.push(format!("- 线索/物件:{}", treasure_hints.join("、"))); + } + if lines.len() == 1 { + lines.push("- 暂无显式实体。".to_string()); + } + lines.join("\n") +} + +fn resolve_last_observe_report(game_state: &Value, scene: Option<&Value>) -> Option { + let current_scene_id = scene.and_then(|scene| read_optional_string_field(scene, "id")); + let last_scene_id = read_optional_string_field(game_state, "lastObserveSignsSceneId"); + if current_scene_id.is_some() && current_scene_id == last_scene_id { + return read_optional_string_field(game_state, "lastObserveSignsReport"); + } + None +} + +fn infer_conversation_situation( + game_state: &Value, + extras: &RuntimeStoryPromptContextExtras, +) -> &'static str { + if read_bool_field(game_state, "inBattle").unwrap_or(false) { + return "shared_danger_coordination"; + } + if extras.last_function_id.as_deref() == Some("story_opening_camp_dialogue") { + return "camp_first_contact"; + } + let encounter = read_object_field(game_state, "currentEncounter"); + if encounter + .and_then(|encounter| read_optional_string_field(encounter, "specialBehavior")) + .as_deref() + == Some("camp_companion") + && extras + .opening_camp_dialogue + .as_deref() + .is_some_and(|text| !text.trim().is_empty()) + { + return "camp_followup"; + } + let recent_text = recent_story_text(game_state, 6); + if contains_any( + recent_text.as_str(), + &["击败", "怪物", "战斗", "切磋", "交手", "脱身"], + ) { + return "post_battle_breath"; + } + if extras.last_function_id.as_deref() == Some("npc_chat") { + return "private_followup"; + } + "first_contact_cautious" +} + +fn infer_conversation_pressure(game_state: &Value, situation: &str) -> &'static str { + let hp = read_i32_field(game_state, "playerHp").unwrap_or(0); + let max_hp = read_i32_field(game_state, "playerMaxHp") + .unwrap_or(1) + .max(1); + if read_bool_field(game_state, "inBattle").unwrap_or(false) || hp * 100 < max_hp * 35 { + return "high"; + } + match situation { + "post_battle_breath" | "shared_danger_coordination" => "medium", + "camp_first_contact" | "camp_followup" => "low", + _ => "medium", + } +} + +fn build_recent_shared_event(game_state: &Value) -> Option { + let recent_text = recent_story_text(game_state, 6); + if contains_any( + recent_text.as_str(), + &["击败", "怪物", "战斗", "切磋", "交手", "脱身"], + ) { + return Some("你们刚经历过一场交锋或切磋,空气里的紧张感还没有完全散去。".to_string()); + } + if contains_any(recent_text.as_str(), &["携手", "相助", "帮你", "并肩"]) { + return Some("你们刚并肩配合过一次,彼此之间的距离感稍微淡了一些。".to_string()); + } + None +} + +fn describe_conversation_situation(situation: &str) -> &'static str { + match situation { + "camp_first_contact" => { + "这是营地里第一次真正静下来对话的时刻,语气要保持谨慎、观察和轻微试探。" + } + "camp_followup" => "营地里的第一轮试探已经发生过了,这一轮应当顺着刚才的话头稍微往深处接。", + "post_battle_breath" => "一场交锋刚结束,眼前危险稍缓,但双方都还带着余悸和紧绷。", + "shared_danger_coordination" => "危险还没过去,对话应当短、准、直接,优先服务眼前判断。", + "private_followup" => "这已经不是严格意义上的初见,更适合作为刚才未说完那句话的延续。", + _ => "双方才刚真正对上话,此刻仍在判断彼此能信到什么程度。", + } +} + +fn describe_conversation_talk_priority(situation: &str) -> &'static str { + match situation { + "camp_first_contact" => "优先写眼前印象、彼此态度和营地气氛,不要一上来就把动机讲透。", + "camp_followup" => "先接住上一轮还没说透的话头,再决定要不要继续往下追问。", + "post_battle_breath" => "先谈刚刚那次交锋以及彼此的判断,再视情况往更深处推进。", + "shared_danger_coordination" => "先说最有用的判断、危险和下一步,不要扩成大段背景说明。", + "private_followup" => "承接当前话头和关系变化,不要把对话又写回刚见面时的节奏。", + _ => "先试探态度和现场判断,不要急着把来意和秘密一次摊开。", + } +} + +fn recent_story_text(game_state: &Value, limit: usize) -> String { + read_array_field(game_state, "storyHistory") + .into_iter() + .rev() + .take(limit) + .collect::>() + .into_iter() + .rev() + .filter_map(|entry| read_optional_string_field(entry, "text")) + .collect::>() + .join("\n") +} + +fn resolve_encounter_narrative_profile<'a>( + game_state: &'a Value, + encounter: Option<&'a Value>, +) -> Option<&'a Value> { + let encounter = encounter?; + if let Some(profile) = read_field(encounter, "narrativeProfile") { + return Some(profile); + } + let profile = read_object_field(game_state, "customWorldProfile")?; + let encounter_id = read_optional_string_field(encounter, "id"); + let encounter_name = read_encounter_name(encounter); + ["storyNpcs", "playableNpcs"] + .into_iter() + .flat_map(|field| read_array_field(profile, field)) + .find(|npc| { + let npc_id = read_optional_string_field(npc, "id"); + let npc_name = read_optional_string_field(npc, "name"); + npc_id.is_some() && npc_id == encounter_id + || npc_name.is_some() && npc_name == encounter_name + }) + .and_then(|npc| read_field(npc, "narrativeProfile")) +} + +fn build_recent_chronicle_summary(game_state: &Value) -> Option { + let memory = read_object_field(game_state, "storyEngineMemory"); + let chapter_summary = read_field(game_state, "chapterState") + .or_else(|| memory.and_then(|memory| read_field(memory, "currentChapter"))) + .and_then(|chapter| read_optional_string_field(chapter, "chapterSummary")); + let chronicle_lines = memory + .and_then(|memory| read_field(memory, "chronicle")) + .and_then(Value::as_array) + .map(|entries| { + entries + .iter() + .rev() + .take(4) + .collect::>() + .into_iter() + .rev() + .filter_map(|entry| { + let title = read_optional_string_field(entry, "title").unwrap_or_default(); + let summary = read_optional_string_field(entry, "summary").unwrap_or_default(); + let text = [title, summary] + .into_iter() + .filter(|text| !text.trim().is_empty()) + .collect::>() + .join(":"); + (!text.trim().is_empty()).then_some(text) + }) + .collect::>() + }) + .unwrap_or_default(); + let text = chapter_summary + .into_iter() + .chain(chronicle_lines) + .collect::>() + .join("\n"); + (!text.trim().is_empty()).then_some(text) +} + +fn build_party_relationship_notes(game_state: &Value) -> Option { + let mut lines = Vec::new(); + for (field, role_label) in [("companions", "当前同行"), ("roster", "营地待命")] { + for companion in read_array_field(game_state, field) { + let Some(character_id) = read_optional_string_field(companion, "characterId") else { + continue; + }; + let Some(summary) = read_character_chat_summary(game_state, character_id.as_str()) + else { + continue; + }; + let name = resolve_character_name(game_state, character_id.as_str()) + .unwrap_or_else(|| character_id.clone()); + lines.push(format!("- {name}({role_label}):{summary}")); + } + } + (!lines.is_empty()).then_some(lines.join("\n")) +} + +fn resolve_character_name(game_state: &Value, character_id: &str) -> Option { + let profile = read_object_field(game_state, "customWorldProfile")?; + ["playableNpcs", "storyNpcs"] + .into_iter() + .flat_map(|field| read_array_field(profile, field)) + .find(|npc| read_optional_string_field(npc, "id").as_deref() == Some(character_id)) + .and_then(|npc| read_optional_string_field(npc, "name")) +} + +fn read_character_chat_summary(game_state: &Value, character_id: &str) -> Option { + read_object_field(game_state, "characterChats") + .and_then(|chats| chats.get(character_id)) + .and_then(|record| read_optional_string_field(record, "summary")) + .filter(|text| !text.trim().is_empty()) +} + +fn is_first_meaningful_contact(npc_state: Option<&Value>) -> bool { + let Some(npc_state) = npc_state else { + return false; + }; + !read_bool_field(npc_state, "firstMeaningfulContactResolved").unwrap_or(false) + && read_i32_field(npc_state, "chattedCount").unwrap_or(0) <= 0 +} + +fn first_contact_relation_stance(npc_state: Option<&Value>) -> Option { + let npc_state = npc_state?; + read_object_field(npc_state, "relationState") + .and_then(|state| read_optional_string_field(state, "stance")) + .filter(|stance| { + matches!( + stance.as_str(), + "guarded" | "neutral" | "cooperative" | "bonded" + ) + }) +} + +fn disclosure_stage(affinity: i32, recruited: bool) -> &'static str { + if recruited || affinity >= 50 { + "deep" + } else if affinity >= 30 { + "honest" + } else if affinity >= 15 { + "partial" + } else { + "guarded" + } +} + +fn warmth_stage(affinity: i32, recruited: bool) -> &'static str { + if recruited || affinity >= 50 { + "warm" + } else if affinity >= 30 { + "cooperative" + } else if affinity >= 15 { + "neutral" + } else { + "distant" + } +} + +fn answer_mode(stage: &str) -> &'static str { + match stage { + "deep" => "candid", + "honest" => "true_but_incomplete", + "partial" => "half_truth", + _ => "situational_only", + } +} + +fn allowed_topics(stage: &str) -> Vec<&'static str> { + match stage { + "guarded" => vec!["眼前危险", "现场判断", "对玩家的态度", "模糊钩子"], + "partial" => vec!["眼前危险", "表层理由", "试探性解释", "有限背景"], + "honest" => vec!["真实动机的轮廓", "旧事碎片", "真正目标的一部分"], + _ => vec!["真实来历", "真正目标", "旧事恩怨", "未说完的核心问题"], + } +} + +fn blocked_topics(stage: &str) -> Vec<&'static str> { + match stage { + "guarded" => vec!["完整来历", "真正目标", "旧事全貌"], + "partial" => vec!["完整来历", "旧事全貌"], + "honest" => vec!["把全部底牌一次说完"], + _ => Vec::new(), + } +} + +fn describe_npc_affinity(affinity: i32) -> String { + if affinity >= 90 { + "高度信赖,言谈间明显亲近。".to_string() + } else if affinity >= 60 { + "已经建立稳固信任,愿意进一步合作。".to_string() + } else if affinity >= 30 { + "态度明显友善,也更愿意正常交流。".to_string() + } else if affinity >= 15 { + "戒备开始松动,愿意试探性配合。".to_string() + } else if affinity >= 0 { + "仍保持明显距离,只会给出谨慎而有限的回应。".to_string() + } else { + "关系降到冰点,对玩家几乎不保留善意。".to_string() + } +} + +fn default_conversation_style() -> Value { + json!({ + "guardStyle": "measured", + "warmStyle": "steady", + "truthStyle": "fragmented", + }) +} + +fn describe_scene_pressure_level(value: &str) -> Option<&'static str> { + match value { + "low" => Some("低"), + "medium" => Some("中"), + "high" => Some("高"), + "extreme" => Some("极高"), + _ => None, + } +} + +fn read_encounter_name(value: &Value) -> Option { + read_optional_string_field(value, "npcName") + .or_else(|| read_optional_string_field(value, "name")) +} + +fn read_string_array(value: Option<&Value>) -> Vec { + value + .and_then(Value::as_array) + .map(|items| { + items + .iter() + .filter_map(Value::as_str) + .map(str::trim) + .filter(|text| !text.is_empty()) + .map(ToOwned::to_owned) + .collect::>() + }) + .unwrap_or_default() +} + +fn contains_any(text: &str, keywords: &[&str]) -> bool { + keywords.iter().any(|keyword| text.contains(keyword)) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn prompt_context_projects_npc_directive_from_server_state() { + let context = build_runtime_story_prompt_context( + &json!({ + "worldType": "WUXIA", + "playerHp": 20, + "playerMaxHp": 100, + "playerMana": 6, + "playerMaxMana": 20, + "inBattle": false, + "currentScenePreset": { + "id": "scene-1", + "name": "旧驿道", + "description": "山风压着尘土。", + "mutationStateText": "路边新添了打斗痕迹。", + "currentPressureLevel": "high" + }, + "currentEncounter": { + "id": "npc-1", + "kind": "npc", + "npcName": "守路人", + "npcDescription": "守在路口的人。" + }, + "npcStates": { + "npc-1": { + "affinity": 18, + "chattedCount": 0, + "recruited": false, + "firstMeaningfulContactResolved": false, + "relationState": { "stance": "guarded" } + } + }, + "storyHistory": [{ + "text": "你刚从一场战斗里脱身。", + "historyRole": "result" + }] + }), + RuntimeStoryPromptContextExtras { + last_function_id: Some("npc_chat".to_string()), + ..RuntimeStoryPromptContextExtras::default() + }, + ); + + assert_eq!(context["sceneName"], json!("旧驿道")); + assert_eq!(context["encounterDisclosureStage"], json!("partial")); + assert_eq!(context["conversationPressure"], json!("high")); + assert_eq!(context["firstContactRelationStance"], json!("guarded")); + assert!( + context["sceneDescription"] + .as_str() + .is_some_and(|text| text.contains("最新世界变化")) + ); + } +} diff --git a/server-rs/crates/module-runtime-story-compat/src/story_engine.rs b/server-rs/crates/module-runtime-story-compat/src/story_engine.rs new file mode 100644 index 00000000..7e0c0db0 --- /dev/null +++ b/server-rs/crates/module-runtime-story-compat/src/story_engine.rs @@ -0,0 +1,1569 @@ +use serde_json::{Map, Value, json}; + +use crate::{ + ensure_json_object, format_now_rfc3339, read_array_field, read_bool_field, read_field, + read_i32_field, read_object_field, read_optional_string_field, +}; + +const CHAPTER_STAGE_OPENING: &str = "opening"; +const CHAPTER_STAGE_EXPANSION: &str = "expansion"; +const CHAPTER_STAGE_TURNING_POINT: &str = "turning_point"; +const CHAPTER_STAGE_CLIMAX: &str = "climax"; +const CHAPTER_STAGE_AFTERMATH: &str = "aftermath"; + +/// 将运行时动作结算后的叙事记忆投影到正式快照。 +/// +/// 中文注释:这里是从前端 story engine hook 迁出的最小确定性状态机。 +/// 它只依赖动作前后 JSON 快照和本轮 functionId,不访问 HTTP、LLM 或外部资源。 +pub fn project_story_engine_after_action( + previous_state: &Value, + game_state: &mut Value, + action_text: &str, + result_text: &str, + function_id: &str, + battle_outcome: Option<&str>, +) { + let mut memory = read_object_field(game_state, "storyEngineMemory") + .cloned() + .unwrap_or_else(|| Value::Object(Map::new())); + ensure_memory_defaults(&mut memory); + + let signals = collect_story_signals( + previous_state, + game_state, + &memory, + action_text, + function_id, + battle_outcome, + ); + apply_thread_signal_updates(game_state, &mut memory, &signals); + ensure_scene_chapter_state(game_state, &mut memory); + + let previous_chapter = read_object_field(game_state, "chapterState") + .or_else(|| read_object_field(&memory, "currentChapter")) + .cloned(); + let chapter_state = + resolve_current_chapter_state(game_state, &memory, previous_chapter.as_ref()); + ensure_json_object(game_state).insert("chapterState".to_string(), chapter_state.clone()); + ensure_json_object(&mut memory).insert("currentChapter".to_string(), chapter_state.clone()); + + let journey_beat = resolve_current_journey_beat(game_state, &memory, &chapter_state); + let journey_beat_id = read_optional_string_field(&journey_beat, "id") + .unwrap_or_else(|| "journey:default".to_string()); + let memory_root = ensure_json_object(&mut memory); + memory_root.insert("currentJourneyBeatId".to_string(), json!(journey_beat_id)); + memory_root.insert("currentJourneyBeat".to_string(), journey_beat.clone()); + + let new_mutations = resolve_world_mutations(game_state, &memory, &signals, &chapter_state); + let world_mutations = append_world_mutations(&memory, new_mutations); + ensure_json_object(&mut memory).insert( + "worldMutations".to_string(), + Value::Array(world_mutations.clone()), + ); + apply_world_mutations_to_game_state(game_state, &world_mutations); + + let reactions = build_companion_reactions(game_state, &signals, action_text); + apply_companion_reactions_to_stance(game_state, &reactions); + append_recent_companion_reactions(&mut memory, reactions); + + let chronicle = append_chronicle_entries(&memory, &chapter_state, &world_mutations); + ensure_json_object(&mut memory) + .insert("chronicle".to_string(), Value::Array(chronicle.clone())); + ensure_json_object(&mut memory).insert( + "continueGameDigest".to_string(), + Value::String(build_continue_digest( + &chapter_state, + result_text, + &chronicle, + )), + ); + ensure_json_object(&mut memory).insert( + "saveMigrationManifest".to_string(), + json!({ + "version": "story-engine-backend-v1", + "requiredTransforms": [], + "backwardCompatible": true + }), + ); + + ensure_json_object(game_state).insert("storyEngineMemory".to_string(), memory); +} + +fn ensure_memory_defaults(memory: &mut Value) { + let root = ensure_json_object(memory); + ensure_array_field(root, "discoveredFactIds"); + ensure_array_field(root, "inferredFactIds"); + ensure_array_field(root, "activeThreadIds"); + ensure_array_field(root, "resolvedScarIds"); + ensure_array_field(root, "recentCarrierIds"); + ensure_array_field(root, "openedSceneChapterIds"); + ensure_array_field(root, "recentSignalIds"); + ensure_array_field(root, "recentCompanionReactions"); + ensure_array_field(root, "worldMutations"); + ensure_array_field(root, "chronicle"); + ensure_array_field(root, "factionTensionStates"); + ensure_array_field(root, "consequenceLedger"); + ensure_array_field(root, "companionResolutions"); + ensure_array_field(root, "narrativeCodex"); + root.entry("currentSceneActState".to_string()) + .or_insert(Value::Null); + root.entry("currentChapter".to_string()) + .or_insert(Value::Null); + root.entry("currentJourneyBeatId".to_string()) + .or_insert(Value::Null); + root.entry("currentJourneyBeat".to_string()) + .or_insert(Value::Null); + root.entry("currentCampEvent".to_string()) + .or_insert(Value::Null); + root.entry("currentSetpieceDirective".to_string()) + .or_insert(Value::Null); + root.entry("continueGameDigest".to_string()) + .or_insert(Value::Null); + root.entry("campaignState".to_string()) + .or_insert(Value::Null); + root.entry("actState".to_string()).or_insert(Value::Null); + root.entry("endingState".to_string()).or_insert(Value::Null); + root.entry("authorialConstraintPack".to_string()) + .or_insert(Value::Null); + root.entry("branchBudgetStatus".to_string()) + .or_insert(Value::Null); + root.entry("narrativeQaReport".to_string()) + .or_insert(Value::Null); + root.entry("releaseGateReport".to_string()) + .or_insert(Value::Null); + root.entry("playerStyleProfile".to_string()) + .or_insert(Value::Null); +} + +fn ensure_array_field(root: &mut Map, key: &str) { + if !root.get(key).is_some_and(Value::is_array) { + root.insert(key.to_string(), Value::Array(Vec::new())); + } +} + +fn collect_story_signals( + previous_state: &Value, + next_state: &Value, + memory: &Value, + action_text: &str, + function_id: &str, + battle_outcome: Option<&str>, +) -> Vec { + let mut signals = Vec::new(); + let active_thread_ids = read_string_array_field(memory, "activeThreadIds"); + let previous_scene_id = current_scene_id(previous_state); + let next_scene_id = current_scene_id(next_state); + + if previous_scene_id != next_scene_id { + if let Some(scene_id) = previous_scene_id.as_deref() { + signals.push(build_signal( + "leave_scene", + scene_id, + json!({ + "sceneId": scene_id, + "threadIds": active_thread_ids, + }), + )); + } + if let Some(scene_id) = next_scene_id.as_deref() { + signals.push(build_signal( + "enter_scene", + scene_id, + json!({ + "sceneId": scene_id, + "threadIds": active_thread_ids, + }), + )); + } + } + + if function_id == "idle_observe_signs" { + let key = next_scene_id.as_deref().unwrap_or("scene"); + signals.push(build_signal( + "inspect_scene", + key, + json!({ + "sceneId": next_scene_id, + "threadIds": active_thread_ids, + }), + )); + } + + if is_talk_signal(function_id, action_text, next_state) { + let actor_id = + current_encounter_id(next_state).or_else(|| current_encounter_id(previous_state)); + let key = actor_id.as_deref().unwrap_or(action_text); + signals.push(build_signal( + "talk_to_actor", + key, + json!({ + "actorId": actor_id, + "threadIds": active_thread_ids, + }), + )); + } + + if function_id == "npc_gift" { + let actor_id = + current_encounter_id(previous_state).or_else(|| current_encounter_id(next_state)); + signals.push(build_signal( + "give_item", + action_text, + json!({ + "actorId": actor_id, + "threadIds": active_thread_ids, + }), + )); + } + + if function_id == "npc_quest_accept" { + let actor_id = + current_encounter_id(previous_state).or_else(|| current_encounter_id(next_state)); + signals.push(build_signal( + "accept_contract", + action_text, + json!({ + "actorId": actor_id, + "threadIds": active_thread_ids, + }), + )); + } + + if is_battle_win_signal(function_id, previous_state, next_state, battle_outcome) { + let key = next_scene_id.as_deref().unwrap_or("battle"); + signals.push(build_signal( + "win_battle", + key, + json!({ + "sceneId": next_scene_id, + "threadIds": active_thread_ids, + }), + )); + } + + for item in find_new_inventory_items(previous_state, next_state) { + let item_id = read_optional_string_field(&item, "id").unwrap_or_else(|| "item".to_string()); + let thread_ids = read_field(&item, "runtimeMetadata") + .and_then(|metadata| read_field(metadata, "storyFingerprint")) + .map(|fingerprint| read_string_array_field(fingerprint, "relatedThreadIds")) + .filter(|ids| !ids.is_empty()) + .unwrap_or_else(|| active_thread_ids.clone()); + signals.push(build_signal( + "obtain_carrier", + item_id.as_str(), + json!({ + "carrierId": item_id, + "threadIds": thread_ids, + }), + )); + } + + dedupe_value_objects_by_id(signals, 12) +} + +fn build_signal(signal_type: &str, key: &str, extra: Value) -> Value { + let mut signal = extra.as_object().cloned().unwrap_or_default(); + signal.insert( + "id".to_string(), + Value::String(format!("{signal_type}:{key}")), + ); + signal.insert( + "signalType".to_string(), + Value::String(signal_type.to_string()), + ); + Value::Object(signal) +} + +fn is_talk_signal(function_id: &str, action_text: &str, next_state: &Value) -> bool { + matches!( + function_id, + "npc_chat" + | "npc_preview_talk" + | "npc_help" + | "story_opening_camp_dialogue" + | "npc_chat_quest_offer_view" + | "npc_quest_accept" + | "npc_quest_turn_in" + ) || read_object_field(next_state, "currentEncounter") + .and_then(|encounter| read_optional_string_field(encounter, "kind")) + .as_deref() + == Some("npc") + || action_text.contains('聊') + || action_text.contains('问') + || action_text.contains("试探") +} + +fn is_battle_win_signal( + function_id: &str, + previous_state: &Value, + next_state: &Value, + battle_outcome: Option<&str>, +) -> bool { + if !function_id.starts_with("battle_") && function_id != "inventory_use" { + return false; + } + if let Some(outcome) = battle_outcome { + // 中文注释:战斗终局已经由 battle resolver 明确给出时, + // story engine 必须信任该结果,避免败北复活清空战斗态后被误判为胜利信号。 + return matches!(outcome, "victory" | "spar_complete"); + } + let previous_battle = read_bool_field(previous_state, "inBattle").unwrap_or(false); + let next_battle = read_bool_field(next_state, "inBattle").unwrap_or(false); + let outcome = read_optional_string_field(next_state, "currentNpcBattleOutcome"); + matches!( + outcome.as_deref(), + Some("fight_victory") | Some("spar_complete") + ) && previous_battle + && !next_battle +} + +fn find_new_inventory_items(previous_state: &Value, next_state: &Value) -> Vec { + let previous_ids = read_array_field(previous_state, "playerInventory") + .into_iter() + .filter_map(|item| read_optional_string_field(item, "id")) + .collect::>(); + + read_array_field(next_state, "playerInventory") + .into_iter() + .filter(|item| { + read_optional_string_field(item, "id").is_some_and(|id| !previous_ids.contains(&id)) + }) + .cloned() + .collect() +} + +fn apply_thread_signal_updates(game_state: &mut Value, memory: &mut Value, signals: &[Value]) { + if signals.is_empty() { + return; + } + + let active_thread_ids = dedupe_strings( + read_string_array_field(memory, "activeThreadIds") + .into_iter() + .chain( + signals + .iter() + .flat_map(|signal| read_string_array_field(signal, "threadIds")), + ) + .collect(), + 8, + ); + let recent_signal_ids = dedupe_strings( + read_string_array_field(memory, "recentSignalIds") + .into_iter() + .chain( + signals + .iter() + .filter_map(|signal| read_optional_string_field(signal, "id")), + ) + .collect(), + 12, + ); + let root = ensure_json_object(memory); + root.insert("activeThreadIds".to_string(), json!(active_thread_ids)); + root.insert("recentSignalIds".to_string(), json!(recent_signal_ids)); + + update_quests_from_signals(game_state, signals); +} + +fn update_quests_from_signals(game_state: &mut Value, signals: &[Value]) { + let signal_thread_ids = signals + .iter() + .flat_map(|signal| read_string_array_field(signal, "threadIds")) + .collect::>(); + if signal_thread_ids.is_empty() { + return; + } + + let root = ensure_json_object(game_state); + let quests = root + .entry("quests".to_string()) + .or_insert_with(|| json!([])); + let Some(items) = quests.as_array_mut() else { + *quests = Value::Array(Vec::new()); + return; + }; + + for quest in items { + let Some(quest_object) = quest.as_object_mut() else { + continue; + }; + let quest_thread_id = quest_object + .get("threadId") + .and_then(Value::as_str) + .map(str::to_string); + if !quest_thread_id + .as_ref() + .is_some_and(|thread_id| signal_thread_ids.iter().any(|id| id == thread_id)) + { + continue; + } + + let next_visible_stage = quest_object + .get("visibleStage") + .and_then(Value::as_i64) + .unwrap_or(0) + .saturating_add(i64::try_from(signals.len()).unwrap_or(0)) + .min(12); + quest_object.insert("visibleStage".to_string(), json!(next_visible_stage)); + let discovered = dedupe_strings( + read_string_array_from_object(quest_object, "discoveredFactIds") + .into_iter() + .chain(signal_thread_ids.clone()) + .collect(), + 12, + ); + quest_object.insert("discoveredFactIds".to_string(), json!(discovered)); + } +} + +fn ensure_scene_chapter_state(game_state: &mut Value, memory: &mut Value) { + let current_scene = read_optional_string_field(game_state, "currentScene"); + let world_type = read_optional_string_field(game_state, "worldType"); + let Some(scene) = read_object_field(game_state, "currentScenePreset").cloned() else { + return; + }; + let Some(scene_id) = read_optional_string_field(&scene, "id") else { + return; + }; + if current_scene.as_deref() != Some("Story") || world_type.is_none() { + return; + } + + let opened = dedupe_strings( + read_string_array_field(memory, "openedSceneChapterIds") + .into_iter() + .chain(std::iter::once(scene_id.clone())) + .collect(), + 64, + ); + ensure_json_object(memory).insert("openedSceneChapterIds".to_string(), json!(opened)); + + if let Some(scene_act_state) = + build_initial_scene_act_runtime_state(game_state, memory, scene_id.as_str()) + { + ensure_json_object(memory).insert("currentSceneActState".to_string(), scene_act_state); + } + + if has_live_scene_chapter_quest(game_state, scene_id.as_str()) { + return; + } + + let quest = build_scene_chapter_quest(game_state, &scene, scene_id.as_str()); + let root = ensure_json_object(game_state); + let quests = root + .entry("quests".to_string()) + .or_insert_with(|| json!([])); + if !quests.is_array() { + *quests = Value::Array(Vec::new()); + } + quests + .as_array_mut() + .expect("quests should be array") + .push(quest); +} + +fn has_live_scene_chapter_quest(game_state: &Value, scene_id: &str) -> bool { + let chapter_id = build_scene_chapter_id(scene_id); + read_array_field(game_state, "quests") + .into_iter() + .any(|quest| { + read_optional_string_field(quest, "chapterId").as_deref() == Some(chapter_id.as_str()) + && !matches!( + read_optional_string_field(quest, "status").as_deref(), + Some("turned_in") | Some("failed") | Some("expired") + ) + }) +} + +fn build_scene_chapter_quest(game_state: &Value, scene: &Value, scene_id: &str) -> Value { + let scene_name = + read_optional_string_field(scene, "name").unwrap_or_else(|| "当前区域".to_string()); + let scene_description = read_optional_string_field(scene, "description") + .unwrap_or_else(|| format!("{scene_name} 的局势正在变化。")); + let (issuer_npc_id, issuer_npc_name) = resolve_scene_chapter_issuer(scene); + let chapter_id = build_scene_chapter_id(scene_id); + let quest_id = format!("quest:chapter:{scene_id}"); + let title = compact_title(format!("{scene_name}异动").as_str(), "查明异动"); + let world_type = read_optional_string_field(game_state, "worldType"); + let currency = if world_type.as_deref() == Some("XIANXIA") { + 54 + } else { + 72 + }; + + json!({ + "id": quest_id, + "issuerNpcId": issuer_npc_id, + "issuerNpcName": issuer_npc_name, + "sceneId": scene_id, + "chapterId": chapter_id, + "actId": read_field(game_state, "storyEngineMemory") + .and_then(|memory| read_field(memory, "actState")) + .and_then(|act| read_optional_string_field(act, "id")), + "threadId": Value::Null, + "contractId": Value::Null, + "title": title, + "description": format!("{scene_description} 这一章需要先把现场线索和压力接住。"), + "summary": format!("在 {scene_name} 接住这一章的线索并完成收束"), + "objective": { + "kind": "talk_to_npc", + "targetNpcId": issuer_npc_id, + "requiredCount": 1 + }, + "progress": 0, + "status": "active", + "completionNotified": false, + "reward": { + "affinityBonus": 12, + "currency": currency, + "experience": 40, + "items": [] + }, + "rewardText": format!("完成后可获得好感 +12、赏金 {currency}、经验 +40。"), + "narrativeBinding": { + "origin": "fallback_builder", + "narrativeType": "investigation", + "dramaticNeed": format!("{scene_name} 的异常已经足以独立成章。"), + "issuerGoal": format!("查清 {scene_name} 当前没有说透的异动。"), + "playerHook": format!("你已经进入 {scene_name},这一章现在就落在你面前。"), + "worldReason": format!("{scene_name} 的线索和残痕正在把局势往前推。"), + "followupHooks": [format!("{scene_name} 的这一章收束后,下一段去向会更明确。")] + }, + "steps": [ + { + "id": format!("{quest_id}:opening"), + "title": "确认现场异样", + "kind": "talk_to_npc", + "targetNpcId": issuer_npc_id, + "requiredCount": 1, + "progress": 0, + "revealText": format!("先在 {scene_name} 确认眼前异样,不要让这一章从开口处滑过去。"), + "completeText": format!("{scene_name} 的表层线索已经确认,可以继续推进收束。") + }, + { + "id": format!("{quest_id}:resolve"), + "title": "收束当前章节", + "kind": "reach_scene", + "targetSceneId": scene_id, + "requiredCount": 1, + "progress": 0, + "revealText": format!("继续推进 {scene_name} 的线索,把这一章推向收束。"), + "completeText": format!("{scene_name} 的这一章已经完成收束。") + } + ], + "activeStepId": format!("{quest_id}:opening"), + "visibleStage": 0, + "hiddenFlags": [], + "discoveredFactIds": [], + "relatedCarrierIds": [], + "consequenceIds": [] + }) +} + +fn resolve_scene_chapter_issuer(scene: &Value) -> (String, String) { + let npc = read_array_field(scene, "npcs") + .into_iter() + .find(|npc| !read_bool_field(npc, "hostile").unwrap_or(false)) + .or_else(|| read_array_field(scene, "npcs").into_iter().next()); + let npc_id = npc + .and_then(|value| read_optional_string_field(value, "id")) + .unwrap_or_else(|| "scene-guide".to_string()); + let npc_name = npc + .and_then(|value| { + read_optional_string_field(value, "name") + .or_else(|| read_optional_string_field(value, "npcName")) + }) + .unwrap_or_else(|| { + read_optional_string_field(scene, "name").unwrap_or_else(|| "现场线索".to_string()) + }); + (npc_id, npc_name) +} + +fn build_initial_scene_act_runtime_state( + game_state: &Value, + memory: &Value, + scene_id: &str, +) -> Option { + let profile = read_object_field(game_state, "customWorldProfile")?; + let chapter = resolve_scene_chapter_blueprint(profile, scene_id)?; + let chapter_id = read_optional_string_field(chapter, "id")?; + let acts = read_array_field(chapter, "acts"); + let first_act = acts.first().copied()?; + let first_act_id = read_optional_string_field(first_act, "id")?; + + if let Some(runtime_state) = read_object_field(memory, "currentSceneActState") { + if read_optional_string_field(runtime_state, "chapterId").as_deref() + == Some(chapter_id.as_str()) + { + let current_act_id = read_optional_string_field(runtime_state, "currentActId"); + if current_act_id.as_ref().is_some_and(|act_id| { + acts.iter().any(|act| { + read_optional_string_field(act, "id").as_deref() == Some(act_id.as_str()) + }) + }) { + return Some(json!({ + "sceneId": read_optional_string_field(runtime_state, "sceneId").unwrap_or_else(|| scene_id.to_string()), + "chapterId": chapter_id, + "currentActId": current_act_id, + "currentActIndex": read_i32_field(runtime_state, "currentActIndex").unwrap_or(0).max(0), + "completedActIds": read_string_array_field(runtime_state, "completedActIds"), + "visitedActIds": read_string_array_field(runtime_state, "visitedActIds"), + })); + } + } + } + + Some(json!({ + "sceneId": read_optional_string_field(chapter, "sceneId").unwrap_or_else(|| scene_id.to_string()), + "chapterId": chapter_id, + "currentActId": first_act_id, + "currentActIndex": 0, + "completedActIds": [], + "visitedActIds": [first_act_id] + })) +} + +fn resolve_scene_chapter_blueprint<'a>(profile: &'a Value, scene_id: &str) -> Option<&'a Value> { + read_array_field(profile, "sceneChapterBlueprints") + .into_iter() + .find(|chapter| { + read_optional_string_field(chapter, "sceneId").as_deref() == Some(scene_id) + || read_string_array_field(chapter, "linkedLandmarkIds") + .iter() + .any(|id| id == scene_id) + || read_array_field(chapter, "acts").into_iter().any(|act| { + read_optional_string_field(act, "sceneId").as_deref() == Some(scene_id) + }) + }) +} + +fn resolve_current_chapter_state( + game_state: &Value, + memory: &Value, + previous_chapter: Option<&Value>, +) -> Value { + let active_thread_ids = read_string_array_field(memory, "activeThreadIds"); + let scene_id = current_scene_id(game_state); + let scene_name = read_object_field(game_state, "currentScenePreset") + .and_then(|scene| read_optional_string_field(scene, "name")) + .unwrap_or_else(|| "当前区域".to_string()); + + if let Some((quest, chapter_id)) = scene_id.as_deref().and_then(|id| { + find_scene_chapter_quest(game_state, id).map(|quest| (quest, build_scene_chapter_id(id))) + }) { + let stage = derive_chapter_stage_from_quest(quest); + let theme = read_optional_string_field(quest, "title") + .or_else(|| resolve_profile_theme(game_state, &active_thread_ids)) + .unwrap_or_else(|| "旅程推进".to_string()); + let primary_thread_ids = dedupe_strings( + read_optional_string_field(quest, "threadId") + .into_iter() + .chain(active_thread_ids.clone()) + .collect(), + 3, + ); + return json!({ + "id": chapter_id, + "title": format!("{scene_name}·{}", stage_label(stage)), + "theme": theme, + "primaryThreadIds": primary_thread_ids, + "stage": stage, + "chapterSummary": build_scene_chapter_summary(scene_name.as_str(), quest, stage), + "sceneId": scene_id, + "chapterQuestId": read_optional_string_field(quest, "id"), + }); + } + + let stage = resolve_freeform_chapter_stage(game_state, memory, previous_chapter); + let theme = resolve_profile_theme(game_state, &active_thread_ids) + .unwrap_or_else(|| "旅程推进".to_string()); + let title = format!("{theme}·{}", stage_label(stage)); + let chapter_id = if previous_chapter + .and_then(|chapter| read_optional_string_field(chapter, "stage")) + .as_deref() + == Some(stage) + && previous_chapter + .and_then(|chapter| read_optional_string_field(chapter, "theme")) + .as_deref() + == Some(theme.as_str()) + { + previous_chapter + .and_then(|chapter| read_optional_string_field(chapter, "id")) + .unwrap_or_else(|| build_freeform_chapter_id(&active_thread_ids, stage)) + } else { + build_freeform_chapter_id(&active_thread_ids, stage) + }; + + json!({ + "id": chapter_id, + "title": title, + "theme": theme, + "primaryThreadIds": dedupe_strings(active_thread_ids, 3), + "stage": stage, + "chapterSummary": format!("{title} 当前围绕 {theme} 推进。"), + "sceneId": Value::Null, + "chapterQuestId": Value::Null, + }) +} + +fn find_scene_chapter_quest<'a>(game_state: &'a Value, scene_id: &str) -> Option<&'a Value> { + let chapter_id = build_scene_chapter_id(scene_id); + read_array_field(game_state, "quests") + .into_iter() + .find(|quest| { + read_optional_string_field(quest, "chapterId").as_deref() == Some(chapter_id.as_str()) + && !matches!( + read_optional_string_field(quest, "status").as_deref(), + Some("failed") | Some("expired") + ) + }) +} + +fn derive_chapter_stage_from_quest(quest: &Value) -> &'static str { + match read_optional_string_field(quest, "status").as_deref() { + Some("turned_in") => return CHAPTER_STAGE_AFTERMATH, + Some("ready_to_turn_in") | Some("completed") => return CHAPTER_STAGE_CLIMAX, + _ => {} + } + + let steps = read_array_field(quest, "steps"); + let active_step_id = read_optional_string_field(quest, "activeStepId"); + let active_step_index = active_step_id + .as_deref() + .and_then(|id| { + steps + .iter() + .position(|step| read_optional_string_field(step, "id").as_deref() == Some(id)) + }) + .unwrap_or(0); + match active_step_index { + 0 => CHAPTER_STAGE_OPENING, + 1 => CHAPTER_STAGE_EXPANSION, + _ => CHAPTER_STAGE_TURNING_POINT, + } +} + +fn build_scene_chapter_summary(scene_name: &str, quest: &Value, stage: &str) -> String { + let quest_description = read_optional_string_field(quest, "description") + .or_else(|| read_optional_string_field(quest, "summary")) + .unwrap_or_else(|| "这一章仍在推进中。".to_string()); + match stage { + CHAPTER_STAGE_OPENING => format!("{scene_name} 的这一章刚刚开启。{quest_description}"), + CHAPTER_STAGE_EXPANSION => format!("{scene_name} 的压力正在展开。{quest_description}"), + CHAPTER_STAGE_TURNING_POINT => { + format!("{scene_name} 的线索正在改写当前判断。{quest_description}") + } + CHAPTER_STAGE_CLIMAX => { + format!("{scene_name} 的核心矛盾已经被推到最后一步,只差正式收束。") + } + CHAPTER_STAGE_AFTERMATH => { + format!("{scene_name} 这一章已经完成收束,余波和下一段去向正在显形。") + } + _ => format!("{scene_name} 的这一章仍在推进中。"), + } +} + +fn resolve_freeform_chapter_stage( + game_state: &Value, + memory: &Value, + previous_chapter: Option<&Value>, +) -> &'static str { + let score = i32::try_from(read_string_array_field(memory, "recentSignalIds").len()) + .unwrap_or(0) + + i32::try_from(read_array_field(memory, "chronicle").len()).unwrap_or(0) + + i32::try_from(read_string_array_field(memory, "activeThreadIds").len()).unwrap_or(0); + if score >= 12 { + CHAPTER_STAGE_AFTERMATH + } else if score >= 9 { + CHAPTER_STAGE_CLIMAX + } else if score >= 6 { + CHAPTER_STAGE_TURNING_POINT + } else if score >= 3 { + CHAPTER_STAGE_EXPANSION + } else if read_object_field(game_state, "chapterState") + .and_then(|chapter| read_optional_string_field(chapter, "stage")) + .or_else(|| { + previous_chapter.and_then(|chapter| read_optional_string_field(chapter, "stage")) + }) + .as_deref() + == Some(CHAPTER_STAGE_AFTERMATH) + { + CHAPTER_STAGE_AFTERMATH + } else { + CHAPTER_STAGE_OPENING + } +} + +fn resolve_profile_theme(game_state: &Value, active_thread_ids: &[String]) -> Option { + let profile = read_object_field(game_state, "customWorldProfile")?; + if let Some(thread_title) = first_thread_title(profile, active_thread_ids) { + return Some(thread_title); + } + read_object_field(profile, "themePack") + .and_then(|theme_pack| read_optional_string_field(theme_pack, "displayName")) + .or_else(|| read_optional_string_field(profile, "summary")) +} + +fn first_thread_title(profile: &Value, active_thread_ids: &[String]) -> Option { + let story_graph = read_object_field(profile, "storyGraph")?; + let threads = read_array_field(story_graph, "visibleThreads") + .into_iter() + .chain(read_array_field(story_graph, "hiddenThreads")) + .collect::>(); + active_thread_ids.iter().find_map(|thread_id| { + threads.iter().find_map(|thread| { + (read_optional_string_field(thread, "id").as_deref() == Some(thread_id.as_str())) + .then(|| read_optional_string_field(thread, "title")) + .flatten() + }) + }) +} + +fn resolve_current_journey_beat( + game_state: &Value, + memory: &Value, + chapter_state: &Value, +) -> Value { + let chapter_id = read_optional_string_field(chapter_state, "id") + .unwrap_or_else(|| "chapter:default".to_string()); + let chapter_title = read_optional_string_field(chapter_state, "title") + .unwrap_or_else(|| "当前章节".to_string()); + let stage = read_optional_string_field(chapter_state, "stage") + .unwrap_or_else(|| CHAPTER_STAGE_OPENING.to_string()); + let beat_type = match stage.as_str() { + CHAPTER_STAGE_OPENING => "approach", + CHAPTER_STAGE_EXPANSION => "investigation", + CHAPTER_STAGE_TURNING_POINT => "conflict", + CHAPTER_STAGE_CLIMAX => "climax", + CHAPTER_STAGE_AFTERMATH => "recovery", + _ => "approach", + }; + let stored_beat_id = read_optional_string_field(memory, "currentJourneyBeatId"); + let id = stored_beat_id.unwrap_or_else(|| format!("{chapter_id}:{beat_type}")); + let current_scene_id = current_scene_id(game_state).into_iter().collect::>(); + let emotional_goal = if beat_type == "climax" { + "把冲突推到最前台。" + } else if beat_type == "recovery" { + "让角色和世界消化刚发生的后果。" + } else { + "让线索、关系和压力继续叠加。" + }; + + json!({ + "id": id, + "beatType": beat_type, + "title": format!("{chapter_title}·当前段落"), + "triggerThreadIds": read_string_array_field(chapter_state, "primaryThreadIds"), + "recommendedSceneIds": current_scene_id, + "emotionalGoal": emotional_goal, + }) +} + +fn resolve_world_mutations( + game_state: &Value, + memory: &Value, + signals: &[Value], + chapter_state: &Value, +) -> Vec { + let mut mutations = Vec::new(); + let current_scene_id = current_scene_id(game_state); + let active_thread_ids = read_string_array_field(memory, "activeThreadIds"); + let chapter_stage = read_optional_string_field(chapter_state, "stage"); + + if let Some(scene_id) = current_scene_id.as_deref() { + let chapter_title = read_optional_string_field(chapter_state, "title") + .unwrap_or_else(|| "当前章节".to_string()); + mutations.push(json!({ + "id": format!("mutation:scene:{scene_id}:{}", chapter_stage.as_deref().unwrap_or(CHAPTER_STAGE_OPENING)), + "mutationType": "scene_text", + "targetId": scene_id, + "reason": format!("{chapter_title}正在改写这片地界的表面气氛。"), + "relatedThreadIds": read_string_array_field(chapter_state, "primaryThreadIds"), + })); + } + + if current_scene_id.is_some() + && signals.iter().any(|signal| { + read_optional_string_field(signal, "signalType").as_deref() == Some("win_battle") + }) + { + let scene_id = current_scene_id.as_deref().unwrap_or("scene"); + mutations.push(json!({ + "id": format!("mutation:pressure:{scene_id}:battle"), + "mutationType": "enemy_pressure", + "targetId": scene_id, + "reason": "这一带的敌意正在因交锋结果重新聚拢。", + "relatedThreadIds": dedupe_strings(active_thread_ids.clone(), 4), + })); + } + + if signals.iter().any(|signal| { + read_optional_string_field(signal, "signalType").as_deref() == Some("obtain_carrier") + }) { + let scene_id = current_scene_id.as_deref().unwrap_or("scene"); + mutations.push(json!({ + "id": format!("mutation:attitude:{scene_id}:carrier"), + "mutationType": "npc_attitude", + "targetId": scene_id, + "reason": "关键载体已经落到你手里,相关角色的口风会开始变化。", + "relatedThreadIds": dedupe_strings(active_thread_ids.clone(), 4), + })); + } + + if chapter_stage.as_deref() == Some(CHAPTER_STAGE_CLIMAX) { + if let Some(scene_id) = current_scene_id.as_deref() { + mutations.push(json!({ + "id": format!("mutation:route:{scene_id}:climax"), + "mutationType": "route_unlock", + "targetId": scene_id, + "reason": "章节高潮逼近,新的通路或对峙点开始显影。", + "relatedThreadIds": read_string_array_field(chapter_state, "primaryThreadIds"), + })); + } + } + + dedupe_value_objects_by_id(mutations, 8) +} + +fn append_world_mutations(memory: &Value, additions: Vec) -> Vec { + dedupe_value_objects_by_id( + read_array_field(memory, "worldMutations") + .into_iter() + .cloned() + .chain(additions) + .collect(), + 24, + ) +} + +fn apply_world_mutations_to_game_state(game_state: &mut Value, mutations: &[Value]) { + let Some(current_scene_id) = current_scene_id(game_state) else { + return; + }; + let relevant = mutations + .iter() + .filter(|mutation| { + read_optional_string_field(mutation, "targetId").as_deref() + == Some(current_scene_id.as_str()) + }) + .collect::>(); + if relevant.is_empty() { + return; + } + + let latest_scene_reason = relevant + .iter() + .rev() + .find(|mutation| { + read_optional_string_field(mutation, "mutationType").as_deref() == Some("scene_text") + }) + .and_then(|mutation| read_optional_string_field(mutation, "reason")); + let latest_attitude_reason = relevant + .iter() + .rev() + .find(|mutation| { + read_optional_string_field(mutation, "mutationType").as_deref() == Some("npc_attitude") + }) + .and_then(|mutation| read_optional_string_field(mutation, "reason")); + let pressure_count = relevant + .iter() + .filter(|mutation| { + read_optional_string_field(mutation, "mutationType").as_deref() + == Some("enemy_pressure") + }) + .count(); + let pressure_level = match pressure_count { + count if count >= 3 => "extreme", + 2 => "high", + 1 => "medium", + _ => "low", + }; + + let root = ensure_json_object(game_state); + let Some(scene) = root + .get_mut("currentScenePreset") + .and_then(Value::as_object_mut) + else { + return; + }; + let mutation_text = [latest_scene_reason.clone(), latest_attitude_reason.clone()] + .into_iter() + .flatten() + .filter(|text| !text.trim().is_empty()) + .collect::>() + .join(" "); + if !mutation_text.is_empty() { + scene.insert( + "mutationStateText".to_string(), + Value::String(mutation_text), + ); + } + scene.insert( + "currentPressureLevel".to_string(), + Value::String(pressure_level.to_string()), + ); + + if let Some(reason) = latest_scene_reason { + let description = scene + .get("description") + .and_then(Value::as_str) + .unwrap_or_default() + .to_string(); + if !description.contains(reason.as_str()) { + let next_description = [description.as_str(), reason.as_str()] + .into_iter() + .filter(|text| !text.trim().is_empty()) + .collect::>() + .join(" "); + scene.insert("description".to_string(), Value::String(next_description)); + } + } + + if let Some(attitude_reason) = latest_attitude_reason { + if let Some(npcs) = scene.get_mut("npcs").and_then(Value::as_array_mut) { + for npc in npcs { + if read_bool_field(npc, "hostile").unwrap_or(false) { + continue; + } + let description = + read_optional_string_field(npc, "description").unwrap_or_default(); + if description.contains(attitude_reason.as_str()) { + continue; + } + if let Some(npc_object) = npc.as_object_mut() { + let next_description = [description.as_str(), attitude_reason.as_str()] + .into_iter() + .filter(|text| !text.trim().is_empty()) + .collect::>() + .join(" "); + npc_object.insert("description".to_string(), Value::String(next_description)); + } + } + } + } +} + +fn build_companion_reactions( + game_state: &Value, + signals: &[Value], + action_text: &str, +) -> Vec { + let signal_types = signals + .iter() + .filter_map(|signal| read_optional_string_field(signal, "signalType")) + .collect::>(); + let related_thread_ids = dedupe_strings( + signals + .iter() + .flat_map(|signal| read_string_array_field(signal, "threadIds")) + .collect(), + 4, + ); + let companions = read_array_field(game_state, "companions") + .into_iter() + .chain(read_array_field(game_state, "roster")) + .take(2) + .cloned() + .collect::>(); + let reaction_type = resolve_reaction_type(action_text, &signal_types); + + companions + .into_iter() + .enumerate() + .filter_map(|(index, companion)| { + let character_id = read_optional_string_field(&companion, "characterId")?; + Some(json!({ + "id": format!("reaction:{character_id}:{}:{}", signal_types.len(), index + 1), + "characterId": character_id, + "reactionType": reaction_type, + "reason": build_reaction_reason(action_text, reaction_type), + "relatedThreadIds": related_thread_ids, + "createdAt": format_now_rfc3339(), + })) + }) + .collect() +} + +fn resolve_reaction_type<'a>(action_text: &str, signal_types: &[String]) -> &'a str { + if action_text.contains("强行") + || action_text.contains("掠夺") + || action_text.contains("恶意") + || action_text.contains("开战") + || action_text.contains("威胁") + { + "disapprove" + } else if signal_types + .iter() + .any(|signal| signal == "accept_contract") + || action_text.contains('帮') + || action_text.contains('援') + || action_text.contains("调查") + { + "approve" + } else if signal_types + .iter() + .any(|signal| signal == "obtain_carrier" || signal == "inspect_scene") + { + "curious" + } else if action_text.contains('礼') || action_text.contains('赠') || action_text.contains('送') + { + "concern" + } else { + "silence" + } +} + +fn build_reaction_reason(action_text: &str, reaction_type: &str) -> String { + match reaction_type { + "approve" => format!("同行角色觉得你这一步接得住局势:{action_text}"), + "disapprove" => format!("同行角色对这一步明显有保留:{action_text}"), + "concern" => format!("同行角色觉得你这一步可能会牵出额外代价:{action_text}"), + "curious" => format!("同行角色被这一步新露出的线索勾住了注意力:{action_text}"), + _ => format!("同行角色暂时没有正面插话,但显然记住了这一步:{action_text}"), + } +} + +fn apply_companion_reactions_to_stance(game_state: &mut Value, reactions: &[Value]) { + if reactions.is_empty() { + return; + } + let companions = read_array_field(game_state, "companions") + .into_iter() + .chain(read_array_field(game_state, "roster")) + .cloned() + .collect::>(); + let root = ensure_json_object(game_state); + let Some(npc_states) = root.get_mut("npcStates").and_then(Value::as_object_mut) else { + return; + }; + + for reaction in reactions { + let Some(character_id) = read_optional_string_field(reaction, "characterId") else { + continue; + }; + let Some(companion) = companions.iter().find(|companion| { + read_optional_string_field(companion, "characterId").as_deref() + == Some(character_id.as_str()) + }) else { + continue; + }; + let Some(npc_id) = read_optional_string_field(companion, "npcId") else { + continue; + }; + let Some(stance) = npc_states + .get_mut(npc_id.as_str()) + .and_then(|state| state.as_object_mut()) + .and_then(|state| state.get_mut("stanceProfile")) + .and_then(Value::as_object_mut) + else { + continue; + }; + let reaction_type = + read_optional_string_field(reaction, "reactionType").unwrap_or_default(); + adjust_stance_for_reaction(stance, reaction, reaction_type.as_str()); + } +} + +fn adjust_stance_for_reaction( + stance: &mut Map, + reaction: &Value, + reaction_type: &str, +) { + match reaction_type { + "approve" => { + bump_stance_number(stance, "trust", 2); + bump_stance_number(stance, "loyalty", 1); + append_stance_note(stance, "recentApprovals", reaction); + } + "disapprove" => { + bump_stance_number(stance, "fearOrGuard", 3); + append_stance_note(stance, "recentDisapprovals", reaction); + } + "concern" => { + bump_stance_number(stance, "fearOrGuard", 2); + append_stance_note(stance, "recentDisapprovals", reaction); + } + "curious" => { + bump_stance_number(stance, "ideologicalFit", 1); + append_stance_note(stance, "recentApprovals", reaction); + } + _ => {} + } +} + +fn bump_stance_number(stance: &mut Map, key: &str, delta: i32) { + let next = stance + .get(key) + .and_then(Value::as_i64) + .unwrap_or(0) + .saturating_add(i64::from(delta)) + .clamp(0, 100); + stance.insert(key.to_string(), json!(next)); +} + +fn append_stance_note(stance: &mut Map, key: &str, reaction: &Value) { + let mut notes = stance + .get(key) + .and_then(Value::as_array) + .cloned() + .unwrap_or_default(); + if let Some(reason) = read_optional_string_field(reaction, "reason") { + notes.push(Value::String(reason)); + } + let keep_from = notes.len().saturating_sub(3); + stance.insert( + key.to_string(), + Value::Array(notes.into_iter().skip(keep_from).collect()), + ); +} + +fn append_recent_companion_reactions(memory: &mut Value, reactions: Vec) { + if reactions.is_empty() { + return; + } + let mut recent = read_array_field(memory, "recentCompanionReactions") + .into_iter() + .cloned() + .chain(reactions) + .collect::>(); + let keep_from = recent.len().saturating_sub(6); + recent = recent.into_iter().skip(keep_from).collect(); + ensure_json_object(memory).insert("recentCompanionReactions".to_string(), Value::Array(recent)); +} + +fn append_chronicle_entries( + memory: &Value, + chapter_state: &Value, + world_mutations: &[Value], +) -> Vec { + let now = format_now_rfc3339(); + let mut entries = read_array_field(memory, "chronicle") + .into_iter() + .cloned() + .collect::>(); + if let Some(chapter_id) = read_optional_string_field(chapter_state, "id") { + entries.push(json!({ + "id": format!("chronicle:chapter:{chapter_id}"), + "category": "chapter", + "title": read_optional_string_field(chapter_state, "title").unwrap_or_else(|| "当前章节".to_string()), + "summary": read_optional_string_field(chapter_state, "chapterSummary").unwrap_or_default(), + "relatedIds": read_string_array_field(chapter_state, "primaryThreadIds"), + "createdAt": now, + })); + } + for mutation in world_mutations.iter().rev().take(4) { + let Some(mutation_id) = read_optional_string_field(mutation, "id") else { + continue; + }; + entries.push(json!({ + "id": format!("chronicle:world_event:{mutation_id}"), + "category": "world_event", + "title": read_optional_string_field(mutation, "reason").unwrap_or_else(|| "世界状态变化".to_string()), + "summary": format!( + "{} 影响了 {}", + read_optional_string_field(mutation, "mutationType").unwrap_or_else(|| "world_event".to_string()), + read_optional_string_field(mutation, "targetId").unwrap_or_else(|| "scene".to_string()) + ), + "relatedIds": read_string_array_field(mutation, "relatedThreadIds"), + "createdAt": format_now_rfc3339(), + })); + } + + dedupe_value_objects_by_id(entries, 18) +} + +fn build_continue_digest(chapter_state: &Value, result_text: &str, chronicle: &[Value]) -> String { + let chapter_summary = read_optional_string_field(chapter_state, "chapterSummary") + .unwrap_or_else(|| "当前章节仍在推进。".to_string()); + let recent = chronicle + .iter() + .rev() + .take(3) + .filter_map(|entry| { + Some(format!( + "- {}:{}", + read_optional_string_field(entry, "title")?, + read_optional_string_field(entry, "summary").unwrap_or_default() + )) + }) + .collect::>() + .join("\n"); + [chapter_summary, result_text.to_string(), recent] + .into_iter() + .filter(|text| !text.trim().is_empty()) + .collect::>() + .join("\n") +} + +fn current_scene_id(game_state: &Value) -> Option { + read_object_field(game_state, "currentScenePreset") + .and_then(|scene| read_optional_string_field(scene, "id")) +} + +fn current_encounter_id(game_state: &Value) -> Option { + read_object_field(game_state, "currentEncounter").and_then(|encounter| { + read_optional_string_field(encounter, "id") + .or_else(|| read_optional_string_field(encounter, "npcName")) + }) +} + +fn build_scene_chapter_id(scene_id: &str) -> String { + format!("chapter:scene:{scene_id}") +} + +fn build_freeform_chapter_id(active_thread_ids: &[String], stage: &str) -> String { + let key = if active_thread_ids.is_empty() { + "default".to_string() + } else { + active_thread_ids + .iter() + .take(2) + .cloned() + .collect::>() + .join("+") + }; + format!("chapter:{key}:{stage}") +} + +fn stage_label(stage: &str) -> &'static str { + match stage { + CHAPTER_STAGE_OPENING => "序章", + CHAPTER_STAGE_EXPANSION => "展开", + CHAPTER_STAGE_TURNING_POINT => "转折", + CHAPTER_STAGE_CLIMAX => "高潮", + CHAPTER_STAGE_AFTERMATH => "余波", + _ => "推进", + } +} + +fn compact_title(raw: &str, fallback: &str) -> String { + let cleaned = raw + .replace(['《', '》', '「', '」', '“', '”', '"', '\''], "") + .split([ + ',', '。', '!', '?', ';', ':', ',', '.', '!', '?', ';', ':', + ]) + .next() + .unwrap_or_default() + .trim() + .to_string(); + if cleaned.is_empty() { + fallback.to_string() + } else if cleaned.chars().count() > 12 { + cleaned.chars().take(10).collect() + } else { + cleaned + } +} + +fn read_string_array_field(value: &Value, key: &str) -> Vec { + read_field(value, key) + .and_then(Value::as_array) + .map(|items| { + items + .iter() + .filter_map(Value::as_str) + .map(str::trim) + .filter(|text| !text.is_empty()) + .map(ToOwned::to_owned) + .collect() + }) + .unwrap_or_default() +} + +fn read_string_array_from_object(value: &Map, key: &str) -> Vec { + value + .get(key) + .and_then(Value::as_array) + .map(|items| { + items + .iter() + .filter_map(Value::as_str) + .map(str::trim) + .filter(|text| !text.is_empty()) + .map(ToOwned::to_owned) + .collect() + }) + .unwrap_or_default() +} + +fn dedupe_strings(values: Vec, limit: usize) -> Vec { + let mut seen = std::collections::HashSet::new(); + let mut result = Vec::new(); + for value in values { + let trimmed = value.trim(); + if trimmed.is_empty() || !seen.insert(trimmed.to_string()) { + continue; + } + result.push(trimmed.to_string()); + if result.len() >= limit { + break; + } + } + result +} + +fn dedupe_value_objects_by_id(values: Vec, limit: usize) -> Vec { + let mut seen = std::collections::HashSet::new(); + let mut result = Vec::new(); + for value in values { + let id = read_optional_string_field(&value, "id").unwrap_or_else(|| value.to_string()); + if !seen.insert(id) { + continue; + } + result.push(value); + } + let keep_from = result.len().saturating_sub(limit); + result.into_iter().skip(keep_from).collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn story_engine_projector_creates_scene_chapter_and_world_mutation() { + let previous_state = json!({ + "worldType": "WUXIA", + "currentScene": "Story", + "storyHistory": [], + "quests": [], + "currentScenePreset": { + "id": "scene-bridge", + "name": "断桥口", + "description": "风从桥下吹上来。", + "npcs": [{ + "id": "npc-guide", + "name": "沈七", + "hostile": false, + "description": "腰间挂着药囊的行商" + }] + }, + "storyEngineMemory": { + "activeThreadIds": ["thread-bridge"] + } + }); + let mut next_state = previous_state.clone(); + + project_story_engine_after_action( + &previous_state, + &mut next_state, + "观察周围迹象", + "你读出桥边留下的新痕。", + "idle_observe_signs", + None, + ); + + assert_eq!( + next_state["chapterState"]["id"], + json!("chapter:scene:scene-bridge") + ); + assert_eq!( + next_state["storyEngineMemory"]["currentChapter"]["stage"], + json!("opening") + ); + assert_eq!( + next_state["quests"][0]["chapterId"], + json!("chapter:scene:scene-bridge") + ); + assert!( + next_state["currentScenePreset"]["mutationStateText"] + .as_str() + .is_some_and(|text| text.contains("断桥口")) + ); + assert!( + next_state["storyEngineMemory"]["worldMutations"] + .as_array() + .is_some_and(|items| !items.is_empty()) + ); + } + + #[test] + fn story_engine_projector_records_battle_pressure_mutation() { + let previous_state = json!({ + "worldType": "WUXIA", + "currentScene": "Story", + "inBattle": true, + "quests": [], + "playerInventory": [], + "currentScenePreset": { + "id": "scene-bridge", + "name": "断桥口", + "description": "风从桥下吹上来。" + }, + "storyEngineMemory": { + "activeThreadIds": [] + } + }); + let mut next_state = previous_state.clone(); + next_state["inBattle"] = Value::Bool(false); + + project_story_engine_after_action( + &previous_state, + &mut next_state, + "普通攻击", + "敌人倒下。", + "battle_attack_basic", + Some("victory"), + ); + + assert_eq!( + next_state["currentScenePreset"]["currentPressureLevel"], + json!("medium") + ); + assert!( + next_state["storyEngineMemory"]["worldMutations"] + .as_array() + .unwrap() + .iter() + .any(|mutation| mutation["mutationType"] == json!("enemy_pressure")) + ); + } + + #[test] + fn story_engine_projector_does_not_record_defeat_as_battle_win() { + let previous_state = json!({ + "worldType": "CUSTOM", + "currentScene": "Story", + "inBattle": true, + "quests": [], + "playerInventory": [], + "currentScenePreset": { + "id": "custom-scene-camp", + "name": "回潮营地", + "description": "潮雾暂时压住脚步。" + }, + "storyEngineMemory": { + "activeThreadIds": [] + } + }); + let mut next_state = previous_state.clone(); + next_state["inBattle"] = Value::Bool(false); + + project_story_engine_after_action( + &previous_state, + &mut next_state, + "普通攻击", + "你在交锋中倒下,随后重新醒来。", + "battle_attack_basic", + Some("defeat"), + ); + + assert!( + next_state["storyEngineMemory"]["recentSignalIds"] + .as_array() + .is_none_or(|items| { + items + .iter() + .all(|signal| signal != "win_battle:custom-scene-camp") + }) + ); + assert!( + next_state["storyEngineMemory"]["worldMutations"] + .as_array() + .unwrap() + .iter() + .all(|mutation| mutation["mutationType"] != json!("enemy_pressure")) + ); + } +} diff --git a/server-rs/crates/module-runtime-story-compat/src/view_model.rs b/server-rs/crates/module-runtime-story-compat/src/view_model.rs index e8bce211..917863f0 100644 --- a/server-rs/crates/module-runtime-story-compat/src/view_model.rs +++ b/server-rs/crates/module-runtime-story-compat/src/view_model.rs @@ -1,13 +1,24 @@ -use serde_json::Value; +use serde_json::{Value, json}; use shared_contracts::runtime_story::{ - RuntimeStoryCompanionViewModel, RuntimeStoryEncounterViewModel, RuntimeStoryOptionView, - RuntimeStoryPlayerViewModel, RuntimeStoryStatusViewModel, RuntimeStoryViewModel, + RuntimeStoryCompanionViewModel, RuntimeStoryEncounterViewModel, RuntimeStoryEquipmentSlotView, + RuntimeStoryForgeRecipeView, RuntimeStoryForgeRequirementView, RuntimeStoryInventoryActionView, + RuntimeStoryInventoryItemActionsView, RuntimeStoryInventoryItemView, + RuntimeStoryInventoryViewModel, RuntimeStoryOptionView, RuntimeStoryPlayerViewModel, + RuntimeStoryStatusViewModel, RuntimeStoryViewModel, }; use crate::{ - read_array_field, read_bool_field, read_i32_field, read_object_field, - read_optional_string_field, read_required_string_field, + battle::inventory_item_has_usable_effect, build_runtime_npc_interaction_view, + equipment_slot_label, read_array_field, read_bool_field, read_field, read_i32_field, + read_object_field, read_optional_string_field, read_player_equipment_item, + read_player_inventory_values, read_required_string_field, remove_inventory_item_from_list, + resolve_equipment_slot_for_item, +}; + +use super::forge::{ + apply_forge_requirements_if_possible, count_matching_forge_requirement, + forge_recipe_definitions, format_currency_text, reforge_cost_definition, }; /// 运行时故事 view-model 只依赖快照 JSON 与共享 contract,可脱离 HTTP 层独立编译。 @@ -24,6 +35,7 @@ pub fn build_runtime_story_view_model( }, encounter: build_runtime_story_encounter(game_state), companions: build_runtime_story_companions(game_state), + inventory: build_runtime_story_inventory(game_state), available_options: options.to_vec(), status: RuntimeStoryStatusViewModel { in_battle: read_bool_field(game_state, "inBattle").unwrap_or(false), @@ -35,9 +47,293 @@ pub fn build_runtime_story_view_model( "currentNpcBattleOutcome", ), }, + npc_interaction: build_runtime_npc_interaction_view(game_state), } } +pub fn build_runtime_story_inventory(game_state: &Value) -> RuntimeStoryInventoryViewModel { + let player_currency = read_i32_field(game_state, "playerCurrency").unwrap_or(0); + let world_type = read_optional_string_field(game_state, "worldType"); + let in_battle = read_bool_field(game_state, "inBattle").unwrap_or(false); + let inventory_items = read_player_inventory_values(game_state); + + RuntimeStoryInventoryViewModel { + player_currency, + currency_text: format_currency_text(player_currency, world_type.as_deref()), + in_battle, + backpack_items: inventory_items + .iter() + .map(|item| build_inventory_item_view(game_state, item)) + .collect(), + equipment_slots: ["weapon", "armor", "relic"] + .into_iter() + .map(|slot_id| build_equipment_slot_view(game_state, slot_id)) + .collect(), + forge_recipes: forge_recipe_definitions() + .into_iter() + .map(|recipe| { + let requirements = recipe + .requirements + .iter() + .map(|requirement| RuntimeStoryForgeRequirementView { + id: requirement.id.to_string(), + label: requirement.label.to_string(), + quantity: requirement.quantity, + owned: count_matching_forge_requirement( + inventory_items.as_slice(), + requirement, + ), + }) + .collect::>(); + let disabled_reason = forge_recipe_disabled_reason( + game_state, + player_currency, + requirements.as_slice(), + recipe.currency_cost, + ); + let can_craft = disabled_reason.is_none(); + + RuntimeStoryForgeRecipeView { + id: recipe.id.to_string(), + name: recipe.name.to_string(), + kind: recipe.kind.to_string(), + description: recipe.description.to_string(), + result_label: recipe.result_label.to_string(), + currency_cost: recipe.currency_cost, + currency_text: format_currency_text( + recipe.currency_cost, + world_type.as_deref(), + ), + requirements, + can_craft, + disabled_reason: disabled_reason.clone(), + action: build_inventory_action( + "forge_craft", + format!("制作{}", recipe.result_label), + Some(json!({ "recipeId": recipe.id })), + can_craft, + disabled_reason, + ), + } + }) + .collect(), + } +} + +fn build_inventory_item_view(game_state: &Value, item: &Value) -> RuntimeStoryInventoryItemView { + RuntimeStoryInventoryItemView { + item: item.clone(), + actions: RuntimeStoryInventoryItemActionsView { + use_item: build_use_item_action(game_state, item), + equip: build_equip_item_action(game_state, item), + dismantle: build_dismantle_item_action(game_state, item), + reforge: build_reforge_item_action(game_state, item), + }, + } +} + +fn build_equipment_slot_view(game_state: &Value, slot_id: &str) -> RuntimeStoryEquipmentSlotView { + let item = read_player_equipment_item(game_state, slot_id); + let item_name = item + .as_ref() + .and_then(|value| read_optional_string_field(value, "name")) + .unwrap_or_else(|| equipment_slot_label(slot_id).to_string()); + let disabled_reason = inventory_non_battle_gate_reason(game_state).or_else(|| { + item.is_none() + .then(|| format!("{}位当前没有装备。", equipment_slot_label(slot_id))) + }); + let enabled = disabled_reason.is_none(); + + RuntimeStoryEquipmentSlotView { + slot_id: slot_id.to_string(), + label: equipment_slot_label(slot_id).to_string(), + item, + unequip: build_inventory_action( + "equipment_unequip", + format!("卸下{item_name}"), + Some(json!({ "slotId": slot_id })), + enabled, + disabled_reason, + ), + } +} + +fn build_use_item_action(game_state: &Value, item: &Value) -> RuntimeStoryInventoryActionView { + let item_id = read_optional_string_field(item, "id"); + let item_name = read_item_name(item); + let disabled_reason = if read_field(game_state, "playerCharacter").is_none() { + Some("缺少玩家角色,无法使用物品。".to_string()) + } else if !read_bool_field(game_state, "inBattle").unwrap_or(false) { + Some("当前物品使用需要在战斗动作中结算。".to_string()) + } else if read_i32_field(item, "quantity").unwrap_or(0) <= 0 { + Some("物品数量不足。".to_string()) + } else if !inventory_item_has_usable_effect(item) { + Some("该物品当前没有可直接使用的效果。".to_string()) + } else { + None + }; + let enabled = disabled_reason.is_none(); + + build_inventory_action( + "inventory_use", + format!("使用{item_name}"), + item_id.map(|item_id| json!({ "itemId": item_id })), + enabled, + disabled_reason, + ) +} + +fn build_equip_item_action(game_state: &Value, item: &Value) -> RuntimeStoryInventoryActionView { + let item_id = read_optional_string_field(item, "id"); + let item_name = read_item_name(item); + let disabled_reason = inventory_non_battle_gate_reason(game_state).or_else(|| { + if read_i32_field(item, "quantity").unwrap_or(0) <= 0 { + Some("物品数量不足。".to_string()) + } else if resolve_equipment_slot_for_item(item).is_none() { + Some("该物品不能装备。".to_string()) + } else { + None + } + }); + let enabled = disabled_reason.is_none(); + + build_inventory_action( + "equipment_equip", + format!("装备{item_name}"), + item_id.map(|item_id| json!({ "itemId": item_id })), + enabled, + disabled_reason, + ) +} + +fn build_dismantle_item_action( + game_state: &Value, + item: &Value, +) -> RuntimeStoryInventoryActionView { + let item_id = read_optional_string_field(item, "id"); + let item_name = read_item_name(item); + let disabled_reason = inventory_non_battle_gate_reason(game_state).or_else(|| { + if read_i32_field(item, "quantity").unwrap_or(0) <= 0 { + Some("物品数量不足。".to_string()) + } else if resolve_equipment_slot_for_item(item).is_none() + && read_field(item, "buildProfile").is_none() + { + Some("该物品不能拆解。".to_string()) + } else { + None + } + }); + let enabled = disabled_reason.is_none(); + + build_inventory_action( + "forge_dismantle", + format!("拆解{item_name}"), + item_id.map(|item_id| json!({ "itemId": item_id })), + enabled, + disabled_reason, + ) +} + +fn build_reforge_item_action(game_state: &Value, item: &Value) -> RuntimeStoryInventoryActionView { + let item_id = read_optional_string_field(item, "id"); + let item_name = read_item_name(item); + let disabled_reason = inventory_non_battle_gate_reason(game_state).or_else(|| { + let Some(slot_id) = resolve_equipment_slot_for_item(item) else { + return Some("该物品不能重铸。".to_string()); + }; + if read_i32_field(item, "quantity").unwrap_or(0) <= 0 { + return Some("物品数量不足。".to_string()); + } + if read_field(item, "buildProfile").is_none() { + return Some("该物品不能重铸。".to_string()); + } + + let cost = reforge_cost_definition(Some(slot_id)); + let player_currency = read_i32_field(game_state, "playerCurrency").unwrap_or(0); + if player_currency < cost.currency_cost { + return Some("货币不足。".to_string()); + } + let Some(item_id) = read_optional_string_field(item, "id") else { + return Some("目标物品缺少 id。".to_string()); + }; + let base_inventory = remove_inventory_item_from_list( + read_player_inventory_values(game_state), + item_id.as_str(), + 1, + ); + if apply_forge_requirements_if_possible( + base_inventory.as_slice(), + cost.requirements.as_slice(), + ) + .is_none() + { + return Some("材料不足。".to_string()); + } + None + }); + let enabled = disabled_reason.is_none(); + + build_inventory_action( + "forge_reforge", + format!("重铸{item_name}"), + item_id.map(|item_id| json!({ "itemId": item_id })), + enabled, + disabled_reason, + ) +} + +fn forge_recipe_disabled_reason( + game_state: &Value, + player_currency: i32, + requirements: &[RuntimeStoryForgeRequirementView], + currency_cost: i32, +) -> Option { + inventory_non_battle_gate_reason(game_state).or_else(|| { + if player_currency < currency_cost { + Some("货币不足。".to_string()) + } else if requirements + .iter() + .any(|requirement| requirement.owned < requirement.quantity) + { + Some("材料不足。".to_string()) + } else { + None + } + }) +} + +fn inventory_non_battle_gate_reason(game_state: &Value) -> Option { + if read_field(game_state, "playerCharacter").is_none() { + return Some("缺少玩家角色,无法操作背包。".to_string()); + } + if read_bool_field(game_state, "inBattle").unwrap_or(false) { + return Some("战斗中无法执行该操作。".to_string()); + } + None +} + +fn build_inventory_action( + function_id: &str, + action_text: String, + payload: Option, + enabled: bool, + reason: Option, +) -> RuntimeStoryInventoryActionView { + RuntimeStoryInventoryActionView { + function_id: function_id.to_string(), + action_text, + payload, + enabled, + reason: if enabled { None } else { reason }, + } +} + +fn read_item_name(item: &Value) -> String { + read_optional_string_field(item, "name") + .or_else(|| read_optional_string_field(item, "id")) + .unwrap_or_else(|| "未命名物品".to_string()) +} + pub fn build_runtime_story_companions(game_state: &Value) -> Vec { read_array_field(game_state, "companions") .into_iter() @@ -84,3 +380,125 @@ pub fn resolve_current_encounter_npc_state<'a>( .get(encounter_id) .or_else(|| npc_states.get(npc_name)) } + +#[cfg(test)] +mod tests { + use super::*; + + fn base_game_state() -> Value { + json!({ + "worldType": "WUXIA", + "playerCharacter": { + "id": "hero-1", + "name": "沈砺" + }, + "playerCurrency": 90, + "playerInventory": [ + { + "id": "scrap-a", + "category": "材料", + "name": "旧铜片", + "quantity": 2, + "rarity": "common", + "tags": ["material", "工巧"] + }, + { + "id": "scrap-b", + "category": "材料", + "name": "风化铁片", + "quantity": 1, + "rarity": "common", + "tags": ["material", "守御"] + }, + { + "id": "duelist-blade", + "category": "武器", + "name": "百炼追风剑", + "quantity": 1, + "rarity": "epic", + "tags": ["weapon", "快剑", "突进"], + "equipmentSlotId": "weapon", + "buildProfile": { + "role": "快剑", + "tags": ["快剑", "突进"], + "forgeRank": 1 + } + }, + { + "id": "refined-ingot", + "category": "材料", + "name": "精炼锭材", + "quantity": 1, + "rarity": "rare", + "tags": ["material", "工巧", "守御"] + } + ], + "playerEquipment": { + "weapon": null, + "armor": null, + "relic": null + }, + "inBattle": false, + "npcInteractionActive": false, + "companions": [] + }) + } + + #[test] + fn inventory_view_compiles_forge_recipe_availability_on_server() { + let view = build_runtime_story_inventory(&base_game_state()); + + let refined = view + .forge_recipes + .iter() + .find(|recipe| recipe.id == "synthesis-refined-ingot") + .expect("refined ingot recipe should exist"); + assert!(refined.can_craft); + assert_eq!(refined.requirements[0].owned, 4); + assert!(refined.action.enabled); + + let blade = view + .backpack_items + .iter() + .find(|item| { + read_optional_string_field(&item.item, "id").as_deref() == Some("duelist-blade") + }) + .expect("blade item view should exist"); + assert!(blade.actions.equip.enabled); + assert!(blade.actions.dismantle.enabled); + assert!(blade.actions.reforge.enabled); + assert!(!blade.actions.use_item.enabled); + } + + #[test] + fn inventory_view_reports_disabled_reasons_for_locked_actions() { + let mut state = base_game_state(); + state + .as_object_mut() + .expect("state should be object") + .insert("inBattle".to_string(), Value::Bool(true)); + + let view = build_runtime_story_inventory(&state); + let refined = view + .forge_recipes + .iter() + .find(|recipe| recipe.id == "synthesis-refined-ingot") + .expect("recipe should exist"); + assert!(!refined.can_craft); + assert_eq!( + refined.disabled_reason.as_deref(), + Some("战斗中无法执行该操作。") + ); + + let weapon_slot = view + .equipment_slots + .iter() + .find(|slot| slot.slot_id == "weapon") + .expect("weapon slot should exist"); + assert!(!weapon_slot.unequip.enabled); + assert_eq!( + weapon_slot.unequip.reason.as_deref(), + Some("战斗中无法执行该操作。") + ); + } +} diff --git a/server-rs/crates/shared-contracts/src/assets.rs b/server-rs/crates/shared-contracts/src/assets.rs index 83fa7b60..8183dfc8 100644 --- a/server-rs/crates/shared-contracts/src/assets.rs +++ b/server-rs/crates/shared-contracts/src/assets.rs @@ -350,6 +350,8 @@ pub struct CharacterWorkflowCachePayload { pub cache_scope_id: Option, pub visual_prompt_text: String, pub animation_prompt_text: String, + #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] + pub animation_prompt_text_by_key: BTreeMap, pub visual_drafts: Vec, pub selected_visual_draft_id: String, pub selected_animation: String, @@ -376,6 +378,8 @@ pub struct CharacterWorkflowCacheSaveRequest { #[serde(default)] pub animation_prompt_text: Option, #[serde(default)] + pub animation_prompt_text_by_key: BTreeMap, + #[serde(default)] pub visual_drafts: Vec, #[serde(default)] pub selected_visual_draft_id: Option, @@ -398,6 +402,91 @@ pub struct CharacterWorkflowCacheGetResponse { pub cache: Option, } +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct CharacterAssetRolePromptInput { + pub id: String, + #[serde(default)] + pub name: String, + #[serde(default)] + pub title: String, + #[serde(default)] + pub role: String, + #[serde(default)] + pub visual_description: Option, + #[serde(default)] + pub action_description: Option, + #[serde(default)] + pub scene_visual_description: Option, + #[serde(default)] + pub description: Option, + #[serde(default)] + pub backstory: Option, + #[serde(default)] + pub personality: Option, + #[serde(default)] + pub motivation: Option, + #[serde(default)] + pub combat_style: Option, + #[serde(default)] + pub tags: Vec, + #[serde(default)] + pub image_src: Option, + #[serde(default)] + pub generated_visual_asset_id: Option, + #[serde(default)] + pub generated_animation_set_id: Option, + #[serde(default)] + pub animation_map: Option, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct CharacterRolePromptBundlePayload { + pub visual_prompt_text: String, + pub animation_prompt_text: String, + pub scene_prompt_text: String, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct CharacterRoleAssetWorkflowPayload { + pub role: CharacterAssetRolePromptInput, + pub default_prompt_bundle: CharacterRolePromptBundlePayload, + pub visual_prompt_text: String, + pub animation_prompt_text: String, + pub animation_prompt_text_by_key: BTreeMap, + pub visual_drafts: Vec, + pub selected_visual_draft_id: String, + pub selected_animation: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub image_src: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub generated_visual_asset_id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub generated_animation_set_id: Option, + #[serde(default)] + pub animation_map: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub updated_at: Option, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct CharacterRoleAssetWorkflowResolveRequest { + #[serde(default)] + pub cache_scope_id: Option, + pub role: CharacterAssetRolePromptInput, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct CharacterRoleAssetWorkflowResponse { + pub ok: bool, + pub cache: Option, + pub workflow: CharacterRoleAssetWorkflowPayload, +} + #[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] #[serde(rename_all = "camelCase")] pub struct CharacterWorkflowCacheSaveResponse { @@ -767,6 +856,10 @@ mod tests { cache_scope_id: Some("world-01".to_string()), visual_prompt_text: "主形象".to_string(), animation_prompt_text: "待机".to_string(), + animation_prompt_text_by_key: BTreeMap::from([( + "idle".to_string(), + "待机".to_string(), + )]), visual_drafts: vec![CharacterVisualDraftPayload { id: "draft-1".to_string(), label: "候选 1".to_string(), @@ -790,6 +883,10 @@ mod tests { assert_eq!(payload["ok"], json!(true)); assert_eq!(payload["cache"]["characterId"], json!("hero")); assert_eq!(payload["cache"]["cacheScopeId"], json!("world-01")); + assert_eq!( + payload["cache"]["animationPromptTextByKey"]["idle"], + json!("待机") + ); assert_eq!( payload["cache"]["visualDrafts"][0]["imageSrc"], json!("/generated-character-drafts/hero/visual/job/candidate.svg") diff --git a/server-rs/crates/shared-contracts/src/big_fish.rs b/server-rs/crates/shared-contracts/src/big_fish.rs index 49a69dca..2923e34c 100644 --- a/server-rs/crates/shared-contracts/src/big_fish.rs +++ b/server-rs/crates/shared-contracts/src/big_fish.rs @@ -50,9 +50,13 @@ pub struct BigFishLevelBlueprintResponse { pub level: u32, pub name: String, pub one_line_fantasy: String, + pub text_description: String, pub silhouette_direction: String, pub size_ratio: f32, + pub visual_description: String, pub visual_prompt_seed: String, + pub idle_motion_description: String, + pub move_motion_description: String, pub motion_prompt_seed: String, pub merge_source_level: Option, pub prey_window: Vec, diff --git a/server-rs/crates/shared-contracts/src/runtime.rs b/server-rs/crates/shared-contracts/src/runtime.rs index 57d5671d..216f9b30 100644 --- a/server-rs/crates/shared-contracts/src/runtime.rs +++ b/server-rs/crates/shared-contracts/src/runtime.rs @@ -55,6 +55,15 @@ pub struct PutSavedGameSnapshotRequest { pub saved_at: Option, } +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct PutRuntimeSaveCheckpointRequest { + pub session_id: String, + pub bottom_tab: String, + #[serde(default)] + pub saved_at: Option, +} + #[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] #[serde(rename_all = "camelCase")] pub struct BasicOkResponse { @@ -345,6 +354,16 @@ pub struct CustomWorldProfileUpsertRequest { pub source_agent_session_id: Option, } +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct GenerateCustomWorldProfileRequest { + pub setting_text: String, + #[serde(default)] + pub creator_intent: Option, + #[serde(default)] + pub generation_mode: Option, +} + #[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] #[serde(rename_all = "camelCase")] pub struct CustomWorldLibraryEntryResponse { @@ -573,6 +592,24 @@ pub struct CustomWorldPublishGateResponse { pub can_enter_world: bool, } +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct CustomWorldCreationResultViewResponse { + pub session: CustomWorldAgentSessionSnapshotResponse, + pub profile: Option, + pub profile_source: String, + pub target_stage: String, + pub generation_view_source: Option, + pub result_view_source: Option, + pub can_autosave_library: bool, + pub can_sync_result_profile: bool, + pub publish_ready: bool, + pub can_enter_world: bool, + pub blocker_count: u32, + pub recovery_action: String, + pub recovery_reason: Option, +} + #[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] #[serde(rename_all = "camelCase")] pub struct CustomWorldAgentSessionSnapshotResponse { diff --git a/server-rs/crates/shared-contracts/src/runtime_story.rs b/server-rs/crates/shared-contracts/src/runtime_story.rs index d1c6e147..dd5a0d2a 100644 --- a/server-rs/crates/shared-contracts/src/runtime_story.rs +++ b/server-rs/crates/shared-contracts/src/runtime_story.rs @@ -22,6 +22,27 @@ pub struct RuntimeStoryStateResolveRequest { pub snapshot: Option, } +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct RuntimeStoryBootstrapRequest { + pub world_type: String, + #[serde(default)] + pub custom_world_profile: Option, + pub character: Value, + #[serde(default)] + pub runtime_mode: Option, + #[serde(default)] + pub disable_persistence: Option, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct RuntimeStoryBootstrapResponse { + pub session_id: String, + pub server_version: u32, + pub snapshot: RuntimeStorySnapshotPayload, +} + #[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] #[serde(rename_all = "camelCase")] pub struct RuntimeStoryChoiceAction { @@ -66,7 +87,13 @@ impl Default for RuntimeStoryAiRequestOptions { #[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] #[serde(rename_all = "camelCase")] pub struct RuntimeStoryAiRequest { + #[serde(default)] + pub session_id: Option, + #[serde(default)] + pub client_version: Option, + #[serde(default)] pub world_type: String, + #[serde(default)] pub character: Value, #[serde(default)] pub monsters: Vec, @@ -74,9 +101,16 @@ pub struct RuntimeStoryAiRequest { pub history: Vec, #[serde(default)] pub choice: String, + #[serde(default)] pub context: Value, #[serde(default)] pub request_options: RuntimeStoryAiRequestOptions, + #[serde(default)] + pub last_function_id: Option, + #[serde(default)] + pub observe_signs_requested: bool, + #[serde(default)] + pub recent_action_result: Option, } #[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] @@ -163,6 +197,130 @@ pub struct RuntimeStoryStatusViewModel { pub current_npc_battle_outcome: Option, } +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct RuntimeStoryInventoryActionView { + pub function_id: String, + pub action_text: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub payload: Option, + pub enabled: bool, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub reason: Option, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct RuntimeStoryInventoryItemActionsView { + #[serde(rename = "use")] + pub use_item: RuntimeStoryInventoryActionView, + pub equip: RuntimeStoryInventoryActionView, + pub dismantle: RuntimeStoryInventoryActionView, + pub reforge: RuntimeStoryInventoryActionView, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct RuntimeStoryInventoryItemView { + pub item: Value, + pub actions: RuntimeStoryInventoryItemActionsView, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct RuntimeStoryEquipmentSlotView { + pub slot_id: String, + pub label: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub item: Option, + pub unequip: RuntimeStoryInventoryActionView, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct RuntimeStoryForgeRequirementView { + pub id: String, + pub label: String, + pub quantity: i32, + pub owned: i32, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct RuntimeStoryForgeRecipeView { + pub id: String, + pub name: String, + pub kind: String, + pub description: String, + pub result_label: String, + pub currency_cost: i32, + pub currency_text: String, + pub requirements: Vec, + pub can_craft: bool, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub disabled_reason: Option, + pub action: RuntimeStoryInventoryActionView, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct RuntimeStoryInventoryViewModel { + pub player_currency: i32, + pub currency_text: String, + pub in_battle: bool, + pub backpack_items: Vec, + pub equipment_slots: Vec, + pub forge_recipes: Vec, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct RuntimeNpcTradeItemView { + pub item_id: String, + pub item: Value, + pub mode: String, + pub unit_price: i32, + pub max_quantity: i32, + pub can_submit: bool, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub reason: Option, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct RuntimeNpcGiftItemView { + pub item_id: String, + pub item: Value, + pub affinity_gain: i32, + pub can_submit: bool, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub reason: Option, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct RuntimeNpcTradeView { + pub buy_items: Vec, + pub sell_items: Vec, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct RuntimeNpcGiftView { + pub items: Vec, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct RuntimeNpcInteractionView { + pub npc_id: String, + pub npc_name: String, + pub player_currency: i32, + pub currency_name: String, + pub trade: RuntimeNpcTradeView, + pub gift: RuntimeNpcGiftView, +} + #[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] #[serde(rename_all = "camelCase")] pub struct RuntimeBattlePresentation { @@ -185,8 +343,11 @@ pub struct RuntimeStoryViewModel { #[serde(default, skip_serializing_if = "Option::is_none")] pub encounter: Option, pub companions: Vec, + pub inventory: RuntimeStoryInventoryViewModel, pub available_options: Vec, pub status: RuntimeStoryStatusViewModel, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub npc_interaction: Option, } #[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] @@ -311,6 +472,23 @@ mod tests { ); } + #[test] + fn runtime_story_bootstrap_request_uses_camel_case_fields() { + let payload = serde_json::to_value(RuntimeStoryBootstrapRequest { + world_type: "CUSTOM".to_string(), + custom_world_profile: Some(json!({ "id": "profile-1" })), + character: json!({ "id": "role-1", "name": "沈砺" }), + runtime_mode: Some("play".to_string()), + disable_persistence: Some(false), + }) + .expect("payload should serialize"); + + assert_eq!(payload["worldType"], json!("CUSTOM")); + assert_eq!(payload["customWorldProfile"]["id"], json!("profile-1")); + assert_eq!(payload["runtimeMode"], json!("play")); + assert_eq!(payload["disablePersistence"], json!(false)); + } + #[test] fn runtime_story_ai_request_defaults_optional_arrays() { let payload: RuntimeStoryAiRequest = serde_json::from_value(json!({ @@ -326,6 +504,33 @@ mod tests { assert!(payload.request_options.available_options.is_empty()); } + #[test] + fn runtime_story_ai_request_accepts_session_only_payload() { + let payload: RuntimeStoryAiRequest = serde_json::from_value(json!({ + "sessionId": "runtime-main", + "clientVersion": 3, + "choice": "继续向前", + "lastFunctionId": "idle_explore_forward", + "requestOptions": { + "optionCatalog": [{ + "functionId": "idle_observe_signs", + "actionText": "观察周围迹象" + }] + } + })) + .expect("payload should deserialize"); + + assert_eq!(payload.session_id.as_deref(), Some("runtime-main")); + assert_eq!(payload.client_version, Some(3)); + assert_eq!(payload.world_type, ""); + assert_eq!(payload.context, Value::Null); + assert_eq!( + payload.last_function_id.as_deref(), + Some("idle_explore_forward") + ); + assert_eq!(payload.request_options.option_catalog.len(), 1); + } + #[test] fn runtime_story_action_response_uses_camel_case_fields() { let payload = serde_json::to_value(RuntimeStoryActionResponse { @@ -353,6 +558,87 @@ mod tests { character_id: Some("char_companion_001".to_string()), joined_at_affinity: 64, }], + inventory: RuntimeStoryInventoryViewModel { + player_currency: 80, + currency_text: "80 铜钱".to_string(), + in_battle: false, + backpack_items: vec![RuntimeStoryInventoryItemView { + item: json!({ + "id": "potion-1", + "name": "疗伤药", + "category": "消耗品", + "quantity": 2, + "rarity": "common", + "tags": ["healing"] + }), + actions: RuntimeStoryInventoryItemActionsView { + use_item: RuntimeStoryInventoryActionView { + function_id: "inventory_use".to_string(), + action_text: "使用疗伤药".to_string(), + payload: Some(json!({ "itemId": "potion-1" })), + enabled: true, + reason: None, + }, + equip: RuntimeStoryInventoryActionView { + function_id: "equipment_equip".to_string(), + action_text: "装备疗伤药".to_string(), + payload: Some(json!({ "itemId": "potion-1" })), + enabled: false, + reason: Some("该物品不能装备。".to_string()), + }, + dismantle: RuntimeStoryInventoryActionView { + function_id: "forge_dismantle".to_string(), + action_text: "拆解疗伤药".to_string(), + payload: Some(json!({ "itemId": "potion-1" })), + enabled: false, + reason: Some("该物品不能拆解。".to_string()), + }, + reforge: RuntimeStoryInventoryActionView { + function_id: "forge_reforge".to_string(), + action_text: "重铸疗伤药".to_string(), + payload: Some(json!({ "itemId": "potion-1" })), + enabled: false, + reason: Some("该物品不能重铸。".to_string()), + }, + }, + }], + equipment_slots: vec![RuntimeStoryEquipmentSlotView { + slot_id: "weapon".to_string(), + label: "武器".to_string(), + item: None, + unequip: RuntimeStoryInventoryActionView { + function_id: "equipment_unequip".to_string(), + action_text: "卸下武器".to_string(), + payload: Some(json!({ "slotId": "weapon" })), + enabled: false, + reason: Some("武器位当前没有装备。".to_string()), + }, + }], + forge_recipes: vec![RuntimeStoryForgeRecipeView { + id: "synthesis-refined-ingot".to_string(), + name: "压炼锭材".to_string(), + kind: "synthesis".to_string(), + description: "把零散残片和基础材料压成稳定可用的金属锭材。".to_string(), + result_label: "精炼锭材".to_string(), + currency_cost: 18, + currency_text: "18 铜钱".to_string(), + requirements: vec![RuntimeStoryForgeRequirementView { + id: "material:any".to_string(), + label: "任意材料".to_string(), + quantity: 3, + owned: 0, + }], + can_craft: false, + disabled_reason: Some("材料不足。".to_string()), + action: RuntimeStoryInventoryActionView { + function_id: "forge_craft".to_string(), + action_text: "制作精炼锭材".to_string(), + payload: Some(json!({ "recipeId": "synthesis-refined-ingot" })), + enabled: false, + reason: Some("材料不足。".to_string()), + }, + }], + }, available_options: vec![RuntimeStoryOptionView { function_id: "npc_chat".to_string(), action_text: "继续交谈".to_string(), @@ -373,6 +659,47 @@ mod tests { current_npc_battle_mode: None, current_npc_battle_outcome: None, }, + npc_interaction: Some(RuntimeNpcInteractionView { + npc_id: "npc_camp_firekeeper".to_string(), + npc_name: "守火人".to_string(), + player_currency: 80, + currency_name: "铜钱".to_string(), + trade: RuntimeNpcTradeView { + buy_items: vec![RuntimeNpcTradeItemView { + item_id: "npc-potion".to_string(), + item: json!({ + "id": "npc-potion", + "name": "疗伤药", + "category": "消耗品", + "quantity": 2, + "rarity": "common", + "tags": ["healing"] + }), + mode: "buy".to_string(), + unit_price: 20, + max_quantity: 2, + can_submit: true, + reason: None, + }], + sell_items: Vec::new(), + }, + gift: RuntimeNpcGiftView { + items: vec![RuntimeNpcGiftItemView { + item_id: "potion-1".to_string(), + item: json!({ + "id": "potion-1", + "name": "疗伤药", + "category": "消耗品", + "quantity": 2, + "rarity": "common", + "tags": ["healing"] + }), + affinity_gain: 10, + can_submit: true, + reason: None, + }], + }, + }), }, presentation: RuntimeStoryPresentation { action_text: "".to_string(), @@ -419,6 +746,14 @@ mod tests { payload["viewModel"]["availableOptions"][0]["interaction"]["npcId"], json!("npc_camp_firekeeper") ); + assert_eq!( + payload["viewModel"]["inventory"]["backpackItems"][0]["actions"]["use"]["functionId"], + json!("inventory_use") + ); + assert_eq!( + payload["viewModel"]["inventory"]["forgeRecipes"][0]["canCraft"], + json!(false) + ); assert_eq!( payload["presentation"]["storyText"], json!("守火人抬眼看了你一瞬,示意你把想问的话继续说完。") diff --git a/server-rs/crates/spacetime-client/src/big_fish.rs b/server-rs/crates/spacetime-client/src/big_fish.rs index 758f18f3..bfa3b7ac 100644 --- a/server-rs/crates/spacetime-client/src/big_fish.rs +++ b/server-rs/crates/spacetime-client/src/big_fish.rs @@ -189,14 +189,13 @@ impl SpacetimeClient { pub async fn compile_big_fish_draft( &self, - session_id: String, - owner_user_id: String, - compiled_at_micros: i64, + input: BigFishDraftCompileRecordInput, ) -> Result { let procedure_input = BigFishDraftCompileInput { - session_id, - owner_user_id, - compiled_at_micros, + session_id: input.session_id, + owner_user_id: input.owner_user_id, + draft_json: input.draft_json, + compiled_at_micros: input.compiled_at_micros, }; self.call_after_connect(move |connection, sender| { diff --git a/server-rs/crates/spacetime-client/src/lib.rs b/server-rs/crates/spacetime-client/src/lib.rs index f990651d..e478adc1 100644 --- a/server-rs/crates/spacetime-client/src/lib.rs +++ b/server-rs/crates/spacetime-client/src/lib.rs @@ -8,8 +8,8 @@ pub use mapper::{ AiResultReferenceRecord, AiTaskMutationRecord, AiTaskRecord, AiTaskStageRecord, AiTextChunkRecord, BattleStateRecord, BigFishAgentMessageRecord, BigFishAnchorItemRecord, BigFishAnchorPackRecord, BigFishAssetCoverageRecord, BigFishAssetGenerateRecordInput, - BigFishAssetSlotRecord, BigFishBackgroundBlueprintRecord, BigFishGameDraftRecord, - BigFishLevelBlueprintRecord, BigFishMessageFinalizeRecordInput, + BigFishAssetSlotRecord, BigFishBackgroundBlueprintRecord, BigFishDraftCompileRecordInput, + BigFishGameDraftRecord, BigFishLevelBlueprintRecord, BigFishMessageFinalizeRecordInput, BigFishMessageSubmitRecordInput, BigFishRuntimeParamsRecord, BigFishSessionCreateRecordInput, BigFishSessionRecord, BigFishWorkSummaryRecord, CustomWorldAgentActionExecuteRecord, CustomWorldAgentActionExecuteRecordInput, CustomWorldAgentCheckpointRecord, @@ -30,10 +30,10 @@ pub use mapper::{ PuzzleBoardRecord, PuzzleCellPositionRecord, PuzzleCreatorIntentRecord, PuzzleGeneratedImageCandidateRecord, PuzzleGeneratedImagesSaveRecordInput, PuzzleLeaderboardEntryRecord, PuzzleLeaderboardSubmitRecordInput, PuzzleMergedGroupRecord, - PuzzlePieceStateRecord, PuzzlePublishRecordInput, - PuzzleResultDraftRecord, PuzzleResultPreviewBlockerRecord, PuzzleResultPreviewFindingRecord, - PuzzleResultPreviewRecord, PuzzleRunDragRecordInput, PuzzleRunNextLevelRecordInput, - PuzzleRunRecord, PuzzleRunStartRecordInput, PuzzleRunSwapRecordInput, PuzzleRuntimeLevelRecord, + PuzzlePieceStateRecord, PuzzlePublishRecordInput, PuzzleResultDraftRecord, + PuzzleResultPreviewBlockerRecord, PuzzleResultPreviewFindingRecord, PuzzleResultPreviewRecord, + PuzzleRunDragRecordInput, PuzzleRunNextLevelRecordInput, PuzzleRunRecord, + PuzzleRunStartRecordInput, PuzzleRunSwapRecordInput, PuzzleRuntimeLevelRecord, PuzzleSelectCoverImageRecordInput, PuzzleWorkProfileRecord, PuzzleWorkUpsertRecordInput, ResolveCombatActionRecord, ResolveNpcBattleInteractionInput, }; diff --git a/server-rs/crates/spacetime-client/src/mapper.rs b/server-rs/crates/spacetime-client/src/mapper.rs index 3e32f9fe..568c2ef0 100644 --- a/server-rs/crates/spacetime-client/src/mapper.rs +++ b/server-rs/crates/spacetime-client/src/mapper.rs @@ -2432,9 +2432,13 @@ pub(crate) fn map_big_fish_level_blueprint( level: snapshot.level, name: snapshot.name, one_line_fantasy: snapshot.one_line_fantasy, + text_description: snapshot.text_description, silhouette_direction: snapshot.silhouette_direction, size_ratio: snapshot.size_ratio, + visual_description: snapshot.visual_description, visual_prompt_seed: snapshot.visual_prompt_seed, + idle_motion_description: snapshot.idle_motion_description, + move_motion_description: snapshot.move_motion_description, motion_prompt_seed: snapshot.motion_prompt_seed, merge_source_level: snapshot.merge_source_level, prey_window: snapshot.prey_window, @@ -4465,6 +4469,14 @@ pub struct BigFishMessageFinalizeRecordInput { pub updated_at_micros: i64, } +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct BigFishDraftCompileRecordInput { + pub session_id: String, + pub owner_user_id: String, + pub draft_json: Option, + pub compiled_at_micros: i64, +} + #[derive(Clone, Debug, PartialEq, Eq)] pub struct BigFishAssetGenerateRecordInput { pub session_id: String, @@ -4497,9 +4509,13 @@ pub struct BigFishLevelBlueprintRecord { pub level: u32, pub name: String, pub one_line_fantasy: String, + pub text_description: String, pub silhouette_direction: String, pub size_ratio: f32, + pub visual_description: String, pub visual_prompt_seed: String, + pub idle_motion_description: String, + pub move_motion_description: String, pub motion_prompt_seed: String, pub merge_source_level: Option, pub prey_window: Vec, @@ -4677,7 +4693,7 @@ mod tests { "level_motion_ready_count":0, "background_ready":false }]"# - .to_string(), + .to_string(), ), error_message: None, }; @@ -4709,7 +4725,7 @@ mod tests { "level_motion_ready_count":16, "background_ready":true }]"# - .to_string(), + .to_string(), ), error_message: None, }; diff --git a/server-rs/crates/spacetime-client/src/module_bindings/big_fish_draft_compile_input_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/big_fish_draft_compile_input_type.rs index 9cf25ddc..69076a74 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/big_fish_draft_compile_input_type.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/big_fish_draft_compile_input_type.rs @@ -9,6 +9,7 @@ use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; pub struct BigFishDraftCompileInput { pub session_id: String, pub owner_user_id: String, + pub draft_json: Option, pub compiled_at_micros: i64, } diff --git a/server-rs/crates/spacetime-client/src/module_bindings/big_fish_level_blueprint_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/big_fish_level_blueprint_type.rs index b8ea2493..4bd70191 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/big_fish_level_blueprint_type.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/big_fish_level_blueprint_type.rs @@ -10,9 +10,13 @@ pub struct BigFishLevelBlueprint { pub level: u32, pub name: String, pub one_line_fantasy: String, + pub text_description: String, pub silhouette_direction: String, pub size_ratio: f32, + pub visual_description: String, pub visual_prompt_seed: String, + pub idle_motion_description: String, + pub move_motion_description: String, pub motion_prompt_seed: String, pub merge_source_level: Option, pub prey_window: Vec, diff --git a/server-rs/crates/spacetime-client/src/module_bindings/mod.rs b/server-rs/crates/spacetime-client/src/module_bindings/mod.rs index 76bf4e34..31669289 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/mod.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/mod.rs @@ -344,8 +344,8 @@ pub mod quest_step_snapshot_type; pub mod quest_treasure_inspected_signal_type; pub mod quest_turn_in_input_type; pub mod redeem_profile_referral_invite_code_procedure; -pub mod refund_profile_wallet_points_and_return_procedure; pub mod refresh_session_type; +pub mod refund_profile_wallet_points_and_return_procedure; pub mod resolve_combat_action_and_return_procedure; pub mod resolve_combat_action_input_type; pub mod resolve_combat_action_procedure_result_type; @@ -813,8 +813,8 @@ pub use quest_step_snapshot_type::QuestStepSnapshot; pub use quest_treasure_inspected_signal_type::QuestTreasureInspectedSignal; pub use quest_turn_in_input_type::QuestTurnInInput; pub use redeem_profile_referral_invite_code_procedure::redeem_profile_referral_invite_code; -pub use refund_profile_wallet_points_and_return_procedure::refund_profile_wallet_points_and_return; pub use refresh_session_type::RefreshSession; +pub use refund_profile_wallet_points_and_return_procedure::refund_profile_wallet_points_and_return; pub use resolve_combat_action_and_return_procedure::resolve_combat_action_and_return; pub use resolve_combat_action_input_type::ResolveCombatActionInput; pub use resolve_combat_action_procedure_result_type::ResolveCombatActionProcedureResult; diff --git a/server-rs/crates/spacetime-client/src/puzzle.rs b/server-rs/crates/spacetime-client/src/puzzle.rs index c3c09287..9636ed13 100644 --- a/server-rs/crates/spacetime-client/src/puzzle.rs +++ b/server-rs/crates/spacetime-client/src/puzzle.rs @@ -478,15 +478,14 @@ impl SpacetimeClient { }; self.call_after_connect(move |connection, sender| { - connection.procedures().submit_puzzle_leaderboard_entry_then( - procedure_input, - move |_, result| { + connection + .procedures() + .submit_puzzle_leaderboard_entry_then(procedure_input, move |_, result| { let mapped = result .map_err(|error| SpacetimeClientError::Procedure(error.to_string())) .and_then(map_puzzle_run_procedure_result); send_once(&sender, mapped); - }, - ); + }); }) .await } diff --git a/server-rs/crates/spacetime-module/src/big_fish/session.rs b/server-rs/crates/spacetime-module/src/big_fish/session.rs index 01459d39..22c11568 100644 --- a/server-rs/crates/spacetime-module/src/big_fish/session.rs +++ b/server-rs/crates/spacetime-module/src/big_fish/session.rs @@ -511,7 +511,13 @@ pub(crate) fn compile_big_fish_draft_tx( .ok_or_else(|| "big_fish_creation_session 不存在".to_string())?; let anchor_pack = deserialize_anchor_pack(&session.anchor_pack_json).map_err(|error| error.to_string())?; - let draft = compile_default_draft(&anchor_pack); + let draft = input + .draft_json + .as_deref() + .map(deserialize_draft) + .transpose() + .map_err(|error| format!("big_fish.draft_json 非法: {error}"))? + .unwrap_or_else(|| compile_default_draft(&anchor_pack)); let asset_slots = list_big_fish_asset_slots(ctx, &session.session_id); let coverage = build_asset_coverage(Some(&draft), &asset_slots); let compiled_at = Timestamp::from_micros_since_unix_epoch(input.compiled_at_micros); diff --git a/server-rs/crates/spacetime-module/src/custom_world/mod.rs b/server-rs/crates/spacetime-module/src/custom_world/mod.rs index a5511a8e..32802668 100644 --- a/server-rs/crates/spacetime-module/src/custom_world/mod.rs +++ b/server-rs/crates/spacetime-module/src/custom_world/mod.rs @@ -2189,7 +2189,7 @@ fn execute_sync_result_profile_action( input: &CustomWorldAgentActionExecuteInput, payload: &JsonMap, ) -> Result { - ensure_refining_stage(session.stage, "sync_result_profile")?; + ensure_result_profile_sync_stage(session.stage, "sync_result_profile")?; let mut profile = payload .get("profile") .and_then(JsonValue::as_object) @@ -3692,6 +3692,22 @@ fn ensure_refining_stage(stage: RpgAgentStage, action: &str) -> Result<(), Strin } } +fn ensure_result_profile_sync_stage(stage: RpgAgentStage, action: &str) -> Result<(), String> { + if matches!( + stage, + RpgAgentStage::ObjectRefining + | RpgAgentStage::VisualRefining + | RpgAgentStage::LongTailReview + | RpgAgentStage::ReadyToPublish + ) { + Ok(()) + } else { + Err(format!( + "{action} is only available during object_refining, visual_refining, long_tail_review or ready_to_publish" + )) + } +} + fn ensure_long_tail_stage(stage: RpgAgentStage, action: &str) -> Result<(), String> { if matches!( stage, diff --git a/server-rs/crates/spacetime-module/src/lib.rs b/server-rs/crates/spacetime-module/src/lib.rs index 82a3b4ef..1cbfe3e7 100644 --- a/server-rs/crates/spacetime-module/src/lib.rs +++ b/server-rs/crates/spacetime-module/src/lib.rs @@ -3405,7 +3405,7 @@ fn execute_sync_result_profile_action( input: &CustomWorldAgentActionExecuteInput, payload: &JsonMap, ) -> Result { - ensure_refining_stage(session.stage, "sync_result_profile")?; + ensure_result_profile_sync_stage(session.stage, "sync_result_profile")?; let mut profile = payload .get("profile") .and_then(JsonValue::as_object) @@ -4606,6 +4606,22 @@ fn ensure_refining_stage(stage: RpgAgentStage, action: &str) -> Result<(), Strin } } +fn ensure_result_profile_sync_stage(stage: RpgAgentStage, action: &str) -> Result<(), String> { + if matches!( + stage, + RpgAgentStage::ObjectRefining + | RpgAgentStage::VisualRefining + | RpgAgentStage::LongTailReview + | RpgAgentStage::ReadyToPublish + ) { + Ok(()) + } else { + Err(format!( + "{action} is only available during object_refining, visual_refining, long_tail_review or ready_to_publish" + )) + } +} + fn ensure_long_tail_stage(stage: RpgAgentStage, action: &str) -> Result<(), String> { if matches!( stage, diff --git a/server-rs/crates/spacetime-module/src/puzzle.rs b/server-rs/crates/spacetime-module/src/puzzle.rs index a1087aa0..7c27aba7 100644 --- a/server-rs/crates/spacetime-module/src/puzzle.rs +++ b/server-rs/crates/spacetime-module/src/puzzle.rs @@ -3,10 +3,10 @@ use module_puzzle::{ PuzzleAgentMessageRole, PuzzleAgentMessageSnapshot, PuzzleAgentSessionCreateInput, PuzzleAgentSessionGetInput, PuzzleAgentSessionProcedureResult, PuzzleAgentSessionSnapshot, PuzzleAgentStage, PuzzleAnchorPack, PuzzleDraftCompileInput, PuzzleGeneratedImageCandidate, - PuzzleGeneratedImagesSaveInput, PuzzlePublicationStatus, PuzzlePublishInput, PuzzleResultDraft, - PuzzleLeaderboardEntry, PuzzleLeaderboardSubmitInput, PuzzleRunDragInput, PuzzleRunGetInput, - PuzzleRunNextLevelInput, PuzzleRunProcedureResult, PuzzleRunSnapshot, PuzzleRunStartInput, - PuzzleRunSwapInput, PuzzleRuntimeLevelStatus, PuzzleSelectCoverImageInput, + PuzzleGeneratedImagesSaveInput, PuzzleLeaderboardEntry, PuzzleLeaderboardSubmitInput, + PuzzlePublicationStatus, PuzzlePublishInput, PuzzleResultDraft, PuzzleRunDragInput, + PuzzleRunGetInput, PuzzleRunNextLevelInput, PuzzleRunProcedureResult, PuzzleRunSnapshot, + PuzzleRunStartInput, PuzzleRunSwapInput, PuzzleRuntimeLevelStatus, PuzzleSelectCoverImageInput, PuzzleWorkDeleteInput, PuzzleWorkGetInput, PuzzleWorkProcedureResult, PuzzleWorkProfile, PuzzleWorkUpsertInput, PuzzleWorksListInput, PuzzleWorksProcedureResult, apply_publish_overrides_to_draft, apply_selected_candidate, build_result_preview, @@ -1689,12 +1689,7 @@ fn upsert_puzzle_leaderboard_entry( ) { let entry_id = build_puzzle_leaderboard_entry_id(user_id, profile_id, grid_size); let updated_at = Timestamp::from_micros_since_unix_epoch(updated_at_micros); - if let Some(existing) = ctx - .db - .puzzle_leaderboard_entry() - .entry_id() - .find(&entry_id) - { + if let Some(existing) = ctx.db.puzzle_leaderboard_entry().entry_id().find(&entry_id) { let should_replace = elapsed_ms < existing.best_elapsed_ms || (elapsed_ms == existing.best_elapsed_ms && updated_at.to_micros_since_unix_epoch() @@ -1725,16 +1720,18 @@ fn upsert_puzzle_leaderboard_entry( return; } - ctx.db.puzzle_leaderboard_entry().insert(PuzzleLeaderboardEntryRow { - entry_id, - profile_id: profile_id.to_string(), - grid_size, - user_id: user_id.to_string(), - nickname: nickname.to_string(), - best_elapsed_ms: elapsed_ms, - last_run_id: run_id.to_string(), - updated_at, - }); + ctx.db + .puzzle_leaderboard_entry() + .insert(PuzzleLeaderboardEntryRow { + entry_id, + profile_id: profile_id.to_string(), + grid_size, + user_id: user_id.to_string(), + nickname: nickname.to_string(), + best_elapsed_ms: elapsed_ms, + last_run_id: run_id.to_string(), + updated_at, + }); } fn list_puzzle_leaderboard_entries( @@ -1799,8 +1796,8 @@ fn deserialize_run(value: &str) -> Result { mod tests { use super::*; use module_puzzle::{ - build_generated_candidates, empty_anchor_pack, recommendation_score, tag_similarity_score, - PuzzleLeaderboardEntry, + PuzzleLeaderboardEntry, build_generated_candidates, empty_anchor_pack, + recommendation_score, tag_similarity_score, }; #[test] diff --git a/src/components/InventoryPanel.tsx b/src/components/InventoryPanel.tsx index f331cb54..c9f64847 100644 --- a/src/components/InventoryPanel.tsx +++ b/src/components/InventoryPanel.tsx @@ -1,7 +1,11 @@ import { useMemo, useState } from 'react'; +import type { + RuntimeStoryEquipmentSlotView, + RuntimeStoryForgeRecipeView, + RuntimeStoryInventoryItemView, +} from '../../packages/shared/src/contracts/rpgRuntimeStoryState'; import { formatCurrency } from '../data/economy'; -import { type ForgeRecipeView } from '../data/forgeSystem'; import { buildInitialPlayerInventory } from '../data/npcInteractions'; import { Character, @@ -25,9 +29,12 @@ interface InventoryPanelProps { playerMana: number; playerMaxMana: number; inBattle: boolean; + currencyText?: string | null; + backpackItems?: RuntimeStoryInventoryItemView[]; + equipmentSlots?: RuntimeStoryEquipmentSlotView[]; onUseItem: (itemId: string) => Promise; onEquipItem: (itemId: string) => Promise; - forgeRecipes: ForgeRecipeView[]; + forgeRecipes: RuntimeStoryForgeRecipeView[]; onCraftRecipe: (recipeId: string) => Promise; onDismantleItem: (itemId: string) => Promise; onReforgeItem: (itemId: string) => Promise; @@ -42,8 +49,15 @@ export function InventoryPanel({ playerInventory, playerCurrency, inBattle, + currencyText = null, + backpackItems = [], + equipmentSlots: _equipmentSlots = [], + onUseItem: _onUseItem, + onEquipItem: _onEquipItem, forgeRecipes, onCraftRecipe, + onDismantleItem: _onDismantleItem, + onReforgeItem: _onReforgeItem, continueGameDigest = null, narrativeCodex = [], narrativeQaReport = null, @@ -51,12 +65,24 @@ export function InventoryPanel({ const [selectedItem, setSelectedItem] = useState(null); const [forgeActionKey, setForgeActionKey] = useState(null); + const serverInventoryItems = useMemo( + () => + backpackItems + .map((view) => view.item as unknown as InventoryItem) + .filter( + (item) => + typeof item.id === 'string' && typeof item.name === 'string', + ), + [backpackItems], + ); const inventoryItems = useMemo( () => - playerInventory.length > 0 - ? playerInventory - : buildInitialPlayerInventory(playerCharacter, worldType), - [playerCharacter, playerInventory, worldType], + serverInventoryItems.length > 0 + ? serverInventoryItems + : playerInventory.length > 0 + ? playerInventory + : buildInitialPlayerInventory(playerCharacter, worldType), + [playerCharacter, playerInventory, serverInventoryItems, worldType], ); const documentItems = useMemo( () => inventoryItems.filter((item) => item.category === '文书' || item.tags.includes('document')), @@ -141,7 +167,7 @@ export function InventoryPanel({
工坊 - {formatCurrency(playerCurrency, worldType)} + {currencyText ?? formatCurrency(playerCurrency, worldType)}
@@ -169,6 +195,7 @@ export function InventoryPanel({ type="button" disabled={ !recipe.canCraft || + !recipe.action.enabled || inBattle || forgeActionKey === recipe.id } @@ -181,7 +208,7 @@ export function InventoryPanel({ } }} className={`rounded-lg border px-3 py-1.5 text-xs transition ${ - recipe.canCraft && !inBattle + recipe.canCraft && recipe.action.enabled && !inBattle ? 'border-emerald-400/30 bg-emerald-500/10 text-emerald-100 hover:bg-emerald-500/20' : 'border-white/8 bg-black/20 text-zinc-500' }`} @@ -208,6 +235,12 @@ export function InventoryPanel({ ))}
+ {(!recipe.canCraft || !recipe.action.enabled) && + (recipe.disabledReason || recipe.action.reason) && ( +
+ {recipe.disabledReason ?? recipe.action.reason} +
+ )} ))} diff --git a/src/components/NpcModals.tsx b/src/components/NpcModals.tsx index f2c90a13..0d209f93 100644 --- a/src/components/NpcModals.tsx +++ b/src/components/NpcModals.tsx @@ -4,10 +4,7 @@ import { useState } from 'react'; import { getCharacterById } from '../data/characterPresets'; import { formatCurrency, - getCurrencyName, getInventoryItemValue, - getNpcBuybackPrice, - getNpcPurchasePrice, } from '../data/economy'; import { getEquipmentSlotFromItem, @@ -19,12 +16,15 @@ import { getInventoryTagLabels, } from '../data/itemPresentation'; import { - buildInitialNpcState, - getGiftCandidates, getRarityLabel, } from '../data/npcInteractions'; import { StoryGenerationNpcUi } from '../hooks/rpg-runtime-story'; -import { GameState, InventoryItem } from '../types'; +import { + GameState, + InventoryItem, + RuntimeNpcGiftItemView, + RuntimeNpcTradeItemView, +} from '../types'; import { CHROME_ICONS, getInventoryItemVisualSrc, getNineSliceStyle, UI_CHROME } from '../uiAssets'; import { PixelIcon } from './PixelIcon'; @@ -38,10 +38,6 @@ type TradeDetailState = { source: 'buy' | 'sell'; } | null; -function getNpcEncounterKey(encounter: NonNullable) { - return encounter.id ?? encounter.npcName; -} - function getItemVisualSrc(item: InventoryItem) { return getInventoryItemVisualSrc(item); } @@ -88,7 +84,7 @@ function TradeItemRow({
-
{item.name}
+
{item.name ?? item.id}
{item.category} / {getRarityLabel(item.rarity)} / 单价 {unitPrice} {currencyName}
@@ -150,71 +146,70 @@ function TradeQuantityStepper({ export function NpcModals({ gameState, npcUi }: NpcModalsProps) { const [tradeDetail, setTradeDetail] = useState(null); - const currencyName = getCurrencyName( - gameState.worldType, - gameState.customWorldProfile, - ); + const npcInteraction = gameState.runtimeNpcInteraction ?? null; + const currencyName = npcInteraction?.currencyName ?? '钱币'; const tradeModal = npcUi.tradeModal; - const tradeNpcState = tradeModal - ? gameState.npcStates[getNpcEncounterKey(tradeModal.encounter)] - ?? buildInitialNpcState(tradeModal.encounter, gameState.worldType, gameState) - : null; - const selectedTradeNpcItem = tradeNpcState?.inventory.find(item => item.id === tradeModal?.selectedNpcItemId) ?? null; - const selectedTradePlayerItem = tradeModal?.selectedPlayerItemId - ? gameState.playerInventory.find(item => item.id === tradeModal?.selectedPlayerItemId) ?? null - : null; const tradeMode = tradeModal?.mode ?? 'buy'; - const activeTradeItem = tradeMode === 'buy' ? selectedTradeNpcItem : selectedTradePlayerItem; - const activeTradeUnitPrice = tradeModal && activeTradeItem && tradeNpcState - ? tradeMode === 'buy' - ? getNpcPurchasePrice(activeTradeItem, tradeNpcState.affinity) - : getNpcBuybackPrice(activeTradeItem, tradeNpcState.affinity) - : 0; - const activeTradeMaxQuantity = activeTradeItem?.quantity ?? 0; + const tradeItemViews: RuntimeNpcTradeItemView[] = tradeMode === 'buy' + ? npcInteraction?.trade.buyItems ?? [] + : npcInteraction?.trade.sellItems ?? []; + const activeTradeView = tradeModal + ? tradeItemViews.find(view => + view.itemId === (tradeMode === 'buy' + ? tradeModal.selectedNpcItemId + : tradeModal.selectedPlayerItemId), + ) ?? null + : null; + const activeTradeItem = activeTradeView?.item ?? null; + const activeTradeUnitPrice = activeTradeView?.unitPrice ?? 0; + const activeTradeMaxQuantity = activeTradeView?.maxQuantity ?? 0; const activeTradeQuantity = tradeModal ? Math.max(1, Math.min(tradeModal.selectedQuantity, Math.max(1, activeTradeMaxQuantity))) : 1; const activeTradeTotalPrice = activeTradeUnitPrice * activeTradeQuantity; const canConfirmTrade = Boolean( - activeTradeItem && - activeTradeMaxQuantity > 0 && - activeTradeQuantity >= 1 && - activeTradeQuantity <= activeTradeMaxQuantity && - (tradeMode === 'sell' || gameState.playerCurrency >= activeTradeTotalPrice) + activeTradeView && + activeTradeView.canSubmit && + activeTradeQuantity >= 1, ); - const tradeItemList = tradeMode === 'buy' - ? (tradeNpcState?.inventory ?? []) - : gameState.playerInventory; + const tradeItemList = tradeItemViews; const tradeDetailItem = tradeDetail - ? (tradeDetail.source === 'buy' ? tradeNpcState?.inventory ?? [] : gameState.playerInventory) - .find(item => item.id === tradeDetail.itemId) ?? null + ? (tradeDetail.source === 'buy' + ? npcInteraction?.trade.buyItems ?? [] + : npcInteraction?.trade.sellItems ?? []) + .find(view => view.itemId === tradeDetail.itemId)?.item ?? null + : null; + const tradeDetailView = tradeDetail + ? (tradeDetail.source === 'buy' + ? npcInteraction?.trade.buyItems ?? [] + : npcInteraction?.trade.sellItems ?? []) + .find(view => view.itemId === tradeDetail.itemId) ?? null : null; const tradeDetailUseEffect = tradeDetailItem && gameState.playerCharacter ? resolveInventoryItemUseEffect(tradeDetailItem, gameState.playerCharacter) : null; const tradeDetailEquipSlot = tradeDetailItem ? getEquipmentSlotFromItem(tradeDetailItem) : null; const tradeDetailEffectText = buildTradeUseEffectText(tradeDetailUseEffect); - const giftCandidates = npcUi.giftModal - ? getGiftCandidates(gameState.playerInventory, npcUi.giftModal.encounter, { - worldType: gameState.worldType, - customWorldProfile: gameState.customWorldProfile, - }) + const giftCandidates: RuntimeNpcGiftItemView[] = npcUi.giftModal + ? npcInteraction?.gift.items ?? [] : []; + const activeGiftView = + giftCandidates.find(item => item.itemId === npcUi.giftModal?.selectedItemId) ?? null; - const handleTradeItemClick = (item: InventoryItem) => { + const handleTradeItemClick = (view: RuntimeNpcTradeItemView) => { if (tradeMode === 'buy') { - npcUi.selectTradeNpcItem(item.id); - setTradeDetail({ itemId: item.id, source: 'buy' }); + npcUi.selectTradeNpcItem(view.itemId); + setTradeDetail({ itemId: view.itemId, source: 'buy' }); return; } - npcUi.selectTradePlayerItem(item.id); - setTradeDetail({ itemId: item.id, source: 'sell' }); + npcUi.selectTradePlayerItem(view.itemId); + setTradeDetail({ itemId: view.itemId, source: 'sell' }); }; return ( - {tradeModal && tradeNpcState && ( + {tradeModal && (
交易
- {tradeModal.encounter.npcName} / 你当前{currencyName}:{gameState.playerCurrency} + {npcInteraction?.npcName ?? tradeModal.encounter.npcName} / 你当前{currencyName}:{npcInteraction?.playerCurrency ?? gameState.playerCurrency}
- diff --git a/src/components/asset-studio/characterAssetWorkflowPersistence.test.ts b/src/components/asset-studio/characterAssetWorkflowPersistence.test.ts new file mode 100644 index 00000000..53c0a341 --- /dev/null +++ b/src/components/asset-studio/characterAssetWorkflowPersistence.test.ts @@ -0,0 +1,106 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { + putCharacterRoleAssetWorkflow, + resolveCharacterRoleAssetWorkflow, +} from './characterAssetWorkflowPersistence'; + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +describe('角色资产工坊 workflow client', () => { + it('通过后端 workflow 接口解析默认 prompt 和缓存合并结果', async () => { + const fetchMock = vi.fn().mockResolvedValue({ + ok: true, + text: async () => + JSON.stringify({ + ok: true, + cache: null, + workflow: { + defaultPromptBundle: { + visualPromptText: '默认视觉', + animationPromptText: '默认动作', + scenePromptText: '默认场景', + }, + visualPromptText: '默认视觉', + animationPromptText: '默认动作', + animationPromptTextByKey: { run: '默认动作' }, + visualDrafts: [], + selectedVisualDraftId: '', + selectedAnimation: 'run', + }, + }), + }); + vi.stubGlobal('fetch', fetchMock); + + const result = await resolveCharacterRoleAssetWorkflow({ + characterId: 'role 01', + cacheScopeId: 'world-01', + role: { + id: 'role 01', + name: '沈砺', + title: '灰炬向导', + role: '边路同行者', + }, + }); + + expect(result.workflow.visualPromptText).toBe('默认视觉'); + expect(fetchMock).toHaveBeenCalledWith( + '/api/runtime/custom-world/asset-studio/role/role%2001/workflow', + expect.objectContaining({ + method: 'POST', + body: JSON.stringify({ + cacheScopeId: 'world-01', + role: { + id: 'role 01', + name: '沈砺', + title: '灰炬向导', + role: '边路同行者', + }, + }), + }), + ); + }); + + it('使用 PUT 保存用户当前工坊草稿缓存', async () => { + const fetchMock = vi.fn().mockResolvedValue({ + ok: true, + text: async () => + JSON.stringify({ + ok: true, + cache: { characterId: 'role-01' }, + saveMessage: '已保存', + }), + }); + vi.stubGlobal('fetch', fetchMock); + + await putCharacterRoleAssetWorkflow({ + characterId: 'role-01', + cacheScopeId: 'world-01', + visualPromptText: '视觉草稿', + animationPromptText: '动作草稿', + animationPromptTextByKey: { run: '奔跑草稿' }, + visualDrafts: [], + selectedVisualDraftId: '', + selectedAnimation: 'run', + }); + + expect(fetchMock).toHaveBeenCalledWith( + '/api/runtime/custom-world/asset-studio/role/role-01/workflow', + expect.objectContaining({ + method: 'PUT', + body: JSON.stringify({ + characterId: 'role-01', + cacheScopeId: 'world-01', + visualPromptText: '视觉草稿', + animationPromptText: '动作草稿', + animationPromptTextByKey: { run: '奔跑草稿' }, + visualDrafts: [], + selectedVisualDraftId: '', + selectedAnimation: 'run', + }), + }), + ); + }); +}); diff --git a/src/components/asset-studio/characterAssetWorkflowPersistence.ts b/src/components/asset-studio/characterAssetWorkflowPersistence.ts index 14315b09..73cf74dd 100644 --- a/src/components/asset-studio/characterAssetWorkflowPersistence.ts +++ b/src/components/asset-studio/characterAssetWorkflowPersistence.ts @@ -2,7 +2,10 @@ import { ASSET_API_PATHS, postApiJson, } from '../../editor/shared/editorApiClient'; -import { fetchJson } from '../../editor/shared/jsonClient'; +import { + fetchJson, + parseApiErrorMessage, +} from '../../editor/shared/jsonClient'; export const CHARACTER_VISUAL_GENERATE_API_PATH = ASSET_API_PATHS.characterVisualGenerate; @@ -21,6 +24,8 @@ export const CHARACTER_ANIMATION_IMPORT_VIDEO_API_PATH = ASSET_API_PATHS.characterAnimationImportVideo; export const CHARACTER_ANIMATION_TEMPLATES_API_PATH = ASSET_API_PATHS.characterAnimationTemplates; +export const ROLE_ASSET_WORKFLOW_API_PATH = + '/api/runtime/custom-world/asset-studio/role'; export type CharacterVisualSourceMode = | 'text-to-image' @@ -61,6 +66,48 @@ export type CharacterAssetWorkflowCache = { updatedAt?: string; }; +export type CharacterAssetRolePromptInput = { + id: string; + name?: string; + title?: string; + role?: string; + visualDescription?: string; + actionDescription?: string; + sceneVisualDescription?: string; + description?: string; + backstory?: string; + personality?: string; + motivation?: string; + combatStyle?: string; + tags?: string[]; + imageSrc?: string; + generatedVisualAssetId?: string; + generatedAnimationSetId?: string; + animationMap?: Record | null; +}; + +export type CharacterRolePromptBundle = { + visualPromptText: string; + animationPromptText: string; + scenePromptText: string; +}; + +export type CharacterRoleAssetWorkflow = { + role: CharacterAssetRolePromptInput; + defaultPromptBundle: CharacterRolePromptBundle; + visualPromptText: string; + animationPromptText: string; + animationPromptTextByKey: Record; + visualDrafts: CharacterVisualDraft[]; + selectedVisualDraftId: string; + selectedAnimation: string; + imageSrc?: string; + generatedVisualAssetId?: string; + generatedAnimationSetId?: string; + animationMap?: Record | null; + updatedAt?: string; +}; + export type CharacterVisualGenerationPayload = { characterId: string; sourceMode: Exclude; @@ -185,6 +232,60 @@ export async function saveCharacterWorkflowCache( ); } +export async function resolveCharacterRoleAssetWorkflow(payload: { + characterId: string; + cacheScopeId?: string; + role: CharacterAssetRolePromptInput; +}) { + const { characterId, cacheScopeId, role } = payload; + return postApiJson<{ + ok: true; + cache: CharacterAssetWorkflowCache | null; + workflow: CharacterRoleAssetWorkflow; + }>( + `${ROLE_ASSET_WORKFLOW_API_PATH}/${encodeURIComponent(characterId)}/workflow`, + { + cacheScopeId, + role, + }, + '读取角色资产工坊工作流失败', + ); +} + +export async function putCharacterRoleAssetWorkflow( + payload: CharacterAssetWorkflowCache, +) { + const url = `${ROLE_ASSET_WORKFLOW_API_PATH}/${encodeURIComponent(payload.characterId)}/workflow`; + const response = await fetch(url, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + }); + const responseText = await response.text(); + + if (!response.ok) { + throw new Error( + parseApiErrorMessage(responseText, '保存角色资产工坊缓存失败'), + ); + } + + return responseText + ? (JSON.parse(responseText) as { + ok: true; + cache: CharacterAssetWorkflowCache; + saveMessage: string; + }) + : ({ + ok: true, + cache: payload, + saveMessage: '', + } as { + ok: true; + cache: CharacterAssetWorkflowCache; + saveMessage: string; + }); +} + export async function fetchCharacterVisualJobStatus(taskId: string) { return fetchJson( `${CHARACTER_VISUAL_JOB_API_PATH}/${encodeURIComponent(taskId)}`, diff --git a/src/components/asset-studio/customWorldRolePromptDefaults.test.ts b/src/components/asset-studio/customWorldRolePromptDefaults.test.ts deleted file mode 100644 index 6fe8edb7..00000000 --- a/src/components/asset-studio/customWorldRolePromptDefaults.test.ts +++ /dev/null @@ -1,51 +0,0 @@ -import { describe, expect, it } from 'vitest'; - -import { buildDefaultRolePromptBundle } from './customWorldRolePromptDefaults'; - -describe('buildDefaultRolePromptBundle', () => { - it('uses model-generated role descriptions directly', () => { - const result = buildDefaultRolePromptBundle({ - name: '沈砺', - title: '灰炬向导', - role: '边路同行者', - visualDescription: - '灰黑短斗篷压着风痕,肩侧挂着旧路标与短弓,整个人像常年在裂潮边路里行走的人。', - actionDescription: - '起手先观察风向和站位,再用短弓牵制后迅速贴近补刀,动作克制但很准。', - sceneVisualDescription: - '他常出现的边路哨点铺着潮湿石板,旧灯火和风旗一直在晃,空气里带着将散未散的盐雾。', - description: '熟悉裂潮边路的灰炬向导。', - }); - - expect(result.visualPromptText).toBe( - '灰黑短斗篷压着风痕,肩侧挂着旧路标与短弓,整个人像常年在裂潮边路里行走的人。', - ); - expect(result.animationPromptText).toBe( - '起手先观察风向和站位,再用短弓牵制后迅速贴近补刀,动作克制但很准。', - ); - expect(result.scenePromptText).toBe( - '他常出现的边路哨点铺着潮湿石板,旧灯火和风旗一直在晃,空气里带着将散未散的盐雾。', - ); - }); - - it('falls back to existing entity descriptions without assembling new rules', () => { - const result = buildDefaultRolePromptBundle({ - name: '顾潮音', - title: '港口守望者', - role: '场景角色', - description: '总在潮雾港高处盯着来往船影的守望者。', - personality: '寡言、敏锐、先看人再开口。', - combatStyle: '长枪封线后借高差压制。', - motivation: '想在港口旧秩序彻底崩掉前找出新的站位。', - backstory: '他把许多没说出口的旧案痕迹留在港口高处。', - tags: ['潮雾港', '守望', '旧案'], - }); - - expect(result.visualPromptText).toBe('总在潮雾港高处盯着来往船影的守望者。'); - expect(result.animationPromptText).toBe('长枪封线后借高差压制。'); - expect(result.scenePromptText).toBe('他把许多没说出口的旧案痕迹留在港口高处。'); - expect(result.visualPromptText).not.toContain('经典横版像素动作角色'); - expect(result.visualPromptText).not.toContain('深色粗轮廓配合清晰大色块'); - expect(result.visualPromptText).not.toContain('提示词'); - }); -}); diff --git a/src/components/asset-studio/customWorldRolePromptDefaults.ts b/src/components/asset-studio/customWorldRolePromptDefaults.ts deleted file mode 100644 index d90daa57..00000000 --- a/src/components/asset-studio/customWorldRolePromptDefaults.ts +++ /dev/null @@ -1 +0,0 @@ -export * from '../../prompts/customWorldRolePromptDefaults'; diff --git a/src/components/big-fish-result/BigFishResultView.test.tsx b/src/components/big-fish-result/BigFishResultView.test.tsx index 7219673d..d46efac7 100644 --- a/src/components/big-fish-result/BigFishResultView.test.tsx +++ b/src/components/big-fish-result/BigFishResultView.test.tsx @@ -60,9 +60,17 @@ function createSession(): BigFishSessionSnapshotResponse { level: 1, name: '荧潮幼体', oneLineFantasy: '在深海荧光裂谷中寻找第一个同伴。', + textDescription: + '荧潮幼体是深海谜境里的初始个体,体型最小,会先谨慎试探并寻找可吞噬目标。', silhouetteDirection: '圆润鱼苗', sizeRatio: 1, + visualDescription: + '带有浅青色荧光纹路的小型鱼苗,轮廓圆润,呈现弱小但灵动的开局形象。', visualPromptSeed: '深海荧光幼体', + idleMotionDescription: + '待机时轻微漂浮,尾鳍做小幅摆动,像是在观察周围海流。', + moveMotionDescription: + '移动时身体前探,尾鳍清晰摆尾推进,呈现连续游动感。', motionPromptSeed: '轻微摆尾', mergeSourceLevel: null, preyWindow: [1], @@ -147,6 +155,34 @@ describe('BigFishResultView', () => { expect(screen.getByAltText('深海谜境 场地背景')).toBeTruthy(); }); + test('uses level descriptions as default prompt content in asset studio', () => { + render( + {}} + onExecuteAction={() => {}} + onStartTestRun={() => {}} + />, + ); + + fireEvent.click(screen.getByRole('button', { name: '主图' })); + expect( + screen.getByText('带有浅青色荧光纹路的小型鱼苗,轮廓圆润,呈现弱小但灵动的开局形象。'), + ).toBeTruthy(); + fireEvent.click(screen.getByRole('button', { name: '关闭' })); + + fireEvent.click(screen.getByRole('button', { name: '待机' })); + expect( + screen.getByText('待机时轻微漂浮,尾鳍做小幅摆动,像是在观察周围海流。'), + ).toBeTruthy(); + fireEvent.click(screen.getByRole('button', { name: '关闭' })); + + fireEvent.click(screen.getByRole('button', { name: '移动' })); + expect( + screen.getByText('移动时身体前探,尾鳍清晰摆尾推进,呈现连续游动感。'), + ).toBeTruthy(); + }); + test('shows publish failures in a dismissible modal', () => { const onDismissError = vi.fn(); diff --git a/src/components/big-fish-result/BigFishResultView.tsx b/src/components/big-fish-result/BigFishResultView.tsx index 14c53a34..d025805f 100644 --- a/src/components/big-fish-result/BigFishResultView.tsx +++ b/src/components/big-fish-result/BigFishResultView.tsx @@ -128,8 +128,10 @@ function BigFishAssetStudioModal({ target.kind === 'stage_background' ? draft.background.backgroundPromptSeed : target.kind === 'level_main_image' - ? target.level.visualPromptSeed - : `${target.level.motionPromptSeed} / ${target.motionKey}`; + ? target.level.visualDescription || target.level.visualPromptSeed + : target.motionKey === 'move_swim' + ? target.level.moveMotionDescription || target.level.motionPromptSeed + : target.level.idleMotionDescription || target.level.motionPromptSeed; const execute = () => { if (target.kind === 'stage_background') { @@ -162,7 +164,7 @@ function BigFishAssetStudioModal({
{target.kind === 'stage_background' ? draft.background.theme - : target.level.oneLineFantasy} + : target.level.textDescription || target.level.oneLineFantasy}
diff --git a/src/components/platform-entry/PlatformEntryCreationTypeModal.tsx b/src/components/platform-entry/PlatformEntryCreationTypeModal.tsx index fdde7c8b..2ab34ca1 100644 --- a/src/components/platform-entry/PlatformEntryCreationTypeModal.tsx +++ b/src/components/platform-entry/PlatformEntryCreationTypeModal.tsx @@ -79,6 +79,12 @@ export function PlatformEntryCreationTypeModal({ return null; } + // 平台入口只渲染当前允许展示的创作类型; + // 被隐藏的玩法仍保留既有实现与路由,不在这里删除能力本体。 + const visibleCreationTypes = PLATFORM_CREATION_TYPES.filter( + (item) => !item.hidden, + ); + return (
- {PLATFORM_CREATION_TYPES.map((item) => ( + {visibleCreationTypes.map((item) => ( - rpgCreationPreviewAdapter.buildPreviewFromSession(session), + syncAgentCreationResultView: sessionController.syncAgentCreationResultView, + buildDraftResultProfile: (view) => + rpgCreationPreviewAdapter.buildPreviewFromResultView(view), }); const detailNavigation = usePlatformEntryLibraryDetail({ @@ -630,9 +631,9 @@ export function PlatformEntryFlowShellImpl({ refreshCustomWorldWorks: platformBootstrap.refreshCustomWorldWorks, refreshPublishedGallery: platformBootstrap.refreshPublishedGallery, persistAgentUiState: sessionController.persistAgentUiState, - syncAgentSessionSnapshot: sessionController.syncAgentSessionSnapshot, - buildDraftResultProfile: (session) => - rpgCreationPreviewAdapter.buildPreviewFromSession(session), + syncAgentCreationResultView: sessionController.syncAgentCreationResultView, + buildDraftResultProfile: (view) => + rpgCreationPreviewAdapter.buildPreviewFromResultView(view), suppressAgentDraftResultAutoOpen: sessionController.suppressAgentDraftResultAutoOpen, releaseAgentDraftResultAutoOpenSuppression: @@ -646,9 +647,9 @@ export function PlatformEntryFlowShellImpl({ isAgentDraftResultView: sessionController.isAgentDraftResultView, activeAgentSessionId: sessionController.activeAgentSessionId, generatedCustomWorldProfile: sessionController.generatedCustomWorldProfile, - agentSessionProfile: sessionController.agentDraftResultProfile, - agentSession: sessionController.agentSession, handleCustomWorldSelect, + syncAgentDraftResultProfile: + autosaveCoordinator.syncAgentDraftResultProfile, executePublishWorld: async () => { const latestSession = await autosaveCoordinator.executeAgentActionAndWait( { @@ -664,6 +665,7 @@ export function PlatformEntryFlowShellImpl({ ]); return latestSession; }, + syncAgentCreationResultView: sessionController.syncAgentCreationResultView, setGeneratedCustomWorldProfile: sessionController.setGeneratedCustomWorldProfile, }); @@ -1252,7 +1254,9 @@ export function PlatformEntryFlowShellImpl({ } setBigFishRun((currentRun) => - currentRun ? advanceLocalBigFishRuntimeRun(currentRun, payload) : currentRun, + currentRun + ? advanceLocalBigFishRuntimeRun(currentRun, payload) + : currentRun, ); }, [bigFishRun], @@ -1308,13 +1312,21 @@ export function PlatformEntryFlowShellImpl({ nickname: authUi?.user?.displayName?.trim() || '玩家', }; + if (isLocalPuzzleRun(puzzleRun)) { + setPuzzleRun(submitLocalPuzzleLeaderboard(puzzleRun, payload.nickname)); + setIsPuzzleLeaderboardBusy(false); + return; + } + void submitPuzzleLeaderboard(puzzleRun.runId, payload) .then(({ run }) => { setPuzzleRun(run); }) .catch((error) => { submittedPuzzleLeaderboardKeysRef.current.delete(submitKey); - setPuzzleError(resolvePuzzleErrorMessage(error, '提交拼图排行榜失败。')); + setPuzzleError( + resolvePuzzleErrorMessage(error, '提交拼图排行榜失败。'), + ); }) .finally(() => { setIsPuzzleLeaderboardBusy(false); @@ -1684,25 +1696,25 @@ export function PlatformEntryFlowShellImpl({ const startBigFishRunFromWork = useCallback( (item: BigFishWorkSummary) => { - const sessionId = item.sourceSessionId?.trim(); - if (!sessionId) { - setBigFishError('当前作品缺少会话信息,暂时无法进入玩法。'); - return; - } + const sessionId = item.sourceSessionId?.trim(); + if (!sessionId) { + setBigFishError('当前作品缺少会话信息,暂时无法进入玩法。'); + return; + } - const publicWorkCode = buildBigFishPublicWorkCode(item.sourceSessionId); - setBigFishError(null); - bigFishFlow.setSession(null); - setBigFishRuntimeShare({ - title: item.title, - publicWorkCode, - }); - setBigFishRun(startLocalBigFishRuntimeRun({ work: item })); - setSelectionStage('big-fish-runtime'); - pushAppHistoryPath( - buildPublicWorkStagePath('big-fish-runtime', publicWorkCode), - ); - }, + const publicWorkCode = buildBigFishPublicWorkCode(item.sourceSessionId); + setBigFishError(null); + bigFishFlow.setSession(null); + setBigFishRuntimeShare({ + title: item.title, + publicWorkCode, + }); + setBigFishRun(startLocalBigFishRuntimeRun({ work: item })); + setSelectionStage('big-fish-runtime'); + pushAppHistoryPath( + buildPublicWorkStagePath('big-fish-runtime', publicWorkCode), + ); + }, [bigFishFlow, setSelectionStage], ); @@ -2532,17 +2544,17 @@ export function PlatformEntryFlowShellImpl({ } > - { - setSelectionStage(puzzleRuntimeReturnStage); - }} + { + setSelectionStage(puzzleRuntimeReturnStage); + }} onSwapPieces={(payload) => { void swapPuzzlePiecesInRun(payload); }} @@ -2627,9 +2639,7 @@ export function PlatformEntryFlowShellImpl({ progressLabel="" error={resultViewError} onProfileChange={(profile) => { - sessionController.setGeneratedCustomWorldProfile( - normalizeAgentBackedProfile(profile), - ); + sessionController.setGeneratedCustomWorldProfile(profile); }} onBack={ sessionController.isAgentDraftResultView @@ -2699,23 +2709,25 @@ export function PlatformEntryFlowShellImpl({ kind === 'landmark' ? 'generate_landmarks' : 'generate_characters'; - const latestSession = - await autosaveCoordinator.executeAgentActionAndWait( - { - action, - count: 1, - ...(kind === 'playable' - ? { roleType: 'playable' as const } - : kind === 'story' - ? { roleType: 'story' as const } - : {}), - }, + await autosaveCoordinator.executeAgentActionAndWait({ + action, + count: 1, + ...(kind === 'playable' + ? { roleType: 'playable' as const } + : kind === 'story' + ? { roleType: 'story' as const } + : {}), + }); + const latestView = + sessionController.activeAgentSessionId + ? await sessionController.syncAgentCreationResultView( + sessionController.activeAgentSessionId, + ) + : null; + const latestProfile = + rpgCreationPreviewAdapter.buildPreviewFromResultView( + latestView, ); - const latestProfile = latestSession - ? rpgCreationPreviewAdapter.buildPreviewFromSession( - latestSession, - ) - : null; if (latestProfile) { sessionController.setGeneratedCustomWorldProfile( latestProfile, @@ -2729,17 +2741,21 @@ export function PlatformEntryFlowShellImpl({ sessionController.isAgentDraftResultView ? async (kind, ids) => { if (ids.length === 0) return; - const latestSession = - await autosaveCoordinator.executeAgentActionAndWait( - kind === 'story' - ? { action: 'delete_characters', roleIds: ids } - : { action: 'delete_landmarks', sceneIds: ids }, + await autosaveCoordinator.executeAgentActionAndWait( + kind === 'story' + ? { action: 'delete_characters', roleIds: ids } + : { action: 'delete_landmarks', sceneIds: ids }, + ); + const latestView = + sessionController.activeAgentSessionId + ? await sessionController.syncAgentCreationResultView( + sessionController.activeAgentSessionId, + ) + : null; + const latestProfile = + rpgCreationPreviewAdapter.buildPreviewFromResultView( + latestView, ); - const latestProfile = latestSession - ? rpgCreationPreviewAdapter.buildPreviewFromSession( - latestSession, - ) - : null; if (latestProfile) { sessionController.setGeneratedCustomWorldProfile( latestProfile, diff --git a/src/components/platform-entry/platformEntryCreationTypes.ts b/src/components/platform-entry/platformEntryCreationTypes.ts index 4da77c8a..236cba6f 100644 --- a/src/components/platform-entry/platformEntryCreationTypes.ts +++ b/src/components/platform-entry/platformEntryCreationTypes.ts @@ -11,10 +11,12 @@ export type PlatformCreationTypeCard = { subtitle: string; badge: string; locked: boolean; + hidden?: boolean; }; /** * 创作页与类型弹层共用同一份模板元数据,避免多入口文案和可用状态漂移。 + * `hidden` 只控制平台入口是否展示,不影响既有玩法链路和路由能力。 */ export const PLATFORM_CREATION_TYPES: PlatformCreationTypeCard[] = [ { @@ -30,6 +32,7 @@ export const PLATFORM_CREATION_TYPES: PlatformCreationTypeCard[] = [ subtitle: '实时成长玩法', badge: '可创建', locked: false, + hidden: true, }, { id: 'puzzle', diff --git a/src/components/platform-entry/platformEntryShared.ts b/src/components/platform-entry/platformEntryShared.ts index accbc055..eee3adb4 100644 --- a/src/components/platform-entry/platformEntryShared.ts +++ b/src/components/platform-entry/platformEntryShared.ts @@ -4,6 +4,5 @@ */ export { buildCreationHubFallbackItems, - normalizeAgentBackedProfile, resolveRpgCreationErrorMessage, } from '../rpg-entry/rpgEntryShared'; diff --git a/src/components/platform-entry/platformEntryTypes.ts b/src/components/platform-entry/platformEntryTypes.ts index 0817458b..2b5ead0f 100644 --- a/src/components/platform-entry/platformEntryTypes.ts +++ b/src/components/platform-entry/platformEntryTypes.ts @@ -1,6 +1,7 @@ import type { CustomWorldAgentSessionSnapshot, } from '../../../packages/shared/src/contracts/customWorldAgent'; +import type { RpgCreationResultView } from '../../../packages/shared/src/contracts/rpgCreationResultView'; import type { HydratedSavedGameSnapshot } from '../../persistence/runtimeSnapshotTypes'; import type { CustomWorldProfile } from '../../types'; @@ -37,6 +38,7 @@ export type CustomWorldAutoSaveState = 'idle' | 'saving' | 'saved' | 'error'; export type SyncedAgentDraftResult = { session: CustomWorldAgentSessionSnapshot | null; profile: CustomWorldProfile | null; + view?: RpgCreationResultView | null; }; export type PlatformEntryFlowShellProps = { diff --git a/src/components/rpg-creation-asset-studio/RpgCreationRoleAssetStudioModalImpl.tsx b/src/components/rpg-creation-asset-studio/RpgCreationRoleAssetStudioModalImpl.tsx index f1962094..9c63c85c 100644 --- a/src/components/rpg-creation-asset-studio/RpgCreationRoleAssetStudioModalImpl.tsx +++ b/src/components/rpg-creation-asset-studio/RpgCreationRoleAssetStudioModalImpl.tsx @@ -18,10 +18,9 @@ import { readFileAsDataUrl } from '../asset-studio/characterAssetWorkflowModel'; import { type CharacterAssetWorkflowCache, type CharacterVisualDraft, - fetchCharacterWorkflowCache, - saveCharacterWorkflowCache, + putCharacterRoleAssetWorkflow, + resolveCharacterRoleAssetWorkflow, } from '../asset-studio/characterAssetWorkflowPersistence'; -import { buildDefaultRolePromptBundle } from '../asset-studio/customWorldRolePromptDefaults'; import { buildProjectPixelStyleReferenceBoard } from '../asset-studio/projectPixelStyleReference'; import { useAuthUi } from '../auth/AuthUiContext'; import { CharacterAnimator } from '../CharacterAnimator'; @@ -51,42 +50,6 @@ function clampAnimationPlaybackRate(value: number) { ); } -function buildDefaultAnimationPromptTextByKey(defaultText: string) { - return CORE_ACTIONS.reduce>>( - (result, action) => ({ - ...result, - [action.animation]: defaultText, - }), - {}, - ); -} - -function pickCachedAnimationPromptTextByKey( - cache: CharacterAssetWorkflowCache, - fallbackText: string, - preferFreshRoleText: boolean, -) { - const fromCache = cache.animationPromptTextByKey ?? {}; - - return CORE_ACTIONS.reduce>>( - (result, action) => { - const cachedText = fromCache[action.animation]?.trim(); - const legacyText = cache.animationPromptText?.trim(); - return { - ...result, - [action.animation]: preferFreshRoleText - ? fallbackText - : cachedText && !isLegacyGeneratedActionDescription(cachedText) - ? cachedText - : legacyText && !isLegacyGeneratedActionDescription(legacyText) - ? legacyText - : fallbackText, - }; - }, - {}, - ); -} - function roundAnimationFps(value: number) { return Math.round(value * 100) / 100; } @@ -321,37 +284,6 @@ function buildRoleCharacterBrief( .join('\n'); } -function isLegacyGeneratedVisualDescription(value: string) { - const normalized = value.trim(); - if (!normalized) { - return false; - } - - return [ - '2D 横版 RPG', - '纯绿色绿幕', - '2 到 2.5 头身', - '深色粗轮廓', - '身体整体朝右', - '脚底完整可见', - ].some((marker) => normalized.includes(marker)); -} - -function isLegacyGeneratedActionDescription(value: string) { - const normalized = value.trim(); - if (!normalized) { - return false; - } - - return [ - '动作气质参考:', - '发力起手明确', - '收招利落', - '动作表现偏向', - '起手克制', - ].some((marker) => normalized.includes(marker)); -} - function mergeRole( role: T, patch: Partial, @@ -561,13 +493,9 @@ export function RpgCreationRoleAssetStudioModal({ role.visualDescription, ], ); - const initialPromptBundle = useMemo( - () => buildDefaultRolePromptBundle(baseRole), - [baseRole], - ); - const [visualPromptText, setVisualPromptText] = useState( - initialPromptBundle.visualPromptText, - ); + const [defaultAnimationPromptText, setDefaultAnimationPromptText] = + useState(''); + const [visualPromptText, setVisualPromptText] = useState(''); const [referenceImageDataUrls, setReferenceImageDataUrls] = useState< string[] >([]); @@ -586,11 +514,7 @@ export function RpgCreationRoleAssetStudioModal({ ); const [animationPromptTextByKey, setAnimationPromptTextByKey] = useState< Partial> - >(() => - buildDefaultAnimationPromptTextByKey( - initialPromptBundle.animationPromptText, - ), - ); + >({}); const [animationStatusByKey, setAnimationStatusByKey] = useState< Partial> >({}); @@ -655,7 +579,7 @@ export function RpgCreationRoleAssetStudioModal({ CORE_ACTIONS[0]!; const animationPromptText = animationPromptTextByKey[selectedAnimation] ?? - initialPromptBundle.animationPromptText; + defaultAnimationPromptText; const previewCharacter = useMemo( () => buildAnimationPreviewCharacter({ @@ -727,12 +651,9 @@ export function RpgCreationRoleAssetStudioModal({ useEffect(() => { let cancelled = false; setWorkingRole(baseRole); - setVisualPromptText(initialPromptBundle.visualPromptText); - setAnimationPromptTextByKey( - buildDefaultAnimationPromptTextByKey( - initialPromptBundle.animationPromptText, - ), - ); + setDefaultAnimationPromptText(''); + setVisualPromptText(''); + setAnimationPromptTextByKey({}); setReferenceImageDataUrls([]); setVisualDrafts([]); setSelectedVisualDraftId(''); @@ -744,50 +665,52 @@ export function RpgCreationRoleAssetStudioModal({ setSaveStatus(null); setIsHydratingCache(true); - void fetchCharacterWorkflowCache(baseRole.id, cacheScopeId) + void resolveCharacterRoleAssetWorkflow({ + characterId: baseRole.id, + cacheScopeId, + role: { + ...baseRole, + animationMap: + (baseRole.animationMap as Record | undefined) ?? + null, + }, + }) .then((result) => { - if (cancelled || !result.cache) { + if (cancelled) { return; } - const cache = result.cache; - if (cacheScopeId && cache.cacheScopeId !== cacheScopeId) { - return; - } + const { workflow } = result; const nextRole = mergeRole(baseRole, { - imageSrc: cache.imageSrc ?? baseRole.imageSrc, + imageSrc: workflow.imageSrc ?? baseRole.imageSrc, generatedVisualAssetId: - cache.generatedVisualAssetId ?? baseRole.generatedVisualAssetId, + workflow.generatedVisualAssetId ?? baseRole.generatedVisualAssetId, generatedAnimationSetId: - cache.generatedAnimationSetId ?? baseRole.generatedAnimationSetId, + workflow.generatedAnimationSetId ?? + baseRole.generatedAnimationSetId, animationMap: - (cache.animationMap as EditableCustomWorldRole['animationMap']) ?? + (workflow.animationMap as EditableCustomWorldRole['animationMap']) ?? baseRole.animationMap, }); setWorkingRole(nextRole); - setVisualPromptText( - !baseRole.visualDescription?.trim() && - cache.visualPromptText && - !isLegacyGeneratedVisualDescription(cache.visualPromptText) - ? cache.visualPromptText - : initialPromptBundle.visualPromptText, + setDefaultAnimationPromptText( + workflow.defaultPromptBundle.animationPromptText, ); + setVisualPromptText(workflow.visualPromptText); setAnimationPromptTextByKey( - pickCachedAnimationPromptTextByKey( - cache, - initialPromptBundle.animationPromptText, - Boolean(baseRole.actionDescription?.trim()), - ), + workflow.animationPromptTextByKey as Partial< + Record + >, ); - setVisualDrafts(cache.visualDrafts ?? []); + setVisualDrafts(workflow.visualDrafts ?? []); setSelectedVisualDraftId( - cache.selectedVisualDraftId || cache.visualDrafts?.[0]?.id || '', + workflow.selectedVisualDraftId || workflow.visualDrafts?.[0]?.id || '', ); setSelectedAnimation( CORE_ACTIONS.some( - (item) => item.animation === cache.selectedAnimation, + (item) => item.animation === workflow.selectedAnimation, ) - ? (cache.selectedAnimation as AnimationState) + ? (workflow.selectedAnimation as AnimationState) : (CORE_ACTIONS[0]?.animation ?? AnimationState.IDLE), ); }) @@ -801,7 +724,7 @@ export function RpgCreationRoleAssetStudioModal({ return () => { cancelled = true; }; - }, [baseRole, cacheScopeId, initialPromptBundle, roleSnapshotKey]); + }, [baseRole, cacheScopeId, roleSnapshotKey]); useEffect(() => { if (isHydratingCache) { @@ -826,7 +749,7 @@ export function RpgCreationRoleAssetStudioModal({ unknown > | null, }; - void saveCharacterWorkflowCache(payload).catch(() => undefined); + void putCharacterRoleAssetWorkflow(payload).catch(() => undefined); }, 350); return () => { diff --git a/src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx b/src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx index f22cd9c3..39aedca7 100644 --- a/src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx +++ b/src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx @@ -8,6 +8,7 @@ import { beforeEach, expect, test, vi } from 'vitest'; import type { BigFishWorkSummary } from '../../../packages/shared/src/contracts/bigFishWorkSummary'; import type { CustomWorldAgentSessionSnapshot } from '../../../packages/shared/src/contracts/customWorldAgent'; import type { PuzzleWorkSummary } from '../../../packages/shared/src/contracts/puzzleWorkSummary'; +import type { RpgCreationResultView } from '../../../packages/shared/src/contracts/rpgCreationResultView'; import type { HydratedSavedGameSnapshot } from '../../persistence/runtimeSnapshotTypes'; import { ApiClientError } from '../../services/apiClient'; import type { AuthUser } from '../../services/authService'; @@ -32,6 +33,7 @@ import { createRpgCreationSession, executeRpgCreationAction, getRpgCreationOperation, + getRpgCreationResultView, getRpgCreationSession, listRpgCreationWorks, streamRpgCreationMessage, @@ -101,6 +103,7 @@ vi.mock('../../services/rpg-creation', () => ({ createRpgCreationSession: vi.fn(), executeRpgCreationAction: vi.fn(), getRpgCreationOperation: vi.fn(), + getRpgCreationResultView: vi.fn(), getRpgCreationSession: vi.fn(), listRpgCreationWorks: vi.fn(), streamRpgCreationMessage: vi.fn(), @@ -523,6 +526,48 @@ const compiledAgentDraftSession: CustomWorldAgentSessionSnapshot = { }, }; +function buildResultViewForSession( + session: CustomWorldAgentSessionSnapshot, +): RpgCreationResultView { + const profile = session.resultPreview?.preview ?? null; + const isResultStage = + session.stage === 'object_refining' || + session.stage === 'visual_refining' || + session.stage === 'long_tail_review' || + session.stage === 'ready_to_publish' || + session.stage === 'published'; + + return { + session, + profile, + profileSource: profile ? 'result_preview' : 'none', + targetStage: profile && isResultStage + ? 'custom-world-result' + : session.stage === 'error' + ? 'custom-world-generating' + : 'agent-workspace', + generationViewSource: session.stage === 'error' + ? 'agent-draft-foundation' + : null, + resultViewSource: profile && isResultStage ? 'agent-draft' : null, + canAutosaveLibrary: Boolean(profile && isResultStage), + canSyncResultProfile: + session.stage === 'object_refining' || + session.stage === 'visual_refining' || + session.stage === 'long_tail_review' || + session.stage === 'ready_to_publish', + publishReady: Boolean(session.resultPreview?.publishReady), + canEnterWorld: Boolean(session.resultPreview?.canEnterWorld), + blockerCount: session.resultPreview?.blockers?.length ?? 0, + recoveryAction: profile && isResultStage + ? 'open_result' + : session.stage === 'error' + ? 'resume_generation' + : 'continue_agent', + recoveryReason: null, + }; +} + type TestAuthValue = { user: AuthUser | null; canAccessProtectedData: boolean; @@ -573,8 +618,11 @@ function TestWrapper({ onContinueGame?: (snapshot?: HydratedSavedGameSnapshot | null) => void; onSelectWorld?: RpgEntryFlowShellProps['handleCustomWorldSelect']; } = {}) { - const [selectionStage, setSelectionStage] = - useState('platform'); + const [selectionStage, setSelectionStage] = useState(() => + window.location.pathname === '/creation/rpg/agent' + ? 'agent-workspace' + : 'platform', + ); const content = ( { - vi.clearAllMocks(); + vi.resetAllMocks(); window.history.replaceState(null, '', '/'); window.sessionStorage.clear(); window.localStorage.clear(); @@ -667,6 +715,9 @@ beforeEach(() => { vi.mocked(createRpgCreationSession).mockResolvedValue({ session: mockSession, }); + vi.mocked(getRpgCreationResultView).mockImplementation(async () => + buildResultViewForSession(mockSession), + ); vi.mocked(createBigFishCreationSession).mockResolvedValue({ session: { sessionId: 'big-fish-session-1', @@ -757,9 +808,17 @@ beforeEach(() => { level: 1, name: '微光孢子', oneLineFantasy: '像发光尘埃一样在深海漂浮。', + textDescription: + '微光孢子是机械深海生态中的起始个体,体型最小,会先漂浮试探并寻找可吞并目标。', silhouetteDirection: '圆润微型机械球', sizeRatio: 1, + visualDescription: + '带有浅色发光核心的微型机械鱼苗或孢子体,轮廓圆润,表现出弱小但灵动的初始形象。', visualPromptSeed: 'deep sea glowing mechanical spore', + idleMotionDescription: + '待机时轻轻漂浮,身体和尾部做小幅摆动,像在适应深海水流。', + moveMotionDescription: + '移动时核心前探,尾部快速摆动推进,带出轻盈的游动轨迹。', motionPromptSeed: 'soft floating mechanical spore', mergeSourceLevel: null, preyWindow: [1], @@ -1168,6 +1227,9 @@ test('create tab opens compiled agent draft in result refinement page', async () }, ]); vi.mocked(getRpgCreationSession).mockResolvedValue(compiledAgentDraftSession); + vi.mocked(getRpgCreationResultView).mockResolvedValue( + buildResultViewForSession(compiledAgentDraftSession), + ); render(); @@ -1271,6 +1333,13 @@ test('create tab resumes agent workspace when session has no draft profile even stage: 'clarifying', draftProfile: null, }); + vi.mocked(getRpgCreationResultView).mockResolvedValue( + buildResultViewForSession({ + ...mockSession, + stage: 'clarifying', + draftProfile: null, + }), + ); render(); @@ -1316,13 +1385,13 @@ test('opening a compiled draft with a missing agent session falls back to create ]) .mockResolvedValueOnce([]); - vi.mocked(getRpgCreationSession).mockRejectedValueOnce( - new ApiClientError({ - message: 'custom world agent session not found', - status: 404, - code: 'NOT_FOUND', - }), - ); + const missingSessionError = new ApiClientError({ + message: 'custom world agent session not found', + status: 404, + code: 'NOT_FOUND', + }); + vi.mocked(getRpgCreationSession).mockRejectedValueOnce(missingSessionError); + vi.mocked(getRpgCreationResultView).mockRejectedValueOnce(missingSessionError); render(); @@ -1699,6 +1768,22 @@ test('restoring an agent workspace ignores a stored session owned by another use expect(window.location.search).toBe(''); }); +test('restoring an agent workspace ignores explicit session pointer without local owner after login', async () => { + window.history.replaceState( + null, + '', + '/?customWorldSessionId=custom-world-agent-session-legacy', + ); + + render(); + + await waitFor(() => { + expect(window.location.search).toBe(''); + }); + + expect(getRpgCreationSession).not.toHaveBeenCalled(); +}); + test('refreshing platform home ignores stored agent workspace pointer without explicit restore path', async () => { window.sessionStorage.setItem( 'genarrative.custom-world-agent-ui.v1', @@ -2243,6 +2328,15 @@ test('failed draft work continues on generation progress view instead of agent w }, ]); vi.mocked(getRpgCreationSession).mockResolvedValue(mockSession); + vi.mocked(getRpgCreationResultView).mockResolvedValue({ + ...buildResultViewForSession({ + ...mockSession, + stage: 'error', + }), + targetStage: 'custom-world-generating', + generationViewSource: 'agent-draft-foundation', + recoveryAction: 'resume_generation', + }); render(); @@ -2267,6 +2361,9 @@ test('existing draft sessions open result page refinement instead of agent dialo error: null, }); vi.mocked(getRpgCreationSession).mockResolvedValue(compiledAgentDraftSession); + vi.mocked(getRpgCreationResultView).mockResolvedValue( + buildResultViewForSession(compiledAgentDraftSession), + ); render(); @@ -2306,7 +2403,7 @@ test('agent result view shows publish blocker dialog before publish action when progress: 100, error: null, }); - vi.mocked(getRpgCreationSession).mockResolvedValue({ + const blockedSession = { ...compiledAgentDraftSession, resultPreview: { ...compiledAgentDraftSession.resultPreview!, @@ -2319,7 +2416,11 @@ test('agent result view shows publish blocker dialog before publish action when }, ], }, - }); + } satisfies CustomWorldAgentSessionSnapshot; + vi.mocked(getRpgCreationSession).mockResolvedValue(blockedSession); + vi.mocked(getRpgCreationResultView).mockResolvedValue( + buildResultViewForSession(blockedSession), + ); render(); @@ -2424,6 +2525,11 @@ test('agent draft result publishes to gallery from publish panel', async () => { vi.mocked(getRpgCreationSession).mockImplementation(async () => hasPublishedWorld ? publishedSession : publishReadyDraftSession, ); + vi.mocked(getRpgCreationResultView).mockImplementation(async () => + buildResultViewForSession( + hasPublishedWorld ? publishedSession : publishReadyDraftSession, + ), + ); function PublishFlowWrapper() { const [selectionStage, setSelectionStage] = @@ -2482,7 +2588,7 @@ test('agent draft result test button enters current draft without publish gate', progress: 100, error: null, }); - vi.mocked(getRpgCreationSession).mockResolvedValue({ + const testDraftSession = { ...compiledAgentDraftSession, stage: 'ready_to_publish', resultPreview: { @@ -2497,7 +2603,11 @@ test('agent draft result test button enters current draft without publish gate', }, ], }, - }); + } satisfies CustomWorldAgentSessionSnapshot; + vi.mocked(getRpgCreationSession).mockResolvedValue(testDraftSession); + vi.mocked(getRpgCreationResultView).mockResolvedValue( + buildResultViewForSession(testDraftSession), + ); function TestDraftWrapper() { const [selectionStage, setSelectionStage] = @@ -2582,7 +2692,7 @@ test('agent result view does not keep legacy publish blockers when preview uses progress: 100, error: null, }); - vi.mocked(getRpgCreationSession).mockResolvedValue({ + const publishGateSession = { ...compiledAgentDraftSession, stage: 'ready_to_publish', resultPreview: { @@ -2650,7 +2760,11 @@ test('agent result view does not keep legacy publish blockers when preview uses ], }, }, - }); + } satisfies CustomWorldAgentSessionSnapshot; + vi.mocked(getRpgCreationSession).mockResolvedValue(publishGateSession); + vi.mocked(getRpgCreationResultView).mockResolvedValue( + buildResultViewForSession(publishGateSession), + ); render(); @@ -2809,6 +2923,9 @@ test('agent draft result back button returns to creation hub without syncing res }, } satisfies CustomWorldAgentSessionSnapshot; vi.mocked(getRpgCreationSession).mockResolvedValue(resultSession); + vi.mocked(getRpgCreationResultView).mockResolvedValue( + buildResultViewForSession(resultSession), + ); render(); @@ -2840,7 +2957,7 @@ test('agent draft result back button returns to creation hub without syncing res expect(screen.queryByText('世界档案')).toBeNull(); }); -test('agent draft result auto-save persists the latest profile from session draft without result sync action', async () => { +test('agent draft result auto-save syncs result profile before persisting backend result view', async () => { const user = userEvent.setup(); const syncedSession = { @@ -2940,6 +3057,35 @@ test('agent draft result auto-save persists the latest profile from session draf }, } satisfies CustomWorldAgentSessionSnapshot; vi.mocked(getRpgCreationSession).mockResolvedValue(syncedSession); + vi.mocked(getRpgCreationResultView).mockResolvedValue( + buildResultViewForSession(syncedSession), + ); + vi.mocked(executeRpgCreationAction).mockImplementation(async (_, payload) => ({ + operation: { + operationId: + payload.action === 'sync_result_profile' + ? 'operation-sync-result-profile-1' + : 'operation-draft-foundation-1', + type: payload.action, + status: 'queued', + phaseLabel: '已接收请求', + phaseDetail: + payload.action === 'sync_result_profile' + ? '正在同步结果页档案。' + : '正在准备生成世界底稿。', + progress: 10, + error: null, + }, + })); + vi.mocked(getRpgCreationOperation).mockResolvedValue({ + operationId: 'operation-sync-result-profile-1', + type: 'sync_result_profile', + status: 'completed', + phaseLabel: '结果页档案已同步', + phaseDetail: '服务端已根据最新结果页档案刷新会话预览。', + progress: 100, + error: null, + }); render(); @@ -2978,7 +3124,7 @@ test('agent draft result auto-save persists the latest profile from session draf sessionId === 'custom-world-agent-session-1' && payload?.action === 'sync_result_profile', ), - ).toBe(false); + ).toBe(true); }); test('agent draft result can open from server result preview without embedded legacyResultProfile', async () => { @@ -3021,6 +3167,9 @@ test('agent draft result can open from server result preview without embedded le } satisfies CustomWorldAgentSessionSnapshot; vi.mocked(getRpgCreationSession).mockResolvedValue(previewOnlySession); + vi.mocked(getRpgCreationResultView).mockResolvedValue( + buildResultViewForSession(previewOnlySession), + ); render(); diff --git a/src/components/rpg-entry/rpgEntryShared.test.ts b/src/components/rpg-entry/rpgEntryShared.test.ts new file mode 100644 index 00000000..3a8c5624 --- /dev/null +++ b/src/components/rpg-entry/rpgEntryShared.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, it } from 'vitest'; + +import type { CustomWorldProfile } from '../../types'; +import { + normalizeRpgEntryAgentBackedProfile, + stringifyRpgEntryAgentBackedProfile, +} from './rpgEntryShared'; + +describe('rpgEntryShared profile save boundary', () => { + it('does not rewrite settingText from creatorIntent on the frontend', () => { + const profile = { + id: 'cwprof_test', + settingText: '结果页用户正在编辑的草稿文案', + creatorIntent: { + worldHook: '海图会在午夜改写群岛航路', + playerPremise: '玩家是失忆领航员', + openingSituation: '正在禁航区醒来', + themeKeywords: ['海雾'], + toneDirectives: ['悬疑'], + coreConflicts: ['议会隐瞒沉船真相'], + keyCharacters: [ + { + name: '顾潮音', + role: '守灯人', + relationToPlayer: '旧识', + hiddenHook: '掌握伪造海图', + }, + ], + iconicElements: ['会说谎的罗盘'], + }, + } as CustomWorldProfile; + + expect(normalizeRpgEntryAgentBackedProfile(profile)).toBe(profile); + expect( + JSON.parse(stringifyRpgEntryAgentBackedProfile(profile)), + ).toMatchObject({ + settingText: '结果页用户正在编辑的草稿文案', + }); + }); +}); diff --git a/src/components/rpg-entry/rpgEntryShared.ts b/src/components/rpg-entry/rpgEntryShared.ts index 9b807cce..0aa5bae6 100644 --- a/src/components/rpg-entry/rpgEntryShared.ts +++ b/src/components/rpg-entry/rpgEntryShared.ts @@ -5,13 +5,9 @@ import type { } from '../../../packages/shared/src/contracts/customWorldAgent'; import type { CustomWorldLibraryEntry } from '../../../packages/shared/src/contracts/runtime'; import { ApiClientError, isTimeoutError } from '../../services/apiClient'; -import { buildCustomWorldCreatorIntentFoundationText } from '../../services/customWorldCreatorIntent'; import type { CustomWorldProfile } from '../../types'; -export function resolveRpgEntryErrorMessage( - error: unknown, - fallback: string, -) { +export function resolveRpgEntryErrorMessage(error: unknown, fallback: string) { if (isTimeoutError(error)) { if (/拼图/u.test(fallback)) { return '开启拼图创作工作台超时,请确认运行时后端已启动后重试。'; @@ -68,24 +64,15 @@ export function buildOptimisticRpgEntryAgentMessage( export function normalizeRpgEntryAgentBackedProfile( profile: CustomWorldProfile, ) { - const foundationText = buildCustomWorldCreatorIntentFoundationText( - profile.creatorIntent, - ).trim(); - - if (!foundationText || foundationText === profile.settingText.trim()) { - return profile; - } - - return { - ...profile, - settingText: foundationText, - } satisfies CustomWorldProfile; + // 中文注释:保存前 canonicalize 已迁到 server-rs; + // 这里保留透传函数只为了兼容旧导入,不再改写正式 profile 字段。 + return profile; } export function stringifyRpgEntryAgentBackedProfile( profile: CustomWorldProfile, ) { - return JSON.stringify(normalizeRpgEntryAgentBackedProfile(profile)); + return JSON.stringify(profile); } export function buildRpgEntryCreationHubFallbackItems( @@ -123,13 +110,9 @@ export function buildRpgEntryCreationHubFallbackItems( * 兼容创作链工作包已经接入的旧 helper 命名,避免本轮迁移波及其他并行改动。 */ export const resolveRpgCreationErrorMessage = resolveRpgEntryErrorMessage; -export const createFailedAgentOperation = - createFailedRpgEntryAgentOperation; -export const buildOptimisticAgentMessage = - buildOptimisticRpgEntryAgentMessage; -export const normalizeAgentBackedProfile = - normalizeRpgEntryAgentBackedProfile; -export const stringifyAgentBackedProfile = - stringifyRpgEntryAgentBackedProfile; +export const createFailedAgentOperation = createFailedRpgEntryAgentOperation; +export const buildOptimisticAgentMessage = buildOptimisticRpgEntryAgentMessage; +export const normalizeAgentBackedProfile = normalizeRpgEntryAgentBackedProfile; +export const stringifyAgentBackedProfile = stringifyRpgEntryAgentBackedProfile; export const buildCreationHubFallbackItems = buildRpgEntryCreationHubFallbackItems; diff --git a/src/components/rpg-entry/useRpgCreationEnterWorld.test.tsx b/src/components/rpg-entry/useRpgCreationEnterWorld.test.tsx index 4f0ef828..e048fd65 100644 --- a/src/components/rpg-entry/useRpgCreationEnterWorld.test.tsx +++ b/src/components/rpg-entry/useRpgCreationEnterWorld.test.tsx @@ -115,11 +115,6 @@ function buildSession(): CustomWorldAgentSessionSnapshot { describe('useRpgCreationEnterWorld', () => { it('Agent 草稿测试进入游戏时优先使用结果页当前 profile,而不是回退到会话快照', async () => { - const staleResultProfile = buildProfile({ - id: 'session-profile', - name: '会话旧快照', - imageSrc: '/template/old-role.png', - }); const resultProfile = buildProfile({ id: 'draft-profile', name: '结果页真相源', @@ -128,16 +123,21 @@ describe('useRpgCreationEnterWorld', () => { const handleCustomWorldSelect = vi.fn(); const setGeneratedCustomWorldProfile = vi.fn(); const executePublishWorld = vi.fn(async () => buildSession()); + const syncAgentCreationResultView = vi.fn(); + const syncAgentDraftResultProfile = vi.fn(async () => ({ + profile: resultProfile, + view: null, + })); function Harness() { const { enterWorldForTestFromCurrentResult } = useRpgCreationEnterWorld({ isAgentDraftResultView: true, activeAgentSessionId: 'session-1', generatedCustomWorldProfile: resultProfile, - agentSessionProfile: staleResultProfile, - agentSession: buildSession(), handleCustomWorldSelect, + syncAgentDraftResultProfile, executePublishWorld, + syncAgentCreationResultView, setGeneratedCustomWorldProfile, }); diff --git a/src/components/rpg-entry/useRpgCreationEnterWorld.ts b/src/components/rpg-entry/useRpgCreationEnterWorld.ts index 052a8721..a3c22707 100644 --- a/src/components/rpg-entry/useRpgCreationEnterWorld.ts +++ b/src/components/rpg-entry/useRpgCreationEnterWorld.ts @@ -1,6 +1,6 @@ import { useCallback } from 'react'; -import type { CustomWorldAgentSessionSnapshot } from '../../../packages/shared/src/contracts/customWorldAgent'; +import type { RpgCreationResultView } from '../../../packages/shared/src/contracts/rpgCreationResultView'; import type { CustomWorldRuntimeLaunchOptions } from '../platform-entry/platformEntryTypes'; import { rpgCreationPreviewAdapter } from '../../services/rpg-creation/rpgCreationPreviewAdapter'; import type { CustomWorldProfile } from '../../types'; @@ -9,13 +9,17 @@ type UseRpgCreationEnterWorldParams = { isAgentDraftResultView: boolean; activeAgentSessionId: string | null; generatedCustomWorldProfile: CustomWorldProfile | null; - agentSessionProfile: CustomWorldProfile | null; - agentSession: CustomWorldAgentSessionSnapshot | null; handleCustomWorldSelect: ( customWorldProfile: CustomWorldProfile, options?: CustomWorldRuntimeLaunchOptions, ) => void; - executePublishWorld: () => Promise; + syncAgentDraftResultProfile: ( + profile: CustomWorldProfile, + ) => Promise<{ profile: CustomWorldProfile | null; view?: RpgCreationResultView | null }>; + executePublishWorld: () => Promise; + syncAgentCreationResultView: ( + sessionId: string, + ) => Promise; setGeneratedCustomWorldProfile: (profile: CustomWorldProfile | null) => void; }; @@ -30,10 +34,10 @@ export function useRpgCreationEnterWorld( isAgentDraftResultView, activeAgentSessionId, generatedCustomWorldProfile, - agentSessionProfile, - agentSession, handleCustomWorldSelect, + syncAgentDraftResultProfile, executePublishWorld, + syncAgentCreationResultView, setGeneratedCustomWorldProfile, } = params; @@ -44,7 +48,7 @@ export function useRpgCreationEnterWorld( // 中文注释:作品测试必须复用“结果页当前真相源”。 // 用户在结果页看到并可能继续编辑的是 generatedCustomWorldProfile; - // 如果这里又回退成会话里的 agentSessionProfile,就会出现 + // 如果这里又回退成 session 里的旧 preview,就会出现 // “结果页看起来已经是新版,但作品测试实际进入的是旧版快照”的错位。 if (isAgentDraftResultView && activeAgentSessionId) { setGeneratedCustomWorldProfile(generatedCustomWorldProfile); @@ -73,36 +77,47 @@ export function useRpgCreationEnterWorld( return generatedCustomWorldProfile; } - if (!agentSessionProfile) { + const syncedResult = await syncAgentDraftResultProfile( + generatedCustomWorldProfile, + ); + const latestProfile = + syncedResult.profile ?? + rpgCreationPreviewAdapter.buildPreviewFromResultView(syncedResult.view); + + if (!latestProfile) { return null; } - setGeneratedCustomWorldProfile(agentSessionProfile); + setGeneratedCustomWorldProfile(latestProfile); - const latestSession = agentSession; const canEnterPublishedWorld = - latestSession?.stage === 'published' && - latestSession.resultPreview?.canEnterWorld; + syncedResult.view?.session.stage === 'published' && + syncedResult.view.canEnterWorld; if (canEnterPublishedWorld) { - return agentSessionProfile; + const latestView = await syncAgentCreationResultView(activeAgentSessionId); + return ( + rpgCreationPreviewAdapter.buildPreviewFromResultView(latestView) ?? + latestProfile + ); } - const publishedSession = await executePublishWorld(); + await executePublishWorld(); + const latestView = await syncAgentCreationResultView(activeAgentSessionId); const publishedProfile = - rpgCreationPreviewAdapter.buildPreviewFromSession(publishedSession) ?? - agentSessionProfile; + rpgCreationPreviewAdapter.buildPreviewFromResultView(latestView) ?? + latestProfile; setGeneratedCustomWorldProfile(publishedProfile); return publishedProfile; }, [ activeAgentSessionId, - agentSession, - agentSessionProfile, executePublishWorld, generatedCustomWorldProfile, isAgentDraftResultView, setGeneratedCustomWorldProfile, + syncAgentDraftResultProfile, + syncAgentCreationResultView, ]); const enterWorldFromCurrentResult = useCallback(async () => { diff --git a/src/components/rpg-entry/useRpgCreationResultAutosave.ts b/src/components/rpg-entry/useRpgCreationResultAutosave.ts index a3fd02f6..9027aa0e 100644 --- a/src/components/rpg-entry/useRpgCreationResultAutosave.ts +++ b/src/components/rpg-entry/useRpgCreationResultAutosave.ts @@ -4,9 +4,9 @@ import type { CustomWorldAgentOperationRecord, CustomWorldAgentSessionSnapshot, } from '../../../packages/shared/src/contracts/customWorldAgent'; -import type { - CustomWorldLibraryEntry, -} from '../../../packages/shared/src/contracts/runtime'; +import type { RpgCreationResultView } from '../../../packages/shared/src/contracts/rpgCreationResultView'; +import type { CustomWorldLibraryEntry } from '../../../packages/shared/src/contracts/runtime'; +import { normalizeCustomWorldProfileRecord } from '../../data/customWorldLibrary'; import { executeRpgCreationAction, getRpgCreationOperation, @@ -14,7 +14,6 @@ import { } from '../../services/rpg-creation'; import type { CustomWorldProfile } from '../../types'; import { - normalizeAgentBackedProfile, resolveRpgCreationErrorMessage, stringifyAgentBackedProfile, } from './rpgEntryShared'; @@ -27,7 +26,6 @@ import type { type UseRpgCreationResultAutosaveParams = { selectionStage: SelectionStage; activeAgentSessionId: string | null; - agentSession: CustomWorldAgentSessionSnapshot | null; generatedCustomWorldProfile: CustomWorldProfile | null; isAgentDraftResultView: boolean; userId: string | null | undefined; @@ -55,8 +53,11 @@ type UseRpgCreationResultAutosaveParams = { syncAgentSessionSnapshot: ( sessionId: string, ) => Promise; + syncAgentCreationResultView: ( + sessionId: string, + ) => Promise; buildDraftResultProfile: ( - session: CustomWorldAgentSessionSnapshot | null, + view: RpgCreationResultView | null, ) => CustomWorldProfile | null; }; @@ -70,7 +71,6 @@ export function useRpgCreationResultAutosave( const { selectionStage, activeAgentSessionId, - agentSession, generatedCustomWorldProfile, isAgentDraftResultView, userId, @@ -81,6 +81,7 @@ export function useRpgCreationResultAutosave( refreshCustomWorldWorks, persistAgentUiState, syncAgentSessionSnapshot, + syncAgentCreationResultView, buildDraftResultProfile, } = params; @@ -118,29 +119,33 @@ export function useRpgCreationResultAutosave( return null; } - const normalizedProfile = normalizeAgentBackedProfile(profile); - const profileSignature = stringifyAgentBackedProfile(normalizedProfile); const requestId = latestAutoSaveRequestIdRef.current + 1; latestAutoSaveRequestIdRef.current = requestId; setCustomWorldAutoSaveState('saving'); setCustomWorldAutoSaveError(null); try { - const mutation = - await upsertRpgWorldProfile( - normalizedProfile, - { - sourceAgentSessionId: - isAgentDraftResultView && activeAgentSessionId - ? activeAgentSessionId - : null, - }, - ); + const mutation = await upsertRpgWorldProfile(profile, { + sourceAgentSessionId: + isAgentDraftResultView && activeAgentSessionId + ? activeAgentSessionId + : null, + }); if (latestAutoSaveRequestIdRef.current !== requestId) { return mutation; } - lastAutoSavedProfileSignatureRef.current = profileSignature; + const canonicalProfile = + normalizeCustomWorldProfileRecord(mutation.entry.profile) ?? + mutation.entry.profile; + // Agent 结果页的界面真相来自 result-view;作品库响应只用于列表与签名回写, + // 避免旧兼容响应缺字段时覆盖当前完整编辑态。 + lastAutoSavedProfileSignatureRef.current = stringifyAgentBackedProfile( + isAgentDraftResultView ? profile : canonicalProfile, + ); + if (!isAgentDraftResultView) { + setGeneratedCustomWorldProfile(canonicalProfile); + } setSavedCustomWorldEntries(mutation.entries); if (userId) { void refreshCustomWorldWorks().catch(() => {}); @@ -174,73 +179,8 @@ export function useRpgCreationResultAutosave( refreshCustomWorldWorks, setSavedCustomWorldEntries, setSelectedDetailEntry, - userId, - ], - ); - - const syncAgentDraftResultProfile = useCallback( - async (profile: CustomWorldProfile) => { - if (!activeAgentSessionId) { - return { - session: null, - profile: null, - } satisfies SyncedAgentDraftResult; - } - - const normalizedProfile = normalizeAgentBackedProfile(profile); - const profileSignature = stringifyAgentBackedProfile(normalizedProfile); - const latestSessionProfile = buildDraftResultProfile(agentSession); - const latestSessionProfileSignature = latestSessionProfile - ? stringifyAgentBackedProfile(latestSessionProfile) - : ''; - const shouldRefreshPublishGate = Boolean( - agentSession?.resultPreview && !agentSession.resultPreview.publishReady, - ); - - if ( - latestSessionProfileSignature === profileSignature && - !shouldRefreshPublishGate - ) { - latestAgentResultSyncSignatureRef.current = profileSignature; - return { - session: agentSession, - profile: normalizeAgentBackedProfile(latestSessionProfile ?? profile), - } satisfies SyncedAgentDraftResult; - } - - if ( - latestAgentResultSyncSignatureRef.current === profileSignature && - !shouldRefreshPublishGate - ) { - return { - session: agentSession, - profile: normalizeAgentBackedProfile(latestSessionProfile ?? profile), - } satisfies SyncedAgentDraftResult; - } - - // Agent 结果页不再把前端 profile 回写到 session。 - // 这里只刷新后端结果页快照,避免在采集/生成早期误触 sync_result_profile。 - const latestSession = await syncAgentSessionSnapshot(activeAgentSessionId); - const latestProfile = normalizeAgentBackedProfile( - buildDraftResultProfile(latestSession) ?? profile, - ); - if (latestProfile) { - setGeneratedCustomWorldProfile(latestProfile); - } - latestAgentResultSyncSignatureRef.current = - stringifyAgentBackedProfile(latestProfile); - - return { - session: latestSession, - profile: latestProfile, - } satisfies SyncedAgentDraftResult; - }, - [ - activeAgentSessionId, - agentSession, - buildDraftResultProfile, setGeneratedCustomWorldProfile, - syncAgentSessionSnapshot, + userId, ], ); @@ -290,6 +230,65 @@ export function useRpgCreationResultAutosave( ], ); + const syncAgentDraftResultProfile = useCallback( + async (profile: CustomWorldProfile) => { + if (!activeAgentSessionId) { + return { + session: null, + profile: null, + } satisfies SyncedAgentDraftResult; + } + + const profileSignature = stringifyAgentBackedProfile(profile); + const currentView = + await syncAgentCreationResultView(activeAgentSessionId); + if (!currentView?.canSyncResultProfile) { + const latestProfile = buildDraftResultProfile(currentView) ?? profile; + if (latestProfile) { + setGeneratedCustomWorldProfile(latestProfile); + } + latestAgentResultSyncSignatureRef.current = + stringifyAgentBackedProfile(latestProfile); + + return { + session: currentView?.session ?? null, + profile: latestProfile, + view: currentView, + } satisfies SyncedAgentDraftResult; + } + + if (latestAgentResultSyncSignatureRef.current !== profileSignature) { + await executeAgentActionAndWait({ + action: 'sync_result_profile', + profile: profile as unknown as Record, + }); + latestAgentResultSyncSignatureRef.current = profileSignature; + } + + const latestView = + await syncAgentCreationResultView(activeAgentSessionId); + const latestProfile = buildDraftResultProfile(latestView) ?? profile; + if (latestProfile) { + setGeneratedCustomWorldProfile(latestProfile); + } + latestAgentResultSyncSignatureRef.current = + stringifyAgentBackedProfile(latestProfile); + + return { + session: latestView?.session ?? null, + profile: latestProfile, + view: latestView, + } satisfies SyncedAgentDraftResult; + }, + [ + activeAgentSessionId, + buildDraftResultProfile, + executeAgentActionAndWait, + setGeneratedCustomWorldProfile, + syncAgentCreationResultView, + ], + ); + useEffect( () => () => { if (customWorldAutoSaveTimeoutRef.current !== null) { @@ -313,7 +312,9 @@ export function useRpgCreationResultAutosave( return; } - const nextSignature = stringifyAgentBackedProfile(generatedCustomWorldProfile); + const nextSignature = stringifyAgentBackedProfile( + generatedCustomWorldProfile, + ); if (nextSignature === lastAutoSavedProfileSignatureRef.current) { return; } @@ -328,14 +329,16 @@ export function useRpgCreationResultAutosave( void (async () => { isCustomWorldAutoSaveBusyRef.current = true; try { - let latestProfileToSave = normalizeAgentBackedProfile(profileToSave); + let latestProfileToSave = profileToSave; if (isAgentDraftResultView) { const syncedResult = await syncAgentDraftResultProfile(profileToSave); + if (syncedResult.view && !syncedResult.view.canAutosaveLibrary) { + setCustomWorldAutoSaveState('idle'); + return; + } // 作品库自动保存优先落同步后 session 重编译出的结果,避免继续保存旧的前端内存态。 - latestProfileToSave = normalizeAgentBackedProfile( - syncedResult.profile ?? profileToSave, - ); + latestProfileToSave = syncedResult.profile ?? profileToSave; } await saveGeneratedCustomWorld(latestProfileToSave); } catch (error) { diff --git a/src/components/rpg-entry/useRpgCreationSessionController.ts b/src/components/rpg-entry/useRpgCreationSessionController.ts index 99940fe8..10c82412 100644 --- a/src/components/rpg-entry/useRpgCreationSessionController.ts +++ b/src/components/rpg-entry/useRpgCreationSessionController.ts @@ -21,6 +21,7 @@ import { import { createRpgCreationSession, executeRpgCreationAction, + getRpgCreationResultView, getRpgCreationSession, streamRpgCreationMessage, } from '../../services/rpg-creation'; @@ -29,7 +30,6 @@ import type { CustomWorldProfile } from '../../types'; import { buildOptimisticAgentMessage, createFailedAgentOperation, - normalizeAgentBackedProfile, resolveRpgCreationErrorMessage, } from './rpgEntryShared'; import type { @@ -40,7 +40,9 @@ import type { type UseRpgCreationSessionControllerParams = { userId: string | null | undefined; - openLoginModal?: ((postLoginAction?: (() => void) | null) => void) | undefined; + openLoginModal?: + | ((postLoginAction?: (() => void) | null) => void) + | undefined; selectionStage: SelectionStage; setSelectionStage: (stage: SelectionStage) => void; enterCreateTab?: (() => void) | undefined; @@ -70,12 +72,23 @@ export function useRpgCreationSessionController( const shouldRestoreInitialAgentUiStateRef = useRef( shouldRestoreCustomWorldAgentUiState(), ); + const initialAgentSessionId = initialAgentUiStateRef.current.activeSessionId; + const isInitialAgentGenerationRestore = + Boolean(initialAgentUiStateRef.current.activeOperationId) && + initialAgentUiStateRef.current.customWorldGenerationSource === + 'agent-draft-foundation'; + const canResolveInitialAgentSessionOwner = + !initialAgentSessionId || + !userId || + Boolean(initialAgentUiStateRef.current.ownerUserId) || + isInitialAgentGenerationRestore; const isInitialAgentUiStateOwnedByCurrentUser = - !initialAgentUiStateRef.current.ownerUserId || - initialAgentUiStateRef.current.ownerUserId === userId; + canResolveInitialAgentSessionOwner && + (!initialAgentUiStateRef.current.ownerUserId || + initialAgentUiStateRef.current.ownerUserId === userId); const isHydratingInitialAgentWorkspaceRef = useRef( Boolean( - initialAgentUiStateRef.current.activeSessionId && + initialAgentSessionId && shouldRestoreInitialAgentUiStateRef.current && isInitialAgentUiStateOwnedByCurrentUser, ), @@ -115,9 +128,12 @@ export function useRpgCreationSessionController( const [pendingAgentUserMessage, setPendingAgentUserMessage] = useState(null); const [isLoadingAgentSession, setIsLoadingAgentSession] = useState(false); - const [creationTypeError, setCreationTypeError] = useState(null); - const [agentWorkspaceRestoreError, setAgentWorkspaceRestoreError] = - useState(null); + const [creationTypeError, setCreationTypeError] = useState( + null, + ); + const [agentWorkspaceRestoreError, setAgentWorkspaceRestoreError] = useState< + string | null + >(null); const [generatedCustomWorldProfile, setGeneratedCustomWorldProfile] = useState(null); const [customWorldError, setCustomWorldError] = useState(null); @@ -127,7 +143,10 @@ export function useRpgCreationSessionController( useState(null); const [agentDraftGenerationStartedAt, setAgentDraftGenerationStartedAt] = useState(null); - const pendingAgentUserMessageRef = useRef(null); + const pendingAgentUserMessageRef = useRef( + null, + ); + const latestAgentResultViewOpenRequestIdRef = useRef(0); useEffect(() => { currentAgentSessionIdRef.current = agentSession?.sessionId ?? null; @@ -191,35 +210,54 @@ export function useRpgCreationSessionController( [userId], ); - const syncAgentSessionSnapshot = useCallback(async (sessionId: string) => { - const requestId = latestAgentSessionSyncRequestIdRef.current + 1; - latestAgentSessionSyncRequestIdRef.current = requestId; - const nextSession = await getRpgCreationSession(sessionId); - const mergedSession = mergePendingAgentUserMessageIntoSession(nextSession); + const syncAgentSessionSnapshot = useCallback( + async (sessionId: string) => { + const requestId = latestAgentSessionSyncRequestIdRef.current + 1; + latestAgentSessionSyncRequestIdRef.current = requestId; + const nextSession = await getRpgCreationSession(sessionId); + const mergedSession = + mergePendingAgentUserMessageIntoSession(nextSession); - if (latestAgentSessionSyncRequestIdRef.current === requestId) { - setAgentSession(mergedSession); - const currentPendingAgentUserMessage = pendingAgentUserMessageRef.current; - const hasServerEchoedPendingMessage = - currentPendingAgentUserMessage?.sessionId === nextSession.sessionId && - nextSession.messages.some( - (message) => message.id === currentPendingAgentUserMessage.message.id, - ); - if (hasServerEchoedPendingMessage) { - setPendingAgentUserMessage(null); + if (latestAgentSessionSyncRequestIdRef.current === requestId) { + setAgentSession(mergedSession); + const currentPendingAgentUserMessage = + pendingAgentUserMessageRef.current; + const hasServerEchoedPendingMessage = + currentPendingAgentUserMessage?.sessionId === nextSession.sessionId && + nextSession.messages.some( + (message) => + message.id === currentPendingAgentUserMessage.message.id, + ); + if (hasServerEchoedPendingMessage) { + setPendingAgentUserMessage(null); + } } - } - return mergedSession; - }, [mergePendingAgentUserMessageIntoSession]); + return mergedSession; + }, + [mergePendingAgentUserMessageIntoSession], + ); + + const syncAgentCreationResultView = useCallback( + async (sessionId: string) => { + const resultView = await getRpgCreationResultView(sessionId); + const mergedSession = mergePendingAgentUserMessageIntoSession( + resultView.session, + ); + setAgentSession(mergedSession); + return { + ...resultView, + session: mergedSession ?? resultView.session, + }; + }, + [mergePendingAgentUserMessageIntoSession], + ); useEffect(() => { - const initialAgentSessionId = initialAgentUiStateRef.current.activeSessionId; + const initialAgentSessionId = + initialAgentUiStateRef.current.activeSessionId; - if ( - !initialAgentSessionId || - hasAppliedInitialAgentWorkspaceRef.current - ) { + if (!initialAgentSessionId || hasAppliedInitialAgentWorkspaceRef.current) { return; } @@ -260,6 +298,20 @@ export function useRpgCreationSessionController( return; } + if ( + !initialAgentUiStateRef.current.ownerUserId && + !( + initialAgentUiStateRef.current.activeOperationId && + initialAgentUiStateRef.current.customWorldGenerationSource === + 'agent-draft-foundation' + ) + ) { + hasAppliedInitialAgentWorkspaceRef.current = true; + isHydratingInitialAgentWorkspaceRef.current = false; + persistAgentUiState(null, null); + return; + } + if ( initialAgentUiStateRef.current.ownerUserId && initialAgentUiStateRef.current.ownerUserId !== userId @@ -283,7 +335,13 @@ export function useRpgCreationSessionController( } setSelectionStage('agent-workspace'); - }, [enterCreateTab, openLoginModal, persistAgentUiState, setSelectionStage, userId]); + }, [ + enterCreateTab, + openLoginModal, + persistAgentUiState, + setSelectionStage, + userId, + ]); useEffect(() => { if ( @@ -365,7 +423,10 @@ export function useRpgCreationSessionController( setAgentWorkspaceRestoreError(null); } else { setAgentWorkspaceRestoreError( - resolveRpgCreationErrorMessage(error, '读取 Agent 共创工作区失败。'), + resolveRpgCreationErrorMessage( + error, + '读取 Agent 共创工作区失败。', + ), ); } setAgentSession(null); @@ -426,37 +487,32 @@ export function useRpgCreationSessionController( attempt += 1 ) { await new Promise((resolve) => { - window.setTimeout( - resolve, - AGENT_DRAFT_RESULT_AUTO_OPEN_RETRY_MS, - ); + window.setTimeout(resolve, AGENT_DRAFT_RESULT_AUTO_OPEN_RETRY_MS); }); if (cancelled) { return; } - const latestSession = activeAgentSessionId - ? await syncAgentSessionSnapshot(activeAgentSessionId).catch( + const latestResultView = activeAgentSessionId + ? await syncAgentCreationResultView(activeAgentSessionId).catch( () => null, ) - : agentSession; + : null; if (cancelled) { return; } const draftResultProfile = - rpgCreationPreviewAdapter.buildPreviewFromSession( - latestSession ?? agentSession, + rpgCreationPreviewAdapter.buildPreviewFromResultView( + latestResultView, ); if (!draftResultProfile) { continue; } - setGeneratedCustomWorldProfile( - normalizeAgentBackedProfile(draftResultProfile), - ); + setGeneratedCustomWorldProfile(draftResultProfile); setAgentDraftGenerationStartedAt(null); setCustomWorldGenerationViewSource(null); setCustomWorldResultViewSource('agent-draft'); @@ -479,7 +535,7 @@ export function useRpgCreationSessionController( customWorldGenerationViewSource, selectionStage, setSelectionStage, - syncAgentSessionSnapshot, + syncAgentCreationResultView, ]); const agentDraftSettingPreview = useMemo( @@ -490,25 +546,6 @@ export function useRpgCreationSessionController( () => buildAgentDraftFoundationAnchorEntries(agentSession), [agentSession], ); - const agentDraftResultProfile = useMemo( - () => rpgCreationPreviewAdapter.buildPreviewFromSession(agentSession), - [agentSession], - ); - const shouldAutoOpenAgentDraftResult = useMemo( - () => - Boolean( - agentDraftResultProfile && - agentSession && - (agentSession.stage === 'object_refining' || - agentSession.stage === 'visual_refining' || - agentSession.stage === 'long_tail_review' || - agentSession.stage === 'ready_to_publish' || - agentSession.stage === 'published') && - agentSession.draftCards.length > 0, - ), - [agentDraftResultProfile, agentSession], - ); - const agentDraftGenerationProgress = useMemo( () => buildAgentDraftFoundationGenerationProgress( @@ -530,7 +567,11 @@ export function useRpgCreationSessionController( : null; useEffect(() => { - if (!shouldAutoOpenAgentDraftResult || !agentDraftResultProfile) { + if ( + !agentSession || + !activeAgentSessionId || + agentSession.draftCards.length === 0 + ) { return; } @@ -538,28 +579,63 @@ export function useRpgCreationSessionController( return; } - if (selectionStage === 'agent-workspace') { - setGeneratedCustomWorldProfile(agentDraftResultProfile); - setCustomWorldResultViewSource('agent-draft'); - isAgentDraftResultAutoOpenSuppressedRef.current = false; - setSelectionStage('custom-world-result'); + if ( + selectionStage !== 'agent-workspace' && + selectionStage !== 'custom-world-result' + ) { return; } if ( selectionStage === 'custom-world-result' && - !generatedCustomWorldProfile + generatedCustomWorldProfile ) { - setGeneratedCustomWorldProfile(agentDraftResultProfile); - setCustomWorldResultViewSource('agent-draft'); - isAgentDraftResultAutoOpenSuppressedRef.current = false; + return; } + + const requestId = latestAgentResultViewOpenRequestIdRef.current + 1; + latestAgentResultViewOpenRequestIdRef.current = requestId; + let cancelled = false; + + void syncAgentCreationResultView(activeAgentSessionId) + .then((resultView) => { + if ( + cancelled || + latestAgentResultViewOpenRequestIdRef.current !== requestId || + isAgentDraftResultAutoOpenSuppressedRef.current || + resultView.targetStage !== 'custom-world-result' + ) { + return; + } + + const resultProfile = + rpgCreationPreviewAdapter.buildPreviewFromResultView(resultView); + if (!resultProfile) { + return; + } + + setGeneratedCustomWorldProfile(resultProfile); + setCustomWorldGenerationViewSource(null); + setCustomWorldResultViewSource( + resultView.resultViewSource ?? 'agent-draft', + ); + isAgentDraftResultAutoOpenSuppressedRef.current = false; + if (selectionStage === 'agent-workspace') { + setSelectionStage('custom-world-result'); + } + }) + .catch(() => {}); + + return () => { + cancelled = true; + }; }, [ - agentDraftResultProfile, + activeAgentSessionId, + agentSession, generatedCustomWorldProfile, selectionStage, setSelectionStage, - shouldAutoOpenAgentDraftResult, + syncAgentCreationResultView, ]); const openRpgAgentWorkspace = useCallback( @@ -689,26 +765,26 @@ export function useRpgCreationSessionController( kind: 'warning', text: errorMessage, }); - setAgentSession((current) => - { - const mergedCurrentSession = mergePendingAgentUserMessageIntoSession( - current, - pendingMessagePayload, - ); - return mergedCurrentSession - ? { - ...mergedCurrentSession, - messages: [...mergedCurrentSession.messages, warningMessage], - updatedAt: warningMessage.createdAt, - } - : current; - }, - ); + setAgentSession((current) => { + const mergedCurrentSession = mergePendingAgentUserMessageIntoSession( + current, + pendingMessagePayload, + ); + return mergedCurrentSession + ? { + ...mergedCurrentSession, + messages: [...mergedCurrentSession.messages, warningMessage], + updatedAt: warningMessage.createdAt, + } + : current; + }); setPendingAgentUserMessage(null); setStreamingAgentReplyText(''); persistAgentUiState(activeAgentSessionId, null); } finally { - if (activeAgentReplyAbortControllerRef.current === replyAbortController) { + if ( + activeAgentReplyAbortControllerRef.current === replyAbortController + ) { activeAgentReplyAbortControllerRef.current = null; } if (!replyAbortController.signal.aborted) { @@ -780,9 +856,7 @@ export function useRpgCreationSessionController( const setNormalizedGeneratedCustomWorldProfile = useCallback( (profile: CustomWorldProfile | null) => { - setGeneratedCustomWorldProfile( - profile ? normalizeAgentBackedProfile(profile) : null, - ); + setGeneratedCustomWorldProfile(profile); }, [], ); @@ -807,7 +881,8 @@ export function useRpgCreationSessionController( return { initialAgentSessionId: - shouldRestoreInitialAgentUiStateRef.current + shouldRestoreInitialAgentUiStateRef.current && + isInitialAgentUiStateOwnedByCurrentUser ? (initialAgentUiStateRef.current.activeSessionId ?? null) : null, isCreatingAgentSession, @@ -837,7 +912,10 @@ export function useRpgCreationSessionController( setAgentDraftGenerationStartedAt, agentDraftSettingPreview, agentDraftAnchorPreviewEntries, - agentDraftResultProfile, + agentDraftResultProfile: + rpgCreationPreviewAdapter.buildPreviewFromResultPreview( + agentSession?.resultPreview, + ), agentDraftGenerationProgress, isAgentDraftGenerationView, isAgentDraftResultView, @@ -848,6 +926,7 @@ export function useRpgCreationSessionController( releaseAgentDraftResultAutoOpenSuppression, persistAgentUiState, syncAgentSessionSnapshot, + syncAgentCreationResultView, openRpgAgentWorkspace, submitAgentMessage, executeAgentAction, diff --git a/src/components/rpg-entry/useRpgEntryAgentDraftRestore.test.tsx b/src/components/rpg-entry/useRpgEntryAgentDraftRestore.test.tsx index 08e03abb..4960068b 100644 --- a/src/components/rpg-entry/useRpgEntryAgentDraftRestore.test.tsx +++ b/src/components/rpg-entry/useRpgEntryAgentDraftRestore.test.tsx @@ -5,9 +5,11 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; import type { CustomWorldAgentSessionSnapshot } from '../../../packages/shared/src/contracts/customWorldAgent'; import type { CustomWorldWorkSummary } from '../../../packages/shared/src/contracts/customWorldWorkSummary'; +import type { RpgCreationResultView } from '../../../packages/shared/src/contracts/rpgCreationResultView'; import { WorldType, type CustomWorldProfile } from '../../types'; import { executeRpgCreationAction, + getRpgCreationOperation, upsertRpgWorldProfile, } from '../../services/rpg-creation'; import { useRpgCreationResultAutosave } from './useRpgCreationResultAutosave'; @@ -109,16 +111,42 @@ function buildSession( }; } +function buildResultView( + overrides: Partial = {}, +): RpgCreationResultView { + const session = overrides.session ?? buildSession(); + return { + session, + profile: null, + profileSource: 'none', + targetStage: 'agent-workspace', + generationViewSource: null, + resultViewSource: null, + canAutosaveLibrary: false, + canSyncResultProfile: false, + publishReady: false, + canEnterWorld: false, + blockerCount: 0, + recoveryAction: 'continue_agent', + recoveryReason: null, + ...overrides, + }; +} + describe('RPG Agent 草稿恢复', () => { beforeEach(() => { vi.clearAllMocks(); }); it('作品摘要已有对象数量但 session 没有 draftProfile 时恢复 Agent 页面', async () => { - const syncAgentSessionSnapshot = vi.fn(async () => - buildSession({ - stage: 'clarifying', - draftProfile: null, + const syncAgentCreationResultView = vi.fn(async () => + buildResultView({ + session: buildSession({ + stage: 'clarifying', + draftProfile: null, + }), + targetStage: 'agent-workspace', + recoveryAction: 'continue_agent', }), ); const setSelectionStage = vi.fn(); @@ -150,9 +178,9 @@ describe('RPG Agent 草稿恢复', () => { refreshCustomWorldWorks: vi.fn(async () => []), refreshPublishedGallery: vi.fn(async () => []), persistAgentUiState, - syncAgentSessionSnapshot, - buildDraftResultProfile: (session) => - (session?.draftProfile as CustomWorldProfile | null) ?? null, + syncAgentCreationResultView, + buildDraftResultProfile: (view) => + (view?.profile as CustomWorldProfile | null) ?? null, suppressAgentDraftResultAutoOpen, releaseAgentDraftResultAutoOpenSuppression: vi.fn(), resetAutoSaveTrackingToIdle: vi.fn(), @@ -183,7 +211,7 @@ describe('RPG Agent 草稿恢复', () => { }); }); - expect(syncAgentSessionSnapshot).toHaveBeenCalledWith('agent-session-1'); + expect(syncAgentCreationResultView).toHaveBeenCalledWith('agent-session-1'); expect(suppressAgentDraftResultAutoOpen).toHaveBeenCalled(); expect(persistAgentUiState).toHaveBeenCalledWith('agent-session-1', null); expect(setGeneratedCustomWorldProfile).toHaveBeenLastCalledWith(null); @@ -192,7 +220,7 @@ describe('RPG Agent 草稿恢复', () => { expect(setSelectionStage).not.toHaveBeenCalledWith('custom-world-result'); }); - it('Agent 结果页自动保存只刷新 session draftProfile,不触发 sync_result_profile', async () => { + it('Agent 结果页自动保存先回写 session,再保存后端 result-view profile', async () => { const oldProfile = buildProfile('旧前端快照'); const latestProfile = { ...buildProfile('服务端草稿快照'), @@ -203,6 +231,36 @@ describe('RPG Agent 草稿恢复', () => { draftProfile: latestProfile as unknown as Record, }); const syncAgentSessionSnapshot = vi.fn(async () => latestSession); + const syncAgentCreationResultView = vi.fn(async () => + buildResultView({ + session: latestSession, + profile: latestProfile, + profileSource: 'result_preview', + targetStage: 'custom-world-result', + resultViewSource: 'agent-draft', + canAutosaveLibrary: true, + canSyncResultProfile: true, + recoveryAction: 'open_result', + }), + ); + vi.mocked(executeRpgCreationAction).mockResolvedValue({ + operation: { + operationId: 'operation-sync-result', + type: 'sync_result_profile', + status: 'running', + phaseLabel: '结果页同步中', + phaseDetail: '正在同步结果页。', + progress: 50, + }, + }); + vi.mocked(getRpgCreationOperation).mockResolvedValue({ + operationId: 'operation-sync-result', + type: 'sync_result_profile', + status: 'completed', + phaseLabel: '结果页已同步', + phaseDetail: '结果页已同步。', + progress: 100, + }); vi.mocked(upsertRpgWorldProfile).mockResolvedValue({ entry: { @@ -230,16 +288,6 @@ describe('RPG Agent 草稿恢复', () => { useRpgCreationResultAutosave({ selectionStage: 'custom-world-result', activeAgentSessionId: 'agent-session-1', - agentSession: buildSession({ - stage: 'object_refining', - draftProfile: oldProfile as unknown as Record, - resultPreview: { - publishReady: false, - blockers: [], - qualityFindings: [], - sourceLabel: '旧预览', - } as never, - }), generatedCustomWorldProfile: oldProfile, isAgentDraftResultView: true, userId: 'user-1', @@ -250,8 +298,9 @@ describe('RPG Agent 草稿恢复', () => { refreshCustomWorldWorks: vi.fn(async () => []), persistAgentUiState: vi.fn(), syncAgentSessionSnapshot, - buildDraftResultProfile: (session) => - (session?.draftProfile as CustomWorldProfile | null) ?? null, + syncAgentCreationResultView, + buildDraftResultProfile: (view) => + (view?.profile as CustomWorldProfile | null) ?? null, }); return null; @@ -266,13 +315,23 @@ describe('RPG Agent 草稿恢复', () => { vi.useRealTimers(); expect(syncAgentSessionSnapshot).toHaveBeenCalledWith('agent-session-1'); - expect(upsertRpgWorldProfile).toHaveBeenCalledWith(latestProfile, { - sourceAgentSessionId: 'agent-session-1', + expect(syncAgentCreationResultView).toHaveBeenCalledWith('agent-session-1'); + expect(executeRpgCreationAction).toHaveBeenCalledWith('agent-session-1', { + action: 'sync_result_profile', + profile: expect.objectContaining({ + id: oldProfile.id, + name: oldProfile.name, + }), }); - expect( - vi.mocked(executeRpgCreationAction).mock.calls.some( - ([, payload]) => payload?.action === 'sync_result_profile', - ), - ).toBe(false); + expect(upsertRpgWorldProfile).toHaveBeenCalledWith( + expect.objectContaining({ + id: latestProfile.id, + name: latestProfile.name, + summary: latestProfile.summary, + }), + { + sourceAgentSessionId: 'agent-session-1', + }, + ); }); }); diff --git a/src/components/rpg-entry/useRpgEntryLibraryDetail.ts b/src/components/rpg-entry/useRpgEntryLibraryDetail.ts index 16273c4c..aed62ddf 100644 --- a/src/components/rpg-entry/useRpgEntryLibraryDetail.ts +++ b/src/components/rpg-entry/useRpgEntryLibraryDetail.ts @@ -1,9 +1,7 @@ import { useCallback, useEffect, useState } from 'react'; -import type { - CustomWorldAgentSessionSnapshot, - CustomWorldWorkSummary, -} from '../../../packages/shared/src/contracts/customWorldAgent'; +import type { CustomWorldWorkSummary } from '../../../packages/shared/src/contracts/customWorldAgent'; +import type { RpgCreationResultView } from '../../../packages/shared/src/contracts/rpgCreationResultView'; import type { CustomWorldGalleryCard, CustomWorldLibraryEntry, @@ -22,10 +20,7 @@ import { unpublishRpgEntryWorldProfile, } from '../../services/rpg-entry/rpgEntryLibraryClient'; import type { CustomWorldProfile } from '../../types'; -import { - normalizeRpgEntryAgentBackedProfile, - resolveRpgEntryErrorMessage, -} from './rpgEntryShared'; +import { resolveRpgEntryErrorMessage } from './rpgEntryShared'; import type { CustomWorldAutoSaveState, CustomWorldGenerationViewSource, @@ -48,18 +43,14 @@ type UseRpgEntryLibraryDetailParams = { setSavedCustomWorldEntries: ( entries: CustomWorldLibraryEntry[], ) => void; - setGeneratedCustomWorldProfile: ( - profile: CustomWorldProfile | null, - ) => void; + setGeneratedCustomWorldProfile: (profile: CustomWorldProfile | null) => void; setCustomWorldError: (error: string | null) => void; setCustomWorldAutoSaveError: (error: string | null) => void; setCustomWorldAutoSaveState: (state: CustomWorldAutoSaveState) => void; setCustomWorldGenerationViewSource: ( source: CustomWorldGenerationViewSource, ) => void; - setCustomWorldResultViewSource: ( - source: CustomWorldResultViewSource, - ) => void; + setCustomWorldResultViewSource: (source: CustomWorldResultViewSource) => void; setSelectionStage: (stage: SelectionStage) => void; setPlatformTabToCreate: () => void; setPlatformError: (error: string | null) => void; @@ -73,11 +64,11 @@ type UseRpgEntryLibraryDetailParams = { operationId: string | null, generationSource?: 'agent-draft-foundation' | null, ) => void; - syncAgentSessionSnapshot: ( + syncAgentCreationResultView: ( sessionId: string, - ) => Promise; + ) => Promise; buildDraftResultProfile: ( - session: CustomWorldAgentSessionSnapshot | null, + view: RpgCreationResultView | null, ) => CustomWorldProfile | null; suppressAgentDraftResultAutoOpen: () => void; releaseAgentDraftResultAutoOpenSuppression: () => void; @@ -85,14 +76,6 @@ type UseRpgEntryLibraryDetailParams = { markAutoSavedProfile: (profile: CustomWorldProfile) => void; }; -const AGENT_RESULT_STAGES = new Set([ - 'object_refining', - 'visual_refining', - 'long_tail_review', - 'ready_to_publish', - 'published', -]); - function isMissingRpgEntryAgentSessionError(error: unknown) { return ( error instanceof ApiClientError && @@ -127,7 +110,7 @@ export function useRpgEntryLibraryDetail( refreshCustomWorldWorks, refreshPublishedGallery, persistAgentUiState, - syncAgentSessionSnapshot, + syncAgentCreationResultView, buildDraftResultProfile, suppressAgentDraftResultAutoOpen, releaseAgentDraftResultAutoOpenSuppression, @@ -225,11 +208,8 @@ export function useRpgEntryLibraryDetail( setCustomWorldResultViewSource(null); setCustomWorldError(null); setGeneratedCustomWorldProfile(null); - const normalizedProfile = normalizeRpgEntryAgentBackedProfile( - entry.profile, - ); - setGeneratedCustomWorldProfile(normalizedProfile); - markAutoSavedProfile(normalizedProfile); + setGeneratedCustomWorldProfile(entry.profile); + markAutoSavedProfile(entry.profile); setCustomWorldAutoSaveState('saved'); setCustomWorldAutoSaveError(null); setCustomWorldError(null); @@ -262,34 +242,28 @@ export function useRpgEntryLibraryDetail( resetAutoSaveTrackingToIdle(); try { - const latestSession = await syncAgentSessionSnapshot(work.sessionId); - const nextProfile = buildDraftResultProfile(latestSession); - const shouldOpenAgentWorkspace = - !latestSession?.draftProfile || - !latestSession.stage || - !AGENT_RESULT_STAGES.has(latestSession.stage); + const resultView = await syncAgentCreationResultView(work.sessionId); + const nextProfile = buildDraftResultProfile(resultView); - const shouldResumeFailedGenerationView = - !nextProfile && - /失败/u.test(`${work.stageLabel ?? ''}${work.summary ?? ''}`); - - if (shouldResumeFailedGenerationView) { + if (resultView?.targetStage === 'custom-world-generating') { // 生成过程中失败的草稿要回到生成过程页承接错误处理,避免误回 Agent 对话。 suppressAgentDraftResultAutoOpen(); persistAgentUiState( work.sessionId, null, - 'agent-draft-foundation', + resultView.generationViewSource ?? 'agent-draft-foundation', ); setGeneratedCustomWorldProfile(null); - setCustomWorldGenerationViewSource('agent-draft-foundation'); + setCustomWorldGenerationViewSource( + resultView.generationViewSource ?? 'agent-draft-foundation', + ); setCustomWorldResultViewSource(null); setPlatformTabToCreate(); setSelectionStage('custom-world-generating'); return; } - if (shouldOpenAgentWorkspace) { + if (resultView?.targetStage === 'agent-workspace') { // 还没有服务端草稿真相源时只能恢复 Agent,对象数量等摘要字段不能决定结果页入口。 suppressAgentDraftResultAutoOpen(); persistAgentUiState(work.sessionId, null); @@ -302,12 +276,10 @@ export function useRpgEntryLibraryDetail( releaseAgentDraftResultAutoOpenSuppression(); if (!nextProfile) { - persistAgentUiState( - work.sessionId, - null, - 'agent-draft-foundation', + persistAgentUiState(work.sessionId, null, 'agent-draft-foundation'); + setPlatformError( + '当前草稿还没有可编辑的结果页数据,请先继续补齐锚点。', ); - setPlatformError('当前草稿还没有可编辑的结果页数据,请先继续补齐锚点。'); setPlatformTabToCreate(); setCustomWorldGenerationViewSource('agent-draft-foundation'); setSelectionStage('custom-world-generating'); @@ -315,12 +287,12 @@ export function useRpgEntryLibraryDetail( } persistAgentUiState(work.sessionId, null); - setGeneratedCustomWorldProfile( - normalizeRpgEntryAgentBackedProfile(nextProfile), + setGeneratedCustomWorldProfile(nextProfile); + setCustomWorldResultViewSource( + resultView?.resultViewSource ?? 'agent-draft', ); - setCustomWorldResultViewSource('agent-draft'); setPlatformTabToCreate(); - setSelectionStage('custom-world-result'); + setSelectionStage(resultView?.targetStage ?? 'custom-world-result'); return; } catch (error) { if (isMissingRpgEntryAgentSessionError(error)) { @@ -391,7 +363,7 @@ export function useRpgEntryLibraryDetail( setSavedCustomWorldEntries, setSelectionStage, suppressAgentDraftResultAutoOpen, - syncAgentSessionSnapshot, + syncAgentCreationResultView, userId, ], ); diff --git a/src/components/rpg-runtime-panels/RpgRuntimePanelRouter.tsx b/src/components/rpg-runtime-panels/RpgRuntimePanelRouter.tsx index cb7c8a41..281de689 100644 --- a/src/components/rpg-runtime-panels/RpgRuntimePanelRouter.tsx +++ b/src/components/rpg-runtime-panels/RpgRuntimePanelRouter.tsx @@ -283,6 +283,9 @@ export function RpgRuntimePanelRouter({ playerMana={visibleGameState.playerMana} playerMaxMana={visibleGameState.playerMaxMana} inBattle={visibleGameState.inBattle} + currencyText={inventoryUi.currencyText} + backpackItems={inventoryUi.backpackItems} + equipmentSlots={inventoryUi.equipmentSlots} onUseItem={inventoryUi.useInventoryItem} onEquipItem={inventoryUi.equipInventoryItem} forgeRecipes={inventoryUi.forgeRecipes} diff --git a/src/components/rpg-runtime-shell/RpgRuntimeOverlayHost.tsx b/src/components/rpg-runtime-shell/RpgRuntimeOverlayHost.tsx index 48e3abc1..6b670055 100644 --- a/src/components/rpg-runtime-shell/RpgRuntimeOverlayHost.tsx +++ b/src/components/rpg-runtime-shell/RpgRuntimeOverlayHost.tsx @@ -225,6 +225,9 @@ export function RpgRuntimeOverlayHost({ playerMana={gameState.playerMana} playerMaxMana={gameState.playerMaxMana} inBattle={gameState.inBattle} + currencyText={inventoryUi.currencyText} + backpackItems={inventoryUi.backpackItems} + equipmentSlots={inventoryUi.equipmentSlots} onUseItem={inventoryUi.useInventoryItem} onEquipItem={inventoryUi.equipInventoryItem} forgeRecipes={inventoryUi.forgeRecipes} diff --git a/src/data/functionCatalog/functionCatalog.test.ts b/src/data/functionCatalog/functionCatalog.test.ts index 14046db9..89f12989 100644 --- a/src/data/functionCatalog/functionCatalog.test.ts +++ b/src/data/functionCatalog/functionCatalog.test.ts @@ -17,6 +17,7 @@ import { NPC_PREVIEW_TALK_FUNCTION, shouldNpcRecruitOpenModal, } from './index'; +import { RPG_FUNCTION_RUNTIME_OVERVIEW } from './runtimeIndex'; import type { Encounter, GameState, InventoryItem } from '../../types'; function createEncounter(overrides: Partial = {}): Encounter { @@ -87,6 +88,12 @@ describe('functionCatalog', () => { }); }); + it('keeps runtime overview aligned with the main function documentation list', () => { + expect(RPG_FUNCTION_RUNTIME_OVERVIEW.allDocumentation).toEqual( + ALL_FUNCTION_DOCUMENTATION, + ); + }); + it('builds flow helper options with the expected function ids', () => { const continueOption = buildContinueAdventureOption(); const campTravelOption = buildCampTravelHomeOption('竹林古道'); @@ -110,16 +117,12 @@ describe('functionCatalog', () => { const state = createModalState(); const encounter = createEncounter(); const tradeModal = buildNpcTradeModalState( - state, encounter, '先看看货', - [ - createInventoryItem('npc-herb', '止血草'), - createInventoryItem('npc-ore', '陨铁碎片'), - ], + 'npc-herb', + 'player-potion', ); const giftModal = buildNpcGiftModalState( - state, encounter, '送你一样东西', 'player-charm', @@ -138,18 +141,13 @@ describe('functionCatalog', () => { expect(shouldNpcRecruitOpenModal(1, 2)).toBe(false); }); - it('prefers the first tradable player item when zero-quantity items exist', () => { + it('keeps server-selected trade item ids when opening the trade modal', () => { const encounter = createEncounter(); const tradeModal = buildNpcTradeModalState( - createModalState({ - playerInventory: [ - createInventoryItem('empty-slot', '空槽位', { quantity: 0 }), - createInventoryItem('usable-item', '可售草药', { quantity: 2 }), - ], - }), encounter, '交易', - [createInventoryItem('npc-herb', '止血草')], + 'npc-herb', + 'usable-item', ); expect(tradeModal.selectedPlayerItemId).toBe('usable-item'); diff --git a/src/data/functionCatalog/index.ts b/src/data/functionCatalog/index.ts index c5d78b89..827eaa89 100644 --- a/src/data/functionCatalog/index.ts +++ b/src/data/functionCatalog/index.ts @@ -31,6 +31,7 @@ export * from './panel/forgeCraft'; export * from './panel/forgeDismantle'; export * from './panel/forgeReforge'; export * from './panel/inventoryUse'; +export * from './runtimeIndex'; export * from './state'; export * from './treasure/treasureInspect'; export * from './treasure/treasureLeave'; diff --git a/src/data/functionCatalog/npc/npcGift.ts b/src/data/functionCatalog/npc/npcGift.ts index 2c86de21..371f8942 100644 --- a/src/data/functionCatalog/npc/npcGift.ts +++ b/src/data/functionCatalog/npc/npcGift.ts @@ -1,5 +1,5 @@ import type { GiftModalState } from '../../../hooks/rpg-runtime-story/uiTypes'; -import type { Encounter, GameState } from '../../../types'; +import type { Encounter } from '../../../types'; import type { FunctionDocumentationEntry } from '../types'; /** @@ -16,10 +16,9 @@ export function buildNpcGiftModalIntroText(encounter: Encounter) { } export function buildNpcGiftModalState( - state: GameState, encounter: Encounter, actionText: string, - selectedItemId: string | null = state.playerInventory[0]?.id ?? null, + selectedItemId: string | null, ): GiftModalState { return { encounter, @@ -34,13 +33,13 @@ export const NPC_GIFT_FUNCTION: FunctionDocumentationEntry = { domain: 'npc', title: '向该角色送礼', source: 'src/data/functionCatalog/npc/npcGift.ts', - summary: '打开送礼面板并根据礼物质量结算 affinity 变化。', + summary: '打开送礼面板并由后端结算 affinity 变化。', detailedDescription: - '它会把当前互动引到礼物选择 modal,通过本地规则估算礼物对该 NPC 的吸引力和好感增益,避免送礼结果漂移。', - trigger: '玩家背包里存在可送出的物品时出现在 NPC 交互菜单里。', + '它会把当前互动引到礼物选择 modal,礼物列表、好感增益和不可选原因都读取后端 runtimeNpcInteraction view。', + trigger: '后端判断当前 NPC 可接收礼物时出现在 NPC 交互菜单里。', execution: - '首次点击只打开 gift modal,确认礼物后再调用 commitGeneratedState 把送礼结果写回主流程。', - result: '玩家可立即看到好感变化与送礼反馈,并影响后续交易、聊天和招募阈值。', + '首次点击只打开 gift modal,确认礼物后只提交 itemId 给后端结算。', + result: '玩家可立即看到后端结算后的好感变化与送礼反馈,并影响后续交易、聊天和招募阈值。', active: true, runtime: { storyMode: 'modal_then_generate', @@ -50,7 +49,7 @@ export const NPC_GIFT_FUNCTION: FunctionDocumentationEntry = { animationNote: '第一次点击不驱动额外演出,重点是切到礼物面板。', storyNote: '真正的剧情推进发生在 confirmGift 之后,届时才会写入好感变化与结果文本。', - uiNote: '会先打开 gift modal,并默认选中当前最适合作为礼物的物品。', + uiNote: '会先打开 gift modal,并默认选中后端 view 中第一件可提交的礼物。', compactDetailText: '送礼提升好感', }, }; diff --git a/src/data/functionCatalog/npc/npcTrade.ts b/src/data/functionCatalog/npc/npcTrade.ts index 97f7f619..0d5f1279 100644 --- a/src/data/functionCatalog/npc/npcTrade.ts +++ b/src/data/functionCatalog/npc/npcTrade.ts @@ -1,5 +1,5 @@ import type { TradeModalState } from '../../../hooks/rpg-runtime-story/uiTypes'; -import type { Encounter, GameState, InventoryItem } from '../../../types'; +import type { Encounter } from '../../../types'; import type { FunctionDocumentationEntry } from '../types'; /** @@ -16,21 +16,17 @@ export function buildNpcTradeModalIntroText(encounter: Encounter) { } export function buildNpcTradeModalState( - state: GameState, encounter: Encounter, actionText: string, - npcInventory: InventoryItem[], + selectedNpcItemId: string | null, + selectedPlayerItemId: string | null, + mode: 'buy' | 'sell' = selectedNpcItemId ? 'buy' : 'sell', ): TradeModalState { - const selectedNpcItemId = - npcInventory.find((item) => item.quantity > 0)?.id ?? null; - const selectedPlayerItemId = - state.playerInventory.find((item) => item.quantity > 0)?.id ?? null; - return { encounter, actionText, introText: buildNpcTradeModalIntroText(encounter), - mode: 'buy', + mode, selectedNpcItemId, selectedPlayerItemId, selectedQuantity: 1, @@ -44,11 +40,11 @@ export const NPC_TRADE_FUNCTION: FunctionDocumentationEntry = { source: 'src/data/functionCatalog/npc/npcTrade.ts', summary: '打开 NPC 交易流程并结算买卖或交换。', detailedDescription: - '它负责把当前交互引到交易面板,展示 NPC 库存、折扣和可交换物。第一次点击通常只打开 modal,真正确认后才继续推进剧情。', + '它负责把当前交互引到交易面板,库存、价格、折扣和不可选原因都读取后端 runtimeNpcInteraction view。第一次点击通常只打开 modal,真正确认后才继续推进剧情。', trigger: '当 NPC 允许交易且自身库存非空时出现在 NPC 交互菜单里。', execution: - '首次点击进入 trade modal,确认后再通过 commitGeneratedState 把结果写回主流程。', - result: '玩家可以买入、以物易物,或在失败时得到明确的价值差提示。', + '首次点击进入 trade modal,确认后只提交 mode、itemId、quantity 给后端结算。', + result: '玩家可以买入、出售物品,或在后端拒绝时得到明确的失败原因。', active: true, runtime: { storyMode: 'modal_then_generate', @@ -58,7 +54,7 @@ export const NPC_TRADE_FUNCTION: FunctionDocumentationEntry = { animationNote: '第一次点击不播额外战斗或位移动画,重点是切到交易窗口。', storyNote: '真正的剧情推进发生在 confirmTrade 之后,而不是打开 modal 的瞬间。', - uiNote: '会先打开交易 modal,并预选 NPC 第一件商品与玩家第一件可卖物品。', + uiNote: '会先打开交易 modal,并预选后端 view 中第一件可提交的买入 / 卖出物品。', compactDetailText: '查看库存与价格', }, }; diff --git a/src/data/functionCatalog/runtimeIndex.ts b/src/data/functionCatalog/runtimeIndex.ts new file mode 100644 index 00000000..61f91b33 --- /dev/null +++ b/src/data/functionCatalog/runtimeIndex.ts @@ -0,0 +1,35 @@ +import { FLOW_FUNCTION_DOCUMENTATION } from './flow'; +import { NPC_FUNCTION_DOCUMENTATION } from './npc'; +import { PANEL_FUNCTION_DOCUMENTATION } from './panel'; +import { + STATE_FUNCTION_DOCUMENTATION, + STATE_FUNCTION_RUNTIME_SOURCES, +} from './state'; +import { TREASURE_FUNCTION_DOCUMENTATION } from './treasure'; +import type { FunctionDocumentationEntry } from './types'; + +export const RPG_FUNCTION_RUNTIME_ALL_DOCUMENTATION: FunctionDocumentationEntry[] = + [ + ...STATE_FUNCTION_DOCUMENTATION, + ...NPC_FUNCTION_DOCUMENTATION, + ...TREASURE_FUNCTION_DOCUMENTATION, + ...FLOW_FUNCTION_DOCUMENTATION, + ...PANEL_FUNCTION_DOCUMENTATION, + ]; + +/** + * RPG function 运行时总览入口。 + * + * 目的: + * 1. 在同一个脚本里集中看到当前所有 function 的注册入口。 + * 2. 先看总表,再跳到各自独立文件维护实现,避免重新回到巨型 switch。 + */ +export const RPG_FUNCTION_RUNTIME_OVERVIEW = { + allDocumentation: RPG_FUNCTION_RUNTIME_ALL_DOCUMENTATION, + stateDocumentation: STATE_FUNCTION_DOCUMENTATION, + npcDocumentation: NPC_FUNCTION_DOCUMENTATION, + treasureDocumentation: TREASURE_FUNCTION_DOCUMENTATION, + flowDocumentation: FLOW_FUNCTION_DOCUMENTATION, + panelDocumentation: PANEL_FUNCTION_DOCUMENTATION, + stateRuntimeSources: STATE_FUNCTION_RUNTIME_SOURCES, +}; diff --git a/src/data/functionCatalog/state/battleAllInCrush.ts b/src/data/functionCatalog/state/battleAllInCrush.ts index 7f7db23c..4aba3956 100644 --- a/src/data/functionCatalog/state/battleAllInCrush.ts +++ b/src/data/functionCatalog/state/battleAllInCrush.ts @@ -1,5 +1,5 @@ import { AnimationState } from '../../../types'; -import type { StateFunctionSource } from '../types'; +import type { StateFunctionRuntimeSource } from '../types'; /** * battle_all_in_crush @@ -7,7 +7,7 @@ import type { StateFunctionSource } from '../types'; * 战斗中的正面爆发动作。它要求主角不绕、不拖,直接把当前回合的叙事、 * 技能权重和视觉表现都推向“强压正面敌人”的方向。 */ -export const BATTLE_ALL_IN_CRUSH_FUNCTION_SOURCE: StateFunctionSource = { +export const BATTLE_ALL_IN_CRUSH_FUNCTION_SOURCE: StateFunctionRuntimeSource = { definition: { id: 'battle_all_in_crush', state: 'battle', @@ -56,5 +56,23 @@ export const BATTLE_ALL_IN_CRUSH_FUNCTION_SOURCE: StateFunctionSource = { category: 'battle', active: true, }, + runtime: { + buildSuggestedActionText({ metrics, environment }) { + if (metrics.monsterHpRatio <= 0.25) { + return `压上去收掉${environment.monsterName}最后一口气`; + } + if (metrics.playerHpRatio <= 0.35) { + return `顶着伤势强压${environment.monsterName}赌一波强杀`; + } + return `正面强压${environment.monsterName}不给喘息`; + }, + getPriority({ metrics }) { + return metrics.monsterHpRatio <= 0.25 + ? 8 + : metrics.playerHpRatio <= 0.35 + ? 2 + : 4; + }, + }, }; diff --git a/src/data/functionCatalog/state/battleEscapeBreakout.ts b/src/data/functionCatalog/state/battleEscapeBreakout.ts index 877b12a7..63ada38c 100644 --- a/src/data/functionCatalog/state/battleEscapeBreakout.ts +++ b/src/data/functionCatalog/state/battleEscapeBreakout.ts @@ -1,5 +1,5 @@ import { AnimationState } from '../../../types'; -import type { StateFunctionSource } from '../types'; +import type { StateFunctionRuntimeSource } from '../types'; /** * battle_escape_breakout @@ -7,7 +7,7 @@ import type { StateFunctionSource } from '../types'; * 战斗中的脱离动作。它不是继续换血,而是明确让主角放弃当前缠斗, * 把叙事重心切到“拉开距离、甩开追击、离开战场”。 */ -export const BATTLE_ESCAPE_BREAKOUT_FUNCTION_SOURCE: StateFunctionSource = { +export const BATTLE_ESCAPE_BREAKOUT_FUNCTION_SOURCE: StateFunctionRuntimeSource = { definition: { id: 'battle_escape_breakout', state: 'battle', @@ -51,5 +51,20 @@ export const BATTLE_ESCAPE_BREAKOUT_FUNCTION_SOURCE: StateFunctionSource = { category: 'escape', active: true, }, + runtime: { + buildSuggestedActionText({ metrics, environment }) { + if (metrics.playerHpRatio <= 0.35) { + return `撑着伤势先脱离${environment.monsterName}的追杀`; + } + return `转身拉开距离,甩开${environment.monsterName}`; + }, + getPriority({ metrics }) { + return metrics.playerHpRatio <= 0.2 + ? 9 + : metrics.playerHpRatio <= 0.35 + ? 5 + : 1; + }, + }, }; diff --git a/src/data/functionCatalog/state/battleFeintStep.ts b/src/data/functionCatalog/state/battleFeintStep.ts index 1f20ff24..224084c2 100644 --- a/src/data/functionCatalog/state/battleFeintStep.ts +++ b/src/data/functionCatalog/state/battleFeintStep.ts @@ -1,5 +1,5 @@ import { AnimationState } from '../../../types'; -import type { StateFunctionSource } from '../types'; +import type { StateFunctionRuntimeSource } from '../types'; /** * battle_feint_step @@ -7,7 +7,7 @@ import type { StateFunctionSource } from '../types'; * 战斗中的机动切入动作。它把重点放在虚晃、变线与抢身位, * 让战斗叙事更偏向灵活切入而不是硬扛伤害。 */ -export const BATTLE_FEINT_STEP_FUNCTION_SOURCE: StateFunctionSource = { +export const BATTLE_FEINT_STEP_FUNCTION_SOURCE: StateFunctionRuntimeSource = { definition: { id: 'battle_feint_step', state: 'battle', @@ -56,5 +56,16 @@ export const BATTLE_FEINT_STEP_FUNCTION_SOURCE: StateFunctionSource = { category: 'battle', active: true, }, + runtime: { + buildSuggestedActionText({ metrics, environment }) { + if (metrics.monsterHpRatio <= 0.35) { + return `虚晃切进去收掉${environment.monsterName}`; + } + return `借假动作切进${environment.monsterName}身前`; + }, + getPriority({ metrics }) { + return metrics.monsterHpRatio <= 0.5 ? 5 : 3; + }, + }, }; diff --git a/src/data/functionCatalog/state/battleFinisherWindow.ts b/src/data/functionCatalog/state/battleFinisherWindow.ts index 42435166..de0416d8 100644 --- a/src/data/functionCatalog/state/battleFinisherWindow.ts +++ b/src/data/functionCatalog/state/battleFinisherWindow.ts @@ -1,5 +1,5 @@ import { AnimationState } from '../../../types'; -import type { StateFunctionSource } from '../types'; +import type { StateFunctionRuntimeSource } from '../types'; /** * battle_finisher_window @@ -7,7 +7,7 @@ import type { StateFunctionSource } from '../types'; * 战斗中的终结窗口动作。它要求系统把这一回合理解为“敌人已经露出空档”, * 因而优先演出收割、补刀和终结技。 */ -export const BATTLE_FINISHER_WINDOW_FUNCTION_SOURCE: StateFunctionSource = { +export const BATTLE_FINISHER_WINDOW_FUNCTION_SOURCE: StateFunctionRuntimeSource = { definition: { id: 'battle_finisher_window', state: 'battle', @@ -55,5 +55,23 @@ export const BATTLE_FINISHER_WINDOW_FUNCTION_SOURCE: StateFunctionSource = { category: 'battle', active: true, }, + runtime: { + buildSuggestedActionText({ metrics, environment }) { + if (metrics.monsterHpRatio <= 0.25) { + return `完成对${environment.monsterName}的残血收割`; + } + if (metrics.monsterHpRatio <= 0.45) { + return `抓住${environment.monsterName}露出的破绽补上重击`; + } + return `盯住${environment.monsterName}的空当准备终结一击`; + }, + getPriority({ metrics }) { + return metrics.monsterHpRatio <= 0.25 + ? 10 + : metrics.monsterHpRatio <= 0.45 + ? 6 + : 1; + }, + }, }; diff --git a/src/data/functionCatalog/state/battleGuardBreak.ts b/src/data/functionCatalog/state/battleGuardBreak.ts index 5957d2b5..46c563b3 100644 --- a/src/data/functionCatalog/state/battleGuardBreak.ts +++ b/src/data/functionCatalog/state/battleGuardBreak.ts @@ -1,5 +1,5 @@ import { AnimationState } from '../../../types'; -import type { StateFunctionSource } from '../types'; +import type { StateFunctionRuntimeSource } from '../types'; /** * battle_guard_break @@ -7,7 +7,7 @@ import type { StateFunctionSource } from '../types'; * 战斗中的破架重击动作。它强调“针对敌人当前动作强拆架势”, * 比纯换血更讲究把敌人的节奏打断。 */ -export const BATTLE_GUARD_BREAK_FUNCTION_SOURCE: StateFunctionSource = { +export const BATTLE_GUARD_BREAK_FUNCTION_SOURCE: StateFunctionRuntimeSource = { definition: { id: 'battle_guard_break', state: 'battle', @@ -54,5 +54,16 @@ export const BATTLE_GUARD_BREAK_FUNCTION_SOURCE: StateFunctionSource = { category: 'battle', active: true, }, + runtime: { + buildSuggestedActionText({ metrics, environment }) { + if (metrics.monsterHpRatio <= 0.35) { + return `砸开${environment.monsterName}的架势直接斩落`; + } + return `重击破开${environment.monsterName}的招架`; + }, + getPriority({ metrics }) { + return metrics.monsterHpRatio <= 0.4 ? 6 : 3; + }, + }, }; diff --git a/src/data/functionCatalog/state/battleProbePressure.ts b/src/data/functionCatalog/state/battleProbePressure.ts index 42b7aa62..1f0e7040 100644 --- a/src/data/functionCatalog/state/battleProbePressure.ts +++ b/src/data/functionCatalog/state/battleProbePressure.ts @@ -1,5 +1,5 @@ import { AnimationState } from '../../../types'; -import type { StateFunctionSource } from '../types'; +import type { StateFunctionRuntimeSource } from '../types'; /** * battle_probe_pressure @@ -7,7 +7,7 @@ import type { StateFunctionSource } from '../types'; * 战斗中的稳扎试探动作。适合在局势未明、资源需要保留时, * 先用安全且持续的压制把信息和节奏摸出来。 */ -export const BATTLE_PROBE_PRESSURE_FUNCTION_SOURCE: StateFunctionSource = { +export const BATTLE_PROBE_PRESSURE_FUNCTION_SOURCE: StateFunctionRuntimeSource = { definition: { id: 'battle_probe_pressure', state: 'battle', @@ -54,5 +54,19 @@ export const BATTLE_PROBE_PRESSURE_FUNCTION_SOURCE: StateFunctionSource = { category: 'battle', active: true, }, + runtime: { + buildSuggestedActionText({ metrics, environment }) { + if (metrics.playerManaRatio <= 0.3) { + return `稳住节奏试探${environment.monsterName},先省下灵力`; + } + if (metrics.monsterHpRatio <= 0.3) { + return `稳步逼近,补掉${environment.monsterName}残余血量`; + } + return `稳扎稳打继续试探${environment.monsterName}`; + }, + getPriority({ metrics }) { + return metrics.playerManaRatio <= 0.3 ? 8 : 4; + }, + }, }; diff --git a/src/data/functionCatalog/state/battleRecoverBreath.ts b/src/data/functionCatalog/state/battleRecoverBreath.ts index bcd654de..103c7fa3 100644 --- a/src/data/functionCatalog/state/battleRecoverBreath.ts +++ b/src/data/functionCatalog/state/battleRecoverBreath.ts @@ -1,5 +1,5 @@ import { AnimationState } from '../../../types'; -import type { StateFunctionSource } from '../types'; +import type { StateFunctionRuntimeSource } from '../types'; /** * battle_recover_breath @@ -7,7 +7,7 @@ import type { StateFunctionSource } from '../types'; * 战斗中的恢复动作。它会把当前回合塑造成“先稳住伤势与灵力”, * 让数值、冷却和叙事都朝回气与整顿节奏的方向靠拢。 */ -export const BATTLE_RECOVER_BREATH_FUNCTION_SOURCE: StateFunctionSource = { +export const BATTLE_RECOVER_BREATH_FUNCTION_SOURCE: StateFunctionRuntimeSource = { definition: { id: 'battle_recover_breath', state: 'battle', @@ -57,5 +57,22 @@ export const BATTLE_RECOVER_BREATH_FUNCTION_SOURCE: StateFunctionSource = { category: 'recovery', active: true, }, + runtime: { + buildSuggestedActionText({ metrics }) { + if (metrics.playerHpRatio <= 0.35) { + return '原地打坐恢复血量'; + } + if (metrics.playerManaRatio <= 0.3) { + return '收势调息回一口灵力'; + } + return '边守边调息稳住节奏'; + }, + getPriority({ metrics }) { + return ( + (metrics.playerHpRatio <= 0.35 ? 10 : 0) + + (metrics.playerManaRatio <= 0.3 ? 6 : 0) + ); + }, + }, }; diff --git a/src/data/functionCatalog/state/idleCallOut.ts b/src/data/functionCatalog/state/idleCallOut.ts index 7eef2e8a..a8b8e18b 100644 --- a/src/data/functionCatalog/state/idleCallOut.ts +++ b/src/data/functionCatalog/state/idleCallOut.ts @@ -1,5 +1,5 @@ import { AnimationState } from '../../../types'; -import type { StateFunctionSource } from '../types'; +import type { StateFunctionRuntimeSource } from '../types'; /** * idle_call_out @@ -7,7 +7,7 @@ import type { StateFunctionSource } from '../types'; * 空闲状态下的主动喊话动作。它会把探索从“静悄悄地摸过去” * 转成“先出声试探,看谁先回应”的节奏。 */ -export const IDLE_CALL_OUT_FUNCTION_SOURCE: StateFunctionSource = { +export const IDLE_CALL_OUT_FUNCTION_SOURCE: StateFunctionRuntimeSource = { definition: { id: 'idle_call_out', state: 'idle', @@ -44,5 +44,24 @@ export const IDLE_CALL_OUT_FUNCTION_SOURCE: StateFunctionSource = { category: 'idle', active: true, }, + runtime: { + applyDefinitionAdjustments(definition) { + return { + ...definition, + text: '主动出声试探', + description: + '主动朝前方喊话试探,可能把附近潜着的角色或怪物直接从远处引出来。', + }; + }, + buildSuggestedActionText({ environment }) { + return `冲着${environment.sceneName}前方扬声试探,看是谁先被逼出来`; + }, + buildDetailText() { + return '主动打破寂静,把附近潜着的角色或怪物从屏幕外直接引到眼前。'; + }, + getPriority() { + return 5; + }, + }, }; diff --git a/src/data/functionCatalog/state/idleExploreForward.ts b/src/data/functionCatalog/state/idleExploreForward.ts index dc915559..78df3421 100644 --- a/src/data/functionCatalog/state/idleExploreForward.ts +++ b/src/data/functionCatalog/state/idleExploreForward.ts @@ -1,5 +1,5 @@ import { AnimationState } from '../../../types'; -import type { StateFunctionSource } from '../types'; +import type { StateFunctionRuntimeSource } from '../types'; /** * idle_explore_forward @@ -7,7 +7,7 @@ import type { StateFunctionSource } from '../types'; * 空闲状态下最核心的推进动作。它负责把“继续往前探”从一句泛化文案, * 落成真正会引出下一幕遭遇的运行时 function。 */ -export const IDLE_EXPLORE_FORWARD_FUNCTION_SOURCE: StateFunctionSource = { +export const IDLE_EXPLORE_FORWARD_FUNCTION_SOURCE: StateFunctionRuntimeSource = { definition: { id: 'idle_explore_forward', state: 'idle', @@ -44,5 +44,32 @@ export const IDLE_EXPLORE_FORWARD_FUNCTION_SOURCE: StateFunctionSource = { category: 'idle', active: true, }, + runtime: { + applyDefinitionAdjustments(definition) { + return { + ...definition, + text: '继续向前探索', + description: + '沿着当前场景继续深入,把前路真正探出来,下一刻就可能撞上新的危险或际遇。', + }; + }, + buildSuggestedActionText({ metrics, environment }) { + if (metrics.playerHpRatio <= 0.35) { + return `按着伤口,沿着${environment.sceneName}继续往深处摸去`; + } + if (environment.hasForwardScene) { + return `顺着${environment.sceneName}的路势,继续朝前方深处探去`; + } + return `拨开${environment.sceneName}前的遮挡,继续朝更深处探去`; + }, + buildDetailText({ environment }) { + return environment.hasForwardScene + ? `沿着${environment.sceneName}继续往前压过去,真正把前方会遇到的人影、怪物或宝藏探出来。` + : `继续深入${environment.sceneName}前方未探明的地带,下一刻就可能撞见新的动静。`; + }, + getPriority({ metrics }) { + return metrics.playerHpRatio > 0.45 ? 6 : 2; + }, + }, }; diff --git a/src/data/functionCatalog/state/idleFollowClue.ts b/src/data/functionCatalog/state/idleFollowClue.ts index e0dfddf0..81ed9b14 100644 --- a/src/data/functionCatalog/state/idleFollowClue.ts +++ b/src/data/functionCatalog/state/idleFollowClue.ts @@ -1,5 +1,5 @@ import { AnimationState } from '../../../types'; -import type { StateFunctionSource } from '../types'; +import type { StateFunctionRuntimeSource } from '../types'; /** * idle_follow_clue @@ -7,7 +7,7 @@ import type { StateFunctionSource } from '../types'; * 空闲状态下的循线推进动作。它在源码定义层仍然存在, * 但当前运行时会在聚合阶段被过滤,因此属于保留中的停用 function。 */ -export const IDLE_FOLLOW_CLUE_FUNCTION_SOURCE: StateFunctionSource = { +export const IDLE_FOLLOW_CLUE_FUNCTION_SOURCE: StateFunctionRuntimeSource = { definition: { id: 'idle_follow_clue', state: 'idle', @@ -44,5 +44,16 @@ export const IDLE_FOLLOW_CLUE_FUNCTION_SOURCE: StateFunctionSource = { category: 'idle', active: false, }, + runtime: { + buildSuggestedActionText() { + return '顺着可疑痕迹继续靠近'; + }, + buildDetailText() { + return '沿着声音、脚印或灵气痕迹继续摸过去,可能更快接近前方目标。'; + }, + getPriority() { + return 5; + }, + }, }; diff --git a/src/data/functionCatalog/state/idleObserveSigns.ts b/src/data/functionCatalog/state/idleObserveSigns.ts index 8455db8a..b95a3182 100644 --- a/src/data/functionCatalog/state/idleObserveSigns.ts +++ b/src/data/functionCatalog/state/idleObserveSigns.ts @@ -1,5 +1,5 @@ import { AnimationState } from '../../../types'; -import type { StateFunctionSource } from '../types'; +import type { StateFunctionRuntimeSource } from '../types'; /** * idle_observe_signs @@ -7,7 +7,7 @@ import type { StateFunctionSource } from '../types'; * 空闲状态下的侦察动作。它把当前回合定义成“停下来观察”, * 重点不是立刻推进,而是为后续选择生成可引用的观察结果。 */ -export const IDLE_OBSERVE_SIGNS_FUNCTION_SOURCE: StateFunctionSource = { +export const IDLE_OBSERVE_SIGNS_FUNCTION_SOURCE: StateFunctionRuntimeSource = { definition: { id: 'idle_observe_signs', state: 'idle', @@ -44,5 +44,16 @@ export const IDLE_OBSERVE_SIGNS_FUNCTION_SOURCE: StateFunctionSource = { category: 'idle', active: true, }, + runtime: { + buildSuggestedActionText() { + return '停步观察附近的风吹草动'; + }, + buildDetailText() { + return '先确认附近是否潜伏着人影、怪物或其他值得靠近的东西。'; + }, + getPriority() { + return 4; + }, + }, }; diff --git a/src/data/functionCatalog/state/idleRestFocus.ts b/src/data/functionCatalog/state/idleRestFocus.ts index 2150c5c7..5f02aebb 100644 --- a/src/data/functionCatalog/state/idleRestFocus.ts +++ b/src/data/functionCatalog/state/idleRestFocus.ts @@ -1,5 +1,5 @@ import { AnimationState } from '../../../types'; -import type { StateFunctionSource } from '../types'; +import type { StateFunctionRuntimeSource } from '../types'; /** * idle_rest_focus @@ -7,7 +7,7 @@ import type { StateFunctionSource } from '../types'; * 空闲状态下的原地恢复动作。它不会推进遭遇,而是给玩家一个 * 在非战斗场景里回收少量血蓝的缓冲回合。 */ -export const IDLE_REST_FOCUS_FUNCTION_SOURCE: StateFunctionSource = { +export const IDLE_REST_FOCUS_FUNCTION_SOURCE: StateFunctionRuntimeSource = { definition: { id: 'idle_rest_focus', state: 'idle', @@ -46,5 +46,21 @@ export const IDLE_REST_FOCUS_FUNCTION_SOURCE: StateFunctionSource = { category: 'recovery', active: true, }, + runtime: { + buildSuggestedActionText({ metrics }) { + if (metrics.playerHpRatio <= 0.35) { + return '原地打坐恢复气血'; + } + if (metrics.playerManaRatio <= 0.35) { + return '盘坐调息恢复灵力'; + } + return '原地调息整理状态'; + }, + getPriority({ metrics }) { + return metrics.playerHpRatio <= 0.35 || metrics.playerManaRatio <= 0.35 + ? 8 + : 2; + }, + }, }; diff --git a/src/data/functionCatalog/state/idleTravelNextScene.ts b/src/data/functionCatalog/state/idleTravelNextScene.ts index 02671a16..8da41285 100644 --- a/src/data/functionCatalog/state/idleTravelNextScene.ts +++ b/src/data/functionCatalog/state/idleTravelNextScene.ts @@ -1,5 +1,5 @@ import { AnimationState } from '../../../types'; -import type { StateFunctionSource } from '../types'; +import type { StateFunctionRuntimeSource } from '../types'; /** * idle_travel_next_scene @@ -7,7 +7,7 @@ import type { StateFunctionSource } from '../types'; * 空闲状态下的切场景动作。它代表玩家主动离开当前地点, * 进入相邻场景重新开启新的遭遇周期。 */ -export const IDLE_TRAVEL_NEXT_SCENE_FUNCTION_SOURCE: StateFunctionSource = { +export const IDLE_TRAVEL_NEXT_SCENE_FUNCTION_SOURCE: StateFunctionRuntimeSource = { definition: { id: 'idle_travel_next_scene', state: 'idle', @@ -44,5 +44,21 @@ export const IDLE_TRAVEL_NEXT_SCENE_FUNCTION_SOURCE: StateFunctionSource = { category: 'idle', active: true, }, + runtime: { + buildSuggestedActionText({ environment }) { + return environment.travelSceneName + ? `前往${environment.travelSceneName}` + : '前往其他场景'; + }, + buildDetailText({ environment }) { + return ( + environment.travelSceneDescription ?? + '离开当前区域,前往相邻场景继续冒险。' + ); + }, + getPriority({ metrics }) { + return metrics.playerHpRatio > 0.45 ? 5 : 3; + }, + }, }; diff --git a/src/data/functionCatalog/state/index.ts b/src/data/functionCatalog/state/index.ts index c4337f07..7a5d532f 100644 --- a/src/data/functionCatalog/state/index.ts +++ b/src/data/functionCatalog/state/index.ts @@ -1,4 +1,4 @@ -import type { StateFunctionSource } from '../types'; +import type { StateFunctionRuntimeSource } from '../types'; import { BATTLE_ALL_IN_CRUSH_FUNCTION_SOURCE } from './battleAllInCrush'; import { BATTLE_ATTACK_BASIC_FUNCTION } from './battleAttackBasic'; import { BATTLE_ESCAPE_BREAKOUT_FUNCTION_SOURCE } from './battleEscapeBreakout'; @@ -15,7 +15,7 @@ import { IDLE_OBSERVE_SIGNS_FUNCTION_SOURCE } from './idleObserveSigns'; import { IDLE_REST_FOCUS_FUNCTION_SOURCE } from './idleRestFocus'; import { IDLE_TRAVEL_NEXT_SCENE_FUNCTION_SOURCE } from './idleTravelNextScene'; -export const STATE_FUNCTION_SOURCES: StateFunctionSource[] = [ +export const STATE_FUNCTION_SOURCES: StateFunctionRuntimeSource[] = [ BATTLE_ALL_IN_CRUSH_FUNCTION_SOURCE, BATTLE_GUARD_BREAK_FUNCTION_SOURCE, BATTLE_PROBE_PRESSURE_FUNCTION_SOURCE, @@ -31,6 +31,10 @@ export const STATE_FUNCTION_SOURCES: StateFunctionSource[] = [ IDLE_CALL_OUT_FUNCTION_SOURCE, ]; +export const STATE_FUNCTION_RUNTIME_SOURCES = STATE_FUNCTION_SOURCES.filter( + (source) => source.runtime, +); + export const STATE_FUNCTION_DEFINITIONS = STATE_FUNCTION_SOURCES.map( (source) => source.definition, ); @@ -47,4 +51,3 @@ export const STATE_FUNCTION_DOCUMENTATION = [ BATTLE_USE_SKILL_FUNCTION, ...STATE_FUNCTION_SOURCES.map((source) => source.documentation), ]; - diff --git a/src/data/functionCatalog/types.ts b/src/data/functionCatalog/types.ts index b83f57a1..f80b1a44 100644 --- a/src/data/functionCatalog/types.ts +++ b/src/data/functionCatalog/types.ts @@ -56,3 +56,40 @@ export interface StateFunctionSource { documentation: FunctionDocumentationEntry; promptDescription: string; } + +export interface StateFunctionRuntimeMetrics { + playerHpRatio: number; + playerManaRatio: number; + monsterHpRatio: number; +} + +export interface StateFunctionRuntimeEnvironment { + sceneName: string; + monsterName: string; + hasForwardScene: boolean; + travelSceneName?: string | null; + travelSceneDescription?: string | null; +} + +export interface StateFunctionRuntimeHandler { + applyDefinitionAdjustments?: ( + definition: StateFunctionDefinition, + ) => StateFunctionDefinition; + buildSuggestedActionText?: (params: { + definition: StateFunctionDefinition; + metrics: StateFunctionRuntimeMetrics; + environment: StateFunctionRuntimeEnvironment; + }) => string; + buildDetailText?: (params: { + definition: StateFunctionDefinition; + environment: StateFunctionRuntimeEnvironment; + }) => string | undefined; + getPriority?: (params: { + definition: StateFunctionDefinition; + metrics: StateFunctionRuntimeMetrics; + }) => number; +} + +export interface StateFunctionRuntimeSource extends StateFunctionSource { + runtime?: StateFunctionRuntimeHandler; +} diff --git a/src/data/stateFunctions.ts b/src/data/stateFunctions.ts index 780be036..2c71aefb 100644 --- a/src/data/stateFunctions.ts +++ b/src/data/stateFunctions.ts @@ -15,6 +15,7 @@ import { NPC_RECRUIT_FUNCTION, STATE_FUNCTION_DEFINITIONS as SPLIT_STATE_FUNCTION_DEFINITIONS, STATE_FUNCTION_PROMPT_DESCRIPTIONS as SPLIT_STATE_FUNCTION_PROMPT_DESCRIPTIONS, + STATE_FUNCTION_RUNTIME_SOURCES, } from './functionCatalog'; import { getForwardScenePreset, @@ -103,6 +104,12 @@ export function getFunctionPromptDescription( const STATE_FUNCTION_OVERRIDES = stateFunctionOverridesJson as StateFunctionOverrideMap; const BASE_FUNCTIONS = [...SPLIT_STATE_FUNCTION_DEFINITIONS]; +const STATE_FUNCTION_RUNTIME_SOURCE_MAP = new Map( + STATE_FUNCTION_RUNTIME_SOURCES.map((source) => [ + source.definition.id, + source, + ]), +); function mergeStateFunctionDefinition( definition: StateFunctionDefinition, @@ -151,25 +158,9 @@ function applyRuntimeFunctionAdjustments( return definitions .filter((definition) => definition.id !== 'idle_follow_clue') .map((definition) => { - if (definition.id === 'idle_explore_forward') { - return { - ...definition, - text: '继续向前探索', - description: - '沿着当前场景继续深入,把前路真正探出来,下一刻就可能撞上新的危险或际遇。', - }; - } - - if (definition.id === 'idle_call_out') { - return { - ...definition, - text: '主动出声试探', - description: - '主动朝前方喊话试探,可能把附近潜着的角色或怪物直接从远处引出来。', - }; - } - - return definition; + const runtime = + STATE_FUNCTION_RUNTIME_SOURCE_MAP.get(definition.id)?.runtime; + return runtime?.applyDefinitionAdjustments?.(definition) ?? definition; }); } @@ -211,15 +202,16 @@ function getMonsterHpRatio(context: FunctionAvailabilityContext) { return monster.hp / Math.max(monster.maxHp, 1); } -function buildSuggestedActionText( - definition: StateFunctionDefinition, - context: FunctionAvailabilityContext, -) { +function buildRuntimeMetrics(context: FunctionAvailabilityContext) { + return { + playerHpRatio: getPlayerHpRatio(context), + playerManaRatio: getPlayerManaRatio(context), + monsterHpRatio: getMonsterHpRatio(context), + }; +} + +function buildRuntimeEnvironment(context: FunctionAvailabilityContext) { const monster = getPrimaryMonster(context); - const monsterName = monster?.name ?? '前方怪物'; - const playerHpRatio = getPlayerHpRatio(context); - const playerManaRatio = getPlayerManaRatio(context); - const monsterHpRatio = getMonsterHpRatio(context); const forwardScene = getForwardScenePreset( context.worldType, context.currentSceneId, @@ -229,153 +221,51 @@ function buildSuggestedActionText( context.currentSceneId, ); - const sceneName = context.currentSceneName ?? '前路'; + return { + sceneName: context.currentSceneName ?? '前路', + monsterName: monster?.name ?? '前方怪物', + hasForwardScene: Boolean(forwardScene), + travelSceneName: travelScene?.name ?? null, + travelSceneDescription: travelScene?.description ?? null, + }; +} - if (definition.id === 'idle_explore_forward') { - if (playerHpRatio <= 0.35) - return `按着伤口,沿着${sceneName}继续往深处摸去`; - if (forwardScene) return `顺着${sceneName}的路势,继续朝前方深处探去`; - return `拨开${sceneName}前的遮挡,继续朝更深处探去`; - } - - if (definition.id === 'idle_call_out') { - return `冲着${sceneName}前方扬声试探,看是谁先被逼出来`; - } - - switch (definition.id) { - case 'battle_finisher_window': - if (monsterHpRatio <= 0.25) return `完成对${monsterName}的残血收割`; - if (monsterHpRatio <= 0.45) return `抓住${monsterName}露出的破绽补上重击`; - return `盯住${monsterName}的空当准备终结一击`; - case 'battle_all_in_crush': - if (monsterHpRatio <= 0.25) return `压上去收掉${monsterName}最后一口气`; - if (playerHpRatio <= 0.35) return `顶着伤势强压${monsterName}赌一波强杀`; - return `正面强压${monsterName}不给喘息`; - case 'battle_guard_break': - if (monsterHpRatio <= 0.35) return `砸开${monsterName}的架势直接斩落`; - return `重击破开${monsterName}的招架`; - case 'battle_probe_pressure': - if (playerManaRatio <= 0.3) - return `稳住节奏试探${monsterName},先省下灵力`; - if (monsterHpRatio <= 0.3) return `稳步逼近,补掉${monsterName}残余血量`; - return `稳扎稳打继续试探${monsterName}`; - case 'battle_feint_step': - if (monsterHpRatio <= 0.35) return `虚晃切进去收掉${monsterName}`; - return `借假动作切进${monsterName}身前`; - case 'battle_recover_breath': - if (playerHpRatio <= 0.35) return '原地打坐恢复血量'; - if (playerManaRatio <= 0.3) return '收势调息回一口灵力'; - return '边守边调息稳住节奏'; - case 'battle_escape_breakout': - if (playerHpRatio <= 0.35) return `撑着伤势先脱离${monsterName}的追杀`; - return `转身拉开距离,甩开${monsterName}`; - case 'idle_explore_forward': - if (forwardScene) return `继续向前探路`; - if (playerHpRatio <= 0.35) return '拖着伤势继续向前摸索'; - return '继续向前探索前路'; - case 'idle_travel_next_scene': - return travelScene ? `前往${travelScene.name}` : '前往其他场景'; - case 'idle_rest_focus': - if (playerHpRatio <= 0.35) return '原地打坐恢复气血'; - if (playerManaRatio <= 0.35) return '盘坐调息恢复灵力'; - return '原地调息整理状态'; - case 'idle_observe_signs': - return '停步观察附近的风吹草动'; - case 'idle_follow_clue': - return '顺着可疑痕迹继续靠近'; - case 'idle_call_out': - return '朝前方主动出声试探'; - default: - return definition.text; - } +function buildSuggestedActionText( + definition: StateFunctionDefinition, + context: FunctionAvailabilityContext, +) { + const runtime = STATE_FUNCTION_RUNTIME_SOURCE_MAP.get(definition.id)?.runtime; + return ( + runtime?.buildSuggestedActionText?.({ + definition, + metrics: buildRuntimeMetrics(context), + environment: buildRuntimeEnvironment(context), + }) ?? definition.text + ); } function buildOptionDetailText( definition: StateFunctionDefinition, context: FunctionAvailabilityContext, ) { - const forwardScene = getForwardScenePreset( - context.worldType, - context.currentSceneId, - ); - const travelScene = getTravelScenePreset( - context.worldType, - context.currentSceneId, - ); - const sceneName = context.currentSceneName ?? '当前区域'; - - if (definition.id === 'idle_explore_forward') { - return forwardScene - ? `沿着${sceneName}继续往前压过去,真正把前方会遇到的人影、怪物或宝藏探出来。` - : `继续深入${sceneName}前方未探明的地带,下一刻就可能撞见新的动静。`; - } - - if (definition.id === 'idle_call_out') { - return '主动打破寂静,把附近潜着的角色或怪物从屏幕外直接引到眼前。'; - } - - switch (definition.id) { - case 'idle_explore_forward': - return forwardScene - ? `沿当前路径继续深入,可能会遇到角色、怪物、宝藏……` - : '继续向前试探这片区域,可能会遇到角色、怪物、宝藏……'; - case 'idle_travel_next_scene': - return travelScene?.description ?? '离开当前区域,前往相邻场景继续冒险。'; - case 'idle_observe_signs': - return '先确认附近是否潜伏着人影、怪物或其他值得靠近的东西。'; - case 'idle_follow_clue': - return '沿着声音、脚印或灵气痕迹继续摸过去,可能更快接近前方目标。'; - case 'idle_call_out': - return '主动打破寂静,看看附近是谁或什么东西先有反应。'; - default: - return undefined; - } + const runtime = STATE_FUNCTION_RUNTIME_SOURCE_MAP.get(definition.id)?.runtime; + return runtime?.buildDetailText?.({ + definition, + environment: buildRuntimeEnvironment(context), + }); } function getFunctionPriority( definition: StateFunctionDefinition, context: FunctionAvailabilityContext, ) { - const playerHpRatio = getPlayerHpRatio(context); - const playerManaRatio = getPlayerManaRatio(context); - const monsterHpRatio = getMonsterHpRatio(context); - - if (definition.id === 'idle_call_out') { - return 5; - } - - switch (definition.id) { - case 'battle_recover_breath': - return ( - (playerHpRatio <= 0.35 ? 10 : 0) + (playerManaRatio <= 0.3 ? 6 : 0) - ); - case 'battle_finisher_window': - return monsterHpRatio <= 0.25 ? 10 : monsterHpRatio <= 0.45 ? 6 : 1; - case 'battle_all_in_crush': - return monsterHpRatio <= 0.25 ? 8 : playerHpRatio <= 0.35 ? 2 : 4; - case 'battle_guard_break': - return monsterHpRatio <= 0.4 ? 6 : 3; - case 'battle_probe_pressure': - return playerManaRatio <= 0.3 ? 8 : 4; - case 'battle_feint_step': - return monsterHpRatio <= 0.5 ? 5 : 3; - case 'battle_escape_breakout': - return playerHpRatio <= 0.2 ? 9 : playerHpRatio <= 0.35 ? 5 : 1; - case 'idle_rest_focus': - return playerHpRatio <= 0.35 || playerManaRatio <= 0.35 ? 8 : 2; - case 'idle_explore_forward': - return playerHpRatio > 0.45 ? 6 : 2; - case 'idle_travel_next_scene': - return playerHpRatio > 0.45 ? 5 : 3; - case 'idle_observe_signs': - return 4; - case 'idle_follow_clue': - return 5; - case 'idle_call_out': - return 3; - default: - return 0; - } + const runtime = STATE_FUNCTION_RUNTIME_SOURCE_MAP.get(definition.id)?.runtime; + return ( + runtime?.getPriority?.({ + definition, + metrics: buildRuntimeMetrics(context), + }) ?? 0 + ); } function matchesCategory( diff --git a/src/hooks/rpg-runtime-story/choiceActions.test.ts b/src/hooks/rpg-runtime-story/choiceActions.test.ts index 9259dc97..0667a6db 100644 --- a/src/hooks/rpg-runtime-story/choiceActions.test.ts +++ b/src/hooks/rpg-runtime-story/choiceActions.test.ts @@ -6,19 +6,36 @@ vi.mock('../../services/aiService', () => ({ const { isRpgRuntimeServerFunctionIdMock, + runServerRuntimeChoiceActionMock, } = vi.hoisted(() => ({ isRpgRuntimeServerFunctionIdMock: vi.fn(() => false), + runServerRuntimeChoiceActionMock: vi.fn(), })); vi.mock('../../services/rpg-runtime', () => ({ isRpgRuntimeServerFunctionId: isRpgRuntimeServerFunctionIdMock, })); -import { generateNextStep } from '../../services/aiService'; -import { getScenePresetsByWorld } from '../../data/scenePresets'; import { AnimationState, type Character, type Encounter, type GameState, type StoryMoment, type StoryOption, WorldType } from '../../types'; import { createStoryChoiceActions } from './choiceActions'; +vi.mock('./storyChoiceRuntime', async () => { + return { + runCampTravelHomeChoice: vi.fn(), + runServerRuntimeChoiceAction: runServerRuntimeChoiceActionMock, + shouldOpenLocalRuntimeNpcModal: (option: StoryOption) => + ( + option.interaction?.kind === 'npc' || + !option.interaction + ) && + ( + option.functionId === 'npc_chat' || + option.functionId === 'npc_trade' || + option.functionId === 'npc_gift' + ), + }; +}); + function createTestCharacter(): Character { return { id: 'test-hero', @@ -150,6 +167,7 @@ describe('createStoryChoiceActions', () => { beforeEach(() => { isRpgRuntimeServerFunctionIdMock.mockReset(); isRpgRuntimeServerFunctionIdMock.mockReturnValue(false); + runServerRuntimeChoiceActionMock.mockReset(); }); it('reveals deferred adventure options when story_continue_adventure is selected', async () => { @@ -290,19 +308,13 @@ describe('createStoryChoiceActions', () => { options: [continueOption], deferredOptions, deferredRuntimeState: { - storyEngineMemory: { - discoveredFactIds: [], - activeThreadIds: [], - resolvedScarIds: [], - recentCarrierIds: [], - currentSceneActState: { - sceneId: 'scene-bridge', - chapterId: 'scene-bridge-chapter', - currentActId: 'scene-bridge-act-2', - currentActIndex: 1, - completedActIds: ['scene-bridge-act-1'], - visitedActIds: ['scene-bridge-act-1', 'scene-bridge-act-2'], - }, + currentScenePreset: { + id: 'scene-bridge', + name: '断桥', + description: '桥上雾气很重。', + imageSrc: '/scene-bridge.png', + treasureHints: [], + npcs: [], }, }, }; @@ -355,13 +367,14 @@ describe('createStoryChoiceActions', () => { expect(setGameState).toHaveBeenCalledWith( expect.objectContaining({ - storyEngineMemory: expect.objectContaining({ - currentSceneActState: expect.objectContaining({ - currentActId: 'scene-bridge-act-2', - }), + currentScenePreset: expect.objectContaining({ + id: 'scene-bridge', }), }), ); + expect(setGameState.mock.calls[0]?.[0]).not.toHaveProperty( + 'storyEngineMemory', + ); expect(setCurrentStory).toHaveBeenCalledWith({ ...currentStory, options: deferredOptions, @@ -527,360 +540,7 @@ describe('createStoryChoiceActions', () => { expect(handleNpcInteraction).toHaveBeenCalledWith(option); }); - it('uses deterministic continue option after local npc victory', async () => { - const encounter: Encounter = { - id: 'npc-opponent', - kind: 'npc', - npcName: '山道客', - npcDescription: '拦路旧敌', - npcAvatar: '/npc.png', - context: '山道旧案', - }; - const state = { - ...createBaseState(), - currentEncounter: encounter, - npcInteractionActive: true, - }; - const option = createBattleOption(); - const afterSequence = { - ...state, - inBattle: false, - sceneHostileNpcs: [], - currentNpcBattleOutcome: 'fight_victory' as const, - }; - const generateStoryForState = vi.fn().mockResolvedValue(createFallbackStory('战后续写')); - const setCurrentStory = vi.fn(); - const setGameState = vi.fn(); - const handleNpcBattleConversationContinuation = vi.fn(() => true); - - const { handleChoice } = createStoryChoiceActions({ - gameState: state, - currentStory: createFallbackStory(), - isLoading: false, - setGameState, - setCurrentStory, - setAiError: vi.fn(), - setIsLoading: vi.fn(), - setBattleReward: vi.fn(), - buildResolvedChoiceState: vi.fn(() => ({ - optionKind: 'battle' as const, - battlePlan: null, - afterSequence, - })), - playResolvedChoice: vi.fn().mockResolvedValue(afterSequence), - buildStoryContextFromState: vi.fn(() => ({ - playerHp: 100, - playerMaxHp: 100, - playerMana: 20, - playerMaxMana: 20, - inBattle: false, - playerX: 0, - playerFacing: 'right', - playerAnimation: AnimationState.IDLE, - skillCooldowns: {}, - })), - buildStoryFromResponse: vi.fn((_, __, response) => response), - buildFallbackStoryForState: vi.fn(() => createFallbackStory()), - generateStoryForState, - getAvailableOptionsForState: vi.fn(() => null), - getStoryGenerationHostileNpcs: vi.fn(() => []), - getResolvedSceneHostileNpcs: vi.fn((inputState: GameState) => inputState.sceneHostileNpcs), - buildNpcStory: vi.fn(() => createFallbackStory()), - handleNpcBattleConversationContinuation, - updateQuestLog: vi.fn((inputState: GameState) => inputState), - incrementRuntimeStats: vi.fn((inputState: GameState) => inputState), - getCampCompanionTravelScene: vi.fn(() => null), - enterNpcInteraction: vi.fn(() => false), - handleNpcInteraction: vi.fn(() => false), - handleTreasureInteraction: vi.fn(() => false), - commitGeneratedStateWithEncounterEntry: vi.fn(), - finalizeNpcBattleResult: vi.fn(() => ({ - nextState: { - ...afterSequence, - currentBattleNpcId: null, - currentNpcBattleMode: null, - currentNpcBattleOutcome: null, - inBattle: false, - }, - resultText: '山道客已经败下阵来。胜利奖励:无战利品。', - })), - isContinueAdventureOption: vi.fn(() => false), - isCampTravelHomeOption: vi.fn(() => false), - isRegularNpcEncounter: neverNpcEncounter, - isNpcEncounter: neverNpcEncounter, - npcPreviewTalkFunctionId: 'npc_preview_talk', - fallbackCompanionName: '同伴', - turnVisualMs: 820, - }); - - await handleChoice(option); - - expect(handleNpcBattleConversationContinuation).not.toHaveBeenCalled(); - expect(setGameState).toHaveBeenCalledWith( - expect.objectContaining({ - currentBattleNpcId: null, - currentNpcBattleMode: null, - currentNpcBattleOutcome: null, - inBattle: false, - }), - ); - expect(generateStoryForState).not.toHaveBeenCalled(); - expect(setCurrentStory).toHaveBeenCalledWith( - expect.objectContaining({ - text: '山道客已经败下阵来。胜利奖励:无战利品。', - options: [ - expect.objectContaining({ - functionId: 'story_continue_adventure', - actionText: '继续前进', - }), - ], - }), - ); - }); - - it('keeps local npc defeat on the death revive chain and resets to the first scene act', async () => { - vi.useFakeTimers(); - const firstScene = getScenePresetsByWorld(WorldType.WUXIA)[0]!; - const state = { - ...createBaseState(), - currentScenePreset: firstScene, - storyEngineMemory: { - discoveredFactIds: [], - activeThreadIds: [], - resolvedScarIds: [], - recentCarrierIds: [], - currentSceneActState: { - sceneId: firstScene.id, - chapterId: `${firstScene.id}-chapter`, - currentActId: `${firstScene.id}-act-2`, - currentActIndex: 1, - completedActIds: [`${firstScene.id}-act-1`], - visitedActIds: [`${firstScene.id}-act-1`, `${firstScene.id}-act-2`], - }, - }, - currentEncounter: { - id: 'npc-opponent', - kind: 'npc' as const, - npcName: '山道客', - npcDescription: '拦路旧敌', - npcAvatar: '/npc.png', - context: '山道旧案', - }, - npcInteractionActive: true, - }; - const option = createBattleOption(); - const afterSequence = { - ...state, - playerHp: 0, - inBattle: false, - sceneHostileNpcs: [], - currentNpcBattleOutcome: 'fight_defeat' as const, - }; - const finalizeNpcBattleResult = vi.fn(() => ({ - nextState: afterSequence, - resultText: '不应该进入胜利结算', - })); - const setCurrentStory = vi.fn(); - const setGameState = vi.fn(); - const { handleChoice } = createStoryChoiceActions({ - gameState: state, - currentStory: createFallbackStory(), - isLoading: false, - setGameState, - setCurrentStory, - setAiError: vi.fn(), - setIsLoading: vi.fn(), - setBattleReward: vi.fn(), - buildResolvedChoiceState: vi.fn(() => ({ - optionKind: 'battle' as const, - battlePlan: null, - afterSequence, - })), - playResolvedChoice: vi.fn().mockResolvedValue(afterSequence), - buildStoryContextFromState: vi.fn(() => ({ - playerHp: 0, - playerMaxHp: 100, - playerMana: 20, - playerMaxMana: 20, - inBattle: false, - playerX: 0, - playerFacing: 'right', - playerAnimation: AnimationState.IDLE, - skillCooldowns: {}, - })), - buildStoryFromResponse: vi.fn((_, __, response) => response), - buildFallbackStoryForState: vi.fn(() => createFallbackStory('fallback')), - generateStoryForState: vi.fn(), - getAvailableOptionsForState: vi.fn(() => null), - getStoryGenerationHostileNpcs: vi.fn(() => []), - getResolvedSceneHostileNpcs: vi.fn((inputState: GameState) => inputState.sceneHostileNpcs), - buildNpcStory: vi.fn(() => createFallbackStory()), - handleNpcBattleConversationContinuation: vi.fn(() => false), - updateQuestLog: vi.fn((inputState: GameState) => inputState), - incrementRuntimeStats: vi.fn((inputState: GameState) => inputState), - getCampCompanionTravelScene: vi.fn(() => null), - enterNpcInteraction: vi.fn(() => false), - handleNpcInteraction: vi.fn(() => false), - handleTreasureInteraction: vi.fn(() => false), - commitGeneratedStateWithEncounterEntry: vi.fn(), - finalizeNpcBattleResult, - isContinueAdventureOption: vi.fn(() => false), - isCampTravelHomeOption: vi.fn(() => false), - isRegularNpcEncounter: neverNpcEncounter, - isNpcEncounter: neverNpcEncounter, - npcPreviewTalkFunctionId: 'npc_preview_talk', - fallbackCompanionName: '同伴', - turnVisualMs: 820, - }); - - const choicePromise = handleChoice(option); - await vi.advanceTimersByTimeAsync(3000); - await choicePromise; - vi.useRealTimers(); - - expect(finalizeNpcBattleResult).not.toHaveBeenCalled(); - expect(setGameState).toHaveBeenNthCalledWith( - 1, - expect.objectContaining({ - playerHp: 0, - inBattle: false, - currentNpcBattleOutcome: 'fight_defeat', - animationState: AnimationState.DIE, - }), - ); - expect(setGameState).toHaveBeenNthCalledWith( - 2, - expect.objectContaining({ - currentScenePreset: expect.objectContaining({ - id: firstScene.id, - }), - playerHp: 100, - playerMana: 20, - inBattle: false, - currentNpcBattleOutcome: null, - }), - ); - const revivedState = setGameState.mock.calls[1]?.[0] as GameState; - expect(revivedState.currentBattleNpcId).toBeNull(); - expect(revivedState.currentNpcBattleMode).toBeNull(); - expect(revivedState.currentNpcBattleOutcome).toBeNull(); - expect( - revivedState.currentEncounter !== null || revivedState.sceneHostileNpcs.length > 0, - ).toBe(true); - expect(setCurrentStory).toHaveBeenCalledWith( - expect.objectContaining({ - text: expect.stringContaining('重新醒来'), - }), - ); - vi.useRealTimers(); - }); - - it('settles escape locally without ai continuation', async () => { - const mockedGenerateNextStep = vi.mocked(generateNextStep); - - const state = { - ...createBaseState(), - currentBattleNpcId: null, - currentNpcBattleMode: null, - sceneHostileNpcs: [ - { - id: 'wolf-1', - name: '山狼', - action: '低伏逼近', - description: '一头山狼', - animation: 'idle' as const, - xMeters: 3.2, - yOffset: 0, - facing: 'left' as const, - attackRange: 1.4, - speed: 7, - hp: 10, - maxHp: 10, - renderKind: 'npc' as const, - }, - ], - }; - const option = createBattleOption('battle_escape_breakout'); - const afterSequence = { - ...state, - inBattle: false, - sceneHostileNpcs: [], - playerX: -1.2, - }; - const setBattleReward = vi.fn(); - const setCurrentStory = vi.fn(); - const incrementRuntimeStats = vi.fn((inputState: GameState) => inputState); - const buildStoryContextFromState = vi.fn(() => ({ - playerHp: 100, - playerMaxHp: 100, - playerMana: 20, - playerMaxMana: 20, - inBattle: false, - playerX: -1.2, - playerFacing: 'right' as const, - playerAnimation: AnimationState.IDLE, - skillCooldowns: {}, - })); - - const { handleChoice } = createStoryChoiceActions({ - gameState: state, - currentStory: createFallbackStory(), - isLoading: false, - setGameState: vi.fn(), - setCurrentStory, - setAiError: vi.fn(), - setIsLoading: vi.fn(), - setBattleReward, - buildResolvedChoiceState: vi.fn(() => ({ - optionKind: 'escape' as const, - battlePlan: null, - afterSequence, - })), - playResolvedChoice: vi.fn().mockResolvedValue(afterSequence), - buildStoryContextFromState, - buildStoryFromResponse: vi.fn((_, __, response) => response), - buildFallbackStoryForState: vi.fn(() => createFallbackStory()), - generateStoryForState: vi.fn(), - getAvailableOptionsForState: vi.fn(() => null), - getStoryGenerationHostileNpcs: vi.fn(() => []), - getResolvedSceneHostileNpcs: vi.fn((inputState: GameState) => inputState.sceneHostileNpcs), - buildNpcStory: vi.fn(() => createFallbackStory()), - handleNpcBattleConversationContinuation: vi.fn(() => false), - updateQuestLog: vi.fn((inputState: GameState) => inputState), - incrementRuntimeStats, - getCampCompanionTravelScene: vi.fn(() => null), - enterNpcInteraction: vi.fn(() => false), - handleNpcInteraction: vi.fn(() => false), - handleTreasureInteraction: vi.fn(() => false), - commitGeneratedStateWithEncounterEntry: vi.fn(), - finalizeNpcBattleResult: vi.fn(() => null), - isContinueAdventureOption: vi.fn(() => false), - isCampTravelHomeOption: vi.fn(() => false), - isRegularNpcEncounter: neverNpcEncounter, - isNpcEncounter: neverNpcEncounter, - npcPreviewTalkFunctionId: 'npc_preview_talk', - fallbackCompanionName: '同伴', - turnVisualMs: 820, - }); - - await handleChoice(option); - - expect(mockedGenerateNextStep).not.toHaveBeenCalled(); - expect(buildStoryContextFromState).not.toHaveBeenCalled(); - expect(setCurrentStory).toHaveBeenCalledWith( - expect.objectContaining({ - text: '你已成功逃脱,与山狼的交战已经被甩开,对方暂时落在身后,当前不再处于战斗状态。', - }), - ); - expect(setBattleReward).toHaveBeenCalledTimes(1); - expect(setBattleReward).toHaveBeenCalledWith(null); - expect(incrementRuntimeStats).toHaveBeenCalledWith( - expect.any(Object), - expect.objectContaining({ hostileNpcsDefeated: 0 }), - ); - }); - - it('keeps battle attack and skill choices on the local combat path even if runtime server supports them', async () => { + it('routes battle attack and skill choices to the backend resolver even while in battle', async () => { const state = { ...createBaseState(), sceneHostileNpcs: [ @@ -969,17 +629,20 @@ describe('createStoryChoiceActions', () => { await handleChoice(option); - expect(buildResolvedChoiceState).toHaveBeenCalledWith( - state, - option, - state.playerCharacter!, + expect(runServerRuntimeChoiceActionMock).toHaveBeenCalledWith( + expect.objectContaining({ + gameState: state, + option, + character: state.playerCharacter, + }), ); - expect(playResolvedChoice).toHaveBeenCalled(); - expect(setGameState).toHaveBeenCalled(); - expect(setCurrentStory).toHaveBeenCalled(); + expect(buildResolvedChoiceState).not.toHaveBeenCalled(); + expect(playResolvedChoice).not.toHaveBeenCalled(); + expect(setGameState).not.toHaveBeenCalled(); + expect(setCurrentStory).not.toHaveBeenCalled(); }); - it('keeps stale battle panel choices on the local combat path when combat presentation is still visible', async () => { + it('routes stale battle panel choices to the backend resolver when combat presentation is still visible', async () => { const battleOption = createBattleOption('battle_attack_basic'); const state = { ...createBaseState(), @@ -1072,11 +735,80 @@ describe('createStoryChoiceActions', () => { await handleChoice(battleOption); - expect(buildResolvedChoiceState).toHaveBeenCalledWith( - state, - battleOption, - state.playerCharacter!, + expect(runServerRuntimeChoiceActionMock).toHaveBeenCalledWith( + expect.objectContaining({ + gameState: state, + currentStory, + option: battleOption, + character: state.playerCharacter, + }), ); - expect(playResolvedChoice).toHaveBeenCalled(); + expect(buildResolvedChoiceState).not.toHaveBeenCalled(); + expect(playResolvedChoice).not.toHaveBeenCalled(); + }); + + it('routes inventory_use combat choices to the backend resolver', async () => { + const state = createBaseState(); + const option: StoryOption = { + ...createBattleOption('inventory_use'), + runtimePayload: { + itemId: 'focus-tonic', + }, + }; + const buildResolvedChoiceState = vi.fn(); + const playResolvedChoice = vi.fn(); + + isRpgRuntimeServerFunctionIdMock.mockReturnValue(true); + + const { handleChoice } = createStoryChoiceActions({ + gameState: state, + currentStory: createFallbackStory(), + isLoading: false, + setGameState: vi.fn(), + setCurrentStory: vi.fn(), + setAiError: vi.fn(), + setIsLoading: vi.fn(), + setBattleReward: vi.fn(), + buildResolvedChoiceState, + playResolvedChoice, + buildStoryContextFromState: vi.fn(), + buildStoryFromResponse: vi.fn((_, __, response) => response), + buildFallbackStoryForState: vi.fn(() => createFallbackStory()), + generateStoryForState: vi.fn(), + getAvailableOptionsForState: vi.fn(() => [option]), + getStoryGenerationHostileNpcs: vi.fn(() => state.sceneHostileNpcs), + getResolvedSceneHostileNpcs: vi.fn( + (inputState: GameState) => inputState.sceneHostileNpcs, + ), + buildNpcStory: vi.fn(() => createFallbackStory()), + handleNpcBattleConversationContinuation: vi.fn(() => false), + updateQuestLog: vi.fn((inputState: GameState) => inputState), + incrementRuntimeStats: vi.fn((inputState: GameState) => inputState), + getCampCompanionTravelScene: vi.fn(() => null), + enterNpcInteraction: vi.fn(() => false), + handleNpcInteraction: vi.fn(() => false), + handleTreasureInteraction: vi.fn(() => false), + commitGeneratedStateWithEncounterEntry: vi.fn(), + finalizeNpcBattleResult: vi.fn(() => null), + isContinueAdventureOption: vi.fn(() => false), + isCampTravelHomeOption: vi.fn(() => false), + isRegularNpcEncounter: neverNpcEncounter, + isNpcEncounter: neverNpcEncounter, + npcPreviewTalkFunctionId: 'npc_preview_talk', + fallbackCompanionName: '同伴', + turnVisualMs: 820, + }); + + await handleChoice(option); + + expect(runServerRuntimeChoiceActionMock).toHaveBeenCalledWith( + expect.objectContaining({ + gameState: state, + option, + character: state.playerCharacter, + }), + ); + expect(buildResolvedChoiceState).not.toHaveBeenCalled(); + expect(playResolvedChoice).not.toHaveBeenCalled(); }); }); diff --git a/src/hooks/rpg-runtime-story/choiceActions.ts b/src/hooks/rpg-runtime-story/choiceActions.ts index 3390108e..87947a84 100644 --- a/src/hooks/rpg-runtime-story/choiceActions.ts +++ b/src/hooks/rpg-runtime-story/choiceActions.ts @@ -77,39 +77,6 @@ type IncrementRuntimeStats = ( increments: RuntimeStatsIncrements, ) => GameState; -function isImmediateCombatChoice(option: StoryOption) { - return ( - option.functionId.startsWith('battle_') || - option.functionId === 'inventory_use' - ); -} - -function shouldResolveCombatChoiceLocally( - gameState: GameState, - currentStory: StoryMoment | null, - option: StoryOption, -) { - if (!isImmediateCombatChoice(option)) { - return false; - } - - if (gameState.inBattle) { - return true; - } - - const hasBattleMarkers = - Boolean(gameState.currentBattleNpcId || gameState.currentNpcBattleMode) || - gameState.sceneHostileNpcs.some((hostileNpc) => hostileNpc.hp > 0); - const storyStillShowsBattleChoices = Boolean( - currentStory?.options.some(isImmediateCombatChoice), - ); - - // 中文注释:真实运行态里可能短暂出现“可见层仍在战斗,但逻辑态 inBattle - // 已经被提前切回 false”的窗口。如果这时玩家点击了还在面板上的 battle_* / - // inventory_use 选项,必须继续走本地逐帧战斗链,不能误分流到服务端直结算。 - return hasBattleMarkers || storyStillShowsBattleChoices; -} - export function createStoryChoiceActions({ gameState, currentStory, @@ -213,9 +180,6 @@ export function createStoryChoiceActions({ currentScenePreset: currentStory.deferredRuntimeState.currentScenePreset ?? gameState.currentScenePreset, - storyEngineMemory: - currentStory.deferredRuntimeState.storyEngineMemory ?? - gameState.storyEngineMemory, }); } setCurrentStory({ @@ -252,10 +216,7 @@ export function createStoryChoiceActions({ return; } - if ( - isRpgRuntimeServerFunctionId(option.functionId) && - !shouldResolveCombatChoiceLocally(gameState, currentStory, option) - ) { + if (isRpgRuntimeServerFunctionId(option.functionId)) { await runServerRuntimeChoiceAction({ gameState, currentStory, diff --git a/src/hooks/rpg-runtime-story/inventoryActions.ts b/src/hooks/rpg-runtime-story/inventoryActions.ts index 3168fac5..fd0b8c41 100644 --- a/src/hooks/rpg-runtime-story/inventoryActions.ts +++ b/src/hooks/rpg-runtime-story/inventoryActions.ts @@ -1,14 +1,11 @@ -import { useMemo, type Dispatch, type SetStateAction } from 'react'; +import { type Dispatch, type SetStateAction, useEffect, useState } from 'react'; +import type { RuntimeStoryInventoryActionView } from '../../../packages/shared/src/contracts/rpgRuntimeStoryState'; import { - EQUIPMENT_EQUIP_FUNCTION, - EQUIPMENT_UNEQUIP_FUNCTION, - FORGE_CRAFT_FUNCTION, - FORGE_DISMANTLE_FUNCTION, - FORGE_REFORGE_FUNCTION, - INVENTORY_USE_FUNCTION, -} from '../../data/functionCatalog'; -import { getForgeRecipeViews } from '../../data/forgeSystem'; + loadRpgRuntimeInventoryView, + type RuntimeStoryChoicePayload, + type RuntimeStoryInventoryView, +} from '../../services/rpg-runtime'; import type { Character, GameState, StoryMoment } from '../../types'; import { resolveRpgRuntimeChoice } from '.'; import type { InventoryFlowUi } from './uiTypes'; @@ -41,20 +38,71 @@ export function useStoryInventoryActions({ setIsLoading, buildFallbackStoryForState, } = runtime; - const forgeRecipes = useMemo( - () => - getForgeRecipeViews( - gameState.playerInventory, - gameState.playerCurrency, - gameState.worldType, - ), - [gameState.playerCurrency, gameState.playerInventory, gameState.worldType], - ); + const [serverInventoryView, setServerInventoryView] = + useState(null); + const runtimeSessionId = gameState.runtimeSessionId; + const runtimeActionVersion = gameState.runtimeActionVersion; + const currentScene = gameState.currentScene; + const hasPlayerCharacter = Boolean(gameState.playerCharacter); + + useEffect(() => { + if (!hasPlayerCharacter || currentScene !== 'Story') { + setServerInventoryView(null); + return; + } + + const controller = new AbortController(); + + void loadRpgRuntimeInventoryView( + { + gameState: { + runtimeSessionId, + runtimeActionVersion, + }, + }, + { signal: controller.signal }, + ) + .then((view) => { + setServerInventoryView(view); + }) + .catch((error) => { + if (controller.signal.aborted) { + return; + } + console.error('Failed to load inventory runtime view:', error); + setAiError(error instanceof Error ? error.message : '背包视图同步失败'); + }); + + return () => { + controller.abort(); + }; + }, [ + currentScene, + hasPlayerCharacter, + runtimeActionVersion, + runtimeSessionId, + setAiError, + ]); + + const rejectInventoryAction = (message: string) => { + setAiError(message); + return false; + }; + + const findBackpackItemView = (itemId: string) => + serverInventoryView?.backpackItems.find( + (candidate) => candidate.item.id === itemId, + ) ?? null; + + const findEquipmentSlotView = (slot: 'weapon' | 'armor' | 'relic') => + serverInventoryView?.equipmentSlots.find( + (candidate) => candidate.slotId === slot, + ) ?? null; const resolveServerInventoryAction = async (params: { functionId: string; actionText: string; - payload: Record; + payload?: RuntimeStoryChoicePayload; }) => { const character = gameState.playerCharacter; if ( @@ -69,7 +117,7 @@ export function useStoryInventoryActions({ setIsLoading(true); try { - const { hydratedSnapshot, nextStory } = await resolveRpgRuntimeChoice({ + const { response, hydratedSnapshot, nextStory } = await resolveRpgRuntimeChoice({ gameState, currentStory, option: { @@ -81,6 +129,7 @@ export function useStoryInventoryActions({ setGameState(hydratedSnapshot.gameState); setCurrentStory(nextStory); + setServerInventoryView(response.viewModel.inventory); return true; } catch (error) { console.error('Failed to resolve inventory runtime action on the server:', error); @@ -94,100 +143,80 @@ export function useStoryInventoryActions({ } }; - const useInventoryItem = async (itemId: string) => { - const item = gameState.playerInventory.find( - (candidate) => candidate.id === itemId, + const submitInventoryAction = async ( + action: RuntimeStoryInventoryActionView | undefined, + fallbackReason: string, + ) => { + if (!action) { + return rejectInventoryAction(fallbackReason); + } + if (!action.enabled) { + return rejectInventoryAction(action.reason ?? fallbackReason); + } + + return resolveServerInventoryAction({ + functionId: action.functionId, + actionText: action.actionText, + payload: action.payload as RuntimeStoryChoicePayload | undefined, + }); + }; + + const useInventoryItem = async (itemId: string) => + submitInventoryAction( + findBackpackItemView(itemId)?.actions.use, + '后端背包视图尚未提供该物品的使用动作。', ); - if (!item) { - return false; - } - return resolveServerInventoryAction({ - functionId: INVENTORY_USE_FUNCTION.id, - actionText: `使用${item.name}`, - payload: { itemId }, - }); - }; - - const equipInventoryItem = async (itemId: string) => { - const item = gameState.playerInventory.find( - (candidate) => candidate.id === itemId, + const equipInventoryItem = async (itemId: string) => + submitInventoryAction( + findBackpackItemView(itemId)?.actions.equip, + '后端背包视图尚未提供该物品的装备动作。', ); - if (!item) { - return false; - } - return resolveServerInventoryAction({ - functionId: EQUIPMENT_EQUIP_FUNCTION.id, - actionText: `装备${item.name}`, - payload: { itemId }, - }); - }; - - const unequipItem = async (slot: 'weapon' | 'armor' | 'relic') => { - const equippedItem = gameState.playerEquipment[slot]; - if (!equippedItem) { - return false; - } - - return resolveServerInventoryAction({ - functionId: EQUIPMENT_UNEQUIP_FUNCTION.id, - actionText: `卸下${equippedItem.name}`, - payload: { slotId: slot }, - }); - }; + const unequipItem = async (slot: 'weapon' | 'armor' | 'relic') => + submitInventoryAction( + findEquipmentSlotView(slot)?.unequip, + '后端装备视图尚未提供该槽位的卸装动作。', + ); const craftRecipe = async (recipeId: string) => { - const recipe = forgeRecipes.find( + const recipe = serverInventoryView?.forgeRecipes.find( (candidate) => candidate.id === recipeId, ); if (!recipe) { - return false; + return rejectInventoryAction('后端锻造视图尚未提供该配方。'); + } + if (!recipe.canCraft) { + return rejectInventoryAction( + recipe.disabledReason ?? recipe.action.reason ?? '当前配方不可制作。', + ); } - return resolveServerInventoryAction({ - functionId: FORGE_CRAFT_FUNCTION.id, - actionText: `制作${recipe.resultLabel}`, - payload: { recipeId }, - }); + return submitInventoryAction(recipe.action, '当前配方不可制作。'); }; - const dismantleItem = async (itemId: string) => { - const item = gameState.playerInventory.find( - (candidate) => candidate.id === itemId, + const dismantleItem = async (itemId: string) => + submitInventoryAction( + findBackpackItemView(itemId)?.actions.dismantle, + '后端背包视图尚未提供该物品的拆解动作。', ); - if (!item) { - return false; - } - return resolveServerInventoryAction({ - functionId: FORGE_DISMANTLE_FUNCTION.id, - actionText: `拆解${item.name}`, - payload: { itemId }, - }); - }; - - const reforgeItem = async (itemId: string) => { - const item = gameState.playerInventory.find( - (candidate) => candidate.id === itemId, + const reforgeItem = async (itemId: string) => + submitInventoryAction( + findBackpackItemView(itemId)?.actions.reforge, + '后端背包视图尚未提供该物品的重铸动作。', ); - if (!item) { - return false; - } - - return resolveServerInventoryAction({ - functionId: FORGE_REFORGE_FUNCTION.id, - actionText: `重铸${item.name}`, - payload: { itemId }, - }); - }; return { inventoryUi: { useInventoryItem, equipInventoryItem, unequipItem, - forgeRecipes, + playerCurrency: serverInventoryView?.playerCurrency ?? null, + currencyText: serverInventoryView?.currencyText ?? null, + backpackItems: serverInventoryView?.backpackItems ?? [], + equipmentSlots: serverInventoryView?.equipmentSlots ?? [], + forgeRecipes: serverInventoryView?.forgeRecipes ?? [], craftRecipe, dismantleItem, reforgeItem, diff --git a/src/hooks/rpg-runtime-story/npcInteraction.test.tsx b/src/hooks/rpg-runtime-story/npcInteraction.test.tsx new file mode 100644 index 00000000..c9c08066 --- /dev/null +++ b/src/hooks/rpg-runtime-story/npcInteraction.test.tsx @@ -0,0 +1,323 @@ +/* @vitest-environment jsdom */ + +import { render, waitFor } from '@testing-library/react'; +import { useEffect, useRef, useState } from 'react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const { resolveRpgRuntimeChoiceMock } = vi.hoisted(() => ({ + resolveRpgRuntimeChoiceMock: vi.fn(), +})); + +vi.mock('.', () => ({ + resolveRpgRuntimeChoice: resolveRpgRuntimeChoiceMock, +})); + +import type { StoryGenerationContext } from '../../services/aiTypes'; +import type { Character, Encounter, GameState, StoryMoment } from '../../types'; +import { AnimationState, WorldType } from '../../types'; +import { useStoryNpcInteractionFlow } from './npcInteraction'; + +function createCharacter(): Character { + return { + id: 'hero', + name: '沈行', + title: '试剑客', + description: '测试角色', + personality: '谨慎', + skills: [], + } as unknown as Character; +} + +function createEncounter(): Encounter { + return { + id: 'npc-merchant', + kind: 'npc', + npcName: '梁伯', + npcDescription: '守着小摊的老人', + npcAvatar: '', + context: '行商', + }; +} + +function createGameState(overrides: Partial = {}): GameState { + const encounter = createEncounter(); + return { + worldType: WorldType.WUXIA, + customWorldProfile: null, + playerCharacter: createCharacter(), + runtimeSessionId: 'runtime-main', + runtimeActionVersion: 0, + runtimeStats: { + playTimeMs: 0, + lastPlayTickAt: null, + hostileNpcsDefeated: 0, + questsAccepted: 0, + itemsUsed: 0, + scenesTraveled: 0, + }, + currentScene: 'Story', + storyHistory: [], + characterChats: {}, + animationState: AnimationState.IDLE, + currentEncounter: encounter, + npcInteractionActive: true, + currentScenePreset: null, + sceneHostileNpcs: [], + playerX: 0, + playerOffsetY: 0, + playerFacing: 'right', + playerActionMode: 'idle', + scrollWorld: false, + inBattle: false, + playerHp: 40, + playerMaxHp: 40, + playerMana: 16, + playerMaxMana: 16, + playerSkillCooldowns: {}, + activeBuildBuffs: [], + activeCombatEffects: [], + playerCurrency: 0, + playerInventory: [], + playerEquipment: { + weapon: null, + armor: null, + relic: null, + }, + runtimeNpcInteraction: { + npcId: 'npc-merchant', + npcName: '梁伯', + playerCurrency: 0, + currencyName: '铜钱', + trade: { + buyItems: [ + { + itemId: 'merchant-tonic', + item: { + id: 'merchant-tonic', + category: '消耗品', + name: '回气散', + quantity: 2, + rarity: 'uncommon', + tags: ['mana'], + }, + mode: 'buy', + unitPrice: 29, + maxQuantity: 2, + canSubmit: false, + reason: '当前钱币不足。', + }, + ], + sellItems: [ + { + itemId: 'player-ingot', + item: { + id: 'player-ingot', + category: '材料', + name: '精炼锭材', + quantity: 1, + rarity: 'rare', + tags: ['material'], + }, + mode: 'sell', + unitPrice: 23, + maxQuantity: 1, + canSubmit: true, + reason: null, + }, + ], + }, + gift: { + items: [ + { + itemId: 'gift-herb', + item: { + id: 'gift-herb', + category: '材料', + name: '暖息草', + quantity: 1, + rarity: 'rare', + tags: ['material', 'mana'], + }, + affinityGain: 16, + canSubmit: true, + reason: null, + }, + ], + }, + }, + npcStates: { + 'npc-merchant': { + affinity: 0, + helpUsed: false, + chattedCount: 0, + giftsGiven: 0, + inventory: [], + recruited: false, + }, + }, + quests: [], + roster: [], + companions: [], + currentBattleNpcId: null, + currentNpcBattleMode: null, + currentNpcBattleOutcome: null, + sparReturnEncounter: null, + sparPlayerHpBefore: null, + sparPlayerMaxHpBefore: null, + sparStoryHistoryBefore: null, + ...overrides, + } as GameState; +} + +function createRuntime(gameState: GameState) { + return { + currentStory: null, + setCurrentStory: vi.fn(), + setAiError: vi.fn(), + setIsLoading: vi.fn(), + buildStoryContextFromState: vi.fn( + () => ({}) as unknown as StoryGenerationContext, + ), + buildFallbackStoryForState: vi.fn( + () => + ({ + text: 'fallback', + options: [], + }) satisfies StoryMoment, + ), + buildDialogueStoryMoment: vi.fn( + (npcName: string, text: string) => + ({ + text, + options: [], + displayMode: 'dialogue', + dialogue: text + ? [{ speaker: 'npc' as const, speakerName: npcName, text }] + : [], + }) satisfies StoryMoment, + ), + generateStoryForState: vi.fn( + async () => + ({ + text: 'next', + options: [], + }) satisfies StoryMoment, + ), + getStoryGenerationHostileNpcs: vi.fn(() => gameState.sceneHostileNpcs), + getTypewriterDelay: vi.fn(() => 0), + }; +} + +function Harness({ + action, + initialState, +}: { + action: 'buy' | 'gift'; + initialState: GameState; +}) { + const [gameState, setGameState] = useState(initialState); + const openedRef = useRef(false); + const confirmedRef = useRef(false); + const runtime = createRuntime(gameState); + const flow = useStoryNpcInteractionFlow({ + gameState, + setGameState, + getNpcEncounterKey: encounter => encounter.id ?? encounter.npcName, + getResolvedNpcState: (state, encounter) => + state.npcStates[encounter.id ?? encounter.npcName]!, + updateNpcState: (state) => state, + cloneInventoryItemForOwner: (item) => item, + runtime, + }); + + useEffect(() => { + if (openedRef.current) { + return; + } + openedRef.current = true; + const encounter = initialState.currentEncounter as Encounter; + if (action === 'buy') { + flow.openTradeModal(encounter, '交易'); + return; + } + flow.openGiftModal(encounter, '赠送礼物'); + }, [action, flow, initialState]); + + useEffect(() => { + if (confirmedRef.current) { + return; + } + if (action === 'buy' && flow.npcUi.tradeModal) { + confirmedRef.current = true; + flow.npcUi.confirmTrade(); + return; + } + if (action === 'gift' && flow.npcUi.giftModal) { + confirmedRef.current = true; + flow.npcUi.confirmGift(); + } + }, [action, flow.npcUi]); + + return null; +} + +describe('useStoryNpcInteractionFlow', () => { + beforeEach(() => { + resolveRpgRuntimeChoiceMock.mockReset(); + resolveRpgRuntimeChoiceMock.mockResolvedValue({ + hydratedSnapshot: { + gameState: createGameState({ + playerCurrency: 12, + }), + }, + nextStory: { + text: 'server resolved', + options: [], + } satisfies StoryMoment, + }); + }); + + it('submits npc trade to the server even when the server view marks local currency insufficient', async () => { + render(); + + await waitFor(() => expect(resolveRpgRuntimeChoiceMock).toHaveBeenCalled()); + expect(resolveRpgRuntimeChoiceMock).toHaveBeenCalledWith( + expect.objectContaining({ + option: expect.objectContaining({ + functionId: 'npc_trade', + interaction: { + kind: 'npc', + npcId: 'npc-merchant', + action: 'trade', + }, + }), + payload: { + mode: 'buy', + itemId: 'merchant-tonic', + quantity: 1, + }, + }), + ); + }); + + it('submits npc gift from the server gift view without checking local inventory first', async () => { + render(); + + await waitFor(() => expect(resolveRpgRuntimeChoiceMock).toHaveBeenCalled()); + expect(resolveRpgRuntimeChoiceMock).toHaveBeenCalledWith( + expect.objectContaining({ + option: expect.objectContaining({ + functionId: 'npc_gift', + interaction: { + kind: 'npc', + npcId: 'npc-merchant', + action: 'gift', + }, + }), + payload: { + itemId: 'gift-herb', + }, + }), + ); + }); +}); diff --git a/src/hooks/rpg-runtime-story/npcInteraction.ts b/src/hooks/rpg-runtime-story/npcInteraction.ts index 6785b956..467a531a 100644 --- a/src/hooks/rpg-runtime-story/npcInteraction.ts +++ b/src/hooks/rpg-runtime-story/npcInteraction.ts @@ -7,34 +7,22 @@ import { useState } from 'react'; import { getCharacterById, } from '../../data/characterPresets'; -import { - getNpcBuybackPrice, - getNpcPurchasePrice, -} from '../../data/economy'; import { buildNpcGiftModalState, buildNpcRecruitModalState, - buildNpcTradeModalState, + buildNpcTradeModalIntroText, } from '../../data/functionCatalog'; import { - addInventoryItems, - buildNpcGiftCommitActionText, - buildNpcGiftResultText, buildNpcTradeTransactionActionText, - buildNpcTradeTransactionResultText, - getGiftCandidates, - getPreferredGiftItemId, - removeInventoryItem, - syncNpcTradeInventory, } from '../../data/npcInteractions'; -import { streamNpcChatDialogue, streamNpcRecruitDialogue } from '../../services/aiService'; +import { streamNpcRecruitDialogue } from '../../services/aiService'; import type { StoryGenerationContext } from '../../services/aiTypes'; -import { createHistoryMoment } from '../../services/storyHistory'; import type { Character, Encounter, GameState, InventoryItem, + RuntimeNpcTradeItemView, StoryMoment, StoryOption, } from '../../types'; @@ -154,13 +142,17 @@ function normalizeRecruitDialogue( return compactLines.slice(0, 6).join('\n'); } +function normalizeTradeQuantity(quantity: number) { + return Math.max(1, Math.floor(Number.isFinite(quantity) ? quantity : 1)); +} + export function useStoryNpcInteractionFlow({ gameState, setGameState, getNpcEncounterKey, - getResolvedNpcState, - updateNpcState, - cloneInventoryItemForOwner, + getResolvedNpcState: _getResolvedNpcState, + updateNpcState: _updateNpcState, + cloneInventoryItemForOwner: _cloneInventoryItemForOwner, runtime, }: { gameState: GameState; @@ -183,184 +175,6 @@ export function useStoryNpcInteractionFlow({ const [giftModal, setGiftModal] = useState(null); const [recruitModal, setRecruitModal] = useState(null); - const getTradeNpcItem = (state: GameState, modal: TradeModalState) => { - const npcState = getResolvedNpcState(state, modal.encounter); - return npcState.inventory.find(item => item.id === modal.selectedNpcItemId) ?? null; - }; - - const getTradePlayerItem = (state: GameState, modal: TradeModalState) => - state.playerInventory.find(item => item.id === modal.selectedPlayerItemId) ?? null; - - const getTradeUnitPrice = (state: GameState, modal: TradeModalState) => { - if (modal.mode === 'buy') { - const npcItem = getTradeNpcItem(state, modal); - const npcState = getResolvedNpcState(state, modal.encounter); - return npcItem ? getNpcPurchasePrice(npcItem, npcState.affinity) : 0; - } - - const playerItem = getTradePlayerItem(state, modal); - const npcState = getResolvedNpcState(state, modal.encounter); - return playerItem ? getNpcBuybackPrice(playerItem, npcState.affinity) : 0; - }; - - const getTradeMaxQuantity = (state: GameState, modal: TradeModalState) => { - if (modal.mode === 'buy') { - return getTradeNpcItem(state, modal)?.quantity ?? 0; - } - - return getTradePlayerItem(state, modal)?.quantity ?? 0; - }; - - const clampTradeQuantity = (state: GameState, modal: TradeModalState, quantity: number) => { - const maxQuantity = getTradeMaxQuantity(state, modal); - if (maxQuantity <= 0) return 1; - return Math.max(1, Math.min(maxQuantity, Math.floor(quantity))); - }; - - const commitNpcReactionAndGenerate = async ({ - nextState, - encounter, - actionText, - resultText, - lastFunctionId, - contextNpcStateOverride, - }: { - nextState: GameState; - encounter: Encounter; - actionText: string; - resultText: string; - lastFunctionId: string; - contextNpcStateOverride?: GameState['npcStates'][string] | null; - }) => { - if (!gameState.playerCharacter || !gameState.worldType) { - return; - } - - const provisionalHistory = [ - ...gameState.storyHistory, - createHistoryMoment(actionText, 'action'), - createHistoryMoment(resultText, 'result'), - ]; - const provisionalState = { - ...nextState, - storyHistory: provisionalHistory, - }; - - setGameState(provisionalState); - runtime.setAiError(null); - runtime.setIsLoading(true); - runtime.setCurrentStory( - runtime.buildDialogueStoryMoment(encounter.npcName, '', [], true), - ); - - let dialogueText = ''; - let streamedTargetText = ''; - let displayedText = ''; - let streamCompleted = false; - - const typewriterPromise = (async () => { - while (!streamCompleted || displayedText.length < streamedTargetText.length) { - if (displayedText.length >= streamedTargetText.length) { - await new Promise(resolve => window.setTimeout(resolve, 40)); - continue; - } - - const nextChar = streamedTargetText[displayedText.length]; - if (!nextChar) { - await new Promise(resolve => window.setTimeout(resolve, 40)); - continue; - } - - displayedText += nextChar; - runtime.setCurrentStory( - runtime.buildDialogueStoryMoment( - encounter.npcName, - displayedText, - [], - true, - ), - ); - await new Promise(resolve => - window.setTimeout(resolve, runtime.getTypewriterDelay(nextChar)), - ); - } - })(); - - try { - dialogueText = await streamNpcChatDialogue( - gameState.worldType, - gameState.playerCharacter, - encounter, - runtime.getStoryGenerationHostileNpcs(provisionalState), - provisionalHistory, - runtime.buildStoryContextFromState(provisionalState, { - lastFunctionId, - encounterNpcStateOverride: contextNpcStateOverride, - }), - actionText, - resultText, - { - onUpdate: text => { - streamedTargetText = text; - }, - }, - ); - streamedTargetText = dialogueText; - streamCompleted = true; - await typewriterPromise; - - const finalDialogueText = dialogueText.trim() || displayedText.trim(); - const finalHistory = finalDialogueText - ? [...provisionalHistory, createHistoryMoment(finalDialogueText, 'result')] - : provisionalHistory; - const finalState = { - ...nextState, - storyHistory: finalHistory, - }; - - setGameState(finalState); - runtime.setCurrentStory( - runtime.buildDialogueStoryMoment( - encounter.npcName, - finalDialogueText || resultText, - [], - false, - ), - ); - await new Promise(resolve => window.setTimeout(resolve, 260)); - - const nextStory = await runtime.generateStoryForState({ - state: finalState, - character: gameState.playerCharacter, - history: finalHistory, - choice: actionText, - lastFunctionId, - }); - runtime.setCurrentStory(nextStory); - } catch (error) { - streamCompleted = true; - await typewriterPromise; - console.error('Failed to continue npc interaction reaction:', error); - runtime.setAiError(error instanceof Error ? error.message : '未知智能生成错误'); - - const fallbackHistory = provisionalHistory; - const fallbackState = { - ...nextState, - storyHistory: fallbackHistory, - }; - setGameState(fallbackState); - runtime.setCurrentStory( - runtime.buildFallbackStoryForState( - fallbackState, - gameState.playerCharacter, - resultText, - ), - ); - } finally { - runtime.setIsLoading(false); - } - }; - const resolveRecruitmentOnServer = async (params: { encounter: Encounter; actionText: string; @@ -516,45 +330,68 @@ export function useStoryNpcInteractionFlow({ }); }; - const openTradeModal = (encounter: Encounter, actionText: string) => { - const currentNpcState = getResolvedNpcState(gameState, encounter); - const npcState = syncNpcTradeInventory( - gameState, - encounter, - currentNpcState, + const getRuntimeTradeItems = ( + mode: 'buy' | 'sell', + ): RuntimeNpcTradeItemView[] => + mode === 'buy' + ? gameState.runtimeNpcInteraction?.trade.buyItems ?? [] + : gameState.runtimeNpcInteraction?.trade.sellItems ?? []; + + const findRuntimeTradeItem = (modal: TradeModalState) => { + const itemId = + modal.mode === 'buy' + ? modal.selectedNpcItemId + : modal.selectedPlayerItemId; + if (!itemId) return null; + + return ( + getRuntimeTradeItems(modal.mode).find((item) => item.itemId === itemId) ?? + null ); + }; - if ( - gameState.npcStates[getNpcEncounterKey(encounter)] !== npcState - || npcState !== currentNpcState - ) { - setGameState(updateNpcState(gameState, encounter, () => npcState)); - } + const findRuntimeGiftItem = (itemId: string | null) => { + if (!itemId) return null; + return ( + gameState.runtimeNpcInteraction?.gift.items.find( + (item) => item.itemId === itemId, + ) ?? null + ); + }; + const openTradeModal = (encounter: Encounter, actionText: string) => { setTradeModal( - buildNpcTradeModalState( - gameState, + { encounter, actionText, - npcState.inventory, - ), + introText: buildNpcTradeModalIntroText(encounter), + mode: 'buy', + selectedNpcItemId: + gameState.runtimeNpcInteraction?.trade.buyItems.find( + (item) => item.canSubmit, + )?.itemId ?? + gameState.runtimeNpcInteraction?.trade.buyItems[0]?.itemId ?? + null, + selectedPlayerItemId: + gameState.runtimeNpcInteraction?.trade.sellItems.find( + (item) => item.canSubmit, + )?.itemId ?? + gameState.runtimeNpcInteraction?.trade.sellItems[0]?.itemId ?? + null, + selectedQuantity: 1, + }, ); }; const openGiftModal = (encounter: Encounter, actionText: string) => { - const selectedItemId = getPreferredGiftItemId( - gameState.playerInventory, - encounter, - { - worldType: gameState.worldType, - customWorldProfile: gameState.customWorldProfile, - }, - ); - if (!selectedItemId) return; + const selectedItemId = + gameState.runtimeNpcInteraction?.gift.items.find((item) => item.canSubmit) + ?.itemId ?? + gameState.runtimeNpcInteraction?.gift.items[0]?.itemId ?? + null; setGiftModal( buildNpcGiftModalState( - gameState, encounter, actionText, selectedItemId, @@ -630,53 +467,24 @@ export function useStoryNpcInteractionFlow({ if (!tradeModal || !gameState.playerCharacter) return; const encounter = tradeModal.encounter; - const quantity = clampTradeQuantity(gameState, tradeModal, tradeModal.selectedQuantity); - const unitPrice = getTradeUnitPrice(gameState, tradeModal); - const totalPrice = unitPrice * quantity; - - if (tradeModal.mode === 'buy') { - const npcItem = getTradeNpcItem(gameState, tradeModal); - if (!npcItem || quantity <= 0) return; - if (npcItem.quantity < quantity || gameState.playerCurrency < totalPrice) return; - - setTradeModal(null); - void resolveServerNpcAction({ - encounter, - actionText: buildNpcTradeTransactionActionText({ - encounter, - mode: 'buy', - item: npcItem, - quantity, - }), - functionId: 'npc_trade', - action: 'trade', - payload: { - mode: 'buy', - itemId: npcItem.id, - quantity, - }, - }); - return; - } - - const playerItem = getTradePlayerItem(gameState, tradeModal); - if (!playerItem || quantity <= 0) return; - if (playerItem.quantity < quantity) return; + const quantity = normalizeTradeQuantity(tradeModal.selectedQuantity); + const tradeItem = findRuntimeTradeItem(tradeModal); + if (!tradeItem) return; setTradeModal(null); void resolveServerNpcAction({ encounter, actionText: buildNpcTradeTransactionActionText({ encounter, - mode: 'sell', - item: playerItem, + mode: tradeModal.mode, + item: tradeItem.item, quantity, }), functionId: 'npc_trade', action: 'trade', payload: { - mode: 'sell', - itemId: playerItem.id, + mode: tradeModal.mode, + itemId: tradeItem.itemId, quantity, }, }); @@ -686,17 +494,17 @@ export function useStoryNpcInteractionFlow({ if (!giftModal || !gameState.playerCharacter) return; const encounter = giftModal.encounter; - const giftItem = gameState.playerInventory.find(item => item.id === giftModal.selectedItemId); + const giftItem = findRuntimeGiftItem(giftModal.selectedItemId); if (!giftItem) return; setGiftModal(null); void resolveServerNpcAction({ encounter, - actionText: buildNpcGiftCommitActionText(encounter, giftItem), + actionText: `把${giftItem.item.name}赠给${encounter.npcName}`, functionId: 'npc_gift', action: 'gift', payload: { - itemId: giftItem.id, + itemId: giftItem.itemId, }, }); }; @@ -708,44 +516,40 @@ export function useStoryNpcInteractionFlow({ recruitModal, setTradeMode: (mode: 'buy' | 'sell') => setTradeModal(current => { if (!current) return current; - const nextModal = { + return { ...current, mode, - selectedNpcItemId: current.selectedNpcItemId ?? getResolvedNpcState(gameState, current.encounter).inventory[0]?.id ?? null, - selectedPlayerItemId: current.selectedPlayerItemId ?? gameState.playerInventory[0]?.id ?? null, + selectedNpcItemId: + current.selectedNpcItemId ?? + gameState.runtimeNpcInteraction?.trade.buyItems[0]?.itemId ?? + null, + selectedPlayerItemId: + current.selectedPlayerItemId ?? + gameState.runtimeNpcInteraction?.trade.sellItems[0]?.itemId ?? + null, selectedQuantity: 1, }; - return { - ...nextModal, - selectedQuantity: clampTradeQuantity(gameState, nextModal, 1), - }; }), selectTradeNpcItem: (itemId: string) => setTradeModal(current => { if (!current) return current; - const nextModal = { + return { ...current, selectedNpcItemId: itemId, - }; - return { - ...nextModal, - selectedQuantity: clampTradeQuantity(gameState, nextModal, 1), + selectedQuantity: 1, }; }), selectTradePlayerItem: (itemId: string) => setTradeModal(current => { if (!current) return current; - const nextModal = { + return { ...current, selectedPlayerItemId: itemId, - }; - return { - ...nextModal, - selectedQuantity: clampTradeQuantity(gameState, nextModal, 1), + selectedQuantity: 1, }; }), setTradeQuantity: (quantity: number) => setTradeModal(current => current ? { ...current, - selectedQuantity: clampTradeQuantity(gameState, current, quantity), + selectedQuantity: normalizeTradeQuantity(quantity), } : current), closeTradeModal: () => setTradeModal(null), diff --git a/src/hooks/rpg-runtime-story/postBattleFlow.test.ts b/src/hooks/rpg-runtime-story/postBattleFlow.test.ts deleted file mode 100644 index 31dc0d23..00000000 --- a/src/hooks/rpg-runtime-story/postBattleFlow.test.ts +++ /dev/null @@ -1,327 +0,0 @@ -import { afterEach, describe, expect, it, vi } from 'vitest'; - -const { ensureSceneEncounterPreviewMock } = vi.hoisted(() => ({ - ensureSceneEncounterPreviewMock: vi.fn(), -})); - -vi.mock('../../data/sceneEncounterPreviews', () => ({ - ensureSceneEncounterPreview: ensureSceneEncounterPreviewMock, -})); - -import { setRuntimeCustomWorldProfile } from '../../data/customWorldRuntime'; -import { getScenePresetsByWorld } from '../../data/scenePresets'; -import { AnimationState, type GameState, WorldType } from '../../types'; -import { buildRevivedFirstSceneState } from './postBattleFlow'; - -function createBackstoryReveal(label: string) { - return { - publicSummary: `${label}的公开背景`, - chapters: [ - { - id: `${label}-surface`, - title: '表层来意', - affinityRequired: 15, - teaser: `${label}先收着话。`, - content: `${label}把真正目的藏在后面。`, - contextSnippet: `${label}表面上仍在试探。`, - }, - { - id: `${label}-scar`, - title: '旧事裂痕', - affinityRequired: 30, - teaser: `${label}提到旧事会迟疑。`, - content: `${label}背后压着旧伤。`, - contextSnippet: `${label}仍被旧事牵制。`, - }, - { - id: `${label}-hidden`, - title: '隐藏执念', - affinityRequired: 60, - teaser: `${label}真正执念并不在表面。`, - content: `${label}真正想守住的是另一条暗线。`, - contextSnippet: `${label}另有没说出口的理由。`, - }, - { - id: `${label}-final`, - title: '最终底牌', - affinityRequired: 90, - teaser: `${label}手里还扣着底牌。`, - content: `${label}掌握能改写局势的最后证据。`, - contextSnippet: `${label}最后底牌还没翻出。`, - }, - ], - }; -} - -function createStoryRole(id: string, name: string, hostile = false) { - return { - id, - name, - title: `${name}的头衔`, - role: hostile ? '敌对角色' : '同幕角色', - description: `${name}的测试描述`, - backstory: `${name}的测试背景`, - personality: '冷静克制', - motivation: hostile ? '阻拦玩家继续向前' : '观察局势变化', - combatStyle: hostile ? '正面压制' : '后排支援', - initialAffinity: hostile ? -20 : 12, - relationshipHooks: [], - tags: [], - backstoryReveal: createBackstoryReveal(name), - skills: [], - initialItems: [], - }; -} - -function createReviveState(): GameState { - const customWorldProfile = { - id: 'custom-revive-test', - name: '复活回场测试世界', - subtitle: '首幕站位恢复', - summary: '用于验证复活后第一幕 NPC 会按既有 encounter preview 恢复。', - settingText: '围绕开局营地与第一幕对峙角色展开的自定义世界。', - tone: '紧张、克制', - playerGoal: '复活后重新回到第一幕并面对主交互角色。', - templateWorldType: WorldType.WUXIA, - majorFactions: [], - coreConflicts: [], - attributeSchema: { - id: 'schema:test', - worldId: 'CUSTOM', - schemaVersion: 1, - schemaName: '测试属性', - generatedFrom: { - worldType: WorldType.CUSTOM, - worldName: '复活回场测试世界', - settingSummary: '首幕站位恢复', - tone: '紧张、克制', - conflictCore: '复活后重新面对主交互角色', - }, - slots: [], - }, - playableNpcs: [], - storyNpcs: [ - createStoryRole('npc-front', '正面对手', true), - createStoryRole('npc-back-1', '后排甲'), - createStoryRole('npc-back-2', '后排乙'), - ], - items: [], - landmarks: [], - camp: { - id: 'custom-scene-camp', - name: '开局营地', - description: '用于复活回场测试。', - visualDescription: '营地火光映着即将重开的第一幕。', - imageSrc: '/camp.png', - sceneNpcIds: ['npc-front', 'npc-back-1', 'npc-back-2'], - connections: [], - narrativeResidues: null, - }, - sceneChapterBlueprints: [ - { - id: 'custom-scene-camp-chapter', - sceneId: 'custom-scene-camp', - title: '开局章节', - summary: '复活后应回到这里的第一幕。', - sceneTaskDescription: '', - linkedThreadIds: [], - linkedLandmarkIds: [], - acts: [ - { - id: 'custom-scene-camp-act-1', - sceneId: 'custom-scene-camp', - title: '第一幕', - summary: '主交互角色与后排角色一同出现。', - stageCoverage: ['opening'], - backgroundImageSrc: '/act-1.png', - encounterNpcIds: ['npc-front', 'npc-back-1', 'npc-back-2'], - primaryNpcId: 'npc-front', - oppositeNpcId: 'npc-front', - eventDescription: '第一幕事件', - linkedThreadIds: [], - advanceRule: 'after_primary_contact', - actGoal: '重新进入首幕', - transitionHook: '首幕回场', - }, - { - id: 'custom-scene-camp-act-2', - sceneId: 'custom-scene-camp', - title: '第二幕', - summary: '这是死亡前已经推进到的幕。', - stageCoverage: ['expansion'], - backgroundImageSrc: '/act-2.png', - encounterNpcIds: ['npc-front', 'npc-back-1', 'npc-back-2'], - primaryNpcId: 'npc-front', - oppositeNpcId: 'npc-front', - eventDescription: '第二幕事件', - linkedThreadIds: [], - advanceRule: 'after_primary_contact', - actGoal: '推进第二幕', - transitionHook: '第二幕推进', - }, - ], - }, - ], - } as NonNullable; - - setRuntimeCustomWorldProfile(customWorldProfile); - const firstScene = getScenePresetsByWorld(WorldType.CUSTOM)[0]!; - - return { - worldType: WorldType.CUSTOM, - customWorldProfile, - playerCharacter: { - id: 'hero', - name: '测试主角', - title: '旅人', - description: '测试角色', - backstory: '测试背景', - avatar: '/hero.png', - portrait: '/hero.png', - assetFolder: 'hero', - assetVariant: 'default', - attributes: { - strength: 10, - agility: 10, - intelligence: 10, - spirit: 10, - }, - personality: 'calm', - skills: [], - adventureOpenings: {}, - }, - runtimeStats: { - playTimeMs: 0, - lastPlayTickAt: null, - hostileNpcsDefeated: 0, - questsAccepted: 0, - itemsUsed: 0, - scenesTraveled: 0, - }, - currentScene: 'Story', - storyHistory: [], - characterChats: {}, - animationState: AnimationState.DIE, - currentEncounter: null, - npcInteractionActive: false, - currentScenePreset: firstScene, - sceneHostileNpcs: [], - playerX: 0, - playerOffsetY: 0, - playerFacing: 'right', - playerActionMode: 'idle', - scrollWorld: false, - inBattle: false, - playerHp: 0, - playerMaxHp: 100, - playerMana: 0, - playerMaxMana: 20, - playerSkillCooldowns: {}, - activeCombatEffects: [], - playerCurrency: 0, - playerInventory: [], - playerEquipment: { - weapon: null, - armor: null, - relic: null, - }, - npcStates: { - 'npc-front': { - affinity: -20, - helpUsed: false, - chattedCount: 0, - giftsGiven: 0, - inventory: [], - recruited: false, - }, - 'npc-back-1': { - affinity: 8, - helpUsed: false, - chattedCount: 0, - giftsGiven: 0, - inventory: [], - recruited: false, - }, - 'npc-back-2': { - affinity: 6, - helpUsed: false, - chattedCount: 0, - giftsGiven: 0, - inventory: [], - recruited: false, - }, - }, - quests: [], - roster: [], - companions: [], - currentBattleNpcId: 'npc-front', - currentNpcBattleMode: 'fight', - currentNpcBattleOutcome: 'fight_defeat', - sparReturnEncounter: null, - sparPlayerHpBefore: null, - sparPlayerMaxHpBefore: null, - sparStoryHistoryBefore: null, - storyEngineMemory: { - discoveredFactIds: [], - activeThreadIds: [], - resolvedScarIds: [], - recentCarrierIds: [], - currentSceneActState: { - sceneId: 'custom-scene-camp', - chapterId: 'custom-scene-camp-chapter', - currentActId: 'custom-scene-camp-act-2', - currentActIndex: 1, - completedActIds: ['custom-scene-camp-act-1'], - visitedActIds: ['custom-scene-camp-act-1', 'custom-scene-camp-act-2'], - }, - }, - } as GameState; -} - -describe('postBattleFlow', () => { - afterEach(() => { - ensureSceneEncounterPreviewMock.mockReset(); - setRuntimeCustomWorldProfile(null); - }); - - it('rebuilds revived first-scene state through encounter preview restoration', () => { - const reviveState = createReviveState(); - const previewRestoredState = { - ...reviveState, - currentEncounter: { - id: 'npc-front', - kind: 'npc' as const, - characterId: 'npc-front', - npcName: '正面对手', - npcDescription: '正面对手的测试描述', - npcAvatar: '正', - context: '敌对角色', - xMeters: 12, - }, - }; - ensureSceneEncounterPreviewMock.mockReturnValue(previewRestoredState); - - const revived = buildRevivedFirstSceneState(reviveState); - - expect(ensureSceneEncounterPreviewMock).toHaveBeenCalledWith( - expect.objectContaining({ - currentScenePreset: expect.objectContaining({ - id: 'custom-scene-camp', - }), - currentEncounter: null, - sceneHostileNpcs: [], - playerHp: 100, - playerMana: 20, - inBattle: false, - currentNpcBattleOutcome: null, - storyEngineMemory: expect.objectContaining({ - currentSceneActState: expect.objectContaining({ - currentActId: 'custom-scene-camp-act-1', - currentActIndex: 0, - }), - }), - }), - ); - expect(revived).toBe(previewRestoredState); - }); -}); diff --git a/src/hooks/rpg-runtime-story/postBattleFlow.ts b/src/hooks/rpg-runtime-story/postBattleFlow.ts deleted file mode 100644 index 08c75979..00000000 --- a/src/hooks/rpg-runtime-story/postBattleFlow.ts +++ /dev/null @@ -1,229 +0,0 @@ -import { getScenePresetById, getScenePresetsByWorld } from '../../data/scenePresets'; -import { ensureSceneEncounterPreview } from '../../data/sceneEncounterPreviews'; -import { - advanceSceneActRuntimeState, - buildInitialSceneActRuntimeState, - getSceneConnectionDirectionText, - resolveSceneActProgression, -} from '../../services/customWorldSceneActRuntime'; -import { createEmptyStoryEngineMemoryState } from '../../services/storyEngine/visibilityEngine'; -import { - AnimationState, - type GameState, - type ScenePresetInfo, - type StoryMoment, - type StoryOption, -} from '../../types'; - -const CONTINUE_ADVENTURE_FUNCTION_ID = 'story_continue_adventure'; -const TRAVEL_NEXT_SCENE_FUNCTION_ID = 'idle_travel_next_scene'; - -function buildBaseFlowVisuals(): StoryOption['visuals'] { - return { - playerAnimation: AnimationState.RUN, - playerMoveMeters: 0.9, - playerOffsetY: 0, - playerFacing: 'right', - scrollWorld: false, - monsterChanges: [], - }; -} - -function buildContinueOption(): StoryOption { - return { - functionId: CONTINUE_ADVENTURE_FUNCTION_ID, - actionText: '继续前进', - text: '继续前进', - priority: 1, - visuals: buildBaseFlowVisuals(), - }; -} - -function buildTravelOption(scene: ScenePresetInfo, actionText: string): StoryOption { - return { - functionId: TRAVEL_NEXT_SCENE_FUNCTION_ID, - actionText, - text: actionText, - priority: 2, - visuals: buildBaseFlowVisuals(), - runtimePayload: { - targetSceneId: scene.id, - }, - }; -} - -export function buildSceneTravelOptions(state: GameState): StoryOption[] { - if (!state.worldType) { - return []; - } - - const currentSceneId = state.currentScenePreset?.id ?? null; - const currentScene = currentSceneId - ? getScenePresetById(state.worldType, currentSceneId) - : null; - const connectionOptions = - currentScene?.connections - ?.map((connection) => { - const scene = getScenePresetById(state.worldType!, connection.sceneId); - if (!scene || scene.id === currentSceneId) { - return null; - } - const directionText = getSceneConnectionDirectionText(connection.relativePosition); - return buildTravelOption(scene, `${directionText},前往${scene.name}`); - }) - .filter((option): option is StoryOption => Boolean(option)) ?? []; - - if (connectionOptions.length > 0) { - return connectionOptions; - } - - return getScenePresetsByWorld(state.worldType) - .filter((scene) => scene.id !== currentSceneId) - .slice(0, 4) - .map((scene) => buildTravelOption(scene, `前往${scene.name}`)); -} - -export function buildPostBattleVictoryState(state: GameState) { - return { - ...state, - currentEncounter: null, - npcInteractionActive: false, - sceneHostileNpcs: [], - inBattle: false, - currentBattleNpcId: null, - currentNpcBattleMode: null, - currentNpcBattleOutcome: null, - sparReturnEncounter: null, - sparPlayerHpBefore: null, - sparPlayerMaxHpBefore: null, - sparStoryHistoryBefore: null, - animationState: AnimationState.IDLE, - playerActionMode: 'idle' as const, - activeCombatEffects: [], - scrollWorld: false, - } satisfies GameState; -} - -export function buildPostBattleVictoryStory( - state: GameState, - resultText: string, - fallbackOptions: StoryOption[] = [], -): { state: GameState; story: StoryMoment } { - const progress = resolveSceneActProgression({ - profile: state.customWorldProfile, - sceneId: state.currentScenePreset?.id ?? null, - storyEngineMemory: state.storyEngineMemory, - }); - const nextActState = progress - ? advanceSceneActRuntimeState({ progress }) - : null; - const nextState = nextActState - ? { - ...state, - storyEngineMemory: { - ...(state.storyEngineMemory ?? createEmptyStoryEngineMemoryState()), - currentSceneActState: nextActState, - }, - } - : state; - if (progress?.isLastAct) { - return { - state: nextState, - story: { - text: resultText, - options: buildSceneTravelOptions(nextState), - streaming: false, - }, - }; - } - - const deferredOptions = - fallbackOptions.length > 0 - ? fallbackOptions - : buildSceneTravelOptions(nextState); - - return { - state: nextState, - story: { - text: resultText, - options: [buildContinueOption()], - deferredOptions, - deferredRuntimeState: nextActState - ? { - storyEngineMemory: nextState.storyEngineMemory, - } - : undefined, - streaming: false, - }, - }; -} - -export function buildRevivedFirstSceneState(state: GameState): GameState { - const firstScene = state.worldType - ? getScenePresetsByWorld(state.worldType)[0] ?? state.currentScenePreset - : state.currentScenePreset; - const storyEngineMemory = - state.storyEngineMemory ?? createEmptyStoryEngineMemoryState(); - const firstActState = buildInitialSceneActRuntimeState({ - profile: state.customWorldProfile, - sceneId: firstScene?.id ?? null, - storyEngineMemory: undefined, - }); - - const revivedBaseState = { - ...state, - currentScenePreset: firstScene, - currentEncounter: null, - npcInteractionActive: false, - sceneHostileNpcs: [], - playerX: 0, - playerFacing: 'right', - playerHp: state.playerMaxHp, - playerMana: state.playerMaxMana, - inBattle: false, - currentBattleNpcId: null, - currentNpcBattleMode: null, - currentNpcBattleOutcome: null, - sparReturnEncounter: null, - sparPlayerHpBefore: null, - sparPlayerMaxHpBefore: null, - sparStoryHistoryBefore: null, - animationState: AnimationState.IDLE, - playerActionMode: 'idle', - activeCombatEffects: [], - scrollWorld: false, - storyEngineMemory: { - ...storyEngineMemory, - currentSceneActState: firstActState, - }, - } satisfies GameState; - - // 中文注释:角色复活后要回到“开局进入世界”同一套首幕 encounter preview - // 构建链,而不是只清空战斗态。这样第一幕主交互 NPC 与同幕陪衬 NPC - // 会按既有槽位一起恢复,避免退化成所有人站成一排。 - return ensureSceneEncounterPreview(revivedBaseState); -} - -export function buildDeathStory( - state: GameState, - deferredOptions?: StoryOption[], -): StoryMoment { - const firstSceneName = - state.worldType - ? getScenePresetsByWorld(state.worldType)[0]?.name - : state.currentScenePreset?.name; - - return { - text: firstSceneName - ? `你在战斗中倒下,随后在${firstSceneName}重新醒来。` - : '你在战斗中倒下,随后重新醒来。', - options: [buildContinueOption()], - // 中文注释:复活后的“继续前进”只负责揭示已经准备好的首场景入口, - // 不能再次触发普通剧情推演,否则容易直接被推进到主 NPC 执行态。 - deferredOptions: - deferredOptions && deferredOptions.length > 0 - ? deferredOptions - : undefined, - streaming: false, - }; -} diff --git a/src/hooks/rpg-runtime-story/progressionActions.ts b/src/hooks/rpg-runtime-story/progressionActions.ts index bb291f35..83028679 100644 --- a/src/hooks/rpg-runtime-story/progressionActions.ts +++ b/src/hooks/rpg-runtime-story/progressionActions.ts @@ -1,86 +1,9 @@ import type { Dispatch, SetStateAction } from 'react'; -import { - acceptQuest, - buildChapterQuestForScene, - getChapterQuestForScene, -} from '../../data/questFlow'; -import { resolveSceneChapterBlueprint } from '../../services/customWorldSceneActRuntime'; import { hasEncounterEntity, interpolateEncounterTransitionState, } from '../../data/encounterTransition'; -import { applyStoryReasoningRecovery } from '../../data/storyRecovery'; -import { - buildFallbackActorNarrativeProfile, - normalizeActorNarrativeProfile, -} from '../../services/storyEngine/actorNarrativeProfile'; -import { resolveCurrentActState } from '../../services/storyEngine/actPlanner'; -import { buildAuthorialConstraintPack } from '../../services/storyEngine/authorialConstraintPack'; -import { evaluateBranchBudget } from '../../services/storyEngine/branchBudgetPlanner'; -import { - advanceCampaignState, - resolveCampaignState, -} from '../../services/storyEngine/campaignDirector'; -import { compileCampaignFromWorldProfile } from '../../services/storyEngine/campaignPackCompiler'; -import { - buildCampEvent, - evaluateCampEventOpportunity, -} from '../../services/storyEngine/campEventDirector'; -import { - advanceChapterState, - resolveCurrentChapterState, -} from '../../services/storyEngine/chapterDirector'; -import { - advanceCompanionArc, - buildCompanionArcStates, -} from '../../services/storyEngine/companionArcDirector'; -import { - applyCompanionReactionToStance, - buildCompanionReactionBatch, -} from '../../services/storyEngine/companionReactionDirector'; -import { resolveAllCompanionResolutions } from '../../services/storyEngine/companionResolutionDirector'; -import { appendConsequenceRecord } from '../../services/storyEngine/consequenceLedger'; -import { buildContentDiffReport } from '../../services/storyEngine/contentDiffReport'; -import { resolveEndingState } from '../../services/storyEngine/endingResolver'; -import { buildEpilogueSummary } from '../../services/storyEngine/epilogueComposer'; -import { buildFactionTensionState } from '../../services/storyEngine/factionTensionState'; -import { resolveCurrentJourneyBeat } from '../../services/storyEngine/journeyBeatPlanner'; -import { buildNarrativeCodex } from '../../services/storyEngine/narrativeCodex'; -import { runNarrativeConsistencyChecks } from '../../services/storyEngine/narrativeConsistencyChecks'; -import { buildNarrativeQaReport } from '../../services/storyEngine/narrativeQaReport'; -import { - recordReplaySeed, - replayNarrativeRun, -} from '../../services/storyEngine/narrativeRegressionReplay'; -import { captureNarrativeTelemetry } from '../../services/storyEngine/narrativeTelemetry'; -import { updatePlayerStyleProfileFromAction } from '../../services/storyEngine/playerStyleProfiler'; -import { runPlaythroughMatrix } from '../../services/storyEngine/playthroughMatrixLab'; -import { buildContinueGameDigest } from '../../services/storyEngine/recapDigest'; -import { buildReleaseGateReport } from '../../services/storyEngine/releaseGateReport'; -import { buildSaveMigrationManifest } from '../../services/storyEngine/saveMigrationManifest'; -import { resolveScenarioPack } from '../../services/storyEngine/scenarioPackRegistry'; -import { - buildSetpieceDirective, - evaluateSetpieceOpportunity, -} from '../../services/storyEngine/setpieceDirector'; -import { appendChronicleEntries } from '../../services/storyEngine/storyChronicle'; -import { buildThemePackFromWorldProfile } from '../../services/storyEngine/themePack'; -import { buildThreadContractsFromProfile } from '../../services/storyEngine/threadContract'; -import { buildInitialSceneActRuntimeState } from '../../services/customWorldSceneActRuntime'; -import { - collectStorySignals, - resolveSignalsToThreadUpdates, -} from '../../services/storyEngine/threadSignalRouter'; -import { - buildEncounterVisibilitySlice, - createEmptyStoryEngineMemoryState, -} from '../../services/storyEngine/visibilityEngine'; -import { - applyWorldMutationsToGameState, - resolveWorldMutations, -} from '../../services/storyEngine/worldMutationRouter'; -import { buildFallbackWorldStoryGraph } from '../../services/storyEngine/worldStoryGraph'; import { createHistoryMoment } from '../../services/storyHistory'; import type { Character, @@ -93,516 +16,6 @@ import type { CommitGeneratedState } from '../generatedState'; const ENCOUNTER_ENTRY_DURATION_MS = 1800; const ENCOUNTER_ENTRY_TICK_MS = 180; -function dedupeStrings(values: Array, limit = 10) { - return [ - ...new Set(values.map((value) => value?.trim() ?? '').filter(Boolean)), - ].slice(0, limit); -} - -function hydrateStoryEngineMemory(state: GameState): GameState { - const storyEngineMemory = - state.storyEngineMemory ?? createEmptyStoryEngineMemoryState(); - if (!state.customWorldProfile || state.currentEncounter?.kind !== 'npc') { - return { - ...state, - storyEngineMemory, - }; - } - - const role = - state.customWorldProfile.storyNpcs.find( - (npc) => - npc.id === state.currentEncounter?.id || - npc.name === state.currentEncounter?.npcName, - ) ?? - state.customWorldProfile.playableNpcs.find( - (npc) => - npc.id === state.currentEncounter?.id || - npc.name === state.currentEncounter?.npcName, - ); - if (!role) { - return { - ...state, - storyEngineMemory, - }; - } - - const themePack = - state.customWorldProfile.themePack ?? - buildThemePackFromWorldProfile(state.customWorldProfile); - const storyGraph = - state.customWorldProfile.storyGraph ?? - buildFallbackWorldStoryGraph(state.customWorldProfile, themePack); - const narrativeProfile = normalizeActorNarrativeProfile( - role.narrativeProfile, - buildFallbackActorNarrativeProfile(role, storyGraph, themePack), - ); - const npcState = - state.npcStates[ - state.currentEncounter.id ?? state.currentEncounter.npcName - ]; - const activeThreadIds = - storyEngineMemory.activeThreadIds.length > 0 - ? storyEngineMemory.activeThreadIds - : narrativeProfile.relatedThreadIds.slice(0, 4); - const visibilitySlice = buildEncounterVisibilitySlice({ - narrativeProfile, - backstoryReveal: state.currentEncounter.backstoryReveal ?? null, - disclosureStage: - npcState?.affinity != null - ? npcState.affinity < 15 - ? 'guarded' - : npcState.affinity < 45 - ? 'partial' - : npcState.affinity < 75 - ? 'honest' - : 'deep' - : 'guarded', - isFirstMeaningfulContact: npcState?.firstMeaningfulContactResolved !== true, - seenBackstoryChapterIds: npcState?.seenBackstoryChapterIds ?? [], - storyEngineMemory, - activeThreadIds, - }); - - return { - ...state, - storyEngineMemory: { - ...storyEngineMemory, - discoveredFactIds: dedupeStrings( - [ - ...storyEngineMemory.discoveredFactIds, - ...visibilitySlice.sayableFactIds, - ], - 16, - ), - activeThreadIds: dedupeStrings( - [...storyEngineMemory.activeThreadIds, ...activeThreadIds], - 6, - ), - }, - }; -} - -function findNewInventoryItems(previousState: GameState, nextState: GameState) { - const previousIds = new Set( - previousState.playerInventory.map((item) => item.id), - ); - return nextState.playerInventory.filter((item) => !previousIds.has(item.id)); -} - -function ensureSceneChapterQuestState(params: { - previousState: GameState; - nextState: GameState; -}) { - const storyEngineMemory = - params.nextState.storyEngineMemory ?? createEmptyStoryEngineMemoryState(); - const scene = params.nextState.currentScenePreset; - if ( - params.nextState.currentScene !== 'Story' || - !params.nextState.worldType || - !scene?.id - ) { - return { - ...params.nextState, - storyEngineMemory, - }; - } - - const openedSceneChapterIds = dedupeStrings( - [...(storyEngineMemory.openedSceneChapterIds ?? [])], - 64, - ); - if (openedSceneChapterIds.includes(scene.id)) { - return { - ...params.nextState, - storyEngineMemory: { - ...storyEngineMemory, - openedSceneChapterIds, - currentSceneActState: - buildInitialSceneActRuntimeState({ - profile: params.nextState.customWorldProfile, - sceneId: scene.id, - storyEngineMemory, - }) ?? storyEngineMemory.currentSceneActState ?? null, - }, - }; - } - - const nextMemory = { - ...storyEngineMemory, - openedSceneChapterIds: [...openedSceneChapterIds, scene.id], - currentSceneActState: - buildInitialSceneActRuntimeState({ - profile: params.nextState.customWorldProfile, - sceneId: scene.id, - storyEngineMemory, - }) ?? storyEngineMemory.currentSceneActState ?? null, - }; - const existingChapterQuest = getChapterQuestForScene( - params.nextState.quests, - scene.id, - ); - if (existingChapterQuest) { - return { - ...params.nextState, - storyEngineMemory: nextMemory, - }; - } - - const sceneChapter = resolveSceneChapterBlueprint( - params.nextState.customWorldProfile, - scene.id, - ); - const sceneChapterContext = sceneChapter - ? { - sceneTaskDescription: sceneChapter.sceneTaskDescription, - actEventDescriptions: sceneChapter.acts - .map((act) => act.eventDescription) - .filter(Boolean), - primaryNpcName: - params.nextState.customWorldProfile?.storyNpcs.find( - (npc) => npc.id === sceneChapter.acts[0]?.primaryNpcId, - )?.name ?? sceneChapter.acts[0]?.primaryNpcId ?? null, - } - : null; - - const chapterQuest = buildChapterQuestForScene({ - scene, - worldType: params.nextState.worldType, - sceneChapterContext, - context: { - worldType: params.nextState.worldType, - actState: params.nextState.storyEngineMemory?.actState ?? null, - recentStoryMoments: params.nextState.storyHistory.slice(-6), - playerCharacter: params.nextState.playerCharacter, - playerProgression: params.nextState.playerProgression ?? null, - }, - }); - if (!chapterQuest) { - return { - ...params.nextState, - storyEngineMemory: nextMemory, - }; - } - - return { - ...params.nextState, - storyEngineMemory: nextMemory, - quests: acceptQuest(params.nextState.quests, chapterQuest), - }; -} - -function applyStoryEngineEchoes(params: { - previousState: GameState; - nextState: GameState; - actionText: string; - lastFunctionId?: string | null; -}) { - const hydratedState = hydrateStoryEngineMemory(params.nextState); - const contracts = hydratedState.customWorldProfile - ? (hydratedState.customWorldProfile.threadContracts ?? - buildThreadContractsFromProfile(hydratedState.customWorldProfile)) - : []; - const newItems = findNewInventoryItems(params.previousState, hydratedState); - const signals = collectStorySignals({ - prevState: params.previousState, - nextState: hydratedState, - actionText: params.actionText, - lastFunctionId: params.lastFunctionId, - rewardItems: newItems, - }); - const stateWithSignals = resolveSignalsToThreadUpdates({ - state: hydratedState, - signals, - contracts, - }); - const stateWithSceneChapter = ensureSceneChapterQuestState({ - previousState: params.previousState, - nextState: stateWithSignals, - }); - const reactions = buildCompanionReactionBatch({ - state: stateWithSceneChapter, - signals, - actionText: params.actionText, - }); - const stateWithReactions = applyCompanionReactionToStance({ - state: stateWithSceneChapter, - reactions, - }); - const storyEngineMemory = - stateWithReactions.storyEngineMemory ?? createEmptyStoryEngineMemoryState(); - const chapterState = advanceChapterState({ - previousChapter: - stateWithReactions.chapterState ?? - storyEngineMemory.currentChapter ?? - null, - nextChapter: resolveCurrentChapterState({ - state: stateWithReactions, - }), - }); - const journeyBeat = resolveCurrentJourneyBeat({ - state: { - ...stateWithReactions, - chapterState, - storyEngineMemory: { - ...storyEngineMemory, - currentChapter: chapterState, - }, - }, - chapterState, - }); - const companionArcStates = advanceCompanionArc({ - previous: storyEngineMemory.companionArcStates, - next: buildCompanionArcStates({ - state: stateWithReactions, - reactions, - }), - }); - const campEvent = evaluateCampEventOpportunity({ - state: stateWithReactions, - chapterState, - journeyBeat, - companionArcStates, - }) - ? buildCampEvent({ - state: stateWithReactions, - chapterState, - journeyBeat, - companionArcStates, - }) - : null; - const worldMutations = resolveWorldMutations({ - state: stateWithReactions, - signals, - chapterState, - }); - const stateWithMutations = applyWorldMutationsToGameState({ - state: stateWithReactions, - mutations: worldMutations, - }); - const setpieceDirective = evaluateSetpieceOpportunity({ - state: stateWithMutations, - chapterState, - journeyBeat, - }) - ? buildSetpieceDirective({ - state: stateWithMutations, - chapterState, - journeyBeat, - }) - : null; - const chronicle = appendChronicleEntries({ - state: stateWithMutations, - chapterState, - worldMutations, - reactions, - signals, - campEvent, - setpieceDirective, - }); - const factionTensionStates = buildFactionTensionState( - stateWithMutations.customWorldProfile, - storyEngineMemory, - ); - const actState = resolveCurrentActState({ - state: stateWithMutations, - chapterState, - }); - const campaignState = advanceCampaignState({ - previous: - storyEngineMemory.campaignState ?? - stateWithMutations.campaignState ?? - null, - next: resolveCampaignState({ - state: stateWithMutations, - actState, - }), - }); - const consequenceLedger = appendConsequenceRecord({ - existing: storyEngineMemory.consequenceLedger, - signals, - reactions, - worldMutations, - campEvent, - }); - const authorialConstraintPack = buildAuthorialConstraintPack({ - profile: stateWithMutations.customWorldProfile, - }); - const compiledPacks = stateWithMutations.customWorldProfile - ? compileCampaignFromWorldProfile({ - profile: stateWithMutations.customWorldProfile, - }) - : null; - const activeScenarioPack = - resolveScenarioPack(stateWithMutations.activeScenarioPackId) ?? - compiledPacks?.scenarioPack ?? - null; - const activeCampaignPack = compiledPacks?.campaignPack ?? null; - const playerStyleProfile = updatePlayerStyleProfileFromAction({ - current: storyEngineMemory.playerStyleProfile, - actionText: params.actionText, - }); - const companionResolutions = resolveAllCompanionResolutions({ - state: stateWithMutations, - arcStates: companionArcStates, - ledger: consequenceLedger, - reactions, - }); - const endingState = - actState?.status === 'finale' || actState?.status === 'resolved' - ? resolveEndingState({ - state: stateWithMutations, - companionResolutions, - factionTensionStates, - }) - : (storyEngineMemory.endingState ?? null); - const epilogueSummary = endingState - ? buildEpilogueSummary({ - endingState, - companionResolutions, - }) - : null; - const currentJourneyBeatId = - journeyBeat?.id ?? storyEngineMemory.currentJourneyBeatId ?? null; - const branchBudgetStatus = evaluateBranchBudget({ - consequenceLedger, - authorialConstraintPack, - endingFamilyCount: endingState ? 1 : 0, - }); - const baseMemoryForQa = { - ...storyEngineMemory, - currentChapter: chapterState, - currentJourneyBeatId, - currentJourneyBeat: journeyBeat, - companionArcStates, - worldMutations, - chronicle, - factionTensionStates, - currentCampEvent: campEvent, - currentSetpieceDirective: setpieceDirective, - campaignState, - actState, - consequenceLedger, - companionResolutions, - endingState, - authorialConstraintPack, - branchBudgetStatus, - playerStyleProfile, - }; - const consistencyIssues = runNarrativeConsistencyChecks({ - memory: baseMemoryForQa, - threadContracts: contracts, - branchBudgetStatus, - }); - const narrativeQaReport = buildNarrativeQaReport({ - issues: consistencyIssues, - }); - const simulationRunResults = - activeScenarioPack && activeCampaignPack - ? runPlaythroughMatrix({ - scenarioPackId: activeScenarioPack.id, - campaignPack: activeCampaignPack, - memory: { - ...baseMemoryForQa, - narrativeQaReport, - }, - seeds: ['baseline', 'companion', 'explore'], - }) - : []; - const replaySummary = simulationRunResults[0] - ? replayNarrativeRun({ - recordedSeed: recordReplaySeed({ - seed: simulationRunResults[0].seed, - label: `${activeCampaignPack?.title ?? 'campaign'} 基线回放`, - }), - result: simulationRunResults[0], - }).summary - : null; - const releaseGateReport = buildReleaseGateReport({ - qaReport: narrativeQaReport, - simulationResults: simulationRunResults, - unresolvedThreadCount: - stateWithMutations.storyEngineMemory?.activeThreadIds.length ?? 0, - }); - const saveMigrationManifest = buildSaveMigrationManifest({ - version: 'story-engine-v5', - }); - const telemetrySnapshot = captureNarrativeTelemetry({ - memory: { - ...baseMemoryForQa, - narrativeQaReport, - }, - qaReport: narrativeQaReport, - }); - const contentDiffReport = buildContentDiffReport({ - previousProfile: params.previousState.customWorldProfile, - nextProfile: stateWithMutations.customWorldProfile, - previousCampaignPack: null, - nextCampaignPack: activeCampaignPack, - }); - const narrativeCodex = buildNarrativeCodex({ - ...stateWithMutations, - chapterState, - campaignState, - storyEngineMemory: { - ...baseMemoryForQa, - narrativeQaReport, - releaseGateReport, - simulationRunResults, - }, - }); - const continueDigest = - buildContinueGameDigest({ - state: { - ...stateWithMutations, - chapterState, - campaignState, - storyEngineMemory: { - ...baseMemoryForQa, - currentJourneyBeatId, - narrativeQaReport, - releaseGateReport, - simulationRunResults, - narrativeCodex, - saveMigrationManifest, - }, - }, - }) + - [ - epilogueSummary, - replaySummary, - telemetrySnapshot.summary, - contentDiffReport.summary, - `发布门禁:${releaseGateReport.status} / ${releaseGateReport.summary}`, - ] - .filter(Boolean) - .join('\n'); - - return { - ...stateWithMutations, - chapterState, - campaignState, - activeScenarioPackId: - activeScenarioPack?.id ?? stateWithMutations.activeScenarioPackId ?? null, - activeCampaignPackId: - activeCampaignPack?.id ?? stateWithMutations.activeCampaignPackId ?? null, - storyEngineMemory: { - ...baseMemoryForQa, - currentJourneyBeatId, - continueGameDigest: continueDigest, - narrativeQaReport, - narrativeCodex, - releaseGateReport, - simulationRunResults, - saveMigrationManifest, - recentCompanionReactions: [ - ...(storyEngineMemory.recentCompanionReactions ?? []), - ...reactions, - ].slice(-6), - }, - }; -} - export type GenerateStoryForState = (params: { state: GameState; character: Character; @@ -664,15 +77,10 @@ export function createStoryProgressionActions({ lastFunctionId, ) => { const nextHistory = appendStoryHistory(gameState, actionText, resultText); - const stateWithHistory = applyStoryEngineEchoes({ - previousState: gameState, - nextState: { - ...nextState, - storyHistory: nextHistory, - } as GameState, - actionText, - lastFunctionId, - }); + const stateWithHistory = { + ...nextState, + storyHistory: nextHistory, + } as GameState; setGameState(stateWithHistory); setAiError(null); @@ -686,13 +94,7 @@ export function createStoryProgressionActions({ choice: actionText, lastFunctionId, }); - const recoveredState = applyStoryEngineEchoes({ - previousState: gameState, - nextState: applyStoryReasoningRecovery(stateWithHistory), - actionText, - lastFunctionId, - }); - setGameState(recoveredState); + setGameState(stateWithHistory); setCurrentStory(nextStory); } catch (error) { console.error('Failed to continue scripted story:', error); @@ -744,15 +146,10 @@ export function createStoryProgressionActions({ } const nextHistory = appendStoryHistory(gameState, actionText, resultText); - const stateWithHistory = applyStoryEngineEchoes({ - previousState: gameState, - nextState: { - ...resolvedState, - storyHistory: nextHistory, - } as GameState, - actionText, - lastFunctionId, - }); + const stateWithHistory = { + ...resolvedState, + storyHistory: nextHistory, + } as GameState; setGameState(stateWithHistory); @@ -764,13 +161,7 @@ export function createStoryProgressionActions({ choice: actionText, lastFunctionId, }); - const recoveredState = applyStoryEngineEchoes({ - previousState: gameState, - nextState: applyStoryReasoningRecovery(stateWithHistory), - actionText, - lastFunctionId, - }); - setGameState(recoveredState); + setGameState(stateWithHistory); setCurrentStory(nextStory); } catch (error) { console.error('Failed to continue encounter-entry story:', error); diff --git a/src/hooks/rpg-runtime-story/rpgRuntimeStoryGateway.ts b/src/hooks/rpg-runtime-story/rpgRuntimeStoryGateway.ts index ec35e9d5..a05804d6 100644 --- a/src/hooks/rpg-runtime-story/rpgRuntimeStoryGateway.ts +++ b/src/hooks/rpg-runtime-story/rpgRuntimeStoryGateway.ts @@ -1,9 +1,3 @@ -import { createNpcBattleMonster } from '../../data/npcInteractions'; -import { - buildNpcBattleFormationFromEncounter, - RESOLVED_ENTITY_X_METERS, -} from '../../data/sceneEncounterPreviews'; -import { getForwardScenePreset } from '../../data/scenePresets'; import { rehydrateSavedSnapshot } from '../../persistence/runtimeSnapshot'; import type { HydratedSavedGameSnapshot } from '../../persistence/runtimeSnapshotTypes'; import { @@ -14,94 +8,8 @@ import { resolveRpgRuntimeStoryMoment, type RuntimeStoryChoicePayload, type RuntimeStoryResponse, - type RuntimeStorySnapshotRequest, } from '../../services/rpg-runtime/rpgRuntimeStoryClient'; -import type { GameState, SceneHostileNpc, StoryMoment, StoryOption } from '../../types'; -import { buildMapTravelResolution } from './storyGenerationState'; - -function isNpcBattleAlignmentDebugEnabled() { - if (typeof window === 'undefined') { - return false; - } - - return ( - window.localStorage.getItem('rpg:npc-battle-alignment-debug') === '1' || - window.location.search.includes('npcBattleAlignmentDebug=1') - ); -} - -function logNpcBattleAlignment(label: string, monsters: GameState['sceneHostileNpcs']) { - if (!isNpcBattleAlignmentDebugEnabled()) { - return; - } - - console.info( - `[npc-battle-alignment] ${label}`, - monsters.map((monster) => ({ - id: monster.id, - encounterId: monster.encounter?.id ?? null, - encounterName: monster.encounter?.npcName ?? null, - xMeters: monster.xMeters, - yOffset: monster.yOffset, - facing: monster.facing, - animation: monster.animation, - })), - ); -} - -function cloneBattleFormation(monsters: GameState['sceneHostileNpcs']) { - return monsters.map( - (monster) => - ({ - ...monster, - encounter: monster.encounter - ? { - ...monster.encounter, - } - : monster.encounter, - }) satisfies SceneHostileNpc, - ); -} - -function alignBattleFormationToVisibleFormation(params: { - visibleFormation: GameState['sceneHostileNpcs']; - battleFormation: GameState['sceneHostileNpcs']; -}) { - const { visibleFormation, battleFormation } = params; - if (visibleFormation.length === 0 || battleFormation.length === 0) { - return battleFormation; - } - - const visibleFormationByEncounterId = new Map( - visibleFormation.map((monster) => [ - monster.encounter?.id ?? monster.encounter?.npcName ?? monster.id, - monster, - ]), - ); - - return battleFormation.map((monster) => { - const encounterKey = - monster.encounter?.id ?? monster.encounter?.npcName ?? monster.id; - const visibleMonster = visibleFormationByEncounterId.get(encounterKey); - if (!visibleMonster) { - return monster; - } - - return { - ...monster, - xMeters: visibleMonster.xMeters, - yOffset: visibleMonster.yOffset, - facing: visibleMonster.facing, - encounter: monster.encounter - ? { - ...monster.encounter, - xMeters: - visibleMonster.encounter?.xMeters ?? visibleMonster.xMeters, - } - : monster.encounter, - } satisfies SceneHostileNpc; - }); -} +import type { GameState, StoryMoment, StoryOption } from '../../types'; function getRuntimeResponseOptions(response: RuntimeStoryResponse) { return response.viewModel.availableOptions.length > 0 @@ -109,209 +17,6 @@ function getRuntimeResponseOptions(response: RuntimeStoryResponse) { : response.presentation.options; } -function buildRuntimeSnapshotRequest( - gameState: GameState, - currentStory: StoryMoment | null, -): RuntimeStorySnapshotRequest { - return { - gameState, - bottomTab: 'adventure', - currentStory, - }; -} - -function resolveServerTravelTargetSceneId(params: { - previousState: GameState; - snapshotState: GameState; -}) { - const { previousState, snapshotState } = params; - const snapshotSceneId = snapshotState.currentScenePreset?.id ?? null; - if ( - snapshotSceneId && - snapshotSceneId !== previousState.currentScenePreset?.id - ) { - return snapshotSceneId; - } - - if (!previousState.worldType) { - return null; - } - - return ( - getForwardScenePreset( - previousState.worldType, - previousState.currentScenePreset?.id, - )?.id ?? - previousState.currentScenePreset?.forwardSceneId ?? - null - ); -} - -function bridgeServerSceneTravelSnapshot(params: { - previousState: GameState; - hydratedSnapshot: HydratedSavedGameSnapshot; - functionId: string; -}) { - const { previousState, hydratedSnapshot, functionId } = params; - if (functionId !== 'idle_travel_next_scene' || !previousState.worldType) { - return hydratedSnapshot; - } - - const targetSceneId = resolveServerTravelTargetSceneId({ - previousState, - snapshotState: hydratedSnapshot.gameState, - }); - if (!targetSceneId) { - return hydratedSnapshot; - } - - const travelResolution = buildMapTravelResolution(previousState, targetSceneId); - if (!travelResolution) { - return hydratedSnapshot; - } - - return { - ...hydratedSnapshot, - gameState: { - ...hydratedSnapshot.gameState, - // 中文注释:服务端 compat 当前只保证“本轮旅行动作已经结算完成”, - // 前端这里复用既有地图旅行真相,补齐下一幕场景 preset、遭遇预览和任务推进结果。 - currentScenePreset: travelResolution.nextState.currentScenePreset, - currentEncounter: travelResolution.nextState.currentEncounter, - npcInteractionActive: travelResolution.nextState.npcInteractionActive, - sceneHostileNpcs: travelResolution.nextState.sceneHostileNpcs, - playerX: travelResolution.nextState.playerX, - playerFacing: travelResolution.nextState.playerFacing, - animationState: travelResolution.nextState.animationState, - playerActionMode: travelResolution.nextState.playerActionMode, - activeCombatEffects: travelResolution.nextState.activeCombatEffects, - scrollWorld: travelResolution.nextState.scrollWorld, - inBattle: travelResolution.nextState.inBattle, - lastObserveSignsSceneId: travelResolution.nextState.lastObserveSignsSceneId, - lastObserveSignsReport: travelResolution.nextState.lastObserveSignsReport, - currentBattleNpcId: travelResolution.nextState.currentBattleNpcId, - currentNpcBattleMode: travelResolution.nextState.currentNpcBattleMode, - currentNpcBattleOutcome: travelResolution.nextState.currentNpcBattleOutcome, - sparReturnEncounter: travelResolution.nextState.sparReturnEncounter, - sparPlayerHpBefore: travelResolution.nextState.sparPlayerHpBefore, - sparPlayerMaxHpBefore: travelResolution.nextState.sparPlayerMaxHpBefore, - sparStoryHistoryBefore: travelResolution.nextState.sparStoryHistoryBefore, - runtimeStats: { - ...hydratedSnapshot.gameState.runtimeStats, - scenesTraveled: - travelResolution.nextState.runtimeStats.scenesTraveled, - }, - quests: - hydratedSnapshot.gameState.quests.length > 0 - ? hydratedSnapshot.gameState.quests - : travelResolution.nextState.quests, - }, - } satisfies HydratedSavedGameSnapshot; -} - -function bridgeServerNpcBattleSnapshot(params: { - previousState: GameState; - hydratedSnapshot: HydratedSavedGameSnapshot; - functionId: string; -}) { - const { previousState, hydratedSnapshot, functionId } = params; - if (functionId !== 'npc_fight' && functionId !== 'npc_spar') { - return hydratedSnapshot; - } - - const snapshotState = hydratedSnapshot.gameState; - const isNpcBattleActive = - snapshotState.inBattle && - Boolean(snapshotState.currentBattleNpcId) && - Boolean(snapshotState.currentNpcBattleMode); - const hasResolvedBattleMonster = snapshotState.sceneHostileNpcs.length > 0; - const sourceEncounter = - previousState.currentEncounter?.kind === 'npc' - ? previousState.currentEncounter - : null; - - // 中文注释:作品测试/幕预览里最容易出现的错位,是服务端已经把 - // currentBattleNpcId / currentNpcBattleMode 切进战斗,但快照里没有把 - // sceneHostileNpcs 一起带回。这样前端本地 battlePlan 会直接判定 - // “场上没有敌人”,点击 battle_* 后立刻把整场战斗收掉。 - // 这里统一在网关层补齐 NPC 战场快照,保证后续本地逐轮回合一定有敌方单位可结算。 - if (!isNpcBattleActive || !sourceEncounter) { - return hydratedSnapshot; - } - - const fallbackNpcState = - snapshotState.npcStates[ - snapshotState.currentBattleNpcId ?? sourceEncounter.id ?? sourceEncounter.npcName - ] ?? - previousState.npcStates[ - previousState.currentBattleNpcId ?? sourceEncounter.id ?? sourceEncounter.npcName - ] ?? { - affinity: sourceEncounter.initialAffinity ?? (sourceEncounter.hostile ? -10 : 0), - helpUsed: false, - chattedCount: 0, - giftsGiven: 0, - inventory: [], - recruited: false, - }; - - const battleMode = - snapshotState.currentNpcBattleMode === 'spar' ? 'spar' : 'fight'; - const fallbackFormationFromSceneAct = buildNpcBattleFormationFromEncounter({ - state: previousState, - encounter: { - ...sourceEncounter, - xMeters: sourceEncounter.xMeters ?? RESOLVED_ENTITY_X_METERS, - }, - mode: battleMode, - }); - const fallbackFormation = - previousState.sceneHostileNpcs.length > 0 - ? cloneBattleFormation(previousState.sceneHostileNpcs) - : fallbackFormationFromSceneAct.length > 0 - ? fallbackFormationFromSceneAct - : [ - createNpcBattleMonster( - sourceEncounter, - fallbackNpcState, - battleMode, - { - worldType: snapshotState.worldType, - customWorldProfile: snapshotState.customWorldProfile, - }, - ), - ]; - const resolvedBattleFormation = hasResolvedBattleMonster - ? alignBattleFormationToVisibleFormation({ - visibleFormation: previousState.sceneHostileNpcs, - battleFormation: snapshotState.sceneHostileNpcs, - }) - : fallbackFormation; - - logNpcBattleAlignment('previous-visible-formation', previousState.sceneHostileNpcs); - logNpcBattleAlignment('server-battle-formation', snapshotState.sceneHostileNpcs); - logNpcBattleAlignment('resolved-battle-formation', resolvedBattleFormation); - - return { - ...hydratedSnapshot, - gameState: { - ...snapshotState, - // 中文注释:优先沿用进入战斗前已经可见的阵容与站位; - // 若上一帧还没有 battle combatants,则从幕预览/当前遭遇恢复完整 NPC 编队, - // 避免只补出一个前排角色,造成后排消失和敌方位置突变。 - sceneHostileNpcs: resolvedBattleFormation, - currentEncounter: null, - npcInteractionActive: false, - // 中文注释:服务端兼容链路若未带回战前遭遇,则沿用进入战斗前的原始 encounter, - // 让后续 fight_victory / spar_complete 都能恢复到正确站位,而不是战斗中的临时坐标。 - sparReturnEncounter: - snapshotState.sparReturnEncounter ?? - (previousState.currentEncounter?.kind === 'npc' - ? previousState.currentEncounter - : null), - }, - } satisfies HydratedSavedGameSnapshot; -} - /** * 前端访问服务端 runtime story 的统一网关。 * 统一处理 option catalog 拉取、继续游戏恢复与正式动作结算。 @@ -320,10 +25,11 @@ export async function loadServerRuntimeOptionCatalog(params: { gameState: GameState; currentStory: StoryMoment | null; }) { + // 中文注释:状态目录只从服务端持久化 session 读取, + // 前端不再上传本地 GameState 快照参与动作合法性解析。 const response = await getRpgRuntimeStoryState({ sessionId: getRpgRuntimeSessionId(params.gameState), clientVersion: getRpgRuntimeClientVersion(params.gameState), - snapshot: buildRuntimeSnapshotRequest(params.gameState, params.currentStory), }); const options = resolveRpgRuntimeStoryMoment({ response, @@ -351,6 +57,8 @@ export async function resumeServerRuntimeStory( }; } + // 中文注释:继续游戏后向服务端刷新一次状态, + // 让长期离线的本地快照重新对齐服务端当前 runtime view model。 const response = await getRpgRuntimeStoryState({ sessionId: getRpgRuntimeSessionId(hydratedSnapshot.gameState), }); @@ -383,6 +91,8 @@ export async function resolveServerRuntimeChoice(params: { Partial>; payload?: RuntimeStoryChoicePayload; }) { + // 中文注释:正式动作结算统一先走服务端; + // 前端这里只提交 action/payload,并消费后端已经补齐的快照与表现数据。 const response = await resolveRpgRuntimeStoryAction({ sessionId: getRpgRuntimeSessionId(params.gameState), clientVersion: getRpgRuntimeClientVersion(params.gameState), @@ -392,17 +102,8 @@ export async function resolveServerRuntimeChoice(params: { ? params.option.interaction.npcId : undefined, payload: params.payload, - snapshot: buildRuntimeSnapshotRequest(params.gameState, params.currentStory), - }); - const hydratedSnapshot = bridgeServerSceneTravelSnapshot({ - previousState: params.gameState, - hydratedSnapshot: bridgeServerNpcBattleSnapshot({ - previousState: params.gameState, - hydratedSnapshot: rehydrateSavedSnapshot(response.snapshot), - functionId: params.option.functionId, - }), - functionId: params.option.functionId, }); + const hydratedSnapshot = rehydrateSavedSnapshot(response.snapshot); return { response, diff --git a/src/hooks/rpg-runtime-story/runtimeStoryCoordinator.test.ts b/src/hooks/rpg-runtime-story/runtimeStoryCoordinator.test.ts index a117b104..b8b1670f 100644 --- a/src/hooks/rpg-runtime-story/runtimeStoryCoordinator.test.ts +++ b/src/hooks/rpg-runtime-story/runtimeStoryCoordinator.test.ts @@ -257,7 +257,7 @@ describe('runtimeStoryCoordinator', () => { getRuntimeClientVersionMock.mockReturnValue(7); }); - it('loads runtime option catalogs through the persisted server snapshot flow', async () => { + it('loads runtime option catalogs through the persisted server state flow', async () => { const gameState = createGameState(); const currentStory = createStory('当前故事'); @@ -311,11 +311,6 @@ describe('runtimeStoryCoordinator', () => { expect(getRuntimeStoryStateMock).toHaveBeenCalledWith({ sessionId: 'runtime-main', clientVersion: 7, - snapshot: { - gameState, - bottomTab: 'adventure', - currentStory, - }, }); expect(options).toEqual([ expect.objectContaining({ @@ -416,11 +411,6 @@ describe('runtimeStoryCoordinator', () => { payload: { note: 'server-runtime-test', }, - snapshot: { - gameState, - bottomTab: 'adventure', - currentStory, - }, }); expect(result.hydratedSnapshot).toBe(hydratedSnapshot); expect(result.nextStory).toEqual( @@ -653,7 +643,7 @@ describe('runtimeStoryCoordinator', () => { ); }); - it('backfills npc battle monsters when npc_fight snapshot marks battle active but omits sceneHostileNpcs', async () => { + it('does not patch incomplete npc_fight snapshots in the frontend gateway', async () => { const gameState = { ...createTravelGameState(), currentEncounter: { @@ -753,419 +743,17 @@ describe('runtimeStoryCoordinator', () => { option, }); - expect(result.hydratedSnapshot.gameState.sceneHostileNpcs).toHaveLength(1); - expect(result.hydratedSnapshot.gameState.sceneHostileNpcs[0]).toEqual( + expect(result.hydratedSnapshot.gameState.sceneHostileNpcs).toEqual([]); + expect(result.hydratedSnapshot.gameState.currentEncounter).toEqual( expect.objectContaining({ - encounter: expect.objectContaining({ - id: 'npc-bandit', - npcName: '断桥匪首', - }), - renderKind: 'npc', + id: 'npc-bandit', + npcName: '断桥匪首', }), ); - expect(result.hydratedSnapshot.gameState.currentEncounter).toBeNull(); expect(result.hydratedSnapshot.gameState.npcInteractionActive).toBe(false); }); - it('preserves previous hostile formation when npc_fight snapshot omits battle members', async () => { - const gameState = { - ...createTravelGameState(), - currentEncounter: { - id: 'npc-front', - kind: 'npc', - npcName: '正面对手', - npcDescription: '正面对手', - npcAvatar: '/npc-front.png', - context: '桥口', - hostile: true, - initialAffinity: -20, - }, - npcInteractionActive: true, - sceneHostileNpcs: [ - { - id: 'npc-opponent-npc-front', - name: '正面对手', - action: '摆开架势,随时准备出手', - description: '正面对手', - animation: 'idle', - xMeters: 3.2, - yOffset: 0, - facing: 'left', - attackRange: 1.8, - speed: 8, - hp: 88, - maxHp: 88, - renderKind: 'npc', - encounter: { - id: 'npc-front', - kind: 'npc', - npcName: '正面对手', - npcDescription: '正面对手', - npcAvatar: '/npc-front.png', - context: '桥口', - hostile: true, - xMeters: 3.2, - }, - }, - { - id: 'npc-opponent-npc-back-1', - name: '后排甲', - action: '摆开架势,随时准备出手', - description: '后排甲', - animation: 'idle', - xMeters: 4.28, - yOffset: 62, - facing: 'left', - attackRange: 1.8, - speed: 7, - hp: 76, - maxHp: 76, - renderKind: 'npc', - encounter: { - id: 'npc-back-1', - kind: 'npc', - npcName: '后排甲', - npcDescription: '后排甲', - npcAvatar: '/npc-back-1.png', - context: '桥口', - hostile: true, - xMeters: 4.28, - }, - }, - ] as GameState['sceneHostileNpcs'], - } as GameState; - const currentStory = createStory('当前故事'); - const option = { - functionId: 'npc_fight', - actionText: '直接开战', - text: '直接开战', - interaction: { - kind: 'npc', - npcId: 'npc-front', - action: 'fight', - }, - visuals: { - playerAnimation: 'idle', - playerMoveMeters: 0, - playerOffsetY: 0, - playerFacing: 'right', - scrollWorld: false, - monsterChanges: [], - }, - } as StoryOption; - - resolveRuntimeStoryActionMock.mockResolvedValue({ - sessionId: 'runtime-main', - serverVersion: 8, - viewModel: { - player: { - hp: 42, - maxHp: 50, - mana: 20, - maxMana: 20, - }, - encounter: { - id: 'npc-front', - kind: 'npc', - npcName: '正面对手', - hostile: true, - affinity: -20, - recruited: false, - interactionActive: false, - battleMode: 'fight', - }, - companions: [], - availableOptions: [ - { - functionId: 'battle_attack_basic', - actionText: '普通攻击', - scope: 'combat', - }, - ], - status: { - inBattle: true, - npcInteractionActive: false, - currentNpcBattleMode: 'fight', - currentNpcBattleOutcome: null, - }, - }, - presentation: { - actionText: '直接开战', - resultText: '当前冲突正式转入战斗结算。', - storyText: '正面对手带着同伴压了上来。', - options: [], - }, - patches: [], - snapshot: createRuntimeNpcBattleSnapshot({ - currentEncounter: { - kind: 'npc', - id: 'npc-front', - npcName: '正面对手', - npcDescription: '正面对手', - context: '桥口', - hostile: true, - } as GameState['currentEncounter'], - npcInteractionActive: false, - sceneHostileNpcs: [], - inBattle: true, - currentBattleNpcId: 'npc-front', - currentNpcBattleMode: 'fight', - }), - }); - - const result = await resolveServerRuntimeChoice({ - gameState, - currentStory, - option, - }); - - expect( - result.hydratedSnapshot.gameState.sceneHostileNpcs.map((monster) => ({ - encounterId: monster.encounter?.id, - xMeters: monster.xMeters, - yOffset: monster.yOffset, - })), - ).toEqual([ - { - encounterId: 'npc-front', - xMeters: 3.2, - yOffset: 0, - }, - { - encounterId: 'npc-back-1', - xMeters: 4.28, - yOffset: 62, - }, - ]); - expect(result.hydratedSnapshot.gameState.sparReturnEncounter).toEqual( - gameState.currentEncounter, - ); - }); - - it('realigns non-empty npc_fight battle snapshots back to the visible pre-battle formation', async () => { - const gameState = { - ...createTravelGameState(), - currentEncounter: { - id: 'npc-front', - kind: 'npc', - npcName: '正面对手', - npcDescription: '正面对手', - npcAvatar: '/npc-front.png', - context: '桥口', - hostile: true, - initialAffinity: -20, - }, - npcInteractionActive: true, - sceneHostileNpcs: [ - { - id: 'npc-opponent-npc-front', - name: '正面对手', - action: '摆开架势,随时准备出手', - description: '正面对手', - animation: 'idle', - xMeters: 3.2, - yOffset: 0, - facing: 'left', - attackRange: 1.8, - speed: 8, - hp: 88, - maxHp: 88, - renderKind: 'npc', - encounter: { - id: 'npc-front', - kind: 'npc', - npcName: '正面对手', - npcDescription: '正面对手', - npcAvatar: '/npc-front.png', - context: '桥口', - hostile: true, - xMeters: 3.2, - }, - }, - { - id: 'npc-opponent-npc-back-1', - name: '后排甲', - action: '摆开架势,随时准备出手', - description: '后排甲', - animation: 'idle', - xMeters: 4.28, - yOffset: 62, - facing: 'left', - attackRange: 1.8, - speed: 7, - hp: 76, - maxHp: 76, - renderKind: 'npc', - encounter: { - id: 'npc-back-1', - kind: 'npc', - npcName: '后排甲', - npcDescription: '后排甲', - npcAvatar: '/npc-back-1.png', - context: '桥口', - hostile: true, - xMeters: 4.28, - }, - }, - ] as GameState['sceneHostileNpcs'], - } as GameState; - const currentStory = createStory('当前故事'); - const option = { - functionId: 'npc_fight', - actionText: '直接开战', - text: '直接开战', - interaction: { - kind: 'npc', - npcId: 'npc-front', - action: 'fight', - }, - visuals: { - playerAnimation: 'idle', - playerMoveMeters: 0, - playerOffsetY: 0, - playerFacing: 'right', - scrollWorld: false, - monsterChanges: [], - }, - } as StoryOption; - - resolveRuntimeStoryActionMock.mockResolvedValue({ - sessionId: 'runtime-main', - serverVersion: 8, - viewModel: { - player: { - hp: 42, - maxHp: 50, - mana: 20, - maxMana: 20, - }, - encounter: { - id: 'npc-front', - kind: 'npc', - npcName: '正面对手', - hostile: true, - affinity: -20, - recruited: false, - interactionActive: false, - battleMode: 'fight', - }, - companions: [], - availableOptions: [ - { - functionId: 'battle_attack_basic', - actionText: '普通攻击', - scope: 'combat', - }, - ], - status: { - inBattle: true, - npcInteractionActive: false, - currentNpcBattleMode: 'fight', - currentNpcBattleOutcome: null, - }, - }, - presentation: { - actionText: '直接开战', - resultText: '当前冲突正式转入战斗结算。', - storyText: '正面对手带着同伴压了上来。', - options: [], - }, - patches: [], - snapshot: createRuntimeNpcBattleSnapshot({ - currentEncounter: { - kind: 'npc', - id: 'npc-front', - npcName: '正面对手', - npcDescription: '正面对手', - context: '桥口', - hostile: true, - } as GameState['currentEncounter'], - npcInteractionActive: false, - sceneHostileNpcs: [ - { - id: 'npc-opponent-npc-front', - name: '正面对手', - action: '摆开架势,随时准备出手', - description: '正面对手', - animation: 'idle', - xMeters: 1.4, - yOffset: 0, - facing: 'left', - attackRange: 1.8, - speed: 8, - hp: 88, - maxHp: 88, - renderKind: 'npc', - encounter: { - id: 'npc-front', - kind: 'npc', - npcName: '正面对手', - npcDescription: '正面对手', - npcAvatar: '/npc-front.png', - context: '桥口', - hostile: true, - xMeters: 1.4, - }, - }, - { - id: 'npc-opponent-npc-back-1', - name: '后排甲', - action: '摆开架势,随时准备出手', - description: '后排甲', - animation: 'idle', - xMeters: 2.1, - yOffset: 16, - facing: 'left', - attackRange: 1.8, - speed: 7, - hp: 76, - maxHp: 76, - renderKind: 'npc', - encounter: { - id: 'npc-back-1', - kind: 'npc', - npcName: '后排甲', - npcDescription: '后排甲', - npcAvatar: '/npc-back-1.png', - context: '桥口', - hostile: true, - xMeters: 2.1, - }, - }, - ] as GameState['sceneHostileNpcs'], - inBattle: true, - currentBattleNpcId: 'npc-front', - currentNpcBattleMode: 'fight', - }), - }); - - const result = await resolveServerRuntimeChoice({ - gameState, - currentStory, - option, - }); - - expect( - result.hydratedSnapshot.gameState.sceneHostileNpcs.map((monster) => ({ - encounterId: monster.encounter?.id, - xMeters: monster.xMeters, - yOffset: monster.yOffset, - })), - ).toEqual([ - { - encounterId: 'npc-front', - xMeters: 3.2, - yOffset: 0, - }, - { - encounterId: 'npc-back-1', - xMeters: 4.28, - yOffset: 62, - }, - ]); - }); - - it('bridges idle_travel_next_scene server snapshots into the next scene runtime state', async () => { + it('uses idle_travel_next_scene snapshots as returned by the backend resolver', async () => { const gameState = createTravelGameState(); const currentStory = createStory('桥口这一段已经收束。'); const option = { @@ -1247,13 +835,13 @@ describe('runtimeStoryCoordinator', () => { ); expect( result.hydratedSnapshot.gameState.runtimeStats.scenesTraveled, - ).toBe(1); + ).toBe(0); expect( Boolean( result.hydratedSnapshot.gameState.currentEncounter || result.hydratedSnapshot.gameState.sceneHostileNpcs.length > 0, ), - ).toBe(true); + ).toBe(false); }); it('rehydrates mid-battle snapshots when resuming a saved runtime story', async () => { diff --git a/src/hooks/rpg-runtime-story/sessionActions.test.ts b/src/hooks/rpg-runtime-story/sessionActions.test.ts index 80f2b7c0..ffcc90f6 100644 --- a/src/hooks/rpg-runtime-story/sessionActions.test.ts +++ b/src/hooks/rpg-runtime-story/sessionActions.test.ts @@ -176,7 +176,7 @@ describe('sessionActions', () => { expect(rewardClaim).toHaveProperty('handoff'); }); - it('refreshes chapter state after a chapter quest is turned in', () => { + it('does not rewrite backend-owned chapter state after a chapter quest is turned in', () => { const baseState = { ...createBaseState(), currentScenePreset: { @@ -243,7 +243,7 @@ describe('sessionActions', () => { throw new Error('Expected reward claim result'); } - expect(rewardClaim.nextState.chapterState?.stage).toBe('aftermath'); - expect(rewardClaim.nextState.storyEngineMemory?.currentChapter?.stage).toBe('aftermath'); + expect(rewardClaim.nextState.chapterState?.stage).toBe('climax'); + expect(rewardClaim.nextState.storyEngineMemory?.currentChapter?.stage).toBe('climax'); }); }); diff --git a/src/hooks/rpg-runtime-story/sessionActions.ts b/src/hooks/rpg-runtime-story/sessionActions.ts index 32b438bb..a9907cca 100644 --- a/src/hooks/rpg-runtime-story/sessionActions.ts +++ b/src/hooks/rpg-runtime-story/sessionActions.ts @@ -9,13 +9,7 @@ import { markQuestCompletionNotified, markQuestTurnedIn, } from '../../data/questFlow'; -import { - advanceChapterState, - resolveCurrentChapterState, -} from '../../services/storyEngine/chapterDirector'; -import { appendStoryEngineCarrierMemory } from '../../services/storyEngine/echoMemory'; import { buildGoalHandoffFromState } from '../../services/storyEngine/goalDirector'; -import { createEmptyStoryEngineMemoryState } from '../../services/storyEngine/visibilityEngine'; import type { GameState, StoryMoment, @@ -53,7 +47,7 @@ export function applyQuestRewardClaim( const issuerNpcState = state.npcStates[quest.issuerNpcId]; - const nextState = appendStoryEngineCarrierMemory({ + const nextState: GameState = { ...state, quests: markQuestTurnedIn(state.quests, questId), playerCurrency: state.playerCurrency + quest.reward.currency, @@ -67,30 +61,11 @@ export function applyQuestRewardClaim( }, } : state.npcStates, - }, quest.reward.items); - const chapterState = advanceChapterState({ - previousChapter: - nextState.chapterState - ?? nextState.storyEngineMemory?.currentChapter - ?? null, - nextChapter: resolveCurrentChapterState({ - state: nextState, - }), - }); - const storyEngineMemory = - nextState.storyEngineMemory ?? createEmptyStoryEngineMemoryState(); - const synchronizedNextState: GameState = { - ...nextState, - chapterState, - storyEngineMemory: { - ...storyEngineMemory, - currentChapter: chapterState, - }, }; return { - nextState: synchronizedNextState, - handoff: buildGoalHandoffFromState(synchronizedNextState), + nextState, + handoff: buildGoalHandoffFromState(nextState), }; } diff --git a/src/hooks/rpg-runtime-story/storyChoiceContinuation.ts b/src/hooks/rpg-runtime-story/storyChoiceContinuation.ts index 475b71d4..47488f1c 100644 --- a/src/hooks/rpg-runtime-story/storyChoiceContinuation.ts +++ b/src/hooks/rpg-runtime-story/storyChoiceContinuation.ts @@ -1,11 +1,7 @@ -import { addInventoryItems } from '../../data/npcInteractions'; -import { applyQuestProgressFromHostileNpcDefeat } from '../../data/questFlow'; -import { applyStoryReasoningRecovery } from '../../data/storyRecovery'; +import { applyStoryReasoningRecovery } from '../../data/storyRecovery'; import { generateNextStep } from '../../services/aiService'; import type { StoryGenerationContext } from '../../services/aiTypes'; -import { appendStoryEngineCarrierMemory } from '../../services/storyEngine/echoMemory'; import { createHistoryMoment } from '../../services/storyHistory'; -import { AnimationState } from '../../types'; import type { Character, Encounter, @@ -13,19 +9,7 @@ import type { StoryMoment, StoryOption, } from '../../types'; -import type { EscapePlaybackSync } from '../combat/escapeFlow'; -import type { BattlePlan } from '../combat/battlePlan'; import type { ResolvedChoiceState } from '../combat/resolvedChoice'; -import { - buildDeathStory, - buildPostBattleVictoryState, - buildPostBattleVictoryStory, - buildRevivedFirstSceneState, -} from './postBattleFlow'; -import { - buildCombatResolutionContextText, - buildHostileNpcBattleReward, -} from './storyChoiceRuntime'; import type { BattleRewardSummary } from './uiTypes'; type RuntimeStatsIncrements = Partial< @@ -84,78 +68,10 @@ type IncrementRuntimeStats = ( increments: RuntimeStatsIncrements, ) => GameState; -const PLAYER_REVIVE_DELAY_MS = 3000; - -function sleep(ms: number) { - return new Promise((resolve) => globalThis.setTimeout(resolve, ms)); -} - -function buildLocalCombatResultText(params: { - option: StoryOption; - battlePlan: BattlePlan | null; - afterSequence: GameState; - combatResolutionContextText: string | null; -}) { - if (params.combatResolutionContextText) { - return params.combatResolutionContextText; - } - - const turns = params.battlePlan?.turns ?? []; - const dealtDamage = turns - .filter((turn) => turn.actor === 'player' || turn.actor === 'companion') - .reduce((sum, turn) => sum + turn.damage, 0); - const takenDamage = turns - .filter((turn) => turn.actor === 'monster' && turn.target === 'player') - .reduce((sum, turn) => sum + turn.damage, 0); - - if (params.afterSequence.playerHp <= 0) { - return takenDamage > 0 - ? `你承受了${takenDamage}点伤害,气血归零。` - : '你在战斗中倒下,气血归零。'; - } - - const details = [ - dealtDamage > 0 ? `造成${dealtDamage}点伤害` : null, - takenDamage > 0 ? `承受${takenDamage}点伤害` : null, - ].filter(Boolean); - - return details.length > 0 - ? `${params.option.actionText}完成,${details.join(',')}。` - : `${params.option.actionText}完成,双方仍在对峙。`; -} - -function buildDeterministicStoryForState(params: { - state: GameState; - character: Character; - resultText: string; - availableOptions: StoryOption[] | null; - buildFallbackStoryForState: BuildFallbackStoryForState; -}) { - if (params.availableOptions?.length) { - return { - text: params.resultText, - options: params.availableOptions, - streaming: false, - } satisfies StoryMoment; - } - - const fallbackStory = params.buildFallbackStoryForState( - params.state, - params.character, - params.resultText, - ); - return { - ...fallbackStory, - text: params.resultText, - streaming: false, - } satisfies StoryMoment; -} - -function isLocalNpcBattleVictoryOutcome( - battleOutcome: GameState['currentNpcBattleOutcome'], -) { +function isBackendOwnedCombatChoice(option: StoryOption) { return ( - battleOutcome === 'fight_victory' || battleOutcome === 'spar_complete' + option.functionId.startsWith('battle_') || + option.functionId === 'inventory_use' ); } @@ -179,7 +95,6 @@ export async function runLocalStoryChoiceContinuation(params: { option: StoryOption, character: Character, resolvedChoice: ResolvedChoiceState, - sync?: EscapePlaybackSync, ) => Promise; buildStoryContextFromState: BuildStoryContextFromState; buildStoryFromResponse: BuildStoryFromResponse; @@ -234,53 +149,32 @@ export async function runLocalStoryChoiceContinuation(params: { let fallbackState = baseChoiceState; try { + if (isBackendOwnedCombatChoice(params.option)) { + throw new Error( + `战斗与物品动作必须由后端结算,禁止进入本地 continuation:${params.option.functionId}`, + ); + } + const history = baseChoiceState.storyHistory; const resolvedChoice = params.buildResolvedChoiceState( baseChoiceState, params.option, params.character, ); + if (resolvedChoice.optionKind === 'battle' || resolvedChoice.optionKind === 'escape') { + throw new Error( + `战斗与逃脱动作必须由后端结算,禁止进入本地 continuation:${params.option.functionId}`, + ); + } + const projectedState = resolvedChoice.afterSequence; - const shouldUseDeterministicCombatFlow = - resolvedChoice.optionKind === 'battle' || - resolvedChoice.optionKind === 'escape'; - const shouldUseLocalNpcVictory = Boolean( - baseChoiceState.currentBattleNpcId && - resolvedChoice.optionKind === 'battle' && - isLocalNpcBattleVictoryOutcome(projectedState.currentNpcBattleOutcome), - ); - const projectedBattleReward = shouldUseLocalNpcVictory - ? null - : await buildHostileNpcBattleReward( - baseChoiceState, - projectedState, - resolvedChoice.optionKind, - params.getResolvedSceneHostileNpcs, - ); - const projectedStateWithBattleReward = projectedBattleReward - ? appendStoryEngineCarrierMemory( - { - ...projectedState, - playerInventory: addInventoryItems( - projectedState.playerInventory, - projectedBattleReward.items, - ), - } as GameState, - projectedBattleReward.items, - ) - : projectedState; + const projectedStateWithBattleReward = projectedState; fallbackState = projectedStateWithBattleReward; const projectedAvailableOptions = params.getAvailableOptionsForState( projectedStateWithBattleReward, params.character, ); - const combatResolutionContextText = buildCombatResolutionContextText({ - baseState: baseChoiceState, - afterSequence: projectedStateWithBattleReward, - optionKind: resolvedChoice.optionKind, - projectedBattleReward, - getResolvedSceneHostileNpcs: params.getResolvedSceneHostileNpcs, - }); + const combatResolutionContextText = null; const historyForStoryGeneration = combatResolutionContextText ? [ ...history, @@ -289,38 +183,27 @@ export async function runLocalStoryChoiceContinuation(params: { ] : history; - const responsePromise = shouldUseLocalNpcVictory || shouldUseDeterministicCombatFlow - ? Promise.resolve(null) - : generateNextStep( - params.gameState.worldType!, - params.character, - params.getStoryGenerationHostileNpcs(projectedStateWithBattleReward), - historyForStoryGeneration, - params.option.actionText, - params.buildStoryContextFromState(projectedStateWithBattleReward, { - lastFunctionId: params.option.functionId, - observeSignsRequested: - params.option.functionId === 'idle_observe_signs', - recentActionResult: combatResolutionContextText, - }), - projectedAvailableOptions - ? { availableOptions: projectedAvailableOptions } - : undefined, - ); - const responseSettledPromise = responsePromise.then( - () => undefined, - () => undefined, + const responsePromise = generateNextStep( + params.gameState.worldType!, + params.character, + params.getStoryGenerationHostileNpcs(projectedStateWithBattleReward), + historyForStoryGeneration, + params.option.actionText, + params.buildStoryContextFromState(projectedStateWithBattleReward, { + lastFunctionId: params.option.functionId, + observeSignsRequested: + params.option.functionId === 'idle_observe_signs', + recentActionResult: combatResolutionContextText, + }), + projectedAvailableOptions + ? { availableOptions: projectedAvailableOptions } + : undefined, ); - const playbackSync: EscapePlaybackSync | undefined = - resolvedChoice.optionKind === 'escape' && !shouldUseDeterministicCombatFlow - ? { waitForStoryResponse: responseSettledPromise } - : undefined; const actionPromise = params.playResolvedChoice( baseChoiceState, params.option, params.character, resolvedChoice, - playbackSync, ); const [actionResult, responseResult] = await Promise.allSettled([ actionPromise, @@ -331,186 +214,14 @@ export async function runLocalStoryChoiceContinuation(params: { throw actionResult.reason; } - let afterSequence = shouldUseLocalNpcVictory - ? resolvedChoice.afterSequence - : actionResult.value; - if (projectedBattleReward) { - afterSequence = appendStoryEngineCarrierMemory( - { - ...afterSequence, - playerInventory: addInventoryItems( - afterSequence.playerInventory, - projectedBattleReward.items, - ), - } as GameState, - projectedBattleReward.items, - ); - } + const afterSequence = actionResult.value; fallbackState = afterSequence; - if (shouldUseLocalNpcVictory) { - const victory = params.finalizeNpcBattleResult( - afterSequence, - params.character, - baseChoiceState.currentNpcBattleMode!, - afterSequence.currentNpcBattleOutcome, - ); - if (victory) { - const historyBase = - baseChoiceState.currentNpcBattleMode === 'spar' - ? (afterSequence.sparStoryHistoryBefore ?? []) - : baseChoiceState.storyHistory; - const nextHistory = [ - ...historyBase, - createHistoryMoment(params.option.actionText, 'action'), - createHistoryMoment(victory.resultText, 'result'), - ]; - const nextState = { - ...victory.nextState, - storyHistory: nextHistory, - }; - const postBattleState = buildPostBattleVictoryState(nextState); - const postBattle = buildPostBattleVictoryStory( - postBattleState, - victory.resultText, - params.getAvailableOptionsForState(postBattleState, params.character) ?? [], - ); - fallbackState = postBattle.state; - params.setGameState(postBattle.state); - params.setCurrentStory(postBattle.story); - return; - } - } - - if (shouldUseDeterministicCombatFlow) { - const defeatedHostileNpcIds = - resolvedChoice.optionKind === 'escape' || baseChoiceState.currentBattleNpcId - ? [] - : params - .getResolvedSceneHostileNpcs(baseChoiceState) - .map((hostileNpc) => hostileNpc.id) - .filter( - (hostileNpcId) => - !params - .getResolvedSceneHostileNpcs(afterSequence) - .some((hostileNpc) => hostileNpc.id === hostileNpcId), - ); - const resultText = buildLocalCombatResultText({ - option: params.option, - battlePlan: resolvedChoice.battlePlan, - afterSequence, - combatResolutionContextText, - }); - const nextHistory = [ - ...baseChoiceState.storyHistory, - createHistoryMoment(params.option.actionText, 'action'), - createHistoryMoment(resultText, 'result'), - ]; - const nextState = params.incrementRuntimeStats( - { - ...params.updateQuestLog(afterSequence, (quests) => - applyQuestProgressFromHostileNpcDefeat( - quests, - baseChoiceState.currentScenePreset?.id ?? null, - defeatedHostileNpcIds, - ), - ), - storyHistory: nextHistory, - }, - { - hostileNpcsDefeated: defeatedHostileNpcIds.length, - }, - ); - - if (projectedBattleReward) { - params.setBattleReward(projectedBattleReward); - } - - if (nextState.playerHp <= 0) { - const deathState = { - ...nextState, - animationState: AnimationState.DIE, - playerActionMode: 'idle' as const, - inBattle: false, - activeCombatEffects: [], - scrollWorld: false, - }; - fallbackState = deathState; - params.setGameState(deathState); - await sleep(PLAYER_REVIVE_DELAY_MS); - const revivedState = { - ...buildRevivedFirstSceneState(deathState), - storyHistory: [ - ...nextHistory, - createHistoryMoment('你在第一个场景第一幕重新醒来。', 'result'), - ], - }; - fallbackState = revivedState; - const revivedDeferredOptions = - params.buildFallbackStoryForState(revivedState, params.character).options; - params.setGameState(revivedState); - params.setCurrentStory( - buildDeathStory(revivedState, revivedDeferredOptions), - ); - return; - } - - if ( - resolvedChoice.optionKind === 'battle' && - ( - nextState.currentNpcBattleOutcome === 'fight_victory' || - nextState.currentNpcBattleOutcome === 'spar_complete' || - (!baseChoiceState.currentBattleNpcId && !nextState.inBattle) - ) - ) { - const postBattleState = buildPostBattleVictoryState(nextState); - const postBattle = buildPostBattleVictoryStory( - postBattleState, - resultText, - params.getAvailableOptionsForState(postBattleState, params.character) ?? [], - ); - fallbackState = postBattle.state; - params.setGameState(postBattle.state); - params.setCurrentStory(postBattle.story); - return; - } - - const availableOptions = params.getAvailableOptionsForState( - nextState, - params.character, - ); - fallbackState = nextState; - params.setGameState(nextState); - params.setCurrentStory( - buildDeterministicStoryForState({ - state: nextState, - character: params.character, - resultText, - availableOptions, - buildFallbackStoryForState: params.buildFallbackStoryForState, - }), - ); - return; - } - if (responseResult.status === 'rejected') { throw responseResult.reason; } const response = responseResult.value!; - const defeatedHostileNpcIds = - baseChoiceState.currentBattleNpcId || - resolvedChoice.optionKind === 'escape' - ? [] - : params - .getResolvedSceneHostileNpcs(baseChoiceState) - .map((hostileNpc) => hostileNpc.id) - .filter( - (hostileNpcId) => - !params - .getResolvedSceneHostileNpcs(afterSequence) - .some((hostileNpc) => hostileNpc.id === hostileNpcId), - ); const nextHistory = combatResolutionContextText ? [ ...historyForStoryGeneration, @@ -524,13 +235,7 @@ export async function runLocalStoryChoiceContinuation(params: { const nextState = params.incrementRuntimeStats( { - ...params.updateQuestLog(afterSequence, (quests) => - applyQuestProgressFromHostileNpcDefeat( - quests, - baseChoiceState.currentScenePreset?.id ?? null, - defeatedHostileNpcIds, - ), - ), + ...afterSequence, lastObserveSignsSceneId: params.option.functionId === 'idle_observe_signs' ? (afterSequence.currentScenePreset?.id ?? null) @@ -541,16 +246,11 @@ export async function runLocalStoryChoiceContinuation(params: { : afterSequence.lastObserveSignsReport ?? null, storyHistory: nextHistory, }, - { - hostileNpcsDefeated: defeatedHostileNpcIds.length, - }, + {}, ); const recoveredState = applyStoryReasoningRecovery(nextState); params.setGameState(recoveredState); - if (projectedBattleReward) { - params.setBattleReward(projectedBattleReward); - } params.setCurrentStory( params.buildStoryFromResponse( diff --git a/src/hooks/rpg-runtime-story/storyChoiceRuntime.test.ts b/src/hooks/rpg-runtime-story/storyChoiceRuntime.test.ts index 4dc4fb2a..a007cd1d 100644 --- a/src/hooks/rpg-runtime-story/storyChoiceRuntime.test.ts +++ b/src/hooks/rpg-runtime-story/storyChoiceRuntime.test.ts @@ -1,34 +1,16 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; -const { - rollHostileNpcLootMock, - resolveServerRuntimeChoiceMock, -} = vi.hoisted(() => ({ - rollHostileNpcLootMock: vi.fn(), +const { resolveServerRuntimeChoiceMock } = vi.hoisted(() => ({ resolveServerRuntimeChoiceMock: vi.fn(), })); -vi.mock('../../data/hostileNpcPresets', async () => { - const actual = - await vi.importActual( - '../../data/hostileNpcPresets', - ); - - return { - ...actual, - rollHostileNpcLoot: rollHostileNpcLootMock, - }; -}); - vi.mock('.', () => ({ resolveRpgRuntimeChoice: resolveServerRuntimeChoiceMock, })); import type { Character, GameState, StoryMoment, StoryOption } from '../../types'; +import { WorldType } from '../../types/core'; import { - buildCombatResolutionContextText, - buildHostileNpcBattleReward, - buildReasonedOptionCatalog, runServerRuntimeChoiceAction, shouldOpenLocalRuntimeNpcModal, } from './storyChoiceRuntime'; @@ -56,10 +38,10 @@ function createCharacter(): Character { } as unknown as Character; } -function createStory(text: string): StoryMoment { +function createStory(text: string, options: StoryOption[] = []): StoryMoment { return { text, - options: [], + options, }; } @@ -140,23 +122,9 @@ function createState(overrides: Partial = {}): GameState { describe('storyChoiceRuntime', () => { beforeEach(() => { - rollHostileNpcLootMock.mockReset(); resolveServerRuntimeChoiceMock.mockReset(); }); - it('deduplicates option catalogs by function id for post-battle recovery', () => { - const options = buildReasonedOptionCatalog([ - createOption('npc_chat'), - createOption('npc_chat'), - createOption('npc_help'), - ]); - - expect(options.map((option) => option.functionId)).toEqual([ - 'npc_chat', - 'npc_help', - ]); - }); - it('keeps npc chat, trade and gift on the local runtime npc interaction path', () => { expect( shouldOpenLocalRuntimeNpcModal( @@ -190,117 +158,6 @@ describe('storyChoiceRuntime', () => { ).toBe(false); }); - it('builds escape and victory context text for local battle resolution', () => { - const baseState = createState({ - inBattle: true, - sceneHostileNpcs: [ - { id: 'wolf', name: '山狼' }, - ] as GameState['sceneHostileNpcs'], - }); - - expect( - buildCombatResolutionContextText({ - baseState, - afterSequence: { - ...baseState, - inBattle: false, - sceneHostileNpcs: [], - }, - optionKind: 'escape', - projectedBattleReward: null, - getResolvedSceneHostileNpcs: (state) => state.sceneHostileNpcs, - }), - ).toContain('你已成功逃脱'); - - expect( - buildCombatResolutionContextText({ - baseState: { - ...baseState, - currentBattleNpcId: null, - }, - afterSequence: { - ...baseState, - inBattle: false, - sceneHostileNpcs: [], - }, - optionKind: 'battle', - projectedBattleReward: { - id: 'reward-1', - defeatedHostileNpcs: [{ id: 'wolf', name: '山狼' }], - items: [ - { id: 'loot-1', category: '材料', name: '狼牙', quantity: 1, rarity: 'common', tags: [] }, - ], - }, - getResolvedSceneHostileNpcs: (state) => state.sceneHostileNpcs, - }), - ).toContain('战利品:狼牙。'); - }); - - it('builds defeated hostile rewards from locally resolved battle states', async () => { - rollHostileNpcLootMock.mockResolvedValue([ - { - id: 'loot-1', - category: '材料', - name: '狼牙', - quantity: 1, - rarity: 'common', - tags: [], - }, - ]); - - const reward = await buildHostileNpcBattleReward( - createState({ - inBattle: true, - sceneHostileNpcs: [ - { id: 'wolf', name: '山狼' }, - ] as GameState['sceneHostileNpcs'], - currentBattleNpcId: null, - }), - createState({ - inBattle: false, - sceneHostileNpcs: [], - }), - 'battle', - (state) => state.sceneHostileNpcs, - ); - - expect(rollHostileNpcLootMock).toHaveBeenCalledTimes(1); - expect(reward?.items[0]).toEqual( - expect.objectContaining({ - name: '狼牙', - }), - ); - }); - - it('keeps defeated hostile reward render keys unique for duplicate monster ids', async () => { - rollHostileNpcLootMock.mockResolvedValue([]); - - const reward = await buildHostileNpcBattleReward( - createState({ - inBattle: true, - sceneHostileNpcs: [ - { id: 'monster-16', name: '雷翼甲' }, - { id: 'monster-16', name: '雷翼乙', xMeters: 4.1, yOffset: 32 }, - ] as GameState['sceneHostileNpcs'], - currentBattleNpcId: null, - }), - createState({ - inBattle: false, - sceneHostileNpcs: [], - }), - 'battle', - (state) => state.sceneHostileNpcs, - ); - - expect(reward?.defeatedHostileNpcs).toHaveLength(2); - expect(reward?.defeatedHostileNpcs.map((npc) => npc.id)).toEqual([ - 'monster-16', - 'monster-16', - ]); - expect(new Set(reward?.defeatedHostileNpcs.map((npc) => npc.renderKey)).size) - .toBe(2); - }); - it('applies server runtime responses and falls back locally when the request fails', async () => { const gameState = createState(); const currentStory = createStory('当前故事'); @@ -452,9 +309,9 @@ describe('storyChoiceRuntime', () => { expect(setGameState).toHaveBeenLastCalledWith(finalState); }); - it('routes server defeat outcomes into death and revive flow instead of victory settlement', async () => { + it('uses the server-returned defeat revive snapshot without local death reconstruction', async () => { const gameState = createState({ - worldType: 'WUXIA', + worldType: WorldType.WUXIA, inBattle: true, playerHp: 6, playerMaxHp: 30, @@ -467,7 +324,7 @@ describe('storyChoiceRuntime', () => { imageSrc: '/scene-a.png', connectedSceneIds: [], connections: [], - forwardSceneId: null, + forwardSceneId: undefined, treasureHints: [], npcs: [], }, @@ -488,16 +345,45 @@ describe('storyChoiceRuntime', () => { }, ], }); - const finalState = createState({ + const serverRevivedState = createState({ ...gameState, inBattle: false, - playerHp: 0, - currentEncounter: null, + playerHp: 30, + playerMana: 10, + currentEncounter: { + kind: 'npc', + id: 'wolf', + npcName: '山狼', + npcDescription: '林间伏击的野兽', + npcAvatar: '狼', + context: '复活后的首场景威胁', + hostile: true, + }, sceneHostileNpcs: [], - currentNpcBattleOutcome: 'fight_defeat', + currentNpcBattleOutcome: null, + currentScenePreset: { + id: 'wuxia-bamboo-road', + name: '竹林古道', + description: '风穿竹影,路面狭长。', + imageSrc: '/scene-a.png', + connectedSceneIds: ['wuxia-mountain-gate'], + connections: [ + { + sceneId: 'wuxia-mountain-gate', + relativePosition: 'forward', + summary: '沿主路继续深入前方区域', + }, + ], + forwardSceneId: 'wuxia-mountain-gate', + treasureHints: [], + npcs: [], + }, }); const setGameState = vi.fn(); const setCurrentStory = vi.fn(); + const serverDeathStory = createStory('你在战斗中倒下,随后在竹林古道重新醒来。', [ + createOption('story_continue_adventure'), + ]); resolveServerRuntimeChoiceMock.mockResolvedValueOnce({ response: { @@ -512,9 +398,9 @@ describe('storyChoiceRuntime', () => { }, }, hydratedSnapshot: { - gameState: finalState, + gameState: serverRevivedState, }, - nextStory: createStory('不会进入胜利文本'), + nextStory: serverDeathStory, }); await runServerRuntimeChoiceAction({ @@ -527,10 +413,7 @@ describe('storyChoiceRuntime', () => { setIsLoading: vi.fn(), setGameState, setCurrentStory: setCurrentStory as (story: StoryMoment) => void, - buildFallbackStoryForState: () => - createStory('fallback', [ - createOption('idle_explore_forward'), - ]), + buildFallbackStoryForState: () => createStory('fallback'), turnVisualMs: 1, }); @@ -541,21 +424,8 @@ describe('storyChoiceRuntime', () => { inBattle: false, }), ); - expect(setCurrentStory).toHaveBeenCalledWith( - expect.objectContaining({ - text: expect.stringContaining('重新醒来'), - options: [ - expect.objectContaining({ - functionId: 'story_continue_adventure', - }), - ], - }), - ); - expect(setCurrentStory).not.toHaveBeenCalledWith( - expect.objectContaining({ - text: '不会进入胜利文本', - }), - ); + expect(setGameState).toHaveBeenLastCalledWith(serverRevivedState); + expect(setCurrentStory).toHaveBeenCalledWith(serverDeathStory); }); it('commits server-returned next-scene state after idle_travel_next_scene resolution', async () => { diff --git a/src/hooks/rpg-runtime-story/storyChoiceRuntime.ts b/src/hooks/rpg-runtime-story/storyChoiceRuntime.ts index 43a12475..5d2b633a 100644 --- a/src/hooks/rpg-runtime-story/storyChoiceRuntime.ts +++ b/src/hooks/rpg-runtime-story/storyChoiceRuntime.ts @@ -2,8 +2,6 @@ buildEncounterEntryState, hasEncounterEntity, } from '../../data/encounterTransition'; -import { rollHostileNpcLoot } from '../../data/hostileNpcPresets'; -import { addInventoryItems } from '../../data/npcInteractions'; import { CALL_OUT_ENTRY_X_METERS, createSceneEncounterPreview, @@ -18,12 +16,6 @@ import { StoryOption, } from '../../types'; import { resolveRpgRuntimeChoice } from '.'; -import { - buildDeathStory, - buildPostBattleVictoryState, - buildPostBattleVictoryStory, - buildRevivedFirstSceneState, -} from './postBattleFlow'; import type { BattleRewardSummary } from './uiTypes'; type RuntimeStatsIncrements = Partial< @@ -48,68 +40,6 @@ function sleep(ms: number) { return new Promise((resolve) => globalThis.setTimeout(resolve, ms)); } -const PLAYER_REVIVE_DELAY_MS = 3000; - -export function buildReasonedOptionCatalog(options: StoryOption[]) { - const seenFunctionIds = new Set(); - - return options.filter((option) => { - if (seenFunctionIds.has(option.functionId)) { - return false; - } - - seenFunctionIds.add(option.functionId); - return true; - }); -} - -export function buildCombatResolutionContextText(params: { - baseState: GameState; - afterSequence: GameState; - optionKind: 'battle' | 'escape' | 'idle'; - projectedBattleReward: BattleRewardSummary | null; - getResolvedSceneHostileNpcs: (state: GameState) => GameState['sceneHostileNpcs']; -}) { - const { - baseState, - afterSequence, - optionKind, - projectedBattleReward, - getResolvedSceneHostileNpcs, - } = params; - - if (optionKind === 'escape') { - const hostileNames = getResolvedSceneHostileNpcs(baseState) - .map((hostileNpc) => hostileNpc.name) - .join('、'); - return hostileNames - ? `你已成功逃脱,与${hostileNames}的交战已经被甩开,对方暂时落在身后,当前不再处于战斗状态。` - : '你已成功逃脱刚才的交战,当前不再处于战斗状态。'; - } - - if ( - !baseState.inBattle || - afterSequence.inBattle || - Boolean(baseState.currentBattleNpcId) - ) { - return null; - } - - const hostileNames = getResolvedSceneHostileNpcs(baseState) - .map((hostileNpc) => hostileNpc.name) - .join('、'); - const lootText = - projectedBattleReward?.items.length - ? `战利品:${projectedBattleReward.items - .map((item) => item.name) - .join('、')}。` - : ''; - - return hostileNames - ? `你已经击败${hostileNames},眼前这一轮交战已经结束。${lootText}` - : `眼前这一轮交战已经结束,当前不再处于战斗状态。${lootText}`; -} - export function shouldOpenLocalRuntimeNpcModal(option: StoryOption) { return ( ( @@ -124,63 +54,6 @@ export function shouldOpenLocalRuntimeNpcModal(option: StoryOption) { ); } -export async function buildHostileNpcBattleReward( - state: GameState, - afterSequence: GameState, - optionKind: 'battle' | 'escape' | 'idle', - getResolvedSceneHostileNpcs: (state: GameState) => GameState['sceneHostileNpcs'], -): Promise { - if ( - optionKind === 'escape' || - !state.worldType || - state.currentBattleNpcId || - !state.inBattle || - afterSequence.inBattle - ) { - return null; - } - - const activeHostileNpcs = getResolvedSceneHostileNpcs(state); - const nextHostileNpcs = getResolvedSceneHostileNpcs(afterSequence); - const defeatedHostileNpcs = activeHostileNpcs.filter( - (hostileNpc) => - !nextHostileNpcs.some( - (nextHostileNpc) => nextHostileNpc.id === hostileNpc.id, - ), - ); - - if (defeatedHostileNpcs.length === 0) { - return null; - } - - const rolledItems = await rollHostileNpcLoot( - state, - defeatedHostileNpcs.map((hostileNpc) => ({ - id: hostileNpc.id, - name: hostileNpc.name, - })), - ); - - return { - id: `battle-reward-${Date.now()}-${Math.random() - .toString(36) - .slice(2, 8)}`, - defeatedHostileNpcs: defeatedHostileNpcs.map((hostileNpc, index) => ({ - id: hostileNpc.id, - name: hostileNpc.name, - // 中文注释:同一场战斗可能击败多个同 preset 怪物,奖励弹层 key 不能只用怪物 id。 - renderKey: [ - hostileNpc.id, - hostileNpc.name, - hostileNpc.xMeters, - hostileNpc.yOffset ?? 0, - index, - ].join(':'), - })), - items: addInventoryItems([], rolledItems), - }; -} - export async function runCampTravelHomeChoice(params: { gameState: GameState; option: StoryOption; @@ -337,47 +210,6 @@ export async function runServerRuntimeChoiceAction(params: { }); } - const battle = response?.presentation.battle; - if (battle && hydratedSnapshot.gameState.playerHp <= 0) { - const deathState = { - ...hydratedSnapshot.gameState, - animationState: AnimationState.DIE, - playerActionMode: 'idle' as const, - inBattle: false, - activeCombatEffects: [], - scrollWorld: false, - }; - params.setGameState(deathState); - await sleep(PLAYER_REVIVE_DELAY_MS); - const revivedState = buildRevivedFirstSceneState(deathState); - const revivedDeferredOptions = - params.buildFallbackStoryForState(revivedState, params.character).options; - params.setGameState(revivedState); - params.setCurrentStory( - buildDeathStory(revivedState, revivedDeferredOptions), - ); - return; - } - - if ( - battle?.outcome === 'victory' || - battle?.outcome === 'spar_complete' - ) { - const resultText = - response?.presentation.resultText || nextStory.text || params.option.actionText; - const postBattleState = buildPostBattleVictoryState( - hydratedSnapshot.gameState, - ); - const postBattle = buildPostBattleVictoryStory( - postBattleState, - resultText, - nextStory.options, - ); - params.setGameState(postBattle.state); - params.setCurrentStory(postBattle.story); - return; - } - params.setGameState(hydratedSnapshot.gameState); params.setCurrentStory(nextStory); } catch (error) { @@ -459,14 +291,15 @@ async function playServerBattlePresentation(params: { const finalTarget = params.finalState.sceneHostileNpcs.find( (hostileNpc) => hostileNpc.id === targetId, ); + const playerDefeated = battle.outcome === 'defeat'; const targetDefeated = battle.outcome === 'victory' || battle.outcome === 'spar_complete' || (battle.outcome !== 'defeat' && !finalTarget && (battle.damageDealt ?? 0) > 0); params.setGameState({ ...actingState, - playerHp: params.finalState.playerHp, - playerMana: params.finalState.playerMana, + playerHp: playerDefeated ? 0 : params.finalState.playerHp, + playerMana: playerDefeated ? params.baseState.playerMana : params.finalState.playerMana, playerSkillCooldowns: params.finalState.playerSkillCooldowns, activeBuildBuffs: params.finalState.activeBuildBuffs, sceneHostileNpcs: actingState.sceneHostileNpcs.map((hostileNpc) => { @@ -483,9 +316,12 @@ async function playServerBattlePresentation(params: { }); await sleep(Math.max(180, Math.round(params.turnVisualMs * 0.45))); - if (params.finalState.playerHp <= 0) { + if (playerDefeated || params.finalState.playerHp <= 0) { + // 中文注释:这里只是 presentation 的临时倒地视觉, + // 正式复活位置、血蓝和故事仍以随后提交的服务端 snapshot 为准。 params.setGameState({ - ...params.finalState, + ...actingState, + playerHp: 0, animationState: AnimationState.DIE, playerActionMode: 'idle', inBattle: false, diff --git a/src/hooks/rpg-runtime-story/storyContextBuilder.ts b/src/hooks/rpg-runtime-story/storyContextBuilder.ts index 433e27c4..bd858aea 100644 --- a/src/hooks/rpg-runtime-story/storyContextBuilder.ts +++ b/src/hooks/rpg-runtime-story/storyContextBuilder.ts @@ -1,60 +1,5 @@ -import { getCharacterById } from '../../data/characterPresets'; -import { - NPC_CHAT_FUNCTION, - STORY_OPENING_CAMP_DIALOGUE_FUNCTION, -} from '../../data/functionCatalog'; -import { - buildInitialNpcState, - describeNpcAffinityInWords, - getNpcConversationDirective, - isNpcFirstMeaningfulContact, -} from '../../data/npcInteractions'; -import { buildSceneEntityCatalogText } from '../../data/scenePresets'; -import { hasMixedNarrativeLanguage } from '../../services/narrativeLanguage'; import type { StoryGenerationContext } from '../../services/aiTypes'; -import { - buildFallbackActorNarrativeProfile, - normalizeActorNarrativeProfile, -} from '../../services/storyEngine/actorNarrativeProfile'; -import { applyAdaptiveTuningToPromptContext } from '../../services/storyEngine/adaptiveNarrativeTuner'; -import { compileCampaignFromWorldProfile } from '../../services/storyEngine/campaignPackCompiler'; -import { - buildCampEvent, - evaluateCampEventOpportunity, -} from '../../services/storyEngine/campEventDirector'; -import { - advanceChapterState, - resolveCurrentChapterState, -} from '../../services/storyEngine/chapterDirector'; -import { - advanceCompanionArc, - buildCompanionArcStates, -} from '../../services/storyEngine/companionArcDirector'; -import { buildGoalStackState } from '../../services/storyEngine/goalDirector'; -import { resolveCurrentJourneyBeat } from '../../services/storyEngine/journeyBeatPlanner'; -import { buildVisibilitySliceFromFacts } from '../../services/storyEngine/knowledgeContract'; -import { buildKnowledgeGraph } from '../../services/storyEngine/knowledgeGraph'; -import { buildRecentCarrierEchoes } from '../../services/storyEngine/narrativeCarrierCatalog'; -import { buildChapterRecap } from '../../services/storyEngine/recapDigest'; -import { resolveScenarioPack } from '../../services/storyEngine/scenarioPackRegistry'; -import { buildSceneNarrativeDirective } from '../../services/storyEngine/sceneNarrativeDirector'; -import { - buildSetpieceDirective, - evaluateSetpieceOpportunity, -} from '../../services/storyEngine/setpieceDirector'; -import { buildChronicleSummary } from '../../services/storyEngine/storyChronicle'; -import { buildThemePackFromWorldProfile } from '../../services/storyEngine/themePack'; -import { - buildEncounterVisibilitySlice, - createEmptyStoryEngineMemoryState, -} from '../../services/storyEngine/visibilityEngine'; -import { buildFallbackWorldStoryGraph } from '../../services/storyEngine/worldStoryGraph'; import type { GameState } from '../../types'; -import { getCharacterChatRecord } from './characterChat'; -import { getNpcEncounterKey } from './storyGenerationState'; - -const OPENING_CAMP_DIALOGUE_FUNCTION_ID = - STORY_OPENING_CAMP_DIALOGUE_FUNCTION.id; export type StoryContextBuilderExtras = { pendingSceneEncounter?: boolean; @@ -66,560 +11,35 @@ export type StoryContextBuilderExtras = { encounterNpcStateOverride?: GameState['npcStates'][string] | null; }; -function buildPartyRelationshipNotes(state: GameState) { - const lines: string[] = []; - const seenCharacterIds = new Set(); - - const appendNote = (characterId: string, roleLabel: string) => { - if (seenCharacterIds.has(characterId)) return; - const character = getCharacterById(characterId); - const summary = getCharacterChatRecord(state, characterId).summary.trim(); - if (hasMixedNarrativeLanguage(summary)) return; - if (!character || !summary) return; - - seenCharacterIds.add(characterId); - lines.push( - `- ${character.name} (${character.title} / ${roleLabel}): ${summary}`, - ); - }; - - state.companions.forEach((companion) => - appendNote(companion.characterId, '当前同行'), - ); - state.roster.forEach((companion) => - appendNote(companion.characterId, '营地待命'), - ); - - return lines.length > 0 ? lines.join('\n') : null; -} - -function describeScenePressureLevel( - pressureLevel: 'low' | 'medium' | 'high' | 'extreme' | null | undefined, -) { - switch (pressureLevel) { - case 'low': - return '低'; - case 'medium': - return '中'; - case 'high': - return '高'; - case 'extreme': - return '极高'; - default: - return null; - } -} - -function buildRecentConversationEventText(state: GameState) { - const recentText = state.storyHistory - .slice(-6) - .map((item) => item.text) - .join('\n'); - if ( - /击败|怪物|战斗|切磋|交手|脱身/u.test(recentText) - ) { - return '你们刚经历过一场交锋或切磋,空气里的紧张感还没有完全散去。'; - } - if (/携手|相助|帮你|并肩/u.test(recentText)) { - return '你们刚并肩配合过一次,彼此之间的距离感稍微淡了一些。'; - } - return null; -} - -function inferConversationSituation( - state: GameState, - extras: Pick< - StoryContextBuilderExtras, - 'lastFunctionId' | 'openingCampDialogue' - >, -) { - if (state.inBattle) return 'shared_danger_coordination' as const; - if (extras.lastFunctionId === OPENING_CAMP_DIALOGUE_FUNCTION_ID) - return 'camp_first_contact' as const; - if ( - state.currentEncounter?.specialBehavior === 'camp_companion' && - extras.openingCampDialogue?.trim() - ) { - return 'camp_followup' as const; - } - const recentText = state.storyHistory - .slice(-6) - .map((item) => item.text) - .join('\n'); - if ( - /击败|怪物|战斗|切磋|交手|脱身/u.test(recentText) - ) { - return 'post_battle_breath' as const; - } - if (extras.lastFunctionId === NPC_CHAT_FUNCTION.id) - return 'private_followup' as const; - return 'first_contact_cautious' as const; -} - -function inferConversationPressure( - state: GameState, - situation: ReturnType, -) { - const hpRatio = state.playerHp / Math.max(state.playerMaxHp, 1); - if (state.inBattle || hpRatio < 0.35) return 'high' as const; - if ( - situation === 'post_battle_breath' || - situation === 'shared_danger_coordination' - ) - return 'medium' as const; - if (situation === 'camp_first_contact' || situation === 'camp_followup') - return 'low' as const; - return 'medium' as const; -} - -function describeConversationSituation( - situation: ReturnType, -) { - switch (situation) { - case 'camp_first_contact': - return '这是营地里第一次真正静下来对话的时刻,语气要保持谨慎、观察和轻微试探。'; - case 'camp_followup': - return '营地里的第一轮试探已经发生过了,这一轮应当顺着刚才的话头稍微往深处接。'; - case 'post_battle_breath': - return '一场交锋刚结束,眼前危险稍缓,但双方都还带着余悸和紧绷。'; - case 'shared_danger_coordination': - return '危险还没过去,对话应当短、准、直接,优先服务眼前判断。'; - case 'private_followup': - return '这已经不是严格意义上的初见,更适合作为刚才未说完那句话的延续。'; - default: - return '双方才刚真正对上话,此刻仍在判断彼此能信到什么程度。'; - } -} - -function describeConversationTalkPriority( - situation: ReturnType, -) { - switch (situation) { - case 'camp_first_contact': - return '优先写眼前印象、彼此态度和营地气氛,不要一上来就把动机讲透。'; - case 'camp_followup': - return '先接住上一轮还没说透的话头,再决定要不要继续往下追问。'; - case 'post_battle_breath': - return '先谈刚刚那次交锋以及彼此的判断,再视情况往更深处推进。'; - case 'shared_danger_coordination': - return '先说最有用的判断、危险和下一步,不要扩成大段背景说明。'; - case 'private_followup': - return '承接当前话头和关系变化,不要把对话又写回刚见面时的节奏。'; - default: - return '先试探态度和现场判断,不要急着把来意和秘密一次摊开。'; - } -} - -function resolveEncounterNarrativeProfile(state: GameState) { - const encounter = state.currentEncounter; - if (!encounter || encounter.kind !== 'npc') { - return null; - } - if (encounter.narrativeProfile) { - return encounter.narrativeProfile; - } - if (!state.customWorldProfile) { - return null; - } - - const role = - state.customWorldProfile.storyNpcs.find((npc) => - npc.id === encounter.id || npc.name === encounter.npcName, - ) - ?? state.customWorldProfile.playableNpcs.find((npc) => - npc.id === encounter.id || npc.name === encounter.npcName, - ); - if (!role) { - return null; - } - - const themePack = - state.customWorldProfile.themePack - ?? buildThemePackFromWorldProfile(state.customWorldProfile); - const storyGraph = - state.customWorldProfile.storyGraph - ?? buildFallbackWorldStoryGraph(state.customWorldProfile, themePack); - - return normalizeActorNarrativeProfile( - role.narrativeProfile, - buildFallbackActorNarrativeProfile(role, storyGraph, themePack), - ); -} - -function resolveActiveThreadIds( - state: GameState, - encounterNarrativeProfile: ReturnType, -) { - if (state.storyEngineMemory?.activeThreadIds?.length) { - return state.storyEngineMemory.activeThreadIds.slice(0, 4); - } - if (encounterNarrativeProfile?.relatedThreadIds.length) { - return encounterNarrativeProfile.relatedThreadIds.slice(0, 4); - } - if (!state.customWorldProfile) { - return []; - } - - const themePack = - state.customWorldProfile.themePack - ?? buildThemePackFromWorldProfile(state.customWorldProfile); - const storyGraph = - state.customWorldProfile.storyGraph - ?? buildFallbackWorldStoryGraph(state.customWorldProfile, themePack); - - return storyGraph.visibleThreads.slice(0, 3).map((thread) => thread.id); -} - +/** + * 运行时 story prompt context 的正式投影已经迁到 server-rs。 + * 前端只保留 session 与少量请求元信息,方便旧调用面继续复用同一个函数签名。 + */ export function buildStoryContextFromState( state: GameState, extras: StoryContextBuilderExtras = {}, ): StoryGenerationContext { - const conversationSituation = inferConversationSituation(state, extras); - const conversationPressure = inferConversationPressure( - state, - conversationSituation, - ); - const recentSharedEvent = buildRecentConversationEventText(state); - const encounterNpcState = - state.currentEncounter?.kind === 'npc' - ? (() => { - const encounter = state.currentEncounter; - return extras.encounterNpcStateOverride - ?? state.npcStates[getNpcEncounterKey(encounter)] - ?? buildInitialNpcState(encounter, state.worldType, state); - })() - : null; - const encounterDirective = - state.currentEncounter?.kind === 'npc' - ? (() => { - const encounter = state.currentEncounter; - return encounterNpcState - ? getNpcConversationDirective(encounter, encounterNpcState) - : null; - })() - : null; - const isFirstMeaningfulContact = - state.currentEncounter?.kind === 'npc' - ? (() => { - const encounter = state.currentEncounter; - return encounterNpcState - ? isNpcFirstMeaningfulContact(encounter, encounterNpcState) - : false; - })() - : false; - const firstContactRelationStance = (() => { - if ( - !isFirstMeaningfulContact || - !state.currentEncounter || - state.currentEncounter.kind !== 'npc' - ) { - return null; - } - - const stance = encounterNpcState?.relationState?.stance ?? null; - if ( - stance === 'guarded' || - stance === 'neutral' || - stance === 'cooperative' || - stance === 'bonded' - ) { - return stance; - } - return null; - })(); - const encounterAffinityText = - state.currentEncounter?.kind === 'npc' - ? (() => { - const encounter = state.currentEncounter; - return encounterNpcState - ? describeNpcAffinityInWords(encounter, encounterNpcState.affinity, { - recruited: encounterNpcState.recruited, - }) - : null; - })() - : null; - const baseSceneDescription = state.currentScenePreset?.description ?? null; - const sceneMutationDescription = [ - state.currentScenePreset?.mutationStateText - ? `最新世界变化:${state.currentScenePreset.mutationStateText}` - : null, - describeScenePressureLevel(state.currentScenePreset?.currentPressureLevel) - ? `当前区域压力等级:${describeScenePressureLevel(state.currentScenePreset?.currentPressureLevel)}` - : null, - ] - .filter(Boolean) - .join('\n'); - const observeSignsSceneDescription = - extras.observeSignsRequested && state.worldType - ? [ - baseSceneDescription, - sceneMutationDescription, - '当前可观察实体池:', - buildSceneEntityCatalogText( - state.worldType, - state.currentScenePreset?.id ?? null, - ), - ] - .filter(Boolean) - .join('\n') - : [baseSceneDescription, sceneMutationDescription].filter(Boolean).join('\n'); - const storyEngineMemory = - state.storyEngineMemory ?? createEmptyStoryEngineMemoryState(); - const knowledgeFacts = - state.customWorldProfile?.knowledgeFacts - ?? (state.customWorldProfile ? buildKnowledgeGraph(state.customWorldProfile) : []); - const encounterNarrativeProfile = resolveEncounterNarrativeProfile(state); - const activeThreadIds = resolveActiveThreadIds( - { - ...state, - storyEngineMemory, - } as GameState, - encounterNarrativeProfile, - ); - const visibilitySlice = - state.currentEncounter?.kind === 'npc' - ? (() => { - const relevantFacts = knowledgeFacts.filter((fact) => - fact.ownerActorIds.includes(state.currentEncounter?.id ?? '') - || fact.ownerActorIds.includes(state.currentEncounter?.npcName ?? '') - || fact.relatedThreadIds.some((threadId) => activeThreadIds.includes(threadId)), - ); - return relevantFacts.length > 0 - ? buildVisibilitySliceFromFacts({ - facts: relevantFacts, - discoveredFactIds: [ - ...storyEngineMemory.discoveredFactIds, - ...(encounterNpcState?.revealedFacts ?? []), - ...(encounterNpcState?.seenBackstoryChapterIds ?? []).map( - (chapterId) => - relevantFacts.find((fact) => - fact.aliases?.includes(chapterId) || fact.id.includes(chapterId), - )?.id ?? '', - ), - ], - activeThreadIds, - disclosureStage: encounterDirective?.disclosureStage ?? null, - isFirstMeaningfulContact, - }) - : buildEncounterVisibilitySlice({ - narrativeProfile: encounterNarrativeProfile, - backstoryReveal: state.currentEncounter.backstoryReveal ?? null, - disclosureStage: encounterDirective?.disclosureStage ?? null, - isFirstMeaningfulContact, - seenBackstoryChapterIds: encounterNpcState?.seenBackstoryChapterIds ?? [], - storyEngineMemory, - activeThreadIds, - }); - })() - : null; - const sceneNarrativeDirective = buildSceneNarrativeDirective({ + return { + runtimeSessionId: state.runtimeSessionId ?? null, + runtimeActionVersion: state.runtimeActionVersion, + playerHp: state.playerHp, + playerMaxHp: state.playerMaxHp, + playerMana: state.playerMana, + playerMaxMana: state.playerMaxMana, + inBattle: state.inBattle, + playerX: state.playerX, + playerFacing: state.playerFacing, + playerAnimation: state.animationState, + skillCooldowns: state.playerSkillCooldowns, sceneId: state.currentScenePreset?.id ?? null, sceneName: state.currentScenePreset?.name ?? null, - encounterId: state.currentEncounter?.id ?? null, - encounterName: state.currentEncounter?.npcName ?? null, - recentActions: state.storyHistory.slice(-3).map((moment) => moment.text), - activeThreadIds, - visibilitySlice, - encounterNarrativeProfile, - disclosureStage: encounterDirective?.disclosureStage ?? null, - isFirstMeaningfulContact, - affinity: encounterNpcState?.affinity ?? null, - }); - const chapterState = advanceChapterState({ - previousChapter: state.chapterState ?? storyEngineMemory.currentChapter ?? null, - nextChapter: resolveCurrentChapterState({ - state: { - ...state, - storyEngineMemory, - }, - }), - }); - const journeyBeat = resolveCurrentJourneyBeat({ - state: { - ...state, - chapterState, - storyEngineMemory: { - ...storyEngineMemory, - currentChapter: chapterState, - }, - } as GameState, - chapterState, - }); - const companionArcStates = advanceCompanionArc({ - previous: storyEngineMemory.companionArcStates, - next: buildCompanionArcStates({ - state, - reactions: storyEngineMemory.recentCompanionReactions, - }), - }); - const currentCampEvent = evaluateCampEventOpportunity({ - state, - chapterState, - journeyBeat, - companionArcStates, - }) - ? buildCampEvent({ - state, - chapterState, - journeyBeat, - companionArcStates, - }) - : null; - const setpieceDirective = evaluateSetpieceOpportunity({ - state, - chapterState, - journeyBeat, - }) - ? buildSetpieceDirective({ - state, - chapterState, - journeyBeat, - }) - : null; - const recentWorldMutations = storyEngineMemory.worldMutations ?? []; - const recentChronicleSummary = buildChronicleSummary({ - ...state, - chapterState, - storyEngineMemory: { - ...storyEngineMemory, - currentChapter: chapterState, - companionArcStates, - }, - } as GameState); - const compiledPacks = state.customWorldProfile - ? compileCampaignFromWorldProfile({ profile: state.customWorldProfile }) - : null; - const goalStack = buildGoalStackState({ - quests: state.quests, - worldType: state.worldType, - currentSceneId: state.currentScenePreset?.id ?? null, - chapterState, - journeyBeat, - setpieceDirective, - currentCampEvent, - currentSceneName: state.currentScenePreset?.name ?? null, - }); - const activeScenarioPack = - resolveScenarioPack(state.activeScenarioPackId) - ?? compiledPacks?.scenarioPack - ?? null; - const activeCampaignPack = compiledPacks?.campaignPack ?? null; - - const fallbackChapterRecap = buildChapterRecap({ - state: { ...state, chapterState } as GameState, - }); - const safeEncounterRelationshipSummary = - state.currentEncounter?.characterId - ? getCharacterChatRecord(state, state.currentEncounter.characterId) - .summary - .trim() - : ''; - - return applyAdaptiveTuningToPromptContext({ - context: { - playerHp: state.playerHp, - playerMaxHp: state.playerMaxHp, - playerMana: state.playerMana, - playerMaxMana: state.playerMaxMana, - inBattle: state.inBattle, - playerX: state.playerX, - playerFacing: state.playerFacing, - playerAnimation: state.animationState, - skillCooldowns: state.playerSkillCooldowns, - sceneId: state.currentScenePreset?.id ?? null, - sceneName: state.currentScenePreset?.name ?? null, - sceneDescription: observeSignsSceneDescription, - pendingSceneEncounter: extras.pendingSceneEncounter ?? false, - lastFunctionId: extras.lastFunctionId ?? null, - observeSignsRequested: extras.observeSignsRequested ?? false, - recentActionResult: extras.recentActionResult ?? null, - lastObserveSignsReport: - state.lastObserveSignsSceneId === (state.currentScenePreset?.id ?? null) - ? (state.lastObserveSignsReport ?? null) - : null, - encounterKind: state.currentEncounter?.kind ?? null, - encounterName: state.currentEncounter?.npcName ?? null, - encounterDescription: state.currentEncounter?.npcDescription ?? null, - encounterContext: state.currentEncounter?.context ?? null, - encounterId: state.currentEncounter?.id ?? null, - encounterCharacterId: state.currentEncounter?.characterId ?? null, - encounterGender: state.currentEncounter?.gender ?? null, - encounterCustomProfile: state.currentEncounter - ? { - title: state.currentEncounter.title ?? '', - description: state.currentEncounter.npcDescription ?? '', - backstory: state.currentEncounter.backstory ?? '', - personality: state.currentEncounter.personality ?? '', - motivation: state.currentEncounter.motivation ?? '', - combatStyle: state.currentEncounter.combatStyle ?? '', - relationshipHooks: [...(state.currentEncounter.relationshipHooks ?? [])], - tags: [...(state.currentEncounter.tags ?? [])], - backstoryReveal: state.currentEncounter.backstoryReveal, - skills: [...(state.currentEncounter.skills ?? [])], - initialItems: [...(state.currentEncounter.initialItems ?? [])], - imageSrc: state.currentEncounter.imageSrc, - visual: state.currentEncounter.visual, - narrativeProfile: state.currentEncounter.narrativeProfile, - } - : null, - encounterAffinity: encounterDirective?.affinity ?? null, - encounterAffinityText, - encounterStanceProfile: encounterNpcState?.stanceProfile ?? null, - encounterConversationStyle: encounterDirective?.style ?? null, - encounterDisclosureStage: encounterDirective?.disclosureStage ?? null, - encounterWarmthStage: encounterDirective?.warmthStage ?? null, - encounterAnswerMode: encounterDirective?.answerMode ?? null, - encounterAllowedTopics: encounterDirective?.allowTopics ?? null, - encounterBlockedTopics: encounterDirective?.blockedTopics ?? null, - isFirstMeaningfulContact, - firstContactRelationStance, - conversationSituation, - conversationPressure, - recentSharedEvent: - recentSharedEvent ?? describeConversationSituation(conversationSituation), - talkPriority: describeConversationTalkPriority(conversationSituation), - visibilitySlice, - sceneNarrativeDirective, - campaignState: state.campaignState ?? storyEngineMemory.campaignState ?? null, - actState: storyEngineMemory.actState ?? null, - chapterState, - journeyBeat, - goalStack, - currentCampEvent, - setpieceDirective, - activeScenarioPack, - activeCampaignPack, - encounterNarrativeProfile, - knowledgeFacts, - activeThreadIds, - companionArcStates, - companionResolutions: storyEngineMemory.companionResolutions ?? [], - consequenceLedger: storyEngineMemory.consequenceLedger ?? [], - authorialConstraintPack: storyEngineMemory.authorialConstraintPack ?? null, - playerStyleProfile: storyEngineMemory.playerStyleProfile ?? null, - recentCompanionReactions: storyEngineMemory.recentCompanionReactions ?? [], - recentCarrierEchoes: buildRecentCarrierEchoes(state), - recentWorldMutations, - recentFactionTensionStates: storyEngineMemory.factionTensionStates ?? [], - recentChronicleSummary: - recentChronicleSummary.trim() && - !hasMixedNarrativeLanguage(recentChronicleSummary) - ? recentChronicleSummary - : fallbackChapterRecap, - narrativeQaReport: storyEngineMemory.narrativeQaReport ?? null, - releaseGateReport: storyEngineMemory.releaseGateReport ?? null, - simulationRunResults: storyEngineMemory.simulationRunResults ?? [], - branchBudgetPressure: storyEngineMemory.branchBudgetStatus?.pressure ?? null, - encounterRelationshipSummary: state.currentEncounter?.characterId - ? !hasMixedNarrativeLanguage(safeEncounterRelationshipSummary) - ? safeEncounterRelationshipSummary || null - : null - : null, - partyRelationshipNotes: buildPartyRelationshipNotes(state), - customWorldProfile: state.customWorldProfile ?? null, - openingCampBackground: extras.openingCampBackground ?? null, - openingCampDialogue: extras.openingCampDialogue ?? null, - }, - profile: storyEngineMemory.playerStyleProfile ?? null, - }); + sceneDescription: state.currentScenePreset?.description ?? null, + pendingSceneEncounter: extras.pendingSceneEncounter ?? false, + lastFunctionId: extras.lastFunctionId ?? null, + observeSignsRequested: extras.observeSignsRequested ?? false, + recentActionResult: extras.recentActionResult ?? null, + customWorldProfile: null, + openingCampBackground: extras.openingCampBackground ?? null, + openingCampDialogue: extras.openingCampDialogue ?? null, + }; } diff --git a/src/hooks/rpg-runtime-story/storyGenerationState.test.ts b/src/hooks/rpg-runtime-story/storyGenerationState.test.ts index d0b0de57..6fd27656 100644 --- a/src/hooks/rpg-runtime-story/storyGenerationState.test.ts +++ b/src/hooks/rpg-runtime-story/storyGenerationState.test.ts @@ -156,6 +156,51 @@ function createBaseState(): GameState { activeCombatEffects: [], playerCurrency: 10, playerInventory: [createInventoryItem('player-potion', 'Potion')], + runtimeNpcInteraction: { + npcId: 'npc-trader', + npcName: 'Trader Lin', + playerCurrency: 10, + currencyName: '铜钱', + trade: { + buyItems: [ + { + itemId: 'npc-herb', + item: createInventoryItem('npc-herb', 'Herb'), + mode: 'buy', + unitPrice: 3, + maxQuantity: 1, + canSubmit: true, + reason: null, + }, + ], + sellItems: [ + { + itemId: 'player-potion', + item: createInventoryItem('player-potion', 'Potion'), + mode: 'sell', + unitPrice: 1, + maxQuantity: 1, + canSubmit: true, + reason: null, + }, + ], + }, + gift: { + items: [ + { + itemId: 'jade-token', + item: createInventoryItem('jade-token', 'Jade Token', { + rarity: 'rare', + category: '专属', + tags: ['merchant'], + }), + affinityGain: 16, + canSubmit: true, + reason: null, + }, + ], + }, + }, playerEquipment: { weapon: null, armor: null, @@ -202,7 +247,7 @@ function createInteractionOption(action: Extract { - it('opens the trade modal with the first npc and player inventory items selected', () => { + it('opens the trade modal with server-selected npc and player items', () => { const decision = resolveNpcInteractionDecision( createBaseState(), createInteractionOption('trade'), @@ -218,14 +263,39 @@ describe('storyGenerationState', () => { expect(decision.modal.selectedQuantity).toBe(1); }); - it('skips zero-quantity player items when opening the trade modal', () => { + it('prefers the first server-submittable sell item when opening the trade modal', () => { + const baseState = createBaseState(); const decision = resolveNpcInteractionDecision( { - ...createBaseState(), - playerInventory: [ - createInventoryItem('empty-slot', 'Empty Slot', { quantity: 0 }), - createInventoryItem('player-herb', 'Herb'), - ], + ...baseState, + runtimeNpcInteraction: { + ...baseState.runtimeNpcInteraction!, + trade: { + buyItems: baseState.runtimeNpcInteraction!.trade.buyItems, + sellItems: [ + { + itemId: 'empty-slot', + item: createInventoryItem('empty-slot', 'Empty Slot', { + quantity: 0, + }), + mode: 'sell', + unitPrice: 1, + maxQuantity: 0, + canSubmit: false, + reason: '背包数量不足。', + }, + { + itemId: 'player-herb', + item: createInventoryItem('player-herb', 'Herb'), + mode: 'sell', + unitPrice: 2, + maxQuantity: 1, + canSubmit: true, + reason: null, + }, + ], + }, + }, }, createInteractionOption('trade'), ); @@ -257,21 +327,9 @@ describe('storyGenerationState', () => { expect(decision.modal.selectedReleaseNpcId).toBe('npc-1'); }); - it('opens the gift modal with the preferred gift candidate selected', () => { - const state = { - ...createBaseState(), - playerInventory: [ - createInventoryItem('empty-slot', 'Empty Slot', { quantity: 0 }), - createInventoryItem('jade-token', 'Jade Token', { - rarity: 'rare', - category: '专属', - tags: ['merchant'], - }), - ], - }; - + it('opens the gift modal with the server-selected gift candidate', () => { const decision = resolveNpcInteractionDecision( - state, + createBaseState(), createInteractionOption('gift'), ); @@ -284,9 +342,13 @@ describe('storyGenerationState', () => { }); it('does not open the gift modal when there are no gift candidates', () => { + const baseState = createBaseState(); const state = { - ...createBaseState(), - playerInventory: [], + ...baseState, + runtimeNpcInteraction: { + ...baseState.runtimeNpcInteraction!, + gift: { items: [] }, + }, }; const decision = resolveNpcInteractionDecision( diff --git a/src/hooks/rpg-runtime-story/storyGenerationState.ts b/src/hooks/rpg-runtime-story/storyGenerationState.ts index 55fb415b..fdba3098 100644 --- a/src/hooks/rpg-runtime-story/storyGenerationState.ts +++ b/src/hooks/rpg-runtime-story/storyGenerationState.ts @@ -10,11 +10,7 @@ import { applyQuestProgressFromSceneReached, } from '../../data/questFlow'; -import { - buildInitialNpcState, - getPreferredGiftItemId, - MAX_COMPANIONS, -} from '../../data/npcInteractions'; +import { MAX_COMPANIONS } from '../../data/npcInteractions'; import { incrementGameRuntimeStats } from '../../data/runtimeStats'; import { ensureSceneEncounterPreview } from '../../data/sceneEncounterPreviews'; import { getScenePresetById } from '../../data/scenePresets'; @@ -53,11 +49,10 @@ export function getNpcEncounterKey(encounter: Encounter) { return encounter.id ?? encounter.npcName; } -function getResolvedNpcState(state: GameState, encounter: Encounter) { - return ( - state.npcStates[getNpcEncounterKey(encounter)] ?? - buildInitialNpcState(encounter, state.worldType) - ); +function findPreferredTradeItemId( + items: Array<{ itemId: string; canSubmit: boolean }>, +) { + return items.find(item => item.canSubmit)?.itemId ?? items[0]?.itemId ?? null; } export function resolveNpcInteractionDecision( @@ -73,29 +68,29 @@ export function resolveNpcInteractionDecision( } const encounter = state.currentEncounter; - const npcState = getResolvedNpcState(state, encounter); switch (option.functionId) { case NPC_TRADE_FUNCTION.id: return { kind: 'trade_modal', modal: buildNpcTradeModalState( - state, encounter, option.actionText, - npcState.inventory, + findPreferredTradeItemId( + state.runtimeNpcInteraction?.trade.buyItems ?? [], + ), + findPreferredTradeItemId( + state.runtimeNpcInteraction?.trade.sellItems ?? [], + ), ), }; case NPC_GIFT_FUNCTION.id: { - const selectedGiftItemId = getPreferredGiftItemId( - state.playerInventory, - encounter, - { - worldType: state.worldType, - customWorldProfile: state.customWorldProfile, - }, - ); + const selectedGiftItemId = + state.runtimeNpcInteraction?.gift.items.find(item => item.canSubmit) + ?.itemId ?? + state.runtimeNpcInteraction?.gift.items[0]?.itemId ?? + null; if (!selectedGiftItemId) { return { kind: 'none' }; } @@ -103,7 +98,6 @@ export function resolveNpcInteractionDecision( return { kind: 'gift_modal', modal: buildNpcGiftModalState( - state, encounter, option.actionText, selectedGiftItemId, diff --git a/src/hooks/rpg-runtime-story/storyInteractionCoordinator.ts b/src/hooks/rpg-runtime-story/storyInteractionCoordinator.ts index 300f7056..b016a61c 100644 --- a/src/hooks/rpg-runtime-story/storyInteractionCoordinator.ts +++ b/src/hooks/rpg-runtime-story/storyInteractionCoordinator.ts @@ -60,6 +60,8 @@ type StoryInteractionCoordinatorParams = { export function createStoryInteractionCoordinatorConfig( params: StoryInteractionCoordinatorParams, ) { + // 中文注释:sharedRuntime 是宝箱流和背包流共享的最小运行时上下文, + // 这两类动作不需要拿到完整 NPC / 对话链配置,因此先抽一层轻量公共配置。 const sharedRuntime = { currentStory: params.currentStory, setGameState: params.setGameState, @@ -87,6 +89,8 @@ export function createStoryInteractionCoordinatorConfig( cloneInventoryItemForOwner: params.runtimeSupport.cloneInventoryItemForOwner, runtime: { + // 中文注释:NPC 交互流需要最完整的故事上下文, + // 包括对话故事构建、继续生成、打字机延迟和敌对 NPC 推断。 currentStory: params.currentStory, setCurrentStory: params.setCurrentStory, setAiError: params.setAiError, @@ -100,6 +104,8 @@ export function createStoryInteractionCoordinatorConfig( }, }, npcEncounterActions: { + // 中文注释:npcEncounterActions 是最重的一组配置, + // 它同时服务“进入 NPC 遭遇”“提交 NPC 动作”“战斗后恢复对话”等整条分支。 gameState: params.gameState, currentStory: params.currentStory, setGameState: params.setGameState, diff --git a/src/hooks/rpg-runtime-story/storyRequestCoordinator.test.ts b/src/hooks/rpg-runtime-story/storyRequestCoordinator.test.ts index 2d7e4526..9c848c0d 100644 --- a/src/hooks/rpg-runtime-story/storyRequestCoordinator.test.ts +++ b/src/hooks/rpg-runtime-story/storyRequestCoordinator.test.ts @@ -118,6 +118,8 @@ describe('storyRequestCoordinator', () => { const buildStoryContextFromState = vi.fn( (_state, extras) => ({ + runtimeSessionId: 'runtime-main', + runtimeActionVersion: 3, playerHp: 100, playerMaxHp: 100, playerMana: 30, @@ -177,7 +179,8 @@ describe('storyRequestCoordinator', () => { history, '继续交谈', expect.objectContaining({ - sceneId: 'inn_room', + runtimeSessionId: 'runtime-main', + runtimeActionVersion: 3, lastFunctionId: 'npc_chat', }), { diff --git a/src/hooks/rpg-runtime-story/uiTypes.ts b/src/hooks/rpg-runtime-story/uiTypes.ts index 7cd1ae26..87322f43 100644 --- a/src/hooks/rpg-runtime-story/uiTypes.ts +++ b/src/hooks/rpg-runtime-story/uiTypes.ts @@ -1,4 +1,9 @@ import type { + RuntimeStoryEquipmentSlotView, + RuntimeStoryForgeRecipeView, + RuntimeStoryInventoryItemView, +} from '../../../packages/shared/src/contracts/rpgRuntimeStoryState'; +import type { Encounter, GoalHandoff, GoalPulseEvent, @@ -52,22 +57,11 @@ export interface InventoryFlowUi { useInventoryItem: (itemId: string) => Promise; equipInventoryItem: (itemId: string) => Promise; unequipItem: (slot: 'weapon' | 'armor' | 'relic') => Promise; - forgeRecipes: Array<{ - id: string; - name: string; - kind: 'synthesis' | 'forge'; - description: string; - resultLabel: string; - currencyCost: number; - currencyText: string; - requirements: Array<{ - id: string; - label: string; - quantity: number; - owned: number; - }>; - canCraft: boolean; - }>; + playerCurrency: number | null; + currencyText: string | null; + backpackItems: RuntimeStoryInventoryItemView[]; + equipmentSlots: RuntimeStoryEquipmentSlotView[]; + forgeRecipes: RuntimeStoryForgeRecipeView[]; craftRecipe: (recipeId: string) => Promise; dismantleItem: (itemId: string) => Promise; reforgeItem: (itemId: string) => Promise; diff --git a/src/hooks/rpg-runtime-story/useRpgRuntimeInteractionFlow.ts b/src/hooks/rpg-runtime-story/useRpgRuntimeInteractionFlow.ts index f19e466a..b4dc014f 100644 --- a/src/hooks/rpg-runtime-story/useRpgRuntimeInteractionFlow.ts +++ b/src/hooks/rpg-runtime-story/useRpgRuntimeInteractionFlow.ts @@ -62,6 +62,8 @@ export function createClearStoryInteractionUi(params: { clearNpcInteractionUi: () => void; }) { return () => { + // 中文注释:story 选择面板和 NPC 交互面板是两套独立 UI; + // 清理运行时交互态时必须同时重置,避免战斗/对话切换后残留旧弹层。 params.clearStoryChoiceUi(); params.clearNpcInteractionUi(); }; @@ -120,6 +122,8 @@ export function useRpgRuntimeInteractionFlow({ } if (isNpcEncounter(gameState.currentEncounter)) { + // 中文注释:当场景里已经解析出 NPC 遭遇,且当前不在战斗/加载中时, + // 自动进入 NPC 交互态,让开场相遇和旅行后遭遇都能无缝落到对话/互动面板。 enterNpcInteraction( gameState.currentEncounter, `与${gameState.currentEncounter.npcName}搭话`, @@ -180,6 +184,8 @@ export function useRpgRuntimeInteractionFlow({ ); }, }; + // 中文注释:choice coordinator 只关心“点下某个 story option 后怎么结算”, + // NPC 战斗结束后要不要回到对话态,则通过 runtimeSupport 在这里桥接进去。 const choiceRuntimeSupport: ChoiceRuntimeSupport = { ...runtimeSupport, handleNpcBattleConversationContinuation: ({ @@ -248,6 +254,8 @@ export function useRpgRuntimeInteractionFlow({ return false; } + // 中文注释:聊天提交是 fire-and-forget, + // 调用方只需要知道“当前能不能发给 NPC”,不需要阻塞等待整轮对话结束。 void handleNpcChatTurn(encounter, input); return true; }, @@ -263,6 +271,8 @@ export function useRpgRuntimeInteractionFlow({ return false; } + // 中文注释:NPC 聊天的“换一组回应建议”当前通过轮转 options 实现, + // 不额外发请求,优先复用本轮已经拿到的候选动作。 interactionConfig.npcEncounterActions.setCurrentStory({ ...story, options: [...restOptions, firstOption], diff --git a/src/hooks/rpg-runtime-story/useRpgRuntimeNpcInteraction.ts b/src/hooks/rpg-runtime-story/useRpgRuntimeNpcInteraction.ts index f8f759fe..62d928e8 100644 --- a/src/hooks/rpg-runtime-story/useRpgRuntimeNpcInteraction.ts +++ b/src/hooks/rpg-runtime-story/useRpgRuntimeNpcInteraction.ts @@ -22,13 +22,9 @@ import { resolveFunctionOption } from '../../data/stateFunctions'; import { streamNpcChatTurn } from '../../services/aiService'; import type { StoryGenerationContext } from '../../services/aiTypes'; import { - advanceSceneActRuntimeState, getSceneConnectionDirectionText, resolveLimitedPrimaryNpcChatState, - resolveSceneActProgression, } from '../../services/customWorldSceneActRuntime'; -import { appendStoryEngineCarrierMemory } from '../../services/storyEngine/echoMemory'; -import { createEmptyStoryEngineMemoryState } from '../../services/storyEngine/visibilityEngine'; import type { Character, Encounter, @@ -513,44 +509,41 @@ export function createStoryNpcEncounterActions({ nextNpcInventory = removeInventoryItem(nextNpcInventory, item.id, 1); } - const nextState: GameState = appendStoryEngineCarrierMemory( - incrementRuntimeStats( - { - ...state, - currentBattleNpcId: null, - currentNpcBattleMode: null, - currentNpcBattleOutcome: null, - currentEncounter: restoredEncounter, - npcInteractionActive: true, - sceneHostileNpcs: [], - playerInventory: addInventoryItems(state.playerInventory, lootItems), - quests: progressedQuests, - npcStates: { - ...state.npcStates, - [battleNpcId]: { - ...markNpcFirstMeaningfulContactResolved(npcState), - affinity: npcState.affinity, - relationState: buildRelationState(npcState.affinity), - recruited: false, - inventory: nextNpcInventory, - }, + const nextState: GameState = incrementRuntimeStats( + { + ...state, + currentBattleNpcId: null, + currentNpcBattleMode: null, + currentNpcBattleOutcome: null, + currentEncounter: restoredEncounter, + npcInteractionActive: true, + sceneHostileNpcs: [], + playerInventory: addInventoryItems(state.playerInventory, lootItems), + quests: progressedQuests, + npcStates: { + ...state.npcStates, + [battleNpcId]: { + ...markNpcFirstMeaningfulContactResolved(npcState), + affinity: npcState.affinity, + relationState: buildRelationState(npcState.affinity), + recruited: false, + inventory: nextNpcInventory, }, - playerX: 0, - playerFacing: 'right' as const, - animationState: state.animationState, - activeCombatEffects: [], - scrollWorld: false, - inBattle: false, - sparReturnEncounter: null, - sparPlayerHpBefore: null, - sparPlayerMaxHpBefore: null, - sparStoryHistoryBefore: null, }, - { - hostileNpcsDefeated: defeatedHostileNpcIds.length, - }, - ), - lootItems, + playerX: 0, + playerFacing: 'right' as const, + animationState: state.animationState, + activeCombatEffects: [], + scrollWorld: false, + inBattle: false, + sparReturnEncounter: null, + sparPlayerHpBefore: null, + sparPlayerMaxHpBefore: null, + sparStoryHistoryBefore: null, + }, + { + hostileNpcsDefeated: defeatedHostileNpcIds.length, + }, ); const lootText = @@ -985,57 +978,6 @@ export function createStoryNpcEncounterActions({ encounter: Encounter, playerCharacter: Character, ) => { - const progression = resolveSceneActProgression({ - profile: gameState.customWorldProfile, - sceneId: gameState.currentScenePreset?.id ?? null, - storyEngineMemory: gameState.storyEngineMemory, - }); - - if (!progression) { - return { - deferredRuntimeState: null, - options: currentStory?.deferredOptions?.length - ? currentStory.deferredOptions - : buildPostNpcChatOptionCatalog(encounter, playerCharacter), - }; - } - - if (!progression.isLastAct) { - const nextActState = advanceSceneActRuntimeState({ progress: progression }); - const nextStoryEngineMemory = nextActState - ? { - ...(gameState.storyEngineMemory ?? - createEmptyStoryEngineMemoryState()), - currentSceneActState: nextActState, - } - : gameState.storyEngineMemory; - const nextState = { - ...gameState, - currentEncounter: null, - npcInteractionActive: false, - sceneHostileNpcs: [], - inBattle: false, - currentBattleNpcId: null, - currentNpcBattleMode: null, - currentNpcBattleOutcome: null, - storyEngineMemory: nextStoryEngineMemory, - }; - const nextOptions = collapseNpcChatOptions( - getAvailableOptionsForState(nextState, playerCharacter) ?? [], - ); - - return { - deferredRuntimeState: { - currentScenePreset: nextState.currentScenePreset, - storyEngineMemory: nextState.storyEngineMemory, - }, - options: - nextOptions.length > 0 - ? nextOptions - : buildPostNpcChatOptionCatalog(encounter, playerCharacter), - }; - } - const travelOptions = buildSceneConnectionTravelOptions(gameState); return { @@ -1794,12 +1736,8 @@ export function createStoryNpcEncounterActions({ currentBattleNpcId: null, currentNpcBattleMode: null, currentNpcBattleOutcome: null, - currentScenePreset: - progressionResult.deferredRuntimeState?.currentScenePreset ?? - gameState.currentScenePreset, - storyEngineMemory: - progressionResult.deferredRuntimeState?.storyEngineMemory ?? - gameState.storyEngineMemory, + currentScenePreset: gameState.currentScenePreset, + storyEngineMemory: gameState.storyEngineMemory, }; setGameState(nextState); diff --git a/src/hooks/rpg-runtime-story/useRpgRuntimeStory.ts b/src/hooks/rpg-runtime-story/useRpgRuntimeStory.ts index b958a093..c8563823 100644 --- a/src/hooks/rpg-runtime-story/useRpgRuntimeStory.ts +++ b/src/hooks/rpg-runtime-story/useRpgRuntimeStory.ts @@ -80,6 +80,8 @@ export function useRpgRuntimeStory({ buildStoryContextFromState, }); + // 中文注释:controller 负责“当前故事是什么”, + // flow 负责“用户点下去以后发生什么”,两者在这里被装成统一运行时 story 出口。 const runtimeController = useRpgRuntimeStoryController({ gameState, setGameState, @@ -125,6 +127,7 @@ export function useRpgRuntimeStory({ turnVisualMs: TURN_VISUAL_MS, }); + // 中文注释:这里返回的对象就是 runtime shell / adventure panel 直接消费的故事域 API。 return { currentStory: runtimeController.currentStory, isLoading: runtimeController.isLoading, diff --git a/src/hooks/rpg-runtime-story/useRpgRuntimeStoryController.test.tsx b/src/hooks/rpg-runtime-story/useRpgRuntimeStoryController.test.tsx index 1e8ced78..4ae8e720 100644 --- a/src/hooks/rpg-runtime-story/useRpgRuntimeStoryController.test.tsx +++ b/src/hooks/rpg-runtime-story/useRpgRuntimeStoryController.test.tsx @@ -43,6 +43,8 @@ function createGameState(params: { } = {}): GameState { return { worldType: WorldType.CUSTOM, + runtimeSessionId: 'runtime-main', + runtimeActionVersion: 4, customWorldProfile: null, playerCharacter: createCharacter(), currentScene: 'Story', @@ -89,6 +91,8 @@ function buildStoryContextFromState( _state: GameState, ): StoryGenerationContext { return { + runtimeSessionId: 'runtime-main', + runtimeActionVersion: 4, playerHp: 100, playerMaxHp: 100, playerMana: 20, @@ -187,8 +191,8 @@ describe('useRpgRuntimeStoryController', () => { expect.objectContaining({ id: 'hero' }), [], expect.objectContaining({ - sceneId: 'scene-opening', - sceneName: '证券交易所大厅', + runtimeSessionId: 'runtime-main', + runtimeActionVersion: 4, }), undefined, ); diff --git a/src/hooks/rpg-runtime-story/useRpgRuntimeStoryController.ts b/src/hooks/rpg-runtime-story/useRpgRuntimeStoryController.ts index 6fb944de..b3ab4aab 100644 --- a/src/hooks/rpg-runtime-story/useRpgRuntimeStoryController.ts +++ b/src/hooks/rpg-runtime-story/useRpgRuntimeStoryController.ts @@ -57,6 +57,8 @@ export function useRpgRuntimeStoryController(params: { [], ); + // 中文注释:presentation 层负责把服务端/AI 返回的原始故事数据 + // 编译成前端当前可直接展示的 StoryMoment。 const buildStoryFromResponse = useCallback( ( state: GameState, @@ -135,6 +137,8 @@ export function useRpgRuntimeStoryController(params: { gameState.currentScenePreset?.id ?? 'scene', gameState.storyHistory.length, ].join(':'); + // 中文注释:开场剧情只允许同一份“玩家 + 场景 + 历史长度”请求飞一次, + // 防止 React 严格模式、状态抖动或异步回填触发重复开局生成。 if (openingStoryRequestKeyRef.current === requestKey) { return; } @@ -162,6 +166,8 @@ export function useRpgRuntimeStoryController(params: { } console.error('Failed to start opening RPG story:', error); + // 中文注释:即使 AI / 服务端首段故事失败,也要兜底出一个本地可玩的故事壳, + // 否则冒险面板会直接卡死在无 story 的空白状态。 setAiError(error instanceof Error ? error.message : '未知智能生成错误'); setCurrentStory(buildFallbackStoryForState(gameState, playerCharacter)); }) @@ -195,6 +201,8 @@ export function useRpgRuntimeStoryController(params: { isLoading, setIsLoading, preparedOpeningAdventure: null, + // 中文注释:这几个 opening adventure 相关字段先按空实现保留, + // 目的是兼容旧调用面,同时避免新 runtime 链再把预制开场逻辑塞回 controller。 startOpeningAdventure: async () => undefined, resetPreparedOpeningAdventure: () => undefined, buildStoryContextFromState, diff --git a/src/hooks/rpg-runtime-story/useRpgRuntimeStoryFlow.ts b/src/hooks/rpg-runtime-story/useRpgRuntimeStoryFlow.ts index 2ca8a439..9161b813 100644 --- a/src/hooks/rpg-runtime-story/useRpgRuntimeStoryFlow.ts +++ b/src/hooks/rpg-runtime-story/useRpgRuntimeStoryFlow.ts @@ -97,6 +97,8 @@ export function useRpgRuntimeStoryFlow({ buildOpeningCampChatContext, resetPreparedOpeningAdventure, } = runtimeController; + // 中文注释:interactionConfig 是“剧情交互协调器”的配置快照; + // 后续选项刷新、动作提交、fallback 叙事都会共用这套上下文。 const interactionConfig = createStoryInteractionCoordinatorConfig({ gameState, setGameState, @@ -131,6 +133,8 @@ export function useRpgRuntimeStoryFlow({ gameState, currentStory, }); + // 中文注释:这一层把“战斗/NPC/背包/地图旅行”等具体交互入口分发到对应流程, + // 保证冒险面板只调用统一的 handleChoice / handleNpcChatInput 等接口。 const { handleChoice, battleRewardUi, @@ -175,6 +179,7 @@ export function useRpgRuntimeStoryFlow({ clearCharacterChatModal, }); + // 中文注释:最终返回的是已经过目标选项协调、交互分发和 story state 收束后的稳定输出。 return { displayedOptions, canRefreshOptions, diff --git a/src/hooks/rpg-runtime-story/useRpgRuntimeStoryState.ts b/src/hooks/rpg-runtime-story/useRpgRuntimeStoryState.ts index 900b400c..1a761da2 100644 --- a/src/hooks/rpg-runtime-story/useRpgRuntimeStoryState.ts +++ b/src/hooks/rpg-runtime-story/useRpgRuntimeStoryState.ts @@ -19,6 +19,8 @@ export function createClearStoryRuntimeUi(params: { clearCharacterChatModal: () => void; }) { return () => { + // 中文注释:story runtime 的“清场”不只是清掉故事文本, + // 还要把目标 UI、交互 UI、错误态、加载态和角色私聊弹层一起回收。 params.clearStoryGoalOptionUi(); params.clearStoryInteractionUi(); params.setAiError(null); @@ -81,6 +83,8 @@ export function useRpgRuntimeStoryState(params: { buildFallbackStoryForState: params.buildFallbackStoryForState, }); + // 中文注释:quest 相关按钮属于运行时 story UI 的一部分, + // 但真正的状态迁移统一交给 sessionActions,当前层只负责对外暴露稳定接口。 return { questUi: { acknowledgeQuestCompletion, diff --git a/src/hooks/rpg-session/useRpgRuntimeSession.ts b/src/hooks/rpg-session/useRpgRuntimeSession.ts index e7729bc4..5970c34d 100644 --- a/src/hooks/rpg-session/useRpgRuntimeSession.ts +++ b/src/hooks/rpg-session/useRpgRuntimeSession.ts @@ -4,7 +4,10 @@ import { DEFAULT_MUSIC_VOLUME } from '../../../packages/shared/src/contracts/run import { useAuthUi } from '../../components/auth/AuthUiContext'; import type { CustomWorldRuntimeLaunchOptions } from '../../components/platform-entry/platformEntryTypes'; import type { RpgRuntimeShellProps } from '../../components/rpg-runtime-shell/types'; -import { activateRosterCompanion, benchActiveCompanion } from '../../data/companionRoster'; +import { + activateRosterCompanion, + benchActiveCompanion, +} from '../../data/companionRoster'; import { syncGameStatePlayTime } from '../../data/runtimeStats'; import type { HydratedSavedGameSnapshot } from '../../persistence/runtimeSnapshotTypes'; import { useBackgroundMusic } from '../useBackgroundMusic'; @@ -33,10 +36,14 @@ export function useRpgRuntimeSession(): RpgRuntimeShellProps { handleCharacterSelect: selectCharacter, } = useRpgSessionBootstrap(); + // 中文注释:战斗播放与结算仍然沿用独立 combat flow; + // runtime session 只消费它暴露出来的“选项结算结果”和“动画播放入口”。 const combatFlow = useCombatFlow({ setGameState, }); + // 中文注释:剧情流是运行时主链的另一半。 + // 这里把 GameState 交给 runtime story,由它负责剧情文本、选项、NPC 交互与任务 UI。 const storyFlow = useRpgRuntimeStory({ gameState, setGameState, @@ -46,6 +53,8 @@ export function useRpgRuntimeSession(): RpgRuntimeShellProps { const { companionRenderStates, buildCompanionRenderStates } = useNpcInteractionFlow(gameState); + // 中文注释:持久化层统一负责继续游戏、自动存档与退出保存, + // session 只把当前运行态快照和 story 水位传进去。 const persistence = useRpgSessionPersistence({ authenticatedUserId: authUi?.user?.id ?? null, gameState, @@ -70,6 +79,8 @@ export function useRpgRuntimeSession(): RpgRuntimeShellProps { return; } + // 中文注释:游玩时长统计不跟每一帧绑定,而是固定 15 秒增量同步, + // 这样既能累计活跃时长,也不会因为高频 setState 拉高运行态噪音。 const intervalId = window.setInterval(() => { setGameState((currentState) => { if ( @@ -90,6 +101,8 @@ export function useRpgRuntimeSession(): RpgRuntimeShellProps { customWorldProfile: Parameters[0], options?: CustomWorldRuntimeLaunchOptions, ) => { + // 中文注释:切换世界前先清空上一局 story 控制器, + // 避免旧世界的 currentStory / 选项残留到新开局。 storyFlow.resetStoryState(); selectCustomWorld(customWorldProfile, { mode: options?.mode, @@ -100,6 +113,8 @@ export function useRpgRuntimeSession(): RpgRuntimeShellProps { const handleCharacterSelect = ( character: Parameters[0], ) => { + // 中文注释:角色确认意味着正式进入新 run, + // 这里同样先清理 story 层,保证开场剧情重新按当前角色生成。 storyFlow.resetStoryState(); selectCharacter(character); }; @@ -110,6 +125,8 @@ export function useRpgRuntimeSession(): RpgRuntimeShellProps { }; const handleContinueGame = (snapshot?: HydratedSavedGameSnapshot | null) => { + // 中文注释:继续游戏是异步恢复链,内部会重新向服务端刷新 runtime story, + // 所以这里显式丢给 persistence 异步执行。 void persistence.continueSavedGame(snapshot); }; @@ -120,9 +137,9 @@ export function useRpgRuntimeSession(): RpgRuntimeShellProps { }; const handleSaveAndExit = () => { - const syncedGameState = syncGameStatePlayTime(gameState); + // 中文注释:退出保存只请求服务端基于已存快照创建 checkpoint; + // 游玩时长的最终刷新由后端 checkpoint 负责,不再上传本地同步后的 GameState。 void persistence.saveCurrentGame({ - gameState: syncedGameState, bottomTab, currentStory: storyFlow.currentStory, }); diff --git a/src/hooks/rpg-session/useRpgSessionBootstrap.ts b/src/hooks/rpg-session/useRpgSessionBootstrap.ts index b98e8281..5bd9fb7a 100644 --- a/src/hooks/rpg-session/useRpgSessionBootstrap.ts +++ b/src/hooks/rpg-session/useRpgSessionBootstrap.ts @@ -1,176 +1,29 @@ -import { useEffect, useState } from 'react'; +import { useEffect, useState } from 'react'; import { buildCustomWorldRuntimeCharacters, - createCharacterSkillCooldowns, - getCharacterMaxHp, - getCharacterMaxMana, setRuntimeCharacterOverrides, } from '../../data/characterPresets'; import { setRuntimeCustomWorldProfile } from '../../data/customWorldRuntime'; -import { getInitialPlayerCurrency } from '../../data/economy'; -import { - applyEquipmentLoadoutToState, - buildInitialEquipmentLoadout, - createEmptyEquipmentLoadout, -} from '../../data/equipmentEffects'; -import { - buildInitialNpcState, - buildInitialPlayerInventory, -} from '../../data/npcInteractions'; +import { createEmptyEquipmentLoadout } from '../../data/equipmentEffects'; import { createInitialPlayerProgressionState } from '../../data/playerProgression'; import { createInitialGameRuntimeStats } from '../../data/runtimeStats'; -import { - ensureSceneEncounterPreview, - RESOLVED_ENTITY_X_METERS, -} from '../../data/sceneEncounterPreviews'; -import { - buildEncounterFromSceneNpc, - getScenePreset, - getScenePresetById, - getWorldCampScenePreset, -} from '../../data/scenePresets'; -import { - findCustomWorldRoleByReference, - resolveCustomWorldRoleIdReference, - resolveCustomWorldRoleIdReferences, -} from '../../services/customWorldRoleReferences'; -import { buildInitialSceneActRuntimeState } from '../../services/customWorldSceneActRuntime'; +import { getScenePreset } from '../../data/scenePresets'; +import { beginRpgRuntimeStorySession } from '../../services/rpg-runtime/rpgRuntimeStoryClient'; import { createEmptyStoryEngineMemoryState } from '../../services/storyEngine/visibilityEngine'; import { AnimationState, Character, CustomWorldProfile, - Encounter, - EquipmentLoadout, GameState, GameRuntimeMode, - InventoryItem, - SceneActBlueprint, - SceneChapterBlueprint, - SceneNpc, WorldType, } from '../../types'; import type { BottomTab } from './rpgSessionTypes'; const PLAYER_BASE_MAX_HP = 180; -function mergeStarterInventoryItems< - T extends { category: string; name: string }, ->(explicitItems: T[], fallbackItems: T[]) { - const merged = new Map(); - - [...explicitItems, ...fallbackItems].forEach((item) => { - merged.set(`${item.category}:${item.name}`, item); - }); - - return [...merged.values()]; -} - -function normalizeExplicitStarterCategory(category: string) { - const normalized = category.trim(); - return normalized === '专属物' ? '专属物品' : normalized; -} - -function inferExplicitStarterSlot(category: string) { - const normalized = normalizeExplicitStarterCategory(category); - if (normalized === '武器') return 'weapon' as const; - if (normalized === '护甲') return 'armor' as const; - if ( - normalized === '饰品' || - normalized === '稀有品' || - normalized === '专属物品' - ) { - return 'relic' as const; - } - return null; -} - -function buildExplicitCustomWorldRoleStarterState( - profile: CustomWorldProfile, - character: Character, -) { - const role = - profile.playableNpcs.find((entry) => entry.id === character.id) ?? - profile.storyNpcs.find((entry) => entry.id === character.id) ?? - profile.playableNpcs.find((entry) => entry.name === character.name) ?? - profile.storyNpcs.find((entry) => entry.name === character.name) ?? - null; - - const inventory = role - ? role.initialItems.map((item, index) => { - const category = normalizeExplicitStarterCategory(item.category); - return { - id: `custom-role-item:${role.id}:${index + 1}`, - category, - name: item.name, - quantity: Math.max(1, item.quantity), - rarity: item.rarity, - tags: [...item.tags], - description: item.description, - equipmentSlotId: inferExplicitStarterSlot(category), - runtimeMetadata: { - origin: 'ai_compiled' as const, - generationChannel: 'discovery' as const, - seedKey: `${role.id}:${index + 1}`, - relationAnchor: { - type: 'npc' as const, - npcId: role.id, - npcName: role.name, - roleText: role.role, - }, - sourceReason: `${role.name}在自定义世界开局时自带的初始物品。`, - }, - } satisfies InventoryItem; - }) - : []; - - const equipment: EquipmentLoadout = createEmptyEquipmentLoadout(); - inventory.forEach((item) => { - const slot = item.equipmentSlotId; - if (!slot || equipment[slot]) { - return; - } - equipment[slot] = item; - }); - - return { - inventory, - equipment, - }; -} - -function createInitialCampEncounter( - worldType: WorldType | null, - playerCharacter: Character, -): Encounter | null { - if (!worldType) return null; - - const campScenePreset = - getWorldCampScenePreset(worldType) ?? getScenePreset(worldType, 0); - const npcCandidates = (campScenePreset?.npcs ?? []) - .filter((npc: SceneNpc) => Boolean(npc.characterId)) - .filter((npc: SceneNpc) => npc.characterId !== playerCharacter.id); - if (npcCandidates.length === 0) return null; - - const npc = - npcCandidates[Math.floor(Math.random() * npcCandidates.length)] ?? null; - if (!npc) return null; - - return { - id: npc.id, - kind: 'npc', - characterId: npc.characterId, - npcName: npc.name, - npcDescription: npc.description, - npcAvatar: npc.avatar, - context: npc.role, - gender: npc.gender, - xMeters: RESOLVED_ENTITY_X_METERS, - }; -} - -function createInitialGameState(): GameState { +function createSelectionGameState(): GameState { return { worldType: null, customWorldProfile: null, @@ -222,261 +75,20 @@ function createInitialGameState(): GameState { }; } -function resolveOpeningActScenePreset( - profile: CustomWorldProfile | null, -): NonNullable | null { - if (!profile) { - return null; - } - - const openingChapter = profile.sceneChapterBlueprints?.[0] ?? null; - const openingSceneIds = [ - openingChapter?.acts[0]?.sceneId, - openingChapter?.sceneId, - ...(openingChapter?.linkedLandmarkIds ?? []), - ] - .map((sceneId) => sceneId?.trim() ?? '') - .filter(Boolean); - - for (const sceneId of openingSceneIds) { - const directScene = resolveCustomWorldScenePresetByConfiguredId( - profile, - sceneId, - ); - if (directScene) { - return directScene; - } - } - - const fallbackLandmarkIndex = profile.landmarks.findIndex( - (landmark) => landmark.sceneNpcIds.length > 0, - ); - if (fallbackLandmarkIndex >= 0) { - return getScenePresetById( - WorldType.CUSTOM, - `custom-scene-landmark-${fallbackLandmarkIndex + 1}`, - ); - } - - const firstLandmarkId = profile.landmarks[0]?.id?.trim() ?? ''; - if (firstLandmarkId) { - const firstLandmarkScene = getScenePresetById( - WorldType.CUSTOM, - 'custom-scene-landmark-1', - ); - if (firstLandmarkScene) { - return firstLandmarkScene; - } - } - - return profile.landmarks.length > 0 - ? getScenePresetById(WorldType.CUSTOM, 'custom-scene-landmark-1') - : null; -} - -function resolveOpeningSceneActBlueprint( - profile: CustomWorldProfile | null, -): { chapter: SceneChapterBlueprint; act: SceneActBlueprint } | null { - const openingChapter = profile?.sceneChapterBlueprints?.[0] ?? null; - const openingAct = openingChapter?.acts[0] ?? null; - return openingChapter && openingAct - ? { chapter: openingChapter, act: openingAct } - : null; -} - -function resolveCustomWorldScenePresetByConfiguredId( - profile: CustomWorldProfile, - sceneId: string | null | undefined, -): NonNullable | null { - const normalizedSceneId = sceneId?.trim() ?? ''; - if (!normalizedSceneId) { - return null; - } - - const directScene = getScenePresetById(WorldType.CUSTOM, normalizedSceneId); - if (directScene) { - return directScene; - } - - const campId = profile.camp?.id?.trim() ?? ''; - if ( - normalizedSceneId === campId || - normalizedSceneId === 'custom-scene-camp' - ) { - return getScenePresetById(WorldType.CUSTOM, 'custom-scene-camp'); - } - - const landmarkIndex = profile.landmarks.findIndex( - (landmark) => landmark.id === normalizedSceneId, - ); - if (landmarkIndex < 0) { - return null; - } - - return getScenePresetById( - WorldType.CUSTOM, - `custom-scene-landmark-${landmarkIndex + 1}`, - ); -} - -function resolveOpeningActNpcIdPriority( - profile: CustomWorldProfile, - openingAct: SceneActBlueprint, -) { - return resolveCustomWorldRoleIdReferences(profile, [ - openingAct.oppositeNpcId, - openingAct.primaryNpcId, - ...openingAct.encounterNpcIds, - ]); -} - -function doRoleReferencesMatch( - profile: CustomWorldProfile | null, - left: string | null | undefined, - right: string | null | undefined, -) { - const normalizedLeft = resolveCustomWorldRoleIdReference(profile, left); - const normalizedRight = resolveCustomWorldRoleIdReference(profile, right); - return Boolean(normalizedLeft && normalizedLeft === normalizedRight); -} - -function findSceneNpcByRuntimeRoleId( - scenePreset: GameState['currentScenePreset'], - profile: CustomWorldProfile | null, - roleId: string, -) { - return ( - scenePreset?.npcs?.find( - (npc) => - doRoleReferencesMatch(profile, npc.id, roleId) || - doRoleReferencesMatch(profile, npc.characterId, roleId) || - doRoleReferencesMatch(profile, npc.name, roleId) || - doRoleReferencesMatch(profile, npc.title, roleId), - ) ?? null - ); -} - -function buildOpeningEncounterFromCustomWorldRole( - profile: CustomWorldProfile, - roleId: string, -): Encounter | null { - const role = - findCustomWorldRoleByReference(profile, roleId); - if (!role) { - return null; - } - - const isHostile = role.initialAffinity < 0; - return { - id: role.id, - kind: 'npc', - characterId: role.id, - npcName: role.name, - npcDescription: role.description, - npcAvatar: role.imageSrc ?? role.name.slice(0, 1) ?? '?', - context: role.role, - xMeters: RESOLVED_ENTITY_X_METERS, - initialAffinity: role.initialAffinity, - hostile: isHostile, - title: role.title, - backstory: role.backstory, - personality: role.personality, - motivation: role.motivation, - combatStyle: role.combatStyle, - relationshipHooks: [...role.relationshipHooks], - tags: [...role.tags], - backstoryReveal: role.backstoryReveal, - skills: role.skills.map((skill) => ({ ...skill })), - initialItems: role.initialItems.map((item) => ({ - ...item, - tags: [...item.tags], - })), - imageSrc: role.imageSrc, - visual: (role as { visual?: Encounter['visual'] }).visual, - narrativeProfile: role.narrativeProfile, - attributeProfile: role.attributeProfile, - }; -} - -function resolveOpeningActEncounter(params: { - profile: CustomWorldProfile | null; - scenePreset: GameState['currentScenePreset']; - playerCharacter: Character; -}) { - const opening = resolveOpeningSceneActBlueprint(params.profile); - if (!opening || !params.profile) { - return null; - } - - for (const npcId of resolveOpeningActNpcIdPriority(params.profile, opening.act)) { - if ( - doRoleReferencesMatch( - params.profile, - npcId, - params.playerCharacter.id, - ) || - doRoleReferencesMatch( - params.profile, - npcId, - params.playerCharacter.name, - ) - ) { - continue; - } - - const sceneNpc = findSceneNpcByRuntimeRoleId( - params.scenePreset, - params.profile, - npcId, - ); - if (sceneNpc && sceneNpc.characterId !== params.playerCharacter.id) { - return { - ...buildEncounterFromSceneNpc(sceneNpc, RESOLVED_ENTITY_X_METERS), - xMeters: RESOLVED_ENTITY_X_METERS, - }; - } - - const roleEncounter = buildOpeningEncounterFromCustomWorldRole( - params.profile, - npcId, - ); - if (roleEncounter) { - return roleEncounter; - } - } - - return null; -} - -function buildOpeningStoryEngineMemory( - profile: CustomWorldProfile | null, - sceneId: string | null | undefined, -) { - const storyEngineMemory = createEmptyStoryEngineMemoryState(); - - return { - ...storyEngineMemory, - currentSceneActState: - buildInitialSceneActRuntimeState({ - profile, - sceneId, - storyEngineMemory, - }) ?? storyEngineMemory.currentSceneActState ?? null, - }; -} - /** * RPG session bootstrap 主实现。 * 工作包 C 起由新域 hook 承载世界选择、选角确认与新开局初始化。 */ export function useRpgSessionBootstrap() { const [gameState, setGameState] = useState(() => - createInitialGameState(), + createSelectionGameState(), ); const [bottomTab, setBottomTab] = useState('adventure'); const [isMapOpen, setIsMapOpen] = useState(false); useEffect(() => { + // 中文注释:当前运行中的自定义世界 profile 需要同步给静态数据层, + // 这样角色预设、场景预设、运行时引用解析才能读取到同一份世界真相。 setRuntimeCustomWorldProfile(gameState.customWorldProfile); setRuntimeCharacterOverrides( gameState.customWorldProfile @@ -486,9 +98,11 @@ export function useRpgSessionBootstrap() { }, [gameState.customWorldProfile]); const resetGame = () => { + // 中文注释:reset 不只清 GameState,还要把底部 tab 和地图弹层一起还原, + // 避免返回入口后 UI 仍停留在上一次冒险的局部状态。 setBottomTab('adventure'); setIsMapOpen(false); - setGameState(createInitialGameState()); + setGameState(createSelectionGameState()); }; const handleCustomWorldSelect = ( @@ -505,11 +119,12 @@ export function useRpgSessionBootstrap() { ); const initialScenePreset = getScenePreset(resolvedWorldType, 0) ?? null; setIsMapOpen(false); - setGameState((prev) => - ensureSceneEncounterPreview({ - ...prev, - worldType: resolvedWorldType, - customWorldProfile, + setGameState((prev) => ({ + // 中文注释:世界刚选中时只进入“已装入世界,但尚未选角”的中间态; + // 正式开局 GameState 必须等待角色确认后由 server-rs 统一生成。 + ...prev, + worldType: resolvedWorldType, + customWorldProfile, runtimeMode, runtimePersistenceDisabled, currentScenePreset: initialScenePreset, @@ -532,153 +147,38 @@ export function useRpgSessionBootstrap() { currentNpcBattleMode: null, currentNpcBattleOutcome: null, sparReturnEncounter: null, - sparPlayerHpBefore: null, - sparPlayerMaxHpBefore: null, - sparStoryHistoryBefore: null, - }), - ); + sparPlayerHpBefore: null, + sparPlayerMaxHpBefore: null, + sparStoryHistoryBefore: null, + })); }; const handleBackToWorldSelect = () => { setBottomTab('adventure'); setIsMapOpen(false); - setGameState(createInitialGameState()); + setGameState(createSelectionGameState()); }; const handleCharacterSelect = (character: Character) => { setBottomTab('adventure'); setIsMapOpen(false); - setGameState((prev) => { - const resolvedWorldType = prev.worldType; - const resolvedCustomWorldProfile = prev.customWorldProfile; - const initialScenePreset = - resolvedWorldType === WorldType.CUSTOM - ? (resolveOpeningActScenePreset(resolvedCustomWorldProfile) ?? - getWorldCampScenePreset(resolvedWorldType) ?? - getScenePreset(resolvedWorldType, 0)) - : resolvedWorldType - ? (getWorldCampScenePreset(resolvedWorldType) ?? - getScenePreset(resolvedWorldType, 0)) - : null; - const initialEncounter = - resolvedWorldType === WorldType.CUSTOM - ? resolveOpeningActEncounter({ - profile: resolvedCustomWorldProfile, - scenePreset: initialScenePreset, - playerCharacter: character, - }) - : createInitialCampEncounter(resolvedWorldType, character); - const initialNpcState = initialEncounter - ? buildInitialNpcState(initialEncounter, resolvedWorldType, prev) - : null; - const initialEquipment = buildInitialEquipmentLoadout( - character, - resolvedCustomWorldProfile, - ); - const explicitStarterItems = - resolvedWorldType === WorldType.CUSTOM - ? buildExplicitCustomWorldRoleStarterState( - resolvedCustomWorldProfile!, - character, - ) - : null; - const mergedStarterEquipment = { - weapon: - explicitStarterItems?.equipment.weapon ?? initialEquipment.weapon, - armor: explicitStarterItems?.equipment.armor ?? initialEquipment.armor, - relic: explicitStarterItems?.equipment.relic ?? initialEquipment.relic, - }; - const playerMaxHp = getCharacterMaxHp( - character, - resolvedWorldType, - resolvedCustomWorldProfile, - ); + const launchState = gameState; + const resolvedWorldType = launchState.worldType; + if (!resolvedWorldType) { + return; + } - const openingState = applyEquipmentLoadoutToState( - { - ...prev, - playerCharacter: character, - runtimeMode: - resolvedWorldType === WorldType.CUSTOM - ? (prev.runtimeMode ?? 'play') - : (prev.runtimeMode ?? 'play'), - runtimePersistenceDisabled: - resolvedWorldType === WorldType.CUSTOM - ? prev.runtimePersistenceDisabled === true - : prev.runtimePersistenceDisabled, - runtimeStats: createInitialGameRuntimeStats({ isActiveRun: true }), - playerProgression: createInitialPlayerProgressionState(), - currentScene: 'Story', - storyHistory: [], - storyEngineMemory: - resolvedWorldType === WorldType.CUSTOM - ? buildOpeningStoryEngineMemory( - resolvedCustomWorldProfile, - initialScenePreset?.id, - ) - : createEmptyStoryEngineMemoryState(), - chapterState: null, - campaignState: null, - activeScenarioPackId: prev.customWorldProfile?.scenarioPackId ?? null, - activeCampaignPackId: prev.customWorldProfile?.campaignPackId ?? null, - characterChats: {}, - currentEncounter: initialEncounter, - npcInteractionActive: false, - currentScenePreset: initialScenePreset, - lastObserveSignsSceneId: null, - lastObserveSignsReport: null, - animationState: AnimationState.IDLE, - sceneHostileNpcs: [], - playerX: 0, - playerOffsetY: 0, - playerFacing: 'right', - playerActionMode: 'idle', - scrollWorld: false, - inBattle: false, - playerHp: playerMaxHp, - playerMaxHp: playerMaxHp, - playerMana: getCharacterMaxMana(character), - playerMaxMana: getCharacterMaxMana(character), - playerSkillCooldowns: createCharacterSkillCooldowns(character), - activeBuildBuffs: [], - activeCombatEffects: [], - playerCurrency: getInitialPlayerCurrency( - resolvedWorldType, - resolvedCustomWorldProfile, - ), - playerInventory: mergeStarterInventoryItems( - explicitStarterItems?.inventory ?? [], - buildInitialPlayerInventory( - character, - resolvedWorldType, - resolvedCustomWorldProfile, - ), - ), - playerEquipment: createEmptyEquipmentLoadout(), - npcStates: - initialEncounter && initialNpcState - ? { - [initialEncounter.id!]: initialNpcState, - } - : {}, - quests: [], - roster: [], - companions: [], - currentBattleNpcId: null, - currentNpcBattleMode: null, - currentNpcBattleOutcome: null, - sparReturnEncounter: null, - sparPlayerHpBefore: null, - sparPlayerMaxHpBefore: null, - sparStoryHistoryBefore: null, - }, - mergedStarterEquipment, - ); - - return resolvedWorldType === WorldType.CUSTOM - ? openingState - : ensureSceneEncounterPreview(openingState); + void beginRpgRuntimeStorySession({ + worldType: resolvedWorldType, + customWorldProfile: launchState.customWorldProfile, + character, + runtimeMode: launchState.runtimeMode ?? 'play', + disablePersistence: launchState.runtimePersistenceDisabled === true, + }).then((response) => { + // 中文注释:开局正式 GameState 由 server-rs 生成并持久化; + // 前端只接收后端快照,避免浏览器继续承担初始背包、装备、遭遇和 NPC 状态裁决。 + setGameState(response.snapshot.gameState); }); }; @@ -699,3 +199,4 @@ export function useRpgSessionBootstrap() { export type RpgSessionBootstrapResult = ReturnType< typeof useRpgSessionBootstrap >; + diff --git a/src/hooks/rpg-session/useRpgSessionPersistence.ts b/src/hooks/rpg-session/useRpgSessionPersistence.ts index 00ccfe2b..a72159f6 100644 --- a/src/hooks/rpg-session/useRpgSessionPersistence.ts +++ b/src/hooks/rpg-session/useRpgSessionPersistence.ts @@ -2,7 +2,10 @@ import type { HydratedSavedGameSnapshot } from '../../persistence/runtimeSnapshotTypes'; import { isAbortError } from '../../services/apiClient'; -import { rpgSnapshotClient } from '../../services/rpg-runtime'; +import { + getRpgRuntimeSessionId, + rpgSnapshotClient, +} from '../../services/rpg-runtime'; import type { GameState, StoryMoment } from '../../types'; import { resumeServerRuntimeStory } from '../rpg-runtime-story/runtimeStoryCoordinator'; import type { BottomTab } from './rpgSessionTypes'; @@ -10,6 +13,8 @@ import type { BottomTab } from './rpgSessionTypes'; const AUTO_SAVE_DELAY_MS = 400; function canPersistSnapshot(gameState: GameState, story: StoryMoment | null) { + // 中文注释:preview / test 模式、非 Story 场景、未选角、以及流式输出中的故事都不应入正式存档, + // 否则容易把临时态或半成品叙事写进继续游戏链路。 return ( gameState.runtimePersistenceDisabled !== true && gameState.runtimeMode !== 'preview' && @@ -30,6 +35,8 @@ function normalizeBottomTab(bottomTab: string | null | undefined): BottomTab { } function resolveRemoteSnapshotState(snapshot: HydratedSavedGameSnapshot) { + // 中文注释:远端快照允许缺少局部 UI 状态; + // 这里统一补底部 tab 的兜底值,避免恢复后落到非法面板名。 return { gameState: snapshot.gameState, currentStory: snapshot.currentStory ?? null, @@ -75,6 +82,8 @@ export function useRpgSessionPersistence({ const saveRequestIdRef = useRef(0); const abortActiveSave = useCallback(() => { + // 中文注释:自动存档是“后写覆盖前写”的串行语义; + // 新一次保存开始前,主动打断旧请求,避免旧快照回写覆盖最新状态。 saveControllerRef.current?.abort(); saveControllerRef.current = null; setIsPersistingSnapshot(false); @@ -83,9 +92,8 @@ export function useRpgSessionPersistence({ const persistSnapshot = useCallback( async (params: { payload: { - gameState: GameState; + sessionId: string; bottomTab: BottomTab; - currentStory: StoryMoment | null; }; logLabel: string; }) => { @@ -103,11 +111,12 @@ export function useRpgSessionPersistence({ setPersistenceError(null); try { + // 中文注释:这里不再上传整份本地快照; + // 前端只告诉后端“当前 session 需要 checkpoint”,真实 GameState 由服务端快照表读取。 const snapshot = await rpgSnapshotClient.putSnapshot( { - gameState: params.payload.gameState, + sessionId: params.payload.sessionId, bottomTab: params.payload.bottomTab, - currentStory: params.payload.currentStory, }, { signal: controller.signal }, ); @@ -158,6 +167,8 @@ export function useRpgSessionPersistence({ hydrateControllerRef.current = controller; setIsHydratingSnapshot(true); + // 中文注释:登录后第一时间探测一次远端快照, + // 让入口页能够准确判断“继续游戏”按钮是否可见。 void rpgSnapshotClient .getSnapshot({ signal: controller.signal }) .then((snapshot) => { @@ -207,12 +218,13 @@ export function useRpgSessionPersistence({ if (!canPersist) return; + // 中文注释:自动存档做一个很短的去抖, + // 避免同一轮状态连锁更新时重复打多次快照请求。 const timeoutId = window.setTimeout(() => { void persistSnapshot({ payload: { - gameState, + sessionId: getRpgRuntimeSessionId(gameState), bottomTab, - currentStory, }, logLabel: 'failed to autosave remote snapshot', }); @@ -235,11 +247,12 @@ export function useRpgSessionPersistence({ return false; } + // 中文注释:手动存档和自动存档走同一套底层 persist 逻辑, + // 差别只在于调用方可显式覆盖本次 checkpoint 的 session 与 UI tab。 const snapshot = await persistSnapshot({ payload: { - gameState: nextGameState, + sessionId: getRpgRuntimeSessionId(nextGameState), bottomTab: nextBottomTab, - currentStory: nextStory, }, logLabel: 'failed to save remote snapshot', }); @@ -300,6 +313,8 @@ export function useRpgSessionPersistence({ resetStoryState(); const fallbackHydration = resolveRemoteSnapshotState(snapshot); + // 中文注释:继续游戏不是简单把旧 currentStory 塞回去, + // 还要向服务端刷新一遍 runtime story,拿到当前服务端判定的可选动作与视图模型。 const resumedState = await resumeServerRuntimeStory(snapshot).catch( (error) => { if (!isAbortError(error)) { diff --git a/src/hooks/runtimeAuthGuards.test.tsx b/src/hooks/runtimeAuthGuards.test.tsx index 42100d03..62b89f4c 100644 --- a/src/hooks/runtimeAuthGuards.test.tsx +++ b/src/hooks/runtimeAuthGuards.test.tsx @@ -23,6 +23,8 @@ vi.mock('../services/rpg-entry', () => ({ })); vi.mock('../services/rpg-runtime', () => ({ + getRpgRuntimeSessionId: (gameState: Pick) => + gameState.runtimeSessionId?.trim() || 'runtime-main', rpgSnapshotClient: { getSnapshot: storageMocks.getSaveSnapshot, putSnapshot: storageMocks.putSaveSnapshot, @@ -30,7 +32,11 @@ vi.mock('../services/rpg-runtime', () => ({ }, })); -function SettingsHarness({ authenticatedUserId }: { authenticatedUserId: string | null }) { +function SettingsHarness({ + authenticatedUserId, +}: { + authenticatedUserId: string | null; +}) { const settings = useGameSettings(authenticatedUserId); return ( @@ -50,14 +56,18 @@ function SettingsHarness({ authenticatedUserId }: { authenticatedUserId: string function PersistenceHarness({ authenticatedUserId, + gameState = {} as GameState, + currentStory = null as StoryMoment | null, }: { authenticatedUserId: string | null; + gameState?: GameState; + currentStory?: StoryMoment | null; }) { const persistence = useRpgSessionPersistence({ authenticatedUserId, - gameState: {} as GameState, + gameState, bottomTab: 'adventure' as BottomTab, - currentStory: null as StoryMoment | null, + currentStory, isLoading: false, setGameState: () => {}, setBottomTab: () => {}, @@ -67,7 +77,9 @@ function PersistenceHarness({ return (
-
{persistence.hasSavedGame ? 'yes' : 'no'}
+
+ {persistence.hasSavedGame ? 'yes' : 'no'} +
{persistence.isHydratingSnapshot ? 'yes' : 'no'}
@@ -161,3 +173,64 @@ test('unauthenticated runtime skips remote snapshot hydration', async () => { expect(screen.getByTestId('saved-game').textContent).toBe('no'); expect(storageMocks.getSaveSnapshot).not.toHaveBeenCalled(); }); + +test('runtime autosave requests backend checkpoint without uploading local state', async () => { + vi.useFakeTimers(); + storageMocks.getSaveSnapshot.mockResolvedValue(null); + storageMocks.putSaveSnapshot.mockResolvedValue({ + version: 2, + savedAt: '2026-04-28T10:00:00.000Z', + bottomTab: 'adventure', + currentStory: null, + gameState: { + runtimeSessionId: 'runtime-main', + currentScene: 'Story', + }, + }); + const gameState = { + runtimeSessionId: 'runtime-main', + runtimePersistenceDisabled: false, + runtimeMode: 'play', + currentScene: 'Story', + worldType: 'CUSTOM', + playerCharacter: { id: 'hero_001' }, + runtimeStats: { + playTimeMs: 0, + lastPlayTickAt: null, + hostileNpcsDefeated: 0, + questsAccepted: 0, + itemsUsed: 0, + scenesTraveled: 0, + }, + } as unknown as GameState; + const story = { text: '开场', options: [], streaming: false } as StoryMoment; + + render( + , + ); + + await act(async () => { + await Promise.resolve(); + }); + expect(storageMocks.getSaveSnapshot).toHaveBeenCalledTimes(1); + + await act(async () => { + vi.advanceTimersByTime(400); + await Promise.resolve(); + await Promise.resolve(); + }); + + expect(storageMocks.putSaveSnapshot).toHaveBeenCalledWith( + { + sessionId: 'runtime-main', + bottomTab: 'adventure', + }, + expect.objectContaining({ + signal: expect.any(AbortSignal), + }), + ); +}); diff --git a/src/persistence/gameSaveStorage.ts b/src/persistence/gameSaveStorage.ts index a175bdb0..047d4f89 100644 --- a/src/persistence/gameSaveStorage.ts +++ b/src/persistence/gameSaveStorage.ts @@ -1,9 +1,10 @@ import { + type RuntimeSaveCheckpointInput as SharedRuntimeSaveCheckpointInput, type SavedGameSnapshot as SharedSavedGameSnapshot, type SavedGameSnapshotInput as SharedSavedGameSnapshotInput, } from '../../packages/shared/src/contracts/runtime'; -import type {GameState, StoryMoment} from '../types'; -import type {BottomTab} from '../types/navigation'; +import type { GameState, StoryMoment } from '../types'; +import type { BottomTab } from '../types/navigation'; export type SavedGameSnapshot = SharedSavedGameSnapshot< GameState, @@ -16,3 +17,6 @@ export type SavedGameSnapshotInput = SharedSavedGameSnapshotInput< BottomTab, StoryMoment >; + +export type RuntimeSaveCheckpointInput = + SharedRuntimeSaveCheckpointInput; diff --git a/src/prompts/characterChatPrompts.ts b/src/prompts/characterChatPrompts.ts deleted file mode 100644 index db6239c4..00000000 --- a/src/prompts/characterChatPrompts.ts +++ /dev/null @@ -1,333 +0,0 @@ -import { - buildSchemaSummary, - describeTopAttributes, - formatAttributeList, - resolveAttributeSchema, - resolveCharacterAttributeProfile, -} from '../data/attributeResolver'; -import { - buildCharacterBackstoryPromptContext, - getCharacterPublicBackstorySummary, - getLockedCharacterBackstoryChapters, -} from '../data/characterPresets'; -import { - AnimationState, - Character, - CharacterChatTurn, - CustomWorldProfile, - FacingDirection, - StoryMoment, - WorldType, -} from '../types'; -import { buildCustomWorldReferenceText } from '../services/customWorld'; -import { buildStoryPromptHistory } from '../services/storyHistory'; - -export interface CharacterChatTargetStatus { - roleLabel?: string | null; - hp: number; - maxHp: number; - mana: number; - maxMana: number; - affinity?: number | null; -} - -export interface CharacterChatPromptContext { - playerHp: number; - playerMaxHp: number; - playerMana: number; - playerMaxMana: number; - inBattle: boolean; - playerFacing: FacingDirection; - playerAnimation: AnimationState; - sceneName?: string | null; - sceneDescription?: string | null; - customWorldProfile?: CustomWorldProfile | null; -} - -export const CHARACTER_PANEL_CHAT_SYSTEM_PROMPT = `你是像素动作 RPG 里的同行角色。 -只回复这名角色此刻会对玩家说的话。 -不要输出角色名、引号、旁白、动作提示、Markdown、JSON 或解释。 -保持人设,结合最近剧情和关系变化,回复简洁自然。`; - -export const CHARACTER_PANEL_CHAT_SUGGESTION_SYSTEM_PROMPT = `生成恰好 3 条玩家回复建议。 -只输出纯文本,共 3 行,每行一条。 -不要加编号、项目符号、Markdown 或额外说明。 -三条建议语气要有区分:关心、追问、轻松或拉近关系。`; - -export const CHARACTER_PANEL_CHAT_SUMMARY_SYSTEM_PROMPT = `总结玩家与这名角色之间不断变化的关系。 -只输出一段简洁文字。 -包含当前关系气氛、态度变化,以及最近聊天里最重要的新信息、承诺、担忧或线索。`; - -function describeWorld(world: WorldType) { - if (world === WorldType.WUXIA) return '边城模板'; - if (world === WorldType.XIANXIA) return '灵潮模板'; - return '自定义世界'; -} - -function describeCustomWorldSection(customWorldProfile?: CustomWorldProfile | null) { - return customWorldProfile - ? `自定义世界参考:\n${buildCustomWorldReferenceText(customWorldProfile)}` - : null; -} - -function describeGender(gender: Character['gender']) { - if (gender === 'female') return '女'; - if (gender === 'male') return '男'; - return '未知'; -} - -function describeFacing(facing: FacingDirection) { - return facing === 'left' ? '左' : '右'; -} - -function describeHpBand(ratio: number) { - if (ratio >= 0.95) return '几乎无伤'; - if (ratio >= 0.75) return '状态稳健'; - if (ratio >= 0.55) return '略有消耗'; - if (ratio >= 0.35) return '伤势明显'; - if (ratio >= 0.15) return '伤势沉重'; - return '濒临极限'; -} - -function describeManaBand(ratio: number) { - if (ratio >= 0.9) return '充盈'; - if (ratio >= 0.7) return '稳定'; - if (ratio >= 0.45) return '尚可'; - if (ratio >= 0.2) return '偏低'; - if (ratio > 0) return '接近枯竭'; - return '耗尽'; -} - -function describeStoryHistory(history: StoryMoment[]) { - const promptHistory = buildStoryPromptHistory(history); - - if (!promptHistory.previousSummary && promptHistory.recentOriginalRounds.length === 0) { - return '近期剧情:暂无。'; - } - - return [ - promptHistory.previousSummary - ? `更早剧情摘要:\n${promptHistory.previousSummary}` - : '更早剧情摘要:暂无。', - promptHistory.recentOriginalRounds.length > 0 - ? `最近 3 轮剧情:\n${promptHistory.recentOriginalRounds - .map((item, index) => `- 第 ${index + 1} 轮:\n${item}`) - .join('\n')}` - : '最近 3 轮剧情:暂无。', - ].join('\n'); -} - -function describeBackstoryContext(label: string, snippets: string[]) { - const normalized = snippets - .map(snippet => snippet.trim()) - .filter(Boolean); - - if (normalized.length === 0) { - return [`${label}:暂无公开信息。`]; - } - - return normalized.map((snippet, index) => - `${label}${index === 0 ? '(公开层)' : `(已解锁片段 ${index})`}:${snippet}`, - ); -} - -function describeCharacterInfo( - label: string, - character: Character, - world: WorldType, - customWorldProfile?: CustomWorldProfile | null, - options: { - affinity?: number | null; - includeUnlockProgress?: boolean; - } = {}, -) { - const schema = resolveAttributeSchema(world, customWorldProfile); - const attributeProfile = resolveCharacterAttributeProfile(character, world, customWorldProfile); - const skills = character.skills.length > 0 - ? character.skills - .map( - skill => `${skill.name}(伤害 ${skill.damage}/灵力 ${skill.manaCost}/冷却 ${skill.cooldownTurns})`, - ) - .join(' | ') - : '无'; - const backgroundLines = options.affinity == null - ? [getCharacterPublicBackstorySummary(character, world)] - : buildCharacterBackstoryPromptContext(character, options.affinity, world); - const nextLockedChapter = options.includeUnlockProgress && options.affinity != null - ? getLockedCharacterBackstoryChapters(character, options.affinity, world)[0] ?? null - : null; - const schemaSummary = buildSchemaSummary(schema) - .map(slot => `${slot.name}(${slot.definition})`) - .join(' | '); - const topAttributes = describeTopAttributes(attributeProfile, schema).join('、') || '无'; - const attributeDetails = formatAttributeList(attributeProfile, schema) - .map(entry => `${entry.slot.name} ${entry.value}`) - .join(' | '); - - return [ - `${label}姓名:${character.name}`, - `${label}称号:${character.title}`, - `${label}性别:${describeGender(character.gender ?? 'unknown')}`, - `${label}描述:${character.description}`, - ...describeBackstoryContext(`${label}背景`, backgroundLines), - nextLockedChapter - ? `${label}未解锁背景:${nextLockedChapter.title}(需好感 ${nextLockedChapter.affinityRequired},当前只知道:${nextLockedChapter.teaser})` - : null, - `${label}性格:${character.personality}`, - `${label}世界属性框架:${schemaSummary}`, - `${label}主要属性:${topAttributes}`, - `${label}属性详情:${attributeDetails}`, - `${label}技能:${skills}`, - ].join('\n'); -} - -function describeChatContext(world: WorldType, context: CharacterChatPromptContext) { - const hpRatio = context.playerHp / Math.max(context.playerMaxHp, 1); - const manaRatio = context.playerMana / Math.max(context.playerMaxMana, 1); - - return [ - `世界:${describeWorld(world)}`, - `玩家战斗状态:${context.inBattle ? '战斗中' : '非战斗'}`, - `场景:${context.sceneName ?? '当前区域'}`, - `场景描述:${context.sceneDescription ?? '周围气氛仍未安定。'}`, - `玩家状态:生命 ${context.playerHp}/${context.playerMaxHp}(${describeHpBand(hpRatio)}),灵力 ${context.playerMana}/${context.playerMaxMana}(${describeManaBand(manaRatio)}),朝向 ${describeFacing(context.playerFacing)},动作 ${context.playerAnimation}`, - ].join('\n'); -} - -function describeTargetStatus(status: CharacterChatTargetStatus) { - const hpRatio = status.hp / Math.max(status.maxHp, 1); - const manaRatio = status.mana / Math.max(status.maxMana, 1); - - return [ - `对方身份:${status.roleLabel ?? '同行角色'}`, - `对方状态:生命 ${status.hp}/${status.maxHp}(${describeHpBand(hpRatio)}),灵力 ${status.mana}/${status.maxMana}(${describeManaBand(manaRatio)})`, - status.affinity != null ? `当前好感:${status.affinity}` : null, - ].filter(Boolean).join('\n'); -} - -function describeCharacterChatHistory(history: CharacterChatTurn[]) { - if (history.length === 0) { - return '聊天记录:暂无。'; - } - - return [ - '聊天记录:', - ...history.slice(-12).map(turn => `- ${turn.speaker === 'player' ? '玩家' : '角色'}:${turn.text}`), - ].join('\n'); -} - -export function buildCharacterPanelChatPrompt({ - world, - playerCharacter, - targetCharacter, - storyHistory, - context, - conversationHistory, - conversationSummary, - playerMessage, - targetStatus, -}: { - world: WorldType; - playerCharacter: Character; - targetCharacter: Character; - storyHistory: StoryMoment[]; - context: CharacterChatPromptContext; - conversationHistory: CharacterChatTurn[]; - conversationSummary: string; - playerMessage: string; - targetStatus: CharacterChatTargetStatus; -}) { - return [ - `世界:${describeWorld(world)}`, - describeChatContext(world, context), - describeCustomWorldSection(context.customWorldProfile), - describeCharacterInfo('玩家 / ', playerCharacter, world, context.customWorldProfile), - describeCharacterInfo('对方 / ', targetCharacter, world, context.customWorldProfile, { - affinity: targetStatus.affinity ?? null, - includeUnlockProgress: true, - }), - describeTargetStatus(targetStatus), - describeStoryHistory(storyHistory), - conversationSummary ? `之前聊天摘要:${conversationSummary}` : '之前聊天摘要:暂无。', - describeCharacterChatHistory(conversationHistory), - `玩家刚刚对 ${targetCharacter.name} 说:${playerMessage}`, - `现在请以 ${targetCharacter.name} 的身份,直接回复玩家。`, - ].filter(Boolean).join('\n\n'); -} - -export function buildCharacterPanelChatSuggestionPrompt({ - world, - playerCharacter, - targetCharacter, - storyHistory, - context, - conversationHistory, - conversationSummary, - targetStatus, -}: { - world: WorldType; - playerCharacter: Character; - targetCharacter: Character; - storyHistory: StoryMoment[]; - context: CharacterChatPromptContext; - conversationHistory: CharacterChatTurn[]; - conversationSummary: string; - targetStatus: CharacterChatTargetStatus; -}) { - const latestCharacterReply = [...conversationHistory] - .reverse() - .find(turn => turn.speaker === 'character')?.text ?? null; - - return [ - `世界:${describeWorld(world)}`, - describeChatContext(world, context), - describeCharacterInfo('玩家 / ', playerCharacter, world, context.customWorldProfile), - describeCharacterInfo('对方 / ', targetCharacter, world, context.customWorldProfile, { - affinity: targetStatus.affinity ?? null, - includeUnlockProgress: true, - }), - describeTargetStatus(targetStatus), - describeStoryHistory(storyHistory), - conversationSummary ? `之前聊天摘要:${conversationSummary}` : '之前聊天摘要:暂无。', - describeCharacterChatHistory(conversationHistory), - latestCharacterReply - ? `角色刚刚的回复:${latestCharacterReply}` - : `玩家正准备与 ${targetCharacter.name} 开始一段新的私聊。`, - '生成 3 条可以直接发送的简短玩家回复候选。', - ].filter(Boolean).join('\n\n'); -} - -export function buildCharacterPanelChatSummaryPrompt({ - world, - playerCharacter, - targetCharacter, - storyHistory, - context, - conversationHistory, - previousSummary, - targetStatus, -}: { - world: WorldType; - playerCharacter: Character; - targetCharacter: Character; - storyHistory: StoryMoment[]; - context: CharacterChatPromptContext; - conversationHistory: CharacterChatTurn[]; - previousSummary: string; - targetStatus: CharacterChatTargetStatus; -}) { - return [ - `世界:${describeWorld(world)}`, - describeChatContext(world, context), - describeCharacterInfo('玩家 / ', playerCharacter, world, context.customWorldProfile), - describeCharacterInfo('对方 / ', targetCharacter, world, context.customWorldProfile, { - affinity: targetStatus.affinity ?? null, - includeUnlockProgress: true, - }), - describeTargetStatus(targetStatus), - describeStoryHistory(storyHistory), - previousSummary ? `旧摘要:${previousSummary}` : '旧摘要:暂无。', - describeCharacterChatHistory(conversationHistory), - '请把旧摘要与最新聊天合并成一段更新后的关系摘要,供后续剧情推理使用。', - ].filter(Boolean).join('\n\n'); -} diff --git a/src/prompts/customWorldRolePromptDefaults.ts b/src/prompts/customWorldRolePromptDefaults.ts deleted file mode 100644 index 304322bf..00000000 --- a/src/prompts/customWorldRolePromptDefaults.ts +++ /dev/null @@ -1,94 +0,0 @@ -/** - * 自定义世界角色资产工坊的“默认描述文本种子”主源。 - * - * 这份脚本只负责一件事: - * - 从当前角色对象已有字段里挑出最合适的文本, - * 作为资产工坊输入框的初始默认值 - * - * 它不负责: - * - 直接调用 LLM 重新编译默认描述 - * - 直接生成图像模型 prompt - * - 直接生成动作模型 prompt - * - * 当前真实调用状态: - * - CustomWorldRoleAssetStudioModal 的初始默认值主链,来自本文件 - * - 也就是说,资产工坊页面打开时看到的“形象描述 / 动作描述” - * 当前直接取这里的本地字段映射 - */ -export type PromptDefaultRole = { - name: string; - title: string; - role: string; - visualDescription?: string; - actionDescription?: string; - sceneVisualDescription?: string; - description?: string; - backstory?: string; - personality?: string; - motivation?: string; - combatStyle?: string; - tags?: string[]; -}; - -export type CustomWorldRolePromptBundle = { - visualPromptText: string; - animationPromptText: string; - scenePromptText: string; -}; - -/** - * 对角色字段做轻量清洗,确保作为输入框默认值时不会带多余空白。 - */ -function cleanSeedText(value: string | undefined, maxLength: number) { - return (value ?? '').replace(/\s+/gu, ' ').trim().slice(0, maxLength); -} - -/** - * 按优先级选择第一条可用文本。 - * - * 这里是非常轻量的本地回退逻辑,不做任何“重新创作”或 prompt 扩写。 - */ -function pickFirstDescription( - values: Array, - maxLength: number, -) { - for (const value of values) { - const normalized = cleanSeedText(value, maxLength); - if (normalized) { - return normalized; - } - } - - return ''; -} - -/** - * 资产工坊默认文本映射规则。 - * - * 规则分层: - * - visualPromptText: 优先使用角色 visualDescription,其次 description - * - animationPromptText: 优先使用 actionDescription,其次 combatStyle - * - scenePromptText: 优先使用 sceneVisualDescription,其次 backstory - * - * 注意: - * - 返回值只是“输入框默认文案” - * - 正式图像 / 动作模型 prompt 还会在后端继续编译 - */ -export function buildDefaultRolePromptBundle( - role: PromptDefaultRole, -): CustomWorldRolePromptBundle { - return { - visualPromptText: pickFirstDescription( - [role.visualDescription, role.description], - 220, - ), - animationPromptText: pickFirstDescription( - [role.actionDescription, role.combatStyle], - 180, - ), - scenePromptText: pickFirstDescription( - [role.sceneVisualDescription, role.backstory], - 220, - ), - }; -} diff --git a/src/prompts/storyPromptBuilders.ts b/src/prompts/storyPromptBuilders.ts deleted file mode 100644 index 58663d60..00000000 --- a/src/prompts/storyPromptBuilders.ts +++ /dev/null @@ -1,1884 +0,0 @@ -import { buildRoleAttributeProfileFromLegacyData } from '../data/attributeProfileGenerator'; -import { - buildSchemaSummary, - describeTopAttributes, - formatAttributeList, - resolveAttributeSchema, - resolveCharacterAttributeProfile, -} from '../data/attributeResolver'; -import { - buildCharacterBackstoryPromptContext, - getCharacterAdventureOpening, - getCharacterById, - getCharacterPublicBackstorySummary, - resolveEncounterRecruitCharacter, -} from '../data/characterPresets'; -import { getMonsterPresetById } from '../data/hostileNpcPresets'; -import { createSceneHostileNpcsFromIds } from '../data/hostileNpcs'; -import { - describeConversationStyle as describeNpcConversationStyle, - describeDisclosureStage, - describeWarmthStage, -} from '../data/npcInteractions'; -import { - buildSceneEntityCatalogText, - getSceneHostileNpcPresetIds, - getScenePresetById, -} from '../data/scenePresets'; -import { - buildFunctionCatalogText, - getFunctionById, - getFunctionPromptDescription, -} from '../data/stateFunctions'; -import type { StoryGenerationContext } from '../services/aiTypes'; -import { buildCustomWorldReferenceText } from '../services/customWorld'; -import { sanitizePromptNarrativeText } from '../services/narrativeLanguage'; -import { describeGoalStackForPrompt } from '../services/storyEngine/goalDirector'; -import { buildStoryPromptHistory } from '../services/storyHistory'; -import { - Character, - CharacterGender, - CustomWorldProfile, - FacingDirection, - SceneHostileNpc, - StoryMoment, - StoryOption, - WorldType, -} from '../types'; - -export const SYSTEM_PROMPT = `你是角色扮演 RPG 的剧情推进者,只能返回 JSON 对象,不能输出解释、markdown 或代码块。 -输出格式必须严格符合: -{ - "storyText": "剧情文本", - "encounter": null, - "options": [ - { - "functionId": "预定义功能ID", - "actionText": "选项显示文本" - } - ] -} - -只有当提示语明确要求你判断“主角继续推进后下一刻会遇到什么”时,才允许把 "encounter" 改成: -{ - "kind": "npc|treasure|none", - "npcId": "仅当 kind=npc 时填写", - "treasureText": "仅当 kind=treasure 时填写" -} - -严格规则: -- 所有文本必须是中文。 -- 如果提示语给出了特定可选列表,你必须严格保留原有数量和 functionId;你可以调整这些特定项的顺序,但排序必须参考最近剧情、刚发生的结果、当前局面轻重缓急,再重点优化 actionText;下文会直接说明每个 function 的行为边界,不是让你发挥的剧本。 -- 如果提示语中没有给特定可选列表,则必须输出至少 6 个选项。 -- 除非提示语明确要求你判断下一刻遭遇,否则 encounter 必须保持为 null;战斗结束后的续写、聊天续写、固定选项续写都不能生成新的 encounter。 -- 每个选项只能包含 functionId 和 actionText。 -- 没有特定列表时,所有 functionId 必须互不重复。 -- 每个选项只能包含一个 function,不要把多个动作塞进同一行。 -- storyText 必须衔接当前界面、最近剧情、当前场景与当前实体,不得割裂上下文凭空发挥。 -- 战斗状态下,storyText 必须提到当前敌对目标或战斗对象正在做什么。 -- actionText 必须同时考虑:主角状态、面前实体状态、最近剧情、当前场景、当前可执行 function。 -- 当主角生命值低下时,至少有一个 actionText 体现维持状态、调整、恢复或撤退。 -- 当主角灵力低下时,至少有一个 actionText 体现节省消耗、保持节奏或尝试恢复。 -- 当对方状态低下时,至少有一个 actionText 体现改变、攻击、结束或压制。 -- actionText 只写玩家能看到的行动文本,不写 functionId,不写特殊解释。 -- 选项顺序不是随机列表,越接近最近剧情推进、当前威胁或当前机会的回应越靠前。 -- 前端不会校验 functionId;不该出现的 function 绝对不要输出。`; - -export const NPC_CHAT_DIALOGUE_SYSTEM_PROMPT = `你是角色扮演 RPG 的对话编剧。 -你只能输出纯文本对话,不能输出解释、代码、markdown、JSON 或额外说明。 - -硬性规则: -- 每一行都必须严格以“你:”或“角色名字:”开头。 -- 第一行必须是“你:”开头。 -- 总行数控制在 4 到 6 行。 -- 玩家和对方至少各说 2 次。 -- 对话必须承接当前聊天主题、当前场景和最近关系变化。 -- 对方必须给出真实回应,不能只用敷衍词。`; - -export const NPC_CHAT_DIALOGUE_STRICT_SYSTEM_PROMPT = `你是角色扮演 RPG 的角色对话编剧。 -你只能输出纯中文对话正文,不能输出解释、代码、markdown、JSON 或额外说明。 - -硬性规则: -- 每一行都必须严格以“你:”或“角色名字:”开头。 -- 第一行必须是“你:”开头。 -- 总行数控制在 4 到 6 行。 -- 玩家和对方至少各说 2 次。 -- 这段内容只是聊天,不是做决定。 -- 禁止在聊天里主动引导、建议、安排或预告交易、招募、切磋、战斗、送礼、求助、离开、继续前进、切换场景等其他 function。 -- 禁止把情报直接写成对玩家的指令。 -- 结束时要让玩家感觉到气氛、情报或关系发生了变化,但变化仍停留在聊天层面。`; - -export const NPC_RECRUIT_DIALOGUE_SYSTEM_PROMPT = `你是角色扮演 RPG 的招募剧情对话编剧。 -你只能输出纯中文对话正文,不能输出解释、代码、markdown、JSON 或额外说明。 - -硬性规则: -- 每一行都必须严格以“你:”或“角色名字:”开头。 -- 第一行必须是“你:”开头。 -- 总行数控制在 4 到 6 行。 -- 玩家和对方至少各说 2 次。 -- 这段对话的目标是把“邀请对方入队”自然谈成。 -- 不允许出现拒绝入队、继续观望、以后再说、条件未满足等结果。 -- 不允许出现“我不能答应”“我还没想好”“再让我考虑”“暂时不行”“以后再说”这类拒绝或拖延表述。 -- 最后一行必须由对方明确答应加入队伍。`; - -export const CHARACTER_PANEL_CHAT_SYSTEM_PROMPT = `你是像素动作 RPG 中可被玩家在角色面板里私下交谈的同行角色。 -你只能输出这名角色此刻会说的话,不能输出角色名、引号、旁白、动作描写、Markdown、JSON 或解释。 -硬性规则: -- 必须始终站在该角色立场回应,语气要符合角色设定、经历、情绪和与你的关系。 -- 只回复角色说话内容,不要代替玩家发言,不要把回复写成系统选项。 -- 可以自然提到最近剧情、战斗感受、彼此关系和顾虑,但不要写成任务说明书。 -- 回复控制在 1 到 3 段,总长度尽量不超过 120 个中文字符。 -- 玩家问得含糊时,也要给出明确、具体、带情绪的回应。`; - -export const CHARACTER_PANEL_CHAT_SUGGESTION_SYSTEM_PROMPT = `你要为玩家生成 3 条下一句可直接发送的中文回复建议。 -你只能输出 3 行纯文本,每行 1 条,不要序号、引号、解释、Markdown 或额外空行。 -硬性规则: -- 三条建议必须风格有区分:一条偏关心,一条偏追问,一条偏轻松或拉近关系。 -- 每条建议尽量控制在 10 到 28 个字。 -- 建议必须贴合最近剧情、当前关系和上一轮聊天内容。`; - -export const CHARACTER_PANEL_CHAT_SUMMARY_SYSTEM_PROMPT = `你要把玩家与这名角色的聊天沉淀成后续剧情推理可用的角色关系摘要。 -你只能输出一段中文摘要,不要标题、序号、Markdown、JSON 或解释。 -摘要必须包含: -- 当前关系气氛与亲疏变化 -- 角色对玩家态度的新变化 -- 聊天里出现的重要信息、承诺、顾虑或暗示 -长度控制在 45 到 120 个字。`; - -function describeConversationSituationLabel( - situation: StoryGenerationContext['conversationSituation'], -) { - switch (situation) { - case 'camp_first_contact': - return '营地初次试探'; - case 'camp_followup': - return '营地顺势续谈'; - case 'post_battle_breath': - return '战后缓气'; - case 'shared_danger_coordination': - return '危险中协同'; - case 'private_followup': - return '私下续谈'; - case 'first_contact_cautious': - return '谨慎初见'; - default: - return '当前对话'; - } -} - -function describeConversationPressureLabel( - pressure: StoryGenerationContext['conversationPressure'], -) { - switch (pressure) { - case 'high': - return '高压'; - case 'medium': - return '中压'; - case 'low': - return '低压'; - default: - return '未知'; - } -} - -function describeRevealBudgetLabel(revealBudget: string | null | undefined) { - switch (revealBudget) { - case 'low': - return '低'; - case 'medium': - return '中'; - case 'high': - return '高'; - default: - return '未设定'; - } -} - -function describeEmotionalCadenceLabel(cadence: string | null | undefined) { - switch (cadence) { - case 'tense': - return '紧绷'; - case 'curious': - return '试探'; - case 'hostile': - return '敌意'; - case 'intimate': - return '亲近'; - case 'tragic': - return '沉重'; - case 'mysterious': - return '迷雾'; - default: - return '未设定'; - } -} - -function describeCompanionReactionTypeLabel(reactionType: string) { - switch (reactionType) { - case 'approve': - return '认可'; - case 'disapprove': - return '保留'; - case 'concern': - return '担心'; - case 'silence': - return '沉默'; - case 'curious': - return '被勾起兴趣'; - default: - return '反应'; - } -} - -function describeActStatusLabel(status: string | null | undefined) { - switch (status) { - case 'opening': - return '开场'; - case 'midgame': - return '中段'; - case 'late_game': - return '后段'; - case 'finale': - return '收束'; - case 'resolved': - return '已落定'; - default: - return '进行中'; - } -} - -function describeBranchBudgetPressureLabel( - pressure: StoryGenerationContext['branchBudgetPressure'], -) { - switch (pressure) { - case 'low': - return '低'; - case 'medium': - return '中'; - case 'high': - return '高'; - default: - return '未知'; - } -} - -function describePlayerStyleLabel(style: string | null | undefined) { - switch (style) { - case 'story_first': - return '剧情优先'; - case 'explorer': - return '探索驱动'; - case 'combat_driver': - return '战斗推进'; - case 'companion_bond': - return '同伴关系'; - case 'collector': - return '收集倾向'; - default: - return '综合型'; - } -} - -function describeQaSeverityLabel(severity: string) { - switch (severity) { - case 'low': - return '低'; - case 'medium': - return '中'; - case 'high': - return '高'; - default: - return '未知'; - } -} - -function describeQaCategoryLabel(category: string) { - switch (category) { - case 'consistency': - return '一致性'; - case 'pacing': - return '节奏'; - case 'payoff': - return '回收'; - case 'branch_budget': - return '分支预算'; - case 'reveal_leak': - return '信息泄露'; - default: - return '叙事问题'; - } -} - -function describeReleaseGateStatusLabel(status: string | null | undefined) { - switch (status) { - case 'pass': - return '通过'; - case 'warn': - return '警告'; - case 'block': - return '阻塞'; - default: - return '未知'; - } -} - -function describeChapterStageLabel(stage: string | null | undefined) { - switch (stage) { - case 'opening': - return '开篇'; - case 'expansion': - return '展开'; - case 'turning_point': - return '转折'; - case 'climax': - return '高潮'; - case 'aftermath': - return '余波'; - default: - return '进行中'; - } -} - -function describeJourneyBeatLabel(beatType: string | null | undefined) { - switch (beatType) { - case 'approach': - return '接近'; - case 'investigation': - return '调查'; - case 'camp': - return '休整'; - case 'conflict': - return '冲突'; - case 'boss_prelude': - return '决战前奏'; - case 'climax': - return '高潮'; - case 'recovery': - return '恢复'; - default: - return '旅程'; - } -} - -function describeCampEventTypeLabel(eventType: string | null | undefined) { - switch (eventType) { - case 'private_talk': - return '私下谈话'; - case 'party_banter': - return '队伍闲谈'; - case 'conflict': - return '冲突'; - case 'comfort': - return '安抚'; - case 'reveal': - return '揭露'; - case 'decision': - return '抉择'; - default: - return '营地事件'; - } -} - -function describeSetpieceTypeLabel(setpieceType: string | null | undefined) { - switch (setpieceType) { - case 'boss_prelude': - return '决战前奏'; - case 'showdown': - return '对峙'; - case 'climax': - return '高潮'; - case 'aftermath': - return '余波'; - default: - return '高光节点'; - } -} - -function describeWorldMutationTypeLabel(mutationType: string) { - switch (mutationType) { - case 'scene_text': - return '场景变化'; - case 'npc_attitude': - return '人物态度变化'; - case 'shop_style': - return '商铺风格变化'; - case 'enemy_pressure': - return '敌方压力变化'; - case 'route_lock': - return '路径封锁'; - case 'route_unlock': - return '路径开启'; - default: - return '世界变化'; - } -} - -function describeAnimationLabel(animation: string | null | undefined) { - switch (animation) { - case 'idle': - return '待机'; - case 'acquire': - return '收取'; - case 'attack': - return '攻击'; - case 'run': - return '奔跑'; - case 'jump': - return '跳跃'; - case 'double jump': - return '二段跳'; - case 'jump attack': - return '跳击'; - case 'dash': - return '冲刺'; - case 'hurt': - return '受击'; - case 'die': - return '倒下'; - case 'climb': - return '攀爬'; - case 'skill1': - return '技能一'; - case 'skill1 jump': - return '技能一起跳'; - case 'skill1 bullet': - return '技能一弹道'; - case 'skill1 bullet FX': - return '技能一特效'; - case 'skill2': - return '技能二'; - case 'skill2 jump': - return '技能二起跳'; - case 'skill3': - return '技能三'; - case 'skill3 jump': - return '技能三起跳'; - case 'skill3 bullet': - return '技能三弹道'; - case 'skill3 bullet FX': - return '技能三特效'; - case 'skill4': - return '技能四'; - case 'Wall Slide': - return '贴墙滑行'; - case 'move': - return '逼近'; - default: - return animation ?? '当前动作'; - } -} - -export function describeWorld(world: WorldType) { - if (world === WorldType.WUXIA) return '边城模板'; - if (world === WorldType.XIANXIA) return '灵潮模板'; - return '自定义世界'; -} - -function describeWorldForPrompt(world: WorldType, customWorldProfile?: CustomWorldProfile | null) { - return customWorldProfile - ? `${customWorldProfile.name}(自定义世界)` - : describeWorld(world); -} - -function describeCustomWorldSection(context: StoryGenerationContext) { - return context.customWorldProfile - ? `自定义世界补充档案:\n${buildCustomWorldReferenceText(context.customWorldProfile, { - activeThreadIds: context.activeThreadIds, - highlightNpcNames: context.encounterName ? [context.encounterName] : [], - })}` - : null; -} - -function describeFacing(facing: FacingDirection) { - return facing === 'left' ? '左' : '右'; -} - -function describeGender(gender: CharacterGender | null | undefined) { - if (gender === 'female') return '女'; - if (gender === 'male') return '男'; - return '未知'; -} - -function describeAdventureOpening(character: Character, world: WorldType) { - const opening = getCharacterAdventureOpening(character, world); - if (!opening) return []; - - return [ - `来到此界的原因:${opening.reason}`, - `当前最重要的目标:${opening.goal}`, - ]; -} - -function describePlayerOpeningByContext(character: Character, world: WorldType, context: StoryGenerationContext) { - const opening = getCharacterAdventureOpening(character, world); - if (!opening) return []; - - const shouldConcealFullOpening = context.lastFunctionId === 'story_opening_camp_dialogue' - || context.lastFunctionId === 'npc_chat' - || context.isFirstMeaningfulContact === true; - if (!shouldConcealFullOpening) { - return describeAdventureOpening(character, world); - } - - return [ - `主角当前只表露出的钩子:${opening.surfaceHook ?? '主角有自己的来意,但不会刚见面就全说。'}`, - `主角当前更在意的事:${opening.immediateConcern ?? '主角会优先先谈眼前局势。'}`, - ]; -} - -function describeEncounterOpeningByStage(character: Character, world: WorldType, context: StoryGenerationContext) { - const opening = getCharacterAdventureOpening(character, world); - if (!opening) return []; - - if (context.isFirstMeaningfulContact) { - return [ - `当前只看得出的钩子:${opening.surfaceHook ?? '对方有自己的来意,但此刻只会先露出一角。'}`, - `当前更在意的事:${opening.immediateConcern ?? '对方会先把注意力放在眼前局势上。'}`, - ]; - } - - const stage = context.encounterDisclosureStage ?? 'guarded'; - if (stage === 'guarded') { - return [ - `当前只看得出的钩子:${opening.surfaceHook ?? '对方知道点什么,但并没有把来意说透。'}`, - `当前更在意的事:${opening.immediateConcern ?? '眼前局势比来历更值得先谈。'}`, - ]; - } - if (stage === 'partial') { - return [ - `当前愿意松口的表层理由:${opening.guardedMotive ?? opening.surfaceHook ?? '对方只肯给一层表面的解释。'}`, - `当前更在意的事:${opening.immediateConcern ?? '眼前局势比旧事更急。'}`, - ]; - } - if (stage === 'honest') { - return [ - `来到此界的原因(可逐步触及):${opening.reason}`, - `当前最重要的目标(仍不必一次说尽):${opening.goal}`, - ]; - } - return [ - `来到此界的原因:${opening.reason}`, - `当前最重要的目标:${opening.goal}`, - ]; -} - -function describeEncounterConversationDirective(context: StoryGenerationContext) { - if (!context.encounterConversationStyle || !context.encounterDisclosureStage || !context.encounterWarmthStage || !context.encounterAnswerMode) { - return null; - } - - return [ - '当前角色对话阶段控制:', - `- 当前好感:${context.encounterAffinity ?? '未知'}`, - `- 信息揭示阶段:${context.encounterDisclosureStage}(${describeDisclosureStage(context.encounterDisclosureStage)})`, - `- 语气亲疏阶段:${context.encounterWarmthStage}(${describeWarmthStage(context.encounterWarmthStage)})`, - `- 回答模式:${context.encounterAnswerMode}`, - `- 角色表述风格:${describeNpcConversationStyle(context.encounterConversationStyle)}`, - context.encounterAllowedTopics?.length - ? `- 本轮优先可谈:${context.encounterAllowedTopics.join('、')}` - : null, - context.encounterBlockedTopics?.length - ? `- 本轮避免直接说破:${context.encounterBlockedTopics.join('、')}` - : null, - ].filter(Boolean).join('\n'); -} - -function describeConversationSituationDirective(context: StoryGenerationContext) { - if (!context.conversationSituation && !context.conversationPressure && !context.recentSharedEvent && !context.talkPriority) { - return null; - } - - const recentSharedEvent = sanitizePromptNarrativeText( - context.recentSharedEvent, - '你们刚共同经历了一段需要承接的局势变化。', - ); - const talkPriority = sanitizePromptNarrativeText( - context.talkPriority, - '优先承接眼前局势与刚刚发生的变化。', - ); - - return [ - '当前对话情景控制:', - context.conversationSituation - ? `- 情景标签:${describeConversationSituationLabel(context.conversationSituation)}` - : null, - context.conversationPressure - ? `- 当前压力:${describeConversationPressureLabel(context.conversationPressure)}` - : null, - recentSharedEvent ? `- 刚刚共同经历:${recentSharedEvent}` : null, - talkPriority ? `- 本轮优先说法:${talkPriority}` : null, - ].filter(Boolean).join('\n'); -} - -function describeFirstContactRelationStance( - stance: StoryGenerationContext['firstContactRelationStance'], -) { - switch (stance) { - case 'guarded': - return '戒备试探'; - case 'neutral': - return '正常交流但仍不熟'; - case 'cooperative': - return '已有善意,先确认合作节奏'; - case 'bonded': - return '明显信任,但仍是第一次正式对上人'; - default: - return '初次接触'; - } -} - -function describeFirstMeaningfulContactDirective(context: StoryGenerationContext) { - if (!context.isFirstMeaningfulContact) { - return null; - } - - return [ - '当前接触阶段:这是你与该角色第一次真正接触。', - `- 当前关系站位:${describeFirstContactRelationStance(context.firstContactRelationStance ?? null)}`, - '- 可以按当前好感写得更冷或更暖,但仍必须保持第一次正式对上的节奏。', - '- 优先写现场判断、态度试探、来意确认和眼前压力,不要直接写成熟人后续轮。', - '- 不要让双方一上来互相讲完整过去;未公开或未解锁背景不能主动说破。', - ].join('\n'); -} - -function hasVisibilityFact( - slice: StoryGenerationContext['visibilitySlice'], - factId: string, -) { - return Boolean(slice?.sayableFactIds.includes(factId)); -} - -function describeVisibilityFactLabel(factId: string) { - if (factId === 'publicMask') return '公开面'; - if (factId === 'firstContactMask') return '首遇遮挡说辞'; - if (factId === 'visibleLine') return '表层线'; - if (factId === 'immediatePressure') return '当前压力'; - if (factId === 'contradiction') return '说辞错位'; - if (factId === 'hiddenLine') return '隐藏线'; - if (factId === 'debtOrBurden') return '债务或负担'; - if (factId === 'taboo') return '禁区'; - if (factId.startsWith('thread:')) return '故事线程索引'; - if (factId.startsWith('scar:')) return '旧痕索引'; - if (factId.startsWith('chapter:')) return '已解锁背景摘要'; - if (factId.startsWith('reaction:')) return '反应钩子'; - return factId; -} - -function describeVisibilitySliceSection(context: StoryGenerationContext) { - if (!context.visibilitySlice) { - return null; - } - - const sayable = context.visibilitySlice.sayableFactIds - .map(describeVisibilityFactLabel) - .join('、'); - const inferred = context.visibilitySlice.inferredFactIds - .map(describeVisibilityFactLabel) - .join('、'); - const forbidden = context.visibilitySlice.forbiddenFactIds - .map(describeVisibilityFactLabel) - .join('、'); - - return [ - '当前信息可见性切片:', - sayable ? `- 可直接进入本轮上下文:${sayable}` : null, - inferred ? `- 只能写成推测或缝隙:${inferred}` : null, - forbidden ? `- 禁止直接说破:${forbidden}` : null, - ...(context.visibilitySlice.misdirectionHints ?? []).map( - (hint) => `- 误导/遮挡提示:${hint}`, - ), - ] - .filter(Boolean) - .join('\n'); -} - -function describeSceneNarrativeDirectiveSection(context: StoryGenerationContext) { - if (!context.sceneNarrativeDirective) { - return null; - } - - const directive = context.sceneNarrativeDirective; - const primaryPressure = sanitizePromptNarrativeText( - directive.primaryPressure, - '当前场景仍有未被说透的压力。', - ); - return [ - '当前场景导演指令:', - primaryPressure ? `- 主压力:${primaryPressure}` : null, - directive.activeThreadIds.length > 0 - ? `- 当前激活故事线程数量:${directive.activeThreadIds.length}` - : null, - `- 揭示预算:${describeRevealBudgetLabel(directive.revealBudget)}`, - `- 情绪节奏:${describeEmotionalCadenceLabel(directive.emotionalCadence)}`, - ].join('\n'); -} - -function describeRecentCompanionReactionsSection(context: StoryGenerationContext) { - if (!context.recentCompanionReactions?.length) { - return null; - } - - return [ - '最近一次同行反应:', - ...context.recentCompanionReactions.slice(-3).map( - (reaction) => { - const safeReason = sanitizePromptNarrativeText( - reaction.reason, - '同行角色对你刚才那一步有了新的态度变化。', - ); - const speaker = - getCharacterById(reaction.characterId)?.name ?? '同行角色'; - return `- ${speaker} / ${describeCompanionReactionTypeLabel(reaction.reactionType)}:${safeReason}`; - }, - ), - ].join('\n'); -} - -function describeRecentCarrierEchoesSection(context: StoryGenerationContext) { - if (!context.recentCarrierEchoes?.length) { - return null; - } - - return [ - '最近叙事载体回响:', - ...context.recentCarrierEchoes.slice(0, 4).map((echo) => `- ${echo}`), - ].join('\n'); -} - -function describeCampaignSection(context: StoryGenerationContext) { - if (!context.campaignState && !context.actState) { - return null; - } - - return [ - '当前战役状态:', - context.campaignState - ? `- 当前战役:${context.campaignState.title}(第 ${context.campaignState.currentActIndex + 1} 幕)` - : null, - context.actState - ? `- 当前幕:${context.actState.title} / ${describeActStatusLabel(context.actState.status)} / ${context.actState.theme}` - : null, - ].filter(Boolean).join('\n'); -} - -function describeConsequenceLedgerSection(context: StoryGenerationContext) { - if (!context.consequenceLedger?.length) { - return null; - } - - return [ - '关键后果账本:', - ...context.consequenceLedger.slice(-5).map( - (record) => `- ${record.title}(权重 ${record.weight}):${record.summary}`, - ), - ].join('\n'); -} - -function describeConstraintSection(context: StoryGenerationContext) { - if (!context.authorialConstraintPack) { - return null; - } - - const pack = context.authorialConstraintPack; - return [ - '作者性约束:', - `- 基调规则:${pack.toneRules.join('、') || '暂无'}`, - `- 禁止模式:${pack.noGoPatterns.join('、') || '暂无'}`, - `- 必须回收:${pack.requiredPayoffs.join('、') || '暂无'}`, - context.branchBudgetPressure - ? `- 当前分支预算压力:${describeBranchBudgetPressureLabel(context.branchBudgetPressure)}` - : null, - ].filter(Boolean).join('\n'); -} - -function describePackSection(context: StoryGenerationContext) { - if (!context.activeScenarioPack && !context.activeCampaignPack) { - return null; - } - - return [ - '当前内容包:', - context.activeScenarioPack - ? `- 当前场景包:${context.activeScenarioPack.title} v${context.activeScenarioPack.version}` - : null, - context.activeCampaignPack - ? `- 当前战役包:${context.activeCampaignPack.title} / ${context.activeCampaignPack.authoringStyle}` - : null, - ].filter(Boolean).join('\n'); -} - -function describePlayerStyleSection(context: StoryGenerationContext) { - if (!context.playerStyleProfile) { - return null; - } - - return [ - '当前玩家画像:', - `- 风格:${describePlayerStyleLabel(context.playerStyleProfile.dominantStyle)}`, - `- 倾向:剧情 ${context.playerStyleProfile.preferenceWeights.story} / 探索 ${context.playerStyleProfile.preferenceWeights.exploration} / 战斗 ${context.playerStyleProfile.preferenceWeights.combat} / 同伴 ${context.playerStyleProfile.preferenceWeights.companion} / 收集 ${context.playerStyleProfile.preferenceWeights.collection}`, - ].join('\n'); -} - -function describeNarrativeQaSection(context: StoryGenerationContext) { - if (!context.narrativeQaReport) { - return null; - } - - return [ - '当前叙事 QA:', - `- 摘要:${context.narrativeQaReport.summary}`, - ...context.narrativeQaReport.issues.slice(0, 4).map( - (issue) => - `- ${describeQaSeverityLabel(issue.severity)} / ${describeQaCategoryLabel(issue.category)}:${issue.summary}`, - ), - context.releaseGateReport - ? `- 发布门禁:${describeReleaseGateStatusLabel(context.releaseGateReport.status)} / ${context.releaseGateReport.summary}` - : null, - context.simulationRunResults?.length - ? `- 模拟覆盖:${context.simulationRunResults.length} 条` - : null, - ].join('\n'); -} - -function describeChapterSection(context: StoryGenerationContext) { - if (!context.chapterState) { - return null; - } - - return [ - '当前章节状态:', - `- 标题:${context.chapterState.title}`, - `- 阶段:${describeChapterStageLabel(context.chapterState.stage)}`, - `- 主题:${context.chapterState.theme}`, - `- 摘要:${context.chapterState.chapterSummary}`, - ].join('\n'); -} - -function describeJourneyBeatSection(context: StoryGenerationContext) { - if (!context.journeyBeat) { - return null; - } - - return [ - '当前旅程段落:', - `- 类型:${describeJourneyBeatLabel(context.journeyBeat.beatType)}`, - `- 标题:${context.journeyBeat.title}`, - `- 情绪目标:${context.journeyBeat.emotionalGoal}`, - ].join('\n'); -} - -function describeGoalStackSection(context: StoryGenerationContext) { - return describeGoalStackForPrompt(context.goalStack); -} - -function describeCampEventSection(context: StoryGenerationContext) { - if (!context.currentCampEvent) { - return null; - } - - return [ - '当前可触发营地/旅途事件:', - `- 标题:${context.currentCampEvent.title}`, - `- 类型:${describeCampEventTypeLabel(context.currentCampEvent.eventType)}`, - `- 原因:${context.currentCampEvent.triggerReason}`, - ].join('\n'); -} - -function describeSetpieceSection(context: StoryGenerationContext) { - if (!context.setpieceDirective) { - return null; - } - - return [ - '当前高光导演指令:', - `- 类型:${describeSetpieceTypeLabel(context.setpieceDirective.setpieceType)}`, - `- 标题:${context.setpieceDirective.title}`, - `- 核心问题:${context.setpieceDirective.dramaticQuestion}`, - ].join('\n'); -} - -function describeWorldMutationSection(context: StoryGenerationContext) { - if (!context.recentWorldMutations?.length) { - return null; - } - - return [ - '最近世界变化:', - ...context.recentWorldMutations.slice(-4).map( - (mutation) => - `- ${describeWorldMutationTypeLabel(mutation.mutationType)}:${mutation.reason}`, - ), - ].join('\n'); -} - -function describeFactionTensionSection(context: StoryGenerationContext) { - if (!context.recentFactionTensionStates?.length) { - return null; - } - - return [ - '当前阵营温度:', - ...context.recentFactionTensionStates.slice(0, 4).map( - (tension) => - `- 温度 ${tension.temperature}:${tension.pressureSummary}`, - ), - ].join('\n'); -} - -function describeChronicleSection(context: StoryGenerationContext) { - const chronicleSummary = sanitizePromptNarrativeText( - context.recentChronicleSummary, - ); - if (!chronicleSummary) { - return null; - } - - return `近期旅程回顾:\n${chronicleSummary}`; -} - -function buildCustomEncounterBackstoryLines(context: StoryGenerationContext) { - const encounterCustomProfile = context.encounterCustomProfile; - const narrativeProfile = context.encounterNarrativeProfile; - if (!encounterCustomProfile || !narrativeProfile) { - return ['对方有自己的来路与立场,只是暂时没有完全表现出来。']; - } - - const lines: string[] = []; - if (hasVisibilityFact(context.visibilitySlice, 'publicMask')) { - lines.push(narrativeProfile.publicMask); - } - if (hasVisibilityFact(context.visibilitySlice, 'firstContactMask')) { - lines.push(narrativeProfile.firstContactMask); - } - if (hasVisibilityFact(context.visibilitySlice, 'visibleLine')) { - lines.push(narrativeProfile.visibleLine); - } - if (hasVisibilityFact(context.visibilitySlice, 'immediatePressure')) { - lines.push(narrativeProfile.immediatePressure); - } - - (encounterCustomProfile.backstoryReveal?.chapters ?? []).forEach((chapter) => { - if (hasVisibilityFact(context.visibilitySlice, `chapter:${chapter.id}`)) { - const snippet = - chapter.contextSnippet || chapter.teaser || encounterCustomProfile.backstoryReveal?.publicSummary; - if (snippet) { - lines.push(snippet); - } - } - }); - - return lines.length > 0 - ? [...new Set(lines.filter(Boolean))] - : [encounterCustomProfile.backstoryReveal?.publicSummary ?? narrativeProfile.publicMask]; -} - -function describeBackstoryContext(label: string, snippets: string[]) { - const normalized = snippets - .map((snippet) => - sanitizePromptNarrativeText( - snippet, - `${label === '主角背景' ? '主角' : '对方'}仍有自己的来路,但此刻不直接沿用非中文原句。`, - ), - ) - .filter(Boolean); - - if (normalized.length === 0) { - return [`${label}:暂无公开信息。`]; - } - - return normalized.map((snippet, index) => - `${label}${index === 0 ? '(公开层)' : `(已解锁片段 ${index})`}:${snippet}`, - ); -} - -function getEncounterGender(context: StoryGenerationContext) { - if (context.encounterCharacterId) { - return getCharacterById(context.encounterCharacterId)?.gender ?? context.encounterGender ?? 'unknown'; - } - - return context.encounterGender ?? 'unknown'; -} - -function describeHpBand(ratio: number) { - if (ratio >= 0.95) return '完好无损'; - if (ratio >= 0.75) return '状态稳健'; - if (ratio >= 0.55) return '略有消耗'; - if (ratio >= 0.35) return '伤势明显'; - if (ratio >= 0.15) return '伤势沉重'; - return '濒临极限'; -} - -function describeManaBand(ratio: number) { - if (ratio >= 0.9) return '灵力满盈'; - if (ratio >= 0.7) return '灵力充沛'; - if (ratio >= 0.45) return '灵力平稳'; - if (ratio >= 0.2) return '灵力吃紧'; - if (ratio > 0) return '灵力见底'; - return '灵力枯竭'; -} - -function describeOverallBand(hpRatio: number, manaRatio: number) { - if (hpRatio >= 0.75 && manaRatio >= 0.7) return '整体状态适合主动推进'; - if (hpRatio >= 0.5 && manaRatio >= 0.4) return '整体状态仍可持续周旋'; - if (hpRatio < 0.35 && manaRatio < 0.2) return '整体状态非常吃紧,应避免冒进'; - if (hpRatio < 0.35) return '身体负担偏重,宜先稳住节奏'; - if (manaRatio < 0.2) return '灵力压力很大,宜保守分配手段'; - return '整体状态已有消耗,需要权衡节奏'; -} - -function inferEncounterPersonality(contextText: string | null | undefined, description: string | null | undefined) { - const source = `${contextText ?? ''} ${description ?? ''}`; - if (/守|卫|先锋|甲/u.test(source)) return '谨慎克制,先观察后出手'; - if (/猎|追踪|巡/u.test(source)) return '警觉敏锐,习惯先捕捉细节'; - if (/商|摊主|军需/u.test(source)) return '精于算计,言谈中会反复权衡得失'; - if (/学|书|碑|录/u.test(source)) return '耐心细致,更看重信息与来历'; - if (/舟|渡|舵/u.test(source)) return '老练沉稳,习惯先判断局势再表态'; - return '对外保持戒备,会先试探你的来意与立场'; -} - -function inferEncounterAttributeProfile( - world: WorldType, - context: StoryGenerationContext, - entityId: string, - extraText: string[] = [], -) { - const schema = resolveAttributeSchema(world, context.customWorldProfile); - return buildRoleAttributeProfileFromLegacyData({ - entityId, - schema, - textBlocks: [ - context.encounterName, - context.encounterContext, - context.encounterDescription, - ...extraText, - ], - }).profile; -} - -function describeAttributeProfileForPrompt( - label: string, - world: WorldType, - context: StoryGenerationContext, - profile: ReturnType | null | undefined, -) { - const schema = resolveAttributeSchema(world, context.customWorldProfile); - return [ - `${label}核心属性:${describeTopAttributes(profile, schema).join('、') || '暂无'}`, - `${label}属性详情:${formatAttributeList(profile, schema) - .map(entry => `${entry.slot.name} ${entry.value}`) - .join('、')}`, - ]; -} - -function describeSkills(character: Character, context: StoryGenerationContext) { - const cooldowns = Object.entries(context.skillCooldowns) - .filter(([, turns]) => turns > 0) - .map(([skillId, turns]) => { - const skill = character.skills.find(item => item.id === skillId); - return skill ? `${skill.name} 还需 ${turns} 回合` : null; - }) - .filter(Boolean) - .join(';'); - - return [ - `当前灵力档位:${describeManaBand(context.playerMana / Math.max(context.playerMaxMana, 1))}`, - '技能列表:', - ...character.skills.map( - skill => `- ${skill.id}:${skill.name},基础伤害 ${skill.damage},消耗 ${skill.manaCost},冷却 ${skill.cooldownTurns} 回合`, - ), - `冷却中的技能:${cooldowns || '暂无'}`, - ].join('\n'); -} - -function describeFrontEntity( - world: WorldType, - context: StoryGenerationContext, - monsters: SceneHostileNpc[], -) { - const schema = resolveAttributeSchema(world, context.customWorldProfile); - if (context.encounterName) { - const encounterCustomProfile = context.encounterCustomProfile; - const encounterCharacter = context.encounterCharacterId - ? getCharacterById(context.encounterCharacterId) ?? resolveEncounterRecruitCharacter({ - characterId: context.encounterCharacterId, - context: context.encounterContext ?? '', - npcName: context.encounterName, - }) - : resolveEncounterRecruitCharacter({ - characterId: undefined, - context: context.encounterContext ?? '', - npcName: context.encounterName, - }); - - const attributeProfile = encounterCharacter - ? resolveCharacterAttributeProfile(encounterCharacter, world, context.customWorldProfile) - : inferEncounterAttributeProfile(world, context, `encounter:${context.encounterName}`, [ - encounterCustomProfile?.personality || - inferEncounterPersonality( - context.encounterContext, - context.encounterDescription, - ), - context.encounterNarrativeProfile?.publicMask ?? '', - context.encounterNarrativeProfile?.visibleLine ?? '', - context.encounterNarrativeProfile?.immediatePressure ?? '', - ...(context.visibilitySlice?.sayableFactIds.includes('contradiction') - && context.encounterNarrativeProfile?.contradiction - ? [context.encounterNarrativeProfile.contradiction] - : []), - ]); - const title = - encounterCharacter?.title ?? - encounterCustomProfile?.title ?? - context.encounterContext ?? - '此地生灵'; - const description = - encounterCharacter?.description ?? - encounterCustomProfile?.description ?? - context.encounterDescription ?? - '对方站在你面前,等待你进一步表态。'; - const personality = - encounterCharacter?.personality ?? - encounterCustomProfile?.personality ?? - inferEncounterPersonality( - context.encounterContext, - context.encounterDescription, - ); - const backstoryLines = encounterCharacter - ? context.isFirstMeaningfulContact - ? [getCharacterPublicBackstorySummary(encounterCharacter, world)] - : buildCharacterBackstoryPromptContext( - encounterCharacter, - context.encounterAffinity ?? 0, - world, - ) - : encounterCustomProfile - ? buildCustomEncounterBackstoryLines(context) - : ['对方有自己的来路与立场,只是暂时没有完全表现出来。']; - const status = context.encounterKind === 'npc' - ? context.isFirstMeaningfulContact - ? '你们正在进行第一次真正接触,对方会先观察你的态度与来意。' - : '对你保持观察与戒备,正在等待你的回应' - : context.encounterKind === 'treasure' - ? '静静停在前方,尚未被真正触碰' - : '状态未明'; - - return [ - '当前面前实体:', - `- 名称:${context.encounterName}`, - `- 身份:${title}`, - `- 描述:${description}`, - ...describeBackstoryContext('背景', backstoryLines).map(line => `- ${line}`), - `- 性格:${personality}`, - context.encounterNarrativeProfile?.firstContactMask - ? `- 首遇遮挡说辞:${context.encounterNarrativeProfile.firstContactMask}` - : null, - context.encounterNarrativeProfile?.visibleLine - ? `- 表层线:${context.encounterNarrativeProfile.visibleLine}` - : null, - context.encounterNarrativeProfile?.immediatePressure - ? `- 当前压力:${context.encounterNarrativeProfile.immediatePressure}` - : null, - context.visibilitySlice?.inferredFactIds.includes('contradiction') && - context.encounterNarrativeProfile?.contradiction - ? `- 可写成推测的错位:${context.encounterNarrativeProfile.contradiction}` - : null, - !context.encounterNarrativeProfile && encounterCustomProfile?.motivation - ? `- 当前动机:${encounterCustomProfile.motivation}` - : null, - !context.encounterNarrativeProfile && encounterCustomProfile?.combatStyle - ? `- 战斗风格:${encounterCustomProfile.combatStyle}` - : null, - !context.encounterNarrativeProfile && encounterCustomProfile?.relationshipHooks?.length - ? `- 关系切入口:${encounterCustomProfile.relationshipHooks.join('、')}` - : null, - !context.encounterNarrativeProfile && encounterCustomProfile?.tags?.length - ? `- 标签:${encounterCustomProfile.tags.join('、')}` - : null, - !context.encounterNarrativeProfile && encounterCustomProfile?.skills?.length - ? `- 自定义技能:${encounterCustomProfile.skills - .map((skill) => `${skill.name}(${skill.style}):${skill.summary}`) - .join(';')}` - : null, - !context.encounterNarrativeProfile && encounterCustomProfile?.initialItems?.length - ? `- 随身物:${encounterCustomProfile.initialItems - .map( - (item) => - `${item.name}x${item.quantity}(${item.category}/${item.rarity})`, - ) - .join(';')}` - : null, - `- 世界属性框架:${buildSchemaSummary(schema).map(slot => `${slot.name}:${slot.definition}`).join('、')}`, - - ...(encounterCharacter ? describeEncounterOpeningByStage(encounterCharacter, world, context).map(line => `- ${line}`) : []), - `- 状态:${status}`, - ...describeAttributeProfileForPrompt('对方', world, context, attributeProfile).map(line => `- ${line}`), - context.encounterKind === 'npc' && context.encounterAffinityText - ? `- 对你的态度:${context.encounterAffinityText}` - : null, - sanitizePromptNarrativeText(context.encounterRelationshipSummary) - ? `- 你与对方私下相处补充:${sanitizePromptNarrativeText(context.encounterRelationshipSummary)}` - : null, - ].filter(Boolean).join('\n'); - } - - const primaryMonster = monsters.find(monster => monster.hp > 0) ?? monsters[0]; - if (!primaryMonster) { - return '当前面前实体:暂无明确实体拦在你面前。'; - } - - const monsterPreset = getMonsterPresetById(world, primaryMonster.id); - const hpRatio = primaryMonster.hp / Math.max(primaryMonster.maxHp, 1); - const monsterProfile = primaryMonster.attributeProfile - ?? inferEncounterAttributeProfile(world, context, `monster:${primaryMonster.id}`, [ - monsterPreset?.description ?? primaryMonster.description, - primaryMonster.action, - ]); - - return [ - '当前面前实体:', - `- 名称:${primaryMonster.name}`, - '- 身份:当前最靠前的敌对目标', - `- 描述:${monsterPreset?.description ?? primaryMonster.description}`, - '- 性格:更接近本能性的压迫与试探,会按当前动作持续逼近你', - `- 状态:生命状态 ${describeHpBand(hpRatio)},当前动作 ${describeAnimationLabel(primaryMonster.animation)},朝向 ${describeFacing(primaryMonster.facing)}`, - ...describeAttributeProfileForPrompt('敌对实体', world, context, monsterProfile).map(line => `- ${line}`), - ].join('\n'); -} - -function describePlayerState(world: WorldType, character: Character, context: StoryGenerationContext) { - const hpRatio = context.playerHp / Math.max(context.playerMaxHp, 1); - const manaRatio = context.playerMana / Math.max(context.playerMaxMana, 1); - const sceneName = context.sceneName || '当前区域'; - const sceneDescription = context.sceneDescription || '此地仍有未知人物、敌对目标与机缘潜伏。'; - const schema = resolveAttributeSchema(world, context.customWorldProfile); - const attributeProfile = resolveCharacterAttributeProfile(character, world, context.customWorldProfile); - const playerBackstoryLines = describeBackstoryContext( - '主角背景', - [getCharacterPublicBackstorySummary(character, world)], - ); - - return [ - `玩家状态:${context.inBattle ? '战斗状态' : '空闲状态'}`, - `当前场景:${sceneName}`, - `场景描述:${sceneDescription}`, - sanitizePromptNarrativeText(context.lastObserveSignsReport) - ? `最近一次观察结果:${sanitizePromptNarrativeText(context.lastObserveSignsReport)}` - : null, - sanitizePromptNarrativeText(context.recentActionResult) - ? `刚刚结算结果:${sanitizePromptNarrativeText(context.recentActionResult)}` - : null, - `主角:${character.name},${character.title}`, - `主角描述:${character.description}`, - ...playerBackstoryLines, - `主角性格:${character.personality}`, - ...describePlayerOpeningByContext(character, world, context), - `世界属性框架:${buildSchemaSummary(schema).map(slot => `${slot.name}:${slot.definition}`).join('、')}`, - `主角状态:生命状态 ${describeHpBand(hpRatio)},灵力状态 ${describeManaBand(manaRatio)},整体判断 ${describeOverallBand(hpRatio, manaRatio)},朝向 ${describeFacing(context.playerFacing)},当前动作 ${describeAnimationLabel(context.playerAnimation)}`, - ...describeAttributeProfileForPrompt('主角', world, context, attributeProfile), - ].filter(Boolean).join('\n'); -} - -function describeMonsters(monsters: SceneHostileNpc[]) { - if (monsters.length === 0) { - return '当前没有可见敌对目标。'; - } - - return monsters - .map(monster => { - const hpRatio = monster.hp / Math.max(monster.maxHp, 1); - return `敌对目标 ${monster.name}:生命状态 ${describeHpBand(hpRatio)},当前动作 ${describeAnimationLabel(monster.animation)},行为“${monster.action}”,朝向 ${describeFacing(monster.facing)}`; - }) - .join('\n'); -} - -function _describeHistory(history: string[]) { - if (history.length === 0) { - return '最近剧情:暂无。'; - } - - return `最近剧情:\n${history.slice(-6).map(item => `- ${item}`).join('\n')}`; -} - -function describeStoryHistory(history: StoryMoment[]) { - const promptHistory = buildStoryPromptHistory(history); - const previousSummary = sanitizePromptNarrativeText( - promptHistory.previousSummary, - '更早的剧情已经推进过数轮,请只承接既有结果,不直接沿用其中的非中文原句。', - ); - const recentOriginalRounds = promptHistory.recentOriginalRounds - .map((item) => - sanitizePromptNarrativeText( - item, - '这一轮的原始文本里夹杂了非中文描述,续写时只承接已发生的结果与局势变化。', - ), - ) - .filter(Boolean); - - if (!previousSummary && recentOriginalRounds.length === 0) { - return '最近剧情:暂无。'; - } - - return [ - previousSummary - ? `3轮以前的历史剧情总结:\n${previousSummary}` - : '3轮以前的历史剧情总结:暂无。', - recentOriginalRounds.length > 0 - ? `最近3轮剧情原文(续写时优先承接):\n${recentOriginalRounds - .map((item, index) => `- 第${index + 1}轮\n${item}`) - .join('\n')}` - : '最近3轮剧情原文:暂无。', - '续写时必须先承接“最近3轮剧情原文”,再与“3轮以前的历史剧情总结”保持一致,不得跳过已经发生的结果、地点、关系变化或战斗状态。', - ].join('\n'); -} - -function _buildResolvedUserPrompt( - world: WorldType, - character: Character, - monsters: SceneHostileNpc[], - history: StoryMoment[], - context: StoryGenerationContext, - choice?: string, - availableOptions?: StoryOption[], - optionCatalog?: StoryOption[], -) { - const functionContext = { - worldType: world, - playerCharacter: character, - inBattle: context.inBattle, - currentSceneId: context.sceneId, - currentSceneName: context.sceneName, - monsters, - playerHp: context.playerHp, - playerMaxHp: context.playerMaxHp, - playerMana: context.playerMana, - playerMaxMana: context.playerMaxMana, - }; - const scene = getScenePresetById(world, context.sceneId); - const pendingEncounter = context.pendingSceneEncounter && !!scene; - const hasProvidedOptions = (availableOptions?.length ?? 0) > 0; - const _hasOptionCatalog = Boolean(optionCatalog && optionCatalog.length > 0); - const hasProvidedNpcChatOptions = Boolean(availableOptions?.some(option => option.functionId === 'npc_chat')); - const isOpeningCampDialogue = context.lastFunctionId === 'story_opening_camp_dialogue' && Boolean(context.encounterName); - const hasOpeningCampFollowupContext = hasProvidedNpcChatOptions - && Boolean(sanitizePromptNarrativeText(context.openingCampBackground)) - && Boolean(sanitizePromptNarrativeText(context.openingCampDialogue)); - const partyRelationshipNotes = sanitizePromptNarrativeText( - context.partyRelationshipNotes, - ); - const openingCampBackground = sanitizePromptNarrativeText( - context.openingCampBackground, - ); - const openingCampDialogue = sanitizePromptNarrativeText( - context.openingCampDialogue, - ); - const safeChoice = choice - ? sanitizePromptNarrativeText( - choice, - '玩家刚刚做出了一个新的决定。', - ) - : null; - const sceneMonsterIds = getSceneHostileNpcPresetIds(scene); - const battleCatalog = scene - ? buildFunctionCatalogText({ - ...functionContext, - inBattle: true, - monsters: createSceneHostileNpcsFromIds(world, sceneMonsterIds, context.playerX), - }) - : ''; - const idleCatalog = buildFunctionCatalogText({ - ...functionContext, - inBattle: false, - monsters: [], - }); - const observeSignsCatalog = context.observeSignsRequested - ? buildSceneEntityCatalogText(world, context.sceneId) - : ''; - - const sections = [ - `世界:${describeWorldForPrompt(world, context.customWorldProfile)}`, - describePlayerState(world, character, context), - describeCustomWorldSection(context), - `主角性别:${describeGender(character.gender ?? 'unknown')}`, - describeFrontEntity(world, context, monsters), - describePackSection(context), - describePlayerStyleSection(context), - describeCampaignSection(context), - describeChapterSection(context), - describeJourneyBeatSection(context), - describeGoalStackSection(context), - describeSceneNarrativeDirectiveSection(context), - describeVisibilitySliceSection(context), - describeConsequenceLedgerSection(context), - describeConstraintSection(context), - describeCampEventSection(context), - describeSetpieceSection(context), - describeRecentCompanionReactionsSection(context), - describeRecentCarrierEchoesSection(context), - describeWorldMutationSection(context), - describeFactionTensionSection(context), - describeChronicleSection(context), - describeNarrativeQaSection(context), - describeConversationSituationDirective(context), - describeEncounterConversationDirective(context), - context.encounterName ? `当前面前实体性别:${describeGender(getEncounterGender(context))}` : null, - describeSkills(character, context), - `当前敌对目标状态:\n${describeMonsters(monsters)}`, - partyRelationshipNotes ? `同行角色补充关系信息:\n${partyRelationshipNotes}` : null, - describeStoryHistory(history), - hasOpeningCampFollowupContext ? `营地开场背景:\n${openingCampBackground}` : null, - hasOpeningCampFollowupContext ? `刚刚发生的第一段营地对话:\n${openingCampDialogue}` : null, - safeChoice ? `玩家刚刚选择:${safeChoice}` : '玩家刚进入当前局面。', - hasProvidedOptions - ? `固定可选项列表(必须保持数量与 functionId 一致,可按最近剧情重排顺序):\n${describeProvidedOptions(availableOptions ?? [])}` - : pendingEncounter - ? `当前场景实体池:\n${buildSceneEntityCatalogText(world, context.sceneId)}` - : `当前可执行 function:\n${buildFunctionCatalogText(functionContext)}`, - hasProvidedOptions - ? '这些选项对应当前局面下真实可执行的本地规则。你必须严格保持数量不变、functionId 不变;可以依据最近剧情、刚刚发生的结果和当前轻重缓急重排顺序;然后按每个 function 的行为边界,自然重写更贴合当前局面和状态的中文 actionText,不要把它写成别的行为。' - : pendingEncounter - ? `如果主角继续推进后遇到敌对目标,你必须只从以下战斗 function 中选择至少 6 个选项:\n${battleCatalog}` - : '请只根据上面的当前状态继续推进这一幕,并输出紧接着发生的剧情文本与至少 6 个选项。', - hasProvidedOptions - ? 'storyText 必须直接承接最近剧情与当前结果,让玩家看到这一动作之后局面的新变化。' - : pendingEncounter - ? `如果主角遇到角色、宝藏或暂时什么都没遇到,你必须只从以下空闲 function 中选择至少 6 个选项:\n${idleCatalog}` - : '这些选项必须全部从当前可执行 function 列表里选择。', - hasProvidedOptions - ? '每个 function 后面的说明都是行为边界。actionText 可以自然改写,但不能把它写成别的行为;若重排顺序,必须让前面的选项更贴近最近剧情与当前局面的主导矛盾。' - : '下方 function 说明都是行为边界。actionText 可以自然改写,但不能把它写成别的行为。', - hasProvidedOptions || !pendingEncounter - ? null - : '你必须先判断主角继续推进后下一刻会遇到什么,并在 encounter 中填写结果。若遇到敌对对象,请使用 kind=npc 并填写 npcId;若不是场景角色,则 options 必须使用空闲 function。', - describeEncounterOutputRequirement(Boolean(pendingEncounter)), - '当敌人状态偏差时,攻击类 actionText 要更像收割、补刀或终结;当主角低血时,恢复类 actionText 要更像稳住伤势、打坐或调息;当主角低蓝时,至少一个 actionText 要体现节省消耗或稳住节奏。', - 'storyText 和 options 必须与当前状态严格一致,不能把已经死亡、已经离场或当前不在眼前的实体重新写回画面。', - ]; - - if (context.observeSignsRequested) { - sections.push(`当前动作是“停步观察动静”。你必须基于当前场景实体池进行推理,把可能出现的角色、敌对目标、BOSS 线索写进 storyText。这里是观察参考实体池:\n${observeSignsCatalog}`); - sections.push('这一段重点是观察和判断,不是立刻推进遭遇。storyText 要写成后续还能继续引用的侦察结论。'); - } - - if (isOpeningCampDialogue) { - sections.push(`当前这一步是你与${context.encounterName}在营地里的开场聊天。storyText 必须直接写成刚刚发生的对话正文,每一行都必须以“你:”或“${context.encounterName}:”开头,总行数控制在 4 到 6 行。`); - sections.push('这段开场白必须承接玩家刚踏入此界后的警觉、判断、保留动机、营地气氛和面前同伴的态度,让它成为后续剧情可继续引用的真实最近剧情,而不是旁白总结。'); - sections.push('玩家在第一轮里也只该表露表层钩子、眼前压力和试探态度,不要主动把完整目标、完整理由或全部过去一次说完。'); - } - - if (hasOpeningCampFollowupContext) { - sections.push('当前不是重新发明泛化选项,而是承接上面的营地开场背景和刚刚那段第一轮对话,整理角色眼下最自然会继续说或继续追问的话。'); - sections.push('如果固定项里包含两个 npc_chat,它们必须排在前两个位置;这两个 npc_chat 的 actionText 必须直接承接刚刚聊到的话头,体现追问、确认、延展或继续对接,而不是回到通用模板。'); - } - - return sections.filter(Boolean).join('\n\n'); -} - -function describeProvidedOptionCore(option: StoryOption) { - const definition = getFunctionById(option.functionId); - const definitionCore = definition?.description?.trim(); - const functionPromptDescription = getFunctionPromptDescription(option.functionId, definitionCore); - - if (option.functionId === 'npc_preview_talk') { - return '把注意力真正转到眼前这个角色身上,准备开始与其交谈;这是进入角色互动层,不是立刻完成一次聊天。'; - } - - if (option.interaction?.kind === 'npc' && option.interaction.action === 'chat') { - return `和面前角色围绕当前这个话题切入点继续交谈,文案可以自然改写,但仍要保持这是聊天而不是别的行为。`; - } - - if (option.interaction?.kind === 'npc' && option.interaction.action === 'trade') { - return '和面前角色进行交易,可以写成更自然的买卖或交换表达,但仍要保持这是交易行为。'; - } - - if (option.interaction?.kind === 'npc' && option.interaction.action === 'help') { - return '向面前角色寻求帮助或支援,但仍要保持这是求助行为。'; - } - - if (option.interaction?.kind === 'npc' && option.interaction.action === 'gift') { - return option.detailText - ? `向面前角色送礼,以改善关系或表达诚意。当前礼物线索:${option.detailText}` - : '向面前角色送礼,以改善关系或表达诚意。'; - } - - if (option.interaction?.kind === 'npc' && option.interaction.action === 'recruit') { - return '邀请面前角色加入队伍或同行,但仍要保持这是招募行为。'; - } - - if (option.interaction?.kind === 'npc' && option.interaction.action === 'quest_accept') { - return '接受面前角色给出的委托或任务。'; - } - - if (option.interaction?.kind === 'npc' && option.interaction.action === 'quest_turn_in') { - return '向面前角色交付已经完成的委托。'; - } - - if (option.interaction?.kind === 'npc' && option.interaction.action === 'leave') { - return '结束与面前实体的当前互动,把注意力重新放回前路。'; - } - - if (option.interaction?.kind === 'npc' && option.interaction.action === 'fight') { - return '与面前角色直接开战。'; - } - - if (option.interaction?.kind === 'npc' && option.interaction.action === 'spar') { - return '与面前角色进行点到为止的切磋。'; - } - - if (option.interaction?.kind === 'treasure') { - return option.interaction.action === 'inspect' - ? '先检查眼前目标的细节与风险,再决定如何收取。' - : option.interaction.action === 'secure' - ? '直接收取眼前的目标,不再做额外停留。' - : '暂时放过眼前目标,把注意力拉回当前环境。'; - } - - return functionPromptDescription || option.detailText || option.actionText; -} - -function describeProvidedOptions(options: StoryOption[]) { - return options - .map((option, index) => { - return `- 第 ${index + 1} 项 / ${option.functionId}:${describeProvidedOptionCore(option)}`; - }) - .join('\n'); -} - -function describeEncounterOutputRequirement(pendingEncounter: boolean) { - return pendingEncounter - ? '只有当前文明确要求你判断“主角继续推进后下一刻会遇到什么”时,encounter 才能填写对象;如果这一刻什么都没遇到,请填写 kind=none。' - : '当前这一步不是遭遇生成流程。encounter 必须为 null(保持为空),不要生成新的 encounter;尤其是战斗结束后的续写、聊天续写、固定选项续写时,禁止新增场景实体。'; -} - -function buildCatalogAwareUserPrompt( - world: WorldType, - character: Character, - monsters: SceneHostileNpc[], - history: StoryMoment[], - context: StoryGenerationContext, - choice?: string, - availableOptions?: StoryOption[], - optionCatalog?: StoryOption[], -) { - const functionContext = { - worldType: world, - playerCharacter: character, - inBattle: context.inBattle, - currentSceneId: context.sceneId, - currentSceneName: context.sceneName, - monsters, - playerHp: context.playerHp, - playerMaxHp: context.playerMaxHp, - playerMana: context.playerMana, - playerMaxMana: context.playerMaxMana, - }; - const scene = getScenePresetById(world, context.sceneId); - const pendingEncounter = context.pendingSceneEncounter && !!scene; - const hasProvidedOptions = (availableOptions?.length ?? 0) > 0; - const hasOptionCatalog = Boolean(optionCatalog && optionCatalog.length > 0); - const hasProvidedNpcChatOptions = Boolean(availableOptions?.some(option => option.functionId === 'npc_chat')); - const isOpeningCampDialogue = context.lastFunctionId === 'story_opening_camp_dialogue' && Boolean(context.encounterName); - const hasOpeningCampFollowupContext = hasProvidedNpcChatOptions - && Boolean(sanitizePromptNarrativeText(context.openingCampBackground)) - && Boolean(sanitizePromptNarrativeText(context.openingCampDialogue)); - const partyRelationshipNotes = sanitizePromptNarrativeText( - context.partyRelationshipNotes, - ); - const openingCampBackground = sanitizePromptNarrativeText( - context.openingCampBackground, - ); - const openingCampDialogue = sanitizePromptNarrativeText( - context.openingCampDialogue, - ); - const safeChoice = choice - ? sanitizePromptNarrativeText( - choice, - '玩家刚刚做出了一个新的决定。', - ) - : null; - const battleCatalog = scene - ? buildFunctionCatalogText({ - ...functionContext, - inBattle: true, - monsters: createSceneHostileNpcsFromIds(world, getSceneHostileNpcPresetIds(scene), context.playerX), - }) - : ''; - const idleCatalog = buildFunctionCatalogText({ - ...functionContext, - inBattle: false, - monsters: [], - }); - const observeSignsCatalog = context.observeSignsRequested - ? buildSceneEntityCatalogText(world, context.sceneId) - : ''; - - const sections = [ - `世界:${describeWorldForPrompt(world, context.customWorldProfile)}`, - describePlayerState(world, character, context), - describeCustomWorldSection(context), - `主角性别:${describeGender(character.gender ?? 'unknown')}`, - describeFrontEntity(world, context, monsters), - describePackSection(context), - describePlayerStyleSection(context), - describeCampaignSection(context), - describeChapterSection(context), - describeJourneyBeatSection(context), - describeGoalStackSection(context), - describeSceneNarrativeDirectiveSection(context), - describeVisibilitySliceSection(context), - describeConsequenceLedgerSection(context), - describeConstraintSection(context), - describeCampEventSection(context), - describeSetpieceSection(context), - describeRecentCompanionReactionsSection(context), - describeRecentCarrierEchoesSection(context), - describeWorldMutationSection(context), - describeFactionTensionSection(context), - describeChronicleSection(context), - describeNarrativeQaSection(context), - context.encounterName ? `当前面前实体性别:${describeGender(getEncounterGender(context))}` : null, - describeSkills(character, context), - `当前敌对目标状态:\n${describeMonsters(monsters)}`, - describeConversationSituationDirective(context), - describeEncounterConversationDirective(context), - describeFirstMeaningfulContactDirective(context), - partyRelationshipNotes ? `同行角色补充关系信息:\n${partyRelationshipNotes}` : null, - describeStoryHistory(history), - hasOpeningCampFollowupContext ? `营地开场背景:\n${openingCampBackground}` : null, - hasOpeningCampFollowupContext ? `刚刚发生的第一段营地对话:\n${openingCampDialogue}` : null, - safeChoice ? `玩家刚刚选择:${safeChoice}` : '玩家刚进入当前局面。', - hasProvidedOptions - ? `固定可选项列表(必须保留数量与 functionId,可按最近剧情重排顺序):\n${describeProvidedOptions(availableOptions ?? [])}` - : hasOptionCatalog - ? `当前局面可调用的交互选项目录(functionId 只能从这里选,但不需要保留原数量和顺序):\n${describeProvidedOptions(optionCatalog ?? [])}` - : pendingEncounter - ? `当前场景实体池:\n${buildSceneEntityCatalogText(world, context.sceneId)}` - : `当前可执行 function:\n${buildFunctionCatalogText(functionContext)}`, - hasProvidedOptions - ? '这些选项对应当前局面下真实可执行的本地规则。你必须保持数量不变、functionId 不变;可以依据最近剧情、刚刚发生的结果和当前轻重缓急重排顺序,并自然重写更贴合当前局面和状态的中文 actionText。' - : hasOptionCatalog - ? '上面的交互选项目录只是当前局面下合法可执行的 function 范围,不是固定模板。你不需要保留原数量、原顺序或原文案,但 options 里的 functionId 只能从这个目录里选择,并且要根据刚刚发生的结果、关系变化和眼前局面,自行决定最合理的选项组合。' - : pendingEncounter - ? `如果主角继续推进后遇到敌对目标,你必须只从以下战斗 function 中选择至少 6 个选项:\n${battleCatalog}` - : '请只根据上面的当前状态继续推进这一幕,并输出紧接着发生的剧情文本与至少 6 个选项。', - hasProvidedOptions - ? 'storyText 必须直接承接最近剧情与当前结果,让玩家看到这一动作之后局面的新变化。' - : hasOptionCatalog - ? '请只根据上面的当前状态继续推进这一幕,输出紧接着发生的剧情文本与至少 6 个选项;如果目录里本身不足 6 个 function,就优先覆盖当前最重要的合法 function。' - : pendingEncounter - ? `如果主角遇到角色、宝藏或暂时什么都没遇到,你必须只从以下空闲 function 中选择至少 6 个选项:\n${idleCatalog}` - : '这些选项必须全部从当前可执行 function 列表里选择。', - hasProvidedOptions - ? '每个 function 后面的说明都是行为边界。actionText 可以自然改写,但不能把它写成别的行为;若重排顺序,必须让前面的选项更贴近最近剧情与当前局面的主导矛盾。' - : hasOptionCatalog - ? '目录里每个 function 后面的说明都是行为边界。actionText 可以自然改写,也可以只挑当前最合理的那部分 function;但不能输出目录外的 function,也不能把某个 function 写成别的行为。' - : '下方 function 说明都是行为边界。actionText 可以自然改写,但不能把它写成别的行为。', - hasProvidedOptions || hasOptionCatalog || !pendingEncounter - ? null - : '你必须先判断主角继续推进后下一刻会遇到什么,并在 encounter 中填写结果。若遇到敌对对象,请使用 kind=npc 并填写 npcId;若不是场景角色,则 options 必须使用空闲 function。', - describeEncounterOutputRequirement(Boolean(pendingEncounter)), - '当敌人状态偏差时,攻击类 actionText 要更像收割、补刀或终结;当主角低血时,恢复类 actionText 要更像稳住伤势、打坐或调息;当主角低蓝时,至少一个 actionText 要体现节省消耗或稳住节奏。', - 'storyText 和 options 必须与当前状态严格一致,不能把已经死亡、已经离场或当前不在眼前的实体重新写回画面。', - ]; - - if (context.observeSignsRequested) { - sections.push(`当前动作是“停步观察动静”。你必须基于当前场景实体池进行推理,把可能出现的角色、敌对目标、BOSS 线索写进 storyText。这里是观察参考实体池:\n${observeSignsCatalog}`); - sections.push('这一段重点是观察和判断,不是立刻推进遭遇。storyText 要写成后续还能继续引用的侦察结论。'); - } - - if (isOpeningCampDialogue) { - sections.push(`当前这一步是你与${context.encounterName}在营地里的开场聊天。storyText 必须直接写成刚刚发生的对话正文,每一行都必须以“你:”或“${context.encounterName}:”开头,总行数控制在 4 到 6 行。`); - sections.push('这段开场白必须承接玩家刚踏入此界后的警觉、判断、保留动机、营地气氛和面前同伴的态度,让它成为后续剧情可继续引用的真实最近剧情,而不是旁白总结。'); - sections.push('玩家在第一轮里也只该表露表层钩子、眼前压力和试探态度,不要主动把完整目标、完整理由或全部过去一次说完。'); - } - - if (hasOpeningCampFollowupContext) { - sections.push('当前不是重新发明泛化选项,而是承接上面的营地开场背景和刚刚那段第一轮对话,整理角色眼下最自然会继续说或继续追问的话。'); - sections.push('如果固定项里包含两个 npc_chat,它们必须排在前两个位置;这两个 npc_chat 的 actionText 必须直接承接刚刚聊到的话头,体现追问、确认、延展或继续对接,而不是回到通用模板。'); - } - - return sections.filter(Boolean).join('\n\n'); -} - -export function buildUserPrompt( - world: WorldType, - character: Character, - monsters: SceneHostileNpc[], - history: StoryMoment[], - context: StoryGenerationContext, - choice?: string, - availableOptions?: StoryOption[], - optionCatalog?: StoryOption[], -) { - return buildCatalogAwareUserPrompt(world, character, monsters, history, context, choice, availableOptions, optionCatalog); -} - -function buildResolvedNpcChatDialoguePrompt( - world: WorldType, - character: Character, - encounterName: string, - monsters: SceneHostileNpc[], - history: StoryMoment[], - context: StoryGenerationContext, - topic: string, - resultSummary: string, -) { - const openingCampBackground = sanitizePromptNarrativeText( - context.openingCampBackground, - ); - const openingCampDialogue = sanitizePromptNarrativeText( - context.openingCampDialogue, - ); - const safeTopic = - sanitizePromptNarrativeText(topic, '眼前刚刚谈到的话头') ?? topic; - const safeResultSummary = - sanitizePromptNarrativeText( - resultSummary, - '这段聊天刚让你们之间的气氛发生了新的变化。', - ) ?? resultSummary; - - return [ - `世界:${describeWorldForPrompt(world, context.customWorldProfile)}`, - describePlayerState(world, character, context), - describeCustomWorldSection(context), - `主角性别:${describeGender(character.gender ?? 'unknown')}`, - describeFrontEntity(world, context, monsters), - describePackSection(context), - describePlayerStyleSection(context), - describeCampaignSection(context), - describeChapterSection(context), - describeJourneyBeatSection(context), - describeGoalStackSection(context), - describeSceneNarrativeDirectiveSection(context), - describeVisibilitySliceSection(context), - describeConsequenceLedgerSection(context), - describeConstraintSection(context), - describeCampEventSection(context), - describeSetpieceSection(context), - describeRecentCompanionReactionsSection(context), - describeRecentCarrierEchoesSection(context), - describeWorldMutationSection(context), - describeFactionTensionSection(context), - describeChronicleSection(context), - describeNarrativeQaSection(context), - `当前面前实体性别:${describeGender(getEncounterGender(context))}`, - describeSkills(character, context), - `当前敌对目标状态:\n${describeMonsters(monsters)}`, - describeStoryHistory(history), - openingCampBackground ? `营地开场背景:\n${openingCampBackground}` : null, - openingCampDialogue ? `刚刚发生的第一段营地对话:\n${openingCampDialogue}` : null, - `当前交谈对象:${encounterName}`, - `聊天主题:${safeTopic}`, - `关系变化结果:${safeResultSummary}`, - describeConversationSituationDirective(context), - describeEncounterConversationDirective(context), - describeFirstMeaningfulContactDirective(context), - openingCampBackground && openingCampDialogue - ? '这段 npc_chat 必须承接上面的营地开场背景和第一段对话,像同一段谈话自然往下推进,不要把语气和话题重置成初见模板。' - : null, - `请围绕“${safeTopic}”写一段刚刚发生的对话。必须只输出对白正文,每一行都必须以“你:”或“${encounterName}:”开头。`, - ].filter(Boolean).join('\n\n'); -} - -function buildNpcChatDialoguePrompt( - world: WorldType, - character: Character, - encounterName: string, - monsters: SceneHostileNpc[], - history: StoryMoment[], - context: StoryGenerationContext, - topic: string, - resultSummary: string, -) { - return buildResolvedNpcChatDialoguePrompt( - world, - character, - encounterName, - monsters, - history, - context, - topic, - resultSummary, - ); -} - -export function buildStrictNpcChatDialoguePrompt( - world: WorldType, - character: Character, - encounter: { npcName: string }, - monsters: SceneHostileNpc[], - history: StoryMoment[], - context: StoryGenerationContext, - topic: string, - resultSummary: string, -) { - return [ - buildNpcChatDialoguePrompt(world, character, encounter.npcName, monsters, history, context, topic, resultSummary), - '补充硬约束:这段内容只是聊天,不是做决定。', - '不要让对方在聊天里推进交易、招募、切磋、战斗、送礼、求助、离开、继续前进、切换场景等其他 function。', - '不要替玩家做选择,不要用建议句、命令句或诱导句把聊天写成别的行为入口。', - '低揭示阶段时,宁可留钩子、先谈眼前局势,也不要把完整来历和目标一次说完。', - context.isFirstMeaningfulContact - ? '如果这是第一次真正接触,对方第一次开口必须先用一句自然招呼或开场判断起手,不能写成“某人看着你,像是在等你把话接下去”这类第三人称占位旁白。' - : null, - '如果当前情景是初见或刚打完一轮冲突,优先写短句、观察句和试探句,不要写成正式自我介绍。', - ].filter(Boolean).join('\n\n'); -} - -export function buildNpcRecruitDialoguePrompt( - world: WorldType, - character: Character, - encounter: { npcName: string }, - monsters: SceneHostileNpc[], - history: StoryMoment[], - context: StoryGenerationContext, - invitationText: string, - recruitSummary: string, -) { - const safeInvitationText = - sanitizePromptNarrativeText(invitationText, '我希望你能加入队伍,与我并肩同行。') ?? - invitationText; - const safeRecruitSummary = - sanitizePromptNarrativeText( - recruitSummary, - '双方已经具备继续同行的条件。', - ) ?? recruitSummary; - - return [ - `世界:${describeWorldForPrompt(world, context.customWorldProfile)}`, - describePlayerState(world, character, context), - describeCustomWorldSection(context), - `主角性别:${describeGender(character.gender ?? 'unknown')}`, - describeFrontEntity(world, context, monsters), - describePackSection(context), - describePlayerStyleSection(context), - describeCampaignSection(context), - describeChapterSection(context), - describeJourneyBeatSection(context), - describeGoalStackSection(context), - describeSceneNarrativeDirectiveSection(context), - describeVisibilitySliceSection(context), - describeConsequenceLedgerSection(context), - describeConstraintSection(context), - describeCampEventSection(context), - describeSetpieceSection(context), - describeRecentCompanionReactionsSection(context), - describeRecentCarrierEchoesSection(context), - describeWorldMutationSection(context), - describeFactionTensionSection(context), - describeChronicleSection(context), - describeNarrativeQaSection(context), - `当前招募对象性别:${describeGender(getEncounterGender(context))}`, - describeSkills(character, context), - `当前敌对目标状态:\n${describeMonsters(monsters)}`, - describeStoryHistory(history), - `当前招募对象:${encounter.npcName}`, - `玩家邀请:${safeInvitationText}`, - `招募补充条件:${safeRecruitSummary}`, - describeConversationSituationDirective(context), - describeEncounterConversationDirective(context), - describeFirstMeaningfulContactDirective(context), - '这是一段“邀请对方入队”的对话。请让几轮交流逐步导向成功加入队伍,不要写出拒绝、观望或延期答复。', - '对方可以谨慎确认,但对话末尾必须明确答应加入,不能把结论停在犹豫、保留或回避上。', - `最后一行必须由 ${encounter.npcName} 明确答应加入队伍。`, - ].join('\n\n'); -} diff --git a/src/services/ai.test.ts b/src/services/ai.test.ts index afd7566a..f4c443f3 100644 --- a/src/services/ai.test.ts +++ b/src/services/ai.test.ts @@ -45,13 +45,8 @@ import { streamCharacterPanelChatReply, streamNpcRecruitDialogue, } from './ai'; -import { - buildOfflineCharacterPanelChatReply, - buildOfflineCharacterPanelChatSuggestions, - buildOfflineNpcRecruitDialogue, -} from './aiFallbacks'; import type { StoryGenerationContext } from './aiTypes'; -import type { CharacterChatTargetStatus } from './characterChatPrompt'; +import type { CharacterChatTargetStatus } from './rpgRuntimeChatTypes'; const [ BACKSTORY_UNLOCK_AFFINITY_EASED, @@ -105,6 +100,8 @@ function createContext( overrides: Partial = {}, ): StoryGenerationContext { return { + runtimeSessionId: 'runtime-main', + runtimeActionVersion: 3, playerHp: 30, playerMaxHp: 40, playerMana: 12, @@ -410,7 +407,57 @@ function createCustomWorldResponse( }; } -describe('ai orchestration fallbacks', () => { +function createApiEnvelopeResponse(data: unknown) { + return { + ok: true, + status: 200, + headers: new Headers(), + text: async () => + JSON.stringify({ + ok: true, + data, + error: null, + meta: { + apiVersion: '2026-04-08', + }, + }), + } as Response; +} + +function createSseResponse(text: string) { + const encoder = new TextEncoder(); + const chunks = [ + encoder.encode( + `data: ${JSON.stringify({ + choices: [{ delta: { content: text } }], + })}\n\n`, + ), + ]; + let index = 0; + + return { + ok: true, + status: 200, + headers: new Headers(), + body: { + getReader() { + return { + async read() { + if (index >= chunks.length) { + return { done: true, value: undefined }; + } + const value = chunks[index]; + index += 1; + return { done: false, value }; + }, + }; + }, + }, + text: async () => '', + } as Response; +} + +describe('ai runtime client orchestration', () => { const playerCharacter = createCharacter(); const targetCharacter = createCharacter({ id: 'ally', @@ -431,9 +478,15 @@ describe('ai orchestration fallbacks', () => { streamPlainTextCompletionMock.mockReset(); }); - it('falls back to the offline story response when story generation loses connectivity', async () => { + it('requests initial story from the runtime api server', async () => { const availableOptions = [createStoryOption()]; - requestChatMessageContentMock.mockRejectedValue(connectivityError); + fetchMock.mockResolvedValue( + createApiEnvelopeResponse({ + storyText: '山路尽头传来新的动静。', + options: availableOptions, + encounter: null, + }), + ); const response = await generateInitialStory( WorldType.WUXIA, @@ -443,12 +496,22 @@ describe('ai orchestration fallbacks', () => { { availableOptions }, ); + expect(fetchMock).toHaveBeenCalledWith( + '/api/runtime/story/initial', + expect.objectContaining({ + method: 'POST', + body: JSON.stringify({ + sessionId: 'runtime-main', + clientVersion: 3, + requestOptions: { availableOptions }, + }), + }), + ); + expect(response.storyText).toBe('山路尽头传来新的动静。'); expect(response.options).toEqual(availableOptions); - expect(response.options).not.toBe(availableOptions); - expect(response.storyText.length).toBeGreaterThan(0); }); - it('repairs mixed-language story text before returning the story response', async () => { + it('requests next story step from the runtime api server', async () => { const availableOptions = [ createStoryOption({ functionId: 'idle_explore_forward', @@ -456,117 +519,46 @@ describe('ai orchestration fallbacks', () => { text: '继续沿山道探路。', }), ]; - requestChatMessageContentMock - .mockResolvedValueOnce( - JSON.stringify({ - storyText: 'The forest is quiet. 你听见远处的风声。', - encounter: null, - options: [ - { - functionId: 'idle_explore_forward', - actionText: 'Move forward carefully.', - }, - ], - }), - ) - .mockResolvedValueOnce( - JSON.stringify({ - storyText: '林间重新安静下来,你听见远处的风声。', - encounter: null, - options: [ - { - functionId: 'idle_explore_forward', - actionText: '继续沿山道探路。', - }, - ], - }), - ); - - const response = await generateInitialStory( - WorldType.WUXIA, - playerCharacter, - monsters, - context, - { availableOptions }, - ); - - expect(response.storyText).toBe('林间重新安静下来,你听见远处的风声。'); - expect(response.options[0]?.actionText).toBe('继续沿山道探路。'); - expect(requestChatMessageContentMock).toHaveBeenCalledTimes(2); - expect(requestChatMessageContentMock.mock.calls[1]?.[2]).toEqual( - expect.objectContaining({ - debugLabel: 'story-language-repair', - }), - ); - }); - - it('ignores generated encounter payloads during post-battle continuations when no new scene encounter is pending', async () => { - const availableOptions = [ - createStoryOption({ - functionId: 'idle_explore_forward', - actionText: '先稳住呼吸,再看看前面的动静。', - text: '先稳住呼吸,再看看前面的动静。', - }), - ]; - const sceneWithNpc = getScenePresetsByWorld(WorldType.WUXIA).find( - (scene) => (scene.npcs?.length ?? 0) > 0, - ); - const targetNpcId = sceneWithNpc?.npcs?.[0]?.id; - if (!sceneWithNpc || !targetNpcId) { - throw new Error('Expected a wuxia scene with at least one npc preset.'); - } - - requestChatMessageContentMock.mockResolvedValue( - JSON.stringify({ - storyText: '山道总算安静下来,你收住气息,重新判断前路。', - encounter: { - kind: 'npc', - npcId: targetNpcId, - }, - options: [ - { - functionId: 'idle_explore_forward', - actionText: '先稳住呼吸,再看看前面的动静。', - }, - ], + fetchMock.mockResolvedValue( + createApiEnvelopeResponse({ + storyText: '林间重新安静下来,你听见远处的风声。', + encounter: null, + options: availableOptions, }), ); const response = await generateNextStep( WorldType.WUXIA, playerCharacter, - [], - [ - { - text: '挥刀抢攻', - options: [], - historyRole: 'action', - }, - { - text: '山道客已经败下阵来。', - options: [], - historyRole: 'result', - }, - ], - '挥刀抢攻', - createContext({ - sceneId: sceneWithNpc.id, - sceneName: sceneWithNpc.name, - sceneDescription: sceneWithNpc.description, - pendingSceneEncounter: false, - }), + monsters, + storyHistory, + '继续向前', + context, { availableOptions }, ); - expect(response.encounter).toBeUndefined(); + expect(fetchMock).toHaveBeenCalledWith( + '/api/runtime/story/continue', + expect.objectContaining({ + method: 'POST', + body: JSON.stringify({ + sessionId: 'runtime-main', + clientVersion: 3, + choice: '继续向前', + requestOptions: { availableOptions }, + }), + }), + ); + expect(response.storyText).toBe('林间重新安静下来,你听见远处的风声。'); expect(response.options).toEqual(availableOptions); - const userPrompt = requestChatMessageContentMock.mock.calls.at(-1)?.[1]; - expect(userPrompt).toContain('encounter 必须为 null'); - expect(userPrompt).toContain('战斗结束后的续写'); }); - it('returns offline character chat suggestions when the plain-text client reports connectivity errors', async () => { - requestPlainTextCompletionMock.mockRejectedValue(connectivityError); + it('requests character chat suggestions from the runtime api server', async () => { + fetchMock.mockResolvedValue( + createApiEnvelopeResponse({ + text: '先说你真正担心的事。\n这件事你还瞒了我什么?\n先别急,我们慢慢说。', + }), + ); const suggestions = await generateCharacterPanelChatSuggestions( WorldType.WUXIA, @@ -579,21 +571,33 @@ describe('ai orchestration fallbacks', () => { targetStatus, ); - expect(suggestions).toEqual( - buildOfflineCharacterPanelChatSuggestions(targetCharacter), + expect(fetchMock).toHaveBeenCalledWith( + '/api/runtime/chat/character/suggestions', + expect.objectContaining({ + method: 'POST', + body: JSON.stringify({ + sessionId: 'runtime-main', + targetCharacter, + conversationHistory: [], + conversationSummary: '', + targetStatus, + }), + }), ); + expect(suggestions).toEqual([ + '先说你真正担心的事。', + '这件事你还瞒了我什么?', + '先别急,我们慢慢说。', + ]); }); - it('streams the offline character chat reply and forwards it to onUpdate when connectivity fails', async () => { + it('streams character chat reply from the runtime api server', async () => { const onUpdate = vi.fn(); const playerMessage = 'Tell me what you are really worried about.'; const conversationSummary = 'Lan has started to trust the player more.'; - const fallbackReply = buildOfflineCharacterPanelChatReply( - targetCharacter, - playerMessage, - conversationSummary, + fetchMock.mockResolvedValue( + createSseResponse('我会认真回答你,但这件事没你想得那么简单。'), ); - streamPlainTextCompletionMock.mockRejectedValue(connectivityError); const reply = await streamCharacterPanelChatReply( WorldType.WUXIA, @@ -608,16 +612,33 @@ describe('ai orchestration fallbacks', () => { { onUpdate }, ); - expect(reply).toBe(fallbackReply); + expect(fetchMock).toHaveBeenCalledWith( + '/api/runtime/chat/character/reply/stream', + expect.objectContaining({ + method: 'POST', + body: JSON.stringify({ + sessionId: 'runtime-main', + targetCharacter, + conversationHistory: [], + conversationSummary, + playerMessage, + targetStatus, + }), + }), + ); + expect(reply).toBe('我会认真回答你,但这件事没你想得那么简单。'); expect(onUpdate).toHaveBeenCalledOnce(); - expect(onUpdate).toHaveBeenCalledWith(fallbackReply); + expect(onUpdate).toHaveBeenCalledWith( + '我会认真回答你,但这件事没你想得那么简单。', + ); }); - it('uses the extracted NPC recruit fallback when recruit dialogue streaming loses connectivity', async () => { + it('streams npc recruit dialogue from the runtime api server', async () => { const onUpdate = vi.fn(); const encounter = createEncounter(); - const fallbackReply = buildOfflineNpcRecruitDialogue(encounter); - streamPlainTextCompletionMock.mockRejectedValue(connectivityError); + fetchMock.mockResolvedValue( + createSseResponse('你:和我一起走下去吧。\nLan:好,我答应你。'), + ); const reply = await streamNpcRecruitDialogue( WorldType.WUXIA, @@ -631,9 +652,23 @@ describe('ai orchestration fallbacks', () => { { onUpdate }, ); - expect(reply).toBe(fallbackReply); + expect(fetchMock).toHaveBeenCalledWith( + '/api/runtime/chat/npc/recruit/stream', + expect.objectContaining({ + method: 'POST', + body: JSON.stringify({ + sessionId: 'runtime-main', + encounter, + invitationText: 'Join us.', + recruitSummary: 'The party is ready to travel together.', + }), + }), + ); + expect(reply).toBe('你:和我一起走下去吧。\nLan:好,我答应你。'); expect(onUpdate).toHaveBeenCalledOnce(); - expect(onUpdate).toHaveBeenCalledWith(fallbackReply); + expect(onUpdate).toHaveBeenCalledWith( + '你:和我一起走下去吧。\nLan:好,我答应你。', + ); }); it('rejects custom world output when the model does not generate enough NPCs and scenes', async () => { diff --git a/src/services/ai.ts b/src/services/ai.ts index dd70dd95..8d3ebf4f 100644 --- a/src/services/ai.ts +++ b/src/services/ai.ts @@ -35,45 +35,34 @@ import { import { AIResponse, Character, - CharacterChatTurn, CustomWorldCreatorIntent, CustomWorldGenerationMode, CustomWorldProfile, - Encounter, SceneEncounterResult, SceneHostileNpc, SceneNpc, - StoryMoment, StoryOption, ThemePack, WorldStoryGraph, WorldType, } from '../types'; import { - buildOfflineCharacterPanelChatReply as buildOfflineCharacterPanelChatReplyFromFallback, - buildOfflineCharacterPanelChatSuggestions as buildOfflineCharacterPanelChatSuggestionsFromFallback, - buildOfflineCharacterPanelChatSummary as buildOfflineCharacterPanelChatSummaryFromFallback, - buildOfflineNpcChatDialogue as buildOfflineNpcChatDialogueFromFallback, - buildOfflineNpcRecruitDialogue as buildOfflineNpcRecruitDialogueFromFallback, -} from './aiFallbacks'; -import type { CustomWorldSceneImageRequest, CustomWorldSceneImageResult, StoryGenerationContext, StoryRequestOptions, TextStreamOptions, } from './aiTypes'; -import { fetchWithApiAuth } from './apiClient'; import { - buildCharacterPanelChatPrompt, - buildCharacterPanelChatSuggestionPrompt, - buildCharacterPanelChatSummaryPrompt, - CHARACTER_PANEL_CHAT_SUGGESTION_SYSTEM_PROMPT, - CHARACTER_PANEL_CHAT_SUMMARY_SYSTEM_PROMPT, - CHARACTER_PANEL_CHAT_SYSTEM_PROMPT, - CharacterChatPromptContext, - CharacterChatTargetStatus, -} from './characterChatPrompt'; + generateCharacterPanelChatSuggestions as generateCharacterPanelChatSuggestionsFromServer, + generateCharacterPanelChatSummary as generateCharacterPanelChatSummaryFromServer, + generateInitialStory as generateInitialStoryFromServer, + generateNextStep as generateNextStepFromServer, + streamCharacterPanelChatReply as streamCharacterPanelChatReplyFromServer, + streamNpcChatDialogue as streamNpcChatDialogueFromServer, + streamNpcRecruitDialogue as streamNpcRecruitDialogueFromServer, +} from './aiService'; +import { fetchWithApiAuth } from './apiClient'; import { buildCustomWorldRawProfileFromFramework, type CustomWorldGenerationFramework, @@ -105,20 +94,8 @@ import { requestPlainTextCompletion as requestPlainTextCompletionFromClient, streamPlainTextCompletion as streamPlainTextCompletionFromClient, } from './llmClient'; -import { - parseJsonResponseText as parseJsonResponseTextFromParser, - parseLineListContent as parseLineListContentFromParser, -} from './llmParsers'; -import { hasMixedNarrativeLanguage } from './narrativeLanguage'; -import { - buildNpcRecruitDialoguePrompt, - buildStrictNpcChatDialoguePrompt, - buildUserPrompt, - describeWorld, - NPC_CHAT_DIALOGUE_STRICT_SYSTEM_PROMPT, - NPC_RECRUIT_DIALOGUE_SYSTEM_PROMPT, - SYSTEM_PROMPT, -} from './prompt'; +import { parseJsonResponseText as parseJsonResponseTextFromParser } from './llmParsers'; +import type { CharacterChatTargetStatus } from './rpgRuntimeChatTypes'; import { buildFallbackActorNarrativeProfile, normalizeActorNarrativeProfile, @@ -1388,23 +1365,6 @@ function cloneStoryOption(option: StoryOption): StoryOption { }; } -function buildCharacterChatPromptContext( - context: StoryGenerationContext, -): CharacterChatPromptContext { - return { - playerHp: context.playerHp, - playerMaxHp: context.playerMaxHp, - playerMana: context.playerMana, - playerMaxMana: context.playerMaxMana, - inBattle: context.inBattle, - playerFacing: context.playerFacing, - playerAnimation: context.playerAnimation, - sceneName: context.sceneName ?? null, - sceneDescription: context.sceneDescription ?? null, - customWorldProfile: context.customWorldProfile ?? null, - }; -} - function resolveOptionsFromProvidedOptions( items: RawOptionItem[], availableOptions: StoryOption[], @@ -1505,357 +1465,9 @@ function getFallbackOptions( ); } -function buildOfflineResponse( - world: WorldType, - character: Character, - monsters: SceneHostileNpc[], - context: StoryGenerationContext, - choice?: string, - requestOptions: StoryRequestOptions = {}, -): AIResponse { - const scene = getScenePresetById(world, context.sceneId); - const fallbackEncounter = context.pendingSceneEncounter - ? normalizeEncounterResult( - scene?.npcs[0] - ? { kind: 'npc', npcId: scene.npcs[0].id } - : { kind: 'none' }, - world, - context, - ) - : undefined; - const resolution = buildEncounterDrivenResolution( - world, - monsters, - context, - fallbackEncounter, - ); - const constrainedOptions = - requestOptions.availableOptions?.map(cloneStoryOption) ?? - requestOptions.optionCatalog?.map(cloneStoryOption); - const options = - constrainedOptions ?? - getFallbackOptions(world, character, resolution.monsters, { - ...context, - inBattle: resolution.inBattle, - }); - const primaryMonster = - resolution.monsters.find((monster) => monster.hp > 0) ?? - resolution.monsters[0]; - const encounterName = context.encounterName || '前方的人影'; +export const generateInitialStoryStrict = generateInitialStoryFromServer; - if (!resolution.inBattle || !primaryMonster) { - return { - storyText: constrainedOptions - ? choice - ? `${encounterName}的态度与周围气氛都出现了新的变化,你需要立刻判断接下来如何应对。` - : `${context.sceneName || describeWorld(world)}的气氛仍在缓慢推进,眼前的${encounterName}正等待你的下一步反应。` - : choice - ? `主角暂时脱离了正面厮杀,四周重新安静下来,${context.sceneName || describeWorld(world)}的前路正等着继续探索。` - : `主角踏入${describeWorld(world)}世界的${context.sceneName || '前方区域'},眼前暂时没有新的敌对角色逼近。`, - options, - encounter: resolution.encounter, - }; - } - - return { - storyText: choice - ? `主角刚做出新的动作,前方的${primaryMonster.name}${primaryMonster.action},局势仍在持续绷紧。` - : `主角刚踏入战场,前方的${primaryMonster.name}${primaryMonster.action},战斗压力已经逼到眼前。`, - options, - encounter: resolution.encounter, - }; -} - -function buildStoryLanguageRepairPrompt(response: AIResponse) { - return [ - '请把下面 JSON 中的 storyText 与 options[].actionText 修复为自然中文。', - '只改写叙事和选项文案,不要改变 encounter、options 数量、顺序或 functionId。', - '如果英文只是角色名或地点名,可以保留名字本体;其余英文句子、英文解释和中英混杂表达都必须改成中文。', - JSON.stringify( - { - storyText: response.storyText, - encounter: response.encounter ?? null, - options: response.options.map((option) => ({ - functionId: option.functionId, - actionText: option.actionText, - })), - }, - null, - 2, - ), - ].join('\n\n'); -} - -function needsStoryLanguageRepair(response: AIResponse) { - return hasMixedNarrativeLanguage(response.storyText); -} - -function buildStoryLanguageFallbackText( - context: StoryGenerationContext, - inBattle: boolean, -) { - if (inBattle) { - return '敌意仍压在眼前,战斗局势还没有真正松开。'; - } - - if (context.encounterName) { - return `${context.encounterName}的态度与周围气氛都出现了新的变化,你需要立刻判断接下来如何应对。`; - } - - return `${context.sceneName || '眼前区域'}里的气氛又有了新的变化,你需要继续判断下一步。`; -} - -function finalizeStoryNarrativeLanguage( - response: AIResponse, - context: StoryGenerationContext, - inBattle: boolean, -): AIResponse { - if (!needsStoryLanguageRepair(response)) { - return response; - } - - return { - ...response, - storyText: buildStoryLanguageFallbackText(context, inBattle), - }; -} - -async function repairStoryNarrativeLanguage( - response: AIResponse, - worldType: WorldType, - character: Character, - monsters: SceneHostileNpc[], - context: StoryGenerationContext, - requestOptions: StoryRequestOptions, -) { - const responseBattleState = buildEncounterDrivenResolution( - worldType, - monsters, - context, - response.encounter, - ).inBattle; - - if (!needsStoryLanguageRepair(response)) { - return finalizeStoryNarrativeLanguage( - response, - context, - responseBattleState, - ); - } - - try { - const repairedContent = await requestChatMessageContent( - STORY_LANGUAGE_REPAIR_SYSTEM_PROMPT, - buildStoryLanguageRepairPrompt(response), - { - debugLabel: 'story-language-repair', - }, - ); - const repairedResponse = normalizeResponse( - parseJsonResponseTextFromParser(repairedContent), - worldType, - character, - monsters, - context, - requestOptions, - ); - const repairedBattleState = buildEncounterDrivenResolution( - worldType, - monsters, - context, - repairedResponse.encounter, - ).inBattle; - return finalizeStoryNarrativeLanguage( - repairedResponse, - context, - repairedBattleState, - ); - } catch (error) { - console.warn('Failed to repair mixed-language story response:', error); - return finalizeStoryNarrativeLanguage( - response, - context, - responseBattleState, - ); - } -} - -function normalizeResponse( - raw: unknown, - worldType: WorldType, - character: Character, - monsters: SceneHostileNpc[], - context: StoryGenerationContext, - requestOptions: StoryRequestOptions = {}, -): AIResponse { - const parsedEncounter = normalizeEncounterResult( - (raw as Record | null)?.encounter, - worldType, - context, - ); - const resolution = buildEncounterDrivenResolution( - worldType, - monsters, - context, - parsedEncounter, - ); - const responseContext = { - ...context, - inBattle: resolution.inBattle, - }; - const fallbackOptions = - requestOptions.availableOptions?.map(cloneStoryOption) ?? - requestOptions.optionCatalog?.map(cloneStoryOption) ?? - getFallbackOptions( - worldType, - character, - resolution.monsters, - responseContext, - ); - - if (!raw || typeof raw !== 'object') { - return { - storyText: responseContext.inBattle - ? '前方敌意仍在持续逼近,局势只允许继续交锋或抽身脱离。' - : '周围暂时平静下来,你可以继续探索或前往别处。', - options: fallbackOptions, - encounter: resolution.encounter, - }; - } - - const data = raw as Record; - const rawOptions = Array.isArray(data.options) ? data.options : []; - const optionItems = rawOptions - .map((option) => { - if (!option || typeof option !== 'object') return null; - const item = option as Record; - const functionId = - typeof item.functionId === 'string' ? item.functionId.trim() : ''; - if (!functionId) return null; - return { - functionId, - actionText: - typeof item.actionText === 'string' - ? item.actionText.trim() - : undefined, - } satisfies RawOptionItem; - }) - .filter(Boolean) as RawOptionItem[]; - - const options = requestOptions.availableOptions - ? resolveOptionsFromProvidedOptions( - optionItems, - requestOptions.availableOptions, - ) - : requestOptions.optionCatalog - ? resolveOptionsFromOptionCatalog( - optionItems, - requestOptions.optionCatalog, - ) - : resolveOptionsFromFunctionIds( - optionItems, - worldType, - character, - resolution.monsters, - responseContext, - ); - - return { - storyText: - typeof data.storyText === 'string' && data.storyText.trim() - ? data.storyText.trim() - : responseContext.inBattle - ? '敌人仍在前方压迫而来,战斗还没有结束。' - : '前路重新安静下来,可以继续决定接下来的探索方向。', - options: options.length > 0 ? options : fallbackOptions, - encounter: resolution.encounter, - }; -} - -async function requestCompletion( - userPrompt: string, - worldType: WorldType, - character: Character, - monsters: SceneHostileNpc[], - context: StoryGenerationContext, - requestOptions: StoryRequestOptions = {}, -): Promise { - const content = await requestChatMessageContent(SYSTEM_PROMPT, userPrompt, { - debugLabel: 'story-completion', - }); - - const response = normalizeResponse( - parseJsonResponseTextFromParser(content), - worldType, - character, - monsters, - context, - requestOptions, - ); - - return repairStoryNarrativeLanguage( - response, - worldType, - character, - monsters, - context, - requestOptions, - ); -} - -export async function generateInitialStoryStrict( - world: WorldType, - character: Character, - monsters: SceneHostileNpc[], - context: StoryGenerationContext, - requestOptions: StoryRequestOptions = {}, -): Promise { - return requestCompletion( - buildUserPrompt( - world, - character, - monsters, - [], - context, - undefined, - requestOptions.availableOptions, - requestOptions.optionCatalog, - ), - world, - character, - monsters, - context, - requestOptions, - ); -} - -export async function generateNextStepStrict( - world: WorldType, - character: Character, - monsters: SceneHostileNpc[], - history: StoryMoment[], - choice: string, - context: StoryGenerationContext, - requestOptions: StoryRequestOptions = {}, -): Promise { - return requestCompletion( - buildUserPrompt( - world, - character, - monsters, - history, - context, - choice, - requestOptions.availableOptions, - requestOptions.optionCatalog, - ), - world, - character, - monsters, - context, - requestOptions, - ); -} +export const generateNextStepStrict = generateNextStepFromServer; export async function generateCustomWorldSceneImage({ profile, @@ -2218,297 +1830,19 @@ export async function generateCustomWorldProfile( } } -export async function streamCharacterPanelChatReply( - world: WorldType, - playerCharacter: Character, - targetCharacter: Character, - storyHistory: StoryMoment[], - context: StoryGenerationContext, - conversationHistory: CharacterChatTurn[], - conversationSummary: string, - playerMessage: string, - targetStatus: CharacterChatTargetStatus, - options: TextStreamOptions = {}, -) { - const userPrompt = buildCharacterPanelChatPrompt({ - world, - playerCharacter, - targetCharacter, - storyHistory, - context: buildCharacterChatPromptContext(context), - conversationHistory, - conversationSummary, - playerMessage, - targetStatus, - }); +export const streamCharacterPanelChatReply = + streamCharacterPanelChatReplyFromServer; - try { - const reply = await streamPlainTextCompletionFromClient( - CHARACTER_PANEL_CHAT_SYSTEM_PROMPT, - userPrompt, - options, - ); - return ( - reply.trim() || - buildOfflineCharacterPanelChatReplyFromFallback( - targetCharacter, - playerMessage, - conversationSummary, - ) - ); - } catch (error) { - if (isLlmConnectivityErrorFromClient(error)) { - const fallbackText = buildOfflineCharacterPanelChatReplyFromFallback( - targetCharacter, - playerMessage, - conversationSummary, - ); - options.onUpdate?.(fallbackText); - return fallbackText; - } - throw error; - } -} +export const generateCharacterPanelChatSuggestions = + generateCharacterPanelChatSuggestionsFromServer; -export async function generateCharacterPanelChatSuggestions( - world: WorldType, - playerCharacter: Character, - targetCharacter: Character, - storyHistory: StoryMoment[], - context: StoryGenerationContext, - conversationHistory: CharacterChatTurn[], - conversationSummary: string, - targetStatus: CharacterChatTargetStatus, -) { - const fallbackSuggestions = - buildOfflineCharacterPanelChatSuggestionsFromFallback(targetCharacter); - const userPrompt = buildCharacterPanelChatSuggestionPrompt({ - world, - playerCharacter, - targetCharacter, - storyHistory, - context: buildCharacterChatPromptContext(context), - conversationHistory, - conversationSummary, - targetStatus, - }); +export const generateCharacterPanelChatSummary = + generateCharacterPanelChatSummaryFromServer; - try { - const text = await requestPlainTextCompletionFromClient( - CHARACTER_PANEL_CHAT_SUGGESTION_SYSTEM_PROMPT, - userPrompt, - ); - const parsedSuggestions = parseLineListContentFromParser(text, 3); - if (parsedSuggestions.length === 0) { - return fallbackSuggestions; - } - return [...parsedSuggestions, ...fallbackSuggestions].slice(0, 3); - } catch (error) { - if (isLlmConnectivityErrorFromClient(error)) { - return fallbackSuggestions; - } - throw error; - } -} +export const generateInitialStory = generateInitialStoryFromServer; -export async function generateCharacterPanelChatSummary( - world: WorldType, - playerCharacter: Character, - targetCharacter: Character, - storyHistory: StoryMoment[], - context: StoryGenerationContext, - conversationHistory: CharacterChatTurn[], - previousSummary: string, - targetStatus: CharacterChatTargetStatus, -) { - const fallbackSummary = buildOfflineCharacterPanelChatSummaryFromFallback( - targetCharacter, - conversationHistory, - previousSummary, - ); - const userPrompt = buildCharacterPanelChatSummaryPrompt({ - world, - playerCharacter, - targetCharacter, - storyHistory, - context: buildCharacterChatPromptContext(context), - conversationHistory, - previousSummary, - targetStatus, - }); +export const generateNextStep = generateNextStepFromServer; - try { - const text = await requestPlainTextCompletionFromClient( - CHARACTER_PANEL_CHAT_SUMMARY_SYSTEM_PROMPT, - userPrompt, - ); - return text.trim() || fallbackSummary; - } catch (error) { - if (isLlmConnectivityErrorFromClient(error)) { - return fallbackSummary; - } - throw error; - } -} +export const streamNpcChatDialogue = streamNpcChatDialogueFromServer; -export async function generateInitialStory( - world: WorldType, - character: Character, - monsters: SceneHostileNpc[], - context: StoryGenerationContext, - requestOptions: StoryRequestOptions = {}, -): Promise { - try { - return await requestCompletion( - buildUserPrompt( - world, - character, - monsters, - [], - context, - undefined, - requestOptions.availableOptions, - requestOptions.optionCatalog, - ), - world, - character, - monsters, - context, - requestOptions, - ); - } catch (error) { - if (isLlmConnectivityErrorFromClient(error)) { - return buildOfflineResponse( - world, - character, - monsters, - context, - undefined, - requestOptions, - ); - } - throw error; - } -} - -export async function generateNextStep( - world: WorldType, - character: Character, - monsters: SceneHostileNpc[], - history: StoryMoment[], - choice: string, - context: StoryGenerationContext, - requestOptions: StoryRequestOptions = {}, -): Promise { - try { - return await requestCompletion( - buildUserPrompt( - world, - character, - monsters, - history, - context, - choice, - requestOptions.availableOptions, - requestOptions.optionCatalog, - ), - world, - character, - monsters, - context, - requestOptions, - ); - } catch (error) { - if (isLlmConnectivityErrorFromClient(error)) { - return buildOfflineResponse( - world, - character, - monsters, - context, - choice, - requestOptions, - ); - } - throw error; - } -} - -export async function streamNpcChatDialogue( - world: WorldType, - character: Character, - encounter: Encounter, - monsters: SceneHostileNpc[], - history: StoryMoment[], - context: StoryGenerationContext, - topic: string, - resultSummary: string, - options: TextStreamOptions = {}, -) { - const userPrompt = buildStrictNpcChatDialoguePrompt( - world, - character, - encounter, - monsters, - history, - context, - topic, - resultSummary, - ); - - try { - return await streamPlainTextCompletionFromClient( - NPC_CHAT_DIALOGUE_STRICT_SYSTEM_PROMPT, - userPrompt, - options, - ); - } catch (error) { - if (isLlmConnectivityErrorFromClient(error)) { - const fallbackText = buildOfflineNpcChatDialogueFromFallback( - encounter, - topic, - ); - options.onUpdate?.(fallbackText); - return fallbackText; - } - throw error; - } -} - -export async function streamNpcRecruitDialogue( - world: WorldType, - character: Character, - encounter: Encounter, - monsters: SceneHostileNpc[], - history: StoryMoment[], - context: StoryGenerationContext, - invitationText: string, - recruitSummary: string, - options: TextStreamOptions = {}, -) { - const userPrompt = buildNpcRecruitDialoguePrompt( - world, - character, - encounter, - monsters, - history, - context, - invitationText, - recruitSummary, - ); - - try { - return await streamPlainTextCompletionFromClient( - NPC_RECRUIT_DIALOGUE_SYSTEM_PROMPT, - userPrompt, - options, - ); - } catch (error) { - if (isLlmConnectivityErrorFromClient(error)) { - const fallbackText = - buildOfflineNpcRecruitDialogueFromFallback(encounter); - options.onUpdate?.(fallbackText); - return fallbackText; - } - throw error; - } -} +export const streamNpcRecruitDialogue = streamNpcRecruitDialogueFromServer; diff --git a/src/services/aiService.ts b/src/services/aiService.ts index 8297632a..ba80297b 100644 --- a/src/services/aiService.ts +++ b/src/services/aiService.ts @@ -9,6 +9,7 @@ import type { NpcRecruitDialogueRequest, PlainTextResponse, } from '../../packages/shared/src/contracts/rpgRuntimeChat'; +import type { RuntimeStoryAiRequest } from '../../packages/shared/src/contracts/rpgRuntimeStoryState'; import type { CustomWorldGenerationProgress, GenerateCustomWorldProfileInput, @@ -32,21 +33,13 @@ import type { TextStreamOptions, } from './aiTypes'; import { fetchWithApiAuth, requestJson } from './apiClient'; -import { type CharacterChatTargetStatus } from './characterChatPrompt'; +import { type CharacterChatTargetStatus } from './rpgRuntimeChatTypes'; import { parseLineListContent } from './llmParsers'; const RUNTIME_API_BASE = '/api/runtime'; -type LegacyAiModule = typeof import('./ai'); - -let legacyAiModulePromise: Promise | null = null; - -async function loadLegacyAiModule() { - if (!legacyAiModulePromise) { - legacyAiModulePromise = import('./ai'); - } - - return legacyAiModulePromise; +function getRuntimeSessionIdFromContext(context: StoryGenerationContext) { + return context.runtimeSessionId?.trim() || undefined; } async function requestPlainText( @@ -169,29 +162,27 @@ export async function generateInitialStory( context: StoryGenerationContext, requestOptions: StoryRequestOptions = {}, ): Promise { - if (typeof window === 'undefined') { - const aiClient = await loadLegacyAiModule(); - return aiClient.generateInitialStory( - world, - character, - monsters, - context, - requestOptions, - ); - } + const sessionId = getRuntimeSessionIdFromContext(context); + const payload: RuntimeStoryAiRequest | Record = sessionId + ? { + sessionId, + clientVersion: context.runtimeActionVersion, + requestOptions, + } + : { + worldType: world, + character, + monsters, + context, + requestOptions, + }; return requestJson( `${RUNTIME_API_BASE}/story/initial`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - worldType: world, - character, - monsters, - context, - requestOptions, - }), + body: JSON.stringify(payload), }, '剧情开局生成失败', ); @@ -206,25 +197,18 @@ export async function generateNextStep( context: StoryGenerationContext, requestOptions: StoryRequestOptions = {}, ): Promise { - if (typeof window === 'undefined') { - const aiClient = await loadLegacyAiModule(); - return aiClient.generateNextStep( - world, - character, - monsters, - history, - choice, - context, - requestOptions, - ); - } - - return requestJson( - `${RUNTIME_API_BASE}/story/continue`, - { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ + const sessionId = getRuntimeSessionIdFromContext(context); + const payload: RuntimeStoryAiRequest | Record = sessionId + ? { + sessionId, + clientVersion: context.runtimeActionVersion, + choice, + lastFunctionId: context.lastFunctionId, + observeSignsRequested: context.observeSignsRequested, + recentActionResult: context.recentActionResult, + requestOptions, + } + : { worldType: world, character, monsters, @@ -232,7 +216,14 @@ export async function generateNextStep( choice, context, requestOptions, - }), + }; + + return requestJson( + `${RUNTIME_API_BASE}/story/continue`, + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), }, '剧情续写失败', ); @@ -248,30 +239,25 @@ export async function generateCharacterPanelChatSuggestions( conversationSummary: string, targetStatus: CharacterChatTargetStatus, ) { - if (typeof window === 'undefined') { - const aiClient = await loadLegacyAiModule(); - return aiClient.generateCharacterPanelChatSuggestions( - world, - playerCharacter, - targetCharacter, - storyHistory, - context, - conversationHistory, - conversationSummary, - targetStatus, - ); - } - - const payload = { - worldType: world, - playerCharacter, - targetCharacter, - storyHistory, - context, - conversationHistory, - conversationSummary, - targetStatus, - } satisfies CharacterChatSuggestionsRequest; + const sessionId = getRuntimeSessionIdFromContext(context); + const payload = sessionId + ? ({ + sessionId, + targetCharacter, + conversationHistory, + conversationSummary, + targetStatus, + } satisfies CharacterChatSuggestionsRequest) + : ({ + worldType: world, + playerCharacter, + targetCharacter, + storyHistory, + context, + conversationHistory, + conversationSummary, + targetStatus, + } satisfies CharacterChatSuggestionsRequest); const { text } = await requestPlainText( `${RUNTIME_API_BASE}/chat/character/suggestions`, @@ -291,30 +277,25 @@ export async function generateCharacterPanelChatSummary( previousSummary: string, targetStatus: CharacterChatTargetStatus, ) { - if (typeof window === 'undefined') { - const aiClient = await loadLegacyAiModule(); - return aiClient.generateCharacterPanelChatSummary( - world, - playerCharacter, - targetCharacter, - storyHistory, - context, - conversationHistory, - previousSummary, - targetStatus, - ); - } - - const payload = { - worldType: world, - playerCharacter, - targetCharacter, - storyHistory, - context, - conversationHistory, - previousSummary, - targetStatus, - } satisfies CharacterChatSummaryRequest; + const sessionId = getRuntimeSessionIdFromContext(context); + const payload = sessionId + ? ({ + sessionId, + targetCharacter, + conversationHistory, + previousSummary, + targetStatus, + } satisfies CharacterChatSummaryRequest) + : ({ + worldType: world, + playerCharacter, + targetCharacter, + storyHistory, + context, + conversationHistory, + previousSummary, + targetStatus, + } satisfies CharacterChatSummaryRequest); const { text } = await requestPlainText( `${RUNTIME_API_BASE}/chat/character/summary`, @@ -336,33 +317,27 @@ export async function streamCharacterPanelChatReply( targetStatus: CharacterChatTargetStatus, options: TextStreamOptions = {}, ) { - if (typeof window === 'undefined') { - const aiClient = await loadLegacyAiModule(); - return aiClient.streamCharacterPanelChatReply( - world, - playerCharacter, - targetCharacter, - storyHistory, - context, - conversationHistory, - conversationSummary, - playerMessage, - targetStatus, - options, - ); - } - - const payload = { - worldType: world, - playerCharacter, - targetCharacter, - storyHistory, - context, - conversationHistory, - conversationSummary, - playerMessage, - targetStatus, - } satisfies CharacterChatReplyRequest; + const sessionId = getRuntimeSessionIdFromContext(context); + const payload = sessionId + ? ({ + sessionId, + targetCharacter, + conversationHistory, + conversationSummary, + playerMessage, + targetStatus, + } satisfies CharacterChatReplyRequest) + : ({ + worldType: world, + playerCharacter, + targetCharacter, + storyHistory, + context, + conversationHistory, + conversationSummary, + playerMessage, + targetStatus, + } satisfies CharacterChatReplyRequest); const reply = await requestPlainTextStream( `${RUNTIME_API_BASE}/chat/character/reply/stream`, @@ -383,31 +358,24 @@ export async function streamNpcChatDialogue( resultSummary: string, options: TextStreamOptions = {}, ) { - if (typeof window === 'undefined') { - const aiClient = await loadLegacyAiModule(); - return aiClient.streamNpcChatDialogue( - world, - character, - encounter, - monsters, - history, - context, - topic, - resultSummary, - options, - ); - } - - const payload = { - worldType: world, - character, - encounter, - monsters, - history, - context, - topic, - resultSummary, - } satisfies NpcChatDialogueRequest; + const sessionId = getRuntimeSessionIdFromContext(context); + const payload = sessionId + ? ({ + sessionId, + encounter, + topic, + resultSummary, + } satisfies NpcChatDialogueRequest) + : ({ + worldType: world, + character, + encounter, + monsters, + history, + context, + topic, + resultSummary, + } satisfies NpcChatDialogueRequest); const dialogue = await requestPlainTextStream( `${RUNTIME_API_BASE}/chat/npc/dialogue/stream`, @@ -442,14 +410,9 @@ export async function streamNpcChatTurn( npcInitiatesConversation?: boolean; } = {}, ) { - const payload = { - worldType: world, - character, - player: character, + const sessionId = getRuntimeSessionIdFromContext(context); + const commonChatPayload = { encounter, - monsters, - history, - context, conversationHistory: conversationHistory ?? [], dialogue: conversationHistory ?? [], playerMessage, @@ -457,7 +420,7 @@ export async function streamNpcChatTurn( npcInitiatesConversation: options.npcInitiatesConversation ?? false, questOfferContext: options.questOfferContext ? { - state: options.questOfferContext.state, + state: sessionId ? {} : options.questOfferContext.state, encounter, turnCount: options.questOfferContext.turnCount, } @@ -471,7 +434,21 @@ export async function streamNpcChatTurn( })), } : null, - } satisfies NpcChatTurnRequest; + }; + const payload = sessionId + ? ({ + sessionId, + ...commonChatPayload, + } satisfies NpcChatTurnRequest) + : ({ + worldType: world, + character, + player: character, + monsters, + history, + context, + ...commonChatPayload, + } satisfies NpcChatTurnRequest); const response = await fetchWithApiAuth( `${RUNTIME_API_BASE}/chat/npc/turn/stream`, @@ -570,31 +547,24 @@ export async function streamNpcRecruitDialogue( recruitSummary: string, options: TextStreamOptions = {}, ) { - if (typeof window === 'undefined') { - const aiClient = await loadLegacyAiModule(); - return aiClient.streamNpcRecruitDialogue( - world, - character, - encounter, - monsters, - history, - context, - invitationText, - recruitSummary, - options, - ); - } - - const payload = { - worldType: world, - character, - encounter, - monsters, - history, - context, - invitationText, - recruitSummary, - } satisfies NpcRecruitDialogueRequest; + const sessionId = getRuntimeSessionIdFromContext(context); + const payload = sessionId + ? ({ + sessionId, + encounter, + invitationText, + recruitSummary, + } satisfies NpcRecruitDialogueRequest) + : ({ + worldType: world, + character, + encounter, + monsters, + history, + context, + invitationText, + recruitSummary, + } satisfies NpcRecruitDialogueRequest); const dialogue = await requestPlainTextStream( `${RUNTIME_API_BASE}/chat/npc/recruit/stream`, diff --git a/src/services/aiTypes.ts b/src/services/aiTypes.ts index 51ded8b9..2acfe96a 100644 --- a/src/services/aiTypes.ts +++ b/src/services/aiTypes.ts @@ -88,6 +88,8 @@ export interface CustomWorldSceneImageResult { } export interface StoryGenerationContext { + runtimeSessionId?: string | null; + runtimeActionVersion?: number; playerHp: number; playerMaxHp: number; playerMana: number; diff --git a/src/services/big-fish-gallery/bigFishGalleryClient.test.ts b/src/services/big-fish-gallery/bigFishGalleryClient.test.ts new file mode 100644 index 00000000..5407598e --- /dev/null +++ b/src/services/big-fish-gallery/bigFishGalleryClient.test.ts @@ -0,0 +1,44 @@ +import { beforeEach, expect, test, vi } from 'vitest'; + +import { ApiClientError } from '../apiClient'; + +const { requestJsonMock } = vi.hoisted(() => ({ + requestJsonMock: vi.fn(), +})); + +vi.mock('../apiClient', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + requestJson: requestJsonMock, + }; +}); + +import { listBigFishGallery } from './bigFishGalleryClient'; + +beforeEach(() => { + requestJsonMock.mockReset(); +}); + +test('listBigFishGallery returns empty items when public gallery is not ready', async () => { + requestJsonMock.mockRejectedValueOnce( + new ApiClientError({ + message: '读取大鱼吃小鱼广场失败', + status: 400, + code: 'HTTP_400', + }), + ); + + await expect(listBigFishGallery()).resolves.toEqual({ items: [] }); +}); + +test('listBigFishGallery keeps non-gallery-read errors visible', async () => { + const error = new ApiClientError({ + message: '服务暂不可用', + status: 503, + code: 'HTTP_503', + }); + requestJsonMock.mockRejectedValueOnce(error); + + await expect(listBigFishGallery()).rejects.toBe(error); +}); diff --git a/src/services/big-fish-gallery/bigFishGalleryClient.ts b/src/services/big-fish-gallery/bigFishGalleryClient.ts index 2f51fc5c..d3c1ef15 100644 --- a/src/services/big-fish-gallery/bigFishGalleryClient.ts +++ b/src/services/big-fish-gallery/bigFishGalleryClient.ts @@ -26,7 +26,10 @@ export async function listBigFishGallery() { }, ); } catch (error) { - if (error instanceof ApiClientError && error.status === 404) { + if ( + error instanceof ApiClientError && + (error.status === 400 || error.status === 404) + ) { return { items: [] }; } throw error; diff --git a/src/services/characterChatPrompt.ts b/src/services/characterChatPrompt.ts deleted file mode 100644 index 49ceb292..00000000 --- a/src/services/characterChatPrompt.ts +++ /dev/null @@ -1 +0,0 @@ -export * from '../prompts/characterChatPrompts'; diff --git a/src/services/customWorldAgentUiState.test.ts b/src/services/customWorldAgentUiState.test.ts index e9ce7d6f..cbdc7e3b 100644 --- a/src/services/customWorldAgentUiState.test.ts +++ b/src/services/customWorldAgentUiState.test.ts @@ -61,6 +61,7 @@ test('custom world agent ui state reads from query first and persists to session activeSessionId: 'session-1', activeOperationId: 'operation-1', customWorldGenerationSource: 'agent-draft-foundation', + ownerUserId: 'user-1', }); currentUrl = '/play'; @@ -75,6 +76,48 @@ test('custom world agent ui state reads from query first and persists to session expect(readCustomWorldAgentUiState(env)).toEqual({}); }); +test('custom world agent ui state hydrates query owner from matching stored session only', () => { + const sessionStorage = createMemoryStorage(); + sessionStorage.setItem( + 'genarrative.custom-world-agent-ui.v1', + JSON.stringify({ + activeSessionId: 'session-1', + ownerUserId: 'user-1', + }), + ); + + expect( + readCustomWorldAgentUiState({ + location: { + pathname: '/', + search: '?customWorldSessionId=session-1', + }, + history: null, + sessionStorage, + }), + ).toEqual({ + activeSessionId: 'session-1', + activeOperationId: null, + customWorldGenerationSource: null, + ownerUserId: 'user-1', + }); + + expect( + readCustomWorldAgentUiState({ + location: { + pathname: '/', + search: '?customWorldSessionId=session-2', + }, + history: null, + sessionStorage, + }), + ).toEqual({ + activeSessionId: 'session-2', + activeOperationId: null, + customWorldGenerationSource: null, + }); +}); + test('custom world agent ui state only auto restores stored pointers on RPG creation paths', () => { const sessionStorage = createMemoryStorage(); sessionStorage.setItem( diff --git a/src/services/customWorldAgentUiState.ts b/src/services/customWorldAgentUiState.ts index 5300fcd3..49104470 100644 --- a/src/services/customWorldAgentUiState.ts +++ b/src/services/customWorldAgentUiState.ts @@ -115,18 +115,38 @@ export function readCustomWorldAgentUiState( params.get(CUSTOM_WORLD_GENERATION_SOURCE_QUERY_KEY), ), }; + const storedValue = resolved.sessionStorage?.getItem( + CUSTOM_WORLD_AGENT_UI_STATE_STORAGE_KEY, + ); if ( stateFromQuery.activeSessionId || stateFromQuery.activeOperationId || stateFromQuery.customWorldGenerationSource ) { - return stateFromQuery; + let storedOwnerUserId: string | null = null; + if (storedValue) { + try { + const parsed = JSON.parse(storedValue) as CustomWorldAgentUiState; + const storedSessionId = normalizeValue(parsed.activeSessionId); + if ( + storedSessionId && + storedSessionId === stateFromQuery.activeSessionId + ) { + storedOwnerUserId = normalizeValue(parsed.ownerUserId); + } + } catch { + resolved.sessionStorage?.removeItem(CUSTOM_WORLD_AGENT_UI_STATE_STORAGE_KEY); + } + } + + return { + ...stateFromQuery, + // URL 只承载可分享的 session 指针,用户归属仍仅来自本机 sessionStorage。 + ...(storedOwnerUserId ? { ownerUserId: storedOwnerUserId } : {}), + }; } - const storedValue = resolved.sessionStorage?.getItem( - CUSTOM_WORLD_AGENT_UI_STATE_STORAGE_KEY, - ); if (!storedValue) { return {}; } diff --git a/src/services/miniGameDraftGenerationProgress.test.ts b/src/services/miniGameDraftGenerationProgress.test.ts new file mode 100644 index 00000000..f4b28507 --- /dev/null +++ b/src/services/miniGameDraftGenerationProgress.test.ts @@ -0,0 +1,66 @@ +import { describe, expect, test } from 'vitest'; + +import { + buildMiniGameDraftGenerationProgress, + type MiniGameDraftGenerationState, +} from './miniGameDraftGenerationProgress'; + +describe('miniGameDraftGenerationProgress', () => { + test('big fish draft generation exposes multiple draft steps', () => { + const state: MiniGameDraftGenerationState = { + kind: 'big-fish', + phase: 'big-fish-draft', + startedAtMs: 1000, + completedAssetCount: 0, + totalAssetCount: 0, + error: null, + }; + + const progress = buildMiniGameDraftGenerationProgress(state, 1500); + + expect(progress).not.toBeNull(); + expect(progress?.steps).toHaveLength(3); + expect(progress?.steps.map((step) => step.id)).toEqual([ + 'big-fish-draft', + 'big-fish-levels', + 'big-fish-runtime', + ]); + expect(progress?.steps[0]?.label).toBe('整理玩法骨架'); + }); + + test('big fish generation progresses to level and runtime phases over time', () => { + const state: MiniGameDraftGenerationState = { + kind: 'big-fish', + phase: 'big-fish-draft', + startedAtMs: 1000, + completedAssetCount: 0, + totalAssetCount: 0, + error: null, + }; + + const levelProgress = buildMiniGameDraftGenerationProgress(state, 3200); + const runtimeProgress = buildMiniGameDraftGenerationProgress(state, 6200); + + expect(levelProgress?.phaseId).toBe('big-fish-levels'); + expect(levelProgress?.phaseLabel).toBe('编译等级蓝图'); + expect(runtimeProgress?.phaseId).toBe('big-fish-runtime'); + expect(runtimeProgress?.phaseLabel).toBe('校准场地与参数'); + }); + + test('big fish ready copy directs user to continue generating assets on result page', () => { + const state: MiniGameDraftGenerationState = { + kind: 'big-fish', + phase: 'ready', + startedAtMs: 1000, + completedAssetCount: 0, + totalAssetCount: 0, + error: null, + }; + + const progress = buildMiniGameDraftGenerationProgress(state, 2000); + + expect(progress?.phaseDetail).toBe( + '玩法草稿已准备完成,可进入结果页继续生成主图、动作和背景。', + ); + }); +}); diff --git a/src/services/miniGameDraftGenerationProgress.ts b/src/services/miniGameDraftGenerationProgress.ts index 07b7da1f..f231d4ee 100644 --- a/src/services/miniGameDraftGenerationProgress.ts +++ b/src/services/miniGameDraftGenerationProgress.ts @@ -11,11 +11,11 @@ export type MiniGameDraftGenerationKind = 'puzzle' | 'big-fish'; export type MiniGameDraftGenerationPhase = | 'idle' | 'compile' + | 'big-fish-draft' + | 'big-fish-levels' + | 'big-fish-runtime' | 'puzzle-images' | 'puzzle-select-image' - | 'big-fish-main-images' - | 'big-fish-motions' - | 'big-fish-background' | 'ready' | 'failed'; @@ -64,29 +64,23 @@ const PUZZLE_STEPS = [ const BIG_FISH_STEPS = [ { - id: 'compile', - label: '编译玩法草稿', - detail: '生成关卡角色描述、生态背景与运行参数。', + id: 'big-fish-draft', + label: '整理玩法骨架', + detail: '收拢玩法承诺、成长阶梯与风险节奏。', + weight: 30, + }, + { + id: 'big-fish-levels', + label: '编译等级蓝图', + detail: '生成每级角色描述、形象描述与动作描述。', + weight: 45, + }, + { + id: 'big-fish-runtime', + label: '校准场地与参数', + detail: '整理背景蓝图与运行参数,准备结果页。', weight: 25, }, - { - id: 'big-fish-main-images', - label: '生成角色图片', - detail: '为每个成长阶段生成主形象。', - weight: 30, - }, - { - id: 'big-fish-motions', - label: '生成动作素材', - detail: '补齐漂浮与游动动作素材。', - weight: 30, - }, - { - id: 'big-fish-background', - label: '生成场地背景', - detail: '生成玩法场地背景图。', - weight: 15, - }, ] as const satisfies ReadonlyArray; function clampProgress(value: number) { @@ -138,7 +132,7 @@ export function createMiniGameDraftGenerationState( ): MiniGameDraftGenerationState { return { kind, - phase: 'compile', + phase: kind === 'big-fish' ? 'big-fish-draft' : 'compile', startedAtMs: Date.now(), completedAssetCount: 0, totalAssetCount: 0, @@ -146,6 +140,16 @@ export function createMiniGameDraftGenerationState( }; } +function resolveBigFishPhaseByElapsedMs(elapsedMs: number): MiniGameDraftGenerationPhase { + if (elapsedMs >= 4_500) { + return 'big-fish-runtime'; + } + if (elapsedMs >= 1_800) { + return 'big-fish-levels'; + } + return 'big-fish-draft'; +} + export function buildMiniGameDraftGenerationProgress( state: MiniGameDraftGenerationState | null, nowMs = Date.now(), @@ -154,46 +158,66 @@ export function buildMiniGameDraftGenerationProgress( return null; } - const steps = getStepDefinitions(state.kind); - const activeStepIndex = getActiveStepIndex(steps, state.phase); + const elapsedMs = Math.max(0, nowMs - state.startedAtMs); + const normalizedState = + state.kind === 'big-fish' && + state.phase !== 'failed' && + state.phase !== 'ready' + ? { + ...state, + phase: resolveBigFishPhaseByElapsedMs(elapsedMs), + } + : state; + + const steps = getStepDefinitions(normalizedState.kind); + const activeStepIndex = getActiveStepIndex(steps, normalizedState.phase); const completedWeight = steps - .slice(0, state.phase === 'ready' ? steps.length : activeStepIndex) + .slice(0, normalizedState.phase === 'ready' ? steps.length : activeStepIndex) .reduce((sum, step) => sum + step.weight, 0); const activeStep = steps[activeStepIndex] ?? steps[0]; const assetRatio = - state.totalAssetCount > 0 - ? Math.min(1, state.completedAssetCount / state.totalAssetCount) - : state.phase === 'ready' + normalizedState.totalAssetCount > 0 + ? Math.min(1, normalizedState.completedAssetCount / normalizedState.totalAssetCount) + : normalizedState.phase === 'ready' ? 1 - : 0; + : normalizedState.kind === 'big-fish' + ? 0.55 + : 0; const overallProgress = - state.phase === 'failed' + normalizedState.phase === 'failed' ? Math.max(1, completedWeight) - : state.phase === 'ready' + : normalizedState.phase === 'ready' ? 100 : completedWeight + activeStep.weight * assetRatio; return { - phaseId: state.phase, + phaseId: normalizedState.phase, phaseLabel: - state.phase === 'failed' + normalizedState.phase === 'failed' ? '生成失败' - : state.phase === 'ready' + : normalizedState.phase === 'ready' ? '生成完成' : activeStep.label, phaseDetail: - state.error ?? - (state.phase === 'ready' - ? '完整草稿与资产已准备完成。' + normalizedState.error ?? + (normalizedState.phase === 'ready' + ? normalizedState.kind === 'big-fish' + ? '玩法草稿已准备完成,可进入结果页继续生成主图、动作和背景。' + : '完整草稿与资产已准备完成。' : activeStep.detail), batchLabel: activeStep.label, overallProgress: clampProgress(overallProgress), completedWeight: clampProgress(overallProgress), totalWeight: 100, - elapsedMs: Math.max(0, nowMs - state.startedAtMs), - estimatedRemainingMs: state.phase === 'ready' ? 0 : null, + elapsedMs, + estimatedRemainingMs: + normalizedState.phase === 'ready' + ? 0 + : normalizedState.kind === 'big-fish' + ? Math.max(0, 7_000 - elapsedMs) + : null, activeStepIndex, - steps: buildMiniGameProgressSteps(steps, activeStepIndex, state), + steps: buildMiniGameProgressSteps(steps, activeStepIndex, normalizedState), }; } diff --git a/src/services/prompt.test.ts b/src/services/prompt.test.ts deleted file mode 100644 index 40c29e59..00000000 --- a/src/services/prompt.test.ts +++ /dev/null @@ -1,301 +0,0 @@ -import { describe, expect, it } from 'vitest'; - -import { AnimationState, type Character, WorldType } from '../types'; -import { buildExpandedCustomWorldProfile } from './customWorldBuilder'; -import { buildUserPrompt } from './prompt'; -import { buildSceneNarrativeDirective } from './storyEngine/sceneNarrativeDirector'; -import { buildEncounterVisibilitySlice } from './storyEngine/visibilityEngine'; - -function createCharacter(): Character { - return { - id: 'hero', - name: '林澈', - title: '行旅客', - description: '一名谨慎前行的旅人。', - backstory: '从北境一路追着旧案残线而来。', - avatar: '/hero.png', - portrait: '/hero-portrait.png', - assetFolder: 'hero', - assetVariant: 'default', - attributes: { - strength: 10, - agility: 9, - intelligence: 8, - spirit: 9, - }, - personality: '谨慎、克制、先看局势。', - skills: [], - adventureOpenings: {}, - }; -} - -describe('buildUserPrompt', () => { - it('does not leak full custom-world backstory on first contact', () => { - const profile = buildExpandedCustomWorldProfile( - { - id: 'prompt-world', - name: '裂潮边城', - subtitle: '旧案回响', - summary: '一座在裂潮与旧案回响之间摇摇欲坠的边城。', - tone: '紧张、克制、暗流涌动', - playerGoal: '查清边城裂潮背后的封桥旧令', - templateWorldType: 'WUXIA', - majorFactions: ['巡边司', '潮商会'], - coreConflicts: ['裂潮再度逼近边路', '封桥旧案再被人提起'], - playableNpcs: [ - { - id: 'playable-1', - name: '沈砺', - title: '灰炬向导', - role: '向导', - description: '熟悉裂潮边路的灰炬向导。', - backstory: '曾在旧撤离线里失去一整支同行队。', - personality: '谨慎寡言,先看风向再开口。', - motivation: '想查清旧撤离线为何再次失控。', - combatStyle: '短弓牵制后贴近补刀。', - initialAffinity: 18, - relationshipHooks: ['旧撤离线', '名单'], - tags: ['裂潮', '向导'], - backstoryReveal: { - publicSummary: '他只说自己熟悉边路。', - chapters: [ - { id: 'surface', title: '表层来意', affinityRequired: 0, teaser: '他总盯着风向和路标。', content: '他先把注意力放在边路是否还能走。', contextSnippet: '他总先谈路和风。' }, - { id: 'scar', title: '旧事裂痕', affinityRequired: 30, teaser: '旧撤离线像在他身上留下了什么。', content: '那次撤离失控后,他一直没再离开这片边路。', contextSnippet: '撤离旧事还没过去。' }, - { id: 'bind', title: '隐藏执念', affinityRequired: 60, teaser: '名单上的名字让他一直不肯放手。', content: '他一直在比对那份回响名单与旧撤离线。', contextSnippet: '他一直在查名单。' }, - { id: 'truth', title: '最终底牌', affinityRequired: 90, teaser: '他知道裂潮里有人故意改过路标。', content: '他怀疑有人借裂潮重启旧案。', contextSnippet: '有人在利用裂潮。' }, - ], - }, - skills: [], - initialItems: [], - }, - ], - storyNpcs: [ - { - id: 'story-1', - name: '梁砺', - title: '断桥巡守', - role: '巡守', - description: '守着断桥与旧哨火的巡守。', - backstory: '旧案爆发时,他是最后一个封桥的人。', - personality: '警觉直接,不喜欢绕弯。', - motivation: '不想让旧案再次借裂潮翻上来。', - combatStyle: '长兵先压,再卡住路口。', - initialAffinity: 6, - relationshipHooks: ['封桥', '旧哨火'], - tags: ['巡守', '断桥'], - backstoryReveal: { - publicSummary: '他只承认自己还在守桥。', - chapters: [ - { id: 'surface', title: '表层来意', affinityRequired: 0, teaser: '他只说桥还不能放开。', content: '他总先谈桥和路。', contextSnippet: '桥还不能放开。' }, - { id: 'scar', title: '旧事裂痕', affinityRequired: 30, teaser: '封桥那夜明显留下了后劲。', content: '他始终忘不了那夜桥上的名单。', contextSnippet: '封桥旧事还压着他。' }, - { id: 'bind', title: '隐藏执念', affinityRequired: 60, teaser: '他像还在替谁守着一个错误。', content: '他一直替旧命令继续守线。', contextSnippet: '他还在守旧命令。' }, - { id: 'truth', title: '最终底牌', affinityRequired: 90, teaser: '有人逼他在封桥和救人之间选过。', content: '那夜真正下封桥令的人还没有露面。', contextSnippet: '封桥命令另有来头。' }, - ], - }, - skills: [], - initialItems: [ - { - id: 'item-1', - name: '旧哨铜钥', - category: '稀有品', - quantity: 1, - rarity: 'rare', - description: '钥身磨得发亮。', - tags: ['旧哨火'], - }, - ], - }, - ], - items: [], - landmarks: [ - { - id: 'landmark-1', - name: '断桥旧哨', - description: '旧哨火和断桥一起守着边城北口。', - sceneNpcIds: ['story-1'], - connections: [], - }, - ], - }, - '玩家想要一个裂潮边城与旧案回响交织的世界。', - ); - - const npc = profile.storyNpcs[0]!; - const visibilitySlice = buildEncounterVisibilitySlice({ - narrativeProfile: npc.narrativeProfile, - backstoryReveal: npc.backstoryReveal, - disclosureStage: 'guarded', - isFirstMeaningfulContact: true, - seenBackstoryChapterIds: [], - storyEngineMemory: { - discoveredFactIds: [], - activeThreadIds: npc.narrativeProfile?.relatedThreadIds ?? [], - resolvedScarIds: [], - recentCarrierIds: [], - }, - activeThreadIds: npc.narrativeProfile?.relatedThreadIds ?? [], - }); - const prompt = buildUserPrompt( - WorldType.CUSTOM, - createCharacter(), - [], - [], - { - playerHp: 30, - playerMaxHp: 40, - playerMana: 10, - playerMaxMana: 20, - inBattle: false, - playerX: 0, - playerFacing: 'right', - playerAnimation: AnimationState.IDLE, - skillCooldowns: {}, - sceneId: 'custom-scene-landmark-1', - sceneName: '断桥旧哨', - sceneDescription: '风里尽是旧哨火和潮声。', - encounterKind: 'npc', - encounterId: npc.id, - encounterName: npc.name, - encounterDescription: npc.description, - encounterContext: npc.role, - encounterAffinity: npc.initialAffinity, - encounterAffinityText: '对你仍有戒备,也在观察你会怎么试探。', - encounterDisclosureStage: 'guarded', - encounterWarmthStage: 'distant', - encounterAnswerMode: 'situational_only', - encounterAllowedTopics: ['眼前危险', '现场判断', '模糊钩子'], - encounterBlockedTopics: ['完整来历', '真正目标', '旧事全貌'], - isFirstMeaningfulContact: true, - firstContactRelationStance: 'guarded', - recentSharedEvent: '你们还只是刚刚真正把话对上。', - talkPriority: '优先谈桥口、来意和眼前压力,不要直接摊开旧案全貌。', - encounterCustomProfile: npc, - encounterNarrativeProfile: npc.narrativeProfile, - visibilitySlice, - sceneNarrativeDirective: buildSceneNarrativeDirective({ - sceneId: 'custom-scene-landmark-1', - sceneName: '断桥旧哨', - encounterId: npc.id, - encounterName: npc.name, - recentActions: [], - activeThreadIds: npc.narrativeProfile?.relatedThreadIds ?? [], - visibilitySlice, - encounterNarrativeProfile: npc.narrativeProfile, - disclosureStage: 'guarded', - isFirstMeaningfulContact: true, - affinity: npc.initialAffinity, - }), - activeThreadIds: npc.narrativeProfile?.relatedThreadIds ?? [], - customWorldProfile: profile, - }, - ); - - expect(prompt).toContain(npc.narrativeProfile?.publicMask ?? ''); - expect(prompt).toContain(npc.narrativeProfile?.immediatePressure ?? ''); - expect(prompt).not.toContain(npc.backstory); - expect(prompt).not.toContain(npc.backstoryReveal.chapters[3]!.content); - expect(prompt).not.toContain(npc.initialItems[0]!.name); - }); - - it('requires an empty encounter payload during non-pending follow-up reasoning such as post-battle continuation', () => { - const prompt = buildUserPrompt( - WorldType.WUXIA, - createCharacter(), - [], - [ - { - text: '挥刀抢攻', - options: [], - historyRole: 'action', - }, - { - text: '山道客已经败下阵来。', - options: [], - historyRole: 'result', - }, - ], - { - playerHp: 26, - playerMaxHp: 40, - playerMana: 8, - playerMaxMana: 20, - inBattle: false, - playerX: 0, - playerFacing: 'right', - playerAnimation: AnimationState.IDLE, - skillCooldowns: {}, - sceneId: 'forest_road', - sceneName: '山道', - sceneDescription: '风从林梢压下来,地上还留着刚才交手的痕迹。', - pendingSceneEncounter: false, - }, - '挥刀抢攻', - ); - - expect(prompt).toContain('encounter 必须为 null'); - expect(prompt).toContain('战斗结束后的续写'); - }); - - it('does not feed mixed-language history and directive snippets back into story prompts', () => { - const prompt = buildUserPrompt( - WorldType.WUXIA, - createCharacter(), - [], - [ - { - text: 'Move forward carefully.', - options: [], - historyRole: 'action', - }, - { - text: 'The wind is cold. 你听见山道尽头有脚步声。', - options: [], - historyRole: 'result', - }, - ], - { - playerHp: 26, - playerMaxHp: 40, - playerMana: 8, - playerMaxMana: 20, - inBattle: false, - playerX: 0, - playerFacing: 'right', - playerAnimation: AnimationState.ATTACK, - skillCooldowns: {}, - sceneId: 'forest_road', - sceneName: '山道', - sceneDescription: '风从林梢压下来。', - pendingSceneEncounter: false, - conversationSituation: 'post_battle_breath', - conversationPressure: 'medium', - recentSharedEvent: - 'A fight just ended. Both sides are still catching their breath.', - talkPriority: - 'Focus on the most useful judgment, danger, and next step.', - partyRelationshipNotes: - 'Lan is becoming more open in private conversation.', - recentChronicleSummary: 'Baseline summary from previous run.', - sceneNarrativeDirective: { - primaryPressure: 'Danger is still active near the camp.', - activeThreadIds: ['thread-old-case'], - foregroundActorIds: [], - foregroundCarrierIds: [], - revealBudget: 'low', - emotionalCadence: 'tense', - }, - }, - 'Move forward carefully.', - ); - - expect(prompt).not.toContain('A fight just ended'); - expect(prompt).not.toContain('Focus on the most useful judgment'); - expect(prompt).not.toContain('Baseline summary'); - expect(prompt).not.toContain('Move forward carefully'); - expect(prompt).not.toContain('thread-old-case'); - expect(prompt).not.toContain('Danger is still active'); - expect(prompt).toContain('战后缓气'); - expect(prompt).toContain('紧绷'); - expect(prompt).toContain('这一轮的局势已经出现了新的变化。'); - }); -}); diff --git a/src/services/prompt.ts b/src/services/prompt.ts deleted file mode 100644 index e56cb0f6..00000000 --- a/src/services/prompt.ts +++ /dev/null @@ -1 +0,0 @@ -export * from '../prompts/storyPromptBuilders'; diff --git a/src/services/puzzle-runtime/puzzleLocalRuntime.test.ts b/src/services/puzzle-runtime/puzzleLocalRuntime.test.ts index 010e95b6..2edd885d 100644 --- a/src/services/puzzle-runtime/puzzleLocalRuntime.test.ts +++ b/src/services/puzzle-runtime/puzzleLocalRuntime.test.ts @@ -5,7 +5,9 @@ import type { PuzzleWorkSummary } from '../../../packages/shared/src/contracts/p import { advanceLocalPuzzleLevel, dragLocalPuzzlePiece, + isLocalPuzzleRun, startLocalPuzzleRun, + submitLocalPuzzleLeaderboard, swapLocalPuzzlePieces, } from './puzzleLocalRuntime'; @@ -314,4 +316,25 @@ describe('puzzleLocalRuntime', () => { expect(hasAnyOriginalNeighborPair(secondRun.currentLevel?.board.pieces ?? [])).toBe(false); expect(hasAnyOriginalNeighborPair(thirdRun.currentLevel?.board.pieces ?? [])).toBe(false); }); + + test('本地 run 通关后用本地排行榜兜底,不再依赖后端 runId', () => { + const clearedRun = solveCurrentLevel(startLocalPuzzleRun(baseWork)); + + expect(isLocalPuzzleRun(clearedRun)).toBe(true); + expect(clearedRun.currentLevel?.leaderboardEntries).toEqual([]); + + const leaderboardRun = submitLocalPuzzleLeaderboard(clearedRun, '本地玩家'); + + expect(leaderboardRun.leaderboardEntries).toEqual([ + { + rank: 1, + nickname: '本地玩家', + elapsedMs: clearedRun.currentLevel?.elapsedMs ?? 0, + isCurrentPlayer: true, + }, + ]); + expect(leaderboardRun.currentLevel?.leaderboardEntries).toEqual( + leaderboardRun.leaderboardEntries, + ); + }); }); diff --git a/src/services/puzzle-runtime/puzzleLocalRuntime.ts b/src/services/puzzle-runtime/puzzleLocalRuntime.ts index e5409fe6..9bfc48c9 100644 --- a/src/services/puzzle-runtime/puzzleLocalRuntime.ts +++ b/src/services/puzzle-runtime/puzzleLocalRuntime.ts @@ -3,6 +3,7 @@ import type { PuzzleBoardSnapshot, PuzzleCellPosition, PuzzleGridSize, + PuzzleLeaderboardEntry, PuzzleMergedGroupState, PuzzlePieceState, PuzzleRunSnapshot, @@ -10,6 +11,8 @@ import type { } from '../../../packages/shared/src/contracts/puzzleRuntimeSession'; import type { PuzzleWorkSummary } from '../../../packages/shared/src/contracts/puzzleWorkSummary'; +const LOCAL_PUZZLE_RUN_ID_PREFIX = 'local-puzzle-run-'; + function resolvePuzzleGridSize(clearedLevelCount: number): PuzzleGridSize { return clearedLevelCount >= 3 ? 4 : 3; } @@ -399,6 +402,20 @@ function buildLocalNextProfileId(entryProfileId: string, levelIndex: number) { return `${entryProfileId}::local-level-${levelIndex}`; } +function buildLocalLeaderboardEntries( + nickname: string, + elapsedMs: number, +): PuzzleLeaderboardEntry[] { + return [ + { + rank: 1, + nickname, + elapsedMs, + isCurrentPlayer: true, + }, + ]; +} + // 第一版单机兜底没有后端推荐池时,才沿用当前作品图片生成可推进的临时关卡名。 function buildLocalLevelName(previousLevelName: string, levelIndex: number) { return `${previousLevelName.replace(/ · 第 \d+ 关$/, '')} · 第 ${levelIndex} 关`; @@ -447,7 +464,7 @@ function buildFallbackLocalLevel(run: PuzzleRunSnapshot): PuzzleRunSnapshot { export function startLocalPuzzleRun(item: PuzzleWorkSummary): PuzzleRunSnapshot { const gridSize = resolvePuzzleGridSize(0); - const runId = `local-puzzle-run-${item.profileId}-${Date.now()}`; + const runId = `${LOCAL_PUZZLE_RUN_ID_PREFIX}${item.profileId}-${Date.now()}`; const startedAtMs = Date.now(); return { runId, @@ -658,3 +675,45 @@ export function dragLocalPuzzlePiece( export function advanceLocalPuzzleLevel(run: PuzzleRunSnapshot): PuzzleRunSnapshot { return buildFallbackLocalLevel(run); } + +/** + * 判断当前拼图运行态是否为前端本地兜底 run。 + * 这类 run 没有后端持久化记录,不能再调用依赖真实 runId 的排行榜接口。 + */ +export function isLocalPuzzleRun(run: PuzzleRunSnapshot | null | undefined) { + return Boolean(run?.runId?.startsWith(LOCAL_PUZZLE_RUN_ID_PREFIX)); +} + +/** + * 本地拼图 run 的排行榜兜底。 + * 当前版本只写入当前玩家成绩,避免结算阶段继续请求后端导致“run 不存在”。 + */ +export function submitLocalPuzzleLeaderboard( + run: PuzzleRunSnapshot, + nickname: string, +): PuzzleRunSnapshot { + const currentLevel = run.currentLevel; + if ( + !currentLevel || + currentLevel.status !== 'cleared' || + currentLevel.elapsedMs === null + ) { + return run; + } + if ((currentLevel.leaderboardEntries ?? []).length > 0) { + return run; + } + + const leaderboardEntries = buildLocalLeaderboardEntries( + nickname, + currentLevel.elapsedMs, + ); + return { + ...run, + leaderboardEntries, + currentLevel: { + ...currentLevel, + leaderboardEntries, + }, + }; +} diff --git a/src/services/rpg-creation/index.ts b/src/services/rpg-creation/index.ts index 777c7f00..d8f5d75f 100644 --- a/src/services/rpg-creation/index.ts +++ b/src/services/rpg-creation/index.ts @@ -3,6 +3,7 @@ export { executeRpgCreationAction, getRpgCreationCardDetail, getRpgCreationOperation, + getRpgCreationResultView, getRpgCreationSession, rpgCreationAgentClient, sendRpgCreationMessage, @@ -23,10 +24,7 @@ export type { GenerateCustomWorldProfileInput, GenerateCustomWorldProfileOptions, } from './rpgCreationGenerationClient'; -export { - generateCustomWorldProfile as generateLegacyCustomWorldProfile, - generateRpgWorldProfile, -} from './rpgCreationGenerationClient'; +export { generateRpgWorldProfile } from './rpgCreationGenerationClient'; export { deleteRpgWorldProfile, getRpgWorldGalleryDetail, @@ -39,6 +37,7 @@ export { } from './rpgCreationLibraryClient'; export { buildRpgCreationPreviewFromResultPreview, + buildRpgCreationPreviewFromResultView, buildRpgCreationPreviewFromSession, rpgCreationPreviewAdapter, } from './rpgCreationPreviewAdapter'; diff --git a/src/services/rpg-creation/rpgCreationAgentClient.ts b/src/services/rpg-creation/rpgCreationAgentClient.ts index f4540731..593465a7 100644 --- a/src/services/rpg-creation/rpgCreationAgentClient.ts +++ b/src/services/rpg-creation/rpgCreationAgentClient.ts @@ -2,6 +2,7 @@ import type { CreateRpgAgentSessionRequest, CreateRpgAgentSessionResponse, GetRpgAgentCardDetailResponse, + RpgCreationResultView, RpgAgentDraftCardDetail, RpgAgentOperationRecord, RpgAgentSessionSnapshot, @@ -46,6 +47,16 @@ export async function getRpgCreationSession(sessionId: string) { ); } +export async function getRpgCreationResultView(sessionId: string) { + return requestRpgCreationRuntimeJson( + `${RPG_AGENT_API_BASE}/${encodeURIComponent(sessionId)}/result-view`, + { + method: 'GET', + }, + '读取世界结果页视图失败', + ); +} + export async function sendRpgCreationMessage( sessionId: string, payload: SendRpgAgentMessageRequest, @@ -133,6 +144,7 @@ export async function getRpgCreationCardDetail( export const rpgCreationAgentClient = { createSession: createRpgCreationSession, getSession: getRpgCreationSession, + getResultView: getRpgCreationResultView, sendMessage: sendRpgCreationMessage, streamMessage: streamRpgCreationMessage, executeAction: executeRpgCreationAction, diff --git a/src/services/rpg-creation/rpgCreationGenerationClient.node.test.ts b/src/services/rpg-creation/rpgCreationGenerationClient.node.test.ts new file mode 100644 index 00000000..d5b27608 --- /dev/null +++ b/src/services/rpg-creation/rpgCreationGenerationClient.node.test.ts @@ -0,0 +1,50 @@ +/* @vitest-environment node */ + +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const { requestJsonMock } = vi.hoisted(() => ({ + requestJsonMock: vi.fn(), +})); + +import { generateRpgWorldProfile } from './rpgCreationGenerationClient'; + +vi.mock('../apiClient', () => ({ + requestJson: requestJsonMock, +})); + +vi.mock('../ai', () => ({ + generateCustomWorldProfile: vi.fn(() => { + throw new Error('不应再调用前端 legacy AI 生成链'); + }), +})); + +describe('rpgCreationGenerationClient node runtime', () => { + beforeEach(() => { + requestJsonMock.mockReset(); + requestJsonMock.mockResolvedValue({ + id: 'server-rs-profile-1', + name: '服务端世界', + subtitle: '副标题', + summary: '概述', + tone: '基调', + playerGoal: '目标', + settingText: '设定', + }); + }); + + it('uses server-rs profile generation instead of importing legacy ai', async () => { + const profile = await generateRpgWorldProfile('一个在 Node 测试中生成的世界'); + + expect(profile.id).toBe('server-rs-profile-1'); + expect(requestJsonMock).toHaveBeenCalledWith( + '/api/runtime/custom-world/profile', + expect.objectContaining({ + method: 'POST', + body: JSON.stringify({ + settingText: '一个在 Node 测试中生成的世界', + }), + }), + '生成自定义世界失败', + ); + }); +}); diff --git a/src/services/rpg-creation/rpgCreationGenerationClient.test.ts b/src/services/rpg-creation/rpgCreationGenerationClient.test.ts index eeb13730..59246cb0 100644 --- a/src/services/rpg-creation/rpgCreationGenerationClient.test.ts +++ b/src/services/rpg-creation/rpgCreationGenerationClient.test.ts @@ -34,6 +34,9 @@ describe('rpgCreationGenerationClient', () => { expect.objectContaining({ method: 'POST', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + settingText: '一个被灵潮反复改写地形的边境世界', + }), }), '生成自定义世界失败', ); @@ -51,4 +54,26 @@ describe('rpgCreationGenerationClient', () => { expect(requestJsonMock).not.toHaveBeenCalled(); }); + + it('passes abort signal to the backend request contract', async () => { + const controller = new AbortController(); + + await generateRpgWorldProfile( + { + settingText: '一个由服务端生成的世界', + generationMode: 'fast', + }, + { + signal: controller.signal, + }, + ); + + expect(requestJsonMock).toHaveBeenCalledWith( + '/api/runtime/custom-world/profile', + expect.objectContaining({ + signal: controller.signal, + }), + '生成自定义世界失败', + ); + }); }); diff --git a/src/services/rpg-creation/rpgCreationGenerationClient.ts b/src/services/rpg-creation/rpgCreationGenerationClient.ts index ce2a8bc8..0633402f 100644 --- a/src/services/rpg-creation/rpgCreationGenerationClient.ts +++ b/src/services/rpg-creation/rpgCreationGenerationClient.ts @@ -6,18 +6,6 @@ import type { import type { CustomWorldProfile } from '../../types'; import { requestJson } from '../apiClient'; -type LegacyAiModule = typeof import('../ai'); - -let legacyAiModulePromise: Promise | null = null; - -async function loadLegacyAiModule() { - if (!legacyAiModulePromise) { - legacyAiModulePromise = import('../ai'); - } - - return legacyAiModulePromise; -} - export async function generateRpgWorldProfile( input: GenerateCustomWorldProfileInput | string, options: GenerateCustomWorldProfileOptions = {}, @@ -29,11 +17,6 @@ export async function generateRpgWorldProfile( } : input; - if (typeof window === 'undefined') { - const aiClient = await loadLegacyAiModule(); - return aiClient.generateCustomWorldProfile(normalizedInput, options); - } - if (options.signal?.aborted) { throw options.signal.reason instanceof Error ? options.signal.reason @@ -45,6 +28,7 @@ export async function generateRpgWorldProfile( { method: 'POST', headers: { 'Content-Type': 'application/json' }, + signal: options.signal, body: JSON.stringify(normalizedInput), }, '生成自定义世界失败', diff --git a/src/services/rpg-creation/rpgCreationPreviewAdapter.test.ts b/src/services/rpg-creation/rpgCreationPreviewAdapter.test.ts index a1152ffc..825c96e9 100644 --- a/src/services/rpg-creation/rpgCreationPreviewAdapter.test.ts +++ b/src/services/rpg-creation/rpgCreationPreviewAdapter.test.ts @@ -3,6 +3,7 @@ import { expect, test } from 'vitest'; import type { CustomWorldAgentSessionSnapshot } from '../../../packages/shared/src/contracts/customWorldAgent'; import { buildRpgCreationPreviewFromResultPreview, + buildRpgCreationPreviewFromResultView, buildRpgCreationPreviewFromSession, } from './rpgCreationPreviewAdapter'; @@ -211,7 +212,7 @@ test('buildRpgCreationPreviewFromSession prefers server result preview', () => { expect(profile?.playableNpcs).toEqual([]); }); -test('buildRpgCreationPreviewFromSession falls back to draft legacy result profile', () => { +test('buildRpgCreationPreviewFromSession no longer reads draft legacy result profile', () => { const profile = buildRpgCreationPreviewFromSession({ ...sessionWithPreview, resultPreview: null, @@ -226,11 +227,36 @@ test('buildRpgCreationPreviewFromSession falls back to draft legacy result profi }, }); - expect(profile?.name).toBe('草稿内嵌结果页'); - expect(profile?.summary).toBe( - 'resultPreview 缺失时继续使用 draft 内嵌的结果页快照。', - ); - expect(profile?.id).toBe('legacy-result-profile-1'); + expect(profile).toBeNull(); +}); + +test('buildRpgCreationPreviewFromResultView consumes backend-selected profile', () => { + const profile = buildRpgCreationPreviewFromResultView({ + session: { + ...sessionWithPreview, + resultPreview: null, + }, + profile: { + ...sessionWithPreview.resultPreview!.preview, + id: 'backend-selected-profile-1', + name: '后端结果页真相', + summary: 'legacy 兼容只允许在后端 result-view 内完成。', + }, + profileSource: 'draft_profile', + targetStage: 'custom-world-result', + generationViewSource: null, + resultViewSource: 'agent-draft', + canAutosaveLibrary: true, + canSyncResultProfile: true, + publishReady: false, + canEnterWorld: false, + blockerCount: 0, + recoveryAction: 'open_result', + }); + + expect(profile?.name).toBe('后端结果页真相'); + expect(profile?.summary).toBe('legacy 兼容只允许在后端 result-view 内完成。'); + expect(profile?.id).toBe('backend-selected-profile-1'); }); test('buildRpgCreationPreviewFromSession does not treat draftProfile as runtime profile', () => { diff --git a/src/services/rpg-creation/rpgCreationPreviewAdapter.ts b/src/services/rpg-creation/rpgCreationPreviewAdapter.ts index 13c8c315..f4d90a37 100644 --- a/src/services/rpg-creation/rpgCreationPreviewAdapter.ts +++ b/src/services/rpg-creation/rpgCreationPreviewAdapter.ts @@ -1,20 +1,8 @@ import type { CustomWorldAgentSessionSnapshot } from '../../../packages/shared/src/contracts/customWorldAgent'; +import type { RpgCreationResultView } from '../../../packages/shared/src/contracts/rpgCreationResultView'; import { normalizeCustomWorldProfileRecord } from '../../data/customWorldLibrary'; import type { CustomWorldProfile } from '../../types'; -function buildCustomWorldProfileFromDraftLegacyResult( - draftProfile: CustomWorldAgentSessionSnapshot['draftProfile'], -): CustomWorldProfile | null { - if (!draftProfile || typeof draftProfile !== 'object') { - return null; - } - - return normalizeCustomWorldProfileRecord( - (draftProfile as { legacyResultProfile?: unknown }).legacyResultProfile ?? - null, - ); -} - export function buildCustomWorldProfileFromResultPreview( resultPreview: | CustomWorldAgentSessionSnapshot['resultPreview'] @@ -27,10 +15,13 @@ export function buildCustomWorldProfileFromResultPreview( export function buildCustomWorldProfileFromAgentSession( session: CustomWorldAgentSessionSnapshot | null | undefined, ): CustomWorldProfile | null { - return ( - buildCustomWorldProfileFromResultPreview(session?.resultPreview) ?? - buildCustomWorldProfileFromDraftLegacyResult(session?.draftProfile ?? null) - ); + return buildCustomWorldProfileFromResultPreview(session?.resultPreview); +} + +export function buildCustomWorldProfileFromResultView( + view: RpgCreationResultView | null | undefined, +): CustomWorldProfile | null { + return normalizeCustomWorldProfileRecord(view?.profile ?? null); } /** @@ -40,9 +31,11 @@ export function buildCustomWorldProfileFromAgentSession( export const rpgCreationPreviewAdapter = { buildPreviewFromSession: buildCustomWorldProfileFromAgentSession, buildPreviewFromResultPreview: buildCustomWorldProfileFromResultPreview, + buildPreviewFromResultView: buildCustomWorldProfileFromResultView, }; export { buildCustomWorldProfileFromResultPreview as buildRpgCreationPreviewFromResultPreview, buildCustomWorldProfileFromAgentSession as buildRpgCreationPreviewFromSession, + buildCustomWorldProfileFromResultView as buildRpgCreationPreviewFromResultView, }; diff --git a/src/services/rpg-runtime/index.ts b/src/services/rpg-runtime/index.ts index bca22886..32cf5fe1 100644 --- a/src/services/rpg-runtime/index.ts +++ b/src/services/rpg-runtime/index.ts @@ -1,10 +1,3 @@ -export { - deleteRpgSaveSnapshot, - getRpgSaveSnapshot, - putRpgSaveSnapshot, - rpgSnapshotClient, - type RuntimeRequestOptions, -} from './rpgSnapshotClient'; export { getRpgCharacterChatSuggestions, getRpgCharacterChatSummary, @@ -21,12 +14,20 @@ export { getRpgRuntimeStoryState, isRpgRuntimeServerFunctionId, isRpgRuntimeTaskFunctionId, + loadRpgRuntimeInventoryView, resolveRpgRuntimeStoryAction, resolveRpgRuntimeStoryMoment, rpgRuntimeStoryClient, - shouldUseRpgRuntimeServerOptions, - type RuntimeStoryChoicePayload, - type RuntimeStoryResponse, type RpgRuntimeStoryClientOptions, - type RuntimeStorySnapshotRequest, + type RuntimeStoryChoicePayload, + type RuntimeStoryInventoryView, + type RuntimeStoryResponse, + shouldUseRpgRuntimeServerOptions, } from './rpgRuntimeStoryClient'; +export { + deleteRpgSaveSnapshot, + getRpgSaveSnapshot, + putRpgSaveSnapshot, + rpgSnapshotClient, + type RuntimeRequestOptions, +} from './rpgSnapshotClient'; diff --git a/src/services/rpg-runtime/rpgRuntimeRequest.ts b/src/services/rpg-runtime/rpgRuntimeRequest.ts index b2aa9faa..d83e45b6 100644 --- a/src/services/rpg-runtime/rpgRuntimeRequest.ts +++ b/src/services/rpg-runtime/rpgRuntimeRequest.ts @@ -30,6 +30,8 @@ export function requestRpgRuntimeJson( options: RuntimeRequestOptions = {}, ) { const method = (init.method ?? 'GET').toUpperCase(); + // 中文注释:运行时读请求和写请求的重试策略分开配置; + // GET 更保守,写请求允许 unsafe method retry,用来兜底瞬时网络抖动。 const retry = options.retry ?? (method === 'GET' ? RUNTIME_READ_RETRY : RUNTIME_WRITE_RETRY); diff --git a/src/services/rpg-runtime/rpgRuntimeStoryClient.test.ts b/src/services/rpg-runtime/rpgRuntimeStoryClient.test.ts index 421d2c00..b628a29e 100644 --- a/src/services/rpg-runtime/rpgRuntimeStoryClient.test.ts +++ b/src/services/rpg-runtime/rpgRuntimeStoryClient.test.ts @@ -15,12 +15,14 @@ vi.mock('../apiClient', async () => { import { AnimationState } from '../../types'; import { + beginRpgRuntimeStorySession, buildStoryMomentFromRuntimeOptions, getRpgRuntimeClientVersion, getRpgRuntimeSessionId, getRpgRuntimeStoryState, isRpgRuntimeServerFunctionId, isRpgRuntimeTaskFunctionId, + loadRpgRuntimeInventoryView, resolveRpgRuntimeStoryAction, resolveRpgRuntimeStoryMoment, shouldUseRpgRuntimeServerOptions, @@ -31,6 +33,52 @@ describe('rpgRuntimeStoryClient', () => { requestJsonMock.mockReset(); }); + it('starts runtime sessions through the backend bootstrap endpoint', async () => { + requestJsonMock.mockResolvedValue({ + sessionId: 'runtime-server-1', + serverVersion: 1, + snapshot: { + version: 2, + savedAt: '2026-04-28T00:00:00.000Z', + bottomTab: 'adventure', + gameState: { + runtimeSessionId: 'runtime-server-1', + currentScene: 'Story', + playerCharacter: { id: 'role-1', name: '沈砺' }, + playerEquipment: { weapon: null, armor: null, relic: null }, + }, + currentStory: null, + }, + }); + + const result = await beginRpgRuntimeStorySession({ + worldType: 'CUSTOM', + customWorldProfile: { id: 'profile-1' } as never, + character: { id: 'role-1', name: '沈砺' } as never, + runtimeMode: 'play', + disablePersistence: false, + }); + + expect(result.snapshot.gameState.runtimeSessionId).toBe( + 'runtime-server-1', + ); + expect(requestJsonMock).toHaveBeenCalledWith( + '/api/runtime/story/sessions', + expect.objectContaining({ + method: 'POST', + body: JSON.stringify({ + worldType: 'CUSTOM', + customWorldProfile: { id: 'profile-1' }, + character: { id: 'role-1', name: '沈砺' }, + runtimeMode: 'play', + disablePersistence: false, + }), + }), + '初始化运行时开局失败', + expect.any(Object), + ); + }); + it('builds runtime action requests against the dedicated story endpoint', async () => { requestJsonMock.mockResolvedValue({ sessionId: 'runtime-main', @@ -76,7 +124,6 @@ describe('rpgRuntimeStoryClient', () => { optionText: '继续交谈', }, }, - snapshot: undefined, }), }), '执行运行时动作失败', @@ -131,7 +178,6 @@ describe('rpgRuntimeStoryClient', () => { itemId: 'focus-tonic', }, }, - snapshot: undefined, }), }), '执行运行时动作失败', @@ -139,7 +185,7 @@ describe('rpgRuntimeStoryClient', () => { ); }); - it('submits runtime state resolution with snapshot context to the server', async () => { + it('reads runtime story state by server session id', async () => { requestJsonMock.mockResolvedValue({ sessionId: 'runtime-main', serverVersion: 4, @@ -179,34 +225,103 @@ describe('rpgRuntimeStoryClient', () => { await getRpgRuntimeStoryState({ sessionId: 'runtime-main', clientVersion: 7, - snapshot: { - gameState: { currentScene: 'Story' } as never, - bottomTab: 'adventure', - currentStory: { - text: '本地故事', - options: [], - } as never, - }, }); expect(requestJsonMock).toHaveBeenCalledWith( - '/api/runtime/story/state/resolve', + '/api/runtime/story/state/runtime-main', expect.objectContaining({ - method: 'POST', - body: JSON.stringify({ - sessionId: 'runtime-main', - clientVersion: 7, - snapshot: { - gameState: { - currentScene: 'Story', + method: 'GET', + }), + '读取运行时故事状态失败', + expect.any(Object), + ); + }); + + it('loads backend inventory view from runtime story state', async () => { + requestJsonMock.mockResolvedValue({ + sessionId: 'runtime-inventory', + serverVersion: 5, + viewModel: { + player: { + hp: 100, + maxHp: 100, + mana: 20, + maxMana: 20, + }, + encounter: null, + companions: [], + inventory: { + playerCurrency: 90, + currencyText: '90 铜钱', + inBattle: false, + backpackItems: [], + equipmentSlots: [], + forgeRecipes: [ + { + id: 'synthesis-refined-ingot', + name: '压炼锭材', + kind: 'synthesis', + description: '把零散残片和基础材料压成稳定可用的金属锭材。', + resultLabel: '精炼锭材', + currencyCost: 18, + currencyText: '18 铜钱', + requirements: [ + { + id: 'material:any', + label: '任意材料', + quantity: 3, + owned: 3, + }, + ], + canCraft: true, + action: { + functionId: 'forge_craft', + actionText: '制作精炼锭材', + payload: { recipeId: 'synthesis-refined-ingot' }, + enabled: true, + }, }, - bottomTab: 'adventure', - currentStory: { - text: '本地故事', - options: [], - }, - }, - }), + ], + }, + availableOptions: [], + status: { + inBattle: false, + npcInteractionActive: false, + currentNpcBattleMode: null, + currentNpcBattleOutcome: null, + }, + }, + presentation: { + actionText: '', + resultText: '', + storyText: '', + options: [], + }, + patches: [], + snapshot: { + version: 2, + savedAt: '2026-04-08T00:00:00.000Z', + bottomTab: 'adventure', + gameState: { + runtimeSessionId: 'runtime-inventory', + runtimeActionVersion: 5, + }, + currentStory: null, + }, + }); + + const view = await loadRpgRuntimeInventoryView({ + gameState: { + runtimeSessionId: 'runtime-inventory', + runtimeActionVersion: 5, + } as never, + }); + + expect(view.forgeRecipes[0]?.action.functionId).toBe('forge_craft'); + expect(requestJsonMock).toHaveBeenCalledWith( + '/api/runtime/story/state/runtime-inventory', + expect.objectContaining({ + method: 'GET', }), '读取运行时故事状态失败', expect.any(Object), @@ -336,6 +451,14 @@ describe('rpgRuntimeStoryClient', () => { player: { hp: 10, maxHp: 10, mana: 5, maxMana: 5 }, encounter: null, companions: [], + inventory: { + playerCurrency: 0, + currencyText: '0 铜钱', + inBattle: false, + backpackItems: [], + equipmentSlots: [], + forgeRecipes: [], + }, availableOptions: [], status: { inBattle: false, diff --git a/src/services/rpg-runtime/rpgRuntimeStoryClient.ts b/src/services/rpg-runtime/rpgRuntimeStoryClient.ts index f5f1ff8a..0fd34c07 100644 --- a/src/services/rpg-runtime/rpgRuntimeStoryClient.ts +++ b/src/services/rpg-runtime/rpgRuntimeStoryClient.ts @@ -1,9 +1,3 @@ -import type { - RuntimeStoryActionRequest, - RuntimeStoryActionResponse, - RuntimeStoryOptionView, - RuntimeStoryStateRequest, -} from '../../../packages/shared/src/contracts/rpgRuntimeStoryState'; import type { RuntimeStoryChoicePayload, ServerRuntimeFunctionId, @@ -13,6 +7,13 @@ import { SERVER_RUNTIME_FUNCTION_IDS, TASK5_RUNTIME_FUNCTION_IDS, } from '../../../packages/shared/src/contracts/rpgRuntimeStoryAction'; +import type { + RuntimeStoryActionRequest, + RuntimeStoryActionResponse, + RuntimeStoryBootstrapRequest, + RuntimeStoryBootstrapResponse, + RuntimeStoryOptionView, +} from '../../../packages/shared/src/contracts/rpgRuntimeStoryState'; import { rehydrateSavedSnapshot } from '../../persistence/runtimeSnapshot'; import type { HydratedSavedGameSnapshot } from '../../persistence/runtimeSnapshotTypes'; import type { GameState, StoryMoment, StoryOption } from '../../types'; @@ -44,11 +45,13 @@ export type RuntimeStoryResponse = RuntimeStoryActionResponse< GameState, StoryMoment >; -export type { RuntimeStoryChoicePayload }; -export type RuntimeStorySnapshotRequest = RuntimeStoryStateRequest< +export type RuntimeStoryBootstrapResult = RuntimeStoryBootstrapResponse< GameState, StoryMoment ->['snapshot']; +>; +export type RuntimeStoryInventoryView = + RuntimeStoryResponse['viewModel']['inventory']; +export type { RuntimeStoryChoicePayload }; function requestRuntimeStoryJson( path: string, @@ -56,6 +59,8 @@ function requestRuntimeStoryJson( fallbackMessage: string, options: RpgRuntimeStoryClientOptions = {}, ) { + // 中文注释:runtime story 请求默认带一层轻量重试, + // 因为这里既有 state 拉取,也有动作结算,请求失败会直接影响当前回合体验。 return requestJson( `${RUNTIME_STORY_API_BASE}${path}`, { @@ -71,6 +76,8 @@ function createRuntimeStoryOption( option: RuntimeStoryOptionView, _gameState?: Pick, ): StoryOption { + // 中文注释:服务端 viewModel 当前只返回动作层字段, + // 前端在这里补齐 StoryOption 所需的基础表现字段,保持冒险面板消费接口稳定。 return { functionId: option.functionId, actionText: option.actionText, @@ -118,6 +125,8 @@ export function isServerRuntimeFunctionId( } export function shouldUseServerRuntimeOptions(options: StoryOption[] | null) { + // 中文注释:只有当整组选项都已经切换到服务端 function id 体系时, + // 前端才把这轮视为“纯服务端 runtime 选项”,避免本地/服务端动作混用。 return Boolean( options?.length && options.every((option) => isServerRuntimeFunctionId(option.functionId)), @@ -152,6 +161,8 @@ export function resolveRuntimeStoryMoment(params: { fallbackGameState?: Pick; fallbackStoryText?: string; }) { + // 中文注释:对话态 story 往往包含 deferredOptions / dialogue 结构, + // 这类内容如果已经存进快照,应优先使用快照,避免被普通 presentation 选项覆盖。 if (shouldPreferSnapshotStory(params.hydratedSnapshot.currentStory)) { return params.hydratedSnapshot.currentStory!; } @@ -178,32 +189,18 @@ export async function getRuntimeStoryState( params: { sessionId: string; clientVersion?: number; - snapshot?: RuntimeStorySnapshotRequest; }, options: RpgRuntimeStoryClientOptions = {}, ) { const normalizedSessionId = params.sessionId || DEFAULT_SESSION_ID; - const response = params.snapshot - ? await requestRuntimeStoryJson( - '/state/resolve', - { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - sessionId: normalizedSessionId, - clientVersion: params.clientVersion, - snapshot: params.snapshot, - } satisfies RuntimeStoryStateRequest), - }, - '读取运行时故事状态失败', - options, - ) - : await requestRuntimeStoryJson( - `/state/${encodeURIComponent(normalizedSessionId)}`, - { method: 'GET' }, - '读取运行时故事状态失败', - options, - ); + // 中文注释:runtime story 状态读取只按服务端持久化 sessionId 拉取, + // 不再允许前端上传本地 GameState 快照参与解析。 + const response = await requestRuntimeStoryJson( + `/state/${encodeURIComponent(normalizedSessionId)}`, + { method: 'GET' }, + '读取运行时故事状态失败', + options, + ); return { ...response, @@ -213,6 +210,51 @@ export async function getRuntimeStoryState( } satisfies RuntimeStoryResponse; } +export async function loadRuntimeInventoryView( + params: { + gameState: Pick; + }, + options: RpgRuntimeStoryClientOptions = {}, +) { + // 中文注释:背包 / 装备 / 锻造 view 只读取后端已持久化的 runtime session; + // 前端不再用本地背包、货币或装备状态重算配方可用性。 + const response = await getRuntimeStoryState( + { + sessionId: getRuntimeSessionId(params.gameState), + clientVersion: getRuntimeClientVersion(params.gameState), + }, + options, + ); + + return response.viewModel.inventory; +} + +export async function beginRuntimeStorySession( + params: RuntimeStoryBootstrapRequest< + GameState['customWorldProfile'], + NonNullable + >, + options: RpgRuntimeStoryClientOptions = {}, +) { + const response = await requestRuntimeStoryJson( + '/sessions', + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(params), + }, + '初始化运行时开局失败', + options, + ); + + return { + ...response, + snapshot: rehydrateSavedSnapshot( + response.snapshot as HydratedSavedGameSnapshot, + ), + } satisfies RuntimeStoryBootstrapResult; +} + export async function resolveRuntimeStoryAction( params: { sessionId?: string; @@ -220,10 +262,11 @@ export async function resolveRuntimeStoryAction( option: Pick; targetId?: string; payload?: RuntimeStoryChoicePayload; - snapshot?: RuntimeStorySnapshotRequest; }, options: RpgRuntimeStoryClientOptions = {}, ) { + // 中文注释:story_choice 是当前前端统一提交给服务端的动作包裹格式, + // optionText 会一起带上,方便服务端日志、提示词和调试链查看用户当轮选择。 const response = await requestRuntimeStoryJson( '/actions/resolve', { @@ -241,7 +284,6 @@ export async function resolveRuntimeStoryAction( ...(params.payload ?? {}), }, }, - snapshot: params.snapshot, } satisfies RuntimeStoryActionRequest), }, '执行运行时动作失败', @@ -260,19 +302,23 @@ export function getRuntimeActionSnapshot(response: RuntimeStoryResponse) { return rehydrateSavedSnapshot(response.snapshot as HydratedSavedGameSnapshot); } +export const beginRpgRuntimeStorySession = beginRuntimeStorySession; export const getRpgRuntimeActionSnapshot = getRuntimeActionSnapshot; export const getRpgRuntimeClientVersion = getRuntimeClientVersion; export const getRpgRuntimeSessionId = getRuntimeSessionId; export const getRpgRuntimeStoryState = getRuntimeStoryState; export const isRpgRuntimeServerFunctionId = isServerRuntimeFunctionId; export const isRpgRuntimeTaskFunctionId = isTask5RuntimeFunctionId; +export const loadRpgRuntimeInventoryView = loadRuntimeInventoryView; export const resolveRpgRuntimeStoryAction = resolveRuntimeStoryAction; export const resolveRpgRuntimeStoryMoment = resolveRuntimeStoryMoment; export const shouldUseRpgRuntimeServerOptions = shouldUseServerRuntimeOptions; export const rpgRuntimeStoryClient = { + beginSession: beginRpgRuntimeStorySession, getActionSnapshot: getRpgRuntimeActionSnapshot, getClientVersion: getRpgRuntimeClientVersion, + getInventoryView: loadRpgRuntimeInventoryView, getSessionId: getRpgRuntimeSessionId, getState: getRpgRuntimeStoryState, resolveAction: resolveRpgRuntimeStoryAction, diff --git a/src/services/rpg-runtime/rpgSnapshotClient.test.ts b/src/services/rpg-runtime/rpgSnapshotClient.test.ts index f24dfedf..912ecac4 100644 --- a/src/services/rpg-runtime/rpgSnapshotClient.test.ts +++ b/src/services/rpg-runtime/rpgSnapshotClient.test.ts @@ -34,7 +34,7 @@ describe('rpgSnapshotClient routes', () => { ); }); - it('writes the current save snapshot through the runtime save route', async () => { + it('requests a backend checkpoint instead of uploading the runtime snapshot', async () => { requestJsonMock.mockResolvedValueOnce({ version: 2, savedAt: '2026-04-21T09:00:00.000Z', @@ -46,13 +46,19 @@ describe('rpgSnapshotClient routes', () => { }); await putRpgSaveSnapshot({ + sessionId: 'runtime-main', bottomTab: 'adventure', - currentStory: null, - gameState: { - worldType: 'CUSTOM', - } as never, }); + const [, init] = requestJsonMock.mock.calls[0]; + const body = JSON.parse(init.body as string); + expect(body).toEqual({ + sessionId: 'runtime-main', + bottomTab: 'adventure', + }); + expect(body.gameState).toBeUndefined(); + expect(body.currentStory).toBeUndefined(); + expect(requestJsonMock).toHaveBeenCalledWith( '/api/runtime/save/snapshot', expect.objectContaining({ diff --git a/src/services/rpg-runtime/rpgSnapshotClient.ts b/src/services/rpg-runtime/rpgSnapshotClient.ts index 901807f6..dbdbc296 100644 --- a/src/services/rpg-runtime/rpgSnapshotClient.ts +++ b/src/services/rpg-runtime/rpgSnapshotClient.ts @@ -1,5 +1,5 @@ import type { BasicOkResult } from '../../../packages/shared/src/contracts/runtime'; -import type { SavedGameSnapshotInput } from '../../persistence/gameSaveStorage'; +import type { RuntimeSaveCheckpointInput } from '../../persistence/gameSaveStorage'; import { rehydrateSavedSnapshot } from '../../persistence/runtimeSnapshot'; import type { HydratedSavedGameSnapshot } from '../../persistence/runtimeSnapshotTypes'; import { @@ -13,29 +13,32 @@ export type { RuntimeRequestOptions }; * RPG 运行时快照 client。 * 工作包 C 起由新域目录承载真实实现,旧 `storageService` 仅保留兼容转发。 */ -export async function getRpgSaveSnapshot( - options: RuntimeRequestOptions = {}, -) { - const snapshot = await requestRpgRuntimeJson( - '/save/snapshot', - { method: 'GET' }, - '读取存档失败', - options, - ); +export async function getRpgSaveSnapshot(options: RuntimeRequestOptions = {}) { + // 中文注释:远端返回的是可序列化快照; + // 客户端每次读取后都先做一次 rehydrate,恢复 Date / 枚举 / 运行时默认字段。 + const snapshot = + await requestRpgRuntimeJson( + '/save/snapshot', + { method: 'GET' }, + '读取存档失败', + options, + ); return snapshot ? rehydrateSavedSnapshot(snapshot) : null; } export async function putRpgSaveSnapshot( - snapshot: SavedGameSnapshotInput, + checkpoint: RuntimeSaveCheckpointInput, options: RuntimeRequestOptions = {}, ) { + // 中文注释:自动/手动存档只提交 checkpoint 元数据; + // 运行时真相由服务端读取已持久化快照后刷新,避免浏览器上传整份 GameState。 const savedSnapshot = await requestRpgRuntimeJson( '/save/snapshot', { method: 'PUT', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(snapshot), + body: JSON.stringify(checkpoint), }, '保存存档失败', options, diff --git a/src/services/rpgRuntimeChatTypes.ts b/src/services/rpgRuntimeChatTypes.ts new file mode 100644 index 00000000..2e83187a --- /dev/null +++ b/src/services/rpgRuntimeChatTypes.ts @@ -0,0 +1,11 @@ +/** + * RPG 运行时聊天前端只保留共享请求类型,不再承载任何提示词拼装逻辑。 + */ +export interface CharacterChatTargetStatus { + roleLabel?: string | null; + hp: number; + maxHp: number; + mana: number; + maxMana: number; + affinity?: number | null; +} diff --git a/src/services/storyEngine/echoMemory.test.ts b/src/services/storyEngine/echoMemory.test.ts index 75b6439b..80668b2a 100644 --- a/src/services/storyEngine/echoMemory.test.ts +++ b/src/services/storyEngine/echoMemory.test.ts @@ -89,6 +89,51 @@ describe('echoMemory', () => { expect(synced.seenBackstoryChapterIds).toContain('scar'); }); + it('accepts projected story engine memory snapshots with missing arrays', () => { + const npcState: NpcPersistentState = { + affinity: 18, + helpUsed: false, + chattedCount: 0, + giftsGiven: 0, + inventory: [], + recruited: false, + revealedFacts: [], + knownAttributeRumors: [], + firstMeaningfulContactResolved: false, + seenBackstoryChapterIds: [], + stanceProfile: { + trust: 50, + warmth: 46, + ideologicalFit: 50, + fearOrGuard: 38, + loyalty: 28, + currentConflictTag: null, + recentApprovals: [], + recentDisapprovals: [], + }, + }; + + const synced = syncNpcNarrativeState({ + encounter: createEncounter(), + npcState, + storyEngineMemory: { + currentChapter: { + id: 'chapter-1', + title: '断桥再燃', + summary: '桥口旧案重新压到眼前。', + stage: 'rising', + relatedQuestIds: [], + relatedSceneIds: [], + relatedThreadIds: ['thread-1'], + pressureTags: [], + }, + } as never, + }); + + expect(synced.revealedFacts).toContain('publicMask'); + expect(synced.revealedFacts).toContain('thread:thread-1'); + }); + it('writes recent carriers and scar echoes into story engine memory', () => { const item: InventoryItem = { id: 'runtime:quest:evidence', diff --git a/src/services/storyEngine/echoMemory.ts b/src/services/storyEngine/echoMemory.ts index 6ab621ed..cbf1747e 100644 --- a/src/services/storyEngine/echoMemory.ts +++ b/src/services/storyEngine/echoMemory.ts @@ -12,7 +12,7 @@ import { import { buildThemePackFromWorldProfile } from './themePack'; import { buildEncounterVisibilitySlice, - createEmptyStoryEngineMemoryState, + normalizeStoryEngineMemoryState, } from './visibilityEngine'; import { buildFallbackWorldStoryGraph } from './worldStoryGraph'; @@ -81,8 +81,7 @@ export function syncNpcNarrativeState(params: { return npcState; } - const storyEngineMemory = - params.storyEngineMemory ?? createEmptyStoryEngineMemoryState(); + const storyEngineMemory = normalizeStoryEngineMemoryState(params.storyEngineMemory); const activeThreadIds = storyEngineMemory.activeThreadIds.length > 0 ? storyEngineMemory.activeThreadIds @@ -122,8 +121,7 @@ export function appendStoryEngineCarrierMemory( state: GameState, items: InventoryItem[], ) { - const storyEngineMemory = - state.storyEngineMemory ?? createEmptyStoryEngineMemoryState(); + const storyEngineMemory = normalizeStoryEngineMemoryState(state.storyEngineMemory); const carriers = items.filter((item) => item.runtimeMetadata?.storyFingerprint); if (carriers.length <= 0) { return { diff --git a/src/services/storyEngine/visibilityEngine.ts b/src/services/storyEngine/visibilityEngine.ts index 40928264..c9812948 100644 --- a/src/services/storyEngine/visibilityEngine.ts +++ b/src/services/storyEngine/visibilityEngine.ts @@ -71,6 +71,67 @@ export function createEmptyStoryEngineMemoryState(): StoryEngineMemoryState { }; } +export function normalizeStoryEngineMemoryState( + memory?: Partial | null, +): StoryEngineMemoryState { + const empty = createEmptyStoryEngineMemoryState(); + if (!memory) return empty; + + // 后端投影或旧存档可能只带增量字段,前端消费前统一补齐数组字段。 + return { + ...empty, + ...memory, + discoveredFactIds: Array.isArray(memory.discoveredFactIds) + ? memory.discoveredFactIds + : empty.discoveredFactIds, + inferredFactIds: Array.isArray(memory.inferredFactIds) + ? memory.inferredFactIds + : empty.inferredFactIds, + activeThreadIds: Array.isArray(memory.activeThreadIds) + ? memory.activeThreadIds + : empty.activeThreadIds, + resolvedScarIds: Array.isArray(memory.resolvedScarIds) + ? memory.resolvedScarIds + : empty.resolvedScarIds, + recentCarrierIds: Array.isArray(memory.recentCarrierIds) + ? memory.recentCarrierIds + : empty.recentCarrierIds, + openedSceneChapterIds: Array.isArray(memory.openedSceneChapterIds) + ? memory.openedSceneChapterIds + : empty.openedSceneChapterIds, + recentSignalIds: Array.isArray(memory.recentSignalIds) + ? memory.recentSignalIds + : empty.recentSignalIds, + recentCompanionReactions: Array.isArray(memory.recentCompanionReactions) + ? memory.recentCompanionReactions + : empty.recentCompanionReactions, + companionArcStates: Array.isArray(memory.companionArcStates) + ? memory.companionArcStates + : empty.companionArcStates, + worldMutations: Array.isArray(memory.worldMutations) + ? memory.worldMutations + : empty.worldMutations, + chronicle: Array.isArray(memory.chronicle) + ? memory.chronicle + : empty.chronicle, + factionTensionStates: Array.isArray(memory.factionTensionStates) + ? memory.factionTensionStates + : empty.factionTensionStates, + consequenceLedger: Array.isArray(memory.consequenceLedger) + ? memory.consequenceLedger + : empty.consequenceLedger, + companionResolutions: Array.isArray(memory.companionResolutions) + ? memory.companionResolutions + : empty.companionResolutions, + narrativeCodex: Array.isArray(memory.narrativeCodex) + ? memory.narrativeCodex + : empty.narrativeCodex, + simulationRunResults: Array.isArray(memory.simulationRunResults) + ? memory.simulationRunResults + : empty.simulationRunResults, + }; +} + function buildBaseFactIds( narrativeProfile?: ActorNarrativeProfile | null, backstoryReveal?: CharacterBackstoryRevealConfig | null, @@ -113,7 +174,7 @@ function resolveUnlockedChapterIds( export function buildEncounterVisibilitySlice( params: EncounterVisibilityParams, ) { - const memory = params.storyEngineMemory ?? createEmptyStoryEngineMemoryState(); + const memory = normalizeStoryEngineMemoryState(params.storyEngineMemory); const factIds = buildBaseFactIds(params.narrativeProfile, params.backstoryReveal); const unlockedChapterIds = resolveUnlockedChapterIds( params.backstoryReveal, @@ -193,7 +254,7 @@ export function buildEncounterVisibilitySlice( export function buildQuestVisibilitySlice( params: QuestVisibilityParams, ) { - const memory = params.storyEngineMemory ?? createEmptyStoryEngineMemoryState(); + const memory = normalizeStoryEngineMemoryState(params.storyEngineMemory); const narrativeProfile = params.issuerNarrativeProfile; const factIds = dedupeStrings([ narrativeProfile ? 'publicMask' : null, diff --git a/src/types/game.ts b/src/types/game.ts index 3dcfec9c..fdbd1d26 100644 --- a/src/types/game.ts +++ b/src/types/game.ts @@ -46,6 +46,40 @@ export interface PlayerProgressionState { lastGrantedSource?: PlayerProgressionGrantSource | null; } +export type RuntimeNpcTradeMode = 'buy' | 'sell'; + +export interface RuntimeNpcTradeItemView { + itemId: string; + item: InventoryItem; + mode: RuntimeNpcTradeMode; + unitPrice: number; + maxQuantity: number; + canSubmit: boolean; + reason?: string | null; +} + +export interface RuntimeNpcGiftItemView { + itemId: string; + item: InventoryItem; + affinityGain: number; + canSubmit: boolean; + reason?: string | null; +} + +export interface RuntimeNpcInteractionView { + npcId: string; + npcName: string; + playerCurrency: number; + currencyName: string; + trade: { + buyItems: RuntimeNpcTradeItemView[]; + sellItems: RuntimeNpcTradeItemView[]; + }; + gift: { + items: RuntimeNpcGiftItemView[]; + }; +} + export interface GameState { worldType: WorldType | null; customWorldProfile: CustomWorldProfile | null; @@ -70,6 +104,7 @@ export interface GameState { animationState: AnimationState; currentEncounter: Encounter | null; npcInteractionActive: boolean; + runtimeNpcInteraction?: RuntimeNpcInteractionView | null; currentScenePreset: ScenePresetInfo | null; sceneHostileNpcs: SceneHostileNpc[]; playerX: number;