diff --git a/backend-rewrite-tasklist/00_MASTER_TASKLIST.md b/backend-rewrite-tasklist/00_MASTER_TASKLIST.md index f952d46a..ab5d2f61 100644 --- a/backend-rewrite-tasklist/00_MASTER_TASKLIST.md +++ b/backend-rewrite-tasklist/00_MASTER_TASKLIST.md @@ -113,6 +113,7 @@ 3. 部署 4. 观测 5. 灰度切流 +6. 收口 `spacetime-module` 主工程结构,拆分过大的 `src/lib.rs` 详见: diff --git a/backend-rewrite-tasklist/03_M4_STORY_AND_GAMEPLAY.md b/backend-rewrite-tasklist/03_M4_STORY_AND_GAMEPLAY.md index c7f11467..e932972b 100644 --- a/backend-rewrite-tasklist/03_M4_STORY_AND_GAMEPLAY.md +++ b/backend-rewrite-tasklist/03_M4_STORY_AND_GAMEPLAY.md @@ -68,14 +68,91 @@ 40. 已再次执行 `cargo check -p spacetime-client --manifest-path D:\\Genarrative\\server-rs\\Cargo.toml` 与 `cargo check -p api-server --manifest-path D:\\Genarrative\\server-rs\\Cargo.toml`,当前 battle/story 新链路在编译层已恢复通过。 41. 已新增 `docs/technical/M4_RUNTIME_STORY_COMPAT_STATE_BRIDGE_DESIGN_2026-04-22.md`,冻结旧 `POST /api/runtime/story/state/resolve` 的首版兼容桥边界,明确当前先做 DTO 与状态桥,不提前误宣称 `actions/resolve` 已可迁移。 42. 已在 `server-rs/crates/shared-contracts` 中新增 `runtime_story` 模块,冻结 `RuntimeStoryStateResolveRequest`、`RuntimeStoryActionResponse` 以及 `viewModel / presentation / patches / snapshot` 的首版 camelCase DTO,与当前前端消费口径对齐。 +43. 已恢复并重建 `server-rs/crates/api-server/src/runtime_story.rs`,把上一轮误删留下的中间态收口回可编译实现。 +44. 已在 Rust `api-server` 侧挂出旧 runtime story 兼容接口: + - `POST /api/runtime/story/state/resolve` + - `GET /api/runtime/story/state/:sessionId` + - `POST /api/runtime/story/actions/resolve` + - `POST /api/runtime/story/initial` + - `POST /api/runtime/story/continue` +45. `state/resolve` 与 `actions/resolve` 已统一复用 `runtime_save` 的 SpacetimeDB 快照持久化链: + - 请求带 `snapshot` 时先写入 `runtime_snapshot` + - 请求不带 `snapshot` 时从持久化 `runtime_snapshot` 读取 + - 无可用快照时返回 `409` +46. `actions/resolve` 已补齐当前前端主链需要的确定性兼容动作闭环,覆盖: + - `story_continue_adventure` + - `story_opening_camp_dialogue` + - `camp_travel_home_scene` + - `idle_call_out` + - `idle_explore_forward` + - `idle_observe_signs` + - `idle_rest_focus` + - `idle_travel_next_scene` + - `npc_preview_talk` + - `npc_chat` + - `npc_help` + - `npc_leave` + - `npc_fight` + - `npc_spar` + - `npc_recruit` + - `battle_attack_basic` + - `battle_use_skill` + - `battle_all_in_crush` + - `battle_escape_breakout` + - `battle_feint_step` + - `battle_finisher_window` + - `battle_guard_break` + - `battle_probe_pressure` + - `battle_recover_breath` + - `treasure_secure` + - `treasure_inspect` + - `treasure_leave` +47. `actions/resolve` 已补 `clientVersion` 与 `gameState.runtimeActionVersion` 的冲突校验、动作后版本自增、`storyHistory` 追加和 snapshot 回写。 +48. `initial` / `continue` 已先落稳定 `RuntimeStoryAiResponse`: + - 优先透传 `requestOptions.availableOptions / optionCatalog` + - 未配置 LLM 时走确定性 fallback 文本 + - 已配置 `platform-llm` 时可做文本增强,但不阻塞接口可用性 +49. 已执行 `cargo test -p shared-contracts`、`cargo check -p api-server`、`cargo test -p api-server runtime_story` 并通过,当前 runtime story 兼容链在 Rust 侧已恢复到可编译、可测试状态。 +50. 已补 Rust 侧 route boundary 回归: + - `runtime_story_routes_resolve_through_rust_route_boundary` + - `runtime_story_action_resolve_rejects_client_version_conflict` + - `runtime_story_npc_help_is_one_shot_and_restores_resources` + - `runtime_story_npc_recruit_requires_threshold_and_release_target_when_party_full` +51. 已把兼容桥里的关键 NPC 行为继续对齐到 Node 旧主链: + - `npc_chat` 好感增长改为 `max(2, 6 - chattedCount)`,首聊可从 `46 -> 52` + - `npc_help` 改为一次性援手,成功时恢复 `10 HP / 8 Mana` 且关系 `+4` + - `npc_recruit` 改为要求 `affinity >= 60`,队伍满员时必须透传 `releaseNpcId` +52. 已补测试环境专用的 runtime snapshot 内存兜底,仅在 `#[cfg(test)]` 下生效,用于在未启动本地 SpacetimeDB 时稳定回归 `PUT /api/runtime/save/snapshot -> GET /api/runtime/story/state -> POST /api/runtime/story/actions/resolve` 这条 Rust 边界链。 +53. 已把 quest compat 主循环补到 Rust `runtime story` 兼容桥: + - `npc_chat_quest_offer_view` + - `npc_chat_quest_offer_replace` + - `npc_chat_quest_offer_abandon` + - `npc_quest_accept` + - `npc_quest_turn_in` +54. 已把 quest offer 对话态的 `currentStory.npcChatState.pendingQuestOffer` 与前端面板依赖的 `runtimePayload.npcChatQuestOfferAction` 一并回填到 Rust compat 回包,保证现有 quest 面板入口不回退。 +55. 已把 `npc_quest_turn_in` 的最小奖励闭环补回 Rust compat handler: + - quest 状态改为保留在 `gameState.quests` 中的 `turned_in` + - 同步写回 `playerCurrency` + - 同步写回 `playerInventory` + - 同步写回 `playerProgression.totalXp / level / xpToNextLevel / lastGrantedSource` + - 同步写回 NPC `affinity` +56. 已新增 quest compat Rust 回归: + - `runtime_story_quest_offer_replace_updates_pending_offer_and_payload` + - `runtime_story_quest_offer_abandon_clears_pending_offer_and_restores_chat_options` + - `runtime_story_quest_accept_writes_quest_runtime_stats_and_followup_story` + - `runtime_story_quest_turn_in_marks_quest_rewards_and_affinity` +57. 已再次执行 `cargo test -p api-server runtime_story`、`cargo check -p api-server` 与 `node scripts/check-encoding.mjs` 并通过,当前 quest compat 已恢复到可编译、可回归状态。 当前验证边界补充: -1. `story_sessions` / `story_battles` 的二进制测试目标在当前机器上编译耗时很长,已有多轮回归尝试,但还没有在单次时窗内收敛到最终断言结果。 -2. `npm run check:encoding` 已启动到 `node scripts/check-encoding.mjs`,但当前尚未在单次时窗内跑完,不能标记为已完成。 -3. 因此,当前可以确认的是 `module -> generated bindings -> spacetime-client -> api-server` 的编译链已打通;测试与编码检查仍应继续追。 +1. `story_sessions` / `story_battles` 的二进制测试目标在当前机器上编译耗时仍然较长,还没有把更大范围的 story/battle 回归全部收拢到单次时窗内。 +2. `node scripts/check-encoding.mjs` 已再次执行并通过,当前本轮涉及的中文文件编码未被写坏。 +3. 当前可以确认的是: + - `module -> generated bindings -> spacetime-client -> api-server` 的编译链已打通 + - Rust `runtime story` compat route boundary 与关键 NPC 主循环规则已有回归覆盖 + - 真正的 `resolve_story_action / sync_runtime_snapshot_projection` 真相链仍未完成 -当前这轮仍未扩到 `resolve_story_action`、`sync_runtime_snapshot_projection`、旧 `/api/runtime/story/*` 兼容接口和前端实际 runtime story API 切换,这些继续保留在后续 `M4` 工作项中。 +当前这轮仍未扩到真正的 SpacetimeDB `resolve_story_action` / `sync_runtime_snapshot_projection` 真相 reducer,也还没有完成前端默认切流到 Rust `api-server`。当前已完成的是“旧 `/api/runtime/story/*` 兼容接口在 Rust 侧的快照桥与确定性动作闭环”,后续 `M4` 继续推进真相态替换与前端切换。 ## 1. SpacetimeDB gameplay 表 @@ -106,7 +183,7 @@ - [ ] 迁移 `rpg-entry` 配套后端入口能力 - [ ] 迁移 `rpg-profile` 资料域 -- [ ] 迁移 `rpg-runtime-story` +- [x] 迁移 `rpg-runtime-story` - [x] 迁移 `combat` - [ ] 迁移 `inventory` - [ ] 迁移 `npc` @@ -117,37 +194,67 @@ ## 4. 兼容接口 -- [ ] 兼容 `POST /api/runtime/story/actions/resolve` -- [ ] 兼容 `GET /api/runtime/story/state/:sessionId` -- [ ] 兼容 `POST /api/runtime/story/state/resolve` -- [ ] 兼容 `POST /api/runtime/story/initial` -- [ ] 兼容 `POST /api/runtime/story/continue` +- [x] 兼容 `POST /api/runtime/story/actions/resolve` +- [x] 兼容 `GET /api/runtime/story/state/:sessionId` +- [x] 兼容 `POST /api/runtime/story/state/resolve` +- [x] 兼容 `POST /api/runtime/story/initial` +- [x] 兼容 `POST /api/runtime/story/continue` 补充说明: -1. 当前已落地的是新的 Rust facade: +1. 当前已落地的是两类 Rust facade: + - 新真相态接口: - `POST /api/story/sessions` - `POST /api/story/sessions/continue` - `GET /api/story/sessions/:storySessionId/state` - `GET /api/story/battles/:battleStateId` - `POST /api/story/npc/battle` -2. 其中前 3 个接口是 `story session` 真相链路,后 2 个接口是 battle / NPC 开战真相链路,都不等价于旧 Node 的 LLM `runtime/story/*` 兼容接口。 -3. 当前新增的 `story state` 查询只返回 `storySession + storyEvents`,还没有兼容旧 `RuntimeStoryActionResponse`、`currentStory`、`availableOptions`。 -4. 当前新增的 `battle state` 查询只返回单个 `battleState`,还没有拼回旧 runtime story state 视图。 -5. 在 `resolve_story_action / story state` contract 未冻结前,不应误勾选旧兼容接口。 + - 旧 runtime story 兼容接口: + - `POST /api/runtime/story/state/resolve` + - `GET /api/runtime/story/state/:sessionId` + - `POST /api/runtime/story/actions/resolve` + - `POST /api/runtime/story/initial` + - `POST /api/runtime/story/continue` +2. 其中新真相态接口仍是 `story session / battle / NPC 开战` 的底层切片;旧 `runtime/story/*` 则是复用 `runtime_snapshot` 的兼容桥,不等价于最终真相态实现。 +3. 当前 `runtime/story/*` 已能返回旧前端需要的 `RuntimeStoryActionResponse / AIResponse` 形状,但内部动作仍以确定性兼容编排为主,不代表 `resolve_story_action` 真相 reducer 已完成。 +4. 当前新增的 `battle state` 查询仍只返回单个 `battleState` 真相切片,不等价于 runtime story 全量视图。 +5. 后续 `M4` 仍需把兼容桥逐步替换成真正的 story action / snapshot projection 真相链。 ## 5. ViewModel 兼容 -- [ ] 兼容当前 `RuntimeStoryActionResponse` -- [ ] 兼容当前 `RuntimeStoryOptionView` -- [ ] 兼容当前 `interaction` 元数据 -- [ ] 兼容当前 battle / toast / patch 响应结构 -- [ ] 兼容当前 `currentStory` 回填逻辑 +- [x] 兼容当前 `RuntimeStoryActionResponse` +- [x] 兼容当前 `RuntimeStoryOptionView` +- [x] 兼容当前 `interaction` 元数据 +- [x] 兼容当前 battle / toast / patch 响应结构 +- [x] 兼容当前 `currentStory` 回填逻辑 ## 6. 阶段验收 -- [ ] 当前前端 story 选项点击后可走新后端闭环 +- [x] 当前前端 story 选项点击后可走新后端闭环 - [ ] NPC / quest / treasure / combat 主循环行为不回退 -- [ ] `story state` 恢复链可用 +- [x] `story state` 恢复链可用 - [ ] 后端边界与当前 `rpgEntry -> rpgSession -> rpgRuntime -> rpgRuntimeStory -> rpgProfile` 口径一致 -- [ ] 旧 Node 版 story route 回归用例完成平移 +- [x] 旧 Node 版 story route 回归用例完成平移 + +阶段验收补充说明: + +1. `当前前端 story 选项点击后可走新后端闭环` 当前按 Rust `api-server` 的真实边界回归判定已满足: + - `PUT /api/runtime/save/snapshot` + - `GET /api/runtime/story/state/runtime-main` + - `POST /api/runtime/story/actions/resolve` + 但这不等于“生产默认流量已经切到 Rust”。 +2. `story state 恢复链可用` 当前指: + - 请求带 `snapshot` 时可先写后读 + - 请求不带 `snapshot` 时可从已持久化 `runtime_snapshot` 恢复 +3. `旧 Node 版 story route 回归用例完成平移` 当前指: + - 已平移 Node 的 `rpg runtime story routes resolve through the new route boundary` + - 已补 `clientVersion` 冲突回归 + - 已把 `npc_chat` 的 `46 -> 52` Node 旧语义对齐进 Rust compat handler +4. `NPC / quest / treasure / combat 主循环行为不回退` 仍不能勾选: + - 虽然 `npc_chat / npc_help / npc_recruit / npc_chat_quest_offer_* / npc_quest_accept / npc_quest_turn_in / npc_fight / npc_spar / treasure_* / battle_*` 已有确定性兼容闭环 + - 且 quest 交付奖励、quest offer 对话态与 quest follow-up 选项已补到 Rust compat handler + - 但 treasure / combat 的更大范围 Node 回归还没全部平移 + - 真相态 reducer 仍未替换 compat bridge +5. `后端边界与当前 rpgEntry -> ...` 仍不能勾选: + - 前端真实调用链已对齐 `/api/runtime/story/*` + - 但“默认走 Rust server”的联调证据仍未冻结 diff --git a/backend-rewrite-tasklist/05_M6_ASSETS_OSS_EDITOR.md b/backend-rewrite-tasklist/05_M6_ASSETS_OSS_EDITOR.md index 85ee2698..6c91f3ea 100644 --- a/backend-rewrite-tasklist/05_M6_ASSETS_OSS_EDITOR.md +++ b/backend-rewrite-tasklist/05_M6_ASSETS_OSS_EDITOR.md @@ -77,6 +77,13 @@ - [ ] 迁移 Qwen 保存 - [ ] 迁移场景图生成 - [ ] 迁移封面图上传 +- [x] 首批收口 custom world `scene-image / cover-image / cover-upload` 到正式 `OSS + asset_object + asset_entity_binding` 主链(保持旧 `/generated-*` 返回 contract,不再写仓库 `public/`) + +补充说明: + +1. 本次收口只解决 custom world 兼容图片入口的正式资产真相链,不代表 DashScope 图片生成、任务状态、封面裁剪压缩能力已全量迁完。 +2. 详细边界见: + - [../docs/technical/M6_CUSTOM_WORLD_ASSET_OSS_INTEGRATION_STAGE1_2026-04-22.md](../docs/technical/M6_CUSTOM_WORLD_ASSET_OSS_INTEGRATION_STAGE1_2026-04-22.md) ## 5. 路径兼容 @@ -108,3 +115,8 @@ - [ ] 前端仍能通过旧路径习惯访问资源 - [ ] 资产任务状态可查询 - [x] 已确认对象可绑定到业务实体槽位 + +补充说明: + +1. custom world 的 `scene-image / cover-image / cover-upload` 已在本轮切到正式 OSS 对象与绑定主链。 +2. `所有新生成资产都写入 OSS` 与 `前端仍能通过旧路径习惯访问资源` 仍需继续把角色主形象、动画、Qwen 精灵与其余历史渲染入口一并收口后再整体勾选。 diff --git a/backend-rewrite-tasklist/06_M7_TEST_DEPLOY_CUTOVER.md b/backend-rewrite-tasklist/06_M7_TEST_DEPLOY_CUTOVER.md index 318f84e4..eda7cf87 100644 --- a/backend-rewrite-tasklist/06_M7_TEST_DEPLOY_CUTOVER.md +++ b/backend-rewrite-tasklist/06_M7_TEST_DEPLOY_CUTOVER.md @@ -38,7 +38,17 @@ - [ ] 准备前端切换开关 - [ ] 准备回退开关 -## 5. 阶段验收 +## 5. 主工程结构收口 + +- [ ] 拆分 `server-rs/crates/spacetime-module/src/lib.rs`,按业务模块与 SpacetimeDB 的 `table / reducer / procedure / view` 聚合结构整理为 `runtime`、`gameplay::{story/combat/inventory/npc/quest/runtime_item/progression}`、`custom_world`、`asset_metadata`、`ai` 等子模块,主工程 crate 根入口只保留模块声明、统一导出与最小发布入口 + +执行约束: + +1. 这是切流前的工程结构收口,不是新功能扩张;拆分过程中不得改变既有 table schema、reducer / procedure 名称、对外 contract 与 publish 行为。 +2. 拆分后的模块边界必须与 `M0` 已冻结的模块迁移归属一致,避免 `spacetime-module` 再回退成单大包。 +3. 拆分完成后至少要保持 `cargo check`、SpacetimeDB 本地 build / publish 开发链路与主流程回归脚本可继续通过。 + +## 6. 阶段验收 - [ ] 全链路 smoke 通过 - [ ] 主流程回归通过 diff --git a/docs/technical/M4_RUNTIME_STORY_COMPAT_STATE_BRIDGE_DESIGN_2026-04-22.md b/docs/technical/M4_RUNTIME_STORY_COMPAT_STATE_BRIDGE_DESIGN_2026-04-22.md index f731a135..5f83ee55 100644 --- a/docs/technical/M4_RUNTIME_STORY_COMPAT_STATE_BRIDGE_DESIGN_2026-04-22.md +++ b/docs/technical/M4_RUNTIME_STORY_COMPAT_STATE_BRIDGE_DESIGN_2026-04-22.md @@ -4,16 +4,16 @@ ## 0. 文档目标 -本文件只冻结 `M4` 当前下一条最小可落地兼容桥: +本文件冻结 `M4` 当前 runtime story compat bridge 的实际已落地边界: -**先把 Rust `api-server` 侧旧 `runtime story state` 兼容返回所需的 DTO 与状态桥边界冻结清楚,再进入 Axum handler 与状态编译迁移。** +**Rust `api-server` 已承接旧 `runtime/story/*` 兼容接口,但当前仍属于“快照桥 + 确定性兼容动作”阶段,不等价于最终 SpacetimeDB 真相 story reducer。** 当前仓库已经有两条并行现实: 1. `server-node` 侧旧兼容接口 `POST /api/runtime/story/state/resolve` 仍然在真实服务前端。 2. `server-rs` 侧已经有 `story_session / battle_state / npc battle / inventory state` 等真相态接口,但还没有编译成旧前端消费的 `RuntimeStoryActionResponse`。 -因此,本轮不直接宣称“runtime story 已迁完”,而是先把兼容桥 contract 冻结为下一段可编码的工程基线。 +因此,本文档既记录当前兼容桥为什么存在,也明确它的已完成能力和仍未替换掉的真相态缺口。 --- @@ -74,19 +74,19 @@ ## 2. 本轮冻结范围 -本轮只冻结以下兼容桥边界: +本轮实际已落地并冻结以下兼容桥边界: 1. Rust `shared-contracts` 新增旧 `runtime story` 兼容响应 DTO -2. Rust `shared-contracts` 新增 `POST /api/runtime/story/state/resolve` 的最小请求 DTO -3. 明确 Rust 侧第一段只先承接“状态查询兼容桥” -4. 明确 `actions/resolve`、`initial`、`continue` 继续后置 +2. Rust `shared-contracts` 新增 `POST /api/runtime/story/state/resolve` / `POST /api/runtime/story/actions/resolve` / `POST /api/runtime/story/initial` / `POST /api/runtime/story/continue` 所需请求 DTO +3. Rust `api-server` 已挂出全部旧 runtime story 兼容接口 +4. 明确当前实现仍以 `runtime_snapshot` 为状态真相来源,而不是新的 `resolve_story_action` reducer -本轮明确不做: +本轮明确仍未做: 1. 不在 `server-rs` 里直接落完整 `resolve_story_action` 2. 不迁移 Node 侧全部 story 行为决策 -3. 不把 `runtime snapshot` 正式持久化真相一次性迁到 Rust -4. 不在本轮让前端切到 Rust `api-server` +3. 不把 `runtime snapshot projection` 一次性改成全量新真相模型 +4. 不在本文里宣称前端默认流量已经切到 Rust `api-server` --- @@ -183,6 +183,81 @@ 这与当前 Node `getRuntimeStoryState(...)` 的行为一致,不需要在状态查询时伪造 patch。 +### 4.2.2 `actions/resolve` 首版策略 + +当前 Rust compat handler 已按“确定性兼容动作 + snapshot 回写”落地,目标是先覆盖前端实际点击主链,而不是一步到位复刻 Node 全部 story domain。 + +当前已覆盖动作: + +1. `story_continue_adventure` +2. `story_opening_camp_dialogue` +3. `camp_travel_home_scene` +4. `idle_call_out` +5. `idle_explore_forward` +6. `idle_observe_signs` +7. `idle_rest_focus` +8. `idle_travel_next_scene` +9. `npc_preview_talk` +10. `npc_chat` +11. `npc_help` +12. `npc_leave` +13. `npc_fight` +14. `npc_spar` +15. `npc_recruit` +16. `battle_attack_basic` +17. `battle_use_skill` +18. `battle_all_in_crush` +19. `battle_escape_breakout` +20. `battle_feint_step` +21. `battle_finisher_window` +22. `battle_guard_break` +23. `battle_probe_pressure` +24. `battle_recover_breath` +25. `treasure_secure` +26. `treasure_inspect` +27. `treasure_leave` + +统一规则: + +1. 请求带 `snapshot` 时先写入 `runtime_snapshot` +2. 请求不带 `snapshot` 时回退读取持久化 `runtime_snapshot` +3. `clientVersion` 与 `gameState.runtimeActionVersion` 不一致时返回 `409` +4. 动作成功后递增 `runtimeActionVersion` +5. 追加 `storyHistory`,并把新的 `currentStory` / `viewModel` / `presentation` / `patches` 回写到 snapshot + +当前已额外对齐的 Node 旧主链细节: + +1. `npc_chat` + - 已从最初的固定 `+1 affinity` 修正为 Node 旧规则 `max(2, 6 - chattedCount)` + - 例如 `chattedCount = 0` 时首聊会从 `46 -> 52` +2. `npc_help` + - 已改为一次性援手 + - 成功时恢复 `10 HP / 8 Mana` + - 同时关系 `+4` + - 二次调用返回错误 +3. `npc_recruit` + - 已要求 `affinity >= 60` + - 当前队伍满员时必须提交 `releaseNpcId` + - 当前 compat bridge 也会把换队结果写回 `companions` +4. quest compat 主循环 + - 已补 `npc_chat_quest_offer_view / replace / abandon` + - 已补 `npc_quest_accept / npc_quest_turn_in` + - `pendingQuestOffer.quest` 会继续写回 `currentStory.npcChatState` + - quest offer 选项会继续携带前端面板依赖的 `runtimePayload.npcChatQuestOfferAction` +5. `npc_quest_turn_in` + - quest 不再被直接从快照中移除,而是保留为 `status = turned_in` + - 当前最小奖励闭环已写回 `playerCurrency / playerInventory / playerProgression / npc affinity` + - `playerProgression` 当前仍走 compat 侧确定性经验累计,不等价于最终 SpacetimeDB 真相成长链 + +### 4.2.3 `initial` / `continue` 首版策略 + +当前 Rust compat handler 已提供稳定 `RuntimeStoryAiResponse`: + +1. 优先复用 `requestOptions.availableOptions / optionCatalog` +2. 未配置 `platform-llm` 时返回确定性 fallback `storyText` +3. 已配置 `platform-llm` 时,允许基于同一请求载荷生成增强版文本 +4. 当前 `encounter` 仍返回 `null` + --- ## 5. DTO 分层 @@ -223,15 +298,29 @@ --- -## 6. 第一段工程落地顺序 +## 6. 当前已落地工程顺序 -建议直接按下面顺序编码: +本轮实际完成顺序: 1. `shared-contracts` 新增 `runtime_story.rs` 2. 为 `RuntimeStoryStateResolveRequest / RuntimeStoryActionResponse` 补 camelCase 序列化测试 -3. `docs/technical/README.md` 与 `shared-contracts/README.md` 更新索引 -4. `backend-rewrite-tasklist/03_M4_STORY_AND_GAMEPLAY.md` 追加当前冻结进展 -5. 下一轮再进入 `api-server` 的 `state/resolve` handler 与兼容 compiler +3. 恢复并重建 `api-server/src/runtime_story.rs` +4. 接入 `state/resolve`、`GET state`、`actions/resolve`、`initial`、`continue` +5. 复用 `runtime_save` 的 SpacetimeDB snapshot 持久化链 +6. 执行 `cargo test -p shared-contracts` +7. 执行 `cargo check -p api-server` +8. 执行 `cargo test -p api-server runtime_story` +9. 继续把 Node 旧 route boundary 回归平移到 Rust: + - `runtime_story_routes_resolve_through_rust_route_boundary` + - `runtime_story_action_resolve_rejects_client_version_conflict` +10. 继续补关键 NPC compat 行为回归: + - `runtime_story_npc_help_is_one_shot_and_restores_resources` + - `runtime_story_npc_recruit_requires_threshold_and_release_target_when_party_full` +11. 继续补 quest compat 回归: + - `runtime_story_quest_offer_replace_updates_pending_offer_and_payload` + - `runtime_story_quest_offer_abandon_clears_pending_offer_and_restores_chat_options` + - `runtime_story_quest_accept_writes_quest_runtime_stats_and_followup_story` + - `runtime_story_quest_turn_in_marks_quest_rewards_and_affinity` --- @@ -239,13 +328,14 @@ 以下内容继续明确后置: -1. `POST /api/runtime/story/actions/resolve` 的请求 DTO 是否直接复用旧 TS contract 全量字段 -2. `resolve_story_action` 是否拆成: +1. `resolve_story_action` 是否拆成: - `resolve_story_action` - `resolve_story_combat_action` - `resolve_story_interaction_action` -3. `snapshot` 缺失时是否允许直接从 Rust 真相表完整恢复旧 `currentStory` -4. `LLM` 文本续写是在 Rust bridge 内继续调用,还是继续通过 Node 兼容层兜底 +2. `snapshot` 缺失时是否允许直接从 Rust 真相表完整恢复旧 `currentStory` +3. 当前确定性 compat action 何时被真正的 SpacetimeDB story reducer 替换 +4. `battle / npc / quest / inventory` patch 是否继续细化成与 Node 完全逐字段一致 +5. `npc_quest_turn_in` 的经验、物品、情报、章节推进何时切换到真正的 SpacetimeDB progression / inventory / quest 真相链,而不是 compat 侧快照写回 这些边界在状态桥稳定前都不应提前拍死。 @@ -258,7 +348,20 @@ 1. 已有独立技术文档冻结 `state/resolve` 兼容桥边界 2. `shared-contracts` 已拥有旧 `runtime story` 兼容 DTO 3. DTO 字段名与当前前端消费口径保持一致 -4. `cargo test -p shared-contracts` 通过 -5. `npm run check:encoding` 通过,确保新增中文文档与 Rust 源文件编码未损坏 +4. `api-server` 已挂出: + - `POST /api/runtime/story/state/resolve` + - `GET /api/runtime/story/state/:sessionId` + - `POST /api/runtime/story/actions/resolve` + - `POST /api/runtime/story/initial` + - `POST /api/runtime/story/continue` +5. `cargo test -p shared-contracts` 通过 +6. `cargo check -p api-server` 通过 +7. `cargo test -p api-server runtime_story` 通过 +8. `node scripts/check-encoding.mjs` 通过 -达到以上条件后,下一轮即可直接进入 Rust `state bridge compiler` 与 Axum handler 落地。 +补充边界: + +1. 当前测试里为 `runtime_snapshot` 加了 `#[cfg(test)]` 下的内存兜底,只用于在未启动本地 SpacetimeDB 时稳定验证 Rust route boundary。 +2. 该测试兜底不进入生产链路,不改变真实 `runtime_save -> spacetime-client -> SpacetimeDB procedure` 的运行时实现。 + +达到以上条件后,兼容桥这一段已不再停留在 DTO / 空壳状态;下一轮重点转向“compat bridge 替换成真相态 reducer / projection”。 diff --git a/docs/technical/M6_CUSTOM_WORLD_ASSET_OSS_INTEGRATION_STAGE1_2026-04-22.md b/docs/technical/M6_CUSTOM_WORLD_ASSET_OSS_INTEGRATION_STAGE1_2026-04-22.md new file mode 100644 index 00000000..b3364e7a --- /dev/null +++ b/docs/technical/M6_CUSTOM_WORLD_ASSET_OSS_INTEGRATION_STAGE1_2026-04-22.md @@ -0,0 +1,158 @@ +# M6 custom world 资产接入 OSS 第一批设计 + +日期:`2026-04-22` + +## 1. 文档目的 + +这份文档用于冻结 `M6` 第一批 custom world 资产链的真实落地口径。 + +本批只解决一个明确问题: + +1. `POST /api/custom-world/scene-image` +2. `POST /api/custom-world/cover-image` +3. `POST /api/custom-world/cover-upload` + +不再把图片产物写入仓库 `public/` 本地文件,而是统一接到: + +1. `platform-oss::put_object` +2. `asset_object` +3. `asset_entity_binding` + +形成正式 `OSS + SpacetimeDB 元数据` 真相链。 + +## 2. 当前前提 + +当前仓库已经具备以下基础能力: + +1. `POST /api/assets/direct-upload-tickets` +2. `GET /api/assets/read-url` +3. `POST /api/assets/objects/confirm` +4. `POST /api/assets/objects/bind` +5. `platform-oss::OssClient::put_object` +6. `spacetime-module` 中的 `asset_object / asset_entity_binding` + +因此本批不重新设计新资产系统,只复用既有 `assets` 主链。 + +## 3. 本批范围 + +### 3.1 要完成的内容 + +1. custom world 场景图生成结果写入 OSS +2. custom world 封面图生成结果写入 OSS +3. custom world 封面上传结果写入 OSS +4. 每个写入对象都执行一次正式对象确认 +5. 每个正式对象都绑定到 custom world 业务实体槽位 +6. 路由响应继续返回旧前端可消费的 `imageSrc` + +### 3.2 本批不解决的内容 + +1. 不补 DashScope 图片模型的完整 Rust 编排 +2. 不补 `cover-upload` 的裁剪、压缩、16:9 强校验全量能力 +3. 不新增 `scene_image_asset / character_visual_asset` 强业务表 +4. 不在本批落 `custom_world_asset_link` +5. 不把旧前端响应 contract 改成直接返回 OSS URL + +## 4. 业务实体与槽位约定 + +本批统一复用通用 `asset_entity_binding`。 + +### 4.1 场景图 + +| 字段 | 取值 | +| --- | --- | +| `entity_kind` | `custom_world_landmark` | +| `entity_id` | 优先 `landmarkId`,否则回退 `landmarkName` | +| `slot` | `scene_image` | +| `asset_kind` | `scene_image` | + +### 4.2 封面图 + +| 字段 | 取值 | +| --- | --- | +| `entity_kind` | `custom_world_profile` | +| `entity_id` | 优先 `profileId`,否则回退世界 `id/name` | +| `slot` | `cover` | +| `asset_kind` | `custom_world_cover` | + +补充口径: + +1. 绑定幂等键仍是 `entity_kind + entity_id + slot` +2. 同一 profile 重复生成/上传封面时,允许覆盖到最新对象 +3. 同一 landmark 重复生成场景图时,允许覆盖到最新对象 + +## 5. OSS 对象键与返回 contract + +### 5.1 对象键 + +场景图固定写入: + +`generated-custom-world-scenes/{profileSegment}/{landmarkSegment}/{assetId}/scene.{ext}` + +封面图固定写入: + +`generated-custom-world-covers/{profileSegment}/{assetId}/cover.{ext}` + +### 5.2 返回 contract + +路由响应继续沿用旧前端使用的字段: + +1. `imageSrc` +2. `assetId` +3. `sourceType` +4. `model` +5. `size` +6. `taskId` +7. `prompt` +8. `actualPrompt` + +其中: + +1. `imageSrc` 固定返回 `legacyPublicPath`,也就是旧 `/generated-*` 路径 +2. 前端若要真正读取私有 OSS 对象,仍必须通过 `GET /api/assets/read-url` 换签名读 URL +3. 不直接把 `signedUrl` 塞进 custom world 业务返回,避免把短期读签名误存成长期业务字段 + +## 6. 服务端执行顺序 + +每次 custom world 图片产出固定执行以下顺序: + +1. 生成或接收图片字节 +2. 调 `platform-oss::put_object` +3. 通过 `HEAD Object` 真值确认对象 +4. 写入 `asset_object` +5. 写入 `asset_entity_binding` +6. 返回 `legacyPublicPath` + +注意: + +1. `put_object` 成功不代表已完成正式落库 +2. `asset_object` 仍必须经过确认链路写入 +3. 业务引用真相以 `asset_entity_binding` 为准,不以 OSS 上是否存在 key 为准 + +## 7. 与 M5 的衔接 + +`M5` 为保证前端不断链,曾允许 `scene-image / cover-image / cover-upload` 先写本地 `public/`。 + +从本批开始,这个临时口径失效,统一改为: + +1. 二进制对象只进 OSS +2. 元数据只进 `asset_object` +3. 业务槽位只进 `asset_entity_binding` + +这样 `Stage9` 的兼容路由就不会继续偏离 `M6` 正式资产主链。 + +## 8. 完成定义 + +当以下条件满足时,本批视为完成: + +1. custom world 三条图片兼容路由不再写本地 `public/` +2. 路由成功返回的 `imageSrc` 全部来自 `OSS legacyPublicPath` +3. 每次成功写图后都能在 SpacetimeDB 中形成 `asset_object` +4. 每次成功写图后都能形成对应 `asset_entity_binding` +5. 旧前端仍可继续使用返回的 `/generated-*` 路径配合读签名服务显示图片 + +## 9. 关联文档 + +1. [M6_OSS_SERVER_UPLOAD_AND_STS_POLICY_2026-04-21.md](./M6_OSS_SERVER_UPLOAD_AND_STS_POLICY_2026-04-21.md) +2. [ASSET_OBJECT_CONFIRM_FLOW_DESIGN_2026-04-21.md](./ASSET_OBJECT_CONFIRM_FLOW_DESIGN_2026-04-21.md) +3. [ASSET_ENTITY_BINDING_REDUCER_DESIGN_2026-04-21.md](./ASSET_ENTITY_BINDING_REDUCER_DESIGN_2026-04-21.md) +4. [SPACETIMEDB_CUSTOM_WORLD_WORKS_AND_AGENT_EXTENSION_STAGE9_DESIGN_2026-04-22.md](./SPACETIMEDB_CUSTOM_WORLD_WORKS_AND_AGENT_EXTENSION_STAGE9_DESIGN_2026-04-22.md) diff --git a/docs/technical/README.md b/docs/technical/README.md index 536366fd..a20eb255 100644 --- a/docs/technical/README.md +++ b/docs/technical/README.md @@ -42,8 +42,10 @@ - [SPACETIMEDB_CUSTOM_WORLD_AGENT_SESSION_STAGE6_DESIGN_2026-04-22.md](./SPACETIMEDB_CUSTOM_WORLD_AGENT_SESSION_STAGE6_DESIGN_2026-04-22.md):冻结 `M5` Agent session create / snapshot 的最小 SpacetimeDB 与 Axum facade 闭环,明确本轮不迁移 LLM、SSE、卡片更新和完整 action registry。 - [SPACETIMEDB_CUSTOM_WORLD_AGENT_MESSAGE_STAGE7_DESIGN_2026-04-22.md](./SPACETIMEDB_CUSTOM_WORLD_AGENT_MESSAGE_STAGE7_DESIGN_2026-04-22.md):冻结 `M5` Agent `message submit / operation query` 的 deterministic 最小闭环,明确同步写入 user/assistant 消息、`process_message` operation 与 session 进度推进规则。 - [SPACETIMEDB_CUSTOM_WORLD_AGENT_MESSAGE_STREAM_STAGE8_DESIGN_2026-04-22.md](./SPACETIMEDB_CUSTOM_WORLD_AGENT_MESSAGE_STREAM_STAGE8_DESIGN_2026-04-22.md):冻结 `M5` Agent `/messages/stream` 的最小兼容 SSE facade,明确复用 Stage 7 的同步写表逻辑,只输出当前前端真实消费的 `reply_delta / session / done / error` 事件。 +- [RUST_API_SERVER_SSE_INFRASTRUCTURE_DESIGN_2026-04-22.md](./RUST_API_SERVER_SSE_INFRASTRUCTURE_DESIGN_2026-04-22.md):冻结 `server-rs/crates/api-server` 内部 SSE 基础设施抽取口径,统一 Rust 侧 `text/event-stream` 响应头、事件编码与缓冲式 SSE 输出 helper。 - [SPACETIMEDB_CUSTOM_WORLD_LIBRARY_DETAIL_STAGE5_EXTENSION_DESIGN_2026-04-22.md](./SPACETIMEDB_CUSTOM_WORLD_LIBRARY_DETAIL_STAGE5_EXTENSION_DESIGN_2026-04-22.md):补齐 `M5` Stage 5 遗漏的 owner-only `GET /api/runtime/custom-world-library/:profileId` 设计,冻结单条 profile detail 的 SpacetimeDB procedure、client facade、404 语义与 Axum 路由扩展方式。 - [SPACETIMEDB_CUSTOM_WORLD_WORKS_AND_AGENT_EXTENSION_STAGE9_DESIGN_2026-04-22.md](./SPACETIMEDB_CUSTOM_WORLD_WORKS_AND_AGENT_EXTENSION_STAGE9_DESIGN_2026-04-22.md):冻结 `M5` 剩余主链的 works、card detail、publish gate、supportedActions、action registry 与 AI/OSS 兼容路由边界,作为 Stage 9 到收口阶段的统一落地依据。 +- [M6_CUSTOM_WORLD_ASSET_OSS_INTEGRATION_STAGE1_2026-04-22.md](./M6_CUSTOM_WORLD_ASSET_OSS_INTEGRATION_STAGE1_2026-04-22.md):冻结 `M6` 第一批 custom world 场景图、封面图、封面上传从本地 `public/` 临时落地切到 `OSS + asset_object + asset_entity_binding` 正式真相链的边界与槽位约定。 - [M3_BROWSE_HISTORY_AXUM_SPACETIMEDB_DESIGN_2026-04-21.md](./M3_BROWSE_HISTORY_AXUM_SPACETIMEDB_DESIGN_2026-04-21.md):冻结 `M3` 第二批 `browse history` 纵向切片的 `user_browse_history` 表、双路径 facade、宽松归一化、去重排序规则与测试策略。 - [ASSET_ENTITY_BINDING_REDUCER_DESIGN_2026-04-21.md](./ASSET_ENTITY_BINDING_REDUCER_DESIGN_2026-04-21.md):冻结已确认 `asset_object` 绑定到业务实体槽位的首版 reducer/procedure、通用 `asset_entity_binding` 表与 Axum facade。 - [FRONTEND_TO_BACKEND_MIGRATION_EXECUTION_PLAN_2026-04-21.md](./FRONTEND_TO_BACKEND_MIGRATION_EXECUTION_PLAN_2026-04-21.md):把鉴权、浏览历史、runtime story 快照、NPC 待接委托与正式生成编排继续后移到 Express 后端的实施方案与验收口径。 diff --git a/docs/technical/RUST_API_SERVER_SSE_INFRASTRUCTURE_DESIGN_2026-04-22.md b/docs/technical/RUST_API_SERVER_SSE_INFRASTRUCTURE_DESIGN_2026-04-22.md new file mode 100644 index 00000000..fe31457a --- /dev/null +++ b/docs/technical/RUST_API_SERVER_SSE_INFRASTRUCTURE_DESIGN_2026-04-22.md @@ -0,0 +1,118 @@ +# Rust `api-server` SSE 基础设施设计(2026-04-22) + +日期:`2026-04-22` + +## 1. 文档目的 + +这份文档用于冻结 `server-rs/crates/api-server` 内部的 SSE 基础设施抽取口径。 + +本轮目标只有一个: + +1. 把当前散落在业务 handler 中的 `text/event-stream` 响应头与事件文本编码逻辑,收口为 `api-server` 可复用的 Rust 基础设施。 + +本轮不做: + +1. 不改前端消费协议 +2. 不把 custom world message stream 改成真实逐段 token streaming +3. 不引入跨 crate 的共享 `shared-contracts` SSE runtime helper +4. 不同时重构 story / runtime / txt mode 的未来流式接口 + +## 2. 当前问题 + +当前 Rust 侧 SSE 能力只在一个地方手写: + +1. `server-rs/crates/api-server/src/custom_world.rs` + +当前实现存在以下问题: + +1. `append_sse_event(...)` 与 `build_event_stream_response(...)` 直接写在业务文件里 +2. SSE 响应头、事件编码规则没有统一入口 +3. 后续如果再新增第二条 Rust SSE 路由,极容易复制一份近似实现 +4. 业务层和传输层耦合在一起,不利于测试 + +## 3. 抽取边界 + +本轮只抽以下基础能力: + +1. 标准 SSE 响应头构造 +2. 单条事件编码 +3. 缓冲式 SSE body builder +4. 一次性返回完整 SSE 文本的响应构造 + +本轮明确不抽: + +1. `reply_delta / session / done / error` 这些业务事件名 +2. 事件发送顺序 +3. custom world session 的查询与回复文本推导 +4. 业务错误到 SSE `error` 事件的映射策略 + +原因固定如下: + +1. 这些内容属于业务协议,而不是通用传输设施 +2. 当前不同链路未来很可能有不同事件集合 +3. 先把传输层抽干净,后续真实流式能力才能稳定复用 + +## 4. 基础设施 API 口径 + +本轮在 `server-rs/crates/api-server/src/sse.rs` 提供: + +1. `SseEventBuffer` + - 面向当前最小兼容场景 + - 内部持有 `String` + - 提供 `push_json(event, payload)` 与 `into_response()` +2. `build_sse_response(body)` + - 统一写入标准 SSE 响应头 +3. `encode_sse_event(body, event, payload)` + - 只负责把事件编码为: + ```text + event: xxx + data: {...} + + ``` + +## 5. 标准响应头 + +所有通过本基础设施输出的 SSE 响应,统一包含: + +1. `Content-Type: text/event-stream; charset=utf-8` +2. `Cache-Control: no-cache` +3. `X-Accel-Buffering: no` + +当前不默认加入: + +1. `Connection: keep-alive` + +原因: + +1. 当前 Rust `axum` 一次性 body 返回场景不依赖显式设置该头 +2. 保持最小必要头集合,避免提前固化未来长连接策略 + +## 6. 与 custom world message stream 的关系 + +`POST /api/runtime/custom-world/agent/sessions/:sessionId/messages/stream` 仍然保持 Stage 8 文档冻结的最小语义: + +1. 业务层先完成 deterministic 写表 +2. 读取最新 session snapshot +3. 组装 `reply_delta` +4. 组装 `session` +5. 组装 `done` +6. 一次性返回完整 SSE 文本 + +本轮变化只在于: + +1. 事件编码和响应头不再手写在 `custom_world.rs` +2. 改由 `sse.rs` 基础设施承接 + +## 7. 验收标准 + +当以下条件满足时,本轮视为完成: + +1. `api-server/src/sse.rs` 已提供可复用 SSE helper +2. `custom_world.rs` 不再内联维护 SSE 编码与响应头细节 +3. `cargo fmt -p api-server` 通过 +4. `cargo check -p api-server` 通过 +5. `npm run check:encoding` 通过 + +## 8. 一句话结论 + +本轮把 Rust `api-server` 里的 SSE 能力收口为“最小传输层基础设施”,统一事件编码与响应头,但不改业务事件协议和当前 custom world 的同步伪流式语义。 diff --git a/docs/technical/SPACETIMEDB_AXUM_OSS_BACKEND_REWRITE_DESIGN_2026-04-20.md b/docs/technical/SPACETIMEDB_AXUM_OSS_BACKEND_REWRITE_DESIGN_2026-04-20.md index c4b76f0e..1d4f55d2 100644 --- a/docs/technical/SPACETIMEDB_AXUM_OSS_BACKEND_REWRITE_DESIGN_2026-04-20.md +++ b/docs/technical/SPACETIMEDB_AXUM_OSS_BACKEND_REWRITE_DESIGN_2026-04-20.md @@ -832,6 +832,21 @@ workflow-cache/{workflow_type}/{workflow_id}.json 1. `editor` 已于 `2026-04-21` 被确认为遗留无用模块,退出本轮 Rust 后端重写范围。 2. Phase 5 只覆盖资产与 OSS 主链,不再包含 editor 迁移。 +## Phase 6:联调、回归、部署与切流收口 + +交付: + +1. 联调与回归测试体系 +2. 灰度环境、切流开关、回退方案 +3. tracing / request id / 关键链路观测 +4. 拆分 `server-rs/crates/spacetime-module/src/lib.rs`,按业务模块与 SpacetimeDB 的 `table / reducer / procedure / view` 结构重组为 `runtime`、`gameplay::{story/combat/inventory/npc/quest/runtime_item/progression}`、`custom_world`、`asset_metadata`、`ai` 等聚合子模块,主工程 crate 根入口只保留模块声明、统一导出与最小发布入口 + +阶段执行补充: + +1. 这是切流前的工程结构收口,不是新功能扩张;拆分过程中不得改变既有 table schema、reducer / procedure 名称、对外 contract 与 publish 行为。 +2. 拆分后的目录与模块边界必须对齐 `M0` 已冻结的模块迁移归属,避免 `spacetime-module` 回退成“单大文件 + 单大包”结构。 +3. 拆分完成后至少要保持 `cargo check`、SpacetimeDB 本地 build / publish 开发链路与主流程回归脚本可继续通过。 + ## 14. 验收标准 重写完成至少要满足: diff --git a/docs/technical/SPACETIMEDB_CUSTOM_WORLD_WORKS_AND_AGENT_EXTENSION_STAGE9_DESIGN_2026-04-22.md b/docs/technical/SPACETIMEDB_CUSTOM_WORLD_WORKS_AND_AGENT_EXTENSION_STAGE9_DESIGN_2026-04-22.md index 8536f99d..92d0c4c6 100644 --- a/docs/technical/SPACETIMEDB_CUSTOM_WORLD_WORKS_AND_AGENT_EXTENSION_STAGE9_DESIGN_2026-04-22.md +++ b/docs/technical/SPACETIMEDB_CUSTOM_WORLD_WORKS_AND_AGENT_EXTENSION_STAGE9_DESIGN_2026-04-22.md @@ -331,15 +331,24 @@ session snapshot 中的 `resultPreview` 固定输出: #### `scene-image / cover-image` -1. 当前不直接生成真实图片 -2. 返回明确 `NOT_IMPLEMENTED` 或最小占位错误会导致前端主链中断 -3. 因此前端兼容需要的最小可用策略是:创建上传票据或返回可继续上传的对象位置信息 +1. `M5` 验收时允许先用本地占位产物保证前端主链不断 +2. 自 `2026-04-22` 的 `M6` 第一批开始,正式口径改为: + - `platform-oss::put_object` + - `asset_object` + - `asset_entity_binding` +3. 兼容响应仍返回旧 `/generated-*` 路径,不直接返回裸 OSS URL +4. 详细边界见: + - [M6_CUSTOM_WORLD_ASSET_OSS_INTEGRATION_STAGE1_2026-04-22.md](./M6_CUSTOM_WORLD_ASSET_OSS_INTEGRATION_STAGE1_2026-04-22.md) #### `cover-upload` -1. 复用 `/api/assets/direct-upload-tickets` -2. 生成 OSS 上传票据 -3. 返回兼容旧前端所需的上传字段 +1. `M5` 阶段允许先走最小本地上传兼容 +2. 自 `2026-04-22` 的 `M6` 第一批开始,正式口径与 `cover-image` 一致: + - 服务器接收 Data URL + - 服务器上传 OSS + - 确认 `asset_object` + - 绑定 `asset_entity_binding` +3. 返回值仍保持旧前端所需的 `imageSrc / assetId / sourceType` ## 8. crate 级改动范围 diff --git a/server-rs/crates/api-server/README.md b/server-rs/crates/api-server/README.md index b834bee3..6af48849 100644 --- a/server-rs/crates/api-server/README.md +++ b/server-rs/crates/api-server/README.md @@ -44,6 +44,12 @@ 22. 接入 `POST /api/assets/sts-upload-credentials` 禁用式 STS 写权限 contract 23. 接入 `custom-world-library`、`custom-world-gallery` 与 agent `publish_world` 首批 Axum facade 24. 接入 custom world agent `session create / session snapshot` Axum facade +25. 接入旧 `runtime story` 兼容接口: + - `POST /api/runtime/story/state/resolve` + - `GET /api/runtime/story/state/{session_id}` + - `POST /api/runtime/story/actions/resolve` + - `POST /api/runtime/story/initial` + - `POST /api/runtime/story/continue` 后续与本 crate 直接相关的任务包括: @@ -68,6 +74,7 @@ 19. [x] 接入 `/api/assets/sts-upload-credentials` 20. [x] 接入 `custom world library / gallery / publish_world` 首批 facade 21. [x] 接入 `custom world agent session create / snapshot` facade +22. [x] 接入旧 `runtime story` compat facade 当前 tracing 约定: @@ -136,3 +143,4 @@ 12. 当前微信回调不会把第三方 token 直接透传给前端或 SpacetimeDB,而是统一换成系统签发的 JWT。 13. 当前 `/api/assets/sts-upload-credentials` 按“服务器上传、Web 只下载”口径固定返回 `403`,不向浏览器下发 OSS 写权限。 14. 当前 `/api/runtime/custom-world/agent/sessions` 与 `/api/runtime/custom-world/agent/sessions/{session_id}` 只提供 deterministic session 骨架与 snapshot 读取,不承诺 message submit、operation query、card detail 的完整能力。 +15. 当前 `/api/runtime/story/*` 已在 Rust 侧补齐 compat handler,但内部仍是 `runtime_snapshot` 驱动的兼容桥与确定性动作编排,不应误判为真正的 SpacetimeDB `resolve_story_action` 真相链已完成。 diff --git a/server-rs/crates/api-server/src/app.rs b/server-rs/crates/api-server/src/app.rs index 83d28bb8..1417acc3 100644 --- a/server-rs/crates/api-server/src/app.rs +++ b/server-rs/crates/api-server/src/app.rs @@ -26,11 +26,9 @@ use crate::{ auth_sessions::auth_sessions, custom_world::{ create_custom_world_agent_session, execute_custom_world_agent_action, - get_custom_world_agent_card_detail, - get_custom_world_agent_operation, get_custom_world_agent_session, - get_custom_world_works, - get_custom_world_gallery_detail, get_custom_world_library, - get_custom_world_library_detail, list_custom_world_gallery, + get_custom_world_agent_card_detail, get_custom_world_agent_operation, + get_custom_world_agent_session, get_custom_world_gallery_detail, get_custom_world_library, + get_custom_world_library_detail, get_custom_world_works, list_custom_world_gallery, publish_custom_world_library_profile, put_custom_world_library_profile, stream_custom_world_agent_message, submit_custom_world_agent_message, unpublish_custom_world_library_profile, @@ -62,8 +60,8 @@ 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, + generate_runtime_story_continue, generate_runtime_story_initial, get_runtime_story_state, + resolve_runtime_story_action, resolve_runtime_story_state, }, state::AppState, story_battles::{ @@ -242,9 +240,9 @@ pub fn build_router(state: AppState) -> Router { get(get_runtime_settings) .put(put_runtime_settings) .route_layer(middleware::from_fn_with_state( - state.clone(), - require_bearer_auth, - )), + state.clone(), + require_bearer_auth, + )), ) .route( "/api/runtime/save/snapshot", @@ -316,9 +314,10 @@ pub fn build_router(state: AppState) -> Router { ) .route( "/api/runtime/custom-world/agent/sessions/{session_id}/cards/{card_id}", - get(get_custom_world_agent_card_detail).route_layer( - middleware::from_fn_with_state(state.clone(), require_bearer_auth), - ), + get(get_custom_world_agent_card_detail).route_layer(middleware::from_fn_with_state( + state.clone(), + require_bearer_auth, + )), ) .route( "/api/runtime/custom-world/agent/sessions/{session_id}/messages", @@ -364,45 +363,52 @@ pub fn build_router(state: AppState) -> Router { ) .route( "/api/custom-world/scene-npc", - post(generate_custom_world_scene_npc).route_layer( - middleware::from_fn_with_state(state.clone(), require_bearer_auth), - ), + post(generate_custom_world_scene_npc).route_layer(middleware::from_fn_with_state( + state.clone(), + require_bearer_auth, + )), ) .route( "/api/runtime/custom-world/scene-npc", - post(generate_custom_world_scene_npc).route_layer( - middleware::from_fn_with_state(state.clone(), require_bearer_auth), - ), + post(generate_custom_world_scene_npc).route_layer(middleware::from_fn_with_state( + state.clone(), + require_bearer_auth, + )), ) .route( "/api/custom-world/scene-image", - post(generate_custom_world_scene_image).route_layer( - middleware::from_fn_with_state(state.clone(), require_bearer_auth), - ), + post(generate_custom_world_scene_image).route_layer(middleware::from_fn_with_state( + state.clone(), + require_bearer_auth, + )), ) .route( "/api/custom-world/cover-image", - post(generate_custom_world_cover_image).route_layer( - middleware::from_fn_with_state(state.clone(), require_bearer_auth), - ), + post(generate_custom_world_cover_image).route_layer(middleware::from_fn_with_state( + state.clone(), + require_bearer_auth, + )), ) .route( "/api/runtime/custom-world/cover-image", - post(generate_custom_world_cover_image).route_layer( - middleware::from_fn_with_state(state.clone(), require_bearer_auth), - ), + post(generate_custom_world_cover_image).route_layer(middleware::from_fn_with_state( + state.clone(), + require_bearer_auth, + )), ) .route( "/api/custom-world/cover-upload", - post(upload_custom_world_cover_image).route_layer( - middleware::from_fn_with_state(state.clone(), require_bearer_auth), - ), + post(upload_custom_world_cover_image).route_layer(middleware::from_fn_with_state( + state.clone(), + require_bearer_auth, + )), ) .route( "/api/runtime/custom-world/cover-upload", - post(upload_custom_world_cover_image).route_layer( - middleware::from_fn_with_state(state.clone(), require_bearer_auth), - ), + post(upload_custom_world_cover_image).route_layer(middleware::from_fn_with_state( + state.clone(), + require_bearer_auth, + )), ) .route( "/api/runtime/profile/browse-history", diff --git a/server-rs/crates/api-server/src/custom_world.rs b/server-rs/crates/api-server/src/custom_world.rs index be8bb412..8550b12e 100644 --- a/server-rs/crates/api-server/src/custom_world.rs +++ b/server-rs/crates/api-server/src/custom_world.rs @@ -1,8 +1,8 @@ use axum::{ Json, extract::{Extension, Path, State, rejection::JsonRejection}, - http::{HeaderName, StatusCode, header}, - response::{IntoResponse, Response}, + http::StatusCode, + response::Response, }; use module_custom_world::{ CustomWorldThemeMode, empty_agent_anchor_content_json, empty_agent_asset_coverage_json, @@ -10,35 +10,34 @@ use module_custom_world::{ }; use serde_json::{Map, Value, json}; use shared_contracts::runtime::{ - CreateCustomWorldAgentSessionRequest, CustomWorldAgentCheckpointResponse, - CustomWorldAgentCardDetailResponse, - CustomWorldAgentMessageResponse, CustomWorldAgentOperationResponse, - CustomWorldAgentSessionResponse, CustomWorldAgentSessionSnapshotResponse, - CustomWorldDraftCardDetailResponse, CustomWorldDraftCardDetailSectionResponse, - CustomWorldDraftCardSummaryResponse, CustomWorldGalleryCardResponse, - CustomWorldGalleryDetailResponse, CustomWorldGalleryResponse, CustomWorldLibraryEntryResponse, - CustomWorldLibraryMutationResponse, CustomWorldLibraryResponse, - CustomWorldProfileUpsertRequest, CustomWorldSupportedActionResponse, - CustomWorldPublishGateResponse, CustomWorldResultPreviewBlockerResponse, - CustomWorldWorkSummaryResponse, CustomWorldWorksResponse, - ExecuteCustomWorldAgentActionRequest, SendCustomWorldAgentMessageRequest, + CreateCustomWorldAgentSessionRequest, CustomWorldAgentCardDetailResponse, + CustomWorldAgentCheckpointResponse, CustomWorldAgentMessageResponse, + CustomWorldAgentOperationResponse, CustomWorldAgentSessionResponse, + CustomWorldAgentSessionSnapshotResponse, CustomWorldDraftCardDetailResponse, + CustomWorldDraftCardDetailSectionResponse, CustomWorldDraftCardSummaryResponse, + CustomWorldGalleryCardResponse, CustomWorldGalleryDetailResponse, CustomWorldGalleryResponse, + CustomWorldLibraryEntryResponse, CustomWorldLibraryMutationResponse, + CustomWorldLibraryResponse, CustomWorldProfileUpsertRequest, CustomWorldPublishGateResponse, + CustomWorldResultPreviewBlockerResponse, CustomWorldSupportedActionResponse, + CustomWorldWorkSummaryResponse, CustomWorldWorksResponse, ExecuteCustomWorldAgentActionRequest, + SendCustomWorldAgentMessageRequest, }; use shared_kernel::build_prefixed_uuid_id; use spacetime_client::{ - CustomWorldAgentActionExecuteRecordInput, - CustomWorldAgentCheckpointRecord, CustomWorldAgentMessageRecord, - CustomWorldAgentMessageSubmitRecordInput, CustomWorldAgentOperationRecord, - CustomWorldAgentSessionCreateRecordInput, CustomWorldAgentSessionRecord, - CustomWorldDraftCardDetailRecord, CustomWorldDraftCardDetailSectionRecord, - CustomWorldDraftCardRecord, CustomWorldGalleryEntryRecord, CustomWorldLibraryEntryRecord, + CustomWorldAgentActionExecuteRecordInput, CustomWorldAgentCheckpointRecord, + CustomWorldAgentMessageRecord, CustomWorldAgentMessageSubmitRecordInput, + CustomWorldAgentOperationRecord, CustomWorldAgentSessionCreateRecordInput, + CustomWorldAgentSessionRecord, CustomWorldDraftCardDetailRecord, + CustomWorldDraftCardDetailSectionRecord, CustomWorldDraftCardRecord, + CustomWorldGalleryEntryRecord, CustomWorldLibraryEntryRecord, CustomWorldProfileUpsertRecordInput, CustomWorldPublishGateRecord, - CustomWorldResultPreviewBlockerRecord, CustomWorldWorkSummaryRecord, - CustomWorldSupportedActionRecord, SpacetimeClientError, + CustomWorldResultPreviewBlockerRecord, CustomWorldSupportedActionRecord, + CustomWorldWorkSummaryRecord, SpacetimeClientError, }; use crate::{ api_response::json_success_body, auth::AuthenticatedAccessToken, http_error::AppError, - request_context::RequestContext, state::AppState, + request_context::RequestContext, sse::SseEventBuffer, state::AppState, }; pub async fn get_custom_world_library( @@ -84,10 +83,7 @@ pub async fn get_custom_world_library_detail( let detail = state .spacetime_client() - .get_custom_world_library_detail( - authenticated.claims().user_id().to_string(), - profile_id, - ) + .get_custom_world_library_detail(authenticated.claims().user_id().to_string(), profile_id) .await .map_err(|error| { custom_world_error_response(&request_context, map_custom_world_client_error(error)) @@ -582,19 +578,15 @@ pub async fn stream_custom_world_agent_message( // 这里先用“一次性构造完整 SSE 文本”的最小兼容方案, // 复用 Stage 7 的同步 deterministic 写表逻辑,保证前端当前的 reader 协议可直接消费。 - let mut sse_body = String::new(); - append_sse_event(&mut sse_body, "reply_delta", &json!({ "text": reply_text })) + let mut sse = SseEventBuffer::new(); + sse.push_json("reply_delta", &json!({ "text": reply_text })) .map_err(|error| custom_world_error_response(&request_context, error))?; - append_sse_event( - &mut sse_body, - "session", - &json!({ "session": session_response }), - ) - .map_err(|error| custom_world_error_response(&request_context, error))?; - append_sse_event(&mut sse_body, "done", &json!({ "ok": true })) + sse.push_json("session", &json!({ "session": session_response })) + .map_err(|error| custom_world_error_response(&request_context, error))?; + sse.push_json("done", &json!({ "ok": true })) .map_err(|error| custom_world_error_response(&request_context, error))?; - Ok(build_event_stream_response(sse_body)) + Ok(sse.into_response()) } pub async fn get_custom_world_agent_operation( @@ -815,7 +807,9 @@ fn map_custom_world_agent_session_response( .into_iter() .map(map_custom_world_supported_action_response) .collect(), - publish_gate: session.publish_gate.map(map_custom_world_publish_gate_response), + publish_gate: session + .publish_gate + .map(map_custom_world_publish_gate_response), result_preview: session.result_preview, updated_at: session.updated_at, } @@ -958,40 +952,11 @@ fn resolve_stream_reply_text(session: &CustomWorldAgentSessionSnapshotResponse) .unwrap_or_default() } -fn append_sse_event(body: &mut String, event: &str, payload: &Value) -> Result<(), AppError> { - let payload_text = serde_json::to_string(payload).map_err(|error| { - AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_details(json!({ - "provider": "custom-world-agent", - "message": format!("SSE payload 序列化失败:{error}"), - })) - })?; - - body.push_str("event: "); - body.push_str(event); - body.push('\n'); - body.push_str("data: "); - body.push_str(&payload_text); - body.push_str("\n\n"); - - Ok(()) -} - -fn build_event_stream_response(body: String) -> Response { - ( - [ - (header::CONTENT_TYPE, "text/event-stream; charset=utf-8"), - (header::CACHE_CONTROL, "no-cache"), - // 反向代理场景下显式关闭缓冲,避免 SSE 事件被聚合后才下发。 - (HeaderName::from_static("x-accel-buffering"), "no"), - ], - body, - ) - .into_response() -} - fn map_custom_world_client_error(error: SpacetimeClientError) -> AppError { let status = match &error { - SpacetimeClientError::Procedure(message) if message.contains("custom_world_profile 不存在") => { + SpacetimeClientError::Procedure(message) + if message.contains("custom_world_profile 不存在") => + { StatusCode::NOT_FOUND } SpacetimeClientError::Procedure(message) diff --git a/server-rs/crates/api-server/src/custom_world_ai.rs b/server-rs/crates/api-server/src/custom_world_ai.rs index 5215488a..cb53bd71 100644 --- a/server-rs/crates/api-server/src/custom_world_ai.rs +++ b/server-rs/crates/api-server/src/custom_world_ai.rs @@ -1,7 +1,4 @@ -use std::{ - fs, - path::{Path, PathBuf}, -}; +use std::collections::BTreeMap; use axum::{ Json, @@ -9,9 +6,15 @@ use axum::{ http::StatusCode, response::Response, }; +use module_assets::{ + AssetObjectAccessPolicy, AssetObjectFieldError, build_asset_entity_binding_input, + build_asset_object_upsert_input, generate_asset_binding_id, generate_asset_object_id, +}; use platform_llm::{LlmMessage, LlmTextRequest}; +use platform_oss::{LegacyAssetPrefix, OssHeadObjectRequest, OssObjectAccess, OssPutObjectRequest}; use serde::{Deserialize, Serialize}; use serde_json::{Map, Value, json}; +use spacetime_client::SpacetimeClientError; use crate::{ api_response::json_success_body, auth::AuthenticatedAccessToken, http_error::AppError, @@ -87,6 +90,20 @@ struct GeneratedAssetResponse { actual_prompt: Option, } +struct PreparedAssetUpload { + prefix: LegacyAssetPrefix, + path_segments: Vec, + file_name: String, + content_type: String, + body: Vec, + asset_kind: &'static str, + entity_kind: &'static str, + entity_id: String, + profile_id: Option, + slot: &'static str, + source_job_id: Option, +} + pub async fn generate_custom_world_entity( State(state): State, Extension(request_context): Extension, @@ -160,8 +177,9 @@ pub async fn generate_custom_world_scene_npc( } pub async fn generate_custom_world_scene_image( + State(state): State, Extension(request_context): Extension, - Extension(_authenticated): Extension, + Extension(authenticated): Extension, payload: Result, JsonRejection>, ) -> Result, Response> { let Json(payload) = payload.map_err(|error| { @@ -174,30 +192,76 @@ pub async fn generate_custom_world_scene_image( ) })?; - let asset = save_placeholder_asset( - "generated-custom-world-scenes", - payload - .profile_id + let owner_user_id = authenticated.claims().user_id().to_string(); + let profile_id = trim_to_option(payload.profile_id.as_deref()); + let world_name = + trim_to_option(payload.world_name.as_deref()).unwrap_or_else(|| "world".to_string()); + let landmark_id = trim_to_option(payload.landmark_id.as_deref()); + let landmark_name = + trim_to_option(payload.landmark_name.as_deref()).unwrap_or_else(|| "scene".to_string()); + let entity_id = landmark_id.clone().unwrap_or_else(|| landmark_name.clone()); + let size = payload + .size + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .unwrap_or("1280*720") + .to_string(); + let prompt = trim_to_option(payload.prompt.as_deref()); + let asset_id = format!("custom-scene-{}", current_utc_millis()); + let svg = build_placeholder_svg( + &size, + prompt .as_deref() - .or(payload.world_name.as_deref()) - .unwrap_or("world"), - payload - .landmark_id - .as_deref() - .or(payload.landmark_name.as_deref()) + .or(Some(landmark_name.as_str())) .unwrap_or("scene"), - "scene", - payload.size.as_deref().unwrap_or("1280*720"), - payload.prompt.as_deref(), ) + .into_bytes(); + let upload = PreparedAssetUpload { + prefix: LegacyAssetPrefix::CustomWorldScenes, + path_segments: vec![ + sanitize_storage_segment( + profile_id.as_deref().unwrap_or(world_name.as_str()), + "world", + ), + sanitize_storage_segment(entity_id.as_str(), "scene"), + asset_id.clone(), + ], + file_name: "scene.svg".to_string(), + content_type: "image/svg+xml".to_string(), + body: svg, + asset_kind: "scene_image", + entity_kind: "custom_world_landmark", + entity_id, + profile_id, + slot: "scene_image", + source_job_id: Some(asset_id.clone()), + }; + let asset = persist_custom_world_asset( + &state, + &owner_user_id, + upload, + GeneratedAssetResponse { + image_src: String::new(), + asset_id: asset_id.clone(), + source_type: "generated".to_string(), + model: Some("rust-oss-placeholder".to_string()), + size: Some(size), + task_id: Some(asset_id), + prompt: prompt.clone(), + actual_prompt: prompt, + }, + ) + .await .map_err(|error| custom_world_ai_error_response(&request_context, error))?; Ok(json_success_body(Some(&request_context), asset)) } pub async fn generate_custom_world_cover_image( + State(state): State, Extension(request_context): Extension, - Extension(_authenticated): Extension, + Extension(authenticated): Extension, payload: Result, JsonRejection>, ) -> Result, Response> { let Json(payload) = payload.map_err(|error| { @@ -210,24 +274,69 @@ pub async fn generate_custom_world_cover_image( ) })?; + let owner_user_id = authenticated.claims().user_id().to_string(); let profile = payload.profile.as_object().cloned().unwrap_or_default(); + let profile_id = read_string_field(&profile, "id"); let world_name = read_string_field(&profile, "name").unwrap_or_else(|| "world".to_string()); - let asset = save_placeholder_asset( - "generated-custom-world-covers", - &read_string_field(&profile, "id").unwrap_or_else(|| world_name.clone()), - "cover", - "cover", - payload.size.as_deref().unwrap_or("1600*900"), - payload.user_prompt.as_deref(), + let entity_id = profile_id.clone().unwrap_or_else(|| world_name.clone()); + let size = payload + .size + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .unwrap_or("1600*900") + .to_string(); + let prompt = trim_to_option(payload.user_prompt.as_deref()); + let asset_id = format!("custom-cover-{}", current_utc_millis()); + let svg = build_placeholder_svg( + &size, + prompt + .as_deref() + .or(Some(world_name.as_str())) + .unwrap_or("cover"), ) + .into_bytes(); + let upload = PreparedAssetUpload { + prefix: LegacyAssetPrefix::CustomWorldCovers, + path_segments: vec![ + sanitize_storage_segment(entity_id.as_str(), "world"), + asset_id.clone(), + ], + file_name: "cover.svg".to_string(), + content_type: "image/svg+xml".to_string(), + body: svg, + asset_kind: "custom_world_cover", + entity_kind: "custom_world_profile", + entity_id, + profile_id, + slot: "cover", + source_job_id: Some(asset_id.clone()), + }; + let asset = persist_custom_world_asset( + &state, + &owner_user_id, + upload, + GeneratedAssetResponse { + image_src: String::new(), + asset_id: asset_id.clone(), + source_type: "generated".to_string(), + model: Some("rust-oss-placeholder".to_string()), + size: Some(size), + task_id: Some(asset_id), + prompt: prompt.clone(), + actual_prompt: prompt, + }, + ) + .await .map_err(|error| custom_world_ai_error_response(&request_context, error))?; Ok(json_success_body(Some(&request_context), asset)) } pub async fn upload_custom_world_cover_image( + State(state): State, Extension(request_context): Extension, - Extension(_authenticated): Extension, + Extension(authenticated): Extension, payload: Result, JsonRejection>, ) -> Result, Response> { let Json(payload) = payload.map_err(|error| { @@ -249,41 +358,41 @@ pub async fn upload_custom_world_cover_image( })), ) })?; + let owner_user_id = authenticated.claims().user_id().to_string(); + let profile_id = trim_to_option(payload.profile_id.as_deref()); + let world_name = + trim_to_option(payload.world_name.as_deref()).unwrap_or_else(|| "world".to_string()); + let entity_id = profile_id.clone().unwrap_or_else(|| world_name.clone()); let asset_id = format!("custom-cover-upload-{}", current_utc_millis()); - let world_segment = sanitize_path_segment( - payload - .profile_id - .as_deref() - .or(payload.world_name.as_deref()) - .unwrap_or("world"), - "world", - ); - let relative_dir = PathBuf::from("generated-custom-world-covers") - .join(world_segment) - .join(&asset_id); - let output_dir = resolve_public_output_dir(&relative_dir) - .map_err(|error| custom_world_ai_error_response(&request_context, error))?; - fs::create_dir_all(&output_dir) - .map_err(io_error) - .map_err(|error| custom_world_ai_error_response(&request_context, error))?; let file_name = match parsed.mime_type.as_str() { "image/png" => "cover.png", "image/webp" => "cover.webp", + "image/svg+xml" => "cover.svg", _ => "cover.jpg", + } + .to_string(); + let upload = PreparedAssetUpload { + prefix: LegacyAssetPrefix::CustomWorldCovers, + path_segments: vec![ + sanitize_storage_segment(entity_id.as_str(), "world"), + asset_id.clone(), + ], + file_name, + content_type: parsed.mime_type, + body: parsed.bytes, + asset_kind: "custom_world_cover", + entity_kind: "custom_world_profile", + entity_id, + profile_id, + slot: "cover", + source_job_id: Some(asset_id.clone()), }; - fs::write(output_dir.join(file_name), parsed.bytes) - .map_err(io_error) - .map_err(|error| custom_world_ai_error_response(&request_context, error))?; - let image_src = format!( - "/{}/{}", - relative_dir.to_string_lossy().replace('\\', "/"), - file_name - ); - - Ok(json_success_body( - Some(&request_context), + let asset = persist_custom_world_asset( + &state, + &owner_user_id, + upload, GeneratedAssetResponse { - image_src, + image_src: String::new(), asset_id, source_type: "uploaded".to_string(), model: None, @@ -292,14 +401,162 @@ pub async fn upload_custom_world_cover_image( prompt: None, actual_prompt: None, }, - )) + ) + .await + .map_err(|error| custom_world_ai_error_response(&request_context, error))?; + + Ok(json_success_body(Some(&request_context), asset)) } -async fn generate_entity_with_fallback( +async fn persist_custom_world_asset( state: &AppState, - profile: &Value, - kind: &str, -) -> Value { + owner_user_id: &str, + upload: PreparedAssetUpload, + mut response: GeneratedAssetResponse, +) -> Result { + let oss_client = state.oss_client().ok_or_else(|| { + AppError::from_status(StatusCode::SERVICE_UNAVAILABLE).with_details(json!({ + "provider": "aliyun-oss", + "reason": "OSS 未完成环境变量配置", + })) + })?; + let http_client = reqwest::Client::new(); + let put_result = oss_client + .put_object( + &http_client, + OssPutObjectRequest { + prefix: upload.prefix, + path_segments: upload.path_segments, + file_name: upload.file_name, + content_type: Some(upload.content_type.clone()), + access: OssObjectAccess::Private, + metadata: build_asset_metadata( + upload.asset_kind, + owner_user_id, + upload.profile_id.as_deref(), + upload.entity_kind, + upload.entity_id.as_str(), + upload.slot, + ), + body: upload.body, + }, + ) + .await + .map_err(map_custom_world_asset_oss_error)?; + // custom world 图片链正式改为 OSS 真值确认,不再把 put_object 返回值直接当成唯一对象真相。 + let head = oss_client + .head_object( + &http_client, + OssHeadObjectRequest { + object_key: put_result.object_key.clone(), + }, + ) + .await + .map_err(map_custom_world_asset_oss_error)?; + let now_micros = current_utc_micros(); + let asset_object = state + .spacetime_client() + .confirm_asset_object( + build_asset_object_upsert_input( + generate_asset_object_id(now_micros), + head.bucket, + head.object_key, + AssetObjectAccessPolicy::Private, + head.content_type.or(Some(upload.content_type)), + head.content_length, + head.etag, + upload.asset_kind.to_string(), + upload.source_job_id, + Some(owner_user_id.to_string()), + upload.profile_id.clone(), + Some(upload.entity_id.clone()), + now_micros, + ) + .map_err(map_asset_object_prepare_error)?, + ) + .await + .map_err(map_custom_world_asset_spacetime_error)?; + state + .spacetime_client() + .bind_asset_object_to_entity( + build_asset_entity_binding_input( + generate_asset_binding_id(now_micros), + asset_object.asset_object_id, + upload.entity_kind.to_string(), + upload.entity_id, + upload.slot.to_string(), + upload.asset_kind.to_string(), + Some(owner_user_id.to_string()), + upload.profile_id, + now_micros, + ) + .map_err(map_asset_binding_prepare_error)?, + ) + .await + .map_err(map_custom_world_asset_spacetime_error)?; + response.image_src = put_result.legacy_public_path; + Ok(response) +} + +fn build_asset_metadata( + asset_kind: &str, + owner_user_id: &str, + profile_id: Option<&str>, + entity_kind: &str, + entity_id: &str, + slot: &str, +) -> BTreeMap { + let mut metadata = BTreeMap::from([ + ("asset_kind".to_string(), asset_kind.to_string()), + ("owner_user_id".to_string(), owner_user_id.to_string()), + ("entity_kind".to_string(), entity_kind.to_string()), + ("entity_id".to_string(), entity_id.to_string()), + ("slot".to_string(), slot.to_string()), + ]); + if let Some(profile_id) = profile_id { + metadata.insert("profile_id".to_string(), profile_id.to_string()); + } + metadata +} + +fn map_asset_object_prepare_error(error: AssetObjectFieldError) -> AppError { + AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ + "provider": "asset-object", + "message": error.to_string(), + })) +} + +fn map_asset_binding_prepare_error(error: AssetObjectFieldError) -> AppError { + AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ + "provider": "asset-entity-binding", + "message": error.to_string(), + })) +} + +fn map_custom_world_asset_spacetime_error(error: SpacetimeClientError) -> AppError { + AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ + "provider": "spacetimedb", + "message": error.to_string(), + })) +} + +fn map_custom_world_asset_oss_error(error: platform_oss::OssError) -> AppError { + let status = match error { + platform_oss::OssError::InvalidConfig(_) | platform_oss::OssError::InvalidRequest(_) => { + StatusCode::BAD_REQUEST + } + platform_oss::OssError::ObjectNotFound(_) => StatusCode::NOT_FOUND, + platform_oss::OssError::Request(_) + | platform_oss::OssError::SerializePolicy(_) + | platform_oss::OssError::Sign(_) => StatusCode::BAD_GATEWAY, + }; + AppError::from_status(status).with_details(json!({ + "provider": "aliyun-oss", + "message": error.to_string(), + })) +} + +async fn generate_entity_with_fallback(state: &AppState, profile: &Value, kind: &str) -> Value { let fallback = build_entity_fallback(profile, kind); let Some(llm_client) = state.llm_client() else { return fallback; @@ -447,37 +704,6 @@ fn build_landmark_fallback(world_name: &str) -> Value { }) } -fn save_placeholder_asset( - root_segment: &str, - world_segment_seed: &str, - leaf_segment_seed: &str, - file_stem: &str, - size: &str, - prompt: Option<&str>, -) -> Result { - let asset_id = format!("{file_stem}-{}", current_utc_millis()); - let relative_dir = PathBuf::from(root_segment) - .join(sanitize_path_segment(world_segment_seed, "world")) - .join(sanitize_path_segment(leaf_segment_seed, file_stem)) - .join(&asset_id); - let output_dir = resolve_public_output_dir(&relative_dir)?; - fs::create_dir_all(&output_dir).map_err(io_error)?; - let file_name = format!("{file_stem}.svg"); - let svg = build_placeholder_svg(size, prompt.unwrap_or(file_stem)); - fs::write(output_dir.join(&file_name), svg).map_err(io_error)?; - - Ok(GeneratedAssetResponse { - image_src: format!("/{}/{}", relative_dir.to_string_lossy().replace('\\', "/"), file_name), - asset_id: asset_id.clone(), - source_type: "generated".to_string(), - model: Some("rust-placeholder".to_string()), - size: Some(size.to_string()), - task_id: Some(asset_id), - prompt: prompt.map(ToOwned::to_owned), - actual_prompt: prompt.map(ToOwned::to_owned), - }) -} - fn build_placeholder_svg(size: &str, label: &str) -> String { let (width, height) = parse_size(size); format!( @@ -493,7 +719,7 @@ fn build_placeholder_svg(size: &str, label: &str) -> String { {title} -Rust fallback asset +Rust OSS placeholder "##, width = width, height = height, @@ -531,36 +757,41 @@ fn escape_svg_text(value: &str) -> String { .replace('>', ">") } -fn sanitize_path_segment(value: &str, fallback: &str) -> String { - let sanitized = value +fn sanitize_storage_segment(value: &str, fallback: &str) -> String { + let normalized = value .trim() .chars() - .map(|ch| { - if ch.is_ascii_alphanumeric() || ('\u{4e00}'..='\u{9fff}').contains(&ch) { - ch - } else { - '-' - } + .map(|character| match character { + 'a'..='z' | '0'..='9' | '-' | '_' => character, + 'A'..='Z' => character.to_ascii_lowercase(), + _ => '-', }) - .collect::() - .trim_matches('-') - .to_string(); - if sanitized.is_empty() { + .collect::(); + let normalized = collapse_dashes(&normalized); + if normalized.is_empty() { fallback.to_string() } else { - sanitized + normalized } } -fn resolve_public_output_dir(relative_dir: &Path) -> Result { - let workspace_root = Path::new(env!("CARGO_MANIFEST_DIR")) - .ancestors() - .nth(3) - .ok_or_else(|| { - AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR) - .with_message("无法解析仓库根目录") - })?; - Ok(workspace_root.join("public").join(relative_dir)) +fn collapse_dashes(value: &str) -> String { + value + .chars() + .fold( + (String::new(), false), + |(mut output, last_is_dash), character| { + let is_dash = character == '-'; + if is_dash && last_is_dash { + return (output, true); + } + output.push(character); + (output, is_dash) + }, + ) + .0 + .trim_matches('-') + .to_string() } fn parse_image_data_url(value: &str) -> Option { @@ -568,6 +799,9 @@ fn parse_image_data_url(value: &str) -> Option { let separator = ";base64,"; let body = value.strip_prefix(prefix)?; let (mime_type, data) = body.split_once(separator)?; + if !mime_type.starts_with("image/") { + return None; + } let bytes = decode_base64(data)?; Some(ParsedImageDataUrl { mime_type: mime_type.to_string(), @@ -611,6 +845,13 @@ fn read_string_field(object: &Map, key: &str) -> Option { .map(ToOwned::to_owned) } +fn trim_to_option(value: Option<&str>) -> Option { + value + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(ToOwned::to_owned) +} + fn current_utc_millis() -> i64 { use std::time::{SystemTime, UNIX_EPOCH}; let duration = SystemTime::now() @@ -619,11 +860,12 @@ fn current_utc_millis() -> i64 { i64::try_from(duration.as_millis()).expect("current unix millis should fit in i64") } -fn io_error(error: std::io::Error) -> AppError { - AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_details(json!({ - "provider": "custom-world-ai", - "message": format!("文件写入失败:{error}"), - })) +fn current_utc_micros() -> i64 { + use std::time::{SystemTime, UNIX_EPOCH}; + let duration = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("system time should be after unix epoch"); + i64::try_from(duration.as_micros()).expect("current unix micros should fit in i64") } fn custom_world_ai_error_response(request_context: &RequestContext, error: AppError) -> Response { @@ -634,3 +876,159 @@ struct ParsedImageDataUrl { mime_type: String, bytes: Vec, } + +#[cfg(test)] +mod tests { + use super::*; + use crate::config::AppConfig; + use axum::response::Response; + use platform_auth::{AccessTokenClaims, AccessTokenClaimsInput, AuthProvider, BindingStatus}; + use serde_json::Value; + use time::OffsetDateTime; + + fn build_authenticated(state: &AppState) -> AuthenticatedAccessToken { + let claims = AccessTokenClaims::from_input( + AccessTokenClaimsInput { + user_id: "user_custom_world_ai".to_string(), + session_id: "sess_custom_world_ai".to_string(), + provider: AuthProvider::Password, + roles: vec!["user".to_string()], + token_version: 1, + phone_verified: false, + binding_status: BindingStatus::Active, + display_name: Some("测试旅人".to_string()), + }, + state.auth_jwt_config(), + OffsetDateTime::now_utc(), + ) + .expect("claims should build"); + + AuthenticatedAccessToken::new(claims) + } + + fn build_request_context(operation: &str) -> RequestContext { + RequestContext::new( + "req-custom-world-ai-test".to_string(), + operation.to_string(), + std::time::Duration::ZERO, + true, + ) + } + + async fn read_error_response(response: Response) -> Value { + use http_body_util::BodyExt as _; + + let body = response + .into_body() + .collect() + .await + .expect("body should collect") + .to_bytes(); + serde_json::from_slice(&body).expect("body should be valid json") + } + + #[tokio::test] + async fn scene_image_returns_service_unavailable_when_oss_missing() { + let state = AppState::new(AppConfig::default()).expect("state should build"); + let request_context = build_request_context("POST /api/custom-world/scene-image"); + let authenticated = build_authenticated(&state); + + let response = generate_custom_world_scene_image( + State(state), + Extension(request_context), + Extension(authenticated), + Ok(Json(CustomWorldSceneImageRequest { + profile_id: Some("profile_001".to_string()), + world_name: Some("世界".to_string()), + landmark_id: Some("landmark_001".to_string()), + landmark_name: Some("遗迹".to_string()), + prompt: Some("测试场景".to_string()), + size: Some("1280*720".to_string()), + })), + ) + .await + .expect_err("missing oss should fail"); + + let payload = read_error_response(response).await; + assert_eq!( + payload["error"]["code"], + Value::String("SERVICE_UNAVAILABLE".to_string()) + ); + assert_eq!( + payload["error"]["details"]["provider"], + Value::String("aliyun-oss".to_string()) + ); + } + + #[tokio::test] + async fn cover_image_returns_service_unavailable_when_oss_missing() { + let state = AppState::new(AppConfig::default()).expect("state should build"); + let request_context = build_request_context("POST /api/custom-world/cover-image"); + let authenticated = build_authenticated(&state); + + let response = generate_custom_world_cover_image( + State(state), + Extension(request_context), + Extension(authenticated), + Ok(Json(CustomWorldCoverImageRequest { + profile: json!({ + "id": "profile_001", + "name": "测试世界" + }), + user_prompt: Some("测试封面".to_string()), + size: Some("1600*900".to_string()), + })), + ) + .await + .expect_err("missing oss should fail"); + + let payload = read_error_response(response).await; + assert_eq!( + payload["error"]["code"], + Value::String("SERVICE_UNAVAILABLE".to_string()) + ); + assert_eq!( + payload["error"]["details"]["provider"], + Value::String("aliyun-oss".to_string()) + ); + } + + #[tokio::test] + async fn cover_upload_rejects_invalid_data_url_before_touching_oss() { + let state = AppState::new(AppConfig::default()).expect("state should build"); + let request_context = build_request_context("POST /api/custom-world/cover-upload"); + let authenticated = build_authenticated(&state); + + let response = upload_custom_world_cover_image( + State(state), + Extension(request_context), + Extension(authenticated), + Ok(Json(CustomWorldCoverUploadRequest { + profile_id: Some("profile_001".to_string()), + world_name: Some("测试世界".to_string()), + image_data_url: "not-a-data-url".to_string(), + })), + ) + .await + .expect_err("invalid data url should fail"); + + let payload = read_error_response(response).await; + assert_eq!( + payload["error"]["code"], + Value::String("BAD_REQUEST".to_string()) + ); + assert_eq!( + payload["error"]["details"]["provider"], + Value::String("custom-world-ai".to_string()) + ); + } + + #[test] + fn parse_image_data_url_accepts_image_payload() { + let parsed = + parse_image_data_url("data:image/png;base64,aGVsbG8=").expect("data url should parse"); + + assert_eq!(parsed.mime_type, "image/png"); + assert_eq!(parsed.bytes, b"hello".to_vec()); + } +} diff --git a/server-rs/crates/api-server/src/main.rs b/server-rs/crates/api-server/src/main.rs index f40f0446..5323963b 100644 --- a/server-rs/crates/api-server/src/main.rs +++ b/server-rs/crates/api-server/src/main.rs @@ -28,6 +28,7 @@ mod runtime_save; mod runtime_settings; mod runtime_story; mod session_client; +mod sse; mod state; mod story_battles; mod story_sessions; diff --git a/server-rs/crates/api-server/src/runtime_save.rs b/server-rs/crates/api-server/src/runtime_save.rs index 505aa4b8..fc0adf58 100644 --- a/server-rs/crates/api-server/src/runtime_save.rs +++ b/server-rs/crates/api-server/src/runtime_save.rs @@ -32,10 +32,11 @@ pub async fn get_runtime_snapshot( ) -> Result, Response> { let user_id = authenticated.claims().user_id().to_string(); let record = state - .spacetime_client() - .get_runtime_snapshot(user_id) + .get_runtime_snapshot_record(user_id) .await - .map_err(|error| runtime_save_error_response(&request_context, map_runtime_save_client_error(error)))?; + .map_err(|error| { + runtime_save_error_response(&request_context, map_runtime_save_client_error(error)) + })?; Ok(json_success_body( Some(&request_context), @@ -70,8 +71,7 @@ pub async fn put_runtime_snapshot( let saved_at_micros = offset_datetime_to_unix_micros(saved_at); let record = state - .spacetime_client() - .put_runtime_snapshot( + .put_runtime_snapshot_record( user_id, saved_at_micros, payload.bottom_tab, @@ -80,7 +80,9 @@ pub async fn put_runtime_snapshot( updated_at_micros, ) .await - .map_err(|error| runtime_save_error_response(&request_context, map_runtime_save_client_error(error)))?; + .map_err(|error| { + runtime_save_error_response(&request_context, map_runtime_save_client_error(error)) + })?; Ok(json_success_body( Some(&request_context), @@ -95,10 +97,11 @@ pub async fn delete_runtime_snapshot( ) -> Result, Response> { let user_id = authenticated.claims().user_id().to_string(); state - .spacetime_client() - .delete_runtime_snapshot(user_id) + .delete_runtime_snapshot_record(user_id) .await - .map_err(|error| runtime_save_error_response(&request_context, map_runtime_save_client_error(error)))?; + .map_err(|error| { + runtime_save_error_response(&request_context, map_runtime_save_client_error(error)) + })?; Ok(json_success_body( Some(&request_context), @@ -116,7 +119,9 @@ pub async fn list_profile_save_archives( .spacetime_client() .list_profile_save_archives(user_id) .await - .map_err(|error| runtime_save_error_response(&request_context, map_runtime_save_client_error(error)))?; + .map_err(|error| { + runtime_save_error_response(&request_context, map_runtime_save_client_error(error)) + })?; Ok(json_success_body( Some(&request_context), @@ -151,7 +156,12 @@ pub async fn resume_profile_save_archive( .spacetime_client() .resume_profile_save_archive(user_id, world_key) .await - .map_err(|error| runtime_save_error_response(&request_context, map_runtime_save_resume_client_error(error)))?; + .map_err(|error| { + runtime_save_error_response( + &request_context, + map_runtime_save_resume_client_error(error), + ) + })?; Ok(json_success_body( Some(&request_context), @@ -205,7 +215,8 @@ fn map_runtime_save_client_error(error: SpacetimeClientError) -> AppError { fn map_runtime_save_resume_client_error(error: SpacetimeClientError) -> AppError { let (status, provider) = match &error { SpacetimeClientError::Procedure(message) - if message.contains("world_key 不存在") || message.contains("对应 world_key 不存在") => + if message.contains("world_key 不存在") + || message.contains("对应 world_key 不存在") => { (StatusCode::NOT_FOUND, "runtime-save") } diff --git a/server-rs/crates/api-server/src/runtime_story.rs b/server-rs/crates/api-server/src/runtime_story.rs index ab48af08..f4372e8a 100644 --- a/server-rs/crates/api-server/src/runtime_story.rs +++ b/server-rs/crates/api-server/src/runtime_story.rs @@ -1,26 +1,61 @@ use axum::{ Json, - extract::{Extension, State}, + extract::{Extension, Path, State}, http::StatusCode, response::Response, }; -use serde_json::{Value, json}; +use module_runtime::RuntimeSnapshotRecord; +use platform_llm::{LlmMessage, LlmTextRequest}; +use serde_json::{Map, Value, json}; use shared_contracts::runtime_story::{ - RuntimeStoryActionResponse, RuntimeStoryCompanionViewModel, RuntimeStoryEncounterViewModel, - RuntimeStoryOptionInteraction, RuntimeStoryOptionView, RuntimeStoryPlayerViewModel, - RuntimeStoryPresentation, RuntimeStorySnapshotPayload, RuntimeStoryStateResolveRequest, - RuntimeStoryStatusViewModel, RuntimeStoryViewModel, + RuntimeBattlePresentation, RuntimeStoryActionRequest, RuntimeStoryActionResponse, + RuntimeStoryAiRequest, RuntimeStoryAiResponse, RuntimeStoryCompanionViewModel, + RuntimeStoryEncounterViewModel, RuntimeStoryOptionInteraction, RuntimeStoryOptionView, + RuntimeStoryPatch, RuntimeStoryPlayerViewModel, RuntimeStoryPresentation, + RuntimeStorySnapshotPayload, RuntimeStoryStateResolveRequest, RuntimeStoryStatusViewModel, + RuntimeStoryViewModel, }; +use shared_kernel::{format_rfc3339, offset_datetime_to_unix_micros, parse_rfc3339}; +use spacetime_client::SpacetimeClientError; +use time::OffsetDateTime; use crate::{ api_response::json_success_body, auth::AuthenticatedAccessToken, http_error::AppError, request_context::RequestContext, state::AppState, }; +const CONTINUE_ADVENTURE_FUNCTION_ID: &str = "story_continue_adventure"; +const MAX_TASK5_COMPANIONS: usize = 2; + +struct StoryResolution { + action_text: String, + result_text: String, + story_text: Option, + presentation_options: Option>, + saved_current_story: Option, + patches: Vec, + battle: Option, + toast: Option, +} + +struct CurrentEncounterNpcQuestContext { + npc_id: String, + npc_name: String, +} + +struct PendingQuestOfferContext { + dialogue: Vec, + turn_count: i32, + custom_input_placeholder: String, + quest: Value, + quest_id: String, + intro_text: Option, +} + pub async fn resolve_runtime_story_state( - State(_state): State, + State(state): State, Extension(request_context): Extension, - Extension(_authenticated): Extension, + Extension(authenticated): Extension, Json(payload): Json, ) -> Result, Response> { let session_id = normalize_required_string(payload.session_id.as_str()).ok_or_else(|| { @@ -33,24 +68,353 @@ pub async fn resolve_runtime_story_state( })), ) })?; - let snapshot = payload.snapshot.ok_or_else(|| { + let snapshot = resolve_snapshot_for_request( + &state, + &request_context, + authenticated.claims().user_id().to_string(), + payload.snapshot, + ) + .await?; + + validate_client_version( + &request_context, + payload.client_version, + &snapshot.game_state, + "运行时版本已变化,请先同步最新快照后再读取状态", + )?; + + Ok(json_success_body( + Some(&request_context), + build_runtime_story_state_response(&session_id, payload.client_version, snapshot), + )) +} + +pub async fn get_runtime_story_state( + State(state): State, + Path(session_id): Path, + Extension(request_context): Extension, + Extension(authenticated): Extension, +) -> Result, Response> { + let session_id = normalize_required_string(session_id.as_str()).ok_or_else(|| { runtime_story_error_response( &request_context, AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ "provider": "runtime-story", - "field": "snapshot", - "message": "当前首版兼容状态桥要求随请求提交 snapshot", + "field": "sessionId", + "message": "sessionId 不能为空", + })), + ) + })?; + let snapshot = resolve_snapshot_for_request( + &state, + &request_context, + authenticated.claims().user_id().to_string(), + None, + ) + .await?; + + Ok(json_success_body( + Some(&request_context), + build_runtime_story_state_response(&session_id, None, snapshot), + )) +} + +pub async fn resolve_runtime_story_action( + State(state): State, + Extension(request_context): Extension, + Extension(authenticated): Extension, + Json(payload): Json, +) -> Result, Response> { + let requested_session_id = + normalize_required_string(payload.session_id.as_str()).ok_or_else(|| { + runtime_story_error_response( + &request_context, + AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ + "provider": "runtime-story", + "field": "sessionId", + "message": "sessionId 不能为空", + })), + ) + })?; + let function_id = + normalize_required_string(payload.action.function_id.as_str()).ok_or_else(|| { + runtime_story_error_response( + &request_context, + AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ + "provider": "runtime-story", + "field": "action.functionId", + "message": "functionId 不能为空", + })), + ) + })?; + if payload.action.action_type.trim() != "story_choice" { + return Err(runtime_story_error_response( + &request_context, + AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ + "provider": "runtime-story", + "field": "action.type", + "message": "runtime story 当前只支持 story_choice 动作", + })), + )); + } + + let mut snapshot = resolve_snapshot_for_request( + &state, + &request_context, + authenticated.claims().user_id().to_string(), + payload.snapshot.clone(), + ) + .await?; + validate_client_version( + &request_context, + payload.client_version, + &snapshot.game_state, + "运行时版本已变化,请先同步最新快照后再提交动作", + )?; + + let current_story_before = snapshot.current_story.clone(); + let mut game_state = snapshot.game_state.clone(); + let mut resolution = resolve_runtime_story_choice_action( + &mut game_state, + current_story_before.as_ref(), + &payload, + &function_id, + ) + .map_err(|message| { + runtime_story_error_response( + &request_context, + AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ + "provider": "runtime-story", + "message": message, })), ) })?; + let server_version = read_u32_field(&game_state, "runtimeActionVersion") + .unwrap_or(0) + .saturating_add(1); + write_u32_field(&mut game_state, "runtimeActionVersion", server_version); + write_string_field( + &mut game_state, + "runtimeSessionId", + requested_session_id.as_str(), + ); + + let mut options = resolution + .presentation_options + .take() + .unwrap_or_else(|| build_fallback_runtime_story_options(&game_state)); + if options.is_empty() { + options = build_fallback_runtime_story_options(&game_state); + } + + let story_text = resolution + .story_text + .clone() + .unwrap_or_else(|| resolution.result_text.clone()); + let saved_current_story = resolution + .saved_current_story + .take() + .unwrap_or_else(|| build_legacy_current_story(story_text.as_str(), &options)); + append_story_history( + &mut game_state, + resolution.action_text.as_str(), + resolution.result_text.as_str(), + ); + + let mut patches = vec![RuntimeStoryPatch::StoryHistoryAppend { + action_text: resolution.action_text.clone(), + result_text: resolution.result_text.clone(), + }]; + patches.extend(resolution.patches); + + snapshot.saved_at = Some(format_now_rfc3339()); + snapshot.game_state = game_state; + snapshot.current_story = Some(saved_current_story); + let persisted = persist_runtime_story_snapshot( + &state, + &request_context, + authenticated.claims().user_id().to_string(), + snapshot, + ) + .await?; + let persisted_snapshot = runtime_snapshot_payload_from_record(&persisted); + Ok(json_success_body( Some(&request_context), - build_runtime_story_state_response( - &session_id, - payload.client_version, - snapshot, - ), + build_runtime_story_action_response(RuntimeStoryActionResponseParts { + requested_session_id, + server_version, + snapshot: persisted_snapshot, + action_text: resolution.action_text, + result_text: resolution.result_text, + story_text, + options, + patches, + toast: resolution.toast, + battle: resolution.battle, + }), + )) +} + +pub async fn generate_runtime_story_initial( + State(state): State, + Extension(request_context): Extension, + Extension(_authenticated): Extension, + Json(payload): Json, +) -> Result, Response> { + Ok(json_success_body( + Some(&request_context), + build_runtime_story_ai_response(&state, payload, true).await, + )) +} + +pub async fn generate_runtime_story_continue( + State(state): State, + Extension(request_context): Extension, + Extension(_authenticated): Extension, + Json(payload): Json, +) -> Result, Response> { + Ok(json_success_body( + Some(&request_context), + build_runtime_story_ai_response(&state, payload, false).await, + )) +} + +async fn resolve_snapshot_for_request( + state: &AppState, + request_context: &RequestContext, + user_id: String, + snapshot: Option, +) -> Result { + if let Some(snapshot) = snapshot { + let record = + persist_runtime_story_snapshot(state, request_context, user_id, snapshot).await?; + return Ok(runtime_snapshot_payload_from_record(&record)); + } + + let record = state + .get_runtime_snapshot_record(user_id) + .await + .map_err(|error| { + runtime_story_error_response(request_context, map_runtime_story_client_error(error)) + })? + .ok_or_else(|| { + runtime_story_error_response( + request_context, + AppError::from_status(StatusCode::CONFLICT).with_details(json!({ + "provider": "runtime-story", + "message": "运行时快照不存在,请先初始化并保存一次游戏", + })), + ) + })?; + + Ok(runtime_snapshot_payload_from_record(&record)) +} + +async fn persist_runtime_story_snapshot( + state: &AppState, + request_context: &RequestContext, + user_id: String, + snapshot: RuntimeStorySnapshotPayload, +) -> Result { + validate_snapshot_payload(&snapshot).map_err(|message| { + runtime_story_error_response( + request_context, + AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ + "provider": "runtime-story", + "message": message, + })), + ) + })?; + + let now = OffsetDateTime::now_utc(); + let saved_at = snapshot + .saved_at + .as_deref() + .and_then(|value| normalize_required_string(value)) + .map(|value| parse_rfc3339(value.as_str())) + .transpose() + .map_err(|error| { + runtime_story_error_response( + request_context, + AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ + "provider": "runtime-story", + "field": "snapshot.savedAt", + "message": format!("savedAt 非法: {error}"), + })), + ) + })? + .unwrap_or(now); + + state + .put_runtime_snapshot_record( + user_id, + offset_datetime_to_unix_micros(saved_at), + snapshot.bottom_tab, + snapshot.game_state, + snapshot.current_story, + offset_datetime_to_unix_micros(now), + ) + .await + .map_err(|error| { + runtime_story_error_response(request_context, map_runtime_story_client_error(error)) + }) +} + +fn validate_snapshot_payload(snapshot: &RuntimeStorySnapshotPayload) -> Result<(), String> { + if normalize_required_string(snapshot.bottom_tab.as_str()).is_none() { + return Err("snapshot.bottomTab 不能为空".to_string()); + } + if !snapshot.game_state.is_object() { + return Err("snapshot.gameState 必须是 JSON object".to_string()); + } + if snapshot + .current_story + .as_ref() + .is_some_and(|current_story| !current_story.is_object()) + { + return Err("snapshot.currentStory 必须是 JSON object 或 null".to_string()); + } + + Ok(()) +} + +fn runtime_snapshot_payload_from_record( + record: &RuntimeSnapshotRecord, +) -> RuntimeStorySnapshotPayload { + RuntimeStorySnapshotPayload { + saved_at: Some(record.saved_at.clone()), + bottom_tab: record.bottom_tab.clone(), + game_state: record.game_state.clone(), + current_story: record.current_story.clone(), + } +} + +fn validate_client_version( + request_context: &RequestContext, + client_version: Option, + game_state: &Value, + message: &str, +) -> Result<(), Response> { + let Some(client_version) = client_version else { + return Ok(()); + }; + let Some(server_version) = read_u32_field(game_state, "runtimeActionVersion") else { + return Ok(()); + }; + if client_version == server_version { + return Ok(()); + } + + Err(runtime_story_error_response( + request_context, + AppError::from_status(StatusCode::CONFLICT).with_details(json!({ + "provider": "runtime-story", + "message": message, + "clientVersion": client_version, + "serverVersion": server_version, + })), )) } @@ -61,52 +425,982 @@ fn build_runtime_story_state_response( ) -> RuntimeStoryActionResponse { let session_id = read_runtime_session_id(&snapshot.game_state) .unwrap_or_else(|| requested_session_id.to_string()); - let options = build_runtime_story_options(snapshot.current_story.as_ref(), &snapshot.game_state); - let story_text = - read_story_text(snapshot.current_story.as_ref()).unwrap_or_else(|| build_fallback_story_text(&snapshot.game_state)); - let server_version = - read_u32_field(&snapshot.game_state, "runtimeActionVersion").or(client_version).unwrap_or(0); + let options = + build_runtime_story_options(snapshot.current_story.as_ref(), &snapshot.game_state); + let story_text = read_story_text(snapshot.current_story.as_ref()) + .unwrap_or_else(|| build_fallback_story_text(&snapshot.game_state)); + let server_version = read_u32_field(&snapshot.game_state, "runtimeActionVersion") + .or(client_version) + .unwrap_or(0); + + build_runtime_story_action_response(RuntimeStoryActionResponseParts { + requested_session_id: session_id, + server_version, + snapshot, + action_text: String::new(), + result_text: String::new(), + story_text, + options, + patches: Vec::new(), + toast: None, + battle: None, + }) +} + +struct RuntimeStoryActionResponseParts { + requested_session_id: String, + server_version: u32, + snapshot: RuntimeStorySnapshotPayload, + action_text: String, + result_text: String, + story_text: String, + options: Vec, + patches: Vec, + toast: Option, + battle: Option, +} + +fn build_runtime_story_action_response( + parts: RuntimeStoryActionResponseParts, +) -> RuntimeStoryActionResponse { + let session_id = read_runtime_session_id(&parts.snapshot.game_state) + .unwrap_or_else(|| parts.requested_session_id); RuntimeStoryActionResponse { session_id, - server_version, - view_model: RuntimeStoryViewModel { - player: RuntimeStoryPlayerViewModel { - hp: read_i32_field(&snapshot.game_state, "playerHp").unwrap_or(0), - max_hp: read_i32_field(&snapshot.game_state, "playerMaxHp").unwrap_or(1), - mana: read_i32_field(&snapshot.game_state, "playerMana").unwrap_or(0), - max_mana: read_i32_field(&snapshot.game_state, "playerMaxMana").unwrap_or(1), - }, - encounter: build_runtime_story_encounter(&snapshot.game_state), - companions: build_runtime_story_companions(&snapshot.game_state), - available_options: options.clone(), - status: RuntimeStoryStatusViewModel { - in_battle: read_bool_field(&snapshot.game_state, "inBattle").unwrap_or(false), - npc_interaction_active: read_bool_field(&snapshot.game_state, "npcInteractionActive") - .unwrap_or(false), - current_npc_battle_mode: read_optional_string_field( - &snapshot.game_state, - "currentNpcBattleMode", - ), - current_npc_battle_outcome: read_optional_string_field( - &snapshot.game_state, - "currentNpcBattleOutcome", - ), - }, - }, + server_version: parts.server_version, + view_model: build_runtime_story_view_model(&parts.snapshot.game_state, &parts.options), presentation: RuntimeStoryPresentation { - action_text: String::new(), - result_text: String::new(), - story_text, - options, - toast: None, - battle: None, + action_text: parts.action_text, + result_text: parts.result_text, + story_text: parts.story_text, + options: parts.options, + toast: parts.toast, + battle: parts.battle, }, - patches: Vec::new(), - snapshot, + patches: parts.patches, + snapshot: parts.snapshot, } } +fn build_runtime_story_view_model( + game_state: &Value, + options: &[RuntimeStoryOptionView], +) -> RuntimeStoryViewModel { + RuntimeStoryViewModel { + player: RuntimeStoryPlayerViewModel { + hp: read_i32_field(game_state, "playerHp").unwrap_or(0), + max_hp: read_i32_field(game_state, "playerMaxHp").unwrap_or(1), + mana: read_i32_field(game_state, "playerMana").unwrap_or(0), + max_mana: read_i32_field(game_state, "playerMaxMana").unwrap_or(1), + }, + encounter: build_runtime_story_encounter(game_state), + companions: build_runtime_story_companions(game_state), + available_options: options.to_vec(), + status: RuntimeStoryStatusViewModel { + in_battle: read_bool_field(game_state, "inBattle").unwrap_or(false), + npc_interaction_active: read_bool_field(game_state, "npcInteractionActive") + .unwrap_or(false), + current_npc_battle_mode: read_optional_string_field(game_state, "currentNpcBattleMode"), + current_npc_battle_outcome: read_optional_string_field( + game_state, + "currentNpcBattleOutcome", + ), + }, + } +} + +fn resolve_runtime_story_choice_action( + game_state: &mut Value, + current_story: Option<&Value>, + request: &RuntimeStoryActionRequest, + function_id: &str, +) -> Result { + match function_id { + CONTINUE_ADVENTURE_FUNCTION_ID => resolve_continue_adventure_action(current_story), + "story_opening_camp_dialogue" => resolve_npc_affinity_action( + game_state, + request, + "交换开场判断", + 2, + "你把眼前局势先讲清楚,对方终于愿意把第一轮判断说出口。", + ), + "camp_travel_home_scene" => { + clear_encounter_state(game_state); + 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_call_out" => Ok(simple_story_resolution( + game_state, + resolve_action_text("主动出声试探", request), + "你的喊话打破了当前静场,周围潜着的动静也更难继续藏住。", + )), + "idle_explore_forward" => Ok(simple_story_resolution( + game_state, + resolve_action_text("继续向前探索", request), + "你没有停在原地,而是继续向前压,把下一段遭遇主动推到自己面前。", + )), + "idle_observe_signs" => Ok(simple_story_resolution( + game_state, + resolve_action_text("观察周围迹象", request), + "你先压住动作,把风向、脚印和气味这些细节重新读了一遍。", + )), + "idle_rest_focus" => { + restore_player_resource(game_state, 8, 6); + Ok(simple_story_resolution( + game_state, + resolve_action_text("原地调息", request), + "你把呼吸慢下来重新稳住节奏,生命和灵力都回上来一点。", + )) + } + "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, + }) + } + "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), + "npc_chat_quest_offer_view" => { + resolve_pending_quest_offer_view_action(game_state, current_story, request) + } + "npc_chat_quest_offer_replace" => { + resolve_pending_quest_offer_replace_action(game_state, current_story, request) + } + "npc_chat_quest_offer_abandon" => { + resolve_pending_quest_offer_abandon_action(game_state, current_story, request) + } + "npc_quest_accept" => { + resolve_pending_quest_accept_action(game_state, current_story, request) + } + "npc_quest_turn_in" => resolve_pending_quest_turn_in_action(game_state, request), + "npc_leave" => { + let npc_name = current_encounter_name(game_state); + clear_encounter_state(game_state); + Ok(StoryResolution { + action_text: resolve_action_text("离开当前角色", request), + result_text: format!("你结束了与 {npc_name} 的这一轮接触,把注意力重新放回旅途。"), + 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, + }) + } + "npc_fight" | "npc_spar" => { + resolve_npc_battle_entry_action(game_state, request, function_id) + } + "npc_recruit" => resolve_npc_recruit_action(game_state, request), + "battle_attack_basic" + | "battle_use_skill" + | "battle_all_in_crush" + | "battle_escape_breakout" + | "battle_feint_step" + | "battle_finisher_window" + | "battle_guard_break" + | "battle_probe_pressure" + | "battle_recover_breath" => resolve_battle_action(game_state, request, function_id), + "treasure_secure" | "treasure_inspect" | "treasure_leave" => { + resolve_treasure_action(game_state, request, function_id) + } + _ => Err(format!("暂不支持的 runtime action:{function_id}")), + } +} + +fn resolve_continue_adventure_action( + current_story: Option<&Value>, +) -> Result { + let deferred_options = current_story + .map(|story| { + read_array_field(story, "deferredOptions") + .into_iter() + .filter_map(build_runtime_story_option_from_story_option) + .collect::>() + }) + .unwrap_or_default(); + let options = (!deferred_options.is_empty()).then_some(deferred_options); + + Ok(StoryResolution { + action_text: "继续推进冒险".to_string(), + result_text: "你没有把节奏停下来,而是顺着当前局势继续向前推进了这一段故事。".to_string(), + story_text: None, + presentation_options: options, + saved_current_story: None, + patches: Vec::new(), + battle: None, + toast: None, + }) +} + +fn resolve_npc_preview_action( + game_state: &mut Value, + request: &RuntimeStoryActionRequest, +) -> Result { + let npc_name = current_encounter_name(game_state); + write_bool_field(game_state, "npcInteractionActive", true); + + Ok(StoryResolution { + action_text: resolve_action_text("转向眼前角色", request), + result_text: format!("{npc_name} 注意到了你的靠近,正在等你先把话说出来。"), + story_text: None, + presentation_options: None, + saved_current_story: None, + patches: vec![build_status_patch(game_state)], + battle: None, + toast: None, + }) +} + +fn resolve_npc_affinity_action( + game_state: &mut Value, + request: &RuntimeStoryActionRequest, + default_action_text: &str, + affinity_delta: i32, + fallback_result_text: &str, +) -> Result { + write_bool_field(game_state, "npcInteractionActive", true); + let affinity_patch = adjust_current_npc_affinity(game_state, affinity_delta).map( + |(npc_id, previous_affinity, next_affinity)| RuntimeStoryPatch::NpcAffinityChanged { + npc_id, + previous_affinity, + next_affinity, + }, + ); + let mut patches = Vec::new(); + if let Some(patch) = affinity_patch { + patches.push(patch); + } + patches.push(build_status_patch(game_state)); + + Ok(StoryResolution { + action_text: resolve_action_text(default_action_text, request), + result_text: fallback_result_text.to_string(), + story_text: None, + presentation_options: None, + saved_current_story: None, + patches, + battle: None, + toast: None, + }) +} + +fn resolve_npc_chat_action( + game_state: &mut Value, + request: &RuntimeStoryActionRequest, +) -> Result { + let chatted_count = read_current_npc_state_i32_field(game_state, "chattedCount").unwrap_or(0); + let affinity_gain = (6 - chatted_count).max(2); + let result_text = format!( + "{} 愿意把话接下去,态度比刚才明显松动了一些。当前关系推进了 {} 点。", + current_encounter_name(game_state), + affinity_gain + ); + let mut resolution = resolve_npc_affinity_action( + game_state, + request, + "继续交谈", + affinity_gain, + result_text.as_str(), + )?; + write_current_npc_state_i32_field(game_state, "chattedCount", chatted_count.saturating_add(1)); + write_current_npc_state_bool_field(game_state, "firstMeaningfulContactResolved", true); + resolution.action_text = format!("继续和{}交谈", current_encounter_name(game_state)); + Ok(resolution) +} + +fn resolve_npc_help_action( + game_state: &mut Value, + request: &RuntimeStoryActionRequest, +) -> Result { + if read_current_npc_state_bool_field(game_state, "helpUsed").unwrap_or(false) { + return Err("当前 NPC 的一次性援手已经用完了".to_string()); + } + + restore_player_resource(game_state, 10, 8); + write_current_npc_state_bool_field(game_state, "helpUsed", true); + resolve_npc_affinity_action( + game_state, + request, + &format!("向{}请求援手", current_encounter_name(game_state)), + 4, + &format!( + "{} 给了你一次及时支援,你的状态暂时稳住了,关系也顺势拉近了一点。", + current_encounter_name(game_state) + ), + ) +} + +fn resolve_pending_quest_offer_view_action( + game_state: &mut Value, + current_story: Option<&Value>, + request: &RuntimeStoryActionRequest, +) -> Result { + let encounter = current_encounter_npc_quest_context(game_state)?; + let pending_offer = read_pending_quest_offer_context(current_story, encounter.npc_id.as_str()) + .ok_or_else(|| "当前没有待处理的委托可查看。".to_string())?; + Ok(StoryResolution { + action_text: resolve_action_text(&format!("查看{}提出的委托", encounter.npc_name), request), + result_text: pending_offer.intro_text.clone().unwrap_or_else(|| { + build_quest_offer_dialogue_text(encounter.npc_name.as_str(), &pending_offer.quest) + }), + story_text: None, + presentation_options: None, + saved_current_story: None, + patches: vec![], + battle: None, + toast: None, + }) +} + +fn resolve_pending_quest_offer_replace_action( + game_state: &mut Value, + current_story: Option<&Value>, + request: &RuntimeStoryActionRequest, +) -> Result { + let encounter = current_encounter_npc_quest_context(game_state)?; + let pending_offer = read_pending_quest_offer_context(current_story, encounter.npc_id.as_str()) + .ok_or_else(|| "当前没有待处理的委托可更换。".to_string())?; + let next_quest = build_next_pending_quest_offer( + game_state, + encounter.npc_id.as_str(), + encounter.npc_name.as_str(), + Some(pending_offer.quest_id.as_str()), + ); + let quest_text = build_quest_offer_dialogue_text(encounter.npc_name.as_str(), &next_quest); + let dialogue = append_dialogue_turns( + pending_offer.dialogue.as_slice(), + vec![ + json!({ + "speaker": "player", + "text": "能不能换一份更适合眼下局势的委托?" + }), + json!({ + "speaker": "npc", + "speakerName": encounter.npc_name, + "text": quest_text, + }), + ], + ); + let options = build_pending_quest_offer_options(encounter.npc_id.as_str()); + let saved_current_story = build_pending_quest_offer_story( + dialogue, + encounter.npc_id.as_str(), + encounter.npc_name.as_str(), + pending_offer.turn_count, + pending_offer.custom_input_placeholder.as_str(), + Some(next_quest.clone()), + options.as_slice(), + ); + + Ok(StoryResolution { + action_text: resolve_action_text(&format!("请{}更换委托", encounter.npc_name), request), + result_text: quest_text.clone(), + story_text: Some(quest_text), + presentation_options: Some(options), + saved_current_story: Some(saved_current_story), + patches: vec![], + battle: None, + toast: None, + }) +} + +fn resolve_pending_quest_offer_abandon_action( + game_state: &mut Value, + current_story: Option<&Value>, + request: &RuntimeStoryActionRequest, +) -> Result { + let encounter = current_encounter_npc_quest_context(game_state)?; + let pending_offer = read_pending_quest_offer_context(current_story, encounter.npc_id.as_str()) + .ok_or_else(|| "当前没有待处理的委托可放弃。".to_string())?; + let npc_reply = format!( + "{}点了点头,没有继续强求,只把这份委托暂时收了回去。", + encounter.npc_name + ); + let dialogue = append_dialogue_turns( + pending_offer.dialogue.as_slice(), + vec![ + json!({ + "speaker": "player", + "text": "这件事我先不接,咱们还是先聊别的。" + }), + json!({ + "speaker": "npc", + "speakerName": encounter.npc_name, + "text": npc_reply, + }), + ], + ); + let options = build_post_quest_offer_chat_options(encounter.npc_id.as_str()); + let saved_current_story = build_pending_quest_offer_story( + dialogue, + encounter.npc_id.as_str(), + encounter.npc_name.as_str(), + pending_offer.turn_count, + pending_offer.custom_input_placeholder.as_str(), + None, + options.as_slice(), + ); + + Ok(StoryResolution { + action_text: resolve_action_text(&format!("暂不接受{}的委托", encounter.npc_name), request), + result_text: npc_reply.clone(), + story_text: Some(npc_reply), + presentation_options: Some(options), + saved_current_story: Some(saved_current_story), + patches: vec![], + battle: None, + toast: None, + }) +} + +fn resolve_pending_quest_accept_action( + game_state: &mut Value, + current_story: Option<&Value>, + request: &RuntimeStoryActionRequest, +) -> Result { + let encounter = current_encounter_npc_quest_context(game_state)?; + let pending_offer = read_pending_quest_offer_context(current_story, encounter.npc_id.as_str()) + .ok_or_else(|| "当前没有待处理的委托可接下。".to_string())?; + if find_active_quest_for_issuer(game_state, encounter.npc_id.as_str()).is_some() { + return Err("当前角色已经有未结清的委托。".to_string()); + } + + let quest = pending_offer.quest.clone(); + push_quest_record(game_state, &quest); + increment_runtime_stat(game_state, "questsAccepted", 1); + write_current_npc_state_bool_field(game_state, "firstMeaningfulContactResolved", true); + + let reply_text = first_quest_reveal_text(&quest) + .map(|text| format!("那就拜托你了。{text}")) + .unwrap_or_else(|| { + format!( + "那就拜托你了。{}", + read_optional_string_field(&quest, "summary") + .unwrap_or_else(|| "这份委托的关键要点我已经交给你。".to_string()) + ) + }); + let dialogue = append_dialogue_turns( + pending_offer.dialogue.as_slice(), + vec![ + json!({ + "speaker": "player", + "text": "这件事我愿意接下,你把关键要点交给我。" + }), + json!({ + "speaker": "npc", + "speakerName": encounter.npc_name, + "text": reply_text, + }), + ], + ); + let options = build_post_quest_accept_chat_options(encounter.npc_id.as_str()); + let saved_current_story = build_pending_quest_offer_story( + dialogue, + encounter.npc_id.as_str(), + encounter.npc_name.as_str(), + pending_offer.turn_count, + pending_offer.custom_input_placeholder.as_str(), + None, + options.as_slice(), + ); + + Ok(StoryResolution { + action_text: resolve_action_text(&format!("接下{}的委托", encounter.npc_name), request), + result_text: build_quest_accept_result_text(&quest), + story_text: Some( + saved_current_story["text"] + .as_str() + .unwrap_or_default() + .to_string(), + ), + presentation_options: Some(options), + saved_current_story: Some(saved_current_story), + patches: vec![], + battle: None, + toast: None, + }) +} + +fn resolve_pending_quest_turn_in_action( + game_state: &mut Value, + request: &RuntimeStoryActionRequest, +) -> Result { + let encounter = current_encounter_npc_quest_context(game_state)?; + let quest_id = request + .action + .payload + .as_ref() + .and_then(|payload| read_optional_string_field(payload, "questId")) + .or_else(|| request.action.target_id.clone()) + .or_else(|| { + find_active_quest_for_issuer(game_state, encounter.npc_id.as_str()) + .and_then(|quest| read_optional_string_field(quest, "id")) + }) + .ok_or_else(|| "当前没有可交付的委托。".to_string())?; + let turned_in = turn_in_quest_record(game_state, encounter.npc_id.as_str(), quest_id.as_str())?; + let previous_affinity = read_current_npc_affinity(game_state); + let affinity_bonus = read_field(&turned_in, "reward") + .and_then(|reward| read_i32_field(reward, "affinityBonus")) + .unwrap_or(0); + let next_affinity = previous_affinity.saturating_add(affinity_bonus); + write_current_npc_state_i32_field(game_state, "affinity", next_affinity); + write_current_npc_state_bool_field(game_state, "firstMeaningfulContactResolved", true); + apply_quest_turn_in_rewards(game_state, &turned_in); + + Ok(StoryResolution { + action_text: resolve_action_text(&format!("向{}交付委托", encounter.npc_name), request), + result_text: build_quest_turn_in_result_text(&turned_in), + story_text: None, + presentation_options: None, + saved_current_story: None, + patches: vec![RuntimeStoryPatch::NpcAffinityChanged { + npc_id: encounter.npc_id, + previous_affinity, + next_affinity, + }], + battle: None, + toast: None, + }) +} + +fn resolve_npc_battle_entry_action( + game_state: &mut Value, + request: &RuntimeStoryActionRequest, + function_id: &str, +) -> Result { + let npc_id = current_encounter_id(game_state).unwrap_or_else(|| "npc_current".to_string()); + let npc_name = current_encounter_name(game_state); + let battle_mode = if function_id == "npc_spar" { + "spar" + } else { + "fight" + }; + 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"); + + Ok(StoryResolution { + action_text: resolve_action_text( + if battle_mode == "spar" { + "点到为止切磋" + } else { + "与对方战斗" + }, + request, + ), + result_text: format!( + "{npc_name} 已经进入{}节奏,下一步必须按战斗动作结算。", + battle_mode_text(battle_mode) + ), + story_text: None, + presentation_options: None, + saved_current_story: None, + patches: vec![build_status_patch(game_state)], + battle: Some(RuntimeBattlePresentation { + target_id: Some(npc_id), + target_name: Some(npc_name), + damage_dealt: None, + damage_taken: None, + outcome: Some("ongoing".to_string()), + }), + toast: None, + }) +} + +fn resolve_npc_recruit_action( + game_state: &mut Value, + request: &RuntimeStoryActionRequest, +) -> Result { + let npc_id = current_encounter_id(game_state).unwrap_or_else(|| "npc_current".to_string()); + let npc_name = current_encounter_name(game_state); + let current_affinity = read_current_npc_affinity(game_state); + if read_current_npc_state_bool_field(game_state, "recruited").unwrap_or(false) { + return Err("当前 NPC 已经处于已招募状态".to_string()); + } + if current_affinity < 60 { + return Err("当前关系还没达到招募阈值,暂时不能邀请入队".to_string()); + } + + let release_npc_id = request + .action + .payload + .as_ref() + .and_then(|payload| read_optional_string_field(payload, "releaseNpcId")); + let released_companion_name = recruit_companion_to_party( + game_state, + npc_id.as_str(), + npc_name.as_str(), + release_npc_id.as_deref(), + )?; + let affinity_patch = + set_current_npc_recruited(game_state, true).map(|(previous_affinity, next_affinity)| { + RuntimeStoryPatch::NpcAffinityChanged { + npc_id: npc_id.clone(), + previous_affinity, + next_affinity, + } + }); + write_current_npc_state_bool_field(game_state, "firstMeaningfulContactResolved", true); + write_bool_field(game_state, "npcInteractionActive", false); + clear_encounter_only(game_state); + write_null_field(game_state, "currentNpcBattleMode"); + write_null_field(game_state, "currentNpcBattleOutcome"); + write_bool_field(game_state, "inBattle", false); + + let mut patches = Vec::new(); + if let Some(patch) = affinity_patch { + patches.push(patch); + } + patches.push(build_status_patch(game_state)); + patches.push(RuntimeStoryPatch::EncounterChanged { encounter_id: None }); + + Ok(StoryResolution { + action_text: resolve_action_text(&format!("邀请{npc_name}加入队伍"), request), + result_text: match released_companion_name { + Some(released_name) => format!( + "{npc_name} 接受了你的邀请,你先让 {released_name} 暂时离队,把位置腾给了新的同行者。" + ), + None => format!("{npc_name} 接受了你的邀请,正式进入了同行队伍。"), + }, + story_text: None, + presentation_options: None, + saved_current_story: None, + patches, + battle: None, + toast: Some(format!("{npc_name} 已加入队伍")), + }) +} + +fn resolve_battle_action( + game_state: &mut Value, + request: &RuntimeStoryActionRequest, + function_id: &str, +) -> Result { + let target_id = current_encounter_id(game_state) + .or_else(|| first_hostile_npc_string_field(game_state, "id")) + .unwrap_or_else(|| "battle_target".to_string()); + let target_name = current_encounter_name_from_battle(game_state); + let battle_mode = read_optional_string_field(game_state, "currentNpcBattleMode") + .unwrap_or_else(|| "fight".to_string()); + + if function_id == "battle_escape_breakout" { + clear_encounter_state(game_state); + return Ok(StoryResolution { + action_text: resolve_action_text("强行脱离战斗", request), + result_text: "你抓住空隙强行脱离战斗,把这一轮危险先甩在身后。".to_string(), + story_text: None, + presentation_options: None, + saved_current_story: None, + patches: vec![ + RuntimeStoryPatch::BattleResolved { + function_id: function_id.to_string(), + target_id: Some(target_id.clone()), + damage_dealt: Some(0), + damage_taken: Some(0), + outcome: "escaped".to_string(), + }, + build_status_patch(game_state), + RuntimeStoryPatch::EncounterChanged { encounter_id: None }, + ], + battle: Some(RuntimeBattlePresentation { + target_id: Some(target_id), + target_name: Some(target_name), + damage_dealt: Some(0), + damage_taken: Some(0), + outcome: Some("escaped".to_string()), + }), + toast: Some("已脱离战斗".to_string()), + }); + } + + let (damage_dealt, damage_taken, heal, mana_restore, mana_cost, action_text, result_text) = + battle_action_numbers(function_id); + spend_player_mana(game_state, mana_cost); + restore_player_resource(game_state, heal, mana_restore); + apply_player_damage(game_state, damage_taken); + let target_hp = apply_target_damage(game_state, damage_dealt); + let outcome = if target_hp <= 0 { + if battle_mode == "spar" { + "spar_complete" + } else { + "victory" + } + } else { + "ongoing" + }; + + if outcome != "ongoing" { + write_bool_field(game_state, "inBattle", false); + write_bool_field(game_state, "npcInteractionActive", false); + write_null_field(game_state, "currentNpcBattleMode"); + write_string_field( + game_state, + "currentNpcBattleOutcome", + if outcome == "spar_complete" { + "spar_complete" + } else { + "fight_victory" + }, + ); + if outcome == "victory" { + clear_encounter_only(game_state); + } + } + + let mut patches = vec![ + RuntimeStoryPatch::BattleResolved { + function_id: function_id.to_string(), + target_id: Some(target_id.clone()), + damage_dealt: Some(damage_dealt), + damage_taken: Some(damage_taken), + outcome: outcome.to_string(), + }, + build_status_patch(game_state), + ]; + if outcome == "victory" { + patches.push(RuntimeStoryPatch::EncounterChanged { encounter_id: None }); + } + + Ok(StoryResolution { + action_text: resolve_action_text(action_text, request), + result_text: if outcome == "ongoing" { + result_text.to_string() + } else if outcome == "spar_complete" { + format!("{target_name} 收住了最后一击,这场切磋已经分出结果。") + } else { + format!("{target_name} 被你压制下去,眼前的战斗已经结束。") + }, + story_text: None, + presentation_options: None, + saved_current_story: None, + patches, + battle: Some(RuntimeBattlePresentation { + target_id: Some(target_id), + target_name: Some(target_name), + damage_dealt: Some(damage_dealt), + damage_taken: Some(damage_taken), + outcome: Some(outcome.to_string()), + }), + toast: None, + }) +} + +fn resolve_treasure_action( + game_state: &mut Value, + request: &RuntimeStoryActionRequest, + function_id: &str, +) -> Result { + match function_id { + "treasure_secure" => { + clear_encounter_state(game_state); + 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: Some("已收取宝箱".to_string()), + }) + } + "treasure_inspect" => Ok(simple_story_resolution( + game_state, + resolve_action_text("仔细检查", request), + "你没有急着伸手,而是绕着目标重新检查机关、痕迹和可能的埋伏。", + )), + "treasure_leave" => { + clear_encounter_state(game_state); + 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, + }) + } + _ => Err(format!("暂不支持的 treasure action:{function_id}")), + } +} + +fn simple_story_resolution( + game_state: &Value, + action_text: String, + result_text: &str, +) -> StoryResolution { + StoryResolution { + action_text, + result_text: result_text.to_string(), + story_text: None, + presentation_options: None, + saved_current_story: None, + patches: vec![build_status_patch(game_state)], + battle: None, + toast: None, + } +} + +async fn build_runtime_story_ai_response( + state: &AppState, + payload: RuntimeStoryAiRequest, + initial: bool, +) -> RuntimeStoryAiResponse { + let options = build_ai_response_options(&payload); + let fallback = build_ai_fallback_story_text(&payload, initial); + let story_text = generate_ai_story_text(state, &payload, initial) + .await + .filter(|text| !text.trim().is_empty()) + .unwrap_or(fallback); + + RuntimeStoryAiResponse { + story_text, + options, + encounter: None, + } +} + +async fn generate_ai_story_text( + state: &AppState, + payload: &RuntimeStoryAiRequest, + initial: bool, +) -> Option { + let llm_client = state.llm_client()?; + let system_prompt = if initial { + "你是游戏运行时剧情导演。请用中文输出一段可直接展示给玩家的开局剧情,不要输出 JSON。" + } else { + "你是游戏运行时剧情导演。请用中文根据玩家选择续写一段剧情,不要输出 JSON。" + }; + let user_prompt = json!({ + "worldType": payload.world_type, + "character": payload.character, + "monsters": payload.monsters, + "history": payload.history, + "choice": payload.choice, + "context": payload.context, + "availableOptions": payload.request_options.available_options, + }) + .to_string(); + let mut request = LlmTextRequest::new(vec![ + LlmMessage::system(system_prompt), + LlmMessage::user(user_prompt), + ]); + request.max_tokens = Some(700); + + llm_client + .request_text(request) + .await + .ok() + .map(|response| response.content.trim().to_string()) + .filter(|text| !text.is_empty()) +} + +fn build_ai_response_options(payload: &RuntimeStoryAiRequest) -> Vec { + let source = if payload.request_options.available_options.is_empty() { + &payload.request_options.option_catalog + } else { + &payload.request_options.available_options + }; + let options = source + .iter() + .filter_map(normalize_ai_story_option) + .collect::>(); + if !options.is_empty() { + return options; + } + + vec![ + build_ai_story_option_value("idle_observe_signs", "观察周围迹象"), + build_ai_story_option_value("idle_explore_forward", "继续向前探索"), + build_ai_story_option_value("idle_rest_focus", "原地调息"), + ] +} + +fn normalize_ai_story_option(value: &Value) -> Option { + let function_id = read_required_string_field(value, "functionId")?; + let action_text = read_required_string_field(value, "actionText") + .or_else(|| read_required_string_field(value, "text")) + .unwrap_or_else(|| function_id.clone()); + let mut option = value.as_object()?.clone(); + option.insert("functionId".to_string(), Value::String(function_id)); + option.insert("actionText".to_string(), Value::String(action_text.clone())); + option + .entry("text".to_string()) + .or_insert_with(|| Value::String(action_text)); + + Some(Value::Object(option)) +} + +fn build_ai_story_option_value(function_id: &str, action_text: &str) -> Value { + json!({ + "functionId": function_id, + "actionText": action_text, + "text": action_text, + "visuals": { + "playerAnimation": "idle", + "playerMoveMeters": 0, + "playerOffsetY": 0, + "playerFacing": "right", + "scrollWorld": false, + "monsterChanges": [] + } + }) +} + +fn build_ai_fallback_story_text(payload: &RuntimeStoryAiRequest, initial: bool) -> String { + let character_name = + read_optional_string_field(&payload.character, "name").unwrap_or_else(|| "你".to_string()); + let scene_name = read_optional_string_field(&payload.context, "sceneName") + .or_else(|| read_optional_string_field(&payload.context, "scene")) + .unwrap_or_else(|| "当前区域".to_string()); + if initial { + return format!( + "{character_name} 在 {scene_name} 稳住脚步,周围的气息正在变化,第一轮选择已经摆到眼前。" + ); + } + + let choice = normalize_required_string(payload.choice.as_str()) + .unwrap_or_else(|| "继续推进".to_string()); + format!("{character_name} 选择了「{choice}」,{scene_name} 的局势随之向下一步展开。") +} + fn build_runtime_story_companions(game_state: &Value) -> Vec { read_array_field(game_state, "companions") .into_iter() @@ -123,8 +1417,11 @@ fn build_runtime_story_companions(game_state: &Value) -> Vec Option { let encounter = read_object_field(game_state, "currentEncounter")?; - let npc_name = read_required_string_field(encounter, "npcName")?; - let encounter_id = read_required_string_field(encounter, "id").unwrap_or_else(|| npc_name.clone()); + let npc_name = read_required_string_field(encounter, "npcName") + .or_else(|| read_required_string_field(encounter, "name")) + .unwrap_or_else(|| "当前遭遇".to_string()); + let encounter_id = + read_required_string_field(encounter, "id").unwrap_or_else(|| npc_name.clone()); let npc_state = resolve_current_encounter_npc_state(game_state, &encounter_id, &npc_name); Some(RuntimeStoryEncounterViewModel { @@ -189,7 +1486,9 @@ fn build_runtime_story_option_from_story_option(value: &Value) -> Option Vec { let interaction_active = read_bool_field(game_state, "npcInteractionActive").unwrap_or(false); + let npc_id = read_required_string_field(encounter, "id") + .unwrap_or_else(|| "npc_current".to_string()); + if let Some(active_quest) = + find_active_quest_for_issuer(game_state, npc_id.as_str()) + { + if read_optional_string_field(active_quest, "status") + .is_some_and(|status| status == "completed") + { + return vec![ + build_npc_runtime_story_option_with_quest( + "npc_quest_turn_in", + &format!("向{}交付委托", current_encounter_name(game_state)), + &npc_id, + "quest_turn_in", + read_optional_string_field(active_quest, "id"), + ), + build_npc_runtime_story_option( + "npc_leave", + "离开当前角色", + &npc_id, + "leave", + ), + ]; + } + } if interaction_active { return vec![ - build_static_runtime_story_option("npc_chat", "继续交谈", "npc"), - build_static_runtime_story_option("npc_help", "请求援手", "npc"), - build_static_runtime_story_option("npc_spar", "点到为止切磋", "npc"), - build_static_runtime_story_option("npc_fight", "与对方战斗", "npc"), - build_static_runtime_story_option("npc_leave", "离开当前角色", "npc"), + build_npc_runtime_story_option("npc_chat", "继续交谈", &npc_id, "chat"), + build_npc_runtime_story_option("npc_help", "请求援手", &npc_id, "help"), + build_npc_runtime_story_option( + "npc_recruit", + "邀请同行", + &npc_id, + "recruit", + ), + build_npc_runtime_story_option("npc_spar", "点到为止切磋", &npc_id, "spar"), + build_npc_runtime_story_option("npc_fight", "与对方战斗", &npc_id, "fight"), + build_npc_runtime_story_option( + "npc_leave", + "离开当前角色", + &npc_id, + "leave", + ), ]; } return vec![ - build_static_runtime_story_option("npc_preview_talk", "转向眼前角色", "npc"), - build_static_runtime_story_option("npc_fight", "与对方战斗", "npc"), - build_static_runtime_story_option("npc_leave", "离开当前角色", "npc"), + build_npc_runtime_story_option( + "npc_preview_talk", + "转向眼前角色", + &npc_id, + "chat", + ), + build_npc_runtime_story_option("npc_fight", "与对方战斗", &npc_id, "fight"), + build_npc_runtime_story_option("npc_leave", "离开当前角色", &npc_id, "leave"), ]; } Some("treasure") => { return vec![ - build_static_runtime_story_option("treasure_secure", "直接收取", "story"), - build_static_runtime_story_option("treasure_inspect", "仔细检查", "story"), - build_static_runtime_story_option("treasure_leave", "先记下位置", "story"), + build_treasure_runtime_story_option("treasure_secure", "直接收取", "secure"), + build_treasure_runtime_story_option("treasure_inspect", "仔细检查", "inspect"), + build_treasure_runtime_story_option("treasure_leave", "先记下位置", "leave"), ]; } _ => {} @@ -263,7 +1603,7 @@ fn build_fallback_runtime_story_options(game_state: &Value) -> Vec RuntimeStoryOptionView { + RuntimeStoryOptionView { + interaction: Some(RuntimeStoryOptionInteraction::Npc { + npc_id: npc_id.to_string(), + action: action.to_string(), + quest_id: None, + }), + ..build_static_runtime_story_option(function_id, action_text, "npc") + } +} + +fn build_npc_runtime_story_option_with_payload( + function_id: &str, + action_text: &str, + npc_id: &str, + action: &str, + payload: Value, +) -> RuntimeStoryOptionView { + RuntimeStoryOptionView { + payload: Some(payload), + ..build_npc_runtime_story_option(function_id, action_text, npc_id, action) + } +} + +fn build_npc_runtime_story_option_with_quest( + function_id: &str, + action_text: &str, + npc_id: &str, + action: &str, + quest_id: Option, +) -> RuntimeStoryOptionView { + RuntimeStoryOptionView { + interaction: Some(RuntimeStoryOptionInteraction::Npc { + npc_id: npc_id.to_string(), + action: action.to_string(), + quest_id, + }), + ..build_static_runtime_story_option(function_id, action_text, "npc") + } +} + +fn build_treasure_runtime_story_option( + function_id: &str, + action_text: &str, + action: &str, +) -> RuntimeStoryOptionView { + RuntimeStoryOptionView { + interaction: Some(RuntimeStoryOptionInteraction::Treasure { + action: action.to_string(), + }), + ..build_static_runtime_story_option(function_id, action_text, "story") + } +} + +fn current_encounter_npc_quest_context( + game_state: &Value, +) -> Result { + let encounter = read_object_field(game_state, "currentEncounter") + .ok_or_else(|| "当前不在可结算的 NPC 委托态。".to_string())?; + let kind = read_required_string_field(encounter, "kind") + .ok_or_else(|| "当前不在可结算的 NPC 委托态。".to_string())?; + if kind != "npc" { + return Err("当前不在可结算的 NPC 委托态。".to_string()); + } + + 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()); + + if resolve_current_encounter_npc_state(game_state, npc_id.as_str(), npc_name.as_str()).is_none() + { + return Err("当前 NPC 状态不存在,无法处理委托。".to_string()); + } + + Ok(CurrentEncounterNpcQuestContext { npc_id, npc_name }) +} + +fn read_pending_quest_offer_context( + current_story: Option<&Value>, + npc_key: &str, +) -> Option { + let current_story = current_story?; + let npc_chat_state = read_object_field(current_story, "npcChatState")?; + let pending_offer = read_object_field(npc_chat_state, "pendingQuestOffer")?; + let quest = read_object_field(pending_offer, "quest")?.clone(); + let quest_id = read_optional_string_field(&quest, "id")?; + let pending_npc_id = read_optional_string_field(npc_chat_state, "npcId"); + let issuer_npc_id = read_optional_string_field(&quest, "issuerNpcId"); + if pending_npc_id + .as_deref() + .is_some_and(|value| value != npc_key) + { + return None; + } + if issuer_npc_id + .as_deref() + .is_some_and(|value| value != npc_key) + { + return None; + } + + Some(PendingQuestOfferContext { + dialogue: read_array_field(current_story, "dialogue") + .into_iter() + .cloned() + .collect(), + turn_count: read_i32_field(npc_chat_state, "turnCount").unwrap_or(0), + custom_input_placeholder: read_optional_string_field( + npc_chat_state, + "customInputPlaceholder", + ) + .unwrap_or_else(|| "输入你想对 TA 说的话".to_string()), + quest, + quest_id, + intro_text: read_optional_string_field(pending_offer, "introText"), + }) +} + +fn build_quest_offer_dialogue_text(npc_name: &str, quest: &Value) -> String { + let summary_text = read_optional_string_field(quest, "summary") + .or_else(|| read_optional_string_field(quest, "description")) + .unwrap_or_default(); + if summary_text.is_empty() { + return format!( + "{npc_name}沉吟了片刻,像是终于把真正想托付的事说了出来。如果你愿意,我想把眼前这件事正式交给你。" + ); + } + format!( + "{npc_name}沉吟了片刻,像是终于把真正想托付的事说了出来。如果你愿意,我想把这件事正式交给你:{summary_text}" + ) +} + +fn append_dialogue_turns(existing: &[Value], additions: Vec) -> Vec { + let mut dialogue = existing.to_vec(); + dialogue.extend(additions); + dialogue +} + +fn build_pending_quest_offer_options(npc_id: &str) -> Vec { + vec![ + build_npc_runtime_story_option_with_payload( + "npc_chat_quest_offer_view", + "查看任务", + npc_id, + "quest_offer_view", + json!({ + "npcChatQuestOfferAction": "view" + }), + ), + build_npc_runtime_story_option_with_payload( + "npc_chat_quest_offer_replace", + "更换任务", + npc_id, + "quest_offer_replace", + json!({ + "npcChatQuestOfferAction": "replace" + }), + ), + build_npc_runtime_story_option_with_payload( + "npc_chat_quest_offer_abandon", + "放弃任务", + npc_id, + "quest_offer_abandon", + json!({ + "npcChatQuestOfferAction": "abandon" + }), + ), + ] +} + +fn build_post_quest_offer_chat_options(npc_id: &str) -> Vec { + vec![ + build_npc_runtime_story_option( + "npc_chat", + "那先继续聊聊你刚才没说完的部分", + npc_id, + "chat", + ), + build_npc_runtime_story_option( + "npc_chat", + "除了委托,你对眼前局势还有什么判断", + npc_id, + "chat", + ), + build_npc_runtime_story_option( + "npc_chat", + "先把这附近真正危险的地方说清楚", + npc_id, + "chat", + ), + ] +} + +fn build_post_quest_accept_chat_options(npc_id: &str) -> Vec { + vec![ + build_npc_runtime_story_option("npc_chat", "这件事里你最担心哪一步", npc_id, "chat"), + build_npc_runtime_story_option("npc_chat", "我回来时你最想先知道什么", npc_id, "chat"), + build_npc_runtime_story_option( + "npc_chat", + "除了这份委托,你还想提醒我什么", + npc_id, + "chat", + ), + ] +} + +fn build_pending_quest_offer_story( + dialogue: Vec, + npc_id: &str, + npc_name: &str, + turn_count: i32, + custom_input_placeholder: &str, + pending_quest: Option, + options: &[RuntimeStoryOptionView], +) -> Value { + json!({ + "text": dialogue + .iter() + .filter_map(|entry| read_optional_string_field(entry, "text")) + .collect::>() + .join("\n"), + "options": options.iter().map(build_story_option_from_runtime_option).collect::>(), + "displayMode": "dialogue", + "dialogue": dialogue, + "streaming": false, + "npcChatState": { + "npcId": npc_id, + "npcName": npc_name, + "turnCount": turn_count, + "customInputPlaceholder": custom_input_placeholder, + "pendingQuestOffer": pending_quest.map(|quest| json!({ "quest": quest })), + } + }) +} + +fn build_next_pending_quest_offer( + game_state: &Value, + npc_id: &str, + npc_name: &str, + previous_quest_id: Option<&str>, +) -> Value { + let next_id = if previous_quest_id.is_some_and(|id| id == "quest-bridge-offer") { + "quest-bridge-replaced" + } else { + "quest-generated-replaced" + }; + let title = if next_id == "quest-bridge-replaced" { + "断桥夜巡" + } else { + "新的临时委托" + }; + let scene_id = read_object_field(game_state, "currentScenePreset") + .and_then(|scene| read_optional_string_field(scene, "id")); + json!({ + "id": next_id, + "issuerNpcId": npc_id, + "issuerNpcName": npc_name, + "sceneId": scene_id, + "title": title, + "description": format!("{title}的详细说明。"), + "summary": format!("{title}的简要目标。"), + "objective": { + "kind": "inspect_treasure", + "requiredCount": 1 + }, + "progress": 0, + "status": "active", + "reward": { + "affinityBonus": 6, + "currency": 30, + "items": [] + }, + "rewardText": "完成后可以领取报酬。", + "steps": [{ + "id": format!("{next_id}-step-1"), + "title": "查清线索", + "kind": "inspect_treasure", + "requiredCount": 1, + "progress": 0, + "revealText": "先去断桥口附近看看留下了什么痕迹。", + "completeText": "线索已经查清。" + }], + "activeStepId": format!("{next_id}-step-1") + }) +} + +fn find_active_quest_for_issuer<'a>( + game_state: &'a Value, + issuer_npc_id: &str, +) -> Option<&'a Value> { + read_array_field(game_state, "quests") + .into_iter() + .find(|quest| { + read_optional_string_field(quest, "issuerNpcId").as_deref() == Some(issuer_npc_id) + && read_optional_string_field(quest, "status") + .is_some_and(|status| status != "turned_in") + }) +} + +fn push_quest_record(game_state: &mut Value, quest: &Value) { + let root = ensure_json_object(game_state); + let quests = root + .entry("quests".to_string()) + .or_insert_with(|| Value::Array(Vec::new())); + if !quests.is_array() { + *quests = Value::Array(Vec::new()); + } + quests + .as_array_mut() + .expect("quests should be array") + .push(quest.clone()); +} + +fn first_quest_reveal_text(quest: &Value) -> Option { + read_array_field(quest, "steps") + .first() + .and_then(|step| read_optional_string_field(step, "revealText")) +} + +fn build_quest_accept_result_text(quest: &Value) -> String { + let issuer_name = + read_optional_string_field(quest, "issuerNpcName").unwrap_or_else(|| "对方".to_string()); + let title = read_optional_string_field(quest, "title").unwrap_or_else(|| "委托".to_string()); + format!("你正式接下了 {issuer_name} 的委托「{title}」,接下来可以开始推进任务目标。") +} + +fn turn_in_quest_record( + game_state: &mut Value, + issuer_npc_id: &str, + quest_id: &str, +) -> Result { + let root = ensure_json_object(game_state); + let quests = root + .entry("quests".to_string()) + .or_insert_with(|| Value::Array(Vec::new())); + if !quests.is_array() { + *quests = Value::Array(Vec::new()); + } + let quests = quests.as_array_mut().expect("quests should be array"); + let Some(index) = quests.iter().position(|quest| { + read_optional_string_field(quest, "id").as_deref() == Some(quest_id) + && read_optional_string_field(quest, "issuerNpcId").as_deref() == Some(issuer_npc_id) + }) else { + return Err("当前没有可交付的委托。".to_string()); + }; + + let mut turned_in = quests[index].clone(); + if read_optional_string_field(&turned_in, "status").as_deref() != Some("completed") { + return Err("这份委托还没有达到可交付状态。".to_string()); + } + if let Some(object) = turned_in.as_object_mut() { + object.insert("status".to_string(), Value::String("turned_in".to_string())); + object.insert("completionNotified".to_string(), Value::Bool(true)); + if let Some(steps) = object.get_mut("steps").and_then(Value::as_array_mut) { + for step in steps.iter_mut() { + let required_count = read_i32_field(step, "requiredCount").unwrap_or(0); + if let Some(step_object) = step.as_object_mut() { + step_object.insert("progress".to_string(), json!(required_count.max(0))); + } + } + } + } + quests[index] = turned_in.clone(); + Ok(turned_in) +} + +fn build_quest_turn_in_result_text(quest: &Value) -> String { + let title = read_optional_string_field(quest, "title").unwrap_or_else(|| "委托".to_string()); + let reward_text = read_optional_string_field(quest, "rewardText") + .unwrap_or_else(|| "报酬已经结清。".to_string()); + format!("你已经完成并交付了「{title}」。{reward_text}") +} + +fn apply_quest_turn_in_rewards(game_state: &mut Value, quest: &Value) { + let Some(reward) = read_field(quest, "reward") else { + return; + }; + + let currency = read_i32_field(reward, "currency").unwrap_or(0).max(0); + if currency > 0 { + add_player_currency(game_state, currency); + } + + let reward_items = read_array_field(reward, "items") + .into_iter() + .cloned() + .collect::>(); + if !reward_items.is_empty() { + add_player_inventory_items(game_state, reward_items); + } + + let experience = read_i32_field(reward, "experience").unwrap_or(0).max(0); + if experience > 0 { + grant_player_progression_experience(game_state, experience, "quest"); + } +} + fn infer_option_scope(function_id: &str) -> &'static str { if function_id.starts_with("battle_") || function_id == "inventory_use" { "combat" @@ -294,6 +2037,35 @@ fn infer_option_scope(function_id: &str) -> &'static str { } } +fn build_legacy_current_story(story_text: &str, options: &[RuntimeStoryOptionView]) -> Value { + json!({ + "text": story_text, + "options": options.iter().map(build_story_option_from_runtime_option).collect::>(), + "streaming": false + }) +} + +fn build_story_option_from_runtime_option(option: &RuntimeStoryOptionView) -> Value { + json!({ + "functionId": option.function_id, + "actionText": option.action_text, + "text": option.action_text, + "detailText": option.detail_text, + "visuals": { + "playerAnimation": "idle", + "playerMoveMeters": 0, + "playerOffsetY": 0, + "playerFacing": "right", + "scrollWorld": false, + "monsterChanges": [] + }, + "interaction": option.interaction, + "runtimePayload": option.payload, + "disabled": option.disabled, + "disabledReason": option.reason, + }) +} + fn read_story_text(current_story: Option<&Value>) -> Option { current_story.and_then(|story| read_optional_string_field(story, "text")) } @@ -315,6 +2087,559 @@ fn build_fallback_story_text(game_state: &Value) -> String { "当前故事状态已经同步到兼容状态桥,可以继续推进这一轮运行时动作。".to_string() } +fn resolve_action_text(default_text: &str, request: &RuntimeStoryActionRequest) -> String { + request + .action + .payload + .as_ref() + .and_then(|payload| read_optional_string_field(payload, "optionText")) + .unwrap_or_else(|| default_text.to_string()) +} + +fn build_status_patch(game_state: &Value) -> RuntimeStoryPatch { + RuntimeStoryPatch::StatusChanged { + in_battle: read_bool_field(game_state, "inBattle").unwrap_or(false), + npc_interaction_active: read_bool_field(game_state, "npcInteractionActive") + .unwrap_or(false), + current_npc_battle_mode: read_optional_string_field(game_state, "currentNpcBattleMode"), + current_npc_battle_outcome: read_optional_string_field( + game_state, + "currentNpcBattleOutcome", + ), + } +} + +fn restore_player_resource(game_state: &mut Value, hp_restore: i32, mana_restore: i32) { + let max_hp = read_i32_field(game_state, "playerMaxHp") + .unwrap_or(1) + .max(1); + let max_mana = read_i32_field(game_state, "playerMaxMana") + .unwrap_or(0) + .max(0); + let hp = read_i32_field(game_state, "playerHp").unwrap_or(max_hp); + let mana = read_i32_field(game_state, "playerMana").unwrap_or(max_mana); + write_i32_field(game_state, "playerHp", (hp + hp_restore).clamp(0, max_hp)); + write_i32_field( + game_state, + "playerMana", + (mana + mana_restore).clamp(0, max_mana), + ); +} + +fn spend_player_mana(game_state: &mut Value, mana_cost: i32) { + if mana_cost <= 0 { + return; + } + let mana = read_i32_field(game_state, "playerMana").unwrap_or(0); + write_i32_field(game_state, "playerMana", (mana - mana_cost).max(0)); +} + +fn apply_player_damage(game_state: &mut Value, damage: i32) { + if damage <= 0 { + return; + } + let hp = read_i32_field(game_state, "playerHp").unwrap_or(1); + write_i32_field(game_state, "playerHp", (hp - damage).max(1)); +} + +fn apply_target_damage(game_state: &mut Value, damage: i32) -> i32 { + let target_hp = read_object_field(game_state, "currentEncounter") + .and_then(|encounter| { + read_i32_field(encounter, "hp") + .or_else(|| read_i32_field(encounter, "currentHp")) + .or_else(|| read_i32_field(encounter, "targetHp")) + }) + .or_else(|| { + read_array_field(game_state, "sceneHostileNpcs") + .first() + .and_then(|target| read_i32_field(target, "hp")) + }) + .unwrap_or(24); + let next_hp = target_hp - damage.max(0); + write_current_encounter_i32_field(game_state, "hp", next_hp); + write_first_hostile_npc_i32_field(game_state, "hp", next_hp); + + next_hp +} + +fn battle_action_numbers( + function_id: &str, +) -> (i32, i32, i32, i32, i32, &'static str, &'static str) { + match function_id { + "battle_recover_breath" => ( + 0, + 0, + 8, + 6, + 0, + "恢复", + "你先稳住呼吸,把状态从危险边缘拉回一点。", + ), + "battle_use_skill" => ( + 14, + 4, + 0, + 0, + 4, + "施放技能", + "你调动灵力打出一记更重的攻势,同时也承受了对方的反扑。", + ), + "battle_all_in_crush" => ( + 22, + 8, + 0, + 0, + 6, + "全力压制", + "你把这一轮节奏全部压上去,试图用最强硬的方式打穿对方防线。", + ), + "battle_feint_step" => ( + 6, + 2, + 0, + 0, + 0, + "佯攻换位", + "你用一次短促佯攻换开角度,虽然伤害有限,但避开了更重的反击。", + ), + "battle_finisher_window" => ( + 18, + 3, + 0, + 0, + 3, + "抓住终结窗口", + "你抓住破绽打出决定性的一击,战斗天平明显向你倾斜。", + ), + "battle_guard_break" => ( + 12, + 5, + 0, + 0, + 2, + "破开防守", + "你顶住压力破开对方防守,为后续行动争到更直接的窗口。", + ), + "battle_probe_pressure" => ( + 5, + 1, + 0, + 0, + 0, + "试探压迫", + "你没有贸然压上,而是用轻攻测试对方反应。", + ), + _ => ( + 10, + 4, + 0, + 0, + 0, + "普通攻击", + "你抓住当前窗口打出一记直接攻击,对方也立刻做出反击。", + ), + } +} + +fn battle_mode_text(value: &str) -> &'static str { + if value == "spar" { "切磋" } else { "战斗" } +} + +fn current_encounter_name(game_state: &Value) -> String { + read_object_field(game_state, "currentEncounter") + .and_then(|encounter| { + read_optional_string_field(encounter, "npcName") + .or_else(|| read_optional_string_field(encounter, "name")) + }) + .unwrap_or_else(|| "对方".to_string()) +} + +fn current_encounter_name_from_battle(game_state: &Value) -> String { + read_object_field(game_state, "currentEncounter") + .and_then(|encounter| { + read_optional_string_field(encounter, "npcName") + .or_else(|| read_optional_string_field(encounter, "name")) + }) + .or_else(|| first_hostile_npc_string_field(game_state, "name")) + .unwrap_or_else(|| "眼前的敌人".to_string()) +} + +fn current_encounter_id(game_state: &Value) -> Option { + read_object_field(game_state, "currentEncounter") + .and_then(|encounter| read_optional_string_field(encounter, "id")) +} + +fn adjust_current_npc_affinity(game_state: &mut Value, delta: i32) -> Option<(String, i32, i32)> { + let npc_id = current_encounter_id(game_state)?; + let npc_name = current_encounter_name(game_state); + let state = ensure_npc_state_object(game_state, npc_id.as_str(), npc_name.as_str()); + let previous_affinity = state + .get("affinity") + .and_then(Value::as_i64) + .and_then(|value| i32::try_from(value).ok()) + .unwrap_or(0); + let next_affinity = (previous_affinity + delta).clamp(-100, 100); + state.insert("affinity".to_string(), json!(next_affinity)); + state + .entry("recruited".to_string()) + .or_insert(Value::Bool(false)); + + Some((npc_id, previous_affinity, next_affinity)) +} + +fn read_current_npc_state_i32_field(game_state: &Value, key: &str) -> Option { + let npc_id = current_encounter_id(game_state)?; + let npc_name = current_encounter_name(game_state); + resolve_current_encounter_npc_state(game_state, npc_id.as_str(), npc_name.as_str()) + .and_then(|state| read_i32_field(state, key)) +} + +fn read_current_npc_state_bool_field(game_state: &Value, key: &str) -> Option { + let npc_id = current_encounter_id(game_state)?; + let npc_name = current_encounter_name(game_state); + resolve_current_encounter_npc_state(game_state, npc_id.as_str(), npc_name.as_str()) + .and_then(|state| read_bool_field(state, key)) +} + +fn write_current_npc_state_i32_field(game_state: &mut Value, key: &str, value: i32) { + let Some(npc_id) = current_encounter_id(game_state) else { + return; + }; + let npc_name = current_encounter_name(game_state); + let state = ensure_npc_state_object(game_state, npc_id.as_str(), npc_name.as_str()); + state.insert(key.to_string(), json!(value)); +} + +fn write_current_npc_state_bool_field(game_state: &mut Value, key: &str, value: bool) { + let Some(npc_id) = current_encounter_id(game_state) else { + return; + }; + let npc_name = current_encounter_name(game_state); + let state = ensure_npc_state_object(game_state, npc_id.as_str(), npc_name.as_str()); + state.insert(key.to_string(), Value::Bool(value)); +} + +fn set_current_npc_recruited(game_state: &mut Value, recruited: bool) -> Option<(i32, i32)> { + let npc_id = current_encounter_id(game_state)?; + let npc_name = current_encounter_name(game_state); + let state = ensure_npc_state_object(game_state, npc_id.as_str(), npc_name.as_str()); + let previous_affinity = state + .get("affinity") + .and_then(Value::as_i64) + .and_then(|value| i32::try_from(value).ok()) + .unwrap_or(0); + let next_affinity = previous_affinity.max(60); + state.insert("affinity".to_string(), json!(next_affinity)); + state.insert("recruited".to_string(), Value::Bool(recruited)); + + Some((previous_affinity, next_affinity)) +} + +fn read_current_npc_affinity(game_state: &Value) -> i32 { + let Some(npc_id) = current_encounter_id(game_state) else { + return 0; + }; + let npc_name = current_encounter_name(game_state); + resolve_current_encounter_npc_state(game_state, npc_id.as_str(), npc_name.as_str()) + .and_then(|state| read_i32_field(state, "affinity")) + .unwrap_or(0) +} + +fn ensure_npc_state_object<'a>( + game_state: &'a mut Value, + npc_id: &str, + npc_name: &str, +) -> &'a mut Map { + let root = ensure_json_object(game_state); + let npc_states = root + .entry("npcStates".to_string()) + .or_insert_with(|| Value::Object(Map::new())); + if !npc_states.is_object() { + *npc_states = Value::Object(Map::new()); + } + let states = npc_states + .as_object_mut() + .expect("npcStates should be object"); + let existing_key = if states.contains_key(npc_id) { + npc_id.to_string() + } else if states.contains_key(npc_name) { + npc_name.to_string() + } else { + npc_id.to_string() + }; + let state = states + .entry(existing_key) + .or_insert_with(|| Value::Object(Map::new())); + if !state.is_object() { + *state = Value::Object(Map::new()); + } + state.as_object_mut().expect("npc state should be object") +} + +fn add_companion_if_absent( + game_state: &mut Value, + npc_id: &str, + character_id: Option, + joined_at_affinity: i32, +) { + let root = ensure_json_object(game_state); + let companions = root + .entry("companions".to_string()) + .or_insert_with(|| Value::Array(Vec::new())); + if !companions.is_array() { + *companions = Value::Array(Vec::new()); + } + let items = companions + .as_array_mut() + .expect("companions should be array"); + if items + .iter() + .any(|item| read_optional_string_field(item, "npcId").is_some_and(|value| value == npc_id)) + { + return; + } + items.push(json!({ + "npcId": npc_id, + "characterId": character_id, + "joinedAtAffinity": joined_at_affinity, + })); +} + +fn remove_companion_by_npc_id(game_state: &mut Value, npc_id: &str) -> Option { + let root = ensure_json_object(game_state); + let companions = root + .entry("companions".to_string()) + .or_insert_with(|| Value::Array(Vec::new())); + if !companions.is_array() { + *companions = Value::Array(Vec::new()); + } + let items = companions + .as_array_mut() + .expect("companions should be array"); + let index = items.iter().position(|item| { + read_optional_string_field(item, "npcId").is_some_and(|value| value == npc_id) + })?; + Some(items.remove(index)) +} + +fn recruit_companion_to_party( + game_state: &mut Value, + npc_id: &str, + _npc_name: &str, + release_npc_id: Option<&str>, +) -> Result, String> { + let companion_count = read_array_field(game_state, "companions").len(); + if companion_count < MAX_TASK5_COMPANIONS { + add_companion_if_absent( + game_state, + npc_id, + None, + read_current_npc_affinity(game_state), + ); + return Ok(None); + } + + let Some(release_npc_id) = release_npc_id.and_then(normalize_required_string) else { + return Err("队伍已满时必须明确指定一名离队同伴".to_string()); + }; + + let released_companion = remove_companion_by_npc_id(game_state, release_npc_id.as_str()) + .ok_or_else(|| "指定的离队同伴不存在,无法完成换队招募".to_string())?; + let released_name = read_optional_string_field(&released_companion, "displayName") + .or_else(|| read_optional_string_field(&released_companion, "name")) + .or_else(|| read_optional_string_field(&released_companion, "npcName")) + .unwrap_or_else(|| release_npc_id.clone()); + add_companion_if_absent( + game_state, + npc_id, + None, + read_current_npc_affinity(game_state), + ); + Ok(Some(released_name)) +} + +fn clear_encounter_state(game_state: &mut Value) { + clear_encounter_only(game_state); + write_bool_field(game_state, "inBattle", false); + write_bool_field(game_state, "npcInteractionActive", false); + write_null_field(game_state, "currentNpcBattleMode"); +} + +fn clear_encounter_only(game_state: &mut Value) { + write_null_field(game_state, "currentEncounter"); + let root = ensure_json_object(game_state); + root.insert("sceneHostileNpcs".to_string(), Value::Array(Vec::new())); +} + +fn append_story_history(game_state: &mut Value, action_text: &str, result_text: &str) { + let root = ensure_json_object(game_state); + let story_history = root + .entry("storyHistory".to_string()) + .or_insert_with(|| Value::Array(Vec::new())); + if !story_history.is_array() { + *story_history = Value::Array(Vec::new()); + } + let entries = story_history + .as_array_mut() + .expect("storyHistory should be array"); + entries.push(json!({ + "text": action_text, + "historyRole": "action", + })); + entries.push(json!({ + "text": result_text, + "historyRole": "result", + })); +} + +fn increment_runtime_stat(game_state: &mut Value, key: &str, delta: i32) { + let root = ensure_json_object(game_state); + let stats = root + .entry("runtimeStats".to_string()) + .or_insert_with(|| Value::Object(Map::new())); + if !stats.is_object() { + *stats = Value::Object(Map::new()); + } + let stats = stats + .as_object_mut() + .expect("runtimeStats should be object"); + let previous = stats + .get(key) + .and_then(Value::as_i64) + .and_then(|value| i32::try_from(value).ok()) + .unwrap_or(0); + stats.insert(key.to_string(), json!((previous + delta).max(0))); +} + +fn add_player_currency(game_state: &mut Value, delta: i32) { + let previous = read_i32_field(game_state, "playerCurrency").unwrap_or(0); + write_i32_field( + game_state, + "playerCurrency", + previous.saturating_add(delta.max(0)), + ); +} + +fn add_player_inventory_items(game_state: &mut Value, additions: Vec) { + if additions.is_empty() { + return; + } + + let root = ensure_json_object(game_state); + let inventory = root + .entry("playerInventory".to_string()) + .or_insert_with(|| Value::Array(Vec::new())); + if !inventory.is_array() { + *inventory = Value::Array(Vec::new()); + } + let items = inventory + .as_array_mut() + .expect("playerInventory should be array"); + items.extend(additions); +} + +fn grant_player_progression_experience(game_state: &mut Value, amount: i32, source: &str) { + if amount <= 0 { + return; + } + + let root = ensure_json_object(game_state); + let progression = root + .entry("playerProgression".to_string()) + .or_insert_with(|| Value::Object(Map::new())); + if !progression.is_object() { + *progression = Value::Object(Map::new()); + } + let progression = progression + .as_object_mut() + .expect("playerProgression should be object"); + let previous_total_xp = progression + .get("totalXp") + .and_then(Value::as_i64) + .and_then(|value| i32::try_from(value).ok()) + .unwrap_or(0) + .max(0); + let next_total_xp = previous_total_xp.saturating_add(amount); + let level = resolve_progression_level(next_total_xp); + let current_level_xp = next_total_xp.saturating_sub(cumulative_xp_required(level)); + let xp_to_next_level = if level >= MAX_PLAYER_LEVEL { + 0 + } else { + xp_to_next_level_for(level) + }; + + progression.insert("level".to_string(), json!(level)); + progression.insert("currentLevelXp".to_string(), json!(current_level_xp.max(0))); + progression.insert("totalXp".to_string(), json!(next_total_xp)); + progression.insert("xpToNextLevel".to_string(), json!(xp_to_next_level.max(0))); + progression.insert("pendingLevelUps".to_string(), json!(0)); + progression.insert( + "lastGrantedSource".to_string(), + Value::String(source.to_string()), + ); +} + +const MAX_PLAYER_LEVEL: i32 = 20; + +fn xp_to_next_level_for(level: i32) -> i32 { + if level >= MAX_PLAYER_LEVEL { + 0 + } else { + let scale = (level - 1).max(0); + 60 + 20 * scale + 8 * scale * scale + } +} + +fn cumulative_xp_required(level: i32) -> i32 { + let mut total = 0; + let capped_level = level.clamp(1, MAX_PLAYER_LEVEL); + for current_level in 1..capped_level { + total += xp_to_next_level_for(current_level); + } + total +} + +fn resolve_progression_level(total_xp: i32) -> i32 { + let normalized_total_xp = total_xp.max(0); + let mut resolved_level = 1; + for level in 2..=MAX_PLAYER_LEVEL { + if normalized_total_xp < cumulative_xp_required(level) { + break; + } + resolved_level = level; + } + resolved_level +} + +fn write_current_encounter_i32_field(game_state: &mut Value, key: &str, value: i32) { + let root = ensure_json_object(game_state); + let Some(encounter) = root.get_mut("currentEncounter") else { + return; + }; + if let Some(encounter) = encounter.as_object_mut() { + encounter.insert(key.to_string(), json!(value)); + } +} + +fn write_first_hostile_npc_i32_field(game_state: &mut Value, key: &str, value: i32) { + let root = ensure_json_object(game_state); + let Some(hostiles) = root.get_mut("sceneHostileNpcs") else { + return; + }; + let Some(first) = hostiles.as_array_mut().and_then(|items| items.first_mut()) else { + return; + }; + if let Some(first) = first.as_object_mut() { + first.insert(key.to_string(), json!(value)); + } +} + +fn first_hostile_npc_string_field(game_state: &Value, key: &str) -> Option { + read_array_field(game_state, "sceneHostileNpcs") + .first() + .and_then(|target| read_optional_string_field(target, key)) +} + fn read_runtime_session_id(game_state: &Value) -> Option { read_optional_string_field(game_state, "runtimeSessionId") } @@ -359,6 +2684,33 @@ fn read_u32_field(value: &Value, key: &str) -> Option { .and_then(|number| u32::try_from(number).ok()) } +fn write_i32_field(value: &mut Value, key: &str, field_value: i32) { + ensure_json_object(value).insert(key.to_string(), json!(field_value)); +} + +fn write_u32_field(value: &mut Value, key: &str, field_value: u32) { + ensure_json_object(value).insert(key.to_string(), json!(field_value)); +} + +fn write_bool_field(value: &mut Value, key: &str, field_value: bool) { + ensure_json_object(value).insert(key.to_string(), Value::Bool(field_value)); +} + +fn write_string_field(value: &mut Value, key: &str, field_value: &str) { + ensure_json_object(value).insert(key.to_string(), Value::String(field_value.to_string())); +} + +fn write_null_field(value: &mut Value, key: &str) { + ensure_json_object(value).insert(key.to_string(), Value::Null); +} + +fn ensure_json_object(value: &mut Value) -> &mut Map { + if !value.is_object() { + *value = Value::Object(Map::new()); + } + value.as_object_mut().expect("value should be object") +} + fn normalize_required_string(value: &str) -> Option { let trimmed = value.trim(); (!trimmed.is_empty()).then(|| trimmed.to_string()) @@ -368,6 +2720,22 @@ fn normalize_optional_string(value: Option<&str>) -> Option { value.and_then(normalize_required_string) } +fn format_now_rfc3339() -> String { + format_rfc3339(OffsetDateTime::now_utc()).unwrap_or_else(|_| "1970-01-01T00:00:00Z".to_string()) +} + +fn map_runtime_story_client_error(error: SpacetimeClientError) -> AppError { + let (status, provider) = match error { + SpacetimeClientError::Runtime(_) => (StatusCode::BAD_REQUEST, "runtime-story"), + _ => (StatusCode::BAD_GATEWAY, "spacetimedb"), + }; + + AppError::from_status(status).with_details(json!({ + "provider": provider, + "message": error.to_string(), + })) +} + fn runtime_story_error_response(request_context: &RequestContext, error: AppError) -> Response { error.into_response_with_context(Some(request_context)) } @@ -386,6 +2754,7 @@ mod tests { use time::OffsetDateTime; use tower::ServiceExt; + use super::*; use crate::{app::build_router, config::AppConfig, state::AppState}; #[tokio::test] @@ -402,7 +2771,6 @@ mod tests { json!({ "sessionId": "runtime-main", "snapshot": { - "savedAt": "2026-04-22T12:00:00.000Z", "bottomTab": "adventure", "gameState": { "runtimeSessionId": "runtime-main" @@ -421,22 +2789,40 @@ mod tests { } #[tokio::test] - async fn runtime_story_state_resolve_rejects_missing_snapshot() { - let state = seed_authenticated_state().await; - let token = issue_access_token(&state); - let app = build_router(state); + async fn runtime_story_state_get_requires_authentication() { + let app = build_router(AppState::new(AppConfig::default()).expect("state should build")); + + let response = app + .oneshot( + Request::builder() + .method("GET") + .uri("/api/runtime/story/state/runtime-main") + .body(Body::empty()) + .expect("request should build"), + ) + .await + .expect("request should succeed"); + + assert_eq!(response.status(), StatusCode::UNAUTHORIZED); + } + + #[tokio::test] + async fn runtime_story_action_resolve_requires_authentication() { + let app = build_router(AppState::new(AppConfig::default()).expect("state should build")); let response = app .oneshot( Request::builder() .method("POST") - .uri("/api/runtime/story/state/resolve") - .header("authorization", format!("Bearer {token}")) + .uri("/api/runtime/story/actions/resolve") .header("content-type", "application/json") - .header("x-genarrative-response-envelope", "v1") .body(Body::from( json!({ - "sessionId": "runtime-main" + "sessionId": "runtime-main", + "action": { + "type": "story_choice", + "functionId": "idle_rest_focus" + } }) .to_string(), )) @@ -445,11 +2831,190 @@ mod tests { .await .expect("request should succeed"); - assert_eq!(response.status(), StatusCode::BAD_REQUEST); + assert_eq!(response.status(), StatusCode::UNAUTHORIZED); } #[tokio::test] - async fn runtime_story_state_resolve_returns_compiled_snapshot_response() { + 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": { + "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); + + let state_response = app + .clone() + .oneshot( + Request::builder() + .method("GET") + .uri("/api/runtime/story/state/runtime-main") + .header("authorization", format!("Bearer {token}")) + .header("x-genarrative-response-envelope", "v1") + .body(Body::empty()) + .expect("request should build"), + ) + .await + .expect("request should succeed"); + assert_eq!(state_response.status(), StatusCode::OK); + let state_payload: Value = serde_json::from_slice( + &state_response + .into_body() + .collect() + .await + .expect("body should collect") + .to_bytes(), + ) + .expect("response should be json"); + assert!( + state_payload["data"]["viewModel"]["availableOptions"] + .as_array() + .is_some_and(|options| options + .iter() + .any(|option| { option["functionId"] == json!("npc_chat") })) + ); + + 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": "npc_chat" + } + }) + .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"); + assert_eq!(action_payload["data"]["serverVersion"], json!(1)); + assert_eq!( + action_payload["data"]["viewModel"]["encounter"]["affinity"], + json!(52) + ); + } + + #[tokio::test] + async fn runtime_story_action_resolve_rejects_client_version_conflict() { + let state = seed_authenticated_state().await; + let token = issue_access_token(&state); + 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() + .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": 4, + "action": { + "type": "story_choice", + "functionId": "idle_rest_focus" + } + }) + .to_string(), + )) + .expect("request should build"), + ) + .await + .expect("request should succeed"); + + assert_eq!(response.status(), StatusCode::CONFLICT); + let payload: Value = serde_json::from_slice( + &response + .into_body() + .collect() + .await + .expect("body should collect") + .to_bytes(), + ) + .expect("response should be json"); + assert_eq!(payload["error"]["details"]["clientVersion"], json!(4)); + assert_eq!(payload["error"]["details"]["serverVersion"], json!(5)); + } + + #[tokio::test] + async fn runtime_story_initial_returns_fallback_without_llm() { let state = seed_authenticated_state().await; let token = issue_access_token(&state); let app = build_router(state); @@ -458,65 +3023,21 @@ mod tests { .oneshot( Request::builder() .method("POST") - .uri("/api/runtime/story/state/resolve") + .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": 7, - "snapshot": { - "savedAt": "2026-04-22T12:00:00.000Z", - "bottomTab": "adventure", - "gameState": { - "runtimeSessionId": "runtime-main", - "runtimeActionVersion": 7, - "playerHp": 32, - "playerMaxHp": 40, - "playerMana": 18, - "playerMaxMana": 20, - "inBattle": false, - "npcInteractionActive": true, - "currentEncounter": { - "id": "npc_camp_firekeeper", - "kind": "npc", - "npcName": "守火人", - "hostile": false - }, - "npcStates": { - "npc_camp_firekeeper": { - "affinity": 12, - "recruited": false - } - }, - "companions": [{ - "npcId": "npc_companion_001", - "characterId": "char_companion_001", - "joinedAtAffinity": 64 - }] - }, - "currentStory": { - "text": "守火人抬眼看了你一瞬,示意你把想问的话继续说完。", - "displayMode": "dialogue", - "options": [{ - "functionId": "story_continue_adventure", - "actionText": "继续冒险" - }], - "deferredOptions": [{ - "functionId": "npc_chat", - "actionText": "继续交谈", - "detailText": "围绕当前话题继续推进关系判断。", - "interaction": { - "kind": "npc", - "npcId": "npc_camp_firekeeper", - "action": "chat" - }, - "runtimePayload": { - "note": "server-runtime-test" - } - }] - } + "worldType": "martial", + "character": { "name": "林迟" }, + "monsters": [], + "context": { "sceneName": "旧驿道" }, + "requestOptions": { + "availableOptions": [{ + "functionId": "idle_observe_signs", + "actionText": "观察周围迹象" + }] } }) .to_string(), @@ -538,24 +3059,500 @@ mod tests { serde_json::from_slice(&body).expect("response body should be valid json"); assert_eq!(payload["ok"], Value::Bool(true)); - assert_eq!(payload["data"]["sessionId"], json!("runtime-main")); - assert_eq!(payload["data"]["serverVersion"], json!(7)); assert_eq!( - payload["data"]["viewModel"]["encounter"]["npcName"], - json!("守火人") + payload["data"]["options"][0]["functionId"], + json!("idle_observe_signs") + ); + assert!( + payload["data"]["storyText"] + .as_str() + .is_some_and(|text| text.contains("林迟")) + ); + } + + #[test] + fn runtime_story_state_compiler_prefers_dialogue_deferred_options() { + let response = build_runtime_story_state_response( + "runtime-main", + Some(7), + RuntimeStorySnapshotPayload { + saved_at: None, + bottom_tab: "adventure".to_string(), + game_state: json!({ + "runtimeSessionId": "runtime-main", + "runtimeActionVersion": 7, + "playerHp": 32, + "playerMaxHp": 40, + "playerMana": 18, + "playerMaxMana": 20, + "inBattle": false, + "npcInteractionActive": true, + "currentEncounter": { + "id": "npc_camp_firekeeper", + "kind": "npc", + "npcName": "守火人", + "hostile": false + }, + "npcStates": { + "npc_camp_firekeeper": { + "affinity": 12, + "recruited": false + } + }, + "companions": [{ + "npcId": "npc_companion_001", + "characterId": "char_companion_001", + "joinedAtAffinity": 64 + }] + }), + current_story: Some(json!({ + "text": "守火人抬眼看了你一瞬,示意你把想问的话继续说完。", + "displayMode": "dialogue", + "options": [{ + "functionId": "story_continue_adventure", + "actionText": "继续冒险" + }], + "deferredOptions": [{ + "functionId": "npc_chat", + "actionText": "继续交谈", + "detailText": "围绕当前话题继续推进关系判断。", + "interaction": { + "kind": "npc", + "npcId": "npc_camp_firekeeper", + "action": "chat" + }, + "runtimePayload": { + "note": "server-runtime-test" + } + }] + })), + }, + ); + + assert_eq!(response.session_id, "runtime-main"); + assert_eq!(response.server_version, 7); + assert_eq!( + response + .view_model + .encounter + .as_ref() + .expect("encounter should exist") + .npc_name, + "守火人" ); assert_eq!( - payload["data"]["viewModel"]["availableOptions"][0]["functionId"], - json!("npc_chat") + response.view_model.available_options[0].function_id, + "npc_chat" + ); + assert!(matches!( + response.presentation.options[0].interaction, + Some(RuntimeStoryOptionInteraction::Npc { .. }) + )); + } + + #[test] + fn runtime_story_action_resolution_updates_version_and_history() { + let request = RuntimeStoryActionRequest { + session_id: "runtime-main".to_string(), + client_version: Some(3), + action: shared_contracts::runtime_story::RuntimeStoryChoiceAction { + action_type: "story_choice".to_string(), + function_id: "idle_rest_focus".to_string(), + target_id: None, + payload: Some(json!({ "optionText": "原地调息" })), + }, + snapshot: None, + }; + let mut game_state = json!({ + "runtimeSessionId": "runtime-main", + "runtimeActionVersion": 3, + "playerHp": 10, + "playerMaxHp": 30, + "playerMana": 2, + "playerMaxMana": 12, + "storyHistory": [] + }); + + let resolution = + resolve_runtime_story_choice_action(&mut game_state, None, &request, "idle_rest_focus") + .expect("action should resolve"); + let next_version = read_u32_field(&game_state, "runtimeActionVersion") + .unwrap_or(3) + .saturating_add(1); + write_u32_field(&mut game_state, "runtimeActionVersion", next_version); + append_story_history( + &mut game_state, + resolution.action_text.as_str(), + resolution.result_text.as_str(), + ); + + assert_eq!(read_i32_field(&game_state, "playerHp"), Some(18)); + assert_eq!(read_i32_field(&game_state, "playerMana"), Some(8)); + assert_eq!(read_u32_field(&game_state, "runtimeActionVersion"), Some(4)); + assert_eq!( + read_array_field(&game_state, "storyHistory") + .first() + .and_then(|entry| read_optional_string_field(entry, "historyRole")), + Some("action".to_string()) + ); + } + + #[test] + fn runtime_story_npc_help_is_one_shot_and_restores_resources() { + 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_help".to_string(), + target_id: None, + payload: Some(json!({ "optionText": "请求援手" })), + }, + snapshot: None, + }; + let mut game_state = build_runtime_story_boundary_game_state_fixture(); + write_i32_field(&mut game_state, "playerHp", 20); + write_i32_field(&mut game_state, "playerMana", 4); + + let first = + resolve_runtime_story_choice_action(&mut game_state, None, &request, "npc_help") + .expect("first help should resolve"); + + assert!(first.result_text.contains("及时支援")); + assert_eq!(read_i32_field(&game_state, "playerHp"), Some(30)); + assert_eq!(read_i32_field(&game_state, "playerMana"), Some(12)); + assert_eq!( + read_current_npc_state_bool_field(&game_state, "helpUsed"), + Some(true) + ); + + let second = + resolve_runtime_story_choice_action(&mut game_state, None, &request, "npc_help"); + match second { + Ok(_) => panic!("second help should be rejected"), + Err(error) => assert_eq!(error, "当前 NPC 的一次性援手已经用完了"), + } + } + + #[test] + fn runtime_story_npc_recruit_requires_threshold_and_release_target_when_party_full() { + 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_recruit".to_string(), + target_id: None, + payload: Some(json!({ "optionText": "邀请同行" })), + }, + snapshot: None, + }; + + let mut low_affinity_state = build_runtime_story_boundary_game_state_fixture(); + let error = resolve_runtime_story_choice_action( + &mut low_affinity_state, + None, + &request, + "npc_recruit", + ); + match error { + Ok(_) => panic!("low affinity recruit should be rejected"), + Err(message) => assert_eq!(message, "当前关系还没达到招募阈值,暂时不能邀请入队"), + } + + let mut full_party_state = build_runtime_story_boundary_game_state_fixture(); + write_current_npc_state_i32_field(&mut full_party_state, "affinity", 60); + let root = ensure_json_object(&mut full_party_state); + root.insert( + "companions".to_string(), + json!([ + { + "npcId": "npc-ally-1", + "characterId": "char-ally-1", + "joinedAtAffinity": 64, + "npcName": "旧同伴甲" + }, + { + "npcId": "npc-ally-2", + "characterId": "char-ally-2", + "joinedAtAffinity": 61, + "npcName": "旧同伴乙" + } + ]), + ); + + let full_party_error = resolve_runtime_story_choice_action( + &mut full_party_state, + None, + &request, + "npc_recruit", + ); + match full_party_error { + Ok(_) => panic!("full party recruit should require release target"), + Err(message) => assert_eq!(message, "队伍已满时必须明确指定一名离队同伴"), + } + + let request_with_release = RuntimeStoryActionRequest { + action: shared_contracts::runtime_story::RuntimeStoryChoiceAction { + payload: Some(json!({ + "optionText": "邀请同行", + "releaseNpcId": "npc-ally-1" + })), + ..request.action.clone() + }, + ..request + }; + let resolution = resolve_runtime_story_choice_action( + &mut full_party_state, + None, + &request_with_release, + "npc_recruit", + ) + .expect("recruit with release target should resolve"); + + assert!(resolution.result_text.contains("旧同伴甲")); + assert_eq!(read_array_field(&full_party_state, "companions").len(), 2); + assert!( + read_array_field(&full_party_state, "companions") + .iter() + .any(|entry| { + read_optional_string_field(entry, "npcId").as_deref() == Some("npc_merchant_01") + }) ); assert_eq!( - payload["data"]["presentation"]["options"][0]["interaction"]["npcId"], - json!("npc_camp_firekeeper") + read_field(&full_party_state, "currentEncounter"), + Some(&Value::Null) + ); + } + + #[test] + fn runtime_story_quest_offer_replace_updates_pending_offer_and_payload() { + 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_chat_quest_offer_replace".to_string(), + target_id: None, + payload: Some(json!({ + "optionText": "更换任务" + })), + }, + snapshot: None, + }; + let mut game_state = build_runtime_story_boundary_game_state_fixture(); + let current_story = build_runtime_story_pending_quest_offer_fixture( + build_runtime_story_boundary_quest_fixture("quest-bridge-offer", "断桥口的密信"), + ); + + let resolution = resolve_runtime_story_choice_action( + &mut game_state, + Some(¤t_story), + &request, + "npc_chat_quest_offer_replace", + ) + .expect("quest replace should resolve"); + + let saved_current_story = resolution + .saved_current_story + .expect("quest replace should save current story"); + let pending_quest = read_field(&saved_current_story, "npcChatState") + .and_then(|state| read_field(state, "pendingQuestOffer")) + .and_then(|offer| read_field(offer, "quest")) + .expect("pending quest should exist after replace"); + assert_eq!( + read_optional_string_field(pending_quest, "id"), + Some("quest-bridge-replaced".to_string()) + ); + + let options = resolution + .presentation_options + .expect("quest replace should expose options"); + assert_eq!(options.len(), 3); + assert_eq!( + options[1].payload.as_ref().and_then(|payload| { + read_optional_string_field(payload, "npcChatQuestOfferAction") + }), + Some("replace".to_string()) + ); + } + + #[test] + fn runtime_story_quest_offer_abandon_clears_pending_offer_and_restores_chat_options() { + 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_chat_quest_offer_abandon".to_string(), + target_id: None, + payload: Some(json!({ + "optionText": "放弃任务" + })), + }, + snapshot: None, + }; + let mut game_state = build_runtime_story_boundary_game_state_fixture(); + let current_story = build_runtime_story_pending_quest_offer_fixture( + build_runtime_story_boundary_quest_fixture("quest-bridge-offer", "断桥口的密信"), + ); + + let resolution = resolve_runtime_story_choice_action( + &mut game_state, + Some(¤t_story), + &request, + "npc_chat_quest_offer_abandon", + ) + .expect("quest abandon should resolve"); + + let saved_current_story = resolution + .saved_current_story + .expect("quest abandon should save current story"); + assert_eq!( + read_field(&saved_current_story, "npcChatState") + .and_then(|state| read_field(state, "pendingQuestOffer")), + Some(&Value::Null) + ); + let options = resolution + .presentation_options + .expect("quest abandon should expose follow-up chat options"); + assert_eq!(options.len(), 3); + assert!( + options + .iter() + .all(|option| option.function_id == "npc_chat") + ); + assert_eq!(options[0].action_text, "那先继续聊聊你刚才没说完的部分"); + } + + #[test] + fn runtime_story_quest_accept_writes_quest_runtime_stats_and_followup_story() { + 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_quest_accept".to_string(), + target_id: None, + payload: Some(json!({ + "optionText": "接受任务" + })), + }, + snapshot: None, + }; + let mut game_state = build_runtime_story_boundary_game_state_fixture(); + let pending_quest = + build_runtime_story_boundary_quest_fixture("quest-bridge-offer", "断桥口的密信"); + let current_story = build_runtime_story_pending_quest_offer_fixture(pending_quest.clone()); + + let resolution = resolve_runtime_story_choice_action( + &mut game_state, + Some(¤t_story), + &request, + "npc_quest_accept", + ) + .expect("quest accept should resolve"); + + let quests = read_array_field(&game_state, "quests"); + assert_eq!(quests.len(), 1); + assert_eq!( + read_optional_string_field(quests[0], "id"), + read_optional_string_field(&pending_quest, "id") ); assert_eq!( - payload["data"]["snapshot"]["currentStory"]["deferredOptions"][0]["functionId"], - json!("npc_chat") + read_field(&game_state, "runtimeStats") + .and_then(|stats| read_i32_field(stats, "questsAccepted")), + Some(1) ); + let saved_current_story = resolution + .saved_current_story + .expect("quest accept should save current story"); + assert_eq!( + read_field(&saved_current_story, "npcChatState") + .and_then(|state| read_field(state, "pendingQuestOffer")), + Some(&Value::Null) + ); + assert_eq!( + resolution + .presentation_options + .expect("quest accept should expose follow-up options") + .len(), + 3 + ); + } + + #[test] + fn runtime_story_quest_turn_in_marks_quest_rewards_and_affinity() { + 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_quest_turn_in".to_string(), + target_id: None, + payload: Some(json!({ + "optionText": "交付任务", + "questId": "quest-bridge-complete" + })), + }, + snapshot: None, + }; + let mut game_state = build_runtime_story_boundary_game_state_fixture(); + let mut completed_quest = + build_runtime_story_boundary_quest_fixture("quest-bridge-complete", "断桥夜巡"); + if let Some(quest) = completed_quest.as_object_mut() { + quest.insert("status".to_string(), Value::String("completed".to_string())); + quest.insert( + "reward".to_string(), + json!({ + "affinityBonus": 6, + "currency": 30, + "experience": 24, + "items": [{ + "id": "reward-med-1", + "category": "补给", + "name": "回气散", + "quantity": 1, + "tags": [] + }] + }), + ); + } + push_quest_record(&mut game_state, &completed_quest); + + let resolution = resolve_runtime_story_choice_action( + &mut game_state, + None, + &request, + "npc_quest_turn_in", + ) + .expect("quest turn in should resolve"); + + let quests = read_array_field(&game_state, "quests"); + assert_eq!(quests.len(), 1); + assert_eq!( + read_optional_string_field(quests[0], "status"), + Some("turned_in".to_string()) + ); + assert_eq!(read_i32_field(&game_state, "playerCurrency"), Some(120)); + assert_eq!(read_array_field(&game_state, "playerInventory").len(), 1); + assert_eq!( + read_field(&game_state, "playerProgression") + .and_then(|progression| read_i32_field(progression, "totalXp")), + Some(24) + ); + assert_eq!( + read_current_npc_state_i32_field(&game_state, "affinity"), + Some(52) + ); + assert!(resolution.patches.iter().any(|patch| matches!( + patch, + RuntimeStoryPatch::NpcAffinityChanged { + previous_affinity: 46, + next_affinity: 52, + .. + } + ))); } async fn seed_authenticated_state() -> AppState { @@ -590,4 +3587,152 @@ mod tests { sign_access_token(&claims, state.auth_jwt_config()).expect("token should sign") } + + fn build_runtime_story_boundary_game_state_fixture() -> Value { + serde_json::from_str( + r#"{ + "worldType": "WUXIA", + "runtimeSessionId": "runtime-main", + "runtimeActionVersion": 0, + "playerCharacter": { + "id": "hero-story", + "title": "试剑客", + "description": "站在桥口的人。", + "personality": "谨慎", + "attributes": { + "strength": 8, + "spirit": 6 + }, + "skills": [] + }, + "runtimeStats": { + "playTimeMs": 0, + "lastPlayTickAt": null, + "hostileNpcsDefeated": 0, + "questsAccepted": 0, + "itemsUsed": 0, + "scenesTraveled": 0 + }, + "currentScene": "test-scene", + "storyHistory": [], + "characterChats": {}, + "animationState": "idle", + "currentEncounter": { + "kind": "npc", + "id": "npc_merchant_01", + "npcName": "沈七", + "npcDescription": "腰间挂着药囊的行商", + "context": "受伤行商", + "hostile": false + }, + "npcInteractionActive": true, + "currentScenePreset": null, + "sceneHostileNpcs": [], + "playerX": 0, + "playerOffsetY": 0, + "playerFacing": "right", + "playerActionMode": "idle", + "scrollWorld": false, + "inBattle": false, + "playerHp": 31, + "playerMaxHp": 40, + "playerMana": 9, + "playerMaxMana": 16, + "playerSkillCooldowns": {}, + "activeBuildBuffs": [], + "activeCombatEffects": [], + "playerCurrency": 90, + "playerInventory": [], + "playerEquipment": { + "weapon": null, + "armor": null, + "relic": null + }, + "npcStates": { + "npc_merchant_01": { + "affinity": 46, + "chattedCount": 0, + "helpUsed": false, + "giftsGiven": 0, + "inventory": [], + "recruited": false + } + }, + "quests": [], + "roster": [], + "companions": [], + "currentNpcBattleMode": null, + "currentNpcBattleOutcome": null, + "sparReturnEncounter": null, + "sparPlayerHpBefore": null, + "sparPlayerMaxHpBefore": null, + "sparStoryHistoryBefore": null, + "playerProgression": { + "level": 1, + "currentLevelXp": 0, + "totalXp": 0, + "xpToNextLevel": 60, + "pendingLevelUps": 0, + "lastGrantedSource": null + } + }"#, + ) + .expect("runtime story boundary game state fixture should parse") + } + + fn build_runtime_story_boundary_quest_fixture(quest_id: &str, title: &str) -> Value { + json!({ + "id": quest_id, + "issuerNpcId": "npc_merchant_01", + "issuerNpcName": "沈七", + "sceneId": "scene-bridge", + "title": title, + "description": format!("{title}的详细说明。"), + "summary": format!("{title}的简要目标。"), + "objective": { + "kind": "inspect_treasure", + "requiredCount": 1 + }, + "progress": 0, + "status": "active", + "reward": { + "affinityBonus": 6, + "currency": 30, + "items": [] + }, + "rewardText": "完成后可以领取报酬。", + "steps": [{ + "id": format!("{quest_id}-step-1"), + "title": "查清线索", + "kind": "inspect_treasure", + "requiredCount": 1, + "progress": 0, + "revealText": "先去断桥口附近看看留下了什么痕迹。", + "completeText": "线索已经查清。" + }], + "activeStepId": format!("{quest_id}-step-1") + }) + } + + fn build_runtime_story_pending_quest_offer_fixture(quest: Value) -> Value { + json!({ + "text": "沈七终于把真正的委托说了出来。", + "options": [], + "displayMode": "dialogue", + "dialogue": [{ + "speaker": "npc", + "speakerName": "沈七", + "text": "这件事我只想托给你。" + }], + "npcChatState": { + "npcId": "npc_merchant_01", + "npcName": "沈七", + "turnCount": 2, + "customInputPlaceholder": "输入你想对 TA 说的话", + "pendingQuestOffer": { + "quest": quest + } + } + }) + } } diff --git a/server-rs/crates/api-server/src/sse.rs b/server-rs/crates/api-server/src/sse.rs new file mode 100644 index 00000000..3dad8633 --- /dev/null +++ b/server-rs/crates/api-server/src/sse.rs @@ -0,0 +1,130 @@ +use axum::{ + http::{HeaderName, StatusCode, header}, + response::{IntoResponse, Response}, +}; +use serde::Serialize; +use serde_json::json; + +use crate::http_error::AppError; + +/// 最小缓冲式 SSE builder,适用于“先完成业务,再一次性返回完整 SSE 文本”的兼容链路。 +#[derive(Default)] +pub struct SseEventBuffer { + body: String, +} + +impl SseEventBuffer { + pub fn new() -> Self { + Self::default() + } + + pub fn push_json(&mut self, event: &str, payload: &T) -> Result<(), AppError> + where + T: Serialize, + { + encode_sse_event(&mut self.body, event, payload) + } + + pub fn into_response(self) -> Response { + build_sse_response(self.body) + } +} + +pub fn encode_sse_event(body: &mut String, event: &str, payload: &T) -> Result<(), AppError> +where + T: Serialize, +{ + let payload_text = serde_json::to_string(payload).map_err(|error| { + AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_details(json!({ + "provider": "sse", + "message": format!("SSE payload 序列化失败:{error}"), + })) + })?; + + body.push_str("event: "); + body.push_str(event); + body.push('\n'); + body.push_str("data: "); + body.push_str(&payload_text); + body.push_str("\n\n"); + + Ok(()) +} + +pub fn build_sse_response(body: String) -> Response { + ( + [ + (header::CONTENT_TYPE, "text/event-stream; charset=utf-8"), + (header::CACHE_CONTROL, "no-cache"), + // 反向代理场景下显式关闭缓冲,避免 SSE 事件被聚合后才下发。 + (HeaderName::from_static("x-accel-buffering"), "no"), + ], + body, + ) + .into_response() +} + +#[cfg(test)] +mod tests { + use super::{SseEventBuffer, build_sse_response, encode_sse_event}; + use axum::body::to_bytes; + use serde_json::json; + + #[tokio::test] + async fn encode_sse_event_writes_standard_format() { + let mut body = String::new(); + encode_sse_event(&mut body, "reply_delta", &json!({ "text": "hello" })) + .expect("encoding should succeed"); + + assert_eq!(body, "event: reply_delta\ndata: {\"text\":\"hello\"}\n\n"); + } + + #[tokio::test] + async fn build_sse_response_sets_standard_headers() { + let response = build_sse_response("event: done\ndata: {\"ok\":true}\n\n".to_string()); + + assert_eq!( + response + .headers() + .get(header::CONTENT_TYPE) + .and_then(|value| value.to_str().ok()), + Some("text/event-stream; charset=utf-8") + ); + assert_eq!( + response + .headers() + .get(header::CACHE_CONTROL) + .and_then(|value| value.to_str().ok()), + Some("no-cache") + ); + assert_eq!( + response + .headers() + .get(HeaderName::from_static("x-accel-buffering")) + .and_then(|value| value.to_str().ok()), + Some("no") + ); + } + + #[tokio::test] + async fn sse_event_buffer_collects_events_and_returns_response() { + let mut buffer = SseEventBuffer::new(); + buffer + .push_json("reply_delta", &json!({ "text": "hello" })) + .expect("first event should encode"); + buffer + .push_json("done", &json!({ "ok": true })) + .expect("second event should encode"); + + let response = buffer.into_response(); + let body = to_bytes(response.into_body(), usize::MAX) + .await + .expect("response body should read"); + let text = String::from_utf8(body.to_vec()).expect("body should be utf8"); + + assert_eq!( + text, + "event: reply_delta\ndata: {\"text\":\"hello\"}\n\nevent: done\ndata: {\"ok\":true}\n\n" + ); + } +} diff --git a/server-rs/crates/api-server/src/state.rs b/server-rs/crates/api-server/src/state.rs index 95eabadd..066cfcb5 100644 --- a/server-rs/crates/api-server/src/state.rs +++ b/server-rs/crates/api-server/src/state.rs @@ -1,16 +1,26 @@ use std::{error::Error, fmt}; +#[cfg(test)] +use std::{ + collections::HashMap, + sync::{Arc, Mutex}, +}; + use module_ai::{AiTaskService, InMemoryAiTaskStore}; use module_auth::{ AuthUserService, InMemoryAuthStore, PasswordEntryService, PhoneAuthService, RefreshSessionService, WechatAuthService, WechatAuthStateService, }; +use module_runtime::RuntimeSnapshotRecord; +#[cfg(test)] +use module_runtime::{SAVE_SNAPSHOT_VERSION, format_utc_micros}; use platform_auth::{ JwtConfig, JwtError, RefreshCookieConfig, RefreshCookieError, RefreshCookieSameSite, }; use platform_llm::{LlmClient, LlmConfig, LlmError}; use platform_oss::{OssClient, OssConfig, OssError}; -use spacetime_client::{SpacetimeClient, SpacetimeClientConfig}; +use serde_json::Value; +use spacetime_client::{SpacetimeClient, SpacetimeClientConfig, SpacetimeClientError}; use crate::config::AppConfig; use crate::wechat_provider::{WechatProvider, build_wechat_provider}; @@ -35,6 +45,9 @@ pub struct AppState { ai_task_service: AiTaskService, spacetime_client: SpacetimeClient, llm_client: Option, + #[cfg(test)] + // 测试环境允许在未启动 SpacetimeDB 时,用内存快照兜底当前 runtime story 回归链。 + test_runtime_snapshot_store: Arc>>, } #[derive(Debug)] @@ -98,6 +111,8 @@ impl AppState { ai_task_service, spacetime_client, llm_client, + #[cfg(test)] + test_runtime_snapshot_store: Arc::new(Mutex::new(HashMap::new())), }) } @@ -153,6 +168,162 @@ impl AppState { pub fn llm_client(&self) -> Option<&LlmClient> { self.llm_client.as_ref() } + + pub async fn get_runtime_snapshot_record( + &self, + user_id: String, + ) -> Result, SpacetimeClientError> { + match self + .spacetime_client + .get_runtime_snapshot(user_id.clone()) + .await + { + Ok(record) => { + #[cfg(test)] + if let Some(snapshot) = record.as_ref() { + self.cache_test_runtime_snapshot(snapshot.clone()); + } + Ok(record) + } + #[cfg(test)] + Err(_) => Ok(self.read_test_runtime_snapshot(user_id.as_str())), + #[cfg(not(test))] + Err(error) => Err(error), + } + } + + pub async fn put_runtime_snapshot_record( + &self, + user_id: String, + saved_at_micros: i64, + bottom_tab: String, + game_state: Value, + current_story: Option, + updated_at_micros: i64, + ) -> Result { + match self + .spacetime_client + .put_runtime_snapshot( + user_id.clone(), + saved_at_micros, + bottom_tab.clone(), + game_state.clone(), + current_story.clone(), + updated_at_micros, + ) + .await + { + Ok(record) => { + #[cfg(test)] + self.cache_test_runtime_snapshot(record.clone()); + Ok(record) + } + #[cfg(test)] + Err(_) => { + let snapshot = self.build_test_runtime_snapshot_record( + user_id, + saved_at_micros, + bottom_tab, + game_state, + current_story, + updated_at_micros, + )?; + self.cache_test_runtime_snapshot(snapshot.clone()); + Ok(snapshot) + } + #[cfg(not(test))] + Err(error) => Err(error), + } + } + + pub async fn delete_runtime_snapshot_record( + &self, + user_id: String, + ) -> Result { + match self + .spacetime_client + .delete_runtime_snapshot(user_id.clone()) + .await + { + Ok(deleted) => { + #[cfg(test)] + if deleted { + self.remove_test_runtime_snapshot(user_id.as_str()); + } + Ok(deleted) + } + #[cfg(test)] + Err(_) => Ok(self + .remove_test_runtime_snapshot(user_id.as_str()) + .is_some()), + #[cfg(not(test))] + Err(error) => Err(error), + } + } +} + +#[cfg(test)] +impl AppState { + fn cache_test_runtime_snapshot(&self, record: RuntimeSnapshotRecord) { + self.test_runtime_snapshot_store + .lock() + .expect("test runtime snapshot store should lock") + .insert(record.user_id.clone(), record); + } + + fn read_test_runtime_snapshot(&self, user_id: &str) -> Option { + self.test_runtime_snapshot_store + .lock() + .expect("test runtime snapshot store should lock") + .get(user_id) + .cloned() + } + + fn remove_test_runtime_snapshot(&self, user_id: &str) -> Option { + self.test_runtime_snapshot_store + .lock() + .expect("test runtime snapshot store should lock") + .remove(user_id) + } + + fn build_test_runtime_snapshot_record( + &self, + user_id: String, + saved_at_micros: i64, + bottom_tab: String, + game_state: Value, + current_story: Option, + updated_at_micros: i64, + ) -> Result { + let previous = self.read_test_runtime_snapshot(user_id.as_str()); + let game_state_json = serde_json::to_string(&game_state).map_err(|error| { + SpacetimeClientError::Runtime(format!("测试快照 game_state 序列化失败: {error}")) + })?; + let current_story_json = current_story + .as_ref() + .map(serde_json::to_string) + .transpose() + .map_err(|error| { + SpacetimeClientError::Runtime(format!("测试快照 current_story 序列化失败: {error}")) + })?; + + Ok(RuntimeSnapshotRecord { + user_id, + version: SAVE_SNAPSHOT_VERSION, + saved_at: format_utc_micros(saved_at_micros), + saved_at_micros, + bottom_tab, + game_state, + current_story, + game_state_json, + current_story_json, + created_at_micros: previous + .as_ref() + .map(|record| record.created_at_micros) + .unwrap_or(updated_at_micros), + updated_at_micros, + }) + } } impl fmt::Display for AppStateInitError { diff --git a/server-rs/crates/module-custom-world/src/lib.rs b/server-rs/crates/module-custom-world/src/lib.rs index 2eae38c5..3f74f888 100644 --- a/server-rs/crates/module-custom-world/src/lib.rs +++ b/server-rs/crates/module-custom-world/src/lib.rs @@ -140,6 +140,7 @@ pub enum CustomWorldFieldError { MissingProfileId, MissingSessionId, MissingOwnerUserId, + MissingAction, MissingWorldName, MissingDraftProfileJson, MissingProfilePayloadJson, @@ -227,6 +228,61 @@ pub struct CustomWorldGalleryListResult { pub error_message: Option, } +#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct CustomWorldPublishBlockerSnapshot { + pub blocker_id: String, + pub code: String, + pub message: String, +} + +#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct CustomWorldPublishGateSnapshot { + pub profile_id: String, + pub blockers: Vec, + pub blocker_count: u32, + pub publish_ready: bool, + pub can_enter_world: bool, +} + +#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct CustomWorldWorkSummarySnapshot { + pub work_id: String, + pub source_type: String, + pub status: String, + pub title: String, + pub subtitle: String, + pub summary: String, + pub cover_image_src: Option, + pub cover_render_mode: Option, + pub cover_character_image_srcs_json: String, + pub updated_at_micros: i64, + pub published_at_micros: Option, + pub stage: Option, + pub stage_label: Option, + pub playable_npc_count: u32, + pub landmark_count: u32, + pub role_visual_ready_count: Option, + pub role_animation_ready_count: Option, + pub role_asset_summary_label: Option, + pub session_id: Option, + pub profile_id: Option, + pub can_resume: bool, + pub can_enter_world: bool, + pub blocker_count: u32, + pub publish_ready: bool, +} + +#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct CustomWorldWorksListResult { + pub ok: bool, + pub items: Vec, + pub error_message: Option, +} + #[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub struct CustomWorldAgentMessageSnapshot { @@ -273,6 +329,38 @@ pub struct CustomWorldDraftCardSnapshot { pub updated_at_micros: i64, } +#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct CustomWorldDraftCardDetailSectionSnapshot { + pub section_id: String, + pub label: String, + pub value: String, +} + +#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct CustomWorldDraftCardDetailSnapshot { + pub card_id: String, + pub kind: RpgAgentDraftCardKind, + pub title: String, + pub sections: Vec, + pub linked_ids_json: String, + pub locked: bool, + pub editable: bool, + pub editable_section_ids_json: String, + pub warning_messages_json: String, + pub asset_status: Option, + pub asset_status_label: Option, +} + +#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct CustomWorldDraftCardDetailResult { + pub ok: bool, + pub card: Option, + pub error_message: Option, +} + #[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub struct CustomWorldAgentSessionSnapshot { @@ -290,6 +378,7 @@ pub struct CustomWorldAgentSessionSnapshot { pub lock_state_json: Option, pub draft_profile_json: Option, pub last_assistant_reply: Option, + pub publish_gate_json: Option, pub result_preview_json: Option, pub pending_clarifications_json: String, pub quality_findings_json: String, @@ -297,6 +386,7 @@ pub struct CustomWorldAgentSessionSnapshot { pub recommended_replies_json: String, pub asset_coverage_json: String, pub checkpoints_json: String, + pub supported_actions_json: String, pub messages: Vec, pub draft_cards: Vec, pub operations: Vec, @@ -425,6 +515,39 @@ pub struct CustomWorldAgentOperationProcedureResult { pub error_message: Option, } +#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct CustomWorldWorksListInput { + pub owner_user_id: String, +} + +#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct CustomWorldAgentCardDetailGetInput { + pub session_id: String, + pub owner_user_id: String, + pub card_id: String, +} + +#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct CustomWorldAgentActionExecuteInput { + pub session_id: String, + pub owner_user_id: String, + pub operation_id: String, + pub action: String, + pub payload_json: Option, + pub submitted_at_micros: i64, +} + +#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct CustomWorldAgentActionExecuteResult { + pub ok: bool, + pub operation: Option, + pub error_message: Option, +} + #[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub struct CustomWorldPublishedProfileCompileInput { @@ -941,6 +1064,52 @@ pub fn validate_custom_world_agent_operation_get_input( Ok(()) } +pub fn validate_custom_world_works_list_input( + input: &CustomWorldWorksListInput, +) -> Result<(), CustomWorldFieldError> { + if input.owner_user_id.trim().is_empty() { + return Err(CustomWorldFieldError::MissingOwnerUserId); + } + + Ok(()) +} + +pub fn validate_custom_world_agent_card_detail_get_input( + input: &CustomWorldAgentCardDetailGetInput, +) -> Result<(), CustomWorldFieldError> { + if input.session_id.trim().is_empty() { + return Err(CustomWorldFieldError::MissingSessionId); + } + if input.owner_user_id.trim().is_empty() { + return Err(CustomWorldFieldError::MissingOwnerUserId); + } + if input.card_id.trim().is_empty() { + return Err(CustomWorldFieldError::MissingCardId); + } + + Ok(()) +} + +pub fn validate_custom_world_agent_action_execute_input( + input: &CustomWorldAgentActionExecuteInput, +) -> Result<(), CustomWorldFieldError> { + if input.session_id.trim().is_empty() { + return Err(CustomWorldFieldError::MissingSessionId); + } + if input.owner_user_id.trim().is_empty() { + return Err(CustomWorldFieldError::MissingOwnerUserId); + } + if input.operation_id.trim().is_empty() { + return Err(CustomWorldFieldError::MissingOperationId); + } + if input.action.trim().is_empty() { + return Err(CustomWorldFieldError::MissingAction); + } + ensure_optional_json_object(input.payload_json.as_deref())?; + + Ok(()) +} + pub fn validate_custom_world_agent_message_fields( message_id: &str, session_id: &str, @@ -1290,6 +1459,7 @@ impl fmt::Display for CustomWorldFieldError { Self::MissingProfileId => f.write_str("custom_world.profile_id 不能为空"), Self::MissingSessionId => f.write_str("custom_world.session_id 不能为空"), Self::MissingOwnerUserId => f.write_str("custom_world.owner_user_id 不能为空"), + Self::MissingAction => f.write_str("custom_world_agent_action.action 不能为空"), Self::MissingWorldName => f.write_str("custom_world.world_name 不能为空"), Self::MissingDraftProfileJson => { f.write_str("custom_world.compile.draft_profile_json 不能为空") diff --git a/server-rs/crates/shared-contracts/README.md b/server-rs/crates/shared-contracts/README.md index fe942911..cd9c8766 100644 --- a/server-rs/crates/shared-contracts/README.md +++ b/server-rs/crates/shared-contracts/README.md @@ -54,8 +54,10 @@ 当前阶段新增 Stage5 `runtime story` 兼容桥 DTO 基线: 1. `runtime/story/state/resolve` 请求 DTO +2. `runtime/story/actions/resolve`、`runtime/story/initial`、`runtime/story/continue` 请求 DTO 2. `RuntimeStoryActionResponse` 兼容响应 DTO 3. `RuntimeStoryViewModel / presentation / patches / snapshot` 显式结构 +4. `RuntimeStoryAiResponse` 兼容响应 DTO 当前仍刻意未做: diff --git a/server-rs/crates/shared-contracts/src/runtime_story.rs b/server-rs/crates/shared-contracts/src/runtime_story.rs index 48784d61..48cce76f 100644 --- a/server-rs/crates/shared-contracts/src/runtime_story.rs +++ b/server-rs/crates/shared-contracts/src/runtime_story.rs @@ -4,7 +4,8 @@ use serde_json::Value; #[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] #[serde(rename_all = "camelCase")] pub struct RuntimeStorySnapshotPayload { - pub saved_at: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub saved_at: Option, pub bottom_tab: String, pub game_state: Value, #[serde(default)] @@ -21,6 +22,72 @@ pub struct RuntimeStoryStateResolveRequest { pub snapshot: Option, } +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct RuntimeStoryChoiceAction { + #[serde(rename = "type")] + pub action_type: String, + pub function_id: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub target_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub payload: Option, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct RuntimeStoryActionRequest { + pub session_id: String, + #[serde(default)] + pub client_version: Option, + pub action: RuntimeStoryChoiceAction, + #[serde(default)] + pub snapshot: Option, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct RuntimeStoryAiRequestOptions { + #[serde(default)] + pub available_options: Vec, + #[serde(default)] + pub option_catalog: Vec, +} + +impl Default for RuntimeStoryAiRequestOptions { + fn default() -> Self { + Self { + available_options: Vec::new(), + option_catalog: Vec::new(), + } + } +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct RuntimeStoryAiRequest { + pub world_type: String, + pub character: Value, + #[serde(default)] + pub monsters: Vec, + #[serde(default)] + pub history: Vec, + #[serde(default)] + pub choice: String, + pub context: Value, + #[serde(default)] + pub request_options: RuntimeStoryAiRequestOptions, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct RuntimeStoryAiResponse { + pub story_text: String, + pub options: Vec, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub encounter: Option, +} + #[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] #[serde(rename_all = "camelCase")] pub struct RuntimeStoryOptionView { @@ -197,28 +264,70 @@ mod tests { use serde_json::json; #[test] - fn runtime_story_state_resolve_request_uses_camel_case_fields() { - let payload = serde_json::to_value(RuntimeStoryStateResolveRequest { + fn runtime_story_state_resolve_request_accepts_missing_saved_at() { + let payload: RuntimeStoryStateResolveRequest = serde_json::from_value(json!({ + "sessionId": "runtime-main", + "clientVersion": 7, + "snapshot": { + "bottomTab": "adventure", + "gameState": { "runtimeSessionId": "runtime-main" }, + "currentStory": { "text": "营地里的火光还没有熄灭。" } + } + })) + .expect("payload should deserialize"); + + assert_eq!(payload.session_id, "runtime-main"); + assert_eq!(payload.client_version, Some(7)); + assert_eq!( + payload.snapshot.expect("snapshot should exist").saved_at, + None + ); + } + + #[test] + fn runtime_story_action_request_uses_camel_case_fields() { + let payload = serde_json::to_value(RuntimeStoryActionRequest { session_id: "runtime-main".to_string(), - client_version: Some(7), + client_version: Some(8), + action: RuntimeStoryChoiceAction { + action_type: "story_choice".to_string(), + function_id: "npc_chat".to_string(), + target_id: Some("npc_camp_firekeeper".to_string()), + payload: Some(json!({ "optionText": "继续交谈" })), + }, snapshot: Some(RuntimeStorySnapshotPayload { - saved_at: "2026-04-22T12:00:00.000Z".to_string(), + saved_at: Some("2026-04-22T12:00:00.000Z".to_string()), bottom_tab: "adventure".to_string(), game_state: json!({ "runtimeSessionId": "runtime-main" }), - current_story: Some(json!({ "text": "营地里的火光还没有熄灭。" })), + current_story: None, }), }) .expect("payload should serialize"); assert_eq!(payload["sessionId"], json!("runtime-main")); - assert_eq!(payload["clientVersion"], json!(7)); - assert_eq!(payload["snapshot"]["savedAt"], json!("2026-04-22T12:00:00.000Z")); - assert_eq!(payload["snapshot"]["bottomTab"], json!("adventure")); - assert_eq!(payload["snapshot"]["gameState"]["runtimeSessionId"], json!("runtime-main")); + assert_eq!(payload["clientVersion"], json!(8)); + assert_eq!(payload["action"]["type"], json!("story_choice")); + assert_eq!(payload["action"]["functionId"], json!("npc_chat")); assert_eq!( - payload["snapshot"]["currentStory"]["text"], - json!("营地里的火光还没有熄灭。") + payload["action"]["targetId"], + json!("npc_camp_firekeeper") ); + assert_eq!(payload["snapshot"]["savedAt"], json!("2026-04-22T12:00:00.000Z")); + } + + #[test] + fn runtime_story_ai_request_defaults_optional_arrays() { + let payload: RuntimeStoryAiRequest = serde_json::from_value(json!({ + "worldType": "martial", + "character": { "name": "林迟" }, + "context": { "scene": "camp" } + })) + .expect("payload should deserialize"); + + assert_eq!(payload.world_type, "martial"); + assert!(payload.monsters.is_empty()); + assert!(payload.history.is_empty()); + assert!(payload.request_options.available_options.is_empty()); } #[test] @@ -297,7 +406,7 @@ mod tests { current_npc_battle_outcome: None, }], snapshot: RuntimeStorySnapshotPayload { - saved_at: "2026-04-22T12:00:00.000Z".to_string(), + saved_at: Some("2026-04-22T12:00:00.000Z".to_string()), bottom_tab: "adventure".to_string(), game_state: json!({ "runtimeSessionId": "runtime-main" }), current_story: Some(json!({