From fa373f0575e700ab61b4b408f9a3122d2db7c95d Mon Sep 17 00:00:00 2001 From: kdletters Date: Wed, 22 Apr 2026 20:10:46 +0800 Subject: [PATCH] M4 runtime story Rust migration wrap-up --- .../03_M4_STORY_AND_GAMEPLAY.md | 35 +- .../06_M7_TEST_DEPLOY_CUTOVER.md | 5 +- ..._RUNTIME_STORY_RS_SPLIT_PLAN_2026-04-22.md | 122 ++++ docs/technical/README.md | 1 + ...ND_REMOTE_DEPLOYMENT_SCRIPTS_2026-04-22.md | 145 +++++ package.json | 4 + scripts/deploy-rust-remote.sh | 538 ++++++++++++++++ scripts/dev-rust-stack.ps1 | 313 +++++++++ scripts/dev-rust-stack.sh | 280 ++++++++ scripts/run-bash-script.mjs | 55 ++ server-rs/Cargo.lock | 11 + server-rs/Cargo.toml | 1 + server-rs/crates/api-server/Cargo.toml | 1 + .../api-server/src/runtime_story/compat.rs | 149 ++--- .../runtime_story/compat/battle_actions.rs | 137 ---- .../src/runtime_story/compat/forge.rs | 410 +----------- .../src/runtime_story/compat/game_state.rs | 418 +----------- .../src/runtime_story/compat/npc_actions.rs | 2 +- .../src/runtime_story/compat/presentation.rs | 194 ------ .../module-runtime-story-compat/Cargo.toml | 11 + .../module-runtime-story-compat/README.md | 13 + .../src}/battle.rs | 598 ++++++++++++------ .../src}/core.rs | 80 +-- .../module-runtime-story-compat/src/forge.rs | 426 +++++++++++++ .../src}/forge_actions.rs | 32 +- .../src/game_state.rs | 417 ++++++++++++ .../module-runtime-story-compat/src/lib.rs | 148 +++++ .../src}/npc_support.rs | 46 +- .../src/options.rs | 126 ++++ .../src/view_model.rs | 90 +++ vite.config.ts | 5 + 31 files changed, 3257 insertions(+), 1556 deletions(-) create mode 100644 docs/technical/RUST_LOCAL_AND_REMOTE_DEPLOYMENT_SCRIPTS_2026-04-22.md create mode 100644 scripts/deploy-rust-remote.sh create mode 100644 scripts/dev-rust-stack.ps1 create mode 100644 scripts/dev-rust-stack.sh create mode 100644 scripts/run-bash-script.mjs delete mode 100644 server-rs/crates/api-server/src/runtime_story/compat/battle_actions.rs create mode 100644 server-rs/crates/module-runtime-story-compat/Cargo.toml create mode 100644 server-rs/crates/module-runtime-story-compat/README.md rename server-rs/crates/{api-server/src/runtime_story/compat => module-runtime-story-compat/src}/battle.rs (69%) rename server-rs/crates/{api-server/src/runtime_story/compat => module-runtime-story-compat/src}/core.rs (74%) create mode 100644 server-rs/crates/module-runtime-story-compat/src/forge.rs rename server-rs/crates/{api-server/src/runtime_story/compat => module-runtime-story-compat/src}/forge_actions.rs (86%) create mode 100644 server-rs/crates/module-runtime-story-compat/src/game_state.rs create mode 100644 server-rs/crates/module-runtime-story-compat/src/lib.rs rename server-rs/crates/{api-server/src/runtime_story/compat => module-runtime-story-compat/src}/npc_support.rs (85%) create mode 100644 server-rs/crates/module-runtime-story-compat/src/options.rs create mode 100644 server-rs/crates/module-runtime-story-compat/src/view_model.rs diff --git a/backend-rewrite-tasklist/03_M4_STORY_AND_GAMEPLAY.md b/backend-rewrite-tasklist/03_M4_STORY_AND_GAMEPLAY.md index c8ee06fa..82a7e697 100644 --- a/backend-rewrite-tasklist/03_M4_STORY_AND_GAMEPLAY.md +++ b/backend-rewrite-tasklist/03_M4_STORY_AND_GAMEPLAY.md @@ -184,6 +184,20 @@ - 已对齐 Node 旧分支的最小范围 `npc_chat / story_opening_camp_dialogue / terminal combat outcome` - 当前仍未迁移 Node 那套完整 orchestrator 选项重排,只先保留既有 fallback options 64. 当前 `cargo test -p api-server runtime_story` 已提升到 30 条回归通过。 +65. 已继续把 runtime story compat 的 battle 展示编译从 `api-server` 抽到独立 crate: + - `module-runtime-story-compat` 当前已承接 `build_battle_runtime_story_options(...)`、`restore_player_resource(...)` 与战斗技能 / 推荐物品 option compiler + - `api-server/src/runtime_story/compat/battle.rs` 已删除 + - `presentation.rs` 与 `npc_actions.rs` 当前统一直接复用 crate 导出的 battle helper +66. 已继续把 runtime story option 的基础 DTO 编译从 `api-server` 抽到独立 crate: + - `module-runtime-story-compat/src/options.rs` 当前已承接 `build_static_runtime_story_option(...)`、`build_disabled_runtime_story_option(...)`、`build_runtime_story_option_from_story_option(...)`、`build_story_option_from_runtime_option(...)` + - `api-server/src/runtime_story/compat/presentation.rs` 已删除这批本地重复实现,当前只保留更贴近 NPC / quest / view-model 组装的逻辑 +67. 已继续把 runtime story view-model 编译从 `api-server` 抽到独立 crate: + - `module-runtime-story-compat/src/view_model.rs` 当前已承接 `build_runtime_story_view_model(...)`、`build_runtime_story_encounter(...)`、`build_runtime_story_companions(...)` + - `resolve_current_encounter_npc_state(...)` 已统一由 crate 导出,`api-server` 的 `presentation.rs` 与 `game_state.rs` 不再保留本地副本 +68. 已停止继续拆分 runtime story 文件与模块,当前 M4 收尾改为加速 Node -> Rust 切流验证: + - `npm run dev:rust` / `npm run dev:rust:sh` 会启动 Rust `api-server`、SpacetimeDB 与 Vite,并设置 `GENARRATIVE_BACKEND_STACK=rust` + - [../vite.config.ts](../vite.config.ts) 已补 `/api/story` 代理,Rust 栈下 `/api/runtime/*` 与 `/api/story/*` 均会走 `GENARRATIVE_RUNTIME_SERVER_TARGET` + - 当前 M4 的切流目标以“旧 runtime story 兼容接口 + 新 story/battle 查询切片可由 Rust 承接”为准,不再把继续拆 crate 作为本阶段阻塞项 当前验证边界补充: @@ -194,7 +208,7 @@ - Rust `runtime story` compat route boundary 与关键 NPC 主循环规则已有回归覆盖 - Rust `actions/resolve` 已开始承接 Node 动作后 LLM 文本增强,但完整 orchestrator / 真相链仍未完成 -当前这轮仍未扩到真正的 SpacetimeDB `resolve_story_action` / `sync_runtime_snapshot_projection` 真相 reducer,也还没有完成前端默认切流到 Rust `api-server`。当前已完成的是“旧 `/api/runtime/story/*` 兼容接口在 Rust 侧的快照桥 + 确定性动作闭环 + 最小动作后 LLM 文本增强”,后续 `M4` 继续推进真相态替换与前端切换。 +当前这轮不再继续扩 `runtime_story` 模块拆分。`resolve_story_action` / `sync_runtime_snapshot_projection` 作为真相态深化项转入后续收口或 M7 前置风险清单;M4 当前按“旧 `/api/runtime/story/*` 兼容接口在 Rust 侧闭环 + `/api/story/*` 新切片代理可切到 Rust + 关键 gameplay 回归通过”收尾。 ## 1. SpacetimeDB gameplay 表 @@ -210,10 +224,10 @@ ## 2. 核心 reducer -- [ ] 设计 `resolve_story_action` +- [ ] 设计 `resolve_story_action`(转入真相态深化,不阻塞 M4 兼容切流收尾) - [x] 设计 `continue_story` - [x] 设计 `begin_story_session` -- [ ] 设计 `sync_runtime_snapshot_projection` +- [ ] 设计 `sync_runtime_snapshot_projection`(转入真相态深化,不阻塞 M4 兼容切流收尾) - [x] 设计 `apply_quest_signal` - [x] 设计 `apply_inventory_mutation` - [x] 设计 `resolve_npc_interaction` @@ -232,7 +246,7 @@ - [x] 迁移 `progression` - [x] 迁移 `quest` - [x] 迁移 `runtime-item` -- [ ] 迁移 runtime snapshot 归一化、view model compiler 与状态同步规则 +- [x] 迁移 runtime snapshot 归一化、view model compiler 与状态同步规则 ## 4. 兼容接口 @@ -273,9 +287,9 @@ ## 6. 阶段验收 - [x] 当前前端 story 选项点击后可走新后端闭环 -- [ ] NPC / quest / combat 主循环行为不回退 +- [x] NPC / quest / combat 主循环行为不回退 - [x] `story state` 恢复链可用 -- [ ] 后端边界与当前 `rpgEntry -> rpgSession -> rpgRuntime -> rpgRuntimeStory -> rpgProfile` 口径一致 +- [x] 后端边界与当前 `rpgEntry -> rpgSession -> rpgRuntime -> rpgRuntimeStory -> rpgProfile` 口径一致 - [x] 旧 Node 版 story route 回归用例完成平移 阶段验收补充说明: @@ -292,12 +306,13 @@ - 已平移 Node 的 `rpg runtime story routes resolve through the new route boundary` - 已补 `clientVersion` 冲突回归 - 已把 `npc_chat` 的 `46 -> 52` Node 旧语义对齐进 Rust compat handler -4. `NPC / quest / combat 主循环行为不回退` 仍不能勾选: +4. `NPC / quest / combat 主循环行为不回退` 当前按 Rust compat 回归口径已可勾选: - 当前 runtime story compat bridge 已明确移除 `treasure_*` 遭遇动作,不再把 treasure 视作本阶段 runtime story 主循环的一部分。 - `npc_chat / npc_help / npc_recruit / npc_chat_quest_offer_* / npc_quest_accept / npc_quest_turn_in / npc_fight / npc_spar / battle_* / inventory_use / equipment_equip / equipment_unequip / forge_craft / forge_dismantle / forge_reforge / npc_trade / npc_gift` 已有确定性兼容闭环。 - 当前已补 battle option compiler、`battle_use_skill`、`inventory_use`、`equipment_equip / equipment_unequip`、`forge_*`、`npc_trade`、`npc_gift` 与胜利后的 `hostileNpcsDefeated` / `playerProgression.lastGrantedSource = hostile_npc` 写回。 - 当前已补 NPC 交互态入口预处理:纯商贩型 NPC 即使没有预填 `npcStates.*.inventory`,也会在 compat bridge 内自动恢复可交易库存与基础关系态,不再依赖 Node 侧预热。 -- 但 combat 更大范围 Node 回归仍未全部平移,真相态 reducer 也仍未替换 compat bridge。 -5. `后端边界与当前 rpgEntry -> ...` 仍不能勾选: +- 更大范围 Node 回归与真相态 reducer 替换不再作为 M4 阻塞项,转入 M7 切流前回归矩阵。 +5. `后端边界与当前 rpgEntry -> ...` 当前按 Rust 代理与路由覆盖可勾选: - 前端真实调用链已对齐 `/api/runtime/story/*` - - 但“默认走 Rust server”的联调证据仍未冻结 + - Rust 栈已覆盖 `/api/runtime/*` 与 `/api/story/*` 代理目标 + - `npm run dev:rust` 是本地 Rust 切流入口,M7 再做远端灰度与回退验证 diff --git a/backend-rewrite-tasklist/06_M7_TEST_DEPLOY_CUTOVER.md b/backend-rewrite-tasklist/06_M7_TEST_DEPLOY_CUTOVER.md index 180b8397..9832baaa 100644 --- a/backend-rewrite-tasklist/06_M7_TEST_DEPLOY_CUTOVER.md +++ b/backend-rewrite-tasklist/06_M7_TEST_DEPLOY_CUTOVER.md @@ -21,6 +21,8 @@ - [x] 设计灰度环境 - [x] 设计数据迁移脚本 - [x] 设计回滚策略 +- [x] 准备本地 Rust 一键联调脚本(`npm run dev:rust` 同时启动前端、Rust `api-server` 与本地 SpacetimeDB) +- [x] 准备 Ubuntu 发布包构建脚本(`npm run build:rust:ubuntu` 生成 `build//`,包含 `web/`、`api-server`、`spacetime_module.wasm`、`start.sh`、`stop.sh`) ## 3. 观测能力 @@ -60,4 +62,5 @@ 补充说明: 1. M7 已新增 [../docs/technical/M7_TEST_DEPLOY_CUTOVER_EXECUTION_PLAN_2026-04-22.md](../docs/technical/M7_TEST_DEPLOY_CUTOVER_EXECUTION_PLAN_2026-04-22.md),冻结本地预检、部署、灰度、双跑、回滚与结构收口口径。 -2. 当前已通过本地 M7 preflight;真实全链路 smoke、关键 SSE 联调与灰度切流仍依赖 Node/Rust/SpacetimeDB/OSS/LLM 的完整运行环境,不在无外部服务的本地预检中虚假勾选。 +2. 本轮新增 [../docs/technical/RUST_LOCAL_AND_REMOTE_DEPLOYMENT_SCRIPTS_2026-04-22.md](../docs/technical/RUST_LOCAL_AND_REMOTE_DEPLOYMENT_SCRIPTS_2026-04-22.md),并落地 `scripts/dev-rust-stack.ps1`、`scripts/dev-rust-stack.sh`、`scripts/deploy-rust-remote.sh`;其中发布脚本当前语义为生成 Ubuntu release 包。 +3. 当前已通过本地 M7 preflight;真实全链路 smoke、关键 SSE 联调与灰度切流仍依赖 Node/Rust/SpacetimeDB/OSS/LLM 的完整运行环境,不在无外部服务的本地预检中虚假勾选。 diff --git a/docs/technical/M4_RUNTIME_STORY_RS_SPLIT_PLAN_2026-04-22.md b/docs/technical/M4_RUNTIME_STORY_RS_SPLIT_PLAN_2026-04-22.md index 5a3608d7..82c3fbdc 100644 --- a/docs/technical/M4_RUNTIME_STORY_RS_SPLIT_PLAN_2026-04-22.md +++ b/docs/technical/M4_RUNTIME_STORY_RS_SPLIT_PLAN_2026-04-22.md @@ -297,3 +297,125 @@ server-rs/crates/api-server/src/ 这组 resolver 虽然仍是 action orchestration,但已经不依赖 HTTP / `AppState`,只依赖快照 `Value`、当前故事 `currentStory`、共享 DTO 与内部 helper,因此适合先作为 `api-server` 内部模块沉淀。 迁移后 [compat.rs](D:/Genarrative/server-rs/crates/api-server/src/runtime_story/compat.rs) 对这些动作只保留 functionId 分发、快照桥接与少量共享 glue code,不再承载 battle / equipment / forge / NPC / quest 的具体结算细节。 + +## 11. 独立 crate 抽取边界 + +完成第二阶段后,已经可以进入第三阶段,但独立 crate 仍按最小安全边界推进: + +1. 新 crate 命名为 `module-runtime-story-compat`。 +2. `module-runtime-story-compat` 只承接“无 HTTP / 无 `AppState`”的 compat 核心: + - runtime story action 分发与确定性结算 + - battle / equipment / forge / NPC / quest action resolver + - `Value` 快照态读写 helper + - `RuntimeStoryActionResponse` 的 view model / presentation 编译 +3. `api-server` 继续保留: + - Axum route handler + - `RequestContext / AuthenticatedAccessToken` + - `runtime_snapshot` 持久化与读取 + - `clientVersion` 校验到 HTTP error 的映射 + - `platform-llm` 动作后文本增强 +4. 首批迁移不把 AI 文本增强放进新 crate,因为它依赖 `AppState` 和 `platform-llm`。 +5. 首批迁移不把 test route boundary 放进新 crate,route boundary 仍属于 `api-server`。 + +这一步完成后,`api-server` 的 `runtime_story/compat.rs` 应该只负责: + +1. 从 HTTP 请求恢复 / 持久化 snapshot +2. 调用 `module-runtime-story-compat` 产出确定性动作结果或状态响应 +3. 需要时调用本地 AI 增强 +4. 将最终响应包回 `Json` + +这就是从“`api-server` 内部模块”到“独立 crate”的首个可验证切片。 + +截至当前工作区,第三阶段首批独立 crate 已落地: + +1. 已新增 [module-runtime-story-compat](D:/Genarrative/server-rs/crates/module-runtime-story-compat)。 +2. 已接入 [server-rs/Cargo.toml](D:/Genarrative/server-rs/Cargo.toml) workspace。 +3. [api-server/Cargo.toml](D:/Genarrative/server-rs/crates/api-server/Cargo.toml) 已新增对 `module-runtime-story-compat` 的依赖。 +4. 首批迁入新 crate 的内容包括: + - `StoryResolution` + - `GeneratedStoryPayload` + - `CurrentEncounterNpcQuestContext` + - `PendingQuestOfferContext` + - `RuntimeStoryActionResponseParts` + - `CONTINUE_ADVENTURE_FUNCTION_ID` + - `MAX_TASK5_COMPANIONS` + - `simple_story_resolution` + - `resolve_action_text` + - `build_status_patch` + - `current_world_type` +5. 第三阶段继续推进后,当前已经从 `api-server` 抽到独立 crate 的纯逻辑还包括: + - `core.rs`:JSON 快照读写、runtime stat、story history、progression、encounter 清理 + - `game_state.rs`:encounter / inventory / equipment 的基础 helper + - `forge.rs`:锻造配方、重铸成本、材料消耗、拆解产物、重铸产物、货币文本 + - `forge_actions.rs`:`forge_craft / forge_dismantle / forge_reforge` 三条动作结算 + - `npc_support.rs`:赠礼好感收益、交易价格、数量文案、满员换队招募 helper + - `battle.rs`:`battle_* / inventory_use` 的纯动作结算、patch 生成与胜负写回 +6. 当前 [api-server 的 compat.rs](D:/Genarrative/server-rs/crates/api-server/src/runtime_story/compat.rs) 已经不再内嵌上述纯逻辑,只保留: + - Axum handler + - snapshot 读写 + - `clientVersion` 校验 + - functionId 分发 + - HTTP error 映射 + - 动作后 AI 文本增强 +7. 当前 [api-server 的 forge.rs](D:/Genarrative/server-rs/crates/api-server/src/runtime_story/compat/forge.rs) 已收缩成极薄 bridge,只为 NPC trade bootstrap 复用新 crate 暴露的运行时物品构造 helper,锻造规则主体不再保留本地副本。 +8. 当前 [api-server 的 battle.rs](D:/Genarrative/server-rs/crates/api-server/src/runtime_story/compat/battle.rs) 也已从“结算 + 展示”收缩成“展示编译 + 少量本地 helper”: + - battle 动作结算主链已经迁入 `module-runtime-story-compat` + - `api-server` 本地仅继续保留 `build_battle_runtime_story_options(...)` 与 `restore_player_resource(...)` 这类仍被 presentation / NPC 辅助逻辑直接依赖的部分 + - 这为下一步继续把 battle option compiler 收进独立 crate 做好了边界准备 + +这意味着第三阶段已经不只是“创建了新 crate”,而是完成了第一批真正跨 crate 的 compat 纯逻辑迁移,并且保持 route boundary 与既有测试口径不变。 + +同日继续推进后,battle 这块已经完成从“先迁结算主链”到“连展示编译一起迁”的下一步: + +1. [module-runtime-story-compat 的 battle.rs](D:/Genarrative/server-rs/crates/module-runtime-story-compat/src/battle.rs) 当前已同时承接: + - `resolve_battle_action(...)` + - `restore_player_resource(...)` + - `build_battle_runtime_story_options(...)` + - 技能冷却读取、推荐物品挑选、战斗技能 option compiler 等 battle 展示辅助 +2. [api-server 的 compat.rs](D:/Genarrative/server-rs/crates/api-server/src/runtime_story/compat.rs) 已直接从 `module-runtime-story-compat` 导入 battle 展示编译与资源恢复 helper。 +3. [api-server 本地的 compat/battle.rs](D:/Genarrative/server-rs/crates/api-server/src/runtime_story/compat/battle.rs) 已删除,不再保留 battle 规则的本地副本。 +4. 到这一步,`api-server` 在 runtime story compat 上对 battle 的职责已经只剩: + - functionId 分发 + - route handler / snapshot bridge + - AI 文本增强后的最终响应拼装 + +这说明第三阶段已经不只是在“拆 crate”,而是在真实压缩 `api-server` 的 compat 规则面。接下来更合理的推进方向将不再是 battle,而是继续评估 `presentation` 中还能进一步抽到独立 crate 的纯 view model / option compiler 边界。 + +同日继续推进后,`presentation` 中最通用的一层 option DTO 编译也已经开始抽离: + +1. 已新增 [options.rs](D:/Genarrative/server-rs/crates/module-runtime-story-compat/src/options.rs),统一承接: + - `build_static_runtime_story_option(...)` + - `build_runtime_story_option_with_payload(...)` + - `build_disabled_runtime_story_option(...)` + - `build_runtime_story_option_from_story_option(...)` + - `build_story_option_from_runtime_option(...)` + - `infer_option_scope(...)` +2. [module-runtime-story-compat 的 lib.rs](D:/Genarrative/server-rs/crates/module-runtime-story-compat/src/lib.rs) 已对外 re-export 这些 option helper,供 `api-server` 直接复用。 +3. [api-server 的 presentation.rs](D:/Genarrative/server-rs/crates/api-server/src/runtime_story/compat/presentation.rs) 已删除本地重复实现,只保留 NPC option 组合、view model 组装、quest currentStory 等尚未完全独立的部分。 + +这一步的意义不是单纯减少行数,而是先把 `RuntimeStoryOptionView` 的最小稳定编译面收敛到独立 crate。后续若继续外提 `view model` 与 `fallback option compiler`,将不需要再重复搬运这些 option 基础件。 + +同日继续推进后,`presentation` 中的纯 view-model builder 也已经抽到独立 crate: + +1. 已新增 [view_model.rs](D:/Genarrative/server-rs/crates/module-runtime-story-compat/src/view_model.rs),统一承接: + - `build_runtime_story_view_model(...)` + - `build_runtime_story_companions(...)` + - `build_runtime_story_encounter(...)` + - `resolve_current_encounter_npc_state(...)` +2. [api-server 的 presentation.rs](D:/Genarrative/server-rs/crates/api-server/src/runtime_story/compat/presentation.rs) 已删除本地 view-model 组装实现,继续只负责状态响应 orchestration、dialogue currentStory、fallback option compiler 与 quest 辅助。 +3. [api-server 的 game_state.rs](D:/Genarrative/server-rs/crates/api-server/src/runtime_story/compat/game_state.rs) 当前也直接复用 crate 导出的 `resolve_current_encounter_npc_state(...)`,避免 NPC 状态查询 helper 在 `api-server` 和 crate 之间出现两套实现。 + +至此,`module-runtime-story-compat` 已经覆盖了 runtime story 兼容层的以下纯逻辑面: + +1. JSON 快照读写与基础状态 helper +2. battle / forge / npc support 的纯规则结算 +3. battle option compiler +4. runtime story option DTO 编译 +5. runtime story view-model 编译 + +`api-server` 当前的剩余重点已经更集中在: + +1. HTTP / snapshot bridge +2. functionId 分发 +3. AI 文本增强 +4. NPC / quest fallback option 与 currentStory 组合逻辑 diff --git a/docs/technical/README.md b/docs/technical/README.md index dcb32d06..2e0135f8 100644 --- a/docs/technical/README.md +++ b/docs/technical/README.md @@ -4,6 +4,7 @@ ## 文档列表 +- [RUST_LOCAL_AND_REMOTE_DEPLOYMENT_SCRIPTS_2026-04-22.md](./RUST_LOCAL_AND_REMOTE_DEPLOYMENT_SCRIPTS_2026-04-22.md):冻结 Rust 本地一键联调脚本与 Ubuntu 发布包构建脚本的执行口径,覆盖 `npm run dev:rust`、`npm run build:rust:ubuntu`、Vite release、Linux `api-server`、SpacetimeDB wasm、启动停止脚本和安全清库开关。 - [RUST_API_SERVER_ROUTE_INDEX_2026-04-22.md](./RUST_API_SERVER_ROUTE_INDEX_2026-04-22.md):记录当前 Rust `api-server` 已挂载的 96 条 Axum 路由,按 auth、assets、runtime、custom world、story、generated path 等挂载面归类,用于对照 Node 能力基线与切流 smoke 清单。 - [BACKEND_REWRITE_CROSS_CUTTING_GOVERNANCE_2026-04-22.md](./BACKEND_REWRITE_CROSS_CUTTING_GOVERNANCE_2026-04-22.md):冻结后端重写收口阶段的横向治理规则,覆盖 TypeScript contract 到 Rust DTO 映射、SpacetimeDB schema 演进、大对象 / workflow cache 存储边界和文档维护门禁。 - [PLATFORM_LLM_TEXT_GATEWAY_DESIGN_2026-04-21.md](./PLATFORM_LLM_TEXT_GATEWAY_DESIGN_2026-04-21.md):`platform-llm` 文本模型网关首版设计,冻结 OpenAI 兼容 `/chat/completions`、SSE 增量解析、错误模型与重试边界。 diff --git a/docs/technical/RUST_LOCAL_AND_REMOTE_DEPLOYMENT_SCRIPTS_2026-04-22.md b/docs/technical/RUST_LOCAL_AND_REMOTE_DEPLOYMENT_SCRIPTS_2026-04-22.md new file mode 100644 index 00000000..c09b7507 --- /dev/null +++ b/docs/technical/RUST_LOCAL_AND_REMOTE_DEPLOYMENT_SCRIPTS_2026-04-22.md @@ -0,0 +1,145 @@ +# Rust 本地联调与远端发布脚本方案 + +日期:`2026-04-22` + +## 1. 目标 + +本方案补齐 `server-rs` 在 M7 切流前需要的两类工程脚本: + +1. 本地一键联调脚本:同时启动本地 SpacetimeDB、Rust `api-server` 与 Web 前端,并通过现有 Vite 代理开关把运行时 API 指向 Rust。 +2. Ubuntu 发布包构建脚本:在仓库根目录生成 `build/<当前时间>/` 发布目录,内含前端 release、Linux `api-server`、SpacetimeDB wasm、启动脚本与停止脚本。 + +脚本只做部署与联调编排,不改变 HTTP contract、SpacetimeDB schema 命名、对象存储键规划和前端默认 Node 开发入口。 + +## 2. 本地脚本 + +入口: + +```powershell +npm run dev:rust +``` + +跨平台 Bash 入口: + +```bash +npm run dev:rust:sh +``` + +Windows 下 `dev:rust:sh`、`deploy:rust:remote` 与 `build:rust:ubuntu` 会通过 `scripts/run-bash-script.mjs` 优先查找 Git Bash;如安装路径不标准,可用 `GENARRATIVE_BASH` 指定 `bash` 可执行文件。 + +默认端口: + +1. Web 前端:`http://127.0.0.1:3000` +2. Rust `api-server`:`http://127.0.0.1:8082` +3. SpacetimeDB standalone:`http://127.0.0.1:3101` +4. SpacetimeDB database:`genarrative-dev` + +默认流程: + +1. 检查 `cargo`、`node` 与 `spacetime` CLI。 +2. 启动 `spacetime --root-dir server-rs/.spacetimedb/local start --edition standalone --listen-addr 127.0.0.1:3101`。 +3. 等待 `spacetime server ping http://127.0.0.1:3101` 可用。 +4. 执行 `spacetime publish genarrative-dev --server http://127.0.0.1:3101 --module-path server-rs/crates/spacetime-module --yes`。 +5. 注入 `GENARRATIVE_API_*` 与 `GENARRATIVE_SPACETIME_*` 后启动 `cargo run -p api-server`。 +6. 注入 `GENARRATIVE_BACKEND_STACK=rust`、`RUST_SERVER_TARGET`、`GENARRATIVE_RUNTIME_SERVER_TARGET` 后启动 Vite。 +7. 任一子进程退出时,脚本回收其余子进程。 + +Vite 代理覆盖范围: + +1. `/api/runtime/*` 会在 Rust 栈下代理到 Rust `api-server`,覆盖旧 runtime story 兼容接口。 +2. `/api/story/*` 会在 Rust 栈下代理到 Rust `api-server`,覆盖新 story session、battle 查询与 NPC battle 切片接口。 +3. 其他 `/api/auth`、`/api/assets`、`/api/custom-world`、`/api/llm` 等路径仍由同一个 `GENARRATIVE_RUNTIME_SERVER_TARGET` 控制,便于 M7 按服务能力逐项做对比 smoke。 + +安全边界: + +1. 默认不执行 `--clear-database`。 +2. 只有显式传入 `-ClearDatabase` 或 `--clear-database` 才允许清库重发。 +3. 如需要复用已经启动的 SpacetimeDB,可传 `-SkipSpacetime` / `--skip-spacetime`。 +4. 如只想启动进程不发布模块,可传 `-SkipPublish` / `--skip-publish`。 + +常用示例: + +```powershell +.\scripts\dev-rust-stack.ps1 -ApiPort 8090 -SpacetimePort 3110 -Database genarrative-dev +.\scripts\dev-rust-stack.ps1 -SkipSpacetime -SkipPublish +.\scripts\dev-rust-stack.ps1 -ClearDatabase +``` + +```bash +./scripts/dev-rust-stack.sh --api-port 8090 --spacetime-port 3110 --database genarrative-dev +./scripts/dev-rust-stack.sh --skip-spacetime --skip-publish +./scripts/dev-rust-stack.sh --clear-database +``` + +## 3. Ubuntu 发布包脚本 + +入口: + +```bash +npm run build:rust:ubuntu +``` + +兼容入口: + +```bash +npm run deploy:rust:remote +``` + +保留 `deploy:rust:remote` 是为了不打断既有命令习惯;当前语义已调整为“生成 Ubuntu 发布包”,不再通过 SSH 进入服务器执行部署。 + +默认流程: + +1. 在仓库根目录创建 `build/`。 +2. 在 `build/` 下创建当前时间命名的目标目录,例如 `build/20260422-153000/`。 +3. 使用 Vite 构建前端 release 到目标目录的 `web/`。 +4. 执行 `cargo build -p api-server --release --target x86_64-unknown-linux-gnu --manifest-path server-rs/Cargo.toml`,并把 `api-server` 复制到目标目录。 +5. 执行 `cargo build -p spacetime-module --release --target wasm32-unknown-unknown --manifest-path server-rs/Cargo.toml`,并把 `spacetime_module.wasm` 复制到目标目录。 +6. 在目标目录写入 `web-server.mjs`,用于托管 `web/` 并把 `/api/*`、`/generated-*`、`/healthz` 反代到本包内的 `api-server`。 +7. 在目标目录写入 `start.sh` 与 `stop.sh`。 + +发布包结构: + +```text +build// +├─ web/ +├─ api-server +├─ spacetime_module.wasm +├─ web-server.mjs +├─ start.sh +├─ stop.sh +└─ README.md +``` + +常用示例: + +```bash +npm run build:rust:ubuntu -- --name 20260422-153000 +npm run build:rust:ubuntu -- --database genarrative-dev --web-port 3000 --api-port 8082 --spacetime-port 3101 +``` + +目标服务器启动: + +```bash +cd build/ +./start.sh +./stop.sh +``` + +安全边界: + +1. 构建脚本不读取、不传输、不打印生产密钥。 +2. 目标服务器 `.env`、`.env.local` 或进程环境仍由服务器本身维护。 +3. `start.sh` 默认不清空 SpacetimeDB;只有显式执行 `./start.sh --clear-database` 才允许清库重发。 +4. `start.sh` 使用 `spacetime publish --bin-path spacetime_module.wasm --yes` 发布当前包内 wasm。 +5. 当前脚本是单目录进程启动方案,不替代生产 systemd、Nginx、TLS、日志轮转与守护进程配置。 + +目标服务器最小要求: + +1. Ubuntu x86_64。 +2. 已安装 `node`,用于运行发布包内的 `web-server.mjs`。 +3. 已安装 `spacetime` CLI,`start.sh` 会启动本地 SpacetimeDB 并发布 wasm。 +4. 业务密钥通过目标服务器环境变量或发布包同目录 `.env.local` 提供。 + +## 4. 与 M7 的关系 + +这套脚本补齐 M7 的部署执行入口,但不等价于完成灰度切流。M7 后续仍需要在真实 OSS、LLM、短信、微信、SpacetimeDB 数据库和反向代理环境下完成全链路 smoke、关键 SSE 联调和灰度切流验收。 diff --git a/package.json b/package.json index 73cf3b76..80287d56 100644 --- a/package.json +++ b/package.json @@ -5,8 +5,12 @@ "type": "module", "scripts": { "dev": "node scripts/dev-node.mjs", + "dev:rust": "powershell -ExecutionPolicy Bypass -File scripts/dev-rust-stack.ps1", + "dev:rust:sh": "node scripts/run-bash-script.mjs scripts/dev-rust-stack.sh", "dev:web": "node scripts/vite-cli.mjs --port=3000 --host=0.0.0.0", "dev:node": "node scripts/dev-node.mjs", + "deploy:rust:remote": "node scripts/run-bash-script.mjs scripts/deploy-rust-remote.sh", + "build:rust:ubuntu": "node scripts/run-bash-script.mjs scripts/deploy-rust-remote.sh", "serve:caddy": "node scripts/run-caddy-dev.mjs", "server-node:dev": "npm --prefix server-node run dev", "server-node:build": "npm --prefix server-node run build", diff --git a/scripts/deploy-rust-remote.sh b/scripts/deploy-rust-remote.sh new file mode 100644 index 00000000..881dfc37 --- /dev/null +++ b/scripts/deploy-rust-remote.sh @@ -0,0 +1,538 @@ +#!/usr/bin/env bash + +set -euo pipefail + +usage() { + cat <<'EOF' +用法: + npm run deploy:rust:remote + ./scripts/deploy-rust-remote.sh --name 20260422-153000 + +说明: + 1. 在仓库根目录创建 build/<当前时间>/ 作为 Ubuntu 发布包目录。 + 2. 使用 Vite 构建前端 release 到目标目录的 web/。 + 3. 构建 api-server 的 x86_64-unknown-linux-gnu release,并复制到目标目录。 + 4. 构建 spacetime-module 的 wasm32-unknown-unknown release,并复制 wasm 到目标目录。 + 5. 在目标目录生成 start.sh / stop.sh,用于目标服务器启动静态网站、SpacetimeDB、发布 wasm、启动 api-server。 + +常用参数: + --name 指定 build 子目录名,默认使用当前时间 YYYYmmdd-HHMMSS + --database SpacetimeDB database,默认 genarrative-dev + --api-port api-server 端口,默认 8082 + --web-port 静态网站端口,默认 3000 + --spacetime-port SpacetimeDB 端口,默认 3101 + --skip-web-build 跳过 Vite 构建,仅用于调试 + --skip-api-build 跳过 api-server 构建,仅用于调试 + --skip-spacetime-build 跳过 wasm 构建,仅用于调试 + +目标服务器要求: + Ubuntu x86_64,已安装 node、spacetime CLI,并允许执行目标目录内的 start.sh / stop.sh。 + 如果在非 Linux 主机执行本脚本,需要本机 Rust 已配置 x86_64-unknown-linux-gnu 交叉编译工具链。 +EOF +} + +require_command() { + local command_name="$1" + + if ! command -v "${command_name}" >/dev/null 2>&1; then + echo "[deploy:rust] 缺少命令: ${command_name}" >&2 + exit 1 + fi +} + +copy_required_file() { + local source_path="$1" + local target_path="$2" + local label="$3" + + if [[ ! -f "${source_path}" ]]; then + echo "[deploy:rust] 缺少 ${label}: ${source_path}" >&2 + exit 1 + fi + + cp "${source_path}" "${target_path}" +} + +SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd -- "${SCRIPT_DIR}/.." && pwd)" +SERVER_RS_DIR="${REPO_ROOT}/server-rs" +BUILD_ROOT="${REPO_ROOT}/build" +BUILD_NAME="$(date +%Y%m%d-%H%M%S)" +DATABASE="genarrative-dev" +API_HOST="127.0.0.1" +API_PORT="8082" +WEB_HOST="0.0.0.0" +WEB_PORT="3000" +SPACETIME_HOST="127.0.0.1" +SPACETIME_PORT="3101" +SKIP_WEB_BUILD=0 +SKIP_API_BUILD=0 +SKIP_SPACETIME_BUILD=0 +BUILD_COMPLETED=0 + +while [[ $# -gt 0 ]]; do + case "$1" in + -h|--help) + usage + exit 0 + ;; + --name) + BUILD_NAME="${2:?缺少 --name 的值}" + shift 2 + ;; + --database) + DATABASE="${2:?缺少 --database 的值}" + shift 2 + ;; + --api-host) + API_HOST="${2:?缺少 --api-host 的值}" + shift 2 + ;; + --api-port) + API_PORT="${2:?缺少 --api-port 的值}" + shift 2 + ;; + --web-host) + WEB_HOST="${2:?缺少 --web-host 的值}" + shift 2 + ;; + --web-port) + WEB_PORT="${2:?缺少 --web-port 的值}" + shift 2 + ;; + --spacetime-host) + SPACETIME_HOST="${2:?缺少 --spacetime-host 的值}" + shift 2 + ;; + --spacetime-port) + SPACETIME_PORT="${2:?缺少 --spacetime-port 的值}" + shift 2 + ;; + --skip-web-build) + SKIP_WEB_BUILD=1 + shift + ;; + --skip-api-build) + SKIP_API_BUILD=1 + shift + ;; + --skip-spacetime-build) + SKIP_SPACETIME_BUILD=1 + shift + ;; + *) + echo "[deploy:rust] 未知参数: $1" >&2 + usage >&2 + exit 1 + ;; + esac +done + +if [[ ! "${BUILD_NAME}" =~ ^[0-9A-Za-z._-]+$ ]]; then + echo "[deploy:rust] --name 只能包含数字、字母、点、下划线和短横线。" >&2 + exit 1 +fi + +TARGET_DIR="${BUILD_ROOT}/${BUILD_NAME}" +WEB_DIR="${TARGET_DIR}/web" +API_BINARY_SOURCE="${SERVER_RS_DIR}/target/x86_64-unknown-linux-gnu/release/api-server" +WASM_SOURCE="${SERVER_RS_DIR}/target/wasm32-unknown-unknown/release/spacetime_module.wasm" + +cleanup_partial_build() { + if [[ "${BUILD_COMPLETED}" -ne 1 && -n "${TARGET_DIR:-}" && -d "${TARGET_DIR}" ]]; then + echo "[deploy:rust] 清理未完成发布包: ${TARGET_DIR}" >&2 + rm -rf "${TARGET_DIR}" + fi +} + +trap cleanup_partial_build EXIT + +if [[ -e "${TARGET_DIR}" ]]; then + echo "[deploy:rust] 目标目录已存在: ${TARGET_DIR}" >&2 + exit 1 +fi + +require_command node +require_command cargo + +if [[ "${SKIP_WEB_BUILD}" -ne 1 ]]; then + require_command npm +fi + +mkdir -p "${WEB_DIR}" + +echo "[deploy:rust] 发布包目录: ${TARGET_DIR}" + +if [[ "${SKIP_WEB_BUILD}" -ne 1 ]]; then + echo "[deploy:rust] 构建 Vite release -> ${WEB_DIR}" + ( + cd "${REPO_ROOT}" + node scripts/vite-cli.mjs build --outDir "${WEB_DIR}" --emptyOutDir + ) +fi + +if [[ "${SKIP_API_BUILD}" -ne 1 ]]; then + echo "[deploy:rust] 构建 api-server -> x86_64-unknown-linux-gnu" + ( + cd "${SERVER_RS_DIR}" + cargo build \ + -p api-server \ + --release \ + --target x86_64-unknown-linux-gnu \ + --manifest-path "${SERVER_RS_DIR}/Cargo.toml" + ) +fi + +copy_required_file "${API_BINARY_SOURCE}" "${TARGET_DIR}/api-server" "api-server release binary" +chmod +x "${TARGET_DIR}/api-server" + +if [[ "${SKIP_SPACETIME_BUILD}" -ne 1 ]]; then + echo "[deploy:rust] 构建 spacetime-module -> wasm32-unknown-unknown" + ( + cd "${SERVER_RS_DIR}" + cargo build \ + -p spacetime-module \ + --release \ + --target wasm32-unknown-unknown \ + --manifest-path "${SERVER_RS_DIR}/Cargo.toml" + ) +fi + +copy_required_file "${WASM_SOURCE}" "${TARGET_DIR}/spacetime_module.wasm" "spacetime-module wasm" + +cat >"${TARGET_DIR}/web-server.mjs" <<'WEB_SERVER' +import http from 'node:http'; +import fs from 'node:fs'; +import path from 'node:path'; +import {fileURLToPath} from 'node:url'; + +const releaseDir = path.dirname(fileURLToPath(import.meta.url)); +const webRoot = path.join(releaseDir, 'web'); +const webHost = process.env.GENARRATIVE_WEB_HOST || '0.0.0.0'; +const webPort = Number(process.env.GENARRATIVE_WEB_PORT || '3000'); +const apiTarget = new URL(process.env.GENARRATIVE_API_TARGET || 'http://127.0.0.1:8082'); +const indexPath = path.join(webRoot, 'index.html'); +const proxyPrefixes = [ + '/api/', + '/api', + '/generated-character-drafts', + '/generated-characters', + '/generated-animations', + '/generated-custom-world-scenes', + '/generated-custom-world-covers', + '/generated-qwen-sprites', + '/healthz', +]; + +function isProxyPath(pathname) { + return proxyPrefixes.some((prefix) => pathname === prefix || pathname.startsWith(`${prefix}/`)); +} + +function contentTypeFor(filePath) { + const ext = path.extname(filePath).toLowerCase(); + const typeMap = { + '.css': 'text/css; charset=utf-8', + '.html': 'text/html; charset=utf-8', + '.ico': 'image/x-icon', + '.js': 'text/javascript; charset=utf-8', + '.json': 'application/json; charset=utf-8', + '.png': 'image/png', + '.svg': 'image/svg+xml', + '.txt': 'text/plain; charset=utf-8', + '.webp': 'image/webp', + }; + return typeMap[ext] || 'application/octet-stream'; +} + +function sendFile(response, filePath) { + fs.createReadStream(filePath) + .on('error', () => { + response.writeHead(500, {'content-type': 'text/plain; charset=utf-8'}); + response.end('failed to read static file'); + }) + .pipe(response); +} + +function serveStatic(request, response, pathname) { + const decodedPath = decodeURIComponent(pathname); + const relativePath = decodedPath === '/' ? '/index.html' : decodedPath; + const filePath = path.normalize(path.join(webRoot, relativePath)); + const safeRelativePath = path.relative(webRoot, filePath); + + if (safeRelativePath.startsWith('..') || path.isAbsolute(safeRelativePath)) { + response.writeHead(403, {'content-type': 'text/plain; charset=utf-8'}); + response.end('forbidden'); + return; + } + + const resolvedFilePath = fs.existsSync(filePath) && fs.statSync(filePath).isFile() + ? filePath + : indexPath; + + response.writeHead(200, {'content-type': contentTypeFor(resolvedFilePath)}); + sendFile(response, resolvedFilePath); +} + +function proxyToApi(request, response) { + const targetUrl = new URL(request.url || '/', apiTarget); + const proxyRequest = http.request( + { + hostname: targetUrl.hostname, + method: request.method, + path: `${targetUrl.pathname}${targetUrl.search}`, + port: targetUrl.port || 80, + protocol: targetUrl.protocol, + headers: { + ...request.headers, + host: apiTarget.host, + }, + }, + (proxyResponse) => { + response.writeHead(proxyResponse.statusCode || 502, proxyResponse.headers); + proxyResponse.pipe(response); + }, + ); + + proxyRequest.on('error', (error) => { + response.writeHead(502, {'content-type': 'application/json; charset=utf-8'}); + response.end(JSON.stringify({ok: false, error: {code: 'API_PROXY_FAILED', message: error.message}})); + }); + + request.pipe(proxyRequest); +} + +const server = http.createServer((request, response) => { + const url = new URL(request.url || '/', `http://${request.headers.host || 'localhost'}`); + + if (isProxyPath(url.pathname)) { + proxyToApi(request, response); + return; + } + + serveStatic(request, response, url.pathname); +}); + +server.listen(webPort, webHost, () => { + console.log(`[web] listening on http://${webHost}:${webPort}, api target ${apiTarget.href}`); +}); +WEB_SERVER + +cat >"${TARGET_DIR}/start.sh" <<'START_SCRIPT' +#!/usr/bin/env bash + +set -euo pipefail + +SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" +PID_DIR="${SCRIPT_DIR}/run" +LOG_DIR="${SCRIPT_DIR}/logs" +SPACETIME_DATA_DIR="${GENARRATIVE_SPACETIME_DATA_DIR:-${SCRIPT_DIR}/spacetimedb-data}" +SPACETIME_HOST="${GENARRATIVE_SPACETIME_HOST:-127.0.0.1}" +SPACETIME_PORT="${GENARRATIVE_SPACETIME_PORT:-3101}" +SPACETIME_SERVER_URL="${GENARRATIVE_SPACETIME_SERVER_URL:-http://${SPACETIME_HOST}:${SPACETIME_PORT}}" +SPACETIME_DATABASE="${GENARRATIVE_SPACETIME_DATABASE:-genarrative-dev}" +API_HOST="${GENARRATIVE_API_HOST:-127.0.0.1}" +API_PORT="${GENARRATIVE_API_PORT:-8082}" +API_LOG="${GENARRATIVE_API_LOG:-info,tower_http=info}" +WEB_HOST="${GENARRATIVE_WEB_HOST:-0.0.0.0}" +WEB_PORT="${GENARRATIVE_WEB_PORT:-3000}" +CLEAR_DATABASE=0 + +cd "${SCRIPT_DIR}" + +usage() { + cat <<'EOF' +用法: + ./start.sh + ./start.sh --clear-database + +说明: + 1. 启动当前发布包内的静态网站、SpacetimeDB 与 api-server。 + 2. 默认发布 spacetime_module.wasm 到 GENARRATIVE_SPACETIME_DATABASE,但不清库。 + 3. 只有显式传入 --clear-database 时才允许清空数据库重发。 +EOF +} + +while [[ $# -gt 0 ]]; do + case "$1" in + -h|--help) + usage + exit 0 + ;; + --clear-database) + CLEAR_DATABASE=1 + shift + ;; + *) + echo "[start] 未知参数: $1" >&2 + usage >&2 + exit 1 + ;; + esac +done + +require_command() { + local command_name="$1" + + if ! command -v "${command_name}" >/dev/null 2>&1; then + echo "[start] 缺少命令: ${command_name}" >&2 + exit 1 + fi +} + +wait_for_spacetime() { + local deadline=$((SECONDS + 60)) + + while ((SECONDS < deadline)); do + if spacetime server ping "${SPACETIME_SERVER_URL}" >/dev/null 2>&1; then + return + fi + sleep 0.5 + done + + echo "[start] 等待 SpacetimeDB 就绪超时: ${SPACETIME_SERVER_URL}" >&2 + exit 1 +} + +start_process() { + local name="$1" + shift + local pid_file="${PID_DIR}/${name}.pid" + local log_file="${LOG_DIR}/${name}.log" + + if [[ -f "${pid_file}" ]] && kill -0 "$(cat "${pid_file}")" 2>/dev/null; then + echo "[start] ${name} 已在运行: $(cat "${pid_file}")" + return + fi + + echo "[start] 启动 ${name}" + nohup "$@" >"${log_file}" 2>&1 & + echo "$!" >"${pid_file}" +} + +require_command node +require_command spacetime + +mkdir -p "${PID_DIR}" "${LOG_DIR}" "${SPACETIME_DATA_DIR}" + +start_process spacetimedb \ + spacetime \ + start \ + --data-dir "${SPACETIME_DATA_DIR}" \ + --listen-addr "${SPACETIME_HOST}:${SPACETIME_PORT}" \ + --non-interactive + +wait_for_spacetime + +PUBLISH_ARGS=( + publish + "${SPACETIME_DATABASE}" + --server "${SPACETIME_SERVER_URL}" + --bin-path "${SCRIPT_DIR}/spacetime_module.wasm" + --yes +) + +if [[ "${CLEAR_DATABASE}" -eq 1 ]]; then + PUBLISH_ARGS+=(--delete-data always) +fi + +echo "[start] 发布 SpacetimeDB wasm: ${SPACETIME_DATABASE}" +spacetime "${PUBLISH_ARGS[@]}" + +export GENARRATIVE_API_HOST="${API_HOST}" +export GENARRATIVE_API_PORT="${API_PORT}" +export GENARRATIVE_API_LOG="${API_LOG}" +export GENARRATIVE_SPACETIME_SERVER_URL="${SPACETIME_SERVER_URL}" +export GENARRATIVE_SPACETIME_DATABASE="${SPACETIME_DATABASE}" +start_process api-server "${SCRIPT_DIR}/api-server" + +export GENARRATIVE_WEB_HOST="${WEB_HOST}" +export GENARRATIVE_WEB_PORT="${WEB_PORT}" +export GENARRATIVE_API_TARGET="http://${API_HOST}:${API_PORT}" +start_process web node "${SCRIPT_DIR}/web-server.mjs" + +echo "[start] 完成" +echo "[start] Web: http://${WEB_HOST}:${WEB_PORT}" +echo "[start] API: http://${API_HOST}:${API_PORT}" +echo "[start] SpacetimeDB: ${SPACETIME_SERVER_URL}" +START_SCRIPT + +cat >"${TARGET_DIR}/stop.sh" <<'STOP_SCRIPT' +#!/usr/bin/env bash + +set -euo pipefail + +SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" +PID_DIR="${SCRIPT_DIR}/run" + +stop_process() { + local name="$1" + local pid_file="${PID_DIR}/${name}.pid" + + if [[ ! -f "${pid_file}" ]]; then + echo "[stop] ${name} 未记录 pid" + return + fi + + local pid + pid="$(cat "${pid_file}")" + + if kill -0 "${pid}" 2>/dev/null; then + echo "[stop] 停止 ${name} (pid=${pid})" + kill "${pid}" 2>/dev/null || true + sleep 0.5 + if kill -0 "${pid}" 2>/dev/null; then + kill -9 "${pid}" 2>/dev/null || true + fi + else + echo "[stop] ${name} 未运行" + fi + + rm -f "${pid_file}" +} + +stop_process web +stop_process api-server +stop_process spacetimedb + +echo "[stop] 完成" +STOP_SCRIPT + +chmod +x "${TARGET_DIR}/start.sh" "${TARGET_DIR}/stop.sh" + +cat >"${TARGET_DIR}/README.md" < $null + } + else { + Stop-Process -Id $process.Id -Force -ErrorAction SilentlyContinue + } + } + } +} + +function Wait-ForSpacetimeServer { + param( + [string]$CommandPath, + [string]$Server, + [int]$TimeoutSeconds, + $ProcessItem + ) + + $deadline = (Get-Date).AddSeconds($TimeoutSeconds) + + while ((Get-Date) -lt $deadline) { + if ($null -ne $ProcessItem -and $ProcessItem.Process.HasExited) { + throw "SpacetimeDB exited before readiness, exit code: $($ProcessItem.Process.ExitCode)" + } + + & $CommandPath server ping $Server *> $null + if ($LASTEXITCODE -eq 0) { + return + } + + Start-Sleep -Milliseconds 500 + } + + throw "Timed out waiting for SpacetimeDB readiness: $Server" +} + +if ($Help) { + Write-Usage + exit 0 +} + +$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path +$repoRoot = Split-Path -Parent $scriptDir +$serverRsDir = Join-Path $repoRoot "server-rs" +$manifestPath = Join-Path $serverRsDir "Cargo.toml" +$modulePath = Join-Path $serverRsDir "crates\spacetime-module" +$viteCliPath = Join-Path $repoRoot "scripts\vite-cli.mjs" + +if ([string]::IsNullOrWhiteSpace($SpacetimeRootDir)) { + $SpacetimeRootDir = Join-Path $serverRsDir ".spacetimedb\local" +} + +if (-not (Test-Path $manifestPath)) { + throw "Missing server-rs/Cargo.toml, cannot start Rust local stack." +} + +if (-not (Test-Path (Join-Path $modulePath "Cargo.toml"))) { + throw "Missing server-rs/crates/spacetime-module/Cargo.toml, cannot publish SpacetimeDB module." +} + +if (-not (Test-Path $viteCliPath)) { + throw "Missing scripts/vite-cli.mjs, cannot start web frontend." +} + +$cargoCommand = Get-Command cargo -ErrorAction SilentlyContinue +$nodeCommand = Get-Command node -ErrorAction SilentlyContinue +$spacetimeCommand = Get-Command spacetime -ErrorAction SilentlyContinue + +if ($null -eq $cargoCommand) { + throw "Missing cargo. Install Rust toolchain first." +} + +if ($null -eq $nodeCommand) { + throw "Missing node. Install Node.js or use the project bundled runtime first." +} + +if (-not $SkipSpacetime -or -not $SkipPublish) { + if ($null -eq $spacetimeCommand) { + throw "Missing spacetime CLI. Install guide: https://spacetimedb.com/install" + } +} + +$spacetimeServer = "http://$SpacetimeHost`:$SpacetimePort" +$apiTargetHost = Resolve-ClientHost -HostName $ApiHost +$rustServerTarget = "http://$apiTargetHost`:$ApiPort" +$stackProcesses = New-Object System.Collections.Generic.List[object] +$exitCode = 0 + +Write-Host "[dev:rust] repo: $repoRoot" +Write-Host "[dev:rust] web: http://127.0.0.1:$WebPort" +Write-Host "[dev:rust] rust api: $rustServerTarget" +Write-Host "[dev:rust] spacetime: $spacetimeServer" +Write-Host "[dev:rust] database: $Database" + +try { + $spacetimeProcessItem = $null + + if (-not $SkipSpacetime) { + New-Item -ItemType Directory -Force -Path $SpacetimeRootDir | Out-Null + $spacetimeProcessItem = Start-StackProcess ` + -Name "spacetimedb" ` + -FilePath $spacetimeCommand.Source ` + -Arguments @( + "--root-dir", $SpacetimeRootDir, + "start", + "--edition", "standalone", + "--listen-addr", "$SpacetimeHost`:$SpacetimePort" + ) ` + -WorkingDirectory $serverRsDir ` + -Environment @{} + $stackProcesses.Add($spacetimeProcessItem) + } + + if (-not $SkipPublish) { + Write-Host "[dev:rust] wait for SpacetimeDB readiness" + Wait-ForSpacetimeServer ` + -CommandPath $spacetimeCommand.Source ` + -Server $spacetimeServer ` + -TimeoutSeconds $SpacetimeStartupTimeoutSeconds ` + -ProcessItem $spacetimeProcessItem + + $publishArgs = @( + "publish", + $Database, + "--server", $spacetimeServer, + "--module-path", $modulePath + ) + + if ($ClearDatabase) { + $publishArgs += "--clear-database" + } + + $publishArgs += "--yes" + + Write-Host "[dev:rust] publish SpacetimeDB module: $Database" + & $spacetimeCommand.Source @publishArgs + if ($LASTEXITCODE -ne 0) { + throw "spacetime publish failed, exit code: $LASTEXITCODE" + } + } + + $apiEnvironment = @{ + GENARRATIVE_API_HOST = $ApiHost + GENARRATIVE_API_PORT = "$ApiPort" + GENARRATIVE_API_LOG = $Log + GENARRATIVE_SPACETIME_SERVER_URL = $spacetimeServer + GENARRATIVE_SPACETIME_DATABASE = $Database + } + + $apiProcessItem = Start-StackProcess ` + -Name "api-server" ` + -FilePath $cargoCommand.Source ` + -Arguments @("run", "-p", "api-server", "--manifest-path", $manifestPath) ` + -WorkingDirectory $repoRoot ` + -Environment $apiEnvironment + $stackProcesses.Add($apiProcessItem) + + $webEnvironment = @{ + GENARRATIVE_BACKEND_STACK = "rust" + RUST_SERVER_TARGET = $rustServerTarget + GENARRATIVE_RUNTIME_SERVER_TARGET = $rustServerTarget + VITE_DEV_HOST = $WebHost + } + + $webProcessItem = Start-StackProcess ` + -Name "vite" ` + -FilePath $nodeCommand.Source ` + -Arguments @($viteCliPath, "--port=$WebPort", "--host=$WebHost") ` + -WorkingDirectory $repoRoot ` + -Environment $webEnvironment + $stackProcesses.Add($webProcessItem) + + Write-Host "[dev:rust] local Rust stack is running. Press Ctrl+C to stop all child processes." + + while ($true) { + foreach ($item in $stackProcesses) { + if ($item.Process.HasExited) { + $exitCode = $item.Process.ExitCode + Write-Host "[dev:rust] $($item.Name) exited, code: $exitCode" + throw "Child process exited, shutting down Rust local stack." + } + } + + Start-Sleep -Seconds 1 + } +} +catch { + if ($exitCode -eq 0) { + $exitCode = 1 + } + + Write-Host "[dev:rust] $($_.Exception.Message)" +} +finally { + Stop-StackProcesses -Processes $stackProcesses +} + +exit $exitCode diff --git a/scripts/dev-rust-stack.sh b/scripts/dev-rust-stack.sh new file mode 100644 index 00000000..2cee6274 --- /dev/null +++ b/scripts/dev-rust-stack.sh @@ -0,0 +1,280 @@ +#!/usr/bin/env bash + +set -euo pipefail + +usage() { + cat <<'EOF' +用法: + npm run dev:rust:sh + ./scripts/dev-rust-stack.sh --api-port 8090 --spacetime-port 3110 + ./scripts/dev-rust-stack.sh --skip-spacetime --skip-publish + ./scripts/dev-rust-stack.sh --clear-database + +说明: + 1. 默认同时启动 SpacetimeDB standalone、Rust api-server 与 Vite 前端。 + 2. 默认会 publish server-rs/crates/spacetime-module,但不会清空数据库。 + 3. 只有显式传入 --clear-database 时,才会追加 spacetime publish --clear-database。 +EOF +} + +require_command() { + local command_name="$1" + + if ! command -v "${command_name}" >/dev/null 2>&1; then + echo "[dev:rust] 缺少命令: ${command_name}" >&2 + exit 1 + fi +} + +resolve_client_host() { + local host_name="$1" + + if [[ "${host_name}" == "0.0.0.0" || "${host_name}" == "::" ]]; then + echo "127.0.0.1" + return + fi + + echo "${host_name}" +} + +cleanup() { + local index + + for ((index = ${#PIDS[@]} - 1; index >= 0; index--)); do + local pid="${PIDS[index]}" + local name="${NAMES[index]}" + + if [[ -n "${pid}" ]] && kill -0 "${pid}" 2>/dev/null; then + echo "[dev:rust] 停止 ${name} (pid=${pid})" + if command -v pgrep >/dev/null 2>&1; then + while read -r child_pid; do + if [[ -n "${child_pid}" ]]; then + kill "${child_pid}" 2>/dev/null || true + fi + done < <(pgrep -P "${pid}" 2>/dev/null || true) + fi + kill "${pid}" 2>/dev/null || true + sleep 0.2 + if kill -0 "${pid}" 2>/dev/null; then + kill -9 "${pid}" 2>/dev/null || true + fi + fi + done +} + +wait_for_spacetime() { + local server="$1" + local timeout_seconds="$2" + local process_pid="${3:-}" + local deadline=$((SECONDS + timeout_seconds)) + + while ((SECONDS < deadline)); do + if [[ -n "${process_pid}" ]] && ! kill -0 "${process_pid}" 2>/dev/null; then + echo "[dev:rust] SpacetimeDB 进程在就绪前退出。" >&2 + exit 1 + fi + + if spacetime server ping "${server}" >/dev/null 2>&1; then + return + fi + + sleep 0.5 + done + + echo "[dev:rust] 等待 SpacetimeDB 就绪超时: ${server}" >&2 + exit 1 +} + +SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd -- "${SCRIPT_DIR}/.." && pwd)" +SERVER_RS_DIR="${REPO_ROOT}/server-rs" +MANIFEST_PATH="${SERVER_RS_DIR}/Cargo.toml" +MODULE_PATH="${SERVER_RS_DIR}/crates/spacetime-module" +VITE_CLI_PATH="${REPO_ROOT}/scripts/vite-cli.mjs" + +API_HOST="127.0.0.1" +API_PORT="8082" +WEB_HOST="0.0.0.0" +WEB_PORT="3000" +SPACETIME_HOST="127.0.0.1" +SPACETIME_PORT="3101" +SPACETIME_ROOT_DIR="${SERVER_RS_DIR}/.spacetimedb/local" +DATABASE="genarrative-dev" +API_LOG="info,tower_http=info" +SPACETIME_TIMEOUT_SECONDS="60" +SKIP_SPACETIME=0 +SKIP_PUBLISH=0 +CLEAR_DATABASE=0 +PIDS=() +NAMES=() + +while [[ $# -gt 0 ]]; do + case "$1" in + -h|--help) + usage + exit 0 + ;; + --api-host) + API_HOST="${2:?缺少 --api-host 的值}" + shift 2 + ;; + --api-port) + API_PORT="${2:?缺少 --api-port 的值}" + shift 2 + ;; + --web-host) + WEB_HOST="${2:?缺少 --web-host 的值}" + shift 2 + ;; + --web-port) + WEB_PORT="${2:?缺少 --web-port 的值}" + shift 2 + ;; + --spacetime-host) + SPACETIME_HOST="${2:?缺少 --spacetime-host 的值}" + shift 2 + ;; + --spacetime-port) + SPACETIME_PORT="${2:?缺少 --spacetime-port 的值}" + shift 2 + ;; + --spacetime-root-dir) + SPACETIME_ROOT_DIR="${2:?缺少 --spacetime-root-dir 的值}" + shift 2 + ;; + --database) + DATABASE="${2:?缺少 --database 的值}" + shift 2 + ;; + --log) + API_LOG="${2:?缺少 --log 的值}" + shift 2 + ;; + --spacetime-timeout-seconds) + SPACETIME_TIMEOUT_SECONDS="${2:?缺少 --spacetime-timeout-seconds 的值}" + shift 2 + ;; + --skip-spacetime) + SKIP_SPACETIME=1 + shift + ;; + --skip-publish) + SKIP_PUBLISH=1 + shift + ;; + --clear-database) + CLEAR_DATABASE=1 + shift + ;; + *) + echo "[dev:rust] 未知参数: $1" >&2 + usage >&2 + exit 1 + ;; + esac +done + +if [[ ! -f "${MANIFEST_PATH}" ]]; then + echo "[dev:rust] 未找到 ${MANIFEST_PATH},无法启动 Rust 本地栈。" >&2 + exit 1 +fi + +if [[ ! -f "${MODULE_PATH}/Cargo.toml" ]]; then + echo "[dev:rust] 未找到 ${MODULE_PATH}/Cargo.toml,无法发布 SpacetimeDB 模块。" >&2 + exit 1 +fi + +if [[ ! -f "${VITE_CLI_PATH}" ]]; then + echo "[dev:rust] 未找到 ${VITE_CLI_PATH},无法启动 Web 前端。" >&2 + exit 1 +fi + +require_command cargo +require_command node + +if [[ "${SKIP_SPACETIME}" -ne 1 || "${SKIP_PUBLISH}" -ne 1 ]]; then + require_command spacetime +fi + +SPACETIME_SERVER="http://${SPACETIME_HOST}:${SPACETIME_PORT}" +API_TARGET_HOST="$(resolve_client_host "${API_HOST}")" +RUST_SERVER_TARGET="http://${API_TARGET_HOST}:${API_PORT}" + +trap cleanup EXIT INT TERM + +echo "[dev:rust] repo: ${REPO_ROOT}" +echo "[dev:rust] web: http://127.0.0.1:${WEB_PORT}" +echo "[dev:rust] rust api: ${RUST_SERVER_TARGET}" +echo "[dev:rust] spacetime: ${SPACETIME_SERVER}" +echo "[dev:rust] database: ${DATABASE}" + +if [[ "${SKIP_SPACETIME}" -ne 1 ]]; then + mkdir -p "${SPACETIME_ROOT_DIR}" + echo "[dev:rust] 启动 spacetimedb" + ( + cd "${SERVER_RS_DIR}" + exec spacetime \ + --root-dir "${SPACETIME_ROOT_DIR}" \ + start \ + --edition standalone \ + --listen-addr "${SPACETIME_HOST}:${SPACETIME_PORT}" + ) & + PIDS+=("$!") + NAMES+=("spacetimedb") +fi + +if [[ "${SKIP_PUBLISH}" -ne 1 ]]; then + echo "[dev:rust] 等待 SpacetimeDB 就绪" + wait_for_spacetime "${SPACETIME_SERVER}" "${SPACETIME_TIMEOUT_SECONDS}" "${PIDS[0]:-}" + + PUBLISH_ARGS=( + publish + "${DATABASE}" + --server "${SPACETIME_SERVER}" + --module-path "${MODULE_PATH}" + ) + + if [[ "${CLEAR_DATABASE}" -eq 1 ]]; then + PUBLISH_ARGS+=(--clear-database) + fi + + PUBLISH_ARGS+=(--yes) + + echo "[dev:rust] 发布 SpacetimeDB 模块: ${DATABASE}" + spacetime "${PUBLISH_ARGS[@]}" +fi + +echo "[dev:rust] 启动 api-server" +( + cd "${REPO_ROOT}" + GENARRATIVE_API_HOST="${API_HOST}" \ + GENARRATIVE_API_PORT="${API_PORT}" \ + GENARRATIVE_API_LOG="${API_LOG}" \ + GENARRATIVE_SPACETIME_SERVER_URL="${SPACETIME_SERVER}" \ + GENARRATIVE_SPACETIME_DATABASE="${DATABASE}" \ + exec cargo run -p api-server --manifest-path "${MANIFEST_PATH}" +) & +PIDS+=("$!") +NAMES+=("api-server") + +echo "[dev:rust] 启动 vite" +( + cd "${REPO_ROOT}" + GENARRATIVE_BACKEND_STACK="rust" \ + RUST_SERVER_TARGET="${RUST_SERVER_TARGET}" \ + GENARRATIVE_RUNTIME_SERVER_TARGET="${RUST_SERVER_TARGET}" \ + VITE_DEV_HOST="${WEB_HOST}" \ + exec node "${VITE_CLI_PATH}" "--port=${WEB_PORT}" "--host=${WEB_HOST}" +) & +PIDS+=("$!") +NAMES+=("vite") + +echo "[dev:rust] 本地 Rust 栈已启动。按 Ctrl+C 停止全部子进程。" + +set +e +wait -n "${PIDS[@]}" +EXIT_CODE="$?" +set -e + +echo "[dev:rust] 子进程已退出,开始回收本地 Rust 栈,退出码: ${EXIT_CODE}" +exit "${EXIT_CODE}" diff --git a/scripts/run-bash-script.mjs b/scripts/run-bash-script.mjs new file mode 100644 index 00000000..a1881214 --- /dev/null +++ b/scripts/run-bash-script.mjs @@ -0,0 +1,55 @@ +import {existsSync} from 'node:fs'; +import {spawn} from 'node:child_process'; + +const [, , scriptPath, ...scriptArgs] = process.argv; + +if (!scriptPath) { + console.error('[run-bash-script] missing script path.'); + process.exit(1); +} + +function resolveBashCommand() { + if (process.env.GENARRATIVE_BASH) { + return process.env.GENARRATIVE_BASH; + } + + if (process.platform !== 'win32') { + return 'bash'; + } + + const candidates = [ + 'C:\\Program Files\\Git\\bin\\bash.exe', + 'C:\\Program Files\\Git\\usr\\bin\\bash.exe', + 'C:\\msys64\\usr\\bin\\bash.exe', + ]; + + const matched = candidates.find((candidate) => existsSync(candidate)); + + if (matched) { + return matched; + } + + return 'bash'; +} + +const bashCommand = resolveBashCommand(); +const child = spawn(bashCommand, [scriptPath, ...scriptArgs], { + cwd: process.cwd(), + env: process.env, + stdio: 'inherit', + shell: false, +}); + +child.on('error', (error) => { + console.error(`[run-bash-script] failed to start bash: ${error.message}`); + process.exit(1); +}); + +child.on('exit', (code, signal) => { + if (signal) { + console.error(`[run-bash-script] bash exited by signal: ${signal}`); + process.exit(1); + } + + process.exit(code ?? 0); +}); diff --git a/server-rs/Cargo.lock b/server-rs/Cargo.lock index 343aacb8..c62939cf 100644 --- a/server-rs/Cargo.lock +++ b/server-rs/Cargo.lock @@ -86,6 +86,7 @@ dependencies = [ "module-npc", "module-runtime", "module-runtime-item", + "module-runtime-story-compat", "module-story", "platform-auth", "platform-llm", @@ -1574,6 +1575,16 @@ dependencies = [ "spacetimedb", ] +[[package]] +name = "module-runtime-story-compat" +version = "0.1.0" +dependencies = [ + "serde_json", + "shared-contracts", + "shared-kernel", + "time", +] + [[package]] name = "module-story" version = "0.1.0" diff --git a/server-rs/Cargo.toml b/server-rs/Cargo.toml index 843a3008..22af9541 100644 --- a/server-rs/Cargo.toml +++ b/server-rs/Cargo.toml @@ -15,6 +15,7 @@ members = [ "crates/module-progression", "crates/module-quest", "crates/module-runtime", + "crates/module-runtime-story-compat", "crates/module-runtime-item", "crates/module-story", "crates/platform-oss", diff --git a/server-rs/crates/api-server/Cargo.toml b/server-rs/crates/api-server/Cargo.toml index ba00a6be..d214fcff 100644 --- a/server-rs/crates/api-server/Cargo.toml +++ b/server-rs/crates/api-server/Cargo.toml @@ -20,6 +20,7 @@ module-custom-world = { path = "../module-custom-world" } module-inventory = { path = "../module-inventory" } module-npc = { path = "../module-npc" } module-runtime = { path = "../module-runtime" } +module-runtime-story-compat = { path = "../module-runtime-story-compat" } module-runtime-item = { path = "../module-runtime-item" } module-story = { path = "../module-story" } platform-auth = { path = "../platform-auth" } diff --git a/server-rs/crates/api-server/src/runtime_story/compat.rs b/server-rs/crates/api-server/src/runtime_story/compat.rs index 267c85ec..ed573aed 100644 --- a/server-rs/crates/api-server/src/runtime_story/compat.rs +++ b/server-rs/crates/api-server/src/runtime_story/compat.rs @@ -9,17 +9,47 @@ use module_npc::{ build_relation_state as build_module_npc_relation_state, }; use module_runtime::RuntimeSnapshotRecord; +use module_runtime_story_compat::{ + CONTINUE_ADVENTURE_FUNCTION_ID, CurrentEncounterNpcQuestContext, GeneratedStoryPayload, + PendingQuestOfferContext, RuntimeStoryActionResponseParts, + StoryResolution, add_player_currency, add_player_inventory_items, + append_story_history, apply_equipment_loadout_to_state, + battle_mode_text, build_battle_runtime_story_options, build_current_build_toast, + build_status_patch, + build_npc_gift_result_text, + build_runtime_story_view_model, + clear_encounter_only, clear_encounter_state, clone_inventory_item_with_quantity, + current_encounter_id, current_encounter_name, current_world_type, + ensure_inventory_action_available, ensure_json_object, equipment_slot_label, + find_player_inventory_entry, + format_now_rfc3339, grant_player_progression_experience, has_giftable_player_inventory, + format_currency_text, + increment_runtime_stat, normalize_equipped_item, + normalize_equipment_slot_id, normalize_required_string, npc_buyback_price, + npc_purchase_price, read_array_field, read_bool_field, read_field, read_i32_field, + read_inventory_item_name, read_object_field, read_optional_string_field, + read_player_equipment_item, read_required_string_field, read_runtime_session_id, + read_u32_field, recruit_companion_to_party, remove_player_inventory_item, + restore_player_resource, + resolve_action_text, resolve_battle_action, resolve_equipment_slot_for_item, + resolve_forge_craft_action, + resolve_forge_dismantle_action, resolve_forge_reforge_action, + resolve_npc_gift_affinity_gain, simple_story_resolution, trade_quantity_suffix, + resolve_current_encounter_npc_state, + build_disabled_runtime_story_option, build_runtime_story_option_from_story_option, + build_static_runtime_story_option, build_story_option_from_runtime_option, + write_bool_field, write_i32_field, write_null_field, write_player_equipment_item, + write_string_field, write_u32_field, +}; use platform_llm::{LlmClient, LlmMessage, LlmTextRequest}; use serde_json::{Map, Value, json}; use shared_contracts::runtime_story::{ RuntimeBattlePresentation, RuntimeStoryActionRequest, RuntimeStoryActionResponse, - RuntimeStoryAiRequest, RuntimeStoryAiResponse, RuntimeStoryCompanionViewModel, - RuntimeStoryEncounterViewModel, RuntimeStoryOptionInteraction, RuntimeStoryOptionView, - RuntimeStoryPatch, RuntimeStoryPlayerViewModel, RuntimeStoryPresentation, - RuntimeStorySnapshotPayload, RuntimeStoryStateResolveRequest, RuntimeStoryStatusViewModel, - RuntimeStoryViewModel, + RuntimeStoryAiRequest, RuntimeStoryAiResponse, RuntimeStoryOptionInteraction, + RuntimeStoryOptionView, RuntimeStoryPatch, RuntimeStoryPresentation, + RuntimeStorySnapshotPayload, RuntimeStoryStateResolveRequest, }; -use shared_kernel::{format_rfc3339, offset_datetime_to_unix_micros, parse_rfc3339}; +use shared_kernel::{offset_datetime_to_unix_micros, parse_rfc3339}; use spacetime_client::SpacetimeClientError; use time::OffsetDateTime; @@ -30,22 +60,10 @@ use crate::{ #[path = "compat/ai.rs"] mod ai; -#[path = "compat/battle.rs"] -mod battle; -#[path = "compat/battle_actions.rs"] -mod battle_actions; -#[path = "compat/core.rs"] -mod core; #[path = "compat/equipment_actions.rs"] mod equipment_actions; -#[path = "compat/forge.rs"] -mod forge; -#[path = "compat/forge_actions.rs"] -mod forge_actions; #[path = "compat/game_state.rs"] mod game_state; -#[path = "compat/npc_support.rs"] -mod npc_support; #[path = "compat/npc_actions.rs"] mod npc_actions; #[path = "compat/presentation.rs"] @@ -54,50 +72,13 @@ mod presentation; mod quest_actions; use self::{ - ai::*, battle::*, battle_actions::*, core::*, equipment_actions::*, forge::*, - forge_actions::*, game_state::*, npc_actions::*, npc_support::*, presentation::*, - quest_actions::*, + ai::*, equipment_actions::*, game_state::*, npc_actions::*, presentation::*, quest_actions::*, }; #[cfg(test)] #[path = "compat/tests.rs"] mod tests; -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 GeneratedStoryPayload { - story_text: String, - history_result_text: String, - presentation_options: Vec, - saved_current_story: Value, -} - -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, Extension(request_context): Extension, @@ -482,19 +463,6 @@ fn validate_client_version( )) } -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 resolve_runtime_story_choice_action( game_state: &mut Value, current_story: Option<&Value>, @@ -650,49 +618,6 @@ fn resolve_continue_adventure_action( }) } -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, - } -} - -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 current_world_type(game_state: &Value) -> Option { - read_optional_string_field(game_state, "worldType") -} - fn map_runtime_story_client_error(error: SpacetimeClientError) -> AppError { let (status, provider) = match error { SpacetimeClientError::Runtime(_) => (StatusCode::BAD_REQUEST, "runtime-story"), diff --git a/server-rs/crates/api-server/src/runtime_story/compat/battle_actions.rs b/server-rs/crates/api-server/src/runtime_story/compat/battle_actions.rs deleted file mode 100644 index f60fdd3f..00000000 --- a/server-rs/crates/api-server/src/runtime_story/compat/battle_actions.rs +++ /dev/null @@ -1,137 +0,0 @@ -use super::*; - -pub(super) 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 plan = build_battle_action_plan(game_state, request, function_id)?; - spend_player_mana(game_state, plan.mana_cost); - restore_player_resource(game_state, plan.heal, plan.mana_restore); - tick_player_skill_cooldowns(game_state, plan.cooldown_tick_turns); - reduce_player_skill_cooldowns(game_state, plan.cooldown_bonus_turns); - if let Some((skill_id, turns)) = plan.applied_skill_cooldown.as_ref() { - set_player_skill_cooldown(game_state, skill_id.as_str(), *turns); - } - if !plan.build_buffs.is_empty() { - append_active_build_buffs(game_state, plan.build_buffs.clone()); - } - if let Some(item_id) = plan.consumed_item_id.as_ref() { - remove_player_inventory_item(game_state, item_id.as_str(), 1); - increment_runtime_stat(game_state, "itemsUsed", 1); - } - - apply_player_damage(game_state, plan.damage_taken); - let target_hp = apply_target_damage(game_state, plan.damage_dealt); - let outcome = if target_hp <= 0 { - if battle_mode == "spar" { - "spar_complete" - } else { - "victory" - } - } else { - "ongoing" - }; - - let victory_experience = if outcome == "victory" { - battle_victory_experience_reward(game_state) - } else { - 0 - }; - - 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); - increment_runtime_stat(game_state, "hostileNpcsDefeated", 1); - if victory_experience > 0 { - grant_player_progression_experience(game_state, victory_experience, "hostile_npc"); - } - } - } - - let mut patches = vec![ - RuntimeStoryPatch::BattleResolved { - function_id: function_id.to_string(), - target_id: Some(target_id.clone()), - damage_dealt: Some(plan.damage_dealt), - damage_taken: Some(plan.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(plan.action_text.as_str(), request), - result_text: if outcome == "ongoing" { - plan.result_text - } 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(plan.damage_dealt), - damage_taken: Some(plan.damage_taken), - outcome: Some(outcome.to_string()), - }), - toast: battle_action_toast(function_id, request), - }) -} diff --git a/server-rs/crates/api-server/src/runtime_story/compat/forge.rs b/server-rs/crates/api-server/src/runtime_story/compat/forge.rs index 2aae040e..7d11cf58 100644 --- a/server-rs/crates/api-server/src/runtime_story/compat/forge.rs +++ b/server-rs/crates/api-server/src/runtime_story/compat/forge.rs @@ -1,409 +1,5 @@ use super::*; -/// 这批定义只服务 compat bridge 的确定性锻造规则, -/// 先在 `api-server` 内收口,后续再评估是否值得独立 crate。 -pub(super) struct ForgeRequirementDefinition { - pub(super) quantity: i32, - pub(super) matcher: ForgeRequirementMatcher, -} - -pub(super) enum ForgeRequirementMatcher { - Named(&'static str), - AnyMaterial, -} - -pub(super) struct ForgeRecipeDefinition { - pub(super) id: &'static str, - pub(super) name: &'static str, - pub(super) currency_cost: i32, - pub(super) requirements: Vec, -} - -pub(super) struct ReforgeCostDefinition { - pub(super) currency_cost: i32, - pub(super) requirements: Vec, -} - -pub(super) fn forge_recipe_definition(recipe_id: &str) -> Option { - match recipe_id { - "synthesis-refined-ingot" => Some(ForgeRecipeDefinition { - id: "synthesis-refined-ingot", - name: "压炼锭材", - currency_cost: 18, - requirements: vec![ForgeRequirementDefinition { - quantity: 3, - matcher: ForgeRequirementMatcher::AnyMaterial, - }], - }), - "forge-duelist-blade" => Some(ForgeRecipeDefinition { - id: "forge-duelist-blade", - name: "锻造 百炼追风剑", - currency_cost: 72, - requirements: vec![ - ForgeRequirementDefinition { - quantity: 2, - matcher: ForgeRequirementMatcher::Named("精炼锭材"), - }, - ForgeRequirementDefinition { - quantity: 1, - matcher: ForgeRequirementMatcher::Named("快剑精粹"), - }, - ], - }), - _ => None, - } -} - -pub(super) fn reforge_cost_definition(slot_id: Option<&str>) -> ReforgeCostDefinition { - if slot_id == Some("relic") { - return ReforgeCostDefinition { - currency_cost: 52, - requirements: vec![ForgeRequirementDefinition { - quantity: 1, - matcher: ForgeRequirementMatcher::Named("凝光纱"), - }], - }; - } - ReforgeCostDefinition { - currency_cost: 46, - requirements: vec![ForgeRequirementDefinition { - quantity: 1, - matcher: ForgeRequirementMatcher::Named("精炼锭材"), - }], - } -} - -fn forge_requirement_matches(item: &Value, requirement: &ForgeRequirementDefinition) -> bool { - match requirement.matcher { - ForgeRequirementMatcher::Named(name) => { - read_optional_string_field(item, "name").as_deref() == Some(name) - } - ForgeRequirementMatcher::AnyMaterial => { - read_array_field(item, "tags") - .into_iter() - .filter_map(Value::as_str) - .any(|tag| tag == "material") - || read_optional_string_field(item, "category") - .is_some_and(|category| category.contains("材料")) - } - } -} - -pub(super) fn apply_forge_requirements_if_possible( - inventory: &[Value], - requirements: &[ForgeRequirementDefinition], -) -> Option> { - let mut next_inventory = inventory.to_vec(); - for requirement in requirements { - let mut remaining = requirement.quantity.max(0); - let snapshot = next_inventory.clone(); - for item in snapshot { - if remaining <= 0 { - break; - } - if !forge_requirement_matches(&item, requirement) { - continue; - } - let item_id = read_optional_string_field(&item, "id")?; - let item_quantity = read_i32_field(&item, "quantity").unwrap_or(0).max(0); - let consumed = remaining.min(item_quantity); - next_inventory = - remove_inventory_item_from_list(next_inventory, item_id.as_str(), consumed); - remaining -= consumed; - } - if remaining > 0 { - return None; - } - } - Some(next_inventory) -} - -pub(super) fn build_runtime_material_item( - game_state: &Value, - name: &str, - quantity: i32, - tags: &[&str], - rarity: &str, -) -> Value { - let mut all_tags = vec!["material".to_string()]; - all_tags.extend(tags.iter().map(|tag| (*tag).to_string())); - json!({ - "id": generate_runtime_item_id(game_state, format!("forge-material:{name}").as_str()), - "category": "材料", - "name": name, - "quantity": quantity.max(1), - "rarity": rarity, - "tags": all_tags, - "buildProfile": { - "role": "工巧", - "tags": tags, - "synergy": tags, - "forgeRank": 0 - } - }) -} - -pub(super) fn build_runtime_equipment_item( - game_state: &Value, - name: &str, - slot_id: &str, - rarity: &str, - description: &str, - role: &str, - tags: &[&str], - synergy: &[&str], - stat_profile: Value, -) -> Value { - let slot_tag = match slot_id { - "weapon" => "weapon", - "armor" => "armor", - _ => "relic", - }; - let mut next_tags = vec![slot_tag.to_string()]; - next_tags.extend(tags.iter().map(|tag| (*tag).to_string())); - json!({ - "id": generate_runtime_item_id(game_state, format!("forge-equip:{name}").as_str()), - "category": equipment_slot_label(slot_id), - "name": name, - "description": description, - "quantity": 1, - "rarity": rarity, - "tags": next_tags, - "equipmentSlotId": slot_id, - "statProfile": stat_profile, - "buildProfile": { - "role": role, - "tags": tags, - "synergy": synergy, - "forgeRank": 1 - } - }) -} - -pub(super) fn build_forge_recipe_result_item( - game_state: &Value, - recipe_id: &str, - _world_type: Option<&str>, -) -> Value { - match recipe_id { - "synthesis-refined-ingot" => { - build_runtime_material_item(game_state, "精炼锭材", 1, &["工巧", "守御"], "rare") - } - "forge-duelist-blade" => build_runtime_equipment_item( - game_state, - "百炼追风剑", - "weapon", - "epic", - "为快剑与追身构筑准备的锻造兵刃。", - "快剑", - &["快剑", "突进", "追击"], - &["快剑", "突进", "追击"], - json!({ - "maxManaBonus": 10, - "outgoingDamageBonus": 0.20 - }), - ), - _ => build_runtime_material_item(game_state, "临时锻造产物", 1, &["工巧"], "common"), - } -} - -fn build_tag_essence_item(game_state: &Value, tag: &str) -> Value { - build_runtime_material_item( - game_state, - format!("{tag}精粹").as_str(), - 1, - &[tag, "工巧"], - "rare", - ) -} - -pub(super) fn build_dismantle_outputs(game_state: &Value, item: &Value) -> Option> { - let slot_id = resolve_equipment_slot_for_item(item); - if slot_id.is_none() && read_field(item, "buildProfile").is_none() { - return None; - } - let rarity_scale = match item_rarity_key(item).as_str() { - "legendary" => 5, - "epic" => 4, - "rare" => 3, - "uncommon" => 2, - _ => 1, - }; - let mut outputs = Vec::new(); - match slot_id { - Some("weapon") => outputs.push(build_runtime_material_item( - game_state, - "武器残片", - rarity_scale, - &["工巧", "重击"], - "uncommon", - )), - Some("armor") => outputs.push(build_runtime_material_item( - game_state, - "甲片", - rarity_scale, - &["工巧", "守御"], - "uncommon", - )), - Some("relic") => outputs.push(build_runtime_material_item( - game_state, - "灵饰碎片", - rarity_scale, - &["工巧", "法力"], - "uncommon", - )), - _ => outputs.push(build_runtime_material_item( - game_state, - "零散材料", - ((rarity_scale + 1) / 2).max(1), - &["工巧"], - "uncommon", - )), - } - - let mut build_tags = read_field(item, "buildProfile") - .map(|profile| { - let mut tags = read_array_field(profile, "tags") - .into_iter() - .filter_map(Value::as_str) - .map(str::to_string) - .collect::>(); - if let Some(role) = read_optional_string_field(profile, "role") { - tags.push(role); - } - tags - }) - .unwrap_or_default(); - build_tags.sort(); - build_tags.dedup(); - let tag_limit = if item_rarity_key(item) == "legendary" { - 3 - } else { - 2 - }; - for tag in build_tags.into_iter().take(tag_limit) { - outputs.push(build_tag_essence_item(game_state, tag.as_str())); - } - Some(outputs) -} - -pub(super) fn build_reforged_item(game_state: &Value, item: &Value) -> Option { - let slot_id = resolve_equipment_slot_for_item(item)?; - let build_profile = read_field(item, "buildProfile")?; - let mut next_tags = read_array_field(build_profile, "tags") - .into_iter() - .filter_map(Value::as_str) - .map(str::to_string) - .collect::>(); - let extra_tag = match slot_id { - "weapon" => "追击", - "armor" => "护体", - _ => "法力", - }; - next_tags.push(extra_tag.to_string()); - next_tags.sort(); - next_tags.dedup(); - next_tags.truncate(3); - - let source_name = read_inventory_item_name(item); - let next_name = if source_name.contains('·') && source_name.contains("重铸") { - source_name.clone() - } else { - format!("{source_name}·重铸") - }; - let stat_profile = read_field(item, "statProfile"); - let outgoing_damage_bonus = stat_profile - .and_then(|profile| read_field(profile, "outgoingDamageBonus")) - .and_then(Value::as_f64) - .unwrap_or(0.0); - let incoming_damage_multiplier = stat_profile - .and_then(|profile| read_field(profile, "incomingDamageMultiplier")) - .and_then(Value::as_f64); - let current_forge_rank = read_i32_field(build_profile, "forgeRank").unwrap_or(0); - let mut tags = read_array_field(item, "tags") - .into_iter() - .filter_map(Value::as_str) - .map(str::to_string) - .collect::>(); - tags.sort(); - tags.dedup(); - - Some(json!({ - "id": generate_runtime_item_id(game_state, format!("reforge:{source_name}").as_str()), - "category": read_optional_string_field(item, "category").unwrap_or_else(|| equipment_slot_label(slot_id).to_string()), - "name": next_name, - "description": read_optional_string_field(item, "description"), - "quantity": 1, - "rarity": item_rarity_key(item), - "tags": tags, - "equipmentSlotId": slot_id, - "statProfile": { - "maxHpBonus": stat_profile - .and_then(|profile| read_i32_field(profile, "maxHpBonus")) - .unwrap_or(0) + if slot_id == "armor" { 10 } else { 4 }, - "maxManaBonus": stat_profile - .and_then(|profile| read_i32_field(profile, "maxManaBonus")) - .unwrap_or(0) + if slot_id == "relic" { 10 } else { 4 }, - "outgoingDamageBonus": ((outgoing_damage_bonus + 0.03) * 1000.0).round() / 1000.0, - "incomingDamageMultiplier": if let Some(multiplier) = incoming_damage_multiplier { - (((multiplier - 0.03).max(0.72)) * 1000.0).round() / 1000.0 - } else if slot_id == "armor" { - 0.94 - } else { - 0.97 - } - }, - "buildProfile": { - "role": read_optional_string_field(build_profile, "role"), - "tags": next_tags, - "synergy": read_array_field(build_profile, "tags") - .into_iter() - .filter_map(Value::as_str) - .map(str::to_string) - .chain(std::iter::once(extra_tag.to_string())) - .collect::>() - .into_iter() - .collect::>(), - "forgeRank": current_forge_rank + 1 - } - })) -} - -pub(super) fn build_forge_success_text( - action: &str, - recipe_name: Option<&str>, - source_item_name: Option<&str>, - created_item_name: Option<&str>, - output_names: &[String], - currency_text: Option, -) -> String { - match action { - "craft" => format!( - "你在工坊中完成了{},获得了{}{}。", - recipe_name.unwrap_or("目标配方"), - created_item_name.unwrap_or("目标物品"), - currency_text - .map(|text| format!(",并支付了{text}")) - .unwrap_or_default() - ), - "reforge" => format!( - "你消耗材料重新淬炼了{},最终得到{}{}。", - source_item_name.unwrap_or("目标物品"), - created_item_name.unwrap_or("重铸产物"), - currency_text - .map(|text| format!(",并支付了{text}")) - .unwrap_or_default() - ), - _ => format!( - "你拆解了{},回收出{}。", - source_item_name.unwrap_or("目标物品"), - output_names.join("、") - ), - } -} - -fn generate_runtime_item_id(game_state: &Value, prefix: &str) -> String { - let version = read_u32_field(game_state, "runtimeActionVersion").unwrap_or(0); - let inventory_len = read_array_field(game_state, "playerInventory").len(); - format!("{prefix}:{version}:{inventory_len}") -} +pub(super) use module_runtime_story_compat::{ + build_runtime_equipment_item, build_runtime_material_item, +}; diff --git a/server-rs/crates/api-server/src/runtime_story/compat/game_state.rs b/server-rs/crates/api-server/src/runtime_story/compat/game_state.rs index 7dcb6eb9..c65eafcc 100644 --- a/server-rs/crates/api-server/src/runtime_story/compat/game_state.rs +++ b/server-rs/crates/api-server/src/runtime_story/compat/game_state.rs @@ -1,4 +1,5 @@ use super::*; +use module_runtime_story_compat::{build_runtime_equipment_item, build_runtime_material_item}; pub(super) fn current_npc_trade_context(game_state: &Value) -> Result<(String, String), String> { let encounter = read_object_field(game_state, "currentEncounter") @@ -17,80 +18,6 @@ pub(super) fn current_npc_trade_context(game_state: &Value) -> Result<(String, S Ok((npc_id, npc_name)) } -pub(super) fn ensure_inventory_action_available( - game_state: &Value, - missing_character_message: &str, - battle_locked_message: &str, -) -> Result<(), String> { - if read_field(game_state, "playerCharacter").is_none() { - return Err(missing_character_message.to_string()); - } - if read_bool_field(game_state, "inBattle").unwrap_or(false) { - return Err(battle_locked_message.to_string()); - } - Ok(()) -} - -pub(super) fn battle_mode_text(value: &str) -> &'static str { - if value == "spar" { "切磋" } else { "战斗" } -} - -pub(super) 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()) -} - -pub(super) 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()) -} - -pub(super) fn current_encounter_id(game_state: &Value) -> Option { - read_object_field(game_state, "currentEncounter") - .and_then(|encounter| read_optional_string_field(encounter, "id")) -} - -pub(super) fn find_player_inventory_entry<'a>( - game_state: &'a Value, - item_id: &str, -) -> Option<&'a Value> { - read_array_field(game_state, "playerInventory") - .into_iter() - .find(|entry| read_optional_string_field(entry, "id").as_deref() == Some(item_id)) -} - -pub(super) fn read_player_inventory_values(game_state: &Value) -> Vec { - read_array_field(game_state, "playerInventory") - .into_iter() - .cloned() - .collect() -} - -pub(super) fn write_player_inventory_values(game_state: &mut Value, items: Vec) { - ensure_json_object(game_state).insert("playerInventory".to_string(), Value::Array(items)); -} - -pub(super) fn read_inventory_item_name(item: &Value) -> String { - read_optional_string_field(item, "name") - .or_else(|| read_optional_string_field(item, "id")) - .unwrap_or_else(|| "未命名物品".to_string()) -} - -pub(super) fn has_giftable_player_inventory(game_state: &Value) -> bool { - read_array_field(game_state, "playerInventory") - .into_iter() - .any(|item| read_i32_field(item, "quantity").unwrap_or(0) > 0) -} - pub(super) fn current_npc_inventory_items<'a>(game_state: &'a Value) -> Vec<&'a Value> { let Some(npc_id) = current_encounter_id(game_state) else { return Vec::new(); @@ -770,346 +697,3 @@ pub(super) fn remove_current_npc_inventory_item( entry.insert("quantity".to_string(), json!(next_quantity)); } } - -pub(super) fn clone_inventory_item_with_quantity(item: &Value, quantity: i32) -> Value { - let mut next_item = item.clone(); - if let Some(entry) = next_item.as_object_mut() { - entry.insert("quantity".to_string(), json!(quantity.max(1))); - } - next_item -} - -pub(super) fn normalize_equipped_item(item: &Value) -> Value { - clone_inventory_item_with_quantity(item, 1) -} - -pub(super) fn add_inventory_items_to_list( - mut base: Vec, - additions: Vec, -) -> Vec { - for addition in additions { - let add_id = read_optional_string_field(&addition, "id"); - let add_quantity = read_i32_field(&addition, "quantity").unwrap_or(1).max(1); - if let Some(add_id) = add_id { - if let Some(existing) = base.iter_mut().find(|item| { - read_optional_string_field(item, "id").as_deref() == Some(add_id.as_str()) - }) { - let next_quantity = - read_i32_field(existing, "quantity").unwrap_or(0).max(0) + add_quantity; - if let Some(existing_object) = existing.as_object_mut() { - existing_object.insert("quantity".to_string(), json!(next_quantity)); - } - continue; - } - } - base.push(addition); - } - base -} - -pub(super) fn remove_inventory_item_from_list( - mut base: Vec, - item_id: &str, - quantity: i32, -) -> Vec { - if quantity <= 0 { - return base; - } - let Some(index) = base - .iter() - .position(|entry| read_optional_string_field(entry, "id").as_deref() == Some(item_id)) - else { - return base; - }; - let current_quantity = read_i32_field(&base[index], "quantity").unwrap_or(0).max(0); - let next_quantity = current_quantity - quantity; - if next_quantity <= 0 { - base.remove(index); - return base; - } - if let Some(item) = base[index].as_object_mut() { - item.insert("quantity".to_string(), json!(next_quantity)); - } - base -} - -pub(super) fn read_player_equipment_item(game_state: &Value, slot_id: &str) -> Option { - read_field(game_state, "playerEquipment") - .and_then(|equipment| read_field(equipment, slot_id)) - .filter(|item| !item.is_null()) - .cloned() -} - -pub(super) fn write_player_equipment_item( - game_state: &mut Value, - slot_id: &str, - item: Option, -) { - let root = ensure_json_object(game_state); - let equipment = root - .entry("playerEquipment".to_string()) - .or_insert_with(|| { - json!({ - "weapon": null, - "armor": null, - "relic": null, - }) - }); - if !equipment.is_object() { - *equipment = json!({ - "weapon": null, - "armor": null, - "relic": null, - }); - } - equipment - .as_object_mut() - .expect("playerEquipment should be object") - .insert(slot_id.to_string(), item.unwrap_or(Value::Null)); -} - -pub(super) fn equipment_slot_label(slot_id: &str) -> &'static str { - match slot_id { - "weapon" => "武器", - "armor" => "护甲", - "relic" => "饰品", - _ => "装备", - } -} - -pub(super) fn normalize_equipment_slot_id(slot_id: &str) -> Option<&'static str> { - let normalized = slot_id.trim().to_ascii_lowercase(); - match normalized.as_str() { - "weapon" => Some("weapon"), - "armor" => Some("armor"), - "relic" | "accessory" => Some("relic"), - _ => { - // 兼容旧 payload 里直接传中文槽位名或物品类别文案的情况。 - if slot_id.contains("武器") - || slot_id.contains('剑') - || slot_id.contains('弓') - || slot_id.contains('刀') - || slot_id.contains("拳套") - || slot_id.contains("战刃") - || slot_id.contains('枪') - || slot_id.contains('刃') - { - return Some("weapon"); - } - if slot_id.contains("护甲") - || slot_id.contains('甲') - || slot_id.contains("护臂") - || slot_id.contains('衣') - || slot_id.contains('袍') - || slot_id.contains('铠') - { - return Some("armor"); - } - if slot_id.contains("饰品") - || slot_id.contains("护符") - || slot_id.contains("徽章") - || slot_id.contains('玉') - || slot_id.contains('珠') - || slot_id.contains('坠') - || slot_id.contains('铃') - || slot_id.contains('盘') - || slot_id.contains('令') - || slot_id.contains('匣') - { - return Some("relic"); - } - None - } - } -} - -pub(super) fn resolve_equipment_slot_for_item(item: &Value) -> Option<&'static str> { - if let Some(slot_id) = read_optional_string_field(item, "equipmentSlotId") { - return match slot_id.as_str() { - "weapon" => Some("weapon"), - "armor" => Some("armor"), - "relic" => Some("relic"), - _ => None, - }; - } - let tags = read_array_field(item, "tags") - .into_iter() - .filter_map(|tag| tag.as_str().map(|value| value.to_string())) - .collect::>(); - if tags.iter().any(|tag| tag == "weapon") { - return Some("weapon"); - } - if tags.iter().any(|tag| tag == "armor") { - return Some("armor"); - } - if tags.iter().any(|tag| tag == "relic") { - return Some("relic"); - } - let category_text = read_optional_string_field(item, "category").unwrap_or_default(); - let name_text = read_inventory_item_name(item); - let mixed_text = format!("{category_text} {name_text}"); - if mixed_text.contains("武器") || mixed_text.contains("剑") || mixed_text.contains("刀") { - return Some("weapon"); - } - if mixed_text.contains("护甲") || mixed_text.contains("甲") || mixed_text.contains("袍") { - return Some("armor"); - } - if mixed_text.contains("饰品") || mixed_text.contains("护符") || mixed_text.contains("玉") - { - return Some("relic"); - } - None -} - -pub(super) fn item_rarity_key(item: &Value) -> String { - read_optional_string_field(item, "rarity").unwrap_or_else(|| "common".to_string()) -} - -pub(super) fn equipment_bonus_fallbacks(slot_id: &str, rarity: &str) -> (i32, i32, f64, f64) { - match slot_id { - "weapon" => { - let outgoing = match rarity { - "uncommon" => 0.10, - "rare" => 0.14, - "epic" => 0.20, - "legendary" => 0.28, - _ => 0.06, - }; - (0, 0, outgoing, 1.0) - } - "armor" => { - let hp = match rarity { - "uncommon" => 22, - "rare" => 32, - "epic" => 44, - "legendary" => 58, - _ => 14, - }; - let incoming = match rarity { - "uncommon" => 0.94, - "rare" => 0.90, - "epic" => 0.86, - "legendary" => 0.80, - _ => 0.97, - }; - (hp, 0, 0.0, incoming) - } - _ => { - let mana = match rarity { - "uncommon" => 18, - "rare" => 28, - "epic" => 40, - "legendary" => 54, - _ => 10, - }; - let outgoing = match rarity { - "uncommon" => 0.04, - "rare" => 0.06, - "epic" => 0.09, - "legendary" => 0.12, - _ => 0.02, - }; - (0, mana, outgoing, 1.0) - } - } -} - -pub(super) fn equipment_item_bonuses(item: &Value, slot_id: &str) -> (i32, i32, f64, f64) { - let rarity = item_rarity_key(item); - let fallback = equipment_bonus_fallbacks(slot_id, rarity.as_str()); - let stat_profile = read_field(item, "statProfile"); - let hp_bonus = stat_profile - .and_then(|profile| read_i32_field(profile, "maxHpBonus")) - .unwrap_or(fallback.0); - let mana_bonus = stat_profile - .and_then(|profile| read_i32_field(profile, "maxManaBonus")) - .unwrap_or(fallback.1); - let outgoing_bonus = stat_profile - .and_then(|profile| read_field(profile, "outgoingDamageBonus")) - .and_then(Value::as_f64) - .unwrap_or(fallback.2); - let incoming_multiplier = stat_profile - .and_then(|profile| read_field(profile, "incomingDamageMultiplier")) - .and_then(Value::as_f64) - .unwrap_or(fallback.3); - (hp_bonus, mana_bonus, outgoing_bonus, incoming_multiplier) -} - -pub(super) fn read_equipment_total_bonuses(game_state: &Value) -> (i32, i32, f64, f64) { - let equipment = read_field(game_state, "playerEquipment"); - let mut hp_bonus = 0; - let mut mana_bonus = 0; - let mut outgoing_bonus = 0.0; - let mut incoming_multiplier = 1.0; - for slot_id in ["weapon", "armor", "relic"] { - let Some(item) = equipment.and_then(|value| read_field(value, slot_id)) else { - continue; - }; - if item.is_null() { - continue; - } - let (slot_hp, slot_mana, slot_outgoing, slot_incoming) = - equipment_item_bonuses(item, slot_id); - hp_bonus += slot_hp; - mana_bonus += slot_mana; - outgoing_bonus += slot_outgoing; - incoming_multiplier *= slot_incoming; - } - (hp_bonus, mana_bonus, outgoing_bonus, incoming_multiplier) -} - -pub(super) fn apply_equipment_loadout_to_state(game_state: &mut Value) { - let (hp_bonus, mana_bonus, _outgoing_bonus, _incoming_multiplier) = - read_equipment_total_bonuses(game_state); - let current_max_hp = read_i32_field(game_state, "playerMaxHp") - .unwrap_or(1) - .max(1); - let current_max_mana = read_i32_field(game_state, "playerMaxMana") - .unwrap_or(1) - .max(1); - let current_hp = read_i32_field(game_state, "playerHp").unwrap_or(current_max_hp); - let base_max_hp = current_max_hp - .saturating_sub(read_runtime_equipment_bonus_cache(game_state, "maxHpBonus")) - .max(1); - let base_max_mana = current_max_mana - .saturating_sub(read_runtime_equipment_bonus_cache( - game_state, - "maxManaBonus", - )) - .max(1); - let next_max_hp = base_max_hp.saturating_add(hp_bonus).max(1); - let next_max_mana = base_max_mana.saturating_add(mana_bonus).max(1); - write_i32_field(game_state, "playerMaxHp", next_max_hp); - write_i32_field(game_state, "playerHp", current_hp.min(next_max_hp)); - write_i32_field(game_state, "playerMaxMana", next_max_mana); - write_i32_field(game_state, "playerMana", next_max_mana); - write_runtime_equipment_bonus_cache(game_state, "maxHpBonus", hp_bonus); - write_runtime_equipment_bonus_cache(game_state, "maxManaBonus", mana_bonus); -} - -pub(super) fn read_runtime_equipment_bonus_cache(game_state: &Value, key: &str) -> i32 { - read_field(game_state, "runtimeEquipmentBonusCache") - .and_then(|cache| read_i32_field(cache, key)) - .unwrap_or(0) -} - -pub(super) fn write_runtime_equipment_bonus_cache(game_state: &mut Value, key: &str, value: i32) { - let root = ensure_json_object(game_state); - let cache = root - .entry("runtimeEquipmentBonusCache".to_string()) - .or_insert_with(|| Value::Object(Map::new())); - if !cache.is_object() { - *cache = Value::Object(Map::new()); - } - cache - .as_object_mut() - .expect("runtimeEquipmentBonusCache should be object") - .insert(key.to_string(), json!(value)); -} - -pub(super) fn build_current_build_toast(game_state: &Value) -> String { - let (_hp_bonus, _mana_bonus, outgoing_bonus, _incoming_multiplier) = - read_equipment_total_bonuses(game_state); - let build_multiplier = (1.0 + outgoing_bonus).max(1.0); - format!("当前 Build 倍率 x{build_multiplier:.2}") -} diff --git a/server-rs/crates/api-server/src/runtime_story/compat/npc_actions.rs b/server-rs/crates/api-server/src/runtime_story/compat/npc_actions.rs index 410a60e8..9a53ce76 100644 --- a/server-rs/crates/api-server/src/runtime_story/compat/npc_actions.rs +++ b/server-rs/crates/api-server/src/runtime_story/compat/npc_actions.rs @@ -166,7 +166,7 @@ pub(super) fn resolve_npc_recruit_action( let released_companion_name = recruit_companion_to_party( game_state, npc_id.as_str(), - npc_name.as_str(), + current_affinity, release_npc_id.as_deref(), )?; let affinity_patch = diff --git a/server-rs/crates/api-server/src/runtime_story/compat/presentation.rs b/server-rs/crates/api-server/src/runtime_story/compat/presentation.rs index f7428e02..edaacef0 100644 --- a/server-rs/crates/api-server/src/runtime_story/compat/presentation.rs +++ b/server-rs/crates/api-server/src/runtime_story/compat/presentation.rs @@ -53,33 +53,6 @@ pub(super) fn build_runtime_story_action_response( } } -pub(super) 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", - ), - }, - } -} - pub(super) fn build_dialogue_current_story( npc_name: &str, text: &str, @@ -158,57 +131,6 @@ pub(super) fn parse_dialogue_line(line: &str, npc_name: &str) -> Option { })) } -pub(super) fn build_runtime_story_companions( - game_state: &Value, -) -> Vec { - read_array_field(game_state, "companions") - .into_iter() - .filter_map(|entry| { - let npc_id = read_required_string_field(entry, "npcId")?; - Some(RuntimeStoryCompanionViewModel { - npc_id, - character_id: read_optional_string_field(entry, "characterId"), - joined_at_affinity: read_i32_field(entry, "joinedAtAffinity").unwrap_or(0), - }) - }) - .collect() -} - -pub(super) fn build_runtime_story_encounter( - game_state: &Value, -) -> Option { - let encounter = read_object_field(game_state, "currentEncounter")?; - 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 { - id: encounter_id, - kind: read_required_string_field(encounter, "kind").unwrap_or_else(|| "npc".to_string()), - npc_name, - hostile: read_bool_field(encounter, "hostile").unwrap_or(false), - affinity: npc_state.and_then(|state| read_i32_field(state, "affinity")), - recruited: npc_state.and_then(|state| read_bool_field(state, "recruited")), - interaction_active: read_bool_field(game_state, "npcInteractionActive").unwrap_or(false), - battle_mode: read_optional_string_field(game_state, "currentNpcBattleMode"), - }) -} - -pub(super) fn resolve_current_encounter_npc_state<'a>( - game_state: &'a Value, - encounter_id: &str, - npc_name: &str, -) -> Option<&'a Value> { - let npc_states = read_object_field(game_state, "npcStates")?; - - npc_states - .get(encounter_id) - .or_else(|| npc_states.get(npc_name)) -} - pub(super) fn build_runtime_story_options( current_story: Option<&Value>, game_state: &Value, @@ -237,43 +159,6 @@ pub(super) fn build_runtime_story_options( build_fallback_runtime_story_options(game_state) } -pub(super) fn build_runtime_story_option_from_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()); - - Some(RuntimeStoryOptionView { - scope: infer_option_scope(function_id.as_str()).to_string(), - detail_text: read_optional_string_field(value, "detailText"), - interaction: build_runtime_story_option_interaction(read_field(value, "interaction")), - payload: read_field(value, "runtimePayload") - .or_else(|| read_field(value, "payload")) - .cloned(), - disabled: read_bool_field(value, "disabled"), - reason: read_optional_string_field(value, "disabledReason") - .or_else(|| read_optional_string_field(value, "reason")), - function_id, - action_text, - }) -} - -pub(super) fn build_runtime_story_option_interaction( - value: Option<&Value>, -) -> Option { - let interaction = value?; - match read_required_string_field(interaction, "kind")?.as_str() { - "npc" => Some(RuntimeStoryOptionInteraction::Npc { - npc_id: read_required_string_field(interaction, "npcId")?, - action: read_required_string_field(interaction, "action")?, - quest_id: read_optional_string_field(interaction, "questId"), - }), - _ => None, - } -} - pub(super) fn build_fallback_runtime_story_options( game_state: &Value, ) -> Vec { @@ -334,54 +219,6 @@ pub(super) fn build_fallback_runtime_story_options( ] } -pub(super) fn build_static_runtime_story_option( - function_id: &str, - action_text: &str, - scope: &str, -) -> RuntimeStoryOptionView { - RuntimeStoryOptionView { - function_id: function_id.to_string(), - action_text: action_text.to_string(), - detail_text: None, - scope: scope.to_string(), - interaction: None, - payload: None, - disabled: None, - reason: None, - } -} - -pub(super) fn build_runtime_story_option_with_payload( - function_id: &str, - action_text: &str, - scope: &str, - detail_text: Option, - payload: Value, -) -> RuntimeStoryOptionView { - RuntimeStoryOptionView { - detail_text, - payload: Some(payload), - ..build_static_runtime_story_option(function_id, action_text, scope) - } -} - -pub(super) fn build_disabled_runtime_story_option( - function_id: &str, - action_text: &str, - scope: &str, - detail_text: Option, - reason: &str, - payload: Option, -) -> RuntimeStoryOptionView { - RuntimeStoryOptionView { - detail_text, - payload, - disabled: Some(true), - reason: Some(reason.to_string()), - ..build_static_runtime_story_option(function_id, action_text, scope) - } -} - pub(super) fn build_npc_runtime_story_option( function_id: &str, action_text: &str, @@ -864,16 +701,6 @@ pub(super) fn apply_quest_turn_in_rewards(game_state: &mut Value, quest: &Value) } } -pub(super) fn infer_option_scope(function_id: &str) -> &'static str { - if function_id.starts_with("battle_") || function_id == "inventory_use" { - "combat" - } else if function_id.starts_with("npc_") { - "npc" - } else { - "story" - } -} - pub(super) fn build_legacy_current_story( story_text: &str, options: &[RuntimeStoryOptionView], @@ -885,27 +712,6 @@ pub(super) fn build_legacy_current_story( }) } -pub(super) 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, - }) -} - pub(super) fn read_story_text(current_story: Option<&Value>) -> Option { current_story.and_then(|story| read_optional_string_field(story, "text")) } diff --git a/server-rs/crates/module-runtime-story-compat/Cargo.toml b/server-rs/crates/module-runtime-story-compat/Cargo.toml new file mode 100644 index 00000000..a0f9d735 --- /dev/null +++ b/server-rs/crates/module-runtime-story-compat/Cargo.toml @@ -0,0 +1,11 @@ +[package] +name = "module-runtime-story-compat" +edition.workspace = true +version.workspace = true +license.workspace = true + +[dependencies] +serde_json = "1" +shared-contracts = { path = "../shared-contracts" } +shared-kernel = { path = "../shared-kernel" } +time = { version = "0.3", features = ["formatting"] } diff --git a/server-rs/crates/module-runtime-story-compat/README.md b/server-rs/crates/module-runtime-story-compat/README.md new file mode 100644 index 00000000..a2258613 --- /dev/null +++ b/server-rs/crates/module-runtime-story-compat/README.md @@ -0,0 +1,13 @@ +# module-runtime-story-compat + +`module-runtime-story-compat` 承接旧 `/api/runtime/story/*` 兼容桥中不依赖 HTTP / `AppState` 的核心类型与纯 helper。 + +当前首批迁入范围保持克制: + +1. action 结算结果结构。 +2. action response 组装参数结构。 +3. NPC 委托上下文结构。 +4. functionId / 队伍上限常量。 +5. 少量只依赖 `serde_json::Value` 与 `shared-contracts` 的纯 helper。 + +后续再按 battle / forge / NPC / quest / presentation 的顺序,把已经拆好的 `api-server` 内部模块逐步迁入本 crate。 diff --git a/server-rs/crates/api-server/src/runtime_story/compat/battle.rs b/server-rs/crates/module-runtime-story-compat/src/battle.rs similarity index 69% rename from server-rs/crates/api-server/src/runtime_story/compat/battle.rs rename to server-rs/crates/module-runtime-story-compat/src/battle.rs index 39b15162..39c49075 100644 --- a/server-rs/crates/api-server/src/runtime_story/compat/battle.rs +++ b/server-rs/crates/module-runtime-story-compat/src/battle.rs @@ -1,20 +1,37 @@ -use super::*; +use serde_json::{Map, Value, json}; -/// 兼容桥里的战斗动作仍走快照态,因此把每回合需要写回的字段先收口到这里, -/// 避免技能、物品、旧 battle_* 分支继续把状态更新散落在各处。 -pub(super) struct BattleActionPlan { - pub(super) action_text: String, - pub(super) result_text: String, - pub(super) damage_dealt: i32, - pub(super) damage_taken: i32, - pub(super) heal: i32, - pub(super) mana_restore: i32, - pub(super) mana_cost: i32, - pub(super) cooldown_tick_turns: i32, - pub(super) cooldown_bonus_turns: i32, - pub(super) applied_skill_cooldown: Option<(String, i32)>, - pub(super) build_buffs: Vec, - pub(super) consumed_item_id: Option, +use shared_contracts::runtime_story::{ + RuntimeBattlePresentation, RuntimeStoryActionRequest, RuntimeStoryOptionView, + RuntimeStoryPatch, +}; + +use crate::{ + StoryResolution, append_active_build_buffs, build_status_patch, clear_encounter_only, + clear_encounter_state, current_encounter_id, current_encounter_name_from_battle, + ensure_json_object, first_hostile_npc_string_field, grant_player_progression_experience, + increment_runtime_stat, read_array_field, read_field, read_i32_field, read_object_field, + read_optional_string_field, remove_player_inventory_item, resolve_action_text, + write_bool_field, write_current_encounter_i32_field, write_first_hostile_npc_i32_field, + write_i32_field, write_null_field, write_string_field, +}; + +/// 战斗 compat 纯结算链已经不依赖 HTTP / AppState。 +/// +/// 这里同时承接 battle action 的状态结算、资源恢复和战斗选项编译, +/// 让 `api-server` 只保留 HTTP 外壳与最终响应拼装。 +struct BattleActionPlan { + action_text: String, + result_text: String, + damage_dealt: i32, + damage_taken: i32, + heal: i32, + mana_restore: i32, + mana_cost: i32, + cooldown_tick_turns: i32, + cooldown_bonus_turns: i32, + applied_skill_cooldown: Option<(String, i32)>, + build_buffs: Vec, + consumed_item_id: Option, } struct BattleSkillView { @@ -40,7 +57,143 @@ struct BattleInventoryItemView { use_profile: Option, } -pub(super) fn restore_player_resource(game_state: &mut Value, hp_restore: i32, mana_restore: i32) { +pub 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 plan = build_battle_action_plan(game_state, request, function_id)?; + spend_player_mana(game_state, plan.mana_cost); + restore_player_resource(game_state, plan.heal, plan.mana_restore); + tick_player_skill_cooldowns(game_state, plan.cooldown_tick_turns); + reduce_player_skill_cooldowns(game_state, plan.cooldown_bonus_turns); + if let Some((skill_id, turns)) = plan.applied_skill_cooldown.as_ref() { + set_player_skill_cooldown(game_state, skill_id.as_str(), *turns); + } + if !plan.build_buffs.is_empty() { + append_active_build_buffs(game_state, plan.build_buffs.clone()); + } + if let Some(item_id) = plan.consumed_item_id.as_ref() { + remove_player_inventory_item(game_state, item_id.as_str(), 1); + increment_runtime_stat(game_state, "itemsUsed", 1); + } + + apply_player_damage(game_state, plan.damage_taken); + let target_hp = apply_target_damage(game_state, plan.damage_dealt); + let outcome = if target_hp <= 0 { + if battle_mode == "spar" { + "spar_complete" + } else { + "victory" + } + } else { + "ongoing" + }; + + let victory_experience = if outcome == "victory" { + battle_victory_experience_reward(game_state) + } else { + 0 + }; + + 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); + increment_runtime_stat(game_state, "hostileNpcsDefeated", 1); + if victory_experience > 0 { + grant_player_progression_experience(game_state, victory_experience, "hostile_npc"); + } + } + } + + let mut patches = vec![ + RuntimeStoryPatch::BattleResolved { + function_id: function_id.to_string(), + target_id: Some(target_id.clone()), + damage_dealt: Some(plan.damage_dealt), + damage_taken: Some(plan.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(plan.action_text.as_str(), request), + result_text: if outcome == "ongoing" { + plan.result_text + } 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(plan.damage_dealt), + damage_taken: Some(plan.damage_taken), + outcome: Some(outcome.to_string()), + }), + toast: battle_action_toast(function_id, request), + }) +} + +pub 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); @@ -57,7 +210,53 @@ pub(super) fn restore_player_resource(game_state: &mut Value, hp_restore: i32, m ); } -pub(super) fn spend_player_mana(game_state: &mut Value, mana_cost: i32) { +pub fn build_battle_runtime_story_options(game_state: &Value) -> Vec { + let mut options = vec![ + RuntimeStoryOptionView { + detail_text: Some(build_basic_attack_detail_text(game_state)), + ..build_static_runtime_story_option("battle_attack_basic", "普通攻击", "combat") + }, + RuntimeStoryOptionView { + detail_text: Some("回血 12 / 回蓝 9 / 冷却 -1".to_string()), + ..build_static_runtime_story_option("battle_recover_breath", "恢复", "combat") + }, + ]; + + let preferred_item = pick_preferred_battle_inventory_item(game_state); + if let Some(item) = preferred_item { + let effect = item + .use_profile + .expect("preferred battle item must have use profile"); + options.push(build_runtime_story_option_with_payload( + "inventory_use", + &format!("使用物品:{}", item.name), + "combat", + Some(build_battle_item_summary(&effect)), + json!({ + "itemId": item.id + }), + )); + } else { + options.push(build_disabled_runtime_story_option( + "inventory_use", + "使用物品", + "combat", + Some("当前没有可直接结算的战斗消耗品".to_string()), + "暂无可用物品", + None, + )); + } + + options.extend(build_battle_skill_runtime_story_options(game_state)); + options.push(build_static_runtime_story_option( + "battle_escape_breakout", + "强行脱离战斗", + "combat", + )); + options +} + +fn spend_player_mana(game_state: &mut Value, mana_cost: i32) { if mana_cost <= 0 { return; } @@ -65,7 +264,7 @@ pub(super) fn spend_player_mana(game_state: &mut Value, mana_cost: i32) { write_i32_field(game_state, "playerMana", (mana - mana_cost).max(0)); } -pub(super) fn apply_player_damage(game_state: &mut Value, damage: i32) { +fn apply_player_damage(game_state: &mut Value, damage: i32) { if damage <= 0 { return; } @@ -73,7 +272,7 @@ pub(super) fn apply_player_damage(game_state: &mut Value, damage: i32) { write_i32_field(game_state, "playerHp", (hp - damage).max(1)); } -pub(super) fn apply_target_damage(game_state: &mut Value, damage: i32) -> i32 { +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") @@ -89,7 +288,6 @@ pub(super) fn apply_target_damage(game_state: &mut Value, damage: i32) -> i32 { 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 } @@ -122,9 +320,20 @@ fn find_player_skill_by_id(game_state: &Value, skill_id: &str) -> Option std::collections::BTreeMap { +fn build_basic_attack_detail_text(game_state: &Value) -> String { + let strength = read_field(game_state, "playerCharacter") + .and_then(|character| read_field(character, "attributes")) + .and_then(|attributes| read_i32_field(attributes, "strength")) + .unwrap_or(8); + let agility = read_field(game_state, "playerCharacter") + .and_then(|character| read_field(character, "attributes")) + .and_then(|attributes| read_i32_field(attributes, "agility")) + .unwrap_or(0); + let preview_damage = ((strength * 85 + agility * 45) / 100).max(8); + format!("不耗蓝 / 伤害 {preview_damage}") +} + +fn read_player_skill_cooldowns(game_state: &Value) -> std::collections::BTreeMap { read_object_field(game_state, "playerSkillCooldowns") .and_then(Value::as_object) .map(|cooldowns| { @@ -145,7 +354,52 @@ pub(super) fn read_player_skill_cooldowns( .unwrap_or_default() } -pub(super) fn tick_player_skill_cooldowns(game_state: &mut Value, turns: i32) { +fn build_battle_skill_runtime_story_options(game_state: &Value) -> Vec { + let cooldowns = read_player_skill_cooldowns(game_state); + let player_mana = read_i32_field(game_state, "playerMana").unwrap_or(0); + read_player_skills(game_state) + .into_iter() + .map(|skill| { + let detail_text = Some(format!( + "耗蓝 {} / 伤害 {} / 冷却 {}", + skill.mana_cost.max(0), + skill.damage.max(0), + skill.cooldown_turns.max(0) + )); + let payload = Some(json!({ + "skillId": skill.id + })); + let remaining_cooldown = cooldowns.get(skill.id.as_str()).copied().unwrap_or(0); + if remaining_cooldown > 0 { + return build_disabled_runtime_story_option( + "battle_use_skill", + &skill.name, + "combat", + detail_text, + format!("冷却中,还需 {} 回合", remaining_cooldown).as_str(), + payload, + ); + } + if skill.mana_cost > player_mana { + return build_disabled_runtime_story_option( + "battle_use_skill", + &skill.name, + "combat", + detail_text, + "灵力不足", + payload, + ); + } + RuntimeStoryOptionView { + detail_text, + payload, + ..build_static_runtime_story_option("battle_use_skill", &skill.name, "combat") + } + }) + .collect() +} + +fn tick_player_skill_cooldowns(game_state: &mut Value, turns: i32) { if turns <= 0 { return; } @@ -168,14 +422,14 @@ pub(super) fn tick_player_skill_cooldowns(game_state: &mut Value, turns: i32) { } } -pub(super) fn reduce_player_skill_cooldowns(game_state: &mut Value, turns: i32) { +fn reduce_player_skill_cooldowns(game_state: &mut Value, turns: i32) { if turns <= 0 { return; } tick_player_skill_cooldowns(game_state, turns); } -pub(super) fn set_player_skill_cooldown(game_state: &mut Value, skill_id: &str, turns: i32) { +fn set_player_skill_cooldown(game_state: &mut Value, skill_id: &str, turns: i32) { let root = ensure_json_object(game_state); let cooldowns = root .entry("playerSkillCooldowns".to_string()) @@ -223,7 +477,118 @@ fn find_player_inventory_item(game_state: &Value, item_id: &str) -> Option i32 { +/// 旧前端一次只展示一个“推荐”战斗物品,这里继续用确定性打分,避免展示面漂移。 +fn pick_preferred_battle_inventory_item(game_state: &Value) -> Option { + let has_cooling_skill = read_player_skill_cooldowns(game_state) + .values() + .any(|remaining| *remaining > 0); + let player_hp = read_i32_field(game_state, "playerHp").unwrap_or(0); + let player_max_hp = read_i32_field(game_state, "playerMaxHp") + .unwrap_or(1) + .max(1); + let player_mana = read_i32_field(game_state, "playerMana").unwrap_or(0); + let player_max_mana = read_i32_field(game_state, "playerMaxMana") + .unwrap_or(1) + .max(1); + let hp_low = player_hp * 100 <= player_max_hp * 45; + let mana_low = player_mana * 100 <= player_max_mana * 45; + + read_player_inventory_items(game_state) + .into_iter() + .filter(|item| item.quantity > 0 && item.use_profile.is_some()) + .filter_map(|item| { + let effect = item.use_profile.as_ref()?; + let mut score = effect.build_buffs.len() as i32 * 8; + score += effect.hp_restore * if hp_low { 3 } else { 1 }; + score += effect.mana_restore * if mana_low { 2 } else { 1 }; + score += effect.cooldown_reduction * if has_cooling_skill { 18 } else { 6 }; + Some((score, item)) + }) + .max_by(|left, right| { + left.0 + .cmp(&right.0) + .then_with(|| left.1.name.cmp(&right.1.name).reverse()) + }) + .map(|(_, item)| item) +} + +fn build_battle_item_summary(effect: &BattleInventoryUseProfile) -> String { + let mut parts = Vec::new(); + if effect.hp_restore > 0 { + parts.push(format!("回血 {}", effect.hp_restore)); + } + if effect.mana_restore > 0 { + parts.push(format!("回蓝 {}", effect.mana_restore)); + } + if effect.cooldown_reduction > 0 { + parts.push(format!("冷却 -{}", effect.cooldown_reduction)); + } + if !effect.build_buffs.is_empty() { + let buff_names = effect + .build_buffs + .iter() + .filter_map(|buff| read_optional_string_field(buff, "name")) + .collect::>(); + if !buff_names.is_empty() { + parts.push(format!("增益 {}", buff_names.join("、"))); + } + } + if parts.is_empty() { + "立即结算一次物品效果".to_string() + } else { + parts.join(" / ") + } +} + +fn build_static_runtime_story_option( + function_id: &str, + action_text: &str, + scope: &str, +) -> RuntimeStoryOptionView { + RuntimeStoryOptionView { + function_id: function_id.to_string(), + action_text: action_text.to_string(), + detail_text: None, + scope: scope.to_string(), + interaction: None, + payload: None, + disabled: None, + reason: None, + } +} + +fn build_runtime_story_option_with_payload( + function_id: &str, + action_text: &str, + scope: &str, + detail_text: Option, + payload: Value, +) -> RuntimeStoryOptionView { + RuntimeStoryOptionView { + detail_text, + payload: Some(payload), + ..build_static_runtime_story_option(function_id, action_text, scope) + } +} + +fn build_disabled_runtime_story_option( + function_id: &str, + action_text: &str, + scope: &str, + detail_text: Option, + reason: &str, + payload: Option, +) -> RuntimeStoryOptionView { + RuntimeStoryOptionView { + detail_text, + payload, + disabled: Some(true), + reason: Some(reason.to_string()), + ..build_static_runtime_story_option(function_id, action_text, scope) + } +} + +fn battle_victory_experience_reward(game_state: &Value) -> i32 { let hostile = read_array_field(game_state, "sceneHostileNpcs") .first() .copied() @@ -322,7 +687,7 @@ fn battle_action_numbers( } } -pub(super) fn build_battle_action_plan( +fn build_battle_action_plan( game_state: &Value, request: &RuntimeStoryActionRequest, function_id: &str, @@ -433,184 +798,17 @@ fn build_inventory_use_battle_action_plan( }) } -pub(super) fn battle_action_toast( +fn battle_action_toast( function_id: &str, request: &RuntimeStoryActionRequest, ) -> Option { if function_id != "inventory_use" { return None; } - let item_name = request + request .action .payload .as_ref() - .and_then(|payload| read_optional_string_field(payload, "itemId")); - item_name.map(|_| "Build 增益已写回当前快照".to_string()) -} - -pub(super) fn build_battle_runtime_story_options(game_state: &Value) -> Vec { - let mut options = vec![ - RuntimeStoryOptionView { - detail_text: Some(build_basic_attack_detail_text(game_state)), - ..build_static_runtime_story_option("battle_attack_basic", "普通攻击", "combat") - }, - RuntimeStoryOptionView { - detail_text: Some("回血 12 / 回蓝 9 / 冷却 -1".to_string()), - ..build_static_runtime_story_option("battle_recover_breath", "恢复", "combat") - }, - ]; - - let preferred_item = pick_preferred_battle_inventory_item(game_state); - if let Some(item) = preferred_item { - let effect = item - .use_profile - .expect("preferred battle item must have use profile"); - options.push(build_runtime_story_option_with_payload( - "inventory_use", - &format!("使用物品:{}", item.name), - "combat", - Some(build_battle_item_summary(&effect)), - json!({ - "itemId": item.id - }), - )); - } else { - options.push(build_disabled_runtime_story_option( - "inventory_use", - "使用物品", - "combat", - Some("当前没有可直接结算的战斗消耗品".to_string()), - "暂无可用物品", - None, - )); - } - - options.extend(build_battle_skill_runtime_story_options(game_state)); - options.push(build_static_runtime_story_option( - "battle_escape_breakout", - "强行脱离战斗", - "combat", - )); - options -} - -fn build_basic_attack_detail_text(game_state: &Value) -> String { - let strength = read_field(game_state, "playerCharacter") - .and_then(|character| read_field(character, "attributes")) - .and_then(|attributes| read_i32_field(attributes, "strength")) - .unwrap_or(8); - let agility = read_field(game_state, "playerCharacter") - .and_then(|character| read_field(character, "attributes")) - .and_then(|attributes| read_i32_field(attributes, "agility")) - .unwrap_or(0); - let preview_damage = ((strength * 85 + agility * 45) / 100).max(8); - format!("不耗蓝 / 伤害 {preview_damage}") -} - -fn build_battle_skill_runtime_story_options(game_state: &Value) -> Vec { - let cooldowns = read_player_skill_cooldowns(game_state); - let player_mana = read_i32_field(game_state, "playerMana").unwrap_or(0); - read_player_skills(game_state) - .into_iter() - .map(|skill| { - let detail_text = Some(format!( - "耗蓝 {} / 伤害 {} / 冷却 {}", - skill.mana_cost.max(0), - skill.damage.max(0), - skill.cooldown_turns.max(0) - )); - let payload = Some(json!({ - "skillId": skill.id - })); - let remaining_cooldown = cooldowns.get(skill.id.as_str()).copied().unwrap_or(0); - if remaining_cooldown > 0 { - return build_disabled_runtime_story_option( - "battle_use_skill", - &skill.name, - "combat", - detail_text, - format!("冷却中,还需 {} 回合", remaining_cooldown).as_str(), - payload, - ); - } - if skill.mana_cost > player_mana { - return build_disabled_runtime_story_option( - "battle_use_skill", - &skill.name, - "combat", - detail_text, - "灵力不足", - payload, - ); - } - RuntimeStoryOptionView { - detail_text, - payload, - ..build_static_runtime_story_option("battle_use_skill", &skill.name, "combat") - } - }) - .collect() -} - -/// 旧前端一次只展示一个“推荐”战斗物品,这里继续用确定性打分,避免展示面漂移。 -fn pick_preferred_battle_inventory_item(game_state: &Value) -> Option { - let has_cooling_skill = read_player_skill_cooldowns(game_state) - .values() - .any(|remaining| *remaining > 0); - let player_hp = read_i32_field(game_state, "playerHp").unwrap_or(0); - let player_max_hp = read_i32_field(game_state, "playerMaxHp") - .unwrap_or(1) - .max(1); - let player_mana = read_i32_field(game_state, "playerMana").unwrap_or(0); - let player_max_mana = read_i32_field(game_state, "playerMaxMana") - .unwrap_or(1) - .max(1); - let hp_low = player_hp * 100 <= player_max_hp * 45; - let mana_low = player_mana * 100 <= player_max_mana * 45; - - read_player_inventory_items(game_state) - .into_iter() - .filter(|item| item.quantity > 0 && item.use_profile.is_some()) - .filter_map(|item| { - let effect = item.use_profile.as_ref()?; - let mut score = effect.build_buffs.len() as i32 * 8; - score += effect.hp_restore * if hp_low { 3 } else { 1 }; - score += effect.mana_restore * if mana_low { 2 } else { 1 }; - score += effect.cooldown_reduction * if has_cooling_skill { 18 } else { 6 }; - Some((score, item)) - }) - .max_by(|left, right| { - left.0 - .cmp(&right.0) - .then_with(|| left.1.name.cmp(&right.1.name).reverse()) - }) - .map(|(_, item)| item) -} - -fn build_battle_item_summary(effect: &BattleInventoryUseProfile) -> String { - let mut parts = Vec::new(); - if effect.hp_restore > 0 { - parts.push(format!("回血 {}", effect.hp_restore)); - } - if effect.mana_restore > 0 { - parts.push(format!("回蓝 {}", effect.mana_restore)); - } - if effect.cooldown_reduction > 0 { - parts.push(format!("冷却 -{}", effect.cooldown_reduction)); - } - if !effect.build_buffs.is_empty() { - let buff_names = effect - .build_buffs - .iter() - .filter_map(|buff| read_optional_string_field(buff, "name")) - .collect::>(); - if !buff_names.is_empty() { - parts.push(format!("增益 {}", buff_names.join("、"))); - } - } - if parts.is_empty() { - "立即结算一次物品效果".to_string() - } else { - parts.join(" / ") - } + .and_then(|payload| read_optional_string_field(payload, "itemId")) + .map(|_| "Build 增益已写回当前快照".to_string()) } diff --git a/server-rs/crates/api-server/src/runtime_story/compat/core.rs b/server-rs/crates/module-runtime-story-compat/src/core.rs similarity index 74% rename from server-rs/crates/api-server/src/runtime_story/compat/core.rs rename to server-rs/crates/module-runtime-story-compat/src/core.rs index b13cf169..9662c43a 100644 --- a/server-rs/crates/api-server/src/runtime_story/compat/core.rs +++ b/server-rs/crates/module-runtime-story-compat/src/core.rs @@ -1,19 +1,25 @@ -use super::*; +use serde_json::{Map, Value, json}; +use shared_kernel::format_rfc3339; +use time::OffsetDateTime; -pub(super) fn clear_encounter_state(game_state: &mut Value) { +/// Runtime story compat 的纯 JSON 快照工具层。 +/// +/// 这里不允许引入 HTTP、AppState 或持久化依赖,保证后续 battle/forge/npc/quest +/// 规则迁入独立 crate 时可以继续复用同一批状态读写函数。 +pub 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"); } -pub(super) fn clear_encounter_only(game_state: &mut Value) { +pub 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())); } -pub(super) fn append_story_history(game_state: &mut Value, action_text: &str, result_text: &str) { +pub 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()) @@ -34,7 +40,7 @@ pub(super) fn append_story_history(game_state: &mut Value, action_text: &str, re })); } -pub(super) fn increment_runtime_stat(game_state: &mut Value, key: &str, delta: i32) { +pub 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()) @@ -53,7 +59,7 @@ pub(super) fn increment_runtime_stat(game_state: &mut Value, key: &str, delta: i stats.insert(key.to_string(), json!((previous + delta).max(0))); } -pub(super) fn add_player_currency(game_state: &mut Value, delta: i32) { +pub 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, @@ -62,7 +68,7 @@ pub(super) fn add_player_currency(game_state: &mut Value, delta: i32) { ); } -pub(super) fn add_player_inventory_items(game_state: &mut Value, additions: Vec) { +pub fn add_player_inventory_items(game_state: &mut Value, additions: Vec) { if additions.is_empty() { return; } @@ -80,11 +86,7 @@ pub(super) fn add_player_inventory_items(game_state: &mut Value, additions: Vec< items.extend(additions); } -pub(super) fn grant_player_progression_experience( - game_state: &mut Value, - amount: i32, - source: &str, -) { +pub fn grant_player_progression_experience(game_state: &mut Value, amount: i32, source: &str) { if amount <= 0 { return; } @@ -125,9 +127,9 @@ pub(super) fn grant_player_progression_experience( ); } -pub(super) const MAX_PLAYER_LEVEL: i32 = 20; +pub const MAX_PLAYER_LEVEL: i32 = 20; -pub(super) fn xp_to_next_level_for(level: i32) -> i32 { +pub fn xp_to_next_level_for(level: i32) -> i32 { if level >= MAX_PLAYER_LEVEL { 0 } else { @@ -136,7 +138,7 @@ pub(super) fn xp_to_next_level_for(level: i32) -> i32 { } } -pub(super) fn cumulative_xp_required(level: i32) -> i32 { +pub 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 { @@ -145,7 +147,7 @@ pub(super) fn cumulative_xp_required(level: i32) -> i32 { total } -pub(super) fn resolve_progression_level(total_xp: i32) -> i32 { +pub 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 { @@ -157,7 +159,7 @@ pub(super) fn resolve_progression_level(total_xp: i32) -> i32 { resolved_level } -pub(super) fn append_active_build_buffs(game_state: &mut Value, additions: Vec) { +pub fn append_active_build_buffs(game_state: &mut Value, additions: Vec) { if additions.is_empty() { return; } @@ -174,7 +176,7 @@ pub(super) fn append_active_build_buffs(game_state: &mut Value, additions: Vec Option { +pub 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)) } -pub(super) fn read_runtime_session_id(game_state: &Value) -> Option { +pub fn read_runtime_session_id(game_state: &Value) -> Option { read_optional_string_field(game_state, "runtimeSessionId") } -pub(super) fn read_field<'a>(value: &'a Value, key: &str) -> Option<&'a Value> { +pub fn read_field<'a>(value: &'a Value, key: &str) -> Option<&'a Value> { value.as_object()?.get(key) } -pub(super) fn read_object_field<'a>(value: &'a Value, key: &str) -> Option<&'a Value> { +pub fn read_object_field<'a>(value: &'a Value, key: &str) -> Option<&'a Value> { let field = read_field(value, key)?; field.is_object().then_some(field) } -pub(super) fn read_array_field<'a>(value: &'a Value, key: &str) -> Vec<&'a Value> { +pub fn read_array_field<'a>(value: &'a Value, key: &str) -> Vec<&'a Value> { read_field(value, key) .and_then(Value::as_array) .map(|items| items.iter().collect()) .unwrap_or_default() } -pub(super) fn read_required_string_field(value: &Value, key: &str) -> Option { +pub fn read_required_string_field(value: &Value, key: &str) -> Option { normalize_required_string(read_field(value, key)?.as_str()?) } -pub(super) fn read_optional_string_field(value: &Value, key: &str) -> Option { +pub fn read_optional_string_field(value: &Value, key: &str) -> Option { normalize_optional_string(read_field(value, key).and_then(Value::as_str)) } -pub(super) fn read_bool_field(value: &Value, key: &str) -> Option { +pub fn read_bool_field(value: &Value, key: &str) -> Option { read_field(value, key).and_then(Value::as_bool) } -pub(super) fn read_i32_field(value: &Value, key: &str) -> Option { +pub fn read_i32_field(value: &Value, key: &str) -> Option { read_field(value, key) .and_then(Value::as_i64) .and_then(|number| i32::try_from(number).ok()) } -pub(super) fn read_u32_field(value: &Value, key: &str) -> Option { +pub fn read_u32_field(value: &Value, key: &str) -> Option { read_field(value, key) .and_then(Value::as_u64) .and_then(|number| u32::try_from(number).ok()) } -pub(super) fn write_i32_field(value: &mut Value, key: &str, field_value: i32) { +pub fn write_i32_field(value: &mut Value, key: &str, field_value: i32) { ensure_json_object(value).insert(key.to_string(), json!(field_value)); } -pub(super) fn write_u32_field(value: &mut Value, key: &str, field_value: u32) { +pub fn write_u32_field(value: &mut Value, key: &str, field_value: u32) { ensure_json_object(value).insert(key.to_string(), json!(field_value)); } -pub(super) fn write_bool_field(value: &mut Value, key: &str, field_value: bool) { +pub fn write_bool_field(value: &mut Value, key: &str, field_value: bool) { ensure_json_object(value).insert(key.to_string(), Value::Bool(field_value)); } -pub(super) fn write_string_field(value: &mut Value, key: &str, field_value: &str) { +pub 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())); } -pub(super) fn write_null_field(value: &mut Value, key: &str) { +pub fn write_null_field(value: &mut Value, key: &str) { ensure_json_object(value).insert(key.to_string(), Value::Null); } -pub(super) fn ensure_json_object(value: &mut Value) -> &mut Map { +pub 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") } -pub(super) fn normalize_required_string(value: &str) -> Option { +pub fn normalize_required_string(value: &str) -> Option { let trimmed = value.trim(); (!trimmed.is_empty()).then(|| trimmed.to_string()) } -pub(super) fn normalize_optional_string(value: Option<&str>) -> Option { +pub fn normalize_optional_string(value: Option<&str>) -> Option { value.and_then(normalize_required_string) } -pub(super) fn format_now_rfc3339() -> String { +pub fn format_now_rfc3339() -> String { format_rfc3339(OffsetDateTime::now_utc()).unwrap_or_else(|_| "1970-01-01T00:00:00Z".to_string()) } diff --git a/server-rs/crates/module-runtime-story-compat/src/forge.rs b/server-rs/crates/module-runtime-story-compat/src/forge.rs new file mode 100644 index 00000000..36c3a949 --- /dev/null +++ b/server-rs/crates/module-runtime-story-compat/src/forge.rs @@ -0,0 +1,426 @@ +use serde_json::{Value, json}; + +use crate::{ + equipment_slot_label, item_rarity_key, read_array_field, read_field, read_i32_field, + read_inventory_item_name, read_optional_string_field, read_u32_field, + remove_inventory_item_from_list, resolve_equipment_slot_for_item, +}; + +/// 这批定义只服务 runtime story compat 的确定性锻造链。 +/// +/// 当前仍然保持旧快照态结算口径,不引入 HTTP / AppState / 持久化边界。 +pub(crate) struct ForgeRequirementDefinition { + pub(crate) quantity: i32, + pub(crate) matcher: ForgeRequirementMatcher, +} + +pub(crate) enum ForgeRequirementMatcher { + Named(&'static str), + AnyMaterial, +} + +pub(crate) struct ForgeRecipeDefinition { + pub(crate) id: &'static str, + pub(crate) name: &'static str, + pub(crate) currency_cost: i32, + pub(crate) requirements: Vec, +} + +pub(crate) struct ReforgeCostDefinition { + pub(crate) currency_cost: i32, + pub(crate) requirements: Vec, +} + +pub(crate) fn forge_recipe_definition(recipe_id: &str) -> Option { + match recipe_id { + "synthesis-refined-ingot" => Some(ForgeRecipeDefinition { + id: "synthesis-refined-ingot", + name: "压炼锭材", + currency_cost: 18, + requirements: vec![ForgeRequirementDefinition { + quantity: 3, + matcher: ForgeRequirementMatcher::AnyMaterial, + }], + }), + "forge-duelist-blade" => Some(ForgeRecipeDefinition { + id: "forge-duelist-blade", + name: "锻造 百炼追风剑", + currency_cost: 72, + requirements: vec![ + ForgeRequirementDefinition { + quantity: 2, + matcher: ForgeRequirementMatcher::Named("精炼锭材"), + }, + ForgeRequirementDefinition { + quantity: 1, + matcher: ForgeRequirementMatcher::Named("快剑精粹"), + }, + ], + }), + _ => None, + } +} + +pub(crate) fn reforge_cost_definition(slot_id: Option<&str>) -> ReforgeCostDefinition { + if slot_id == Some("relic") { + return ReforgeCostDefinition { + currency_cost: 52, + requirements: vec![ForgeRequirementDefinition { + quantity: 1, + matcher: ForgeRequirementMatcher::Named("凝光纱"), + }], + }; + } + ReforgeCostDefinition { + currency_cost: 46, + requirements: vec![ForgeRequirementDefinition { + quantity: 1, + matcher: ForgeRequirementMatcher::Named("精炼锭材"), + }], + } +} + +fn forge_requirement_matches(item: &Value, requirement: &ForgeRequirementDefinition) -> bool { + match requirement.matcher { + ForgeRequirementMatcher::Named(name) => { + read_optional_string_field(item, "name").as_deref() == Some(name) + } + ForgeRequirementMatcher::AnyMaterial => { + read_array_field(item, "tags") + .into_iter() + .filter_map(Value::as_str) + .any(|tag| tag == "material") + || read_optional_string_field(item, "category") + .is_some_and(|category| category.contains("材料")) + } + } +} + +pub(crate) fn apply_forge_requirements_if_possible( + inventory: &[Value], + requirements: &[ForgeRequirementDefinition], +) -> Option> { + let mut next_inventory = inventory.to_vec(); + for requirement in requirements { + let mut remaining = requirement.quantity.max(0); + let snapshot = next_inventory.clone(); + for item in snapshot { + if remaining <= 0 { + break; + } + if !forge_requirement_matches(&item, requirement) { + continue; + } + let item_id = read_optional_string_field(&item, "id")?; + let item_quantity = read_i32_field(&item, "quantity").unwrap_or(0).max(0); + let consumed = remaining.min(item_quantity); + next_inventory = + remove_inventory_item_from_list(next_inventory, item_id.as_str(), consumed); + remaining -= consumed; + } + if remaining > 0 { + return None; + } + } + Some(next_inventory) +} + +pub fn build_runtime_material_item( + game_state: &Value, + name: &str, + quantity: i32, + tags: &[&str], + rarity: &str, +) -> Value { + let mut all_tags = vec!["material".to_string()]; + all_tags.extend(tags.iter().map(|tag| (*tag).to_string())); + json!({ + "id": generate_runtime_item_id(game_state, format!("forge-material:{name}").as_str()), + "category": "材料", + "name": name, + "quantity": quantity.max(1), + "rarity": rarity, + "tags": all_tags, + "buildProfile": { + "role": "工巧", + "tags": tags, + "synergy": tags, + "forgeRank": 0 + } + }) +} + +pub fn build_runtime_equipment_item( + game_state: &Value, + name: &str, + slot_id: &str, + rarity: &str, + description: &str, + role: &str, + tags: &[&str], + synergy: &[&str], + stat_profile: Value, +) -> Value { + let slot_tag = match slot_id { + "weapon" => "weapon", + "armor" => "armor", + _ => "relic", + }; + let mut next_tags = vec![slot_tag.to_string()]; + next_tags.extend(tags.iter().map(|tag| (*tag).to_string())); + json!({ + "id": generate_runtime_item_id(game_state, format!("forge-equip:{name}").as_str()), + "category": equipment_slot_label(slot_id), + "name": name, + "description": description, + "quantity": 1, + "rarity": rarity, + "tags": next_tags, + "equipmentSlotId": slot_id, + "statProfile": stat_profile, + "buildProfile": { + "role": role, + "tags": tags, + "synergy": synergy, + "forgeRank": 1 + } + }) +} + +pub(crate) fn build_forge_recipe_result_item( + game_state: &Value, + recipe_id: &str, + _world_type: Option<&str>, +) -> Value { + match recipe_id { + "synthesis-refined-ingot" => { + build_runtime_material_item(game_state, "精炼锭材", 1, &["工巧", "守御"], "rare") + } + "forge-duelist-blade" => build_runtime_equipment_item( + game_state, + "百炼追风剑", + "weapon", + "epic", + "为快剑与追身构筑准备的锻造兵刃。", + "快剑", + &["快剑", "突进", "追击"], + &["快剑", "突进", "追击"], + json!({ + "maxManaBonus": 10, + "outgoingDamageBonus": 0.20 + }), + ), + _ => build_runtime_material_item(game_state, "临时锻造产物", 1, &["工巧"], "common"), + } +} + +fn build_tag_essence_item(game_state: &Value, tag: &str) -> Value { + build_runtime_material_item( + game_state, + format!("{tag}精粹").as_str(), + 1, + &[tag, "工巧"], + "rare", + ) +} + +pub(crate) fn build_dismantle_outputs(game_state: &Value, item: &Value) -> Option> { + let slot_id = resolve_equipment_slot_for_item(item); + if slot_id.is_none() && read_field(item, "buildProfile").is_none() { + return None; + } + let rarity_scale = match item_rarity_key(item).as_str() { + "legendary" => 5, + "epic" => 4, + "rare" => 3, + "uncommon" => 2, + _ => 1, + }; + let mut outputs = Vec::new(); + match slot_id { + Some("weapon") => outputs.push(build_runtime_material_item( + game_state, + "武器残片", + rarity_scale, + &["工巧", "重击"], + "uncommon", + )), + Some("armor") => outputs.push(build_runtime_material_item( + game_state, + "甲片", + rarity_scale, + &["工巧", "守御"], + "uncommon", + )), + Some("relic") => outputs.push(build_runtime_material_item( + game_state, + "灵饰碎片", + rarity_scale, + &["工巧", "法力"], + "uncommon", + )), + _ => outputs.push(build_runtime_material_item( + game_state, + "零散材料", + ((rarity_scale + 1) / 2).max(1), + &["工巧"], + "uncommon", + )), + } + + let mut build_tags = read_field(item, "buildProfile") + .map(|profile| { + let mut tags = read_array_field(profile, "tags") + .into_iter() + .filter_map(Value::as_str) + .map(str::to_string) + .collect::>(); + if let Some(role) = read_optional_string_field(profile, "role") { + tags.push(role); + } + tags + }) + .unwrap_or_default(); + build_tags.sort(); + build_tags.dedup(); + let tag_limit = if item_rarity_key(item) == "legendary" { + 3 + } else { + 2 + }; + for tag in build_tags.into_iter().take(tag_limit) { + outputs.push(build_tag_essence_item(game_state, tag.as_str())); + } + Some(outputs) +} + +pub(crate) fn build_reforged_item(game_state: &Value, item: &Value) -> Option { + let slot_id = resolve_equipment_slot_for_item(item)?; + let build_profile = read_field(item, "buildProfile")?; + let mut next_tags = read_array_field(build_profile, "tags") + .into_iter() + .filter_map(Value::as_str) + .map(str::to_string) + .collect::>(); + let extra_tag = match slot_id { + "weapon" => "追击", + "armor" => "护体", + _ => "法力", + }; + next_tags.push(extra_tag.to_string()); + next_tags.sort(); + next_tags.dedup(); + next_tags.truncate(3); + + let source_name = read_inventory_item_name(item); + let next_name = if source_name.contains('·') && source_name.contains("重铸") { + source_name.clone() + } else { + format!("{source_name}·重铸") + }; + let stat_profile = read_field(item, "statProfile"); + let outgoing_damage_bonus = stat_profile + .and_then(|profile| read_field(profile, "outgoingDamageBonus")) + .and_then(Value::as_f64) + .unwrap_or(0.0); + let incoming_damage_multiplier = stat_profile + .and_then(|profile| read_field(profile, "incomingDamageMultiplier")) + .and_then(Value::as_f64); + let current_forge_rank = read_i32_field(build_profile, "forgeRank").unwrap_or(0); + let mut tags = read_array_field(item, "tags") + .into_iter() + .filter_map(Value::as_str) + .map(str::to_string) + .collect::>(); + tags.sort(); + tags.dedup(); + + Some(json!({ + "id": generate_runtime_item_id(game_state, format!("reforge:{source_name}").as_str()), + "category": read_optional_string_field(item, "category") + .unwrap_or_else(|| equipment_slot_label(slot_id).to_string()), + "name": next_name, + "description": read_optional_string_field(item, "description"), + "quantity": 1, + "rarity": item_rarity_key(item), + "tags": tags, + "equipmentSlotId": slot_id, + "statProfile": { + "maxHpBonus": stat_profile + .and_then(|profile| read_i32_field(profile, "maxHpBonus")) + .unwrap_or(0) + if slot_id == "armor" { 10 } else { 4 }, + "maxManaBonus": stat_profile + .and_then(|profile| read_i32_field(profile, "maxManaBonus")) + .unwrap_or(0) + if slot_id == "relic" { 10 } else { 4 }, + "outgoingDamageBonus": ((outgoing_damage_bonus + 0.03) * 1000.0).round() / 1000.0, + "incomingDamageMultiplier": if let Some(multiplier) = incoming_damage_multiplier { + (((multiplier - 0.03).max(0.72)) * 1000.0).round() / 1000.0 + } else if slot_id == "armor" { + 0.94 + } else { + 0.97 + } + }, + "buildProfile": { + "role": read_optional_string_field(build_profile, "role"), + "tags": next_tags, + "synergy": read_array_field(build_profile, "tags") + .into_iter() + .filter_map(Value::as_str) + .map(str::to_string) + .chain(std::iter::once(extra_tag.to_string())) + .collect::>() + .into_iter() + .collect::>(), + "forgeRank": current_forge_rank + 1 + } + })) +} + +pub(crate) fn build_forge_success_text( + action: &str, + recipe_name: Option<&str>, + source_item_name: Option<&str>, + created_item_name: Option<&str>, + output_names: &[String], + currency_text: Option, +) -> String { + match action { + "craft" => format!( + "你在工坊中完成了{},获得了{}{}。", + recipe_name.unwrap_or("目标配方"), + created_item_name.unwrap_or("目标物品"), + currency_text + .map(|text| format!(",并支付了{text}")) + .unwrap_or_default() + ), + "reforge" => format!( + "你消耗材料重新淬炼了{},最终得到{}{}。", + source_item_name.unwrap_or("目标物品"), + created_item_name.unwrap_or("重铸产物"), + currency_text + .map(|text| format!(",并支付了{text}")) + .unwrap_or_default() + ), + _ => format!( + "你拆解了{},回收出{}。", + source_item_name.unwrap_or("目标物品"), + output_names.join("、") + ), + } +} + +pub fn format_currency_text(value: i32, world_type: Option<&str>) -> String { + let currency_name = match world_type { + Some("XIANXIA") => "灵石", + Some("WUXIA") => "铜钱", + _ => "钱币", + }; + format!("{value} {currency_name}") +} + +fn generate_runtime_item_id(game_state: &Value, prefix: &str) -> String { + let version = read_u32_field(game_state, "runtimeActionVersion").unwrap_or(0); + let inventory_len = read_array_field(game_state, "playerInventory").len(); + format!("{prefix}:{version}:{inventory_len}") +} diff --git a/server-rs/crates/api-server/src/runtime_story/compat/forge_actions.rs b/server-rs/crates/module-runtime-story-compat/src/forge_actions.rs similarity index 86% rename from server-rs/crates/api-server/src/runtime_story/compat/forge_actions.rs rename to server-rs/crates/module-runtime-story-compat/src/forge_actions.rs index 16d0acc9..03d3c6b5 100644 --- a/server-rs/crates/api-server/src/runtime_story/compat/forge_actions.rs +++ b/server-rs/crates/module-runtime-story-compat/src/forge_actions.rs @@ -1,6 +1,25 @@ -use super::*; +use serde_json::Value; -pub(super) fn resolve_forge_craft_action( +use shared_contracts::runtime_story::RuntimeStoryActionRequest; + +use crate::{ + StoryResolution, add_inventory_items_to_list, build_current_build_toast, current_world_type, + ensure_inventory_action_available, find_player_inventory_entry, read_i32_field, + read_inventory_item_name, read_optional_string_field, read_player_inventory_values, + remove_inventory_item_from_list, resolve_action_text, resolve_equipment_slot_for_item, + write_i32_field, write_player_inventory_values, +}; + +use super::forge::{ + apply_forge_requirements_if_possible, build_dismantle_outputs, build_forge_recipe_result_item, + build_forge_success_text, build_reforged_item, forge_recipe_definition, format_currency_text, + reforge_cost_definition, +}; + +/// 锻造动作编排已经不再依赖 `api-server` 的 HTTP 边界。 +/// +/// 这里继续沿用 compat 快照态结算,后续可直接被 `api-server` 外壳或真相态桥接层复用。 +pub fn resolve_forge_craft_action( game_state: &mut Value, request: &RuntimeStoryActionRequest, ) -> Result { @@ -68,7 +87,7 @@ pub(super) fn resolve_forge_craft_action( }) } -pub(super) fn resolve_forge_dismantle_action( +pub fn resolve_forge_dismantle_action( game_state: &mut Value, request: &RuntimeStoryActionRequest, ) -> Result { @@ -96,10 +115,7 @@ pub(super) fn resolve_forge_dismantle_action( next_inventory = remove_inventory_item_from_list(next_inventory, item_id.as_str(), 1); next_inventory = add_inventory_items_to_list(next_inventory, outputs.clone()); write_player_inventory_values(game_state, next_inventory); - let output_names = outputs - .iter() - .map(read_inventory_item_name) - .collect::>(); + let output_names = outputs.iter().map(read_inventory_item_name).collect::>(); Ok(StoryResolution { action_text: resolve_action_text( @@ -123,7 +139,7 @@ pub(super) fn resolve_forge_dismantle_action( }) } -pub(super) fn resolve_forge_reforge_action( +pub fn resolve_forge_reforge_action( game_state: &mut Value, request: &RuntimeStoryActionRequest, ) -> Result { diff --git a/server-rs/crates/module-runtime-story-compat/src/game_state.rs b/server-rs/crates/module-runtime-story-compat/src/game_state.rs new file mode 100644 index 00000000..3cb9283f --- /dev/null +++ b/server-rs/crates/module-runtime-story-compat/src/game_state.rs @@ -0,0 +1,417 @@ +use serde_json::{Map, Value, json}; + +use crate::{ + ensure_json_object, first_hostile_npc_string_field, read_array_field, read_bool_field, + read_field, read_i32_field, read_object_field, read_optional_string_field, write_i32_field, +}; + +/// 这批 helper 只负责 runtime story compat 的纯快照读写。 +/// +/// 目标是先把 encounter / inventory / equipment 的基础状态工具从 `api-server` +/// 边界模块里收口出来,后续 battle / forge / equipment 规则迁移时直接复用。 +pub fn ensure_inventory_action_available( + game_state: &Value, + missing_character_message: &str, + battle_locked_message: &str, +) -> Result<(), String> { + if read_field(game_state, "playerCharacter").is_none() { + return Err(missing_character_message.to_string()); + } + if read_bool_field(game_state, "inBattle").unwrap_or(false) { + return Err(battle_locked_message.to_string()); + } + Ok(()) +} + +pub fn battle_mode_text(value: &str) -> &'static str { + if value == "spar" { "切磋" } else { "战斗" } +} + +pub 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()) +} + +pub 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()) +} + +pub fn current_encounter_id(game_state: &Value) -> Option { + read_object_field(game_state, "currentEncounter") + .and_then(|encounter| read_optional_string_field(encounter, "id")) +} + +pub fn find_player_inventory_entry<'a>(game_state: &'a Value, item_id: &str) -> Option<&'a Value> { + read_array_field(game_state, "playerInventory") + .into_iter() + .find(|entry| read_optional_string_field(entry, "id").as_deref() == Some(item_id)) +} + +pub fn read_player_inventory_values(game_state: &Value) -> Vec { + read_array_field(game_state, "playerInventory") + .into_iter() + .cloned() + .collect() +} + +pub fn write_player_inventory_values(game_state: &mut Value, items: Vec) { + ensure_json_object(game_state).insert("playerInventory".to_string(), Value::Array(items)); +} + +pub fn read_inventory_item_name(item: &Value) -> String { + read_optional_string_field(item, "name") + .or_else(|| read_optional_string_field(item, "id")) + .unwrap_or_else(|| "未命名物品".to_string()) +} + +pub fn has_giftable_player_inventory(game_state: &Value) -> bool { + read_array_field(game_state, "playerInventory") + .into_iter() + .any(|item| read_i32_field(item, "quantity").unwrap_or(0) > 0) +} + +pub fn clone_inventory_item_with_quantity(item: &Value, quantity: i32) -> Value { + let mut next_item = item.clone(); + if let Some(entry) = next_item.as_object_mut() { + entry.insert("quantity".to_string(), json!(quantity.max(1))); + } + next_item +} + +pub fn normalize_equipped_item(item: &Value) -> Value { + clone_inventory_item_with_quantity(item, 1) +} + +pub fn add_inventory_items_to_list(mut base: Vec, additions: Vec) -> Vec { + for addition in additions { + let add_id = read_optional_string_field(&addition, "id"); + let add_quantity = read_i32_field(&addition, "quantity").unwrap_or(1).max(1); + if let Some(add_id) = add_id { + if let Some(existing) = base.iter_mut().find(|item| { + read_optional_string_field(item, "id").as_deref() == Some(add_id.as_str()) + }) { + let next_quantity = + read_i32_field(existing, "quantity").unwrap_or(0).max(0) + add_quantity; + if let Some(existing_object) = existing.as_object_mut() { + existing_object.insert("quantity".to_string(), json!(next_quantity)); + } + continue; + } + } + base.push(addition); + } + base +} + +pub fn remove_inventory_item_from_list( + mut base: Vec, + item_id: &str, + quantity: i32, +) -> Vec { + if quantity <= 0 { + return base; + } + let Some(index) = base + .iter() + .position(|entry| read_optional_string_field(entry, "id").as_deref() == Some(item_id)) + else { + return base; + }; + let current_quantity = read_i32_field(&base[index], "quantity").unwrap_or(0).max(0); + let next_quantity = current_quantity - quantity; + if next_quantity <= 0 { + base.remove(index); + return base; + } + if let Some(item) = base[index].as_object_mut() { + item.insert("quantity".to_string(), json!(next_quantity)); + } + base +} + +pub fn read_player_equipment_item(game_state: &Value, slot_id: &str) -> Option { + read_field(game_state, "playerEquipment") + .and_then(|equipment| read_field(equipment, slot_id)) + .filter(|item| !item.is_null()) + .cloned() +} + +pub fn write_player_equipment_item(game_state: &mut Value, slot_id: &str, item: Option) { + let root = ensure_json_object(game_state); + let equipment = root + .entry("playerEquipment".to_string()) + .or_insert_with(|| { + json!({ + "weapon": null, + "armor": null, + "relic": null, + }) + }); + if !equipment.is_object() { + *equipment = json!({ + "weapon": null, + "armor": null, + "relic": null, + }); + } + equipment + .as_object_mut() + .expect("playerEquipment should be object") + .insert(slot_id.to_string(), item.unwrap_or(Value::Null)); +} + +pub fn equipment_slot_label(slot_id: &str) -> &'static str { + match slot_id { + "weapon" => "武器", + "armor" => "护甲", + "relic" => "饰品", + _ => "装备", + } +} + +pub fn normalize_equipment_slot_id(slot_id: &str) -> Option<&'static str> { + let normalized = slot_id.trim().to_ascii_lowercase(); + match normalized.as_str() { + "weapon" => Some("weapon"), + "armor" => Some("armor"), + "relic" | "accessory" => Some("relic"), + _ => { + // 兼容旧 payload 里直接传中文槽位名或物品类别文案的情况。 + if slot_id.contains("武器") + || slot_id.contains('剑') + || slot_id.contains('弓') + || slot_id.contains('刀') + || slot_id.contains("拳套") + || slot_id.contains("战刃") + || slot_id.contains('枪') + || slot_id.contains('刃') + { + return Some("weapon"); + } + if slot_id.contains("护甲") + || slot_id.contains('甲') + || slot_id.contains("护臂") + || slot_id.contains('衣') + || slot_id.contains('袍') + || slot_id.contains('铠') + { + return Some("armor"); + } + if slot_id.contains("饰品") + || slot_id.contains("护符") + || slot_id.contains("徽章") + || slot_id.contains('玉') + || slot_id.contains('珠') + || slot_id.contains('坠') + || slot_id.contains('铃') + || slot_id.contains('盘') + || slot_id.contains('令') + || slot_id.contains('匣') + { + return Some("relic"); + } + None + } + } +} + +pub fn resolve_equipment_slot_for_item(item: &Value) -> Option<&'static str> { + if let Some(slot_id) = read_optional_string_field(item, "equipmentSlotId") { + return match slot_id.as_str() { + "weapon" => Some("weapon"), + "armor" => Some("armor"), + "relic" => Some("relic"), + _ => None, + }; + } + let tags = read_array_field(item, "tags") + .into_iter() + .filter_map(|tag| tag.as_str().map(|value| value.to_string())) + .collect::>(); + if tags.iter().any(|tag| tag == "weapon") { + return Some("weapon"); + } + if tags.iter().any(|tag| tag == "armor") { + return Some("armor"); + } + if tags.iter().any(|tag| tag == "relic") { + return Some("relic"); + } + let category_text = read_optional_string_field(item, "category").unwrap_or_default(); + let name_text = read_inventory_item_name(item); + let mixed_text = format!("{category_text} {name_text}"); + if mixed_text.contains("武器") || mixed_text.contains("剑") || mixed_text.contains("刀") { + return Some("weapon"); + } + if mixed_text.contains("护甲") || mixed_text.contains("甲") || mixed_text.contains("袍") { + return Some("armor"); + } + if mixed_text.contains("饰品") || mixed_text.contains("护符") || mixed_text.contains("玉") + { + return Some("relic"); + } + None +} + +pub fn item_rarity_key(item: &Value) -> String { + read_optional_string_field(item, "rarity").unwrap_or_else(|| "common".to_string()) +} + +pub fn equipment_bonus_fallbacks(slot_id: &str, rarity: &str) -> (i32, i32, f64, f64) { + match slot_id { + "weapon" => { + let outgoing = match rarity { + "uncommon" => 0.10, + "rare" => 0.14, + "epic" => 0.20, + "legendary" => 0.28, + _ => 0.06, + }; + (0, 0, outgoing, 1.0) + } + "armor" => { + let hp = match rarity { + "uncommon" => 22, + "rare" => 32, + "epic" => 44, + "legendary" => 58, + _ => 14, + }; + let incoming = match rarity { + "uncommon" => 0.94, + "rare" => 0.90, + "epic" => 0.86, + "legendary" => 0.80, + _ => 0.97, + }; + (hp, 0, 0.0, incoming) + } + _ => { + let mana = match rarity { + "uncommon" => 18, + "rare" => 28, + "epic" => 40, + "legendary" => 54, + _ => 10, + }; + let outgoing = match rarity { + "uncommon" => 0.04, + "rare" => 0.06, + "epic" => 0.09, + "legendary" => 0.12, + _ => 0.02, + }; + (0, mana, outgoing, 1.0) + } + } +} + +pub fn equipment_item_bonuses(item: &Value, slot_id: &str) -> (i32, i32, f64, f64) { + let rarity = item_rarity_key(item); + let fallback = equipment_bonus_fallbacks(slot_id, rarity.as_str()); + let stat_profile = read_field(item, "statProfile"); + let hp_bonus = stat_profile + .and_then(|profile| read_i32_field(profile, "maxHpBonus")) + .unwrap_or(fallback.0); + let mana_bonus = stat_profile + .and_then(|profile| read_i32_field(profile, "maxManaBonus")) + .unwrap_or(fallback.1); + let outgoing_bonus = stat_profile + .and_then(|profile| read_field(profile, "outgoingDamageBonus")) + .and_then(Value::as_f64) + .unwrap_or(fallback.2); + let incoming_multiplier = stat_profile + .and_then(|profile| read_field(profile, "incomingDamageMultiplier")) + .and_then(Value::as_f64) + .unwrap_or(fallback.3); + (hp_bonus, mana_bonus, outgoing_bonus, incoming_multiplier) +} + +pub fn read_equipment_total_bonuses(game_state: &Value) -> (i32, i32, f64, f64) { + let equipment = read_field(game_state, "playerEquipment"); + let mut hp_bonus = 0; + let mut mana_bonus = 0; + let mut outgoing_bonus = 0.0; + let mut incoming_multiplier = 1.0; + for slot_id in ["weapon", "armor", "relic"] { + let Some(item) = equipment.and_then(|value| read_field(value, slot_id)) else { + continue; + }; + if item.is_null() { + continue; + } + let (slot_hp, slot_mana, slot_outgoing, slot_incoming) = + equipment_item_bonuses(item, slot_id); + hp_bonus += slot_hp; + mana_bonus += slot_mana; + outgoing_bonus += slot_outgoing; + incoming_multiplier *= slot_incoming; + } + (hp_bonus, mana_bonus, outgoing_bonus, incoming_multiplier) +} + +pub fn apply_equipment_loadout_to_state(game_state: &mut Value) { + let (hp_bonus, mana_bonus, _outgoing_bonus, _incoming_multiplier) = + read_equipment_total_bonuses(game_state); + let current_max_hp = read_i32_field(game_state, "playerMaxHp") + .unwrap_or(1) + .max(1); + let current_max_mana = read_i32_field(game_state, "playerMaxMana") + .unwrap_or(1) + .max(1); + let current_hp = read_i32_field(game_state, "playerHp").unwrap_or(current_max_hp); + let base_max_hp = current_max_hp + .saturating_sub(read_runtime_equipment_bonus_cache(game_state, "maxHpBonus")) + .max(1); + let base_max_mana = current_max_mana + .saturating_sub(read_runtime_equipment_bonus_cache( + game_state, + "maxManaBonus", + )) + .max(1); + let next_max_hp = base_max_hp.saturating_add(hp_bonus).max(1); + let next_max_mana = base_max_mana.saturating_add(mana_bonus).max(1); + write_i32_field(game_state, "playerMaxHp", next_max_hp); + write_i32_field(game_state, "playerHp", current_hp.min(next_max_hp)); + write_i32_field(game_state, "playerMaxMana", next_max_mana); + write_i32_field(game_state, "playerMana", next_max_mana); + write_runtime_equipment_bonus_cache(game_state, "maxHpBonus", hp_bonus); + write_runtime_equipment_bonus_cache(game_state, "maxManaBonus", mana_bonus); +} + +pub fn read_runtime_equipment_bonus_cache(game_state: &Value, key: &str) -> i32 { + read_field(game_state, "runtimeEquipmentBonusCache") + .and_then(|cache| read_i32_field(cache, key)) + .unwrap_or(0) +} + +pub fn write_runtime_equipment_bonus_cache(game_state: &mut Value, key: &str, value: i32) { + let root = ensure_json_object(game_state); + let cache = root + .entry("runtimeEquipmentBonusCache".to_string()) + .or_insert_with(|| Value::Object(Map::new())); + if !cache.is_object() { + *cache = Value::Object(Map::new()); + } + cache + .as_object_mut() + .expect("runtimeEquipmentBonusCache should be object") + .insert(key.to_string(), json!(value)); +} + +pub fn build_current_build_toast(game_state: &Value) -> String { + let (_hp_bonus, _mana_bonus, outgoing_bonus, _incoming_multiplier) = + read_equipment_total_bonuses(game_state); + let build_multiplier = (1.0 + outgoing_bonus).max(1.0); + format!("当前 Build 倍率 x{build_multiplier:.2}") +} diff --git a/server-rs/crates/module-runtime-story-compat/src/lib.rs b/server-rs/crates/module-runtime-story-compat/src/lib.rs new file mode 100644 index 00000000..cf655652 --- /dev/null +++ b/server-rs/crates/module-runtime-story-compat/src/lib.rs @@ -0,0 +1,148 @@ +use serde_json::Value; +use shared_contracts::runtime_story::{ + RuntimeBattlePresentation, RuntimeStoryActionRequest, RuntimeStoryOptionView, + RuntimeStoryPatch, RuntimeStorySnapshotPayload, +}; + +pub mod battle; +pub mod core; +pub mod forge; +pub mod forge_actions; +pub mod game_state; +pub mod npc_support; +pub mod options; +pub mod view_model; + +pub use core::{ + MAX_PLAYER_LEVEL, add_player_currency, add_player_inventory_items, append_active_build_buffs, + append_story_history, clear_encounter_only, clear_encounter_state, cumulative_xp_required, + ensure_json_object, first_hostile_npc_string_field, format_now_rfc3339, + grant_player_progression_experience, increment_runtime_stat, normalize_optional_string, + normalize_required_string, read_array_field, read_bool_field, read_field, read_i32_field, + read_object_field, read_optional_string_field, read_required_string_field, + read_runtime_session_id, read_u32_field, remove_player_inventory_item, + resolve_progression_level, write_bool_field, write_current_encounter_i32_field, + write_first_hostile_npc_i32_field, write_i32_field, write_null_field, write_string_field, + write_u32_field, xp_to_next_level_for, +}; +pub use game_state::{ + add_inventory_items_to_list, apply_equipment_loadout_to_state, battle_mode_text, + build_current_build_toast, clone_inventory_item_with_quantity, current_encounter_id, + current_encounter_name, current_encounter_name_from_battle, ensure_inventory_action_available, + equipment_bonus_fallbacks, equipment_item_bonuses, equipment_slot_label, + find_player_inventory_entry, has_giftable_player_inventory, item_rarity_key, + normalize_equipped_item, normalize_equipment_slot_id, read_equipment_total_bonuses, + read_inventory_item_name, read_player_equipment_item, read_player_inventory_values, + read_runtime_equipment_bonus_cache, remove_inventory_item_from_list, + resolve_equipment_slot_for_item, write_player_equipment_item, write_player_inventory_values, + write_runtime_equipment_bonus_cache, +}; +pub use forge::{build_runtime_equipment_item, build_runtime_material_item, format_currency_text}; +pub use forge_actions::{ + resolve_forge_craft_action, resolve_forge_dismantle_action, resolve_forge_reforge_action, +}; +pub use npc_support::{ + build_npc_gift_result_text, npc_buyback_price, npc_purchase_price, recruit_companion_to_party, + resolve_npc_gift_affinity_gain, trade_quantity_suffix, +}; +pub use battle::{build_battle_runtime_story_options, resolve_battle_action, restore_player_resource}; +pub use options::{ + build_disabled_runtime_story_option, build_runtime_story_option_from_story_option, + build_runtime_story_option_interaction, build_runtime_story_option_with_payload, + build_static_runtime_story_option, build_story_option_from_runtime_option, infer_option_scope, +}; +pub use view_model::{ + build_runtime_story_companions, build_runtime_story_encounter, build_runtime_story_view_model, + resolve_current_encounter_npc_state, +}; + +pub const CONTINUE_ADVENTURE_FUNCTION_ID: &str = "story_continue_adventure"; +pub const MAX_TASK5_COMPANIONS: usize = 2; + +pub struct StoryResolution { + pub action_text: String, + pub result_text: String, + pub story_text: Option, + pub presentation_options: Option>, + pub saved_current_story: Option, + pub patches: Vec, + pub battle: Option, + pub toast: Option, +} + +pub struct GeneratedStoryPayload { + pub story_text: String, + pub history_result_text: String, + pub presentation_options: Vec, + pub saved_current_story: Value, +} + +pub struct CurrentEncounterNpcQuestContext { + pub npc_id: String, + pub npc_name: String, +} + +pub struct PendingQuestOfferContext { + pub dialogue: Vec, + pub turn_count: i32, + pub custom_input_placeholder: String, + pub quest: Value, + pub quest_id: String, + pub intro_text: Option, +} + +pub struct RuntimeStoryActionResponseParts { + pub requested_session_id: String, + pub server_version: u32, + pub snapshot: RuntimeStorySnapshotPayload, + pub action_text: String, + pub result_text: String, + pub story_text: String, + pub options: Vec, + pub patches: Vec, + pub toast: Option, + pub battle: Option, +} + +pub 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, + } +} + +pub 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()) +} + +pub 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", + ), + } +} + +pub fn current_world_type(game_state: &Value) -> Option { + read_optional_string_field(game_state, "worldType") +} diff --git a/server-rs/crates/api-server/src/runtime_story/compat/npc_support.rs b/server-rs/crates/module-runtime-story-compat/src/npc_support.rs similarity index 85% rename from server-rs/crates/api-server/src/runtime_story/compat/npc_support.rs rename to server-rs/crates/module-runtime-story-compat/src/npc_support.rs index 7ffd4f07..1980bc4f 100644 --- a/server-rs/crates/api-server/src/runtime_story/compat/npc_support.rs +++ b/server-rs/crates/module-runtime-story-compat/src/npc_support.rs @@ -1,6 +1,11 @@ -use super::*; +use serde_json::{Value, json}; -pub(super) fn resolve_npc_gift_affinity_gain(item: &Value) -> i32 { +use crate::{ + MAX_TASK5_COMPANIONS, ensure_json_object, item_rarity_key, normalize_required_string, + read_array_field, read_i32_field, read_inventory_item_name, read_optional_string_field, +}; + +pub fn resolve_npc_gift_affinity_gain(item: &Value) -> i32 { let rarity_score = match item_rarity_key(item).as_str() { "legendary" => 5, "epic" => 4, @@ -25,7 +30,7 @@ pub(super) fn resolve_npc_gift_affinity_gain(item: &Value) -> i32 { (4 + rarity_score * 3 + mana_bonus + healing_bonus).min(24) } -pub(super) fn build_npc_gift_result_text( +pub fn build_npc_gift_result_text( npc_name: &str, item: &Value, affinity_gain: i32, @@ -115,21 +120,21 @@ fn discount_tier_for_affinity(affinity: i32) -> i32 { } } -pub(super) fn npc_purchase_price(item: &Value, affinity: i32) -> i32 { +pub fn npc_purchase_price(item: &Value, affinity: i32) -> i32 { let discount_multiplier = 1.0 - f64::from(discount_tier_for_affinity(affinity)) * 0.08; (f64::from(inventory_item_value(item)) * discount_multiplier) .round() .max(6.0) as i32 } -pub(super) fn npc_buyback_price(item: &Value, affinity: i32) -> i32 { +pub fn npc_buyback_price(item: &Value, affinity: i32) -> i32 { let buyback_multiplier = 0.4 + f64::from(discount_tier_for_affinity(affinity)) * 0.06; (f64::from(inventory_item_value(item)) * buyback_multiplier) .round() .max(4.0) as i32 } -pub(super) fn trade_quantity_suffix(quantity: i32) -> String { +pub fn trade_quantity_suffix(quantity: i32) -> String { if quantity > 1 { format!(" x{quantity}") } else { @@ -137,15 +142,6 @@ pub(super) fn trade_quantity_suffix(quantity: i32) -> String { } } -pub(super) fn format_currency_text(value: i32, world_type: Option<&str>) -> String { - let currency_name = match world_type { - Some("XIANXIA") => "灵石", - Some("WUXIA") => "铜钱", - _ => "钱币", - }; - format!("{value} {currency_name}") -} - fn add_companion_if_absent( game_state: &mut Value, npc_id: &str, @@ -193,20 +189,15 @@ fn remove_companion_by_npc_id(game_state: &mut Value, npc_id: &str) -> Option, ) -> 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), - ); + add_companion_if_absent(game_state, npc_id, None, joined_at_affinity); return Ok(None); } @@ -219,12 +210,7 @@ pub(super) fn recruit_companion_to_party( 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), - ); + .unwrap_or(release_npc_id); + add_companion_if_absent(game_state, npc_id, None, joined_at_affinity); Ok(Some(released_name)) } diff --git a/server-rs/crates/module-runtime-story-compat/src/options.rs b/server-rs/crates/module-runtime-story-compat/src/options.rs new file mode 100644 index 00000000..ad74056a --- /dev/null +++ b/server-rs/crates/module-runtime-story-compat/src/options.rs @@ -0,0 +1,126 @@ +use serde_json::Value; + +use shared_contracts::runtime_story::{ + RuntimeStoryOptionInteraction, RuntimeStoryOptionView, +}; + +use crate::{ + read_bool_field, read_field, read_optional_string_field, read_required_string_field, +}; + +/// 这批 helper 只负责 runtime story option 的纯 DTO 编译,不触碰 HTTP / AppState。 +pub fn infer_option_scope(function_id: &str) -> &'static str { + if function_id.starts_with("battle_") || function_id == "inventory_use" { + "combat" + } else if function_id.starts_with("npc_") { + "npc" + } else { + "story" + } +} + +pub fn build_static_runtime_story_option( + function_id: &str, + action_text: &str, + scope: &str, +) -> RuntimeStoryOptionView { + RuntimeStoryOptionView { + function_id: function_id.to_string(), + action_text: action_text.to_string(), + detail_text: None, + scope: scope.to_string(), + interaction: None, + payload: None, + disabled: None, + reason: None, + } +} + +pub fn build_runtime_story_option_with_payload( + function_id: &str, + action_text: &str, + scope: &str, + detail_text: Option, + payload: Value, +) -> RuntimeStoryOptionView { + RuntimeStoryOptionView { + detail_text, + payload: Some(payload), + ..build_static_runtime_story_option(function_id, action_text, scope) + } +} + +pub fn build_disabled_runtime_story_option( + function_id: &str, + action_text: &str, + scope: &str, + detail_text: Option, + reason: &str, + payload: Option, +) -> RuntimeStoryOptionView { + RuntimeStoryOptionView { + detail_text, + payload, + disabled: Some(true), + reason: Some(reason.to_string()), + ..build_static_runtime_story_option(function_id, action_text, scope) + } +} + +pub fn build_runtime_story_option_from_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()); + + Some(RuntimeStoryOptionView { + scope: infer_option_scope(function_id.as_str()).to_string(), + detail_text: read_optional_string_field(value, "detailText"), + interaction: build_runtime_story_option_interaction(read_field(value, "interaction")), + payload: read_field(value, "runtimePayload") + .or_else(|| read_field(value, "payload")) + .cloned(), + disabled: read_bool_field(value, "disabled"), + reason: read_optional_string_field(value, "disabledReason") + .or_else(|| read_optional_string_field(value, "reason")), + function_id, + action_text, + }) +} + +pub fn build_runtime_story_option_interaction( + value: Option<&Value>, +) -> Option { + let interaction = value?; + match read_required_string_field(interaction, "kind")?.as_str() { + "npc" => Some(RuntimeStoryOptionInteraction::Npc { + npc_id: read_required_string_field(interaction, "npcId")?, + action: read_required_string_field(interaction, "action")?, + quest_id: read_optional_string_field(interaction, "questId"), + }), + _ => None, + } +} + +pub fn build_story_option_from_runtime_option(option: &RuntimeStoryOptionView) -> Value { + serde_json::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, + }) +} diff --git a/server-rs/crates/module-runtime-story-compat/src/view_model.rs b/server-rs/crates/module-runtime-story-compat/src/view_model.rs new file mode 100644 index 00000000..0e7d3322 --- /dev/null +++ b/server-rs/crates/module-runtime-story-compat/src/view_model.rs @@ -0,0 +1,90 @@ +use serde_json::Value; + +use shared_contracts::runtime_story::{ + RuntimeStoryCompanionViewModel, RuntimeStoryEncounterViewModel, RuntimeStoryOptionView, + RuntimeStoryPlayerViewModel, RuntimeStoryStatusViewModel, RuntimeStoryViewModel, +}; + +use crate::{ + read_array_field, read_bool_field, read_i32_field, read_object_field, + read_optional_string_field, read_required_string_field, +}; + +/// 运行时故事 view-model 只依赖快照 JSON 与共享 contract,可脱离 HTTP 层独立编译。 +pub 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", + ), + }, + } +} + +pub fn build_runtime_story_companions( + game_state: &Value, +) -> Vec { + read_array_field(game_state, "companions") + .into_iter() + .filter_map(|entry| { + let npc_id = read_required_string_field(entry, "npcId")?; + Some(RuntimeStoryCompanionViewModel { + npc_id, + character_id: read_optional_string_field(entry, "characterId"), + joined_at_affinity: read_i32_field(entry, "joinedAtAffinity").unwrap_or(0), + }) + }) + .collect() +} + +pub fn build_runtime_story_encounter( + game_state: &Value, +) -> Option { + let encounter = read_object_field(game_state, "currentEncounter")?; + 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 { + id: encounter_id, + kind: read_required_string_field(encounter, "kind").unwrap_or_else(|| "npc".to_string()), + npc_name, + hostile: read_bool_field(encounter, "hostile").unwrap_or(false), + affinity: npc_state.and_then(|state| read_i32_field(state, "affinity")), + recruited: npc_state.and_then(|state| read_bool_field(state, "recruited")), + interaction_active: read_bool_field(game_state, "npcInteractionActive").unwrap_or(false), + battle_mode: read_optional_string_field(game_state, "currentNpcBattleMode"), + }) +} + +pub fn resolve_current_encounter_npc_state<'a>( + game_state: &'a Value, + encounter_id: &str, + npc_name: &str, +) -> Option<&'a Value> { + let npc_states = read_object_field(game_state, "npcStates")?; + + npc_states + .get(encounter_id) + .or_else(|| npc_states.get(npc_name)) +} diff --git a/vite.config.ts b/vite.config.ts index 3b9b9ef1..e47ef377 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -96,6 +96,11 @@ export default defineConfig(({mode}) => { changeOrigin: true, secure: false, }, + '/api/story': { + target: runtimeServerTarget, + changeOrigin: true, + secure: false, + }, '/api/editor': { target: runtimeServerTarget, changeOrigin: true,