M4 runtime story Rust migration wrap-up
This commit is contained in:
@@ -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 再做远端灰度与回退验证
|
||||
|
||||
@@ -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 的完整运行环境,不在无外部服务的本地预检中虚假勾选。
|
||||
|
||||
@@ -297,3 +297,125 @@ server-rs/crates/api-server/src/
|
||||
这组 resolver 虽然仍是 action orchestration,但已经不依赖 HTTP / `AppState`,只依赖快照 `Value`、当前故事 `currentStory`、共享 DTO 与内部 helper,因此适合先作为 `api-server` 内部模块沉淀。
|
||||
|
||||
迁移后 [compat.rs](D:/Genarrative/server-rs/crates/api-server/src/runtime_story/compat.rs) 对这些动作只保留 functionId 分发、快照桥接与少量共享 glue code,不再承载 battle / equipment / forge / NPC / quest 的具体结算细节。
|
||||
|
||||
## 11. 独立 crate 抽取边界
|
||||
|
||||
完成第二阶段后,已经可以进入第三阶段,但独立 crate 仍按最小安全边界推进:
|
||||
|
||||
1. 新 crate 命名为 `module-runtime-story-compat`。
|
||||
2. `module-runtime-story-compat` 只承接“无 HTTP / 无 `AppState`”的 compat 核心:
|
||||
- runtime story action 分发与确定性结算
|
||||
- battle / equipment / forge / NPC / quest action resolver
|
||||
- `Value` 快照态读写 helper
|
||||
- `RuntimeStoryActionResponse` 的 view model / presentation 编译
|
||||
3. `api-server` 继续保留:
|
||||
- Axum route handler
|
||||
- `RequestContext / AuthenticatedAccessToken`
|
||||
- `runtime_snapshot` 持久化与读取
|
||||
- `clientVersion` 校验到 HTTP error 的映射
|
||||
- `platform-llm` 动作后文本增强
|
||||
4. 首批迁移不把 AI 文本增强放进新 crate,因为它依赖 `AppState` 和 `platform-llm`。
|
||||
5. 首批迁移不把 test route boundary 放进新 crate,route boundary 仍属于 `api-server`。
|
||||
|
||||
这一步完成后,`api-server` 的 `runtime_story/compat.rs` 应该只负责:
|
||||
|
||||
1. 从 HTTP 请求恢复 / 持久化 snapshot
|
||||
2. 调用 `module-runtime-story-compat` 产出确定性动作结果或状态响应
|
||||
3. 需要时调用本地 AI 增强
|
||||
4. 将最终响应包回 `Json<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 组合逻辑
|
||||
|
||||
@@ -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 增量解析、错误模型与重试边界。
|
||||
|
||||
@@ -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 联调和灰度切流验收。
|
||||
@@ -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",
|
||||
|
||||
538
scripts/deploy-rust-remote.sh
Normal file
538
scripts/deploy-rust-remote.sh
Normal 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
313
scripts/dev-rust-stack.ps1
Normal 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
280
scripts/dev-rust-stack.sh
Normal 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}"
|
||||
55
scripts/run-bash-script.mjs
Normal file
55
scripts/run-bash-script.mjs
Normal 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
11
server-rs/Cargo.lock
generated
@@ -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"
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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" }
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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),
|
||||
})
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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}")
|
||||
}
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -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"))
|
||||
}
|
||||
|
||||
11
server-rs/crates/module-runtime-story-compat/Cargo.toml
Normal file
11
server-rs/crates/module-runtime-story-compat/Cargo.toml
Normal 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"] }
|
||||
13
server-rs/crates/module-runtime-story-compat/README.md
Normal file
13
server-rs/crates/module-runtime-story-compat/README.md
Normal 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。
|
||||
@@ -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())
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
426
server-rs/crates/module-runtime-story-compat/src/forge.rs
Normal file
426
server-rs/crates/module-runtime-story-compat/src/forge.rs
Normal 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}")
|
||||
}
|
||||
@@ -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> {
|
||||
417
server-rs/crates/module-runtime-story-compat/src/game_state.rs
Normal file
417
server-rs/crates/module-runtime-story-compat/src/game_state.rs
Normal 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}")
|
||||
}
|
||||
148
server-rs/crates/module-runtime-story-compat/src/lib.rs
Normal file
148
server-rs/crates/module-runtime-story-compat/src/lib.rs
Normal 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")
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
126
server-rs/crates/module-runtime-story-compat/src/options.rs
Normal file
126
server-rs/crates/module-runtime-story-compat/src/options.rs
Normal 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,
|
||||
})
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user