M4 runtime story Rust migration wrap-up

This commit is contained in:
2026-04-22 20:10:46 +08:00
parent 35958d5942
commit fa373f0575
31 changed files with 3257 additions and 1556 deletions

View File

@@ -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 再做远端灰度与回退验证

View File

@@ -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/<timestamp>/`,包含 `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 的完整运行环境,不在无外部服务的本地预检中虚假勾选。

View File

@@ -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 放进新 crateroute boundary 仍属于 `api-server`
这一步完成后,`api-server``runtime_story/compat.rs` 应该只负责:
1. 从 HTTP 请求恢复 / 持久化 snapshot
2. 调用 `module-runtime-story-compat` 产出确定性动作结果或状态响应
3. 需要时调用本地 AI 增强
4. 将最终响应包回 `Json<Value>`
这就是从“`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 组合逻辑

View File

@@ -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 增量解析、错误模型与重试边界。

View File

@@ -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/<timestamp>/
├─ 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/<timestamp>
./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 联调和灰度切流验收。

View File

@@ -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",

View File

@@ -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 <folder-name> 指定 build 子目录名,默认使用当前时间 YYYYmmdd-HHMMSS
--database <database> SpacetimeDB database默认 genarrative-dev
--api-port <port> api-server 端口,默认 8082
--web-port <port> 静态网站端口,默认 3000
--spacetime-port <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" <<EOF
# Genarrative Ubuntu Release
构建时间:\`${BUILD_NAME}\`
## 内容
- \`web/\`Vite release 静态资源
- \`api-server\`x86_64-unknown-linux-gnu release 可执行文件
- \`spacetime_module.wasm\`wasm32-unknown-unknown release 模块
- \`web-server.mjs\`:静态网站与 API 反代入口
- \`start.sh\` / \`stop.sh\`:目标服务器启动与停止脚本
## 启动
\`\`\`bash
./start.sh
\`\`\`
默认不清空 SpacetimeDB。如需开发库清库重发
\`\`\`bash
./start.sh --clear-database
\`\`\`
## 环境变量
- \`GENARRATIVE_WEB_HOST\` / \`GENARRATIVE_WEB_PORT\`
- \`GENARRATIVE_API_HOST\` / \`GENARRATIVE_API_PORT\` / \`GENARRATIVE_API_LOG\`
- \`GENARRATIVE_SPACETIME_HOST\` / \`GENARRATIVE_SPACETIME_PORT\`
- \`GENARRATIVE_SPACETIME_SERVER_URL\` / \`GENARRATIVE_SPACETIME_DATABASE\`
- \`GENARRATIVE_SPACETIME_DATA_DIR\`
- OSS、LLM、短信、微信等业务密钥仍通过目标服务器环境变量或同目录 \`.env.local\` 管理。
EOF
BUILD_COMPLETED=1
echo "[deploy:rust] 完成: ${TARGET_DIR}"

313
scripts/dev-rust-stack.ps1 Normal file
View File

@@ -0,0 +1,313 @@
[CmdletBinding()]
param(
[Alias("h")]
[switch]$Help,
[string]$ApiHost = "127.0.0.1",
[int]$ApiPort = 8082,
[string]$WebHost = "0.0.0.0",
[int]$WebPort = 3000,
[string]$SpacetimeHost = "127.0.0.1",
[int]$SpacetimePort = 3101,
[string]$SpacetimeRootDir = "",
[string]$Database = "genarrative-dev",
[string]$Log = "info,tower_http=info",
[int]$SpacetimeStartupTimeoutSeconds = 60,
[switch]$SkipSpacetime,
[switch]$SkipPublish,
[switch]$ClearDatabase
)
$ErrorActionPreference = "Stop"
function Write-Usage {
@(
'Usage:',
' npm run dev:rust',
' .\scripts\dev-rust-stack.ps1 -ApiPort 8090 -SpacetimePort 3110',
' .\scripts\dev-rust-stack.ps1 -SkipSpacetime -SkipPublish',
' .\scripts\dev-rust-stack.ps1 -ClearDatabase',
'',
'Notes:',
' 1. Start SpacetimeDB standalone, Rust api-server, and Vite web together.',
' 2. Publish server-rs/crates/spacetime-module by default, without clearing data.',
' 3. Only -ClearDatabase appends spacetime publish --clear-database.',
' 4. Web listens on 0.0.0.0:3000 by default; API listens on 127.0.0.1:8082.'
) -join [Environment]::NewLine
}
function Quote-ProcessArgument {
param([string]$Value)
if ($null -eq $Value) {
return '""'
}
if ($Value -notmatch '[\s"]') {
return $Value
}
return '"' + $Value.Replace('"', '\"') + '"'
}
function Join-ProcessArguments {
param([string[]]$Arguments)
return (($Arguments | ForEach-Object { Quote-ProcessArgument $_ }) -join " ")
}
function Resolve-ClientHost {
param([string]$HostName)
if ($HostName -eq "0.0.0.0" -or $HostName -eq "::") {
return "127.0.0.1"
}
return $HostName
}
function Start-StackProcess {
param(
[string]$Name,
[string]$FilePath,
[string[]]$Arguments,
[string]$WorkingDirectory,
[hashtable]$Environment
)
$argumentLine = Join-ProcessArguments -Arguments $Arguments
Write-Host "[dev:rust] start ${Name}: $FilePath $argumentLine"
$startInfo = New-Object System.Diagnostics.ProcessStartInfo
$startInfo.FileName = $FilePath
$startInfo.Arguments = $argumentLine
$startInfo.WorkingDirectory = $WorkingDirectory
$startInfo.UseShellExecute = $false
$startInfo.RedirectStandardOutput = $false
$startInfo.RedirectStandardError = $false
$startInfo.RedirectStandardInput = $false
foreach ($entry in $Environment.GetEnumerator()) {
$startInfo.EnvironmentVariables[$entry.Key] = [string]$entry.Value
}
$process = New-Object System.Diagnostics.Process
$process.StartInfo = $startInfo
if (-not $process.Start()) {
throw "Failed to start process: $Name"
}
return [PSCustomObject]@{
Name = $Name
Process = $process
}
}
function Stop-StackProcesses {
param([System.Collections.Generic.List[object]]$Processes)
for ($index = $Processes.Count - 1; $index -ge 0; $index--) {
$item = $Processes[$index]
$process = $item.Process
if ($null -ne $process -and -not $process.HasExited) {
Write-Host "[dev:rust] stop $($item.Name) (pid=$($process.Id))"
$taskkillCommand = Get-Command taskkill.exe -ErrorAction SilentlyContinue
if ($null -ne $taskkillCommand) {
& $taskkillCommand.Source /PID $process.Id /T /F *> $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

280
scripts/dev-rust-stack.sh Normal file
View File

@@ -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}"

View File

@@ -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);
});

11
server-rs/Cargo.lock generated
View File

@@ -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"

View File

@@ -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",

View File

@@ -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" }

View File

@@ -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<String>,
presentation_options: Option<Vec<RuntimeStoryOptionView>>,
saved_current_story: Option<Value>,
patches: Vec<RuntimeStoryPatch>,
battle: Option<RuntimeBattlePresentation>,
toast: Option<String>,
}
struct GeneratedStoryPayload {
story_text: String,
history_result_text: String,
presentation_options: Vec<RuntimeStoryOptionView>,
saved_current_story: Value,
}
struct CurrentEncounterNpcQuestContext {
npc_id: String,
npc_name: String,
}
struct PendingQuestOfferContext {
dialogue: Vec<Value>,
turn_count: i32,
custom_input_placeholder: String,
quest: Value,
quest_id: String,
intro_text: Option<String>,
}
pub async fn resolve_runtime_story_state(
State(state): State<AppState>,
Extension(request_context): Extension<RequestContext>,
@@ -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<RuntimeStoryOptionView>,
patches: Vec<RuntimeStoryPatch>,
toast: Option<String>,
battle: Option<RuntimeBattlePresentation>,
}
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<String> {
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"),

View File

@@ -1,137 +0,0 @@
use super::*;
pub(super) fn resolve_battle_action(
game_state: &mut Value,
request: &RuntimeStoryActionRequest,
function_id: &str,
) -> Result<StoryResolution, String> {
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),
})
}

View File

@@ -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<ForgeRequirementDefinition>,
}
pub(super) struct ReforgeCostDefinition {
pub(super) currency_cost: i32,
pub(super) requirements: Vec<ForgeRequirementDefinition>,
}
pub(super) fn forge_recipe_definition(recipe_id: &str) -> Option<ForgeRecipeDefinition> {
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<Vec<Value>> {
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<Vec<Value>> {
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::<Vec<_>>();
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<Value> {
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::<Vec<_>>();
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::<Vec<_>>();
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::<std::collections::BTreeSet<_>>()
.into_iter()
.collect::<Vec<_>>(),
"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>,
) -> 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,
};

View File

@@ -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<String> {
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<Value> {
read_array_field(game_state, "playerInventory")
.into_iter()
.cloned()
.collect()
}
pub(super) fn write_player_inventory_values(game_state: &mut Value, items: Vec<Value>) {
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<Value>,
additions: Vec<Value>,
) -> Vec<Value> {
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<Value>,
item_id: &str,
quantity: i32,
) -> Vec<Value> {
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<Value> {
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<Value>,
) {
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::<Vec<_>>();
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}")
}

View File

@@ -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 =

View File

@@ -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<Value> {
}))
}
pub(super) fn build_runtime_story_companions(
game_state: &Value,
) -> Vec<RuntimeStoryCompanionViewModel> {
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<RuntimeStoryEncounterViewModel> {
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<RuntimeStoryOptionView> {
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<RuntimeStoryOptionInteraction> {
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<RuntimeStoryOptionView> {
@@ -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<String>,
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<String>,
reason: &str,
payload: Option<Value>,
) -> 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<String> {
current_story.and_then(|story| read_optional_string_field(story, "text"))
}

View File

@@ -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"] }

View File

@@ -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。

View File

@@ -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<Value>,
pub(super) consumed_item_id: Option<String>,
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<Value>,
consumed_item_id: Option<String>,
}
struct BattleSkillView {
@@ -40,7 +57,143 @@ struct BattleInventoryItemView {
use_profile: Option<BattleInventoryUseProfile>,
}
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<StoryResolution, String> {
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<RuntimeStoryOptionView> {
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<BattleS
.find(|skill| skill.id == skill_id)
}
pub(super) fn read_player_skill_cooldowns(
game_state: &Value,
) -> std::collections::BTreeMap<String, i32> {
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<String, i32> {
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<RuntimeStoryOptionView> {
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<Battl
.find(|item| item.id == item_id)
}
pub(super) fn battle_victory_experience_reward(game_state: &Value) -> i32 {
/// 旧前端一次只展示一个“推荐”战斗物品,这里继续用确定性打分,避免展示面漂移。
fn pick_preferred_battle_inventory_item(game_state: &Value) -> Option<BattleInventoryItemView> {
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::<Vec<_>>();
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<String>,
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<String>,
reason: &str,
payload: Option<Value>,
) -> 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<String> {
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<RuntimeStoryOptionView> {
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<RuntimeStoryOptionView> {
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<BattleInventoryItemView> {
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::<Vec<_>>();
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())
}

View File

@@ -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<Value>) {
pub fn add_player_inventory_items(game_state: &mut Value, additions: Vec<Value>) {
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<Value>) {
pub fn append_active_build_buffs(game_state: &mut Value, additions: Vec<Value>) {
if additions.is_empty() {
return;
}
@@ -174,7 +176,7 @@ pub(super) fn append_active_build_buffs(game_state: &mut Value, additions: Vec<V
.extend(additions);
}
pub(super) fn remove_player_inventory_item(game_state: &mut Value, item_id: &str, quantity: i32) {
pub fn remove_player_inventory_item(game_state: &mut Value, item_id: &str, quantity: i32) {
if quantity <= 0 {
return;
}
@@ -207,7 +209,7 @@ pub(super) fn remove_player_inventory_item(game_state: &mut Value, item_id: &str
}
}
pub(super) fn write_current_encounter_i32_field(game_state: &mut Value, key: &str, value: i32) {
pub fn write_current_encounter_i32_field(game_state: &mut Value, key: &str, value: i32) {
let root = ensure_json_object(game_state);
let Some(encounter) = root.get_mut("currentEncounter") else {
return;
@@ -217,7 +219,7 @@ pub(super) fn write_current_encounter_i32_field(game_state: &mut Value, key: &st
}
}
pub(super) fn write_first_hostile_npc_i32_field(game_state: &mut Value, key: &str, value: i32) {
pub fn write_first_hostile_npc_i32_field(game_state: &mut Value, key: &str, value: i32) {
let root = ensure_json_object(game_state);
let Some(hostiles) = root.get_mut("sceneHostileNpcs") else {
return;
@@ -230,92 +232,92 @@ pub(super) fn write_first_hostile_npc_i32_field(game_state: &mut Value, key: &st
}
}
pub(super) fn first_hostile_npc_string_field(game_state: &Value, key: &str) -> Option<String> {
pub fn first_hostile_npc_string_field(game_state: &Value, key: &str) -> Option<String> {
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<String> {
pub fn read_runtime_session_id(game_state: &Value) -> Option<String> {
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<String> {
pub fn read_required_string_field(value: &Value, key: &str) -> Option<String> {
normalize_required_string(read_field(value, key)?.as_str()?)
}
pub(super) fn read_optional_string_field(value: &Value, key: &str) -> Option<String> {
pub fn read_optional_string_field(value: &Value, key: &str) -> Option<String> {
normalize_optional_string(read_field(value, key).and_then(Value::as_str))
}
pub(super) fn read_bool_field(value: &Value, key: &str) -> Option<bool> {
pub fn read_bool_field(value: &Value, key: &str) -> Option<bool> {
read_field(value, key).and_then(Value::as_bool)
}
pub(super) fn read_i32_field(value: &Value, key: &str) -> Option<i32> {
pub fn read_i32_field(value: &Value, key: &str) -> Option<i32> {
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<u32> {
pub fn read_u32_field(value: &Value, key: &str) -> Option<u32> {
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<String, Value> {
pub fn ensure_json_object(value: &mut Value) -> &mut Map<String, Value> {
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<String> {
pub fn normalize_required_string(value: &str) -> Option<String> {
let trimmed = value.trim();
(!trimmed.is_empty()).then(|| trimmed.to_string())
}
pub(super) fn normalize_optional_string(value: Option<&str>) -> Option<String> {
pub fn normalize_optional_string(value: Option<&str>) -> Option<String> {
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())
}

View File

@@ -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<ForgeRequirementDefinition>,
}
pub(crate) struct ReforgeCostDefinition {
pub(crate) currency_cost: i32,
pub(crate) requirements: Vec<ForgeRequirementDefinition>,
}
pub(crate) fn forge_recipe_definition(recipe_id: &str) -> Option<ForgeRecipeDefinition> {
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<Vec<Value>> {
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<Vec<Value>> {
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::<Vec<_>>();
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<Value> {
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::<Vec<_>>();
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::<Vec<_>>();
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::<std::collections::BTreeSet<_>>()
.into_iter()
.collect::<Vec<_>>(),
"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>,
) -> 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}")
}

View File

@@ -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<StoryResolution, String> {
@@ -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<StoryResolution, String> {
@@ -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::<Vec<_>>();
let output_names = outputs.iter().map(read_inventory_item_name).collect::<Vec<_>>();
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<StoryResolution, String> {

View File

@@ -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<String> {
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<Value> {
read_array_field(game_state, "playerInventory")
.into_iter()
.cloned()
.collect()
}
pub fn write_player_inventory_values(game_state: &mut Value, items: Vec<Value>) {
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<Value>, additions: Vec<Value>) -> Vec<Value> {
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<Value>,
item_id: &str,
quantity: i32,
) -> Vec<Value> {
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<Value> {
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<Value>) {
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::<Vec<_>>();
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}")
}

View File

@@ -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<String>,
pub presentation_options: Option<Vec<RuntimeStoryOptionView>>,
pub saved_current_story: Option<Value>,
pub patches: Vec<RuntimeStoryPatch>,
pub battle: Option<RuntimeBattlePresentation>,
pub toast: Option<String>,
}
pub struct GeneratedStoryPayload {
pub story_text: String,
pub history_result_text: String,
pub presentation_options: Vec<RuntimeStoryOptionView>,
pub saved_current_story: Value,
}
pub struct CurrentEncounterNpcQuestContext {
pub npc_id: String,
pub npc_name: String,
}
pub struct PendingQuestOfferContext {
pub dialogue: Vec<Value>,
pub turn_count: i32,
pub custom_input_placeholder: String,
pub quest: Value,
pub quest_id: String,
pub intro_text: Option<String>,
}
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<RuntimeStoryOptionView>,
pub patches: Vec<RuntimeStoryPatch>,
pub toast: Option<String>,
pub battle: Option<RuntimeBattlePresentation>,
}
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<String> {
read_optional_string_field(game_state, "worldType")
}

View File

@@ -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<Va
}
/// compat bridge 先只维护一个轻量队伍名单,继续复用旧前端的满员换队语义。
pub(super) fn recruit_companion_to_party(
pub fn recruit_companion_to_party(
game_state: &mut Value,
npc_id: &str,
_npc_name: &str,
joined_at_affinity: i32,
release_npc_id: Option<&str>,
) -> Result<Option<String>, 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))
}

View File

@@ -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<String>,
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<String>,
reason: &str,
payload: Option<Value>,
) -> 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<RuntimeStoryOptionView> {
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<RuntimeStoryOptionInteraction> {
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,
})
}

View File

@@ -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<RuntimeStoryCompanionViewModel> {
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<RuntimeStoryEncounterViewModel> {
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))
}

View File

@@ -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,