This commit is contained in:
2026-04-28 20:25:37 +08:00
parent f0471a4f8d
commit 0f013b6eee
45 changed files with 1117 additions and 1047 deletions

View File

@@ -20,7 +20,7 @@
- [RPG_RUNTIME_DIRECT_DRAFT_PROFILE_AUDIT_2026-04-25.md](./RPG_RUNTIME_DIRECT_DRAFT_PROFILE_AUDIT_2026-04-25.md)RPG 运行时进入世界时改为直读 Agent session 草稿 profile 的链路检查。 - [RPG_RUNTIME_DIRECT_DRAFT_PROFILE_AUDIT_2026-04-25.md](./RPG_RUNTIME_DIRECT_DRAFT_PROFILE_AUDIT_2026-04-25.md)RPG 运行时进入世界时改为直读 Agent session 草稿 profile 的链路检查。
- [RPG_WORLD_DRAFT_EDIT_AUTOSAVE_OVERRIDE_AUDIT_2026-04-28.md](./RPG_WORLD_DRAFT_EDIT_AUTOSAVE_OVERRIDE_AUDIT_2026-04-28.md)RPG 世界草稿结果页编辑后被旧设定覆盖的前端本地态、session 真相源与自动保存链路审计。 - [RPG_WORLD_DRAFT_EDIT_AUTOSAVE_OVERRIDE_AUDIT_2026-04-28.md](./RPG_WORLD_DRAFT_EDIT_AUTOSAVE_OVERRIDE_AUDIT_2026-04-28.md)RPG 世界草稿结果页编辑后被旧设定覆盖的前端本地态、session 真相源与自动保存链路审计。
- [engineering/RPG_FRONTEND_SCRIPT_BACKEND_MIGRATION_AUDIT_2026-04-28.md](./engineering/RPG_FRONTEND_SCRIPT_BACKEND_MIGRATION_AUDIT_2026-04-28.md)RPG 前端脚本中仍应迁到 `server-rs` / SpacetimeDB 的开局、快照、story engine、战斗、NPC/背包规则与创作残留后门审计。 - [engineering/RPG_FRONTEND_SCRIPT_BACKEND_MIGRATION_AUDIT_2026-04-28.md](./engineering/RPG_FRONTEND_SCRIPT_BACKEND_MIGRATION_AUDIT_2026-04-28.md)RPG 前端脚本中仍应迁到 `server-rs` / SpacetimeDB 的开局、快照、story engine、战斗、NPC/背包规则与创作残留后门审计。
- [engineering/RPG_FRONTEND_SCRIPT_BACKEND_MIGRATION_COMPLETION_CHECK_2026-04-28.md](./engineering/RPG_FRONTEND_SCRIPT_BACKEND_MIGRATION_COMPLETION_CHECK_2026-04-28.md)RPG 前端脚本后端迁移完成度复核,标明已完成项、已收口的结果页保存 normalize以及仍需收尾的 `camp_travel_home_scene` 前端专用旅行分支 - [engineering/RPG_FRONTEND_SCRIPT_BACKEND_MIGRATION_COMPLETION_CHECK_2026-04-28.md](./engineering/RPG_FRONTEND_SCRIPT_BACKEND_MIGRATION_COMPLETION_CHECK_2026-04-28.md)RPG 前端脚本后端迁移完成度复核,标明开局、快照、story engine / prompt context、`camp_travel_home_scene`、战斗、NPC、背包/锻造、结果页保存 normalize 与角色资产 prompt 主链均已收口
- [engineering/ENGINEERING_CLEANUP_AND_BACKEND_BOUNDARY_AUDIT_2026-04-20.md](./engineering/ENGINEERING_CLEANUP_AND_BACKEND_BOUNDARY_AUDIT_2026-04-20.md):对 `2026-04-19` 工程清理审计的当前仓库复核,区分已完成项、仍存边界问题和新的热点迁移。 - [engineering/ENGINEERING_CLEANUP_AND_BACKEND_BOUNDARY_AUDIT_2026-04-20.md](./engineering/ENGINEERING_CLEANUP_AND_BACKEND_BOUNDARY_AUDIT_2026-04-20.md):对 `2026-04-19` 工程清理审计的当前仓库复核,区分已完成项、仍存边界问题和新的热点迁移。
- [engineering/ENGINEERING_CLEANUP_AND_BACKEND_BOUNDARY_AUDIT_2026-04-19.md](./engineering/ENGINEERING_CLEANUP_AND_BACKEND_BOUNDARY_AUDIT_2026-04-19.md):未引用垃圾、旧入口残留、前后端双份真相与后端迁移项的专项审计。 - [engineering/ENGINEERING_CLEANUP_AND_BACKEND_BOUNDARY_AUDIT_2026-04-19.md](./engineering/ENGINEERING_CLEANUP_AND_BACKEND_BOUNDARY_AUDIT_2026-04-19.md):未引用垃圾、旧入口残留、前后端双份真相与后端迁移项的专项审计。

View File

@@ -13,7 +13,7 @@
4. [RPG_FRONTEND_SCRIPT_BACKEND_MIGRATION_AUDIT_2026-04-28.md](./RPG_FRONTEND_SCRIPT_BACKEND_MIGRATION_AUDIT_2026-04-28.md) 4. [RPG_FRONTEND_SCRIPT_BACKEND_MIGRATION_AUDIT_2026-04-28.md](./RPG_FRONTEND_SCRIPT_BACKEND_MIGRATION_AUDIT_2026-04-28.md)
这一版专项扫描 `src/` 下 RPG 开头脚本明确运行时开局、快照、story engine、战斗后处理、NPC/背包规则和创作链残留后门中应迁到 `server-rs` 的逻辑。 这一版专项扫描 `src/` 下 RPG 开头脚本明确运行时开局、快照、story engine、战斗后处理、NPC/背包规则和创作链残留后门中应迁到 `server-rs` 的逻辑。
5. [RPG_FRONTEND_SCRIPT_BACKEND_MIGRATION_COMPLETION_CHECK_2026-04-28.md](./RPG_FRONTEND_SCRIPT_BACKEND_MIGRATION_COMPLETION_CHECK_2026-04-28.md) 5. [RPG_FRONTEND_SCRIPT_BACKEND_MIGRATION_COMPLETION_CHECK_2026-04-28.md](./RPG_FRONTEND_SCRIPT_BACKEND_MIGRATION_COMPLETION_CHECK_2026-04-28.md)
这一版复核 RPG 前端脚本后端迁移完成度确认开局、快照、存档、NPC、背包/锻造、结果页保存前 normalize 与角色资产 prompt 主链已收口,同时标出 `camp_travel_home_scene` 前端专用旅行分支仍未完全迁完 这一版复核 RPG 前端脚本后端迁移完成度,确认开局、快照、存档、story engine / prompt context、`camp_travel_home_scene`NPC、背包/锻造、战斗后处理、结果页保存前 normalize 与角色资产 prompt 主链已收口。
6. [FRONTEND_LOGIC_BACKEND_MIGRATION_AUDIT_2026-04-21.md](./FRONTEND_LOGIC_BACKEND_MIGRATION_AUDIT_2026-04-21.md) 6. [FRONTEND_LOGIC_BACKEND_MIGRATION_AUDIT_2026-04-21.md](./FRONTEND_LOGIC_BACKEND_MIGRATION_AUDIT_2026-04-21.md)
这一版是本轮前端越界逻辑专项审计,专门汇总当前仍应继续迁到 `server-rs` 的运行时、鉴权、生成编排与本地真相残留。 这一版是本轮前端越界逻辑专项审计,专门汇总当前仍应继续迁到 `server-rs` 的运行时、鉴权、生成编排与本地真相残留。
7. [ENGINEERING_DEAD_CODE_CLEANUP_BATCH_D_2026-04-21.md](./ENGINEERING_DEAD_CODE_CLEANUP_BATCH_D_2026-04-21.md) 7. [ENGINEERING_DEAD_CODE_CLEANUP_BATCH_D_2026-04-21.md](./ENGINEERING_DEAD_CODE_CLEANUP_BATCH_D_2026-04-21.md)

View File

@@ -4,18 +4,18 @@
本次按 `RPG_FRONTEND_SCRIPT_BACKEND_MIGRATION_AUDIT_2026-04-28.md` 中列出的应迁后端项逐项检查当前代码。 本次按 `RPG_FRONTEND_SCRIPT_BACKEND_MIGRATION_AUDIT_2026-04-28.md` 中列出的应迁后端项逐项检查当前代码。
结论:**应迁移项尚未全部迁移完成。** 结论:**应迁移项全部迁移完成。**
当前状态: 当前状态:
1. `已完成`9 项。 1. `已完成`10 项。
2. `部分完成`1 项。 2. `部分完成`0 项。
3. `未发现完全未启动`0 项。 3. `未发现完全未启动`0 项。
本轮重新核查的变化: 本轮重新核查的变化:
1. 上次残留的 `RPG 创作结果页` 保存前 profile normalize 已完成后端化。 1. 上次残留的 `RPG 创作结果页` 保存前 profile normalize 已完成后端化。
2. 新发现 `camp_travel_home_scene`登记为服务端 runtime function id但正式点击仍会被前端专用旅行分支提前拦截并本地拼装场景迁移状态 2. `camp_travel_home_scene`完成后端收口:正式点击统一走 `/api/runtime/story/actions/resolve`目标场景、encounter preview、`scenesTraveled` 与快照持久化由 `server-rs` 裁决
## 2. 核验口径 ## 2. 核验口径
@@ -43,7 +43,7 @@
| `P0` 运行时开局 `GameState` 装配 | 已完成 | 正式开局状态由 `server-rs/crates/api-server/src/runtime_story/compat/bootstrap.rs` 创建并持久化;前端 `useRpgSessionBootstrap.ts` 只保留选择页占位态和 `beginRpgRuntimeStorySession(...)` 调用。 | | `P0` 运行时开局 `GameState` 装配 | 已完成 | 正式开局状态由 `server-rs/crates/api-server/src/runtime_story/compat/bootstrap.rs` 创建并持久化;前端 `useRpgSessionBootstrap.ts` 只保留选择页占位态和 `beginRpgRuntimeStorySession(...)` 调用。 |
| `P0` runtime story 网关客户端快照解析/补丁 | 已完成 | `rpgRuntimeStoryGateway.ts` 不再有 `buildRuntimeSnapshotRequest` / `bridgeServer*Snapshot``rpgRuntimeStoryClient.ts` 读取 `/state/:sessionId`,动作提交 `/actions/resolve`,不再上传完整 `snapshot.gameState/currentStory`。 | | `P0` runtime story 网关客户端快照解析/补丁 | 已完成 | `rpgRuntimeStoryGateway.ts` 不再有 `buildRuntimeSnapshotRequest` / `bridgeServer*Snapshot``rpgRuntimeStoryClient.ts` 读取 `/state/:sessionId`,动作提交 `/actions/resolve`,不再上传完整 `snapshot.gameState/currentStory`。 |
| `P0` 自动保存整份运行时快照 | 已完成 | `useRpgSessionPersistence.ts` / `rpgSnapshotClient.ts` 保存链路只提交 `sessionId/bottomTab` checkpoint`runtime_save.rs` 从服务端已有快照刷新 checkpoint并测试拒绝旧式完整快照上传。 | | `P0` 自动保存整份运行时快照 | 已完成 | `useRpgSessionPersistence.ts` / `rpgSnapshotClient.ts` 保存链路只提交 `sessionId/bottomTab` checkpoint`runtime_save.rs` 从服务端已有快照刷新 checkpoint并测试拒绝旧式完整快照上传。 |
| `P0` story engine / chapter / world mutation / prompt context 编排 | 部分完成 | 后端已有 `project_story_engine_after_action(...)``build_runtime_story_prompt_context(...)``/story/initial``/story/continue``sessionId` 时只从服务端 snapshot 投影 world / character / history / prompt context`camp_travel_home_scene` 已是服务端 function id前端仍先走本地 `runCampTravelHomeChoice(...)` 拼装场景迁移、encounter preview runtimeStats。 | | `P0` story engine / chapter / world mutation / prompt context 编排 | 完成 | 后端已有 `project_story_engine_after_action(...)``build_runtime_story_prompt_context(...)``/story/initial``/story/continue``sessionId` 时只从服务端 snapshot 投影 world / character / history / prompt context`camp_travel_home_scene` 正式点击也已统一进入后端 resolver不再由前端拼装场景迁移、encounter preview runtimeStats。 |
| `P0` 战斗胜负后处理、死亡复活、战斗后章节推进 | 已完成 | `battle_* / inventory_use` 正式点击统一走 `runServerRuntimeChoiceAction(...)` 与后端 `/actions/resolve``storyChoiceContinuation.ts` 对战斗 / 逃脱 / 物品动作加硬保护,不再裁决掉落、任务推进、死亡复活或战后 story`postBattleFlow.ts` 正式状态构造函数已删除。 | | `P0` 战斗胜负后处理、死亡复活、战斗后章节推进 | 已完成 | `battle_* / inventory_use` 正式点击统一走 `runServerRuntimeChoiceAction(...)` 与后端 `/actions/resolve``storyChoiceContinuation.ts` 对战斗 / 逃脱 / 物品动作加硬保护,不再裁决掉落、任务推进、死亡复活或战后 story`postBattleFlow.ts` 正式状态构造函数已删除。 |
| `P1` NPC 交易/送礼价格数量库存校验 | 已完成 | 前端 `npcInteraction.ts` 已改为消费 `runtimeNpcInteraction` view 并只提交 `{ mode, itemId, quantity }`;后端 `npc_actions.rs` / `npc_support.rs` 负责价格、库存、货币、赠礼好感和原子更新。 | | `P1` NPC 交易/送礼价格数量库存校验 | 已完成 | 前端 `npcInteraction.ts` 已改为消费 `runtimeNpcInteraction` view 并只提交 `{ mode, itemId, quantity }`;后端 `npc_actions.rs` / `npc_support.rs` 负责价格、库存、货币、赠礼好感和原子更新。 |
| `P1` 背包/装备/锻造可用性与配方视图 | 已完成 | 前端 `inventoryActions.ts` 读取 `loadRpgRuntimeInventoryView(...)`,根据后端 action/view 提交;后端 `view_model.rs` / `forge.rs` 生成背包、装备槽、配方、`canCraft/enabled/reason`。 | | `P1` 背包/装备/锻造可用性与配方视图 | 已完成 | 前端 `inventoryActions.ts` 读取 `loadRpgRuntimeInventoryView(...)`,根据后端 action/view 提交;后端 `view_model.rs` / `forge.rs` 生成背包、装备槽、配方、`canCraft/enabled/reason`。 |
@@ -51,9 +51,9 @@
| `P1` 创作结果页保存与 Agent session/result preview 真相优先级 | 已完成 | 后端已提供 `GET /api/runtime/custom-world/agent/sessions/:sessionId/result-view`,统一 `targetStage/profileSource/canAutosaveLibrary/canSyncResultProfile`,前端不再直接读取 `legacyResultProfile`;保存前 canonicalize 已迁到 `server-rs``normalizeRpgEntryAgentBackedProfile(...)` 现在只透传兼容旧导入。 | | `P1` 创作结果页保存与 Agent session/result preview 真相优先级 | 已完成 | 后端已提供 `GET /api/runtime/custom-world/agent/sessions/:sessionId/result-view`,统一 `targetStage/profileSource/canAutosaveLibrary/canSyncResultProfile`,前端不再直接读取 `legacyResultProfile`;保存前 canonicalize 已迁到 `server-rs``normalizeRpgEntryAgentBackedProfile(...)` 现在只透传兼容旧导入。 |
| `P1` 角色资产工坊默认 prompt 与缓存合并规则 | 已完成 | 默认 prompt、legacy prompt 过滤、逐动作缓存合并已在 `server-rs/crates/api-server/src/prompt/rpg/role_asset_studio.rs`;前端 modal 只调用 workflow API、保存用户草稿和发起生成/发布。 | | `P1` 角色资产工坊默认 prompt 与缓存合并规则 | 已完成 | 默认 prompt、legacy prompt 过滤、逐动作缓存合并已在 `server-rs/crates/api-server/src/prompt/rpg/role_asset_studio.rs`;前端 modal 只调用 workflow API、保存用户草稿和发起生成/发布。 |
## 4. 仍未完成的具体收尾点 ## 4. 完成的具体收尾点
### 4.1 story engine / prompt context 主链已完成,但 `camp_travel_home_scene` 仍残留前端正式分支 ### 4.1 story engine / prompt context 主链 `camp_travel_home_scene` 已完成后端收口
当前后端已经处理动作结算后的确定性 story projector 当前后端已经处理动作结算后的确定性 story projector
@@ -83,17 +83,25 @@
4. `npm run test -- src/services/ai.test.ts src/hooks/rpg-runtime-story/storyRequestCoordinator.test.ts src/hooks/rpg-runtime-story/useRpgRuntimeStoryController.test.tsx` 覆盖前端 story / chat 请求在 session 模式下只发送轻量 payload。 4. `npm run test -- src/services/ai.test.ts src/hooks/rpg-runtime-story/storyRequestCoordinator.test.ts src/hooks/rpg-runtime-story/useRpgRuntimeStoryController.test.tsx` 覆盖前端 story / chat 请求在 session 模式下只发送轻量 payload。
5. `npm run test -- src/hooks/rpg-runtime-story/sessionActions.test.ts src/hooks/rpg-runtime-story/choiceActions.test.ts src/hooks/rpg-runtime-story/npcEncounterActions.test.ts` 覆盖前端旧 UI 分支不再回写后端拥有的 `storyEngineMemory` 5. `npm run test -- src/hooks/rpg-runtime-story/sessionActions.test.ts src/hooks/rpg-runtime-story/choiceActions.test.ts src/hooks/rpg-runtime-story/npcEncounterActions.test.ts` 覆盖前端旧 UI 分支不再回写后端拥有的 `storyEngineMemory`
重新核查新增残留 本轮收尾
1. `packages/shared/src/contracts/rpgRuntimeStoryAction.ts` 已把 `camp_travel_home_scene` 纳入 `TASK5_RUNTIME_FUNCTION_IDS` / `SERVER_RUNTIME_FUNCTION_IDS` 1. `packages/shared/src/contracts/rpgRuntimeStoryAction.ts` 已把 `camp_travel_home_scene` 纳入 `TASK5_RUNTIME_FUNCTION_IDS` / `SERVER_RUNTIME_FUNCTION_IDS`
2. `server-rs/crates/api-server/src/runtime_story/compat.rs` 已有 `camp_travel_home_scene` resolver 分支,但当前只清理 encounter并未承接前端旧分支里的目标场景、encounter preview 与完整离营故事提交 2. `server-rs/crates/api-server/src/runtime_story/compat.rs` `camp_travel_home_scene` resolver 已承接前端旧分支的正式状态职责:解析目标场景、写入 `currentScenePreset`、清理战斗/遭遇残留、递增 `scenesTraveled`、生成 encounter preview并让后续故事和持久化继续走后端 snapshot 主链
3. `src/hooks/rpg-runtime-story/choiceActions.ts` 仍在 `isRpgRuntimeServerFunctionId(...)` 之前判断 `isCampTravelHomeOption(...)`,并调用 `runCampTravelHomeChoice(...)` 3. 目标场景解析以后端为准:优先接收兼容 payload 中的 `targetSceneId`,其次使用内置角色主场景映射,自定义世界按角色与 landmark 绑定解析,再回退到当前场景前向连接或首个冒险场景
4. `src/hooks/rpg-runtime-story/storyChoiceRuntime.ts` `runCampTravelHomeChoice(...)` 会在浏览器中决定目标场景、清理战斗/遭遇、递增 `scenesTraveled`、构造 encounter preview并通过 `commitGeneratedStateWithEncounterEntry(...)` 写入后续故事 4. `src/hooks/rpg-runtime-story/choiceActions.ts` 不再调用 `runCampTravelHomeChoice(...)``camp_travel_home_scene` 即使命中旧展示 helper也会按服务端 function id 统一进入 `runServerRuntimeChoiceAction(...)`
5. `src/hooks/rpg-runtime-story/storyChoiceRuntime.ts` 已删除 `runCampTravelHomeChoice(...)`,前端不再保留正式场景迁移构造函数。
影响 已消除风险
1. 这条链不是纯动画表现,而是正式场景迁移、运行时统计、遭遇预览和后续故事提交 1. `camp_travel_home_scene` 不再由浏览器决定目标场景、运行时统计或 encounter preview
2. 它已经具备服务端 function id 身份,却没有统一走 `/api/runtime/story/actions/resolve`,因此仍不满足“前端只提交 action后端返回 hydrated snapshot”的边界。 2. 正式离营状态已经满足“前端只提交 action后端返回 hydrated snapshot”的边界。
本轮验收补充:
1. `cargo test -p api-server runtime_story_route_boundary_camp_travel_home_scene_is_server_owned --manifest-path server-rs\Cargo.toml` 覆盖点击后 hydrated snapshot 进入角色主场景、生成 encounter preview、递增 `scenesTraveled` 并持久化。
2. `cargo test -p api-server runtime_story --manifest-path server-rs\Cargo.toml` 覆盖 runtime story 相关后端回归。
3. `npm run test -- src/hooks/rpg-runtime-story/choiceActions.test.ts` 覆盖 `camp_travel_home_scene` 只调用后端 resolver不触发旧本地旅行分支。
4. `npm run test -- src/hooks/rpg-runtime-story/storyChoiceRuntime.test.ts src/services/rpg-runtime/rpgRuntimeStoryClient.test.ts` 覆盖服务端 runtime choice presentation 与 story client 轻量 payload。
### 4.2 本地战斗 continuation 已收口到后端 ### 4.2 本地战斗 continuation 已收口到后端
@@ -156,14 +164,14 @@
4. 角色资产工坊 modal 中的 prompt 输入框与缓存保存:这是用户正在编辑的 UI 草稿,默认 prompt 和合并规则已由后端 workflow 输出。 4. 角色资产工坊 modal 中的 prompt 输入框与缓存保存:这是用户正在编辑的 UI 草稿,默认 prompt 和合并规则已由后端 workflow 输出。
5. `playServerBattlePresentation(...)`:只播放临时动画态,最终 `GameState/currentStory` 仍以服务端 snapshot 为准。 5. `playServerBattlePresentation(...)`:只播放临时动画态,最终 `GameState/currentStory` 仍以服务端 snapshot 为准。
## 6. 下一步建议 ## 6. 后续建议
推荐按风险顺序继续 本轮核验范围内的应迁项已经收口。后续建议转为质量维护
1. `camp_travel_home_scene` 点击链统一改为 `runServerRuntimeChoiceAction(...)` / `/api/runtime/story/actions/resolve` 1. 继续把 function catalog / 旧文档里“本地规则结算”的历史描述逐批改成当前后端归属,避免误导后续开发
2. 扩展 `server-rs/crates/api-server/src/runtime_story/compat.rs` 中的 `camp_travel_home_scene` resolver让目标场景、encounter preview、`scenesTraveled`、故事提交和快照持久化全部由后端完成 2. 新增 runtime function 时先补后端 resolver / view / contract再让前端接展示入口保持“前端不造正式状态”的边界
3. 补齐前端测试,锁定 `camp_travel_home_scene` 不再调用 `runCampTravelHomeChoice(...)`;补齐后端 route 级测试,覆盖离营后 hydrated snapshot 字段 3. 对仍保留的前端本地 continuation 只允许处理非服务端 function id凡进入 `SERVER_RUNTIME_FUNCTION_IDS` 的动作都应有 route 级测试
## 7. 一句话结论 ## 7. 一句话结论
**当前迁移已经完成开局、快照、存档、story engine / prompt context 主链、NPC、背包/锻造、战斗后处理、profile 生成、创作结果页 normalize 和角色资产 prompt 主链;但 `camp_travel_home_scene` 仍由前端专用分支拼装正式场景迁移状态,所以不能判定“应迁移项已全部迁移完成”** **当前迁移已经完成开局、快照、存档、story engine / prompt context 主链、`camp_travel_home_scene` 离营迁移、NPC、背包/锻造、战斗后处理、profile 生成、创作结果页 normalize 和角色资产 prompt 主链;本核验范围内不再保留前端正式状态裁决残留**

View File

@@ -74,7 +74,7 @@
新系统必须满足: 新系统必须满足:
1. 可解释:玩家能理解“为什么这个角色擅长这个”“为什么这个 NPC 会喜欢这种行为”“为什么这个怪物在这个世界里是这种威胁”。 1. 可解释:玩家能理解“为什么这个角色擅长这个”“为什么这个 NPC 会喜欢这种行为”“为什么这个怪物在这个世界里是这种威胁”。
2. 可生成:自定义世界可以稳定生成新属性名称与定义 2. 可生成:自定义世界可以稳定生成新属性名称。
3. 可校验AI 输出不能直接裸写进运行时,必须经过本地验证。 3. 可校验AI 输出不能直接裸写进运行时,必须经过本地验证。
4. 可复用:同一套属性 schema 能进入角色、怪物、技能、Build、物品、对话 prompt。 4. 可复用:同一套属性 schema 能进入角色、怪物、技能、Build、物品、对话 prompt。
5. 可迁移:能从当前四维属性 / 标签 / 怪物 preset 平滑过渡。 5. 可迁移:能从当前四维属性 / 标签 / 怪物 preset 平滑过渡。
@@ -165,12 +165,6 @@ type WorldAttributeSchema = {
slots: Array<{ slots: Array<{
slotId: string; slotId: string;
name: string; name: string;
definition: string;
positiveSignals: string[];
negativeSignals: string[];
combatUseText: string;
socialUseText: string;
explorationUseText: string;
}>; }>;
}; };
``` ```
@@ -178,12 +172,9 @@ type WorldAttributeSchema = {
### 关键原则 ### 关键原则
1. `slotId` 是稳定技术标识,例如 `axis_a` ~ `axis_f` 1. `slotId` 是稳定技术标识,例如 `axis_a` ~ `axis_f`
2. `name` 是世界内真实显示名称,例如武侠里可能不是“力量”,而是“骨势”“身法” 2. 本世界六维名称在创作、提示词输出、解析后保存的数据中只保留 `name`;其他定义、信号和用途说明字段不再进入 schema
3. `definition` 必须描述角色本质倾向,而不是派生战斗数值 3. `name` 是世界内真实显示名称,例如武侠里可能不是“力量”,而是“骨势”“身法”
4. 每个属性都必须能解释: 4. 六个名称需要能支撑战斗、对话、探索的叙事理解,但这些说明由下游运行时按场景生成,不写入 schema。
- 战斗中的体现
- 对话中的体现
- 探索中的体现
### 禁止项 ### 禁止项
@@ -247,16 +238,9 @@ type AttributeSchemaGenerationInput = {
```ts ```ts
type AttributeSchemaGenerationOutput = { type AttributeSchemaGenerationOutput = {
schemaName: string;
slots: Array<{ slots: Array<{
slotId: string; slotId: string;
name: string; name: string;
definition: string;
positiveSignals: string[];
negativeSignals: string[];
combatUseText: string;
socialUseText: string;
explorationUseText: string;
}>; }>;
}; };
``` ```
@@ -267,13 +251,9 @@ AI 输出后必须通过本地校验:
1. 属性数量必须等于 6。 1. 属性数量必须等于 6。
2. `name` 需唯一,长度建议 `2~4` 个中文字符。 2. `name` 需唯一,长度建议 `2~4` 个中文字符。
3. `definition` 不得出现“提升攻击 / 提升防御力 / 提升生命值”这类派生描述 3. `name` 不得出现“生命 / 法力 / 护甲 / 攻击 / 防御 / 力量 / 敏捷 / 智力 / 精神”这类旧四维或派生资源词
4. 每个属性都必须同时具备: 4. 任意两个属性名称不能重复,也不能只做同义换皮。
- 一个战斗说明 5. 若校验失败:
- 一个社交说明
- 一个探索说明
5. 任意两条属性定义关键词重叠度不能过高。
6. 若校验失败:
- 预设世界回退到固化 schema - 预设世界回退到固化 schema
- 自定义世界回退到模板世界 schema并记录失败日志 - 自定义世界回退到模板世界 schema并记录失败日志
@@ -283,25 +263,25 @@ AI 输出后必须通过本地校验:
### 武侠世界示例 ### 武侠世界示例
| 属性名 | 定义 | | 槽位 | 属性名 |
| --- | --- | | --- | --- |
| 骨势 | 扛压、顶冲、硬吃风险也不退的势头 | | axis_a | 骨势 |
| 身法 | 腾挪、抢位、换线、把握出手节奏的能力 | | axis_b | 身法 |
| 眼脉 | 看破破绽、拆招、识局、看穿人心的能力 | | axis_c | 眼脉 |
| 心焰 | 决断、压迫、胆气、在局面中立住自身意志的能力 | | axis_d | 心焰 |
| 尘缘 | 与人事、情面、承诺、牵引关系打交道的能力 | | axis_e | 尘缘 |
| 玄息 | 调息、稳态、久战、把自身维持在可用状态的能力 | | axis_f | 玄息 |
### 仙侠世界示例 ### 仙侠世界示例
| 属性名 | 定义 | | 槽位 | 属性名 |
| --- | --- | | --- | --- |
| 道骨 | 承载道压与高强度冲击的底子 | | axis_a | 道骨 |
| 灵行 | 位移、御空、转场、抢占天时地利的能力 | | axis_b | 灵行 |
| 识海 | 解析禁制、洞察因果、识破虚实的能力 | | axis_c | 识海 |
| 心契 | 与他者、器物、灵兽、誓约建立共鸣的能力 | | axis_d | 心契 |
| 劫纹 | 在高危变化中强行推进、改写局势的能力 | | axis_e | 劫纹 |
| 玄息 | 循环灵息、稳住心神、让自身持续在线的能力 | | axis_f | 玄息 |
关键点: 关键点:
@@ -692,10 +672,8 @@ AI 不可以直接生成:
```ts ```ts
type PromptAttributeSummary = { type PromptAttributeSummary = {
schemaName: string;
slots: Array<{ slots: Array<{
name: string; name: string;
definition: string;
}>; }>;
actorTopAttributes: string[]; actorTopAttributes: string[];
targetTopAttributes?: string[]; targetTopAttributes?: string[];
@@ -726,12 +704,6 @@ export type AttributeVector = Record<string, number>;
export interface WorldAttributeSlot { export interface WorldAttributeSlot {
slotId: string; slotId: string;
name: string; name: string;
definition: string;
positiveSignals: string[];
negativeSignals: string[];
combatUseText: string;
socialUseText: string;
explorationUseText: string;
} }
export interface WorldAttributeSchema { export interface WorldAttributeSchema {
@@ -1017,8 +989,8 @@ behaviorVectors: Array<{
对策: 对策:
1. 增加本地定义重叠校验 1. 增加本地名称重复、旧词和同义换皮校验
2. 强制每个属性都写战斗 / 社交 / 探索三种说明 2. 提示词要求六个名称覆盖不同叙事气质,避免全部落在同一种行动倾向上
3. 首版预设世界采用固化 schema不在运行时漂移 3. 首版预设世界采用固化 schema不在运行时漂移
### 风险 2过度抽象策划难以配置 ### 风险 2过度抽象策划难以配置
@@ -1029,8 +1001,8 @@ behaviorVectors: Array<{
对策: 对策:
1. 每个属性附带定义、正反信号、示例行为 1. 编辑器只显示并编辑六个属性名称
2. 编辑器永远显示 `name + definition` 2. 解释文本由技能 / 标签 / 物品等下游按具体场景生成
3. 所有技能 / 标签 / 物品的属性向量都显示人类可读解释 3. 所有技能 / 标签 / 物品的属性向量都显示人类可读解释
### 风险 3旧系统迁移期间双轨并存混乱 ### 风险 3旧系统迁移期间双轨并存混乱

View File

@@ -112,7 +112,7 @@
- `npc_help` - `npc_help`
脚本:`src/data/functionCatalog/npc/npcHelp.ts` 脚本:`src/data/functionCatalog/npc/npcHelp.ts`
说明:向 NPC 寻求补给、回复或支援的 function。奖励由本地规则稳定计算,避免帮助收益被模型临场漂移。 说明:向 NPC 寻求补给、回复或支援的 function。正式奖励、资源变化与 one-shot 状态由后端 runtime action resolver 稳定计算,避免帮助收益被模型临场漂移。
- `npc_chat` - `npc_chat`
脚本:`src/data/functionCatalog/npc/npcChat.ts` 脚本:`src/data/functionCatalog/npc/npcChat.ts`
@@ -140,7 +140,7 @@
- `npc_quest_accept` - `npc_quest_accept`
脚本:`src/data/functionCatalog/npc/npcQuestAccept.ts` 脚本:`src/data/functionCatalog/npc/npcQuestAccept.ts`
说明:正式接下 NPC 委托的 function。它把本地生成的任务写入 quest log并让剧情承接“玩家已经答应处理这件事”。 说明:正式接下 NPC 委托的 function。它把后端 pending quest offer 写入 quest log并让剧情承接“玩家已经答应处理这件事”。
- `npc_quest_turn_in` - `npc_quest_turn_in`
脚本:`src/data/functionCatalog/npc/npcQuestTurnIn.ts` 脚本:`src/data/functionCatalog/npc/npcQuestTurnIn.ts`
@@ -172,7 +172,7 @@
- `camp_travel_home_scene` - `camp_travel_home_scene`
脚本:`src/data/functionCatalog/flow/campTravelHomeScene.ts` 脚本:`src/data/functionCatalog/flow/campTravelHomeScene.ts`
说明:营地开场或同伴交流结束后,正式前往角色主场景的流程项。它负责定制化场景迁移和状态清理,不属于普通 state function 说明:营地开场或同伴交流结束后,正式前往角色主场景的流程项。前端脚本只保留按钮与视觉元信息目标场景、状态清理、encounter preview、`scenesTraveled` 与快照持久化由后端 runtime action resolver 负责
- `story_opening_camp_dialogue` - `story_opening_camp_dialogue`
脚本:`src/data/functionCatalog/flow/storyOpeningCampDialogue.ts` 脚本:`src/data/functionCatalog/flow/storyOpeningCampDialogue.ts`
@@ -182,7 +182,7 @@
- `inventory_use` - `inventory_use`
脚本:`src/data/functionCatalog/panel/inventoryUse.ts` 脚本:`src/data/functionCatalog/panel/inventoryUse.ts`
说明:在背包面板里使用药品、灵力物或 build buff 物品的 function。它先由本地规则结算资源变化,再把结果记入故事历史 说明:在背包面板里使用药品、灵力物或 build buff 物品的 function。前端只提交物品动作资源变化、数量扣减、build buff 与故事历史由后端 resolver 写入
- `equipment_equip` - `equipment_equip`
脚本:`src/data/functionCatalog/panel/equipmentEquip.ts` 脚本:`src/data/functionCatalog/panel/equipmentEquip.ts`
@@ -190,7 +190,7 @@
- `equipment_unequip` - `equipment_unequip`
脚本:`src/data/functionCatalog/panel/equipmentUnequip.ts` 脚本:`src/data/functionCatalog/panel/equipmentUnequip.ts`
说明:从装备槽位卸下物品的 function。它确保卸装结果由本地规则严格处理,不会破坏背包数量和 loadout 一致性。 说明:从装备槽位卸下物品的 function。后端 resolver 负责卸装结果、背包数量和 loadout 一致性。
- `forge_craft` - `forge_craft`
脚本:`src/data/functionCatalog/panel/forgeCraft.ts` 脚本:`src/data/functionCatalog/panel/forgeCraft.ts`
@@ -198,7 +198,7 @@
- `forge_dismantle` - `forge_dismantle`
脚本:`src/data/functionCatalog/panel/forgeDismantle.ts` 脚本:`src/data/functionCatalog/panel/forgeDismantle.ts`
说明:在锻造面板中拆解物品回收材料的 function。拆解产出由本地锻造规则控制,避免与物品设计脱节。 说明:在锻造面板中拆解物品回收材料的 function。拆解产出由后端锻造 resolver 控制,避免与物品设计脱节。
- `forge_reforge` - `forge_reforge`
脚本:`src/data/functionCatalog/panel/forgeReforge.ts` 脚本:`src/data/functionCatalog/panel/forgeReforge.ts`

View File

@@ -6,44 +6,36 @@
统一角色属性系统把一个世界中“角色能力如何被理解”收口到 `CustomWorldProfile.attributeSchema.slots`。这六个 slot 是世界级设定,不是单个角色自己的六个字段。 统一角色属性系统把一个世界中“角色能力如何被理解”收口到 `CustomWorldProfile.attributeSchema.slots`。这六个 slot 是世界级设定,不是单个角色自己的六个字段。
当前结果页世界页可以展示角色维度,但编辑世界信息时只能修改世界名称、概述、基调、目标等文本,尚不能手动修订六个维度本身的信息 当前结果页世界页可以展示角色维度。旧方案曾允许编辑维度定义、正负信号和战斗/社交/探索用途,但这些字段会让创作和提示词下游过早背负规则说明。本轮收缩为只允许修订六个维度名称
## 2. 本次目标 ## 2. 本次目标
在“编辑世界信息”独立面板中允许用户编辑六个角色维度的信息 在“编辑基本设定”独立面板中允许用户编辑六个角色维度名称
1. 修改 `attributeSchema.slots` 中每个维度的 `name``definition``positiveSignals``negativeSignals``combatUseText``socialUseText``explorationUseText` 1. 修改 `attributeSchema.slots` 中每个维度的 `name`
2. 不在可扮演角色或场景角色编辑器中新增单角色六维数值编辑。 2. 不在可扮演角色或场景角色编辑器中新增单角色六维数值编辑。
3. 保存时同步更新 `profile.attributeSchema` 3. 保存时同步更新 `profile.attributeSchema`
4.`profile.ownedSettingLayers.ruleProfile.attributeSchema` 存在,同步写入同一份 schema避免世界档案和设定层出现双源漂移。 4.`profile.ownedSettingLayers.ruleProfile.attributeSchema` 存在,同步写入同一份 schema避免世界档案和设定层出现双源漂移。
5. 前端只负责编辑结构化文本,不新增属性结算逻辑。 5. 前端只负责编辑名称,不新增属性结算逻辑,也不再保存维度说明、正负信号和用途文本
## 3. 交互设计 ## 3. 交互设计
入口位置: 入口位置:
- 世界页点击“世界概述”里的编辑按钮 - 世界页点击“基本设定”里的编辑按钮
- 打开现有“编辑世界信息”面板 - 打开现有“编辑基本设定”面板
- 在基础世界文本字段下方增加“角色维度”区块 - 在基础世界文本字段下方增加“角色维度”区块
每个维度展示并允许编辑 每个维度展示并允许编辑“维度名称”。
- 维度名称
- 定义
- 正向信号
- 负向信号
- 战斗体现
- 社交体现
- 探索体现
正向信号与负向信号使用逗号、中文逗号或换行拆分成数组。
## 4. 数据落点 ## 4. 数据落点
保存路径: 保存路径:
- `profile.attributeSchema.slots[n]` - `profile.attributeSchema.slots[n].name`
- `profile.ownedSettingLayers.ruleProfile.attributeSchema.slots[n]`,仅当 `ownedSettingLayers` 已存在时同步 - `profile.ownedSettingLayers.ruleProfile.attributeSchema.slots[n].name`,仅当 `ownedSettingLayers` 已存在时同步
系统仍保留 `slotId` 作为稳定键,解析旧草稿时会丢弃旧 `definition``positiveSignals``negativeSignals``combatUseText``socialUseText``explorationUseText` 字段。
不修改: 不修改:
@@ -52,7 +44,7 @@
## 5. 验收 ## 5. 验收
1. 世界信息面板能看到六个角色维度。 1. 基本设定面板能看到六个角色维度名称
2. 修改任一维度名称、定义、信号或三类用途说明后,保存到 `profile.attributeSchema.slots` 2. 修改任一维度名称后,保存到 `profile.attributeSchema.slots`,且不会写回旧说明字段
3. 编辑角色自身时不出现单角色六维数值输入区。 3. 编辑角色自身时不出现单角色六维数值输入区。
4. UI 仍读取当前世界 schema不回退写死旧四维文案。 4. UI 仍读取当前世界 schema不回退写死旧四维文案。

View File

@@ -162,3 +162,29 @@
5. `npm run test -- src/services/ai.test.ts src/hooks/rpg-runtime-story/storyRequestCoordinator.test.ts src/hooks/rpg-runtime-story/useRpgRuntimeStoryController.test.tsx` 5. `npm run test -- src/services/ai.test.ts src/hooks/rpg-runtime-story/storyRequestCoordinator.test.ts src/hooks/rpg-runtime-story/useRpgRuntimeStoryController.test.tsx`
6. `npm run test -- src/hooks/rpg-runtime-story/sessionActions.test.ts src/hooks/rpg-runtime-story/choiceActions.test.ts src/hooks/rpg-runtime-story/npcEncounterActions.test.ts` 6. `npm run test -- src/hooks/rpg-runtime-story/sessionActions.test.ts src/hooks/rpg-runtime-story/choiceActions.test.ts src/hooks/rpg-runtime-story/npcEncounterActions.test.ts`
7. `npx eslint src/hooks/rpg-runtime-story/sessionActions.ts src/hooks/rpg-runtime-story/sessionActions.test.ts src/hooks/rpg-runtime-story/useRpgRuntimeNpcInteraction.ts src/hooks/rpg-runtime-story/choiceActions.ts src/hooks/rpg-runtime-story/choiceActions.test.ts --max-warnings 0` 7. `npx eslint src/hooks/rpg-runtime-story/sessionActions.ts src/hooks/rpg-runtime-story/sessionActions.test.ts src/hooks/rpg-runtime-story/useRpgRuntimeNpcInteraction.ts src/hooks/rpg-runtime-story/choiceActions.ts src/hooks/rpg-runtime-story/choiceActions.test.ts --max-warnings 0`
### 4.6 `camp_travel_home_scene` 后端收尾
本轮继续收口完成度核验中最后一个残留点:`camp_travel_home_scene` 不能再被前端 `runCampTravelHomeChoice(...)` 提前拦截并本地拼装正式状态。
落地规则:
1. 前端点击 `camp_travel_home_scene` 后统一进入 `runServerRuntimeChoiceAction(...)`,只提交 `sessionId / clientVersion / functionId / optionText / runtimePayload`
2. `server-rs/crates/api-server/src/runtime_story/compat.rs` 负责解析离营目标场景:
- 优先使用 action payload 中的 `targetSceneId`
- 内置世界按 `playerCharacter.id + worldType` 映射到角色主场景。
- 自定义世界优先找玩家角色在 `landmarks[].sceneNpcIds` 中绑定的地点,否则使用当前营地的 `forwardSceneId / connectedSceneIds` 或第一个 landmark。
3. 后端 resolver 写入完整离营状态:
- `currentScenePreset`
- `currentEncounter / sceneHostileNpcs / npcInteractionActive / inBattle`
- `runtimeStats.scenesTraveled`
- `playerX / playerFacing / animationState / playerActionMode / scrollWorld`
- `lastObserveSigns* / currentBattle* / spar* / activeCombatEffects`
4. 后端在目标场景上生成确定性的 encounter preview内置场景至少带一个可交互 NPC自定义场景复用 `build_custom_scene_preset(...)` 中的 NPC 投影。
5. 后端保存 `storyHistory``currentStory`,随后继续走 `project_story_engine_after_action(...)` 和持久化快照。
6. 前端保留 `camp_travel_home_scene` 作为 function id 与展示用 helper但不再保留正式状态构造函数。
验证新增:
1. 后端 route 测试覆盖 `camp_travel_home_scene` 点击后 hydrated snapshot 已进入角色主场景、生成 encounter preview、递增 `scenesTraveled` 并持久化。
2. 前端 `choiceActions.test.ts` 覆盖 `camp_travel_home_scene` 即使命中旧 helper 判定,也只调用 `runServerRuntimeChoiceAction(...)`

View File

@@ -7,7 +7,7 @@ RPG Agent 生成世界草稿时,前端会把 `draftProfile` 归一化成 `Cust
## 落地约束 ## 落地约束
- `draftProfile.attributeSchema` 是世界草稿真相源的一部分,必须随 foundation draft 一起生成并保存。 - `draftProfile.attributeSchema` 是世界草稿真相源的一部分,必须随 foundation draft 一起生成并保存。
- 六维固定使用 `axis_a``axis_f` 六个槽位,但 `schemaName`每个槽位 `name` 和说明必须贴合本次世界设定 - 六维固定使用 `axis_a``axis_f` 六个系统槽位,但创作、提示词输出、解析后保存的数据只保留每个槽位 `name``slotId` 由系统补齐用于数值映射,不要求模型理解或生成额外说明字段
- 维度名不得沿用通用旧词:生命、法力、护甲、攻击、防御、力量、敏捷、智力、精神。 - 维度名不得沿用通用旧词:生命、法力、护甲、攻击、防御、力量、敏捷、智力、精神。
- 若模型遗漏或结构不合规,后端必须生成中文兜底属性体系,不能让前端只靠固定模板补齐。 - 若模型遗漏或结构不合规,后端必须生成中文兜底属性体系,不能让前端只靠固定模板补齐。
- 世界页面的“世界”页签必须展示当前 `attributeSchema.slots` 的六个名称,作为玩家进入世界前可见的规则信号。 - 世界页面的“世界”页签必须展示当前 `attributeSchema.slots` 的六个名称,作为玩家进入世界前可见的规则信号。
@@ -25,7 +25,7 @@ RPG Agent 生成世界草稿时,前端会把 `draftProfile` 归一化成 `Cust
3. `server-rs/crates/api-server/src/custom_world_foundation_draft.rs` 3. `server-rs/crates/api-server/src/custom_world_foundation_draft.rs`
- `normalize_framework_shape()` 归一化 `attributeSchema` - `normalize_framework_shape()` 归一化 `attributeSchema`
- `build_foundation_draft_profile_from_framework()` 将归一化后的 `attributeSchema` 写入 `draftProfile` - `build_foundation_draft_profile_from_framework()` 将归一化后的 `attributeSchema` 写入 `draftProfile`
- 新增兜底生成器,基于世界名、基调、目标、冲突和种子文本生成六个中文维度。 - 新增兜底生成器,基于世界名、基调、目标、冲突和种子文本生成六个中文维度名称
4. `src/components/CustomWorldEntityCatalog.tsx` 4. `src/components/CustomWorldEntityCatalog.tsx`
- 在世界页签增加“角色维度”区域,直接渲染 `profile.attributeSchema.slots` 的六个名称。 - 在世界页签增加“角色维度”区域,直接渲染 `profile.attributeSchema.slots` 的六个名称。
@@ -33,5 +33,5 @@ RPG Agent 生成世界草稿时,前端会把 `draftProfile` 归一化成 `Cust
## 验收 ## 验收
- 新生成的 RPG 世界草稿 JSON 顶层包含 `attributeSchema.slots.length === 6` - 新生成的 RPG 世界草稿 JSON 顶层包含 `attributeSchema.slots.length === 6`
- 结果页/世界页展示六个自定义维度名,而非固定的力量、敏捷、智力、精神。 - 结果页/世界页展示六个自定义维度名,而非固定的力量、敏捷、智力、精神;页面不展示维度说明、正负信号或用途说明
- 缺失或非法模型输出会被后端兜底为合法中文六维。 - 缺失或非法模型输出会被后端兜底为合法中文六维。

View File

@@ -130,12 +130,6 @@ export interface RpgAgentFoundationDraftCamp {
export interface RpgAgentWorldAttributeSlot { export interface RpgAgentWorldAttributeSlot {
slotId: 'axis_a' | 'axis_b' | 'axis_c' | 'axis_d' | 'axis_e' | 'axis_f'; slotId: 'axis_a' | 'axis_b' | 'axis_c' | 'axis_d' | 'axis_e' | 'axis_f';
name: string; name: string;
definition: string;
positiveSignals: string[];
negativeSignals: string[];
combatUseText: string;
socialUseText: string;
explorationUseText: string;
} }
export interface RpgAgentWorldAttributeSchema { export interface RpgAgentWorldAttributeSchema {
@@ -149,7 +143,6 @@ export interface RpgAgentWorldAttributeSchema {
tone: string; tone: string;
conflictCore: string; conflictCore: string;
}; };
schemaName?: string;
slots: RpgAgentWorldAttributeSlot[]; slots: RpgAgentWorldAttributeSlot[];
} }

View File

@@ -182,7 +182,6 @@ export function createRpgAgentFoundationDraftProfileFixture(): RpgAgentFoundatio
id: 'schema:rpg-agent:tide-fixture', id: 'schema:rpg-agent:tide-fixture',
worldId: 'custom:潮雾列岛', worldId: 'custom:潮雾列岛',
schemaVersion: 1, schemaVersion: 1,
schemaName: '潮雾六脉',
generatedFrom: { generatedFrom: {
worldType: 'CUSTOM', worldType: 'CUSTOM',
worldName: '潮雾列岛', worldName: '潮雾列岛',
@@ -194,62 +193,26 @@ export function createRpgAgentFoundationDraftProfileFixture(): RpgAgentFoundatio
{ {
slotId: 'axis_a', slotId: 'axis_a',
name: '潮骨', name: '潮骨',
definition: '承受潮压、封航令与正面冲击的底子。',
positiveSignals: ['承压', '稳阵'],
negativeSignals: ['散乱', '畏压'],
combatUseText: '顶住正面压迫并守住行动空间。',
socialUseText: '在封锁与质问中保持可信姿态。',
explorationUseText: '穿过潮湿险境时维持身体与装备状态。',
}, },
{ {
slotId: 'axis_b', slotId: 'axis_b',
name: '浪步', name: '浪步',
definition: '顺潮借势、换线穿行与抢占位置的能力。',
positiveSignals: ['借势', '轻快'],
negativeSignals: ['迟滞', '失位'],
combatUseText: '借地形切线、拉开距离或抢先手。',
socialUseText: '顺着对方语气调整节奏。',
explorationUseText: '穿越港口、水路、雾区与复杂地形。',
}, },
{ {
slotId: 'axis_c', slotId: 'axis_c',
name: '灯识', name: '灯识',
definition: '看懂灯号、潮痕、档案错页与人心遮掩的能力。',
positiveSignals: ['辨伪', '识局'],
negativeSignals: ['误读', '迟钝'],
combatUseText: '识破破绽并判断局势变化。',
socialUseText: '听出隐瞒、试探与交换空间。',
explorationUseText: '辨认潮痕、灯册和沉船遗留线索。',
}, },
{ {
slotId: 'axis_d', slotId: 'axis_d',
name: '雾魄', name: '雾魄',
definition: '在海雾、旧案与封锁压力中推进真相的胆气。',
positiveSignals: ['果断', '压前'],
negativeSignals: ['犹疑', '退缩'],
combatUseText: '顶着高压窗口推进突破口。',
socialUseText: '在谈判或对峙中定调。',
explorationUseText: '面对陌生雾区与异状仍敢继续前探。',
}, },
{ {
slotId: 'axis_e', slotId: 'axis_e',
name: '旧约', name: '旧约',
definition: '与旧友、信物、灯令和地方关系建立牵引的能力。',
positiveSignals: ['守诺', '通人情'],
negativeSignals: ['疏离', '失信'],
combatUseText: '借同伴协同与旧约牵制形成连锁。',
socialUseText: '安抚、结盟、交换与维系信任。',
explorationUseText: '从人情、传闻和旧物中打开线索。',
}, },
{ {
slotId: 'axis_f', slotId: 'axis_f',
name: '回澜', name: '回澜',
definition: '在长线消耗中回稳节奏并维持判断的能力。',
positiveSignals: ['回稳', '续航'],
negativeSignals: ['紊乱', '断流'],
combatUseText: '久战不乱,把节奏重新拉回手里。',
socialUseText: '情绪稳定,不轻易被带偏。',
explorationUseText: '在漫长远行与恶劣天气里保有余力。',
}, },
], ],
}, },

View File

@@ -870,24 +870,6 @@ fn normalize_world_attribute_schema(
normalized_slots.push(json!({ normalized_slots.push(json!({
"slotId": slot_id, "slotId": slot_id,
"name": name, "name": name,
"definition": json_map_text(raw_slot, "definition")
.or_else(|| json_map_text(&fallback_slot, "definition"))
.unwrap_or_else(|| "这个维度用于描述角色在当前世界中的关键表现。".to_string()),
"positiveSignals": json_map_string_array(raw_slot, "positiveSignals")
.or_else(|| json_map_string_array(&fallback_slot, "positiveSignals"))
.unwrap_or_else(|| vec!["稳定".to_string(), "主动".to_string()]),
"negativeSignals": json_map_string_array(raw_slot, "negativeSignals")
.or_else(|| json_map_string_array(&fallback_slot, "negativeSignals"))
.unwrap_or_else(|| vec!["失衡".to_string(), "被动".to_string()]),
"combatUseText": json_map_text(raw_slot, "combatUseText")
.or_else(|| json_map_text(&fallback_slot, "combatUseText"))
.unwrap_or_else(|| "影响战斗中的推进、承压与应对。".to_string()),
"socialUseText": json_map_text(raw_slot, "socialUseText")
.or_else(|| json_map_text(&fallback_slot, "socialUseText"))
.unwrap_or_else(|| "影响对话中的判断、牵引与立场。".to_string()),
"explorationUseText": json_map_text(raw_slot, "explorationUseText")
.or_else(|| json_map_text(&fallback_slot, "explorationUseText"))
.unwrap_or_else(|| "影响探索中的观察、穿行与续航。".to_string()),
})); }));
} }
@@ -901,9 +883,6 @@ fn normalize_world_attribute_schema(
.and_then(JsonValue::as_i64) .and_then(JsonValue::as_i64)
.filter(|value| *value > 0) .filter(|value| *value > 0)
.unwrap_or(1), .unwrap_or(1),
"schemaName": json_map_text(schema, "schemaName")
.filter(|value| !is_invalid_attribute_schema_name(value))
.unwrap_or_else(|| build_attribute_schema_name(framework, setting_text)),
"generatedFrom": { "generatedFrom": {
"worldType": "CUSTOM", "worldType": "CUSTOM",
"worldName": framework_world_name(framework, setting_text), "worldName": framework_world_name(framework, setting_text),
@@ -945,7 +924,6 @@ fn build_fallback_world_attribute_schema(framework: &JsonValue, setting_text: &s
"id": build_attribute_schema_id(framework, setting_text), "id": build_attribute_schema_id(framework, setting_text),
"worldId": format!("custom:{world_name}"), "worldId": format!("custom:{world_name}"),
"schemaVersion": 1, "schemaVersion": 1,
"schemaName": build_attribute_schema_name(framework, setting_text),
"generatedFrom": { "generatedFrom": {
"worldType": "CUSTOM", "worldType": "CUSTOM",
"worldName": world_name, "worldName": world_name,
@@ -954,35 +932,20 @@ fn build_fallback_world_attribute_schema(framework: &JsonValue, setting_text: &s
"conflictCore": conflict_core, "conflictCore": conflict_core,
}, },
"slots": [ "slots": [
build_attribute_slot("axis_a", format!("{prefix}"), format!("承受{prefix}压、正面冲击与长期消耗的底子。"), ["承压", "稳阵"], ["虚浮", "易散"], "顶住正面压力并守住行动空间。", "在强压场面里保持可信和稳固。", "穿过危险环境时维持身体与装备状态。"), build_attribute_slot("axis_a", format!("{prefix}")),
build_attribute_slot("axis_b", format!("{prefix_alt}"), format!("顺应{prefix_alt}势、换位穿行与抢占时机的能力。"), ["借势", "轻快"], ["迟滞", "失位"], "切线换位、闪避、追击和抢先手。", "反应灵活,能顺势调整话术。", "穿越复杂地形、封锁线与危险通路。"), build_attribute_slot("axis_b", format!("{prefix_alt}")),
build_attribute_slot("axis_c", format!("{prefix}"), "看清局势、线索、虚实与隐藏代价的能力。", ["洞察", "辨伪"], ["误读", "迟钝"], "识破破绽并判断战局变化。", "听出隐瞒、试探与交换空间。", "整理线索、辨认路径并推断风险。"), build_attribute_slot("axis_c", format!("{prefix}")),
build_attribute_slot("axis_d", format!("{prefix_alt}"), "在高压变化里仍能推进目标和拍板的胆气。", ["果断", "压前"], ["犹疑", "退缩"], "顶着高压窗口推进突破口。", "在谈判或对峙中定调。", "面对未知异象仍敢继续前探。"), build_attribute_slot("axis_d", format!("{prefix_alt}")),
build_attribute_slot("axis_e", format!("{prefix}"), "与人、物、誓约、地方关系建立牵引的能力。", ["协同", "守诺"], ["疏离", "失信"], "借同伴协同与牵制形成连锁。", "安抚、结盟、交换与维系信任。", "从人情、传闻和旧物中打开线索。"), build_attribute_slot("axis_e", format!("{prefix}")),
build_attribute_slot("axis_f", format!("{prefix_alt}"), "在长线消耗和局势反复中回稳节奏的能力。", ["回稳", "续航"], ["紊乱", "断续"], "久战不乱,把节奏重新拉回手里。", "情绪稳定,不轻易被带偏。", "在漫长探索与恶劣环境里保有余力。"), build_attribute_slot("axis_f", format!("{prefix_alt}")),
], ],
}) })
} }
fn build_attribute_slot( fn build_attribute_slot(slot_id: &str, name: String) -> JsonValue {
slot_id: &str,
name: String,
definition: impl Into<String>,
positive_signals: [&str; 2],
negative_signals: [&str; 2],
combat_use_text: &str,
social_use_text: &str,
exploration_use_text: &str,
) -> JsonValue {
json!({ json!({
"slotId": slot_id, "slotId": slot_id,
"name": name, "name": name,
"definition": definition.into(),
"positiveSignals": positive_signals,
"negativeSignals": negative_signals,
"combatUseText": combat_use_text,
"socialUseText": social_use_text,
"explorationUseText": exploration_use_text,
}) })
} }
@@ -1008,20 +971,6 @@ fn build_attribute_schema_id(framework: &JsonValue, setting_text: &str) -> Strin
) )
} }
fn build_attribute_schema_name(framework: &JsonValue, setting_text: &str) -> String {
let source = [
framework_world_name(framework, setting_text),
json_text(framework, "summary").unwrap_or_default(),
json_text(framework, "tone").unwrap_or_default(),
]
.join("");
let terms = collect_attribute_theme_terms(source.as_str());
format!(
"{}六维",
terms.first().cloned().unwrap_or_else(|| "叙境".to_string())
)
}
fn collect_attribute_theme_terms(source: &str) -> Vec<String> { fn collect_attribute_theme_terms(source: &str) -> Vec<String> {
let mut terms = Vec::new(); let mut terms = Vec::new();
let chinese_chars = source let chinese_chars = source
@@ -1062,12 +1011,6 @@ fn is_invalid_attribute_name(name: &str, seen_names: &[String]) -> bool {
.any(|banned| trimmed.contains(banned)) .any(|banned| trimmed.contains(banned))
} }
fn is_invalid_attribute_schema_name(name: &str) -> bool {
BANNED_ATTRIBUTE_NAMES
.iter()
.any(|banned| name.trim().contains(banned))
}
fn json_map_text(map: &JsonMap<String, JsonValue>, key: &str) -> Option<String> { fn json_map_text(map: &JsonMap<String, JsonValue>, key: &str) -> Option<String> {
map.get(key) map.get(key)
.and_then(JsonValue::as_str) .and_then(JsonValue::as_str)
@@ -1076,18 +1019,6 @@ fn json_map_text(map: &JsonMap<String, JsonValue>, key: &str) -> Option<String>
.map(ToOwned::to_owned) .map(ToOwned::to_owned)
} }
fn json_map_string_array(map: &JsonMap<String, JsonValue>, key: &str) -> Option<Vec<String>> {
let items = map
.get(key)?
.as_array()?
.iter()
.filter_map(|entry| entry.as_str().map(str::trim))
.filter(|entry| !entry.is_empty())
.map(ToOwned::to_owned)
.collect::<Vec<_>>();
if items.is_empty() { None } else { Some(items) }
}
fn first_json_string(value: &JsonValue, key: &str) -> Option<String> { fn first_json_string(value: &JsonValue, key: &str) -> Option<String> {
value value
.get(key) .get(key)
@@ -2492,7 +2423,7 @@ mod tests {
request_capture.clone(), request_capture.clone(),
vec![ vec![
llm_response( llm_response(
r#"{"name":"雾港归航","subtitle":"失灯旧案","summary":"守灯人与群岛议会围绕沉船旧案对峙。","tone":"海雾悬疑","playerGoal":"查清父亲沉船真相","templateWorldType":"WUXIA","majorFactions":["群岛议会","灯塔署"],"coreConflicts":["守灯塔的旧档案被人改写。"],"attributeSchema":{"schemaName":"雾港六维","slots":[{"slotId":"axis_a","name":"灯骨","definition":"承受封航压力与潮湿险境的底子。","positiveSignals":["承压"],"negativeSignals":["虚浮"],"combatUseText":"顶住正面压迫。","socialUseText":"在质问中稳住姿态。","explorationUseText":"穿过潮湿险境。"},{"slotId":"axis_b","name":"潮步","definition":"顺潮换位与穿行的能力。","positiveSignals":["轻快"],"negativeSignals":["迟滞"],"combatUseText":"切线换位。","socialUseText":"顺势调整说法。","explorationUseText":"穿越雾港通路。"},{"slotId":"axis_c","name":"灯识","definition":"辨认灯号和旧档错页的能力。","positiveSignals":["辨伪"],"negativeSignals":["误读"],"combatUseText":"看破破绽。","socialUseText":"听出遮掩。","explorationUseText":"辨认旧档线索。"},{"slotId":"axis_d","name":"雾魄","definition":"在海雾和旧案压力中推进的胆气。","positiveSignals":["果断"],"negativeSignals":["退缩"],"combatUseText":"压上突破口。","socialUseText":"在对峙中定调。","explorationUseText":"敢进陌生雾区。"},{"slotId":"axis_e","name":"旧约","definition":"维系旧友、信物与地方关系的能力。","positiveSignals":["守诺"],"negativeSignals":["疏离"],"combatUseText":"借同伴协同。","socialUseText":"建立信任交换。","explorationUseText":"从人情旧物找线索。"},{"slotId":"axis_f","name":"回澜","definition":"长线消耗中回稳节奏的能力。","positiveSignals":["回稳"],"negativeSignals":["紊乱"],"combatUseText":"久战不乱。","socialUseText":"不被情绪带偏。","explorationUseText":"远行中保有余力。"}]},"camp":{"name":"旧灯塔归舍","description":"海雾边缘的守灯人旧居。"}}"#, r#"{"name":"雾港归航","subtitle":"失灯旧案","summary":"守灯人与群岛议会围绕沉船旧案对峙。","tone":"海雾悬疑","playerGoal":"查清父亲沉船真相","templateWorldType":"WUXIA","majorFactions":["群岛议会","灯塔署"],"coreConflicts":["守灯塔的旧档案被人改写。"],"attributeSchema":{"slots":[{"name":"灯骨"},{"name":"潮步"},{"name":"灯识"},{"name":"雾魄"},{"name":"旧约"},{"name":"回澜"}]},"camp":{"name":"旧灯塔归舍","description":"海雾边缘的守灯人旧居。"}}"#,
), ),
llm_response( llm_response(
r#"{"playableNpcs":[{"name":"岑灯","title":"返乡守灯人","role":"主角代理","description":"追查旧案的人","visualDescription":"灰蓝旧灯披风压着海盐痕,腰侧挂旧海图筒和短灯杖。","actionDescription":"举灯照海图,短杖点地辨认潮声。","sceneVisualDescription":"旧灯塔回廊被海雾压低,墙上挂满潮湿航线图。","initialAffinity":24,"relationshipHooks":["旧案牵连"],"tags":["守灯人"]}]}"#, r#"{"playableNpcs":[{"name":"岑灯","title":"返乡守灯人","role":"主角代理","description":"追查旧案的人","visualDescription":"灰蓝旧灯披风压着海盐痕,腰侧挂旧海图筒和短灯杖。","actionDescription":"举灯照海图,短杖点地辨认潮声。","sceneVisualDescription":"旧灯塔回廊被海雾压低,墙上挂满潮湿航线图。","initialAffinity":24,"relationshipHooks":["旧案牵连"],"tags":["守灯人"]}]}"#,
@@ -2595,6 +2526,16 @@ mod tests {
.and_then(JsonValue::as_str), .and_then(JsonValue::as_str),
Some("灯骨") Some("灯骨")
); );
assert_eq!(
draft_profile
.get("attributeSchema")
.and_then(|schema| schema.get("slots"))
.and_then(JsonValue::as_array)
.and_then(|entries| entries.first())
.and_then(JsonValue::as_object)
.map(|entry| entry.contains_key("definition")),
Some(false)
);
assert!( assert!(
draft_profile draft_profile
.get("worldHook") .get("worldHook")

View File

@@ -21,14 +21,13 @@ pub(crate) fn build_custom_world_framework_prompt(setting_text: &str) -> String
" \"majorFactions\": [\"势力甲\", \"势力乙\"],".to_string(), " \"majorFactions\": [\"势力甲\", \"势力乙\"],".to_string(),
" \"coreConflicts\": [\"冲突甲\", \"冲突乙\"],".to_string(), " \"coreConflicts\": [\"冲突甲\", \"冲突乙\"],".to_string(),
" \"attributeSchema\": {".to_string(), " \"attributeSchema\": {".to_string(),
" \"schemaName\": \"本世界六维名称\",".to_string(),
" \"slots\": [".to_string(), " \"slots\": [".to_string(),
" { \"slotId\": \"axis_a\", \"name\": \"维度名\", \"definition\": \"维度定义\", \"positiveSignals\": [\"正向表现\"], \"negativeSignals\": [\"负向表现\"], \"combatUseText\": \"战斗用途\", \"socialUseText\": \"社交用途\", \"explorationUseText\": \"探索用途\" },".to_string(), " { \"name\": \"维度名\" },".to_string(),
" { \"slotId\": \"axis_b\", \"name\": \"维度名\", \"definition\": \"维度定义\", \"positiveSignals\": [\"正向表现\"], \"negativeSignals\": [\"负向表现\"], \"combatUseText\": \"战斗用途\", \"socialUseText\": \"社交用途\", \"explorationUseText\": \"探索用途\" },".to_string(), " { \"name\": \"维度名\" },".to_string(),
" { \"slotId\": \"axis_c\", \"name\": \"维度名\", \"definition\": \"维度定义\", \"positiveSignals\": [\"正向表现\"], \"negativeSignals\": [\"负向表现\"], \"combatUseText\": \"战斗用途\", \"socialUseText\": \"社交用途\", \"explorationUseText\": \"探索用途\" },".to_string(), " { \"name\": \"维度名\" },".to_string(),
" { \"slotId\": \"axis_d\", \"name\": \"维度名\", \"definition\": \"维度定义\", \"positiveSignals\": [\"正向表现\"], \"negativeSignals\": [\"负向表现\"], \"combatUseText\": \"战斗用途\", \"socialUseText\": \"社交用途\", \"explorationUseText\": \"探索用途\" },".to_string(), " { \"name\": \"维度名\" },".to_string(),
" { \"slotId\": \"axis_e\", \"name\": \"维度名\", \"definition\": \"维度定义\", \"positiveSignals\": [\"正向表现\"], \"negativeSignals\": [\"负向表现\"], \"combatUseText\": \"战斗用途\", \"socialUseText\": \"社交用途\", \"explorationUseText\": \"探索用途\" },".to_string(), " { \"name\": \"维度名\" },".to_string(),
" { \"slotId\": \"axis_f\", \"name\": \"维度名\", \"definition\": \"维度定义\", \"positiveSignals\": [\"正向表现\"], \"negativeSignals\": [\"负向表现\"], \"combatUseText\": \"战斗用途\", \"socialUseText\": \"社交用途\", \"explorationUseText\": \"探索用途\" }".to_string(), " { \"name\": \"维度名\" }".to_string(),
" ]".to_string(), " ]".to_string(),
" },".to_string(), " },".to_string(),
" \"camp\": {".to_string(), " \"camp\": {".to_string(),
@@ -45,9 +44,9 @@ pub(crate) fn build_custom_world_framework_prompt(setting_text: &str) -> String
"- camp 只表示玩家开局时的落脚处占位,更接近归舍、住处、栖居、前哨居所这类“家/归处”的概念;不要在这一步生成开局场景任务、三幕事件或三幕背景。".to_string(), "- camp 只表示玩家开局时的落脚处占位,更接近归舍、住处、栖居、前哨居所这类“家/归处”的概念;不要在这一步生成开局场景任务、三幕事件或三幕背景。".to_string(),
"- 不要输出 playableNpcs、storyNpcs、landmarks、items也不要输出任何角色和地图细节。".to_string(), "- 不要输出 playableNpcs、storyNpcs、landmarks、items也不要输出任何角色和地图细节。".to_string(),
"- majorFactions 保持 2 到 3 个coreConflicts 保持 2 到 3 个。".to_string(), "- majorFactions 保持 2 到 3 个coreConflicts 保持 2 到 3 个。".to_string(),
"- attributeSchema 必须是本世界专属的角色六维属性体系slots 必须恰好 6 个slotId 固定为 axis_a 到 axis_f,维度名必须是 2 到 4 个汉字且互不重复。".to_string(), "- attributeSchema 必须是本世界专属的角色六维名称体系slots 必须恰好 6 个,每个 slot 只输出 name,维度名必须是 2 到 4 个汉字且互不重复。".to_string(),
"- attributeSchema.slots 的 name 禁止使用:生命、法力、护甲、攻击、防御、力量、敏捷、智力、精神;不要写通用 DND 或传统四维属性。".to_string(), "- attributeSchema.slots 的 name 禁止使用:生命、法力、护甲、攻击、防御、力量、敏捷、智力、精神;不要写通用 DND 或传统四维属性。".to_string(),
"- 每个属性维度definition都要像RPG游戏属性名同时能服务战斗、社交、探索三种场景definition、combatUseText、socialUseText、explorationUseText 必须贴合本世界主题".to_string(), "- 不要在 attributeSchema.slots 内输出 definition、positiveSignals、negativeSignals、combatUseText、socialUseText、explorationUseText 或其他说明字段".to_string(),
"- 世界设定必须直接源自玩家输入,不要脱离主题乱扩写。".to_string(), "- 世界设定必须直接源自玩家输入,不要脱离主题乱扩写。".to_string(),
"- 每个字符串尽量简洁subtitle 控制在 8 到 18 个汉字内summary 控制在 16 到 32 个汉字内tone 控制在 6 到 16 个汉字内playerGoal 控制在 16 到 32 个汉字内camp.description 控制在 18 到 40 个汉字内。".to_string(), "- 每个字符串尽量简洁subtitle 控制在 8 到 18 个汉字内summary 控制在 16 到 32 个汉字内tone 控制在 6 到 16 个汉字内playerGoal 控制在 16 到 32 个汉字内camp.description 控制在 18 到 40 个汉字内。".to_string(),
"- 返回前自检:必须是一个能被 JSON.parse 直接解析的单个 JSON 对象。".to_string(), "- 返回前自检:必须是一个能被 JSON.parse 直接解析的单个 JSON 对象。".to_string(),
@@ -61,7 +60,7 @@ pub(crate) fn build_custom_world_framework_json_repair_prompt(response_text: &st
"顶层必须只包含name、subtitle、summary、tone、playerGoal、templateWorldType、majorFactions、coreConflicts、attributeSchema、camp。", "顶层必须只包含name、subtitle、summary、tone、playerGoal、templateWorldType、majorFactions、coreConflicts、attributeSchema、camp。",
"不要输出 playableNpcs、storyNpcs、landmarks、items 或任何其他字段。", "不要输出 playableNpcs、storyNpcs、landmarks、items 或任何其他字段。",
"majorFactions 与 coreConflicts 必须是字符串数组。", "majorFactions 与 coreConflicts 必须是字符串数组。",
"attributeSchema 必须是对象,且包含 schemaName 与 slotsslots 必须恰好 6 个slotId 固定为 axis_a 到 axis_f", "attributeSchema 必须是对象,且包含 slotsslots 必须恰好 6 个,每个 slot 只保留 name",
"camp 必须是对象且只包含name、description。", "camp 必须是对象且只包含name、description。",
"原始文本:", "原始文本:",
response_text.trim(), response_text.trim(),

View File

@@ -678,22 +678,7 @@ fn resolve_runtime_story_choice_action(
2, 2,
"你把眼前局势先讲清楚,对方终于愿意把第一轮判断说出口。", "你把眼前局势先讲清楚,对方终于愿意把第一轮判断说出口。",
), ),
"camp_travel_home_scene" => { "camp_travel_home_scene" => resolve_camp_travel_home_scene_action(game_state, request),
clear_encounter_state(game_state);
Ok(StoryResolution {
action_text: resolve_action_text("返回营地", request),
result_text: "你主动结束了当前遭遇,把节奏带回了更安全的营地。".to_string(),
story_text: None,
presentation_options: None,
saved_current_story: None,
patches: vec![
build_status_patch(game_state),
RuntimeStoryPatch::EncounterChanged { encounter_id: None },
],
battle: None,
toast: None,
})
}
"idle_call_out" => Ok(simple_story_resolution( "idle_call_out" => Ok(simple_story_resolution(
game_state, game_state,
resolve_action_text("主动出声试探", request), resolve_action_text("主动出声试探", request),
@@ -854,6 +839,557 @@ fn resolve_idle_travel_next_scene_action(
}) })
} }
fn resolve_camp_travel_home_scene_action(
game_state: &mut Value,
request: &RuntimeStoryActionRequest,
) -> Result<StoryResolution, String> {
let target_scene = resolve_camp_travel_target_scene(game_state, request)
.ok_or_else(|| "无法解析离营后的目标场景".to_string())?;
let target_scene_name =
read_optional_string_field(&target_scene, "name").unwrap_or_else(|| "前方场景".to_string());
let companion_name = 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());
ensure_json_object(game_state).insert("currentScenePreset".to_string(), target_scene);
reset_scene_travel_runtime_state(game_state);
increment_runtime_stat(game_state, "scenesTraveled", 1);
ensure_scene_encounter_preview(game_state);
let encounter_id = read_object_field(game_state, "currentEncounter")
.and_then(|encounter| read_optional_string_field(encounter, "id"));
Ok(StoryResolution {
action_text: resolve_action_text(&format!("前往{target_scene_name}"), request),
result_text: format!(
"你和{companion_name}离开营地,正式踏入{target_scene_name},把冒险推进到新的现场。"
),
story_text: None,
presentation_options: None,
saved_current_story: None,
patches: vec![
build_status_patch(game_state),
RuntimeStoryPatch::EncounterChanged { encounter_id },
],
battle: None,
toast: None,
})
}
fn resolve_camp_travel_target_scene(
game_state: &Value,
request: &RuntimeStoryActionRequest,
) -> Option<Value> {
resolve_payload_target_scene(game_state, request)
.or_else(|| resolve_character_home_scene(game_state))
.or_else(|| resolve_current_scene_forward_scene(game_state))
.or_else(|| resolve_default_first_adventure_scene(game_state))
}
fn resolve_payload_target_scene(
game_state: &Value,
request: &RuntimeStoryActionRequest,
) -> Option<Value> {
// 中文注释:旧前端如果补传 targetSceneId后端可以接收
// 但正式主链不依赖前端,缺省时仍由服务端自行解析目标场景。
let target_scene_id = request
.action
.payload
.as_ref()
.and_then(|payload| read_optional_string_field(payload, "targetSceneId"))
.or_else(|| request.action.target_id.clone())?;
resolve_scene_preset_by_id(game_state, target_scene_id.as_str())
}
fn resolve_character_home_scene(game_state: &Value) -> Option<Value> {
let character_id = read_object_field(game_state, "playerCharacter")
.and_then(|character| read_optional_string_field(character, "id"));
let world_type = current_world_type(game_state);
let Some(character_id) = character_id else {
return None;
};
if world_type.as_deref() == Some("CUSTOM") {
return resolve_custom_character_home_scene(game_state, character_id.as_str());
}
let scene_id = match (character_id.as_str(), world_type.as_deref()) {
("sword-princess", Some("XIANXIA")) => "xianxia-celestial-corridor",
("sword-princess", _) => "wuxia-palace-court",
("archer-hero", Some("XIANXIA")) => "xianxia-star-vessel",
("archer-hero", _) => "wuxia-border-camp",
("girl-hero", Some("XIANXIA")) => "xianxia-waterfall-cliff",
("girl-hero", _) => "wuxia-rain-street",
("punch-hero", Some("XIANXIA")) => "xianxia-molten-realm",
("punch-hero", _) => "wuxia-forge-works",
("fighter-4", Some("XIANXIA")) => "xianxia-thunder-altar",
("fighter-4", _) => "wuxia-mountain-gate",
_ => return None,
};
resolve_builtin_scene_preset(world_type.as_deref().unwrap_or("WUXIA"), scene_id)
}
fn resolve_custom_character_home_scene(game_state: &Value, character_id: &str) -> Option<Value> {
let profile = read_object_field(game_state, "customWorldProfile")?;
let role_id = find_custom_world_role_id_by_reference(profile, character_id)
.or_else(|| {
read_object_field(game_state, "playerCharacter")
.and_then(|character| read_optional_string_field(character, "name"))
.and_then(|name| find_custom_world_role_id_by_reference(profile, name.as_str()))
})
.unwrap_or_else(|| character_id.to_string());
read_array_field(profile, "landmarks")
.into_iter()
.enumerate()
.find_map(|(index, landmark)| {
read_array_field(landmark, "sceneNpcIds")
.into_iter()
.filter_map(Value::as_str)
.any(|npc_id| custom_role_references_equal(profile, npc_id, role_id.as_str()))
.then(|| {
bootstrap::build_custom_scene_preset(
profile,
format!("custom-scene-landmark-{}", index + 1).as_str(),
)
})
.flatten()
})
}
fn resolve_current_scene_forward_scene(game_state: &Value) -> Option<Value> {
let current_scene = read_object_field(game_state, "currentScenePreset")?;
let current_scene_id = read_optional_string_field(current_scene, "id");
read_optional_string_field(current_scene, "forwardSceneId")
.or_else(|| {
read_array_field(current_scene, "connectedSceneIds")
.into_iter()
.filter_map(Value::as_str)
.find(|scene_id| Some(*scene_id) != current_scene_id.as_deref())
.map(str::to_string)
})
.or_else(|| {
read_array_field(current_scene, "connections")
.into_iter()
.find_map(|connection| {
read_optional_string_field(connection, "sceneId")
.filter(|scene_id| Some(scene_id.as_str()) != current_scene_id.as_deref())
})
})
.and_then(|scene_id| resolve_scene_preset_by_id(game_state, scene_id.as_str()))
}
fn resolve_default_first_adventure_scene(game_state: &Value) -> Option<Value> {
if current_world_type(game_state).as_deref() == Some("CUSTOM") {
let profile = read_object_field(game_state, "customWorldProfile")?;
if !read_array_field(profile, "landmarks").is_empty() {
return bootstrap::build_custom_scene_preset(profile, "custom-scene-landmark-1");
}
return bootstrap::build_custom_scene_preset(profile, "custom-scene-camp");
}
resolve_builtin_scene_preset(
current_world_type(game_state).as_deref().unwrap_or("WUXIA"),
if current_world_type(game_state).as_deref() == Some("XIANXIA") {
"xianxia-cloud-gate"
} else {
"wuxia-bamboo-road"
},
)
}
fn resolve_scene_preset_by_id(game_state: &Value, scene_id: &str) -> Option<Value> {
if current_world_type(game_state).as_deref() == Some("CUSTOM") {
return read_object_field(game_state, "customWorldProfile")
.and_then(|profile| bootstrap::build_custom_scene_preset(profile, scene_id));
}
resolve_builtin_scene_preset(
current_world_type(game_state).as_deref().unwrap_or("WUXIA"),
scene_id,
)
}
fn reset_scene_travel_runtime_state(game_state: &mut Value) {
clear_encounter_state(game_state);
write_i32_field(game_state, "playerX", 0);
write_i32_field(game_state, "playerOffsetY", 0);
write_string_field(game_state, "playerFacing", "right");
write_string_field(game_state, "animationState", "idle");
write_string_field(game_state, "playerActionMode", "idle");
write_bool_field(game_state, "scrollWorld", false);
write_null_field(game_state, "lastObserveSignsSceneId");
write_null_field(game_state, "lastObserveSignsReport");
write_null_field(game_state, "currentBattleNpcId");
write_null_field(game_state, "currentNpcBattleMode");
write_null_field(game_state, "currentNpcBattleOutcome");
write_null_field(game_state, "sparReturnEncounter");
write_null_field(game_state, "sparPlayerHpBefore");
write_null_field(game_state, "sparPlayerMaxHpBefore");
write_null_field(game_state, "sparStoryHistoryBefore");
ensure_json_object(game_state).insert("activeCombatEffects".to_string(), Value::Array(vec![]));
}
fn resolve_builtin_scene_preset(world_type: &str, scene_id: &str) -> Option<Value> {
let scene = builtin_scene_definition(world_type, scene_id)?;
Some(build_builtin_scene_preset_from_definition(
world_type, scene,
))
}
fn build_builtin_scene_preset_from_definition(
world_type: &str,
scene: BuiltinSceneDefinition,
) -> Value {
let connections =
build_builtin_scene_connections(&scene.connected_scene_ids, scene.forward_scene_id);
let narrative_residues = scene
.treasure_hints
.iter()
.take(2)
.enumerate()
.map(|(index, hint)| {
json!({
"id": format!("residue:{}:{}", scene.id, index + 1),
"title": format!("{}的残痕 {}", scene.name, index + 1),
"visibleClue": hint,
"linkedFactIds": [],
"linkedThreadIds": []
})
})
.collect::<Vec<_>>();
json!({
"id": scene.id,
"name": scene.name,
"description": scene.description,
"imageSrc": "",
"worldType": world_type,
"forwardSceneId": scene.forward_scene_id,
"connectedSceneIds": scene.connected_scene_ids,
"connections": connections,
"npcs": [build_builtin_scene_npc(scene.npc_id, scene.npc_name, scene.npc_role, scene.npc_avatar, scene.npc_description)],
"treasureHints": scene.treasure_hints,
"narrativeResidues": narrative_residues
})
}
fn build_builtin_scene_connections(
connected_scene_ids: &[&str],
forward_scene_id: &str,
) -> Vec<Value> {
connected_scene_ids
.iter()
.enumerate()
.map(|(index, scene_id)| {
let relative_position = if *scene_id == forward_scene_id {
"forward"
} else if index % 2 == 0 {
"left"
} else {
"right"
};
json!({
"sceneId": scene_id,
"relativePosition": relative_position,
"summary": if relative_position == "forward" {
"沿主路继续深入前方区域"
} else {
"这里分出一条支路"
}
})
})
.collect()
}
fn build_builtin_scene_npc(
id: &str,
name: &str,
role: &str,
avatar: &str,
description: &str,
) -> Value {
json!({
"id": id,
"name": name,
"description": description,
"avatar": avatar,
"role": role,
"gender": "unknown",
"initialAffinity": 18,
"hostile": false,
"functions": ["trade", "fight", "spar", "help", "chat", "recruit", "gift"]
})
}
struct BuiltinSceneDefinition {
id: &'static str,
name: &'static str,
description: &'static str,
connected_scene_ids: Vec<&'static str>,
forward_scene_id: &'static str,
treasure_hints: Vec<&'static str>,
npc_id: &'static str,
npc_name: &'static str,
npc_role: &'static str,
npc_avatar: &'static str,
npc_description: &'static str,
}
fn builtin_scene_definition(world_type: &str, scene_id: &str) -> Option<BuiltinSceneDefinition> {
match (world_type, scene_id) {
(_, "wuxia-bamboo-road") => Some(BuiltinSceneDefinition {
id: "wuxia-bamboo-road",
name: "竹林古道",
description: "风过竹叶如刀鸣,窄道蜿蜒向深处,最适合藏伏毒物和游侠。",
connected_scene_ids: vec![
"wuxia-mountain-gate",
"wuxia-mist-woods",
"wuxia-ferry-bridge",
],
forward_scene_id: "wuxia-mountain-gate",
treasure_hints: vec!["竹根旁半埋的刀鞘", "倒竹间的旧药囊"],
npc_id: "wuxia-npc-bamboo-woodcutter",
npc_name: "樵夫老周",
npc_role: "樵夫",
npc_avatar: "",
npc_description: "常在竹海边缘砍柴,对附近路数和兽踪了如指掌。",
}),
(_, "wuxia-mountain-gate") => Some(BuiltinSceneDefinition {
id: "wuxia-mountain-gate",
name: "山门石阶",
description: "青石阶层层向上,旧山门半开半掩,守山人与伏兽都能藏得很稳。",
connected_scene_ids: vec![
"wuxia-temple-forecourt",
"wuxia-border-camp",
"wuxia-bamboo-road",
],
forward_scene_id: "wuxia-temple-forecourt",
treasure_hints: vec!["裂缝里的铜钥", "石狮座下遗落的令牌"],
npc_id: "wuxia-npc-gate-disciple",
npc_name: "守山弟子",
npc_role: "门派弟子",
npc_avatar: "",
npc_description: "一直盯着石阶尽头的动静,像在等某位重要来客。",
}),
(_, "wuxia-rain-street") => Some(BuiltinSceneDefinition {
id: "wuxia-rain-street",
name: "雨夜长街",
description: "长街积水映灯,屋檐下尽是藏身空隙,最易碰见追踪者与夜行客。",
connected_scene_ids: vec![
"wuxia-ferry-bridge",
"wuxia-palace-court",
"wuxia-ruined-village",
],
forward_scene_id: "wuxia-ferry-bridge",
treasure_hints: vec!["灯檐下浸湿的布包", "排水沟边翻起的账册残页"],
npc_id: "wuxia-npc-night-vendor",
npc_name: "夜灯摊主",
npc_role: "摊主",
npc_avatar: "",
npc_description: "深夜仍在街口守着灯摊,见过太多不该见的人。",
}),
(_, "wuxia-border-camp") => Some(BuiltinSceneDefinition {
id: "wuxia-border-camp",
name: "边关营地",
description: "营火与旌旗都带着风沙味,士卒、斥候和异兽都可能在这里短暂停留。",
connected_scene_ids: vec![
"wuxia-ferry-bridge",
"wuxia-mountain-gate",
"wuxia-ruined-village",
],
forward_scene_id: "wuxia-rain-street",
treasure_hints: vec!["废营帐里的箭囊", "火盆旁埋着的军需匣"],
npc_id: "wuxia-npc-quartermaster",
npc_name: "军需官",
npc_role: "营地官",
npc_avatar: "",
npc_description: "管着兵器和粮草,对各路来客始终保持戒心。",
}),
(_, "wuxia-forge-works") => Some(BuiltinSceneDefinition {
id: "wuxia-forge-works",
name: "铸坊工场",
description: "火星、铁水与重锤声混在一起,热浪里最容易引来重甲怪物与寻刀之人。",
connected_scene_ids: vec![
"wuxia-mine-depths",
"wuxia-palace-court",
"wuxia-border-camp",
],
forward_scene_id: "wuxia-palace-court",
treasure_hints: vec!["淬火池旁的铁匣", "风箱后压着的旧兵谱"],
npc_id: "wuxia-npc-blacksmith",
npc_name: "老铸匠",
npc_role: "铸匠",
npc_avatar: "",
npc_description: "看一眼兵器缺口就知道你刚从什么地方杀出来。",
}),
(_, "wuxia-palace-court") => Some(BuiltinSceneDefinition {
id: "wuxia-palace-court",
name: "宫苑内庭",
description: "回廊深处静得过分,花木修得齐整,却处处像埋着王庭旧案。",
connected_scene_ids: vec![
"wuxia-forge-works",
"wuxia-rain-street",
"wuxia-crypt-passage",
],
forward_scene_id: "wuxia-rain-street",
treasure_hints: vec!["回廊暗格里的香囊", "花圃石座下的旧金牌"],
npc_id: "wuxia-npc-maid",
npc_name: "旧宫侍女",
npc_role: "宫人",
npc_avatar: "",
npc_description: "嘴上说得少,却总知道哪条回廊最近不该过去。",
}),
("XIANXIA", "xianxia-cloud-gate") => Some(BuiltinSceneDefinition {
id: "xianxia-cloud-gate",
name: "云海仙门",
description: "云阶在脚下翻涌,门阙后方灵光不断,来客与守门异物都极显眼。",
connected_scene_ids: vec![
"xianxia-floating-isle",
"xianxia-celestial-corridor",
"xianxia-star-vessel",
],
forward_scene_id: "xianxia-celestial-corridor",
treasure_hints: vec!["云阶尽头的灵符匣", "门阙阴影里的玉牌"],
npc_id: "xianxia-npc-gate-attendant",
npc_name: "守门灵官",
npc_role: "门官",
npc_avatar: "",
npc_description: "站在门阙侧旁观来者,像在等一份迟迟未到的回报。",
}),
("XIANXIA", "xianxia-celestial-corridor") => Some(BuiltinSceneDefinition {
id: "xianxia-celestial-corridor",
name: "天宫长廊",
description: "廊柱之间回响着空灵风声,禁制和书妖都喜欢寄在这类高处回廊里。",
connected_scene_ids: vec![
"xianxia-cloud-gate",
"xianxia-thunder-altar",
"xianxia-ancient-ruins",
],
forward_scene_id: "xianxia-thunder-altar",
treasure_hints: vec!["廊柱暗槽里的玉简", "风铃后藏着的封签"],
npc_id: "xianxia-npc-palace-page",
npc_name: "抄经侍者",
npc_role: "侍者",
npc_avatar: "",
npc_description: "抱着卷册在廊下快步穿行,像是在躲某种会翻页的东西。",
}),
("XIANXIA", "xianxia-star-vessel") => Some(BuiltinSceneDefinition {
id: "xianxia-star-vessel",
name: "星舟甲板",
description: "甲板横在高天之上,风压和星光都很强,飞行异物最爱在这里盘旋。",
connected_scene_ids: vec![
"xianxia-thunder-altar",
"xianxia-cloud-gate",
"xianxia-floating-isle",
],
forward_scene_id: "xianxia-floating-isle",
treasure_hints: vec!["舵台后的星图匣", "甲板缝里卡着的灵罗盘"],
npc_id: "xianxia-npc-helmsman",
npc_name: "星舟舵手",
npc_role: "舵手",
npc_avatar: "",
npc_description: "守着老旧星舟的航线图,对高空中的异动异常敏感。",
}),
("XIANXIA", "xianxia-waterfall-cliff") => Some(BuiltinSceneDefinition {
id: "xianxia-waterfall-cliff",
name: "飞瀑仙崖",
description: "瀑声压住一切杂音,崖边潮气浓重,飞蝠、水灵与章影都很容易现身。",
connected_scene_ids: vec![
"xianxia-sacred-tree",
"xianxia-molten-realm",
"xianxia-floating-isle",
],
forward_scene_id: "xianxia-cloud-gate",
treasure_hints: vec!["瀑幕后闪着光的石匣", "崖边藤上挂着的护身铃"],
npc_id: "xianxia-npc-cliff-scout",
npc_name: "崖巡女修",
npc_role: "巡修",
npc_avatar: "",
npc_description: "长期在飞瀑边巡看,脚步轻得像从不曾碰到过石面。",
}),
("XIANXIA", "xianxia-molten-realm") => Some(BuiltinSceneDefinition {
id: "xianxia-molten-realm",
name: "熔岩秘境",
description: "热浪裹着赤光翻涌,附近的异章与泥灵都容易被灼气激得发狂。",
connected_scene_ids: vec![
"xianxia-thunder-altar",
"xianxia-waterfall-cliff",
"xianxia-jade-cavern",
],
forward_scene_id: "xianxia-waterfall-cliff",
treasure_hints: vec!["熔岩边冷却的矿匣", "焦岩后藏着的火纹石"],
npc_id: "xianxia-npc-fire-forger",
npc_name: "熔炉匠修",
npc_role: "炼匠",
npc_avatar: "",
npc_description: "在热浪里锻器不歇,见惯灵火失控的后果。",
}),
("XIANXIA", "xianxia-thunder-altar") => Some(BuiltinSceneDefinition {
id: "xianxia-thunder-altar",
name: "雷殿祭坛",
description: "祭坛上方雷纹未散,灵书、飞蛾与雷意余波总会把来者围在中心。",
connected_scene_ids: vec![
"xianxia-celestial-corridor",
"xianxia-molten-realm",
"xianxia-star-vessel",
],
forward_scene_id: "xianxia-star-vessel",
treasure_hints: vec!["祭坛角落的雷纹匣", "断碑背面的青铜铃"],
npc_id: "xianxia-npc-thunder-keeper",
npc_name: "祭雷守使",
npc_role: "守使",
npc_avatar: "",
npc_description: "总站在祭坛边缘看天,像在确认下一道雷会落到哪里。",
}),
_ => None,
}
}
fn find_custom_world_role_id_by_reference(profile: &Value, reference: &str) -> Option<String> {
let normalized_reference = normalize_custom_role_reference(reference);
if normalized_reference.is_empty() {
return None;
}
read_array_field(profile, "storyNpcs")
.into_iter()
.chain(read_array_field(profile, "playableNpcs"))
.find(|role| custom_role_aliases(role).contains(&normalized_reference))
.and_then(|role| read_optional_string_field(role, "id"))
}
fn custom_role_references_equal(profile: &Value, left: &str, right: &str) -> bool {
let left = find_custom_world_role_id_by_reference(profile, left)
.unwrap_or_else(|| left.trim().to_string());
let right = find_custom_world_role_id_by_reference(profile, right)
.unwrap_or_else(|| right.trim().to_string());
!left.trim().is_empty() && left == right
}
fn custom_role_aliases(role: &Value) -> Vec<String> {
[
read_optional_string_field(role, "id"),
read_optional_string_field(role, "name"),
read_optional_string_field(role, "title"),
]
.into_iter()
.flatten()
.map(|value| normalize_custom_role_reference(value.as_str()))
.filter(|value| !value.is_empty())
.collect()
}
fn normalize_custom_role_reference(value: &str) -> String {
value
.trim()
.to_lowercase()
.chars()
.filter(|ch| ch.is_alphanumeric())
.collect()
}
fn resolve_next_scene_preset(game_state: &Value) -> Option<Value> { fn resolve_next_scene_preset(game_state: &Value) -> Option<Value> {
let current_scene = read_object_field(game_state, "currentScenePreset")?; let current_scene = read_object_field(game_state, "currentScenePreset")?;
let current_scene_id = read_optional_string_field(current_scene, "id"); let current_scene_id = read_optional_string_field(current_scene, "id");

View File

@@ -2141,6 +2141,176 @@ async fn runtime_story_route_boundary_projects_story_engine_state() {
); );
} }
#[tokio::test]
async fn runtime_story_route_boundary_camp_travel_home_scene_is_server_owned() {
let state = seed_authenticated_state().await;
let token = issue_access_token(&state);
let mut game_state = build_runtime_story_boundary_game_state_fixture();
let root = ensure_json_object(&mut game_state);
root.insert("worldType".to_string(), json!("WUXIA"));
root.insert(
"playerCharacter".to_string(),
json!({
"id": "sword-princess",
"name": "青璃",
"title": "试剑客",
"description": "准备离营的角色。",
"personality": "谨慎",
"attributes": {
"strength": 8,
"spirit": 6
},
"skills": []
}),
);
root.insert(
"currentScenePreset".to_string(),
json!({
"id": "wuxia-border-camp",
"name": "边关营地",
"description": "营火未熄。",
"imageSrc": "",
"connectedSceneIds": ["wuxia-palace-court"],
"connections": [{
"sceneId": "wuxia-palace-court",
"relativePosition": "forward",
"summary": "沿旧宫线索离营"
}],
"forwardSceneId": "wuxia-palace-court",
"treasureHints": [],
"npcs": []
}),
);
root.insert(
"currentEncounter".to_string(),
json!({
"kind": "npc",
"id": "npc-camp-companion",
"npcName": "营地同伴",
"npcDescription": "准备一起出发的同伴",
"npcAvatar": "",
"context": "营地",
"hostile": false
}),
);
root.insert(
"runtimeStats".to_string(),
json!({
"playTimeMs": 0,
"lastPlayTickAt": null,
"hostileNpcsDefeated": 0,
"questsAccepted": 0,
"itemsUsed": 0,
"scenesTraveled": 2
}),
);
seed_runtime_story_snapshot(
&state,
game_state,
Some(json!({
"text": "营地对话已经结束。",
"options": []
})),
)
.await;
let app = build_router(state);
let action_response = app
.clone()
.oneshot(
Request::builder()
.method("POST")
.uri("/api/runtime/story/actions/resolve")
.header("authorization", format!("Bearer {token}"))
.header("content-type", "application/json")
.header("x-genarrative-response-envelope", "v1")
.body(Body::from(
json!({
"sessionId": "runtime-main",
"clientVersion": 0,
"action": {
"type": "story_choice",
"functionId": "camp_travel_home_scene",
"payload": {
"optionText": "前往宫苑内庭"
}
}
})
.to_string(),
))
.expect("request should build"),
)
.await
.expect("request should succeed");
assert_eq!(action_response.status(), StatusCode::OK);
let action_payload: Value = serde_json::from_slice(
&action_response
.into_body()
.collect()
.await
.expect("body should collect")
.to_bytes(),
)
.expect("response should be json");
let action_state = &action_payload["data"]["snapshot"]["gameState"];
assert_eq!(
action_state["currentScenePreset"]["id"],
json!("wuxia-palace-court")
);
assert_eq!(action_state["runtimeStats"]["scenesTraveled"], json!(3));
assert_eq!(action_state["inBattle"], json!(false));
assert_eq!(action_state["npcInteractionActive"], json!(false));
assert_eq!(action_state["sceneHostileNpcs"], json!([]));
assert_eq!(
action_state["currentEncounter"]["id"],
json!("wuxia-npc-maid")
);
assert_eq!(
action_state["storyHistory"]
.as_array()
.expect("story history should be array")
.len(),
2
);
assert!(
action_payload["data"]["presentation"]["resultText"]
.as_str()
.is_some_and(|text| text.contains("宫苑内庭"))
);
let state_response = app
.oneshot(
Request::builder()
.method("GET")
.uri("/api/runtime/story/state/runtime-main")
.header("authorization", format!("Bearer {token}"))
.header("x-genarrative-response-envelope", "v1")
.body(Body::empty())
.expect("request should build"),
)
.await
.expect("request should succeed");
assert_eq!(state_response.status(), StatusCode::OK);
let state_payload: Value = serde_json::from_slice(
&state_response
.into_body()
.collect()
.await
.expect("body should collect")
.to_bytes(),
)
.expect("response should be json");
assert_eq!(
state_payload["data"]["snapshot"]["gameState"]["currentScenePreset"]["id"],
json!("wuxia-palace-court")
);
assert_eq!(
state_payload["data"]["snapshot"]["gameState"]["currentEncounter"]["id"],
json!("wuxia-npc-maid")
);
}
#[test] #[test]
fn runtime_story_npc_help_is_one_shot_and_restores_resources() { fn runtime_story_npc_help_is_one_shot_and_restores_resources() {
let request = RuntimeStoryActionRequest { let request = RuntimeStoryActionRequest {

View File

@@ -1383,9 +1383,6 @@ export function AdventureEntityModal({
)} )}
</span> </span>
</div> </div>
<div className="mt-2 text-[11px] leading-5 text-zinc-500">
{attribute.definition}
</div>
</div> </div>
))} ))}
</div> </div>

View File

@@ -331,7 +331,7 @@ export function CharacterAttributeGrid({
boostedCombatStats, boostedCombatStats,
resourceLabels, resourceLabels,
) )
: slot.combatUseText, : '',
}; };
}); });
@@ -364,9 +364,11 @@ export function CharacterAttributeGrid({
</div> </div>
</div> </div>
</div> </div>
<div className="mt-2 text-[10px] leading-relaxed text-sky-200/85"> {effectText ? (
{effectText} <div className="mt-2 text-[10px] leading-relaxed text-sky-200/85">
</div> {effectText}
</div>
) : null}
</div> </div>
), ),
)} )}

View File

@@ -561,9 +561,6 @@ export function CharacterPanel({
)} )}
</span> </span>
</div> </div>
<div className="mt-2 text-[11px] leading-5 text-zinc-500">
{attribute.definition}
</div>
</div> </div>
))} ))}
</div> </div>

View File

@@ -545,16 +545,6 @@ function buildLandmarkSearchText(
].join(' '); ].join(' ');
} }
function buildAttributeSlotSummary(
slot: CustomWorldProfile['attributeSchema']['slots'][number],
) {
return compactTextList([
slot.combatUseText,
slot.socialUseText,
slot.explorationUseText,
]).join(' / ');
}
export function CustomWorldEntityCatalog({ export function CustomWorldEntityCatalog({
profile, profile,
previewCharacters, previewCharacters,
@@ -984,11 +974,6 @@ export function CustomWorldEntityCatalog({
<div className="text-[11px] font-bold tracking-[0.18em] text-zinc-500"> <div className="text-[11px] font-bold tracking-[0.18em] text-zinc-500">
</div> </div>
{profile.attributeSchema?.schemaName ? (
<div className="text-xs leading-5 text-zinc-500">
{profile.attributeSchema.schemaName}
</div>
) : null}
</div> </div>
<div className="mt-3 grid grid-cols-2 gap-2 sm:grid-cols-3 xl:grid-cols-6"> <div className="mt-3 grid grid-cols-2 gap-2 sm:grid-cols-3 xl:grid-cols-6">
{attributeSlots.map((slot) => ( {attributeSlots.map((slot) => (
@@ -999,9 +984,6 @@ export function CustomWorldEntityCatalog({
<div className="text-sm font-semibold text-white"> <div className="text-sm font-semibold text-white">
{slot.name} {slot.name}
</div> </div>
<div className="mt-1 line-clamp-2 text-[11px] leading-5 text-zinc-400">
{buildAttributeSlotSummary(slot) || slot.definition}
</div>
</div> </div>
))} ))}
</div> </div>

View File

@@ -2,7 +2,6 @@
import { import {
cleanup, cleanup,
fireEvent,
render, render,
screen, screen,
waitFor, waitFor,
@@ -198,7 +197,6 @@ function createProfile(): CustomWorldProfile {
attributeSchema: { attributeSchema: {
id: 'schema-1', id: 'schema-1',
worldId: 'world-1', worldId: 'world-1',
schemaName: '潮雾六维',
schemaVersion: 1, schemaVersion: 1,
generatedFrom: { generatedFrom: {
worldType: 'WUXIA', worldType: 'WUXIA',
@@ -211,62 +209,26 @@ function createProfile(): CustomWorldProfile {
{ {
slotId: 'axis_a', slotId: 'axis_a',
name: '骨势', name: '骨势',
definition: '扛住压力并正面推进的底子。',
positiveSignals: ['硬顶'],
negativeSignals: ['畏缩'],
combatUseText: '正面承压与破阵。',
socialUseText: '在谈判里稳住立场。',
explorationUseText: '穿过危险地形。',
}, },
{ {
slotId: 'axis_b', slotId: 'axis_b',
name: '身法', name: '身法',
definition: '抢位、转场与把握节奏的能力。',
positiveSignals: ['灵动'],
negativeSignals: ['迟滞'],
combatUseText: '移动换位。',
socialUseText: '捕捉话锋。',
explorationUseText: '快速穿行。',
}, },
{ {
slotId: 'axis_c', slotId: 'axis_c',
name: '眼脉', name: '眼脉',
definition: '看破破绽、拆解局势的能力。',
positiveSignals: ['洞察'],
negativeSignals: ['误判'],
combatUseText: '识破招式。',
socialUseText: '辨别谎言。',
explorationUseText: '发现线索。',
}, },
{ {
slotId: 'axis_d', slotId: 'axis_d',
name: '心焰', name: '心焰',
definition: '决断、压迫与坚持意志的能力。',
positiveSignals: ['果断'],
negativeSignals: ['犹疑'],
combatUseText: '强行压制。',
socialUseText: '立威推进。',
explorationUseText: '面对险境不退。',
}, },
{ {
slotId: 'axis_e', slotId: 'axis_e',
name: '尘缘', name: '尘缘',
definition: '处理人情、承诺和关系牵引的能力。',
positiveSignals: ['守信'],
negativeSignals: ['冷漠'],
combatUseText: '协作配合。',
socialUseText: '建立信任。',
explorationUseText: '借助人脉。',
}, },
{ {
slotId: 'axis_f', slotId: 'axis_f',
name: '玄息', name: '玄息',
definition: '调息、稳态和久战的能力。',
positiveSignals: ['沉稳'],
negativeSignals: ['浮躁'],
combatUseText: '续战恢复。',
socialUseText: '保持耐心。',
explorationUseText: '长线跋涉。',
}, },
], ],
}, },
@@ -753,7 +715,7 @@ test('基本设定目标打开独立编辑面板', () => {
expect(screen.queryByText('编辑世界信息')).toBeNull(); expect(screen.queryByText('编辑世界信息')).toBeNull();
}); });
test('世界信息面板可以编辑六个角色维度信息', async () => { test('基本设定面板只编辑六个角色维度名称', async () => {
const user = userEvent.setup(); const user = userEvent.setup();
const savedProfileRef: { current: CustomWorldProfile | null } = { const savedProfileRef: { current: CustomWorldProfile | null } = {
current: null, current: null,
@@ -762,7 +724,7 @@ test('世界信息面板可以编辑六个角色维度信息', async () => {
render( render(
<RpgCreationEntityEditorModal <RpgCreationEntityEditorModal
profile={createProfile()} profile={createProfile()}
target={{ kind: 'world' }} target={{ kind: 'foundation' }}
onClose={() => {}} onClose={() => {}}
onProfileChange={(profile) => { onProfileChange={(profile) => {
savedProfileRef.current = profile; savedProfileRef.current = profile;
@@ -775,33 +737,15 @@ test('世界信息面板可以编辑六个角色维度信息', async () => {
await user.clear(nameInputs[0]!); await user.clear(nameInputs[0]!);
await user.type(nameInputs[0]!, '潮骨'); await user.type(nameInputs[0]!, '潮骨');
const definitionFields = screen.getAllByLabelText('定义'); expect(screen.queryByLabelText('定义')).toBeNull();
await user.clear(definitionFields[0]!); expect(screen.queryByLabelText('正向信号')).toBeNull();
await user.type(definitionFields[0]!, '顶住潮压并正面推进的角色底色。'); expect(screen.queryByLabelText('战斗体现')).toBeNull();
const positiveSignalFields = screen.getAllByLabelText('正向信号');
fireEvent.change(positiveSignalFields[0]!, {
target: { value: '硬顶, 护阵' },
});
const combatFields = screen.getAllByLabelText('战斗体现');
await user.clear(combatFields[0]!);
await user.type(combatFields[0]!, '正面压线与护住阵脚。');
await user.click(screen.getByRole('button', { name: //u })); await user.click(screen.getByRole('button', { name: //u }));
expect(savedProfileRef.current?.attributeSchema.slots[0]?.name).toBe( expect(savedProfileRef.current?.attributeSchema.slots[0]?.name).toBe(
'潮骨', '潮骨',
); );
expect(savedProfileRef.current?.attributeSchema.slots[0]?.definition).toBe(
'顶住潮压并正面推进的角色底色。',
);
expect(
savedProfileRef.current?.attributeSchema.slots[0]?.positiveSignals,
).toEqual(['硬顶', '护阵']);
expect(savedProfileRef.current?.attributeSchema.slots[0]?.combatUseText).toBe(
'正面压线与护住阵脚。',
);
}); });
test('可扮演角色列表使用缩略卡片并点击进入编辑', async () => { test('可扮演角色列表使用缩略卡片并点击进入编辑', async () => {

View File

@@ -55,8 +55,8 @@ test('creation hub reflects updated draft title summary and counts after rerende
expect(screen.getByText('角色 3')).toBeTruthy(); expect(screen.getByText('角色 3')).toBeTruthy();
expect(screen.getByText('地点 4')).toBeTruthy(); expect(screen.getByText('地点 4')).toBeTruthy();
expect(screen.getByRole('button', { name: / RPG/u })).toBeTruthy(); expect(screen.getByRole('button', { name: / RPG/u })).toBeTruthy();
expect(screen.getByRole('button', { name: //u })).toBeTruthy();
expect(screen.getByRole('button', { name: //u })).toBeTruthy(); expect(screen.getByRole('button', { name: //u })).toBeTruthy();
expect(screen.queryByRole('button', { name: //u })).toBeNull();
rerender( rerender(
<CustomWorldCreationHub <CustomWorldCreationHub

View File

@@ -43,8 +43,8 @@ test('creation hub draft card renders compiled work summary fields', () => {
expect(html).toContain('玩家是失职返乡的守灯人'); expect(html).toContain('玩家是失职返乡的守灯人');
expect(html).toContain('守灯会与沉船商盟争夺航道解释权'); expect(html).toContain('守灯会与沉船商盟争夺航道解释权');
expect(html).toContain('角色扮演 RPG'); expect(html).toContain('角色扮演 RPG');
expect(html).toContain('大鱼吃小鱼');
expect(html).toContain('拼图玩法'); expect(html).toContain('拼图玩法');
expect(html).not.toContain('大鱼吃小鱼');
}); });
test('creation hub renders puzzle works in the same unified list with puzzle tag', () => { test('creation hub renders puzzle works in the same unified list with puzzle tag', () => {

View File

@@ -1,7 +1,7 @@
import { ArrowRight } from 'lucide-react'; import { ArrowRight } from 'lucide-react';
import { import {
PLATFORM_CREATION_TYPES, getVisiblePlatformCreationTypes,
type PlatformCreationTypeId, type PlatformCreationTypeId,
} from '../platform-entry/platformEntryCreationTypes'; } from '../platform-entry/platformEntryCreationTypes';
@@ -16,6 +16,10 @@ export function CustomWorldCreationStartCard({
error = null, error = null,
onCreateType, onCreateType,
}: CustomWorldCreationStartCardProps) { }: CustomWorldCreationStartCardProps) {
// 创作首页首屏卡带与创作类型弹层保持同一份展示口径,
// 避免某个玩法只在其中一个入口被隐藏而出现状态漂移。
const visibleCreationTypes = getVisiblePlatformCreationTypes();
return ( return (
// 移动端限制模块高度,模板入口改为横向滚动,避免挤占作品列表首屏空间。 // 移动端限制模块高度,模板入口改为横向滚动,避免挤占作品列表首屏空间。
<div className="platform-surface platform-surface--hero relative max-h-[33svh] overflow-hidden px-3 py-3 sm:max-h-none sm:px-5 sm:py-5 xl:px-5 xl:py-4"> <div className="platform-surface platform-surface--hero relative max-h-[33svh] overflow-hidden px-3 py-3 sm:max-h-none sm:px-5 sm:py-5 xl:px-5 xl:py-4">
@@ -34,7 +38,7 @@ export function CustomWorldCreationStartCard({
</div> </div>
<div className="-mx-1 flex snap-x gap-2 overflow-x-auto px-1 pb-1 sm:mx-0 sm:grid sm:gap-3 sm:overflow-visible sm:px-0 sm:pb-0 sm:grid-cols-2 xl:grid-cols-5 xl:gap-2.5"> <div className="-mx-1 flex snap-x gap-2 overflow-x-auto px-1 pb-1 sm:mx-0 sm:grid sm:gap-3 sm:overflow-visible sm:px-0 sm:pb-0 sm:grid-cols-2 xl:grid-cols-5 xl:gap-2.5">
{PLATFORM_CREATION_TYPES.map((item) => { {visibleCreationTypes.map((item) => {
const disabled = item.locked || busy; const disabled = item.locked || busy;
return ( return (

View File

@@ -1,7 +1,7 @@
import { ArrowRight } from 'lucide-react'; import { ArrowRight } from 'lucide-react';
import { UnifiedModal } from '../common/UnifiedModal'; import { UnifiedModal } from '../common/UnifiedModal';
import { PLATFORM_CREATION_TYPES } from './platformEntryCreationTypes'; import { getVisiblePlatformCreationTypes } from './platformEntryCreationTypes';
export interface PlatformEntryCreationTypeModalProps { export interface PlatformEntryCreationTypeModalProps {
isOpen: boolean; isOpen: boolean;
@@ -14,7 +14,7 @@ export interface PlatformEntryCreationTypeModalProps {
} }
function CreationTypeCard(props: { function CreationTypeCard(props: {
item: (typeof PLATFORM_CREATION_TYPES)[number]; item: ReturnType<typeof getVisiblePlatformCreationTypes>[number];
busy: boolean; busy: boolean;
onSelect: () => void; onSelect: () => void;
}) { }) {
@@ -81,9 +81,7 @@ export function PlatformEntryCreationTypeModal({
// 平台入口只渲染当前允许展示的创作类型; // 平台入口只渲染当前允许展示的创作类型;
// 被隐藏的玩法仍保留既有实现与路由,不在这里删除能力本体。 // 被隐藏的玩法仍保留既有实现与路由,不在这里删除能力本体。
const visibleCreationTypes = PLATFORM_CREATION_TYPES.filter( const visibleCreationTypes = getVisiblePlatformCreationTypes();
(item) => !item.hidden,
);
return ( return (
<UnifiedModal <UnifiedModal

View File

@@ -122,6 +122,7 @@ import { useRpgCreationResultAutosave } from '../rpg-entry/useRpgCreationResultA
import { useRpgCreationSessionController } from '../rpg-entry/useRpgCreationSessionController'; import { useRpgCreationSessionController } from '../rpg-entry/useRpgCreationSessionController';
import { PlatformEntryCreationTypeModal } from './PlatformEntryCreationTypeModal'; import { PlatformEntryCreationTypeModal } from './PlatformEntryCreationTypeModal';
import type { PlatformCreationTypeId } from './platformEntryCreationTypes'; import type { PlatformCreationTypeId } from './platformEntryCreationTypes';
import { isPlatformCreationTypeVisible } from './platformEntryCreationTypes';
import { import {
PlatformEntryHomeView, PlatformEntryHomeView,
type PlatformHomeTab, type PlatformHomeTab,
@@ -473,6 +474,7 @@ export function PlatformEntryFlowShellImpl({
const [deletingCreationWorkId, setDeletingCreationWorkId] = useState< const [deletingCreationWorkId, setDeletingCreationWorkId] = useState<
string | null string | null
>(null); >(null);
const isBigFishCreationVisible = isPlatformCreationTypeVisible('big-fish');
const hadReadableProtectedDataRef = useRef(false); const hadReadableProtectedDataRef = useRef(false);
const hasInitialAgentSession = Boolean( const hasInitialAgentSession = Boolean(
readCustomWorldAgentUiState().activeSessionId && readCustomWorldAgentUiState().activeSessionId &&
@@ -660,7 +662,9 @@ export function PlatformEntryFlowShellImpl({
await Promise.allSettled([ await Promise.allSettled([
platformBootstrap.refreshPublishedGallery(), platformBootstrap.refreshPublishedGallery(),
platformBootstrap.refreshCustomWorldWorks(), platformBootstrap.refreshCustomWorldWorks(),
refreshBigFishGallery(), isBigFishCreationVisible
? refreshBigFishGallery()
: Promise.resolve([] as BigFishWorkSummary[]),
refreshPuzzleGallery(), refreshPuzzleGallery(),
]); ]);
return latestSession; return latestSession;
@@ -716,9 +720,9 @@ export function PlatformEntryFlowShellImpl({
}, [agentResultPreview]); }, [agentResultPreview]);
const featuredGalleryEntries = useMemo(() => { const featuredGalleryEntries = useMemo(() => {
const bigFishPublicEntries = bigFishGalleryEntries.map( const bigFishPublicEntries = isBigFishCreationVisible
mapBigFishWorkToPlatformGalleryCard, ? bigFishGalleryEntries.map(mapBigFishWorkToPlatformGalleryCard)
); : [];
const puzzlePublicEntries = puzzleGalleryEntries.map( const puzzlePublicEntries = puzzleGalleryEntries.map(
mapPuzzleWorkToPlatformGalleryCard, mapPuzzleWorkToPlatformGalleryCard,
); );
@@ -727,6 +731,7 @@ export function PlatformEntryFlowShellImpl({
[...bigFishPublicEntries, ...puzzlePublicEntries], [...bigFishPublicEntries, ...puzzlePublicEntries],
).slice(0, 6); ).slice(0, 6);
}, [ }, [
isBigFishCreationVisible,
bigFishGalleryEntries, bigFishGalleryEntries,
platformBootstrap.publishedGalleryEntries, platformBootstrap.publishedGalleryEntries,
puzzleGalleryEntries, puzzleGalleryEntries,
@@ -736,11 +741,14 @@ export function PlatformEntryFlowShellImpl({
mergePlatformPublicGalleryEntries( mergePlatformPublicGalleryEntries(
platformBootstrap.publishedGalleryEntries, platformBootstrap.publishedGalleryEntries,
[ [
...bigFishGalleryEntries.map(mapBigFishWorkToPlatformGalleryCard), ...(isBigFishCreationVisible
? bigFishGalleryEntries.map(mapBigFishWorkToPlatformGalleryCard)
: []),
...puzzleGalleryEntries.map(mapPuzzleWorkToPlatformGalleryCard), ...puzzleGalleryEntries.map(mapPuzzleWorkToPlatformGalleryCard),
], ],
), ),
[ [
isBigFishCreationVisible,
bigFishGalleryEntries, bigFishGalleryEntries,
platformBootstrap.publishedGalleryEntries, platformBootstrap.publishedGalleryEntries,
puzzleGalleryEntries, puzzleGalleryEntries,
@@ -986,7 +994,6 @@ export function PlatformEntryFlowShellImpl({
const bigFishError = bigFishFlow.error; const bigFishError = bigFishFlow.error;
const setBigFishError = bigFishFlow.setError; const setBigFishError = bigFishFlow.setError;
const isBigFishBusy = bigFishFlow.isBusy; const isBigFishBusy = bigFishFlow.isBusy;
const setIsBigFishBusy = bigFishFlow.setIsBusy;
const streamingBigFishReplyText = bigFishFlow.streamingReplyText; const streamingBigFishReplyText = bigFishFlow.streamingReplyText;
const isStreamingBigFishReply = bigFishFlow.isStreamingReply; const isStreamingBigFishReply = bigFishFlow.isStreamingReply;
@@ -1892,10 +1899,17 @@ export function PlatformEntryFlowShellImpl({
useEffect(() => { useEffect(() => {
if (selectionStage === 'platform') { if (selectionStage === 'platform') {
void refreshBigFishGallery(); if (isBigFishCreationVisible) {
void refreshBigFishGallery();
}
void refreshPuzzleGallery(); void refreshPuzzleGallery();
} }
}, [refreshBigFishGallery, refreshPuzzleGallery, selectionStage]); }, [
isBigFishCreationVisible,
refreshBigFishGallery,
refreshPuzzleGallery,
selectionStage,
]);
useEffect(() => { useEffect(() => {
if ( if (
@@ -1914,6 +1928,7 @@ export function PlatformEntryFlowShellImpl({
useEffect(() => { useEffect(() => {
if ( if (
isBigFishCreationVisible &&
(platformBootstrap.platformTab === 'create' || (platformBootstrap.platformTab === 'create' ||
selectionStage === 'platform') && selectionStage === 'platform') &&
platformBootstrap.canReadProtectedData platformBootstrap.canReadProtectedData
@@ -1921,6 +1936,7 @@ export function PlatformEntryFlowShellImpl({
void refreshBigFishShelf(); void refreshBigFishShelf();
} }
}, [ }, [
isBigFishCreationVisible,
platformBootstrap.canReadProtectedData, platformBootstrap.canReadProtectedData,
platformBootstrap.platformTab, platformBootstrap.platformTab,
refreshBigFishShelf, refreshBigFishShelf,
@@ -1955,7 +1971,9 @@ export function PlatformEntryFlowShellImpl({
resolveRpgCreationErrorMessage(error, '读取创作作品列表失败。'), resolveRpgCreationErrorMessage(error, '读取创作作品列表失败。'),
); );
}); });
void refreshBigFishShelf(); if (isBigFishCreationVisible) {
void refreshBigFishShelf();
}
void refreshPuzzleShelf(); void refreshPuzzleShelf();
}} }}
createError={ createError={
@@ -1991,20 +2009,32 @@ export function PlatformEntryFlowShellImpl({
handleExperienceRpgWork(item); handleExperienceRpgWork(item);
}} }}
rpgLibraryEntries={platformBootstrap.savedCustomWorldEntries} rpgLibraryEntries={platformBootstrap.savedCustomWorldEntries}
bigFishItems={bigFishWorks} bigFishItems={isBigFishCreationVisible ? bigFishWorks : []}
onOpenBigFishDetail={(item) => { onOpenBigFishDetail={
runProtectedAction(() => { isBigFishCreationVisible
void openBigFishDraft(item); ? (item) => {
}); runProtectedAction(() => {
}} void openBigFishDraft(item);
onExperienceBigFish={(item) => { });
runProtectedAction(() => { }
void startBigFishRunFromWork(item); : undefined
}); }
}} onExperienceBigFish={
onDeleteBigFish={(item) => { isBigFishCreationVisible
handleDeleteBigFishWork(item); ? (item) => {
}} runProtectedAction(() => {
void startBigFishRunFromWork(item);
});
}
: null
}
onDeleteBigFish={
isBigFishCreationVisible
? (item) => {
handleDeleteBigFishWork(item);
}
: null
}
puzzleItems={puzzleWorks} puzzleItems={puzzleWorks}
onOpenPuzzleDetail={(item) => { onOpenPuzzleDetail={(item) => {
runProtectedAction(() => { runProtectedAction(() => {

View File

@@ -14,6 +14,21 @@ export type PlatformCreationTypeCard = {
hidden?: boolean; hidden?: boolean;
}; };
/**
* 返回当前平台入口允许展示的创作类型。
* 平台层的入口、首屏卡带与初始化请求都应基于这份结果统一判断。
*/
export function getVisiblePlatformCreationTypes() {
return PLATFORM_CREATION_TYPES.filter((item) => !item.hidden);
}
/**
* 判断某个创作类型当前是否仍暴露在平台入口中。
*/
export function isPlatformCreationTypeVisible(id: PlatformCreationTypeId) {
return PLATFORM_CREATION_TYPES.some((item) => item.id === id && !item.hidden);
}
/** /**
* 创作页与类型弹层共用同一份模板元数据,避免多入口文案和可用状态漂移。 * 创作页与类型弹层共用同一份模板元数据,避免多入口文案和可用状态漂移。
* `hidden` 只控制平台入口是否展示,不影响既有玩法链路和路由能力。 * `hidden` 只控制平台入口是否展示,不影响既有玩法链路和路由能力。

View File

@@ -5007,83 +5007,19 @@ function WorldAttributeSchemaEditor({
}; };
return ( return (
<SectionPanel title="角色维度" subtitle={value.schemaName || '世界能力维度'}> <SectionPanel title="角色维度">
<div className="space-y-3"> <div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
{value.slots.map((slot) => ( {value.slots.map((slot) => (
<div <div
key={slot.slotId} key={slot.slotId}
className="rounded-2xl border border-white/8 bg-black/20 px-3 py-3" className="rounded-2xl border border-white/8 bg-black/20 px-3 py-3"
> >
<div className="grid gap-3 sm:grid-cols-[10rem_minmax(0,1fr)]"> <Field label="维度名称">
<Field label="维度名称"> <TextInput
<TextInput value={slot.name}
value={slot.name} onChange={(name) => updateSlot(slot.slotId, { name })}
onChange={(name) => updateSlot(slot.slotId, { name })} />
/> </Field>
</Field>
<Field label="定义">
<TextArea
value={slot.definition}
onChange={(definition) =>
updateSlot(slot.slotId, { definition })
}
rows={2}
/>
</Field>
</div>
<div className="mt-3 grid gap-3 sm:grid-cols-2">
<Field label="正向信号">
<TextArea
value={commaText(slot.positiveSignals)}
onChange={(text) =>
updateSlot(slot.slotId, {
positiveSignals: parseCommaText(text),
})
}
rows={2}
/>
</Field>
<Field label="负向信号">
<TextArea
value={commaText(slot.negativeSignals)}
onChange={(text) =>
updateSlot(slot.slotId, {
negativeSignals: parseCommaText(text),
})
}
rows={2}
/>
</Field>
</div>
<div className="mt-3 grid gap-3 sm:grid-cols-3">
<Field label="战斗体现">
<TextArea
value={slot.combatUseText}
onChange={(combatUseText) =>
updateSlot(slot.slotId, { combatUseText })
}
rows={2}
/>
</Field>
<Field label="社交体现">
<TextArea
value={slot.socialUseText}
onChange={(socialUseText) =>
updateSlot(slot.slotId, { socialUseText })
}
rows={2}
/>
</Field>
<Field label="探索体现">
<TextArea
value={slot.explorationUseText}
onChange={(explorationUseText) =>
updateSlot(slot.slotId, { explorationUseText })
}
rows={2}
/>
</Field>
</div>
</div> </div>
))} ))}
</div> </div>

View File

@@ -133,62 +133,26 @@ test('custom world character selection stays stable when character ids are empty
{ {
slotId: 'axis_a', slotId: 'axis_a',
name: '潮骨', name: '潮骨',
definition: '扛住潮压与正面冲击的底子。',
positiveSignals: [],
negativeSignals: [],
combatUseText: '顶住正面浪涌。',
socialUseText: '给人能扛事的可靠感。',
explorationUseText: '在风浪里稳住自己。',
}, },
{ {
slotId: 'axis_b', slotId: 'axis_b',
name: '浪步', name: '浪步',
definition: '顺潮借势、换位穿行的能力。',
positiveSignals: [],
negativeSignals: [],
combatUseText: '借势切线。',
socialUseText: '谈吐灵活。',
explorationUseText: '穿越复杂地形。',
}, },
{ {
slotId: 'axis_c', slotId: 'axis_c',
name: '舟识', name: '舟识',
definition: '辨流向、识潮眼的能力。',
positiveSignals: [],
negativeSignals: [],
combatUseText: '抓住变化时机。',
socialUseText: '看懂局势留白。',
explorationUseText: '辨认水路与遗痕。',
}, },
{ {
slotId: 'axis_d', slotId: 'axis_d',
name: '潮魄', name: '潮魄',
definition: '在剧烈变化中仍敢推进的胆气。',
positiveSignals: [],
negativeSignals: [],
combatUseText: '顶着压力推进。',
socialUseText: '在冲突里压住场子。',
explorationUseText: '面对异变继续前探。',
}, },
{ {
slotId: 'axis_e', slotId: 'axis_e',
name: '契汐', name: '契汐',
definition: '与人和约定形成牵引的能力。',
positiveSignals: [],
negativeSignals: [],
combatUseText: '借协同形成连锁。',
socialUseText: '结盟、安抚与交换。',
explorationUseText: '从旧约中打开局面。',
}, },
{ {
slotId: 'axis_f', slotId: 'axis_f',
name: '回澜', name: '回澜',
definition: '在漫长消耗中回稳状态的能力。',
positiveSignals: [],
negativeSignals: [],
combatUseText: '久战不乱。',
socialUseText: '遇事沉静。',
explorationUseText: '在恶劣天气里保有余力。',
}, },
], ],
}, },

View File

@@ -14,7 +14,6 @@ import { ApiClientError } from '../../services/apiClient';
import type { AuthUser } from '../../services/authService'; import type { AuthUser } from '../../services/authService';
import { import {
createBigFishCreationSession, createBigFishCreationSession,
executeBigFishCreationAction,
getBigFishCreationSession, getBigFishCreationSession,
} from '../../services/big-fish-creation'; } from '../../services/big-fish-creation';
import { listBigFishGallery } from '../../services/big-fish-gallery'; import { listBigFishGallery } from '../../services/big-fish-gallery';
@@ -1175,6 +1174,21 @@ test('create hub exposes direct template entry, keeps AIRP and visual novel lock
).toBeTruthy(); ).toBeTruthy();
}); });
test('platform create hub does not prefetch hidden big fish platform data', async () => {
const user = userEvent.setup();
render(<TestWrapper withAuth />);
await openCreationHub(user);
expect(
await screen.findByRole('button', { name: / RPG/u }),
).toBeTruthy();
expect(screen.queryByRole('button', { name: //u })).toBeNull();
expect(listBigFishWorks).not.toHaveBeenCalled();
expect(listBigFishGallery).not.toHaveBeenCalled();
});
test('opening RPG agent workspace does not refetch session snapshot in a render loop', async () => { test('opening RPG agent workspace does not refetch session snapshot in a render loop', async () => {
const user = userEvent.setup(); const user = userEvent.setup();
@@ -1540,14 +1554,13 @@ test('creation hub clears all private work shelves immediately after logout stat
const createPanel = getPlatformTabPanel('create'); const createPanel = getPlatformTabPanel('create');
expect(await within(createPanel).findByText('RPG 退出缓存作品')).toBeTruthy(); expect(await within(createPanel).findByText('RPG 退出缓存作品')).toBeTruthy();
expect(await within(createPanel).findByText('大鱼退出缓存作品')).toBeTruthy(); expect(within(createPanel).queryByText('大鱼退出缓存作品')).toBeNull();
expect(await within(createPanel).findByText('拼图退出缓存作品')).toBeTruthy(); expect(await within(createPanel).findByText('拼图退出缓存作品')).toBeTruthy();
rerender(<TestWrapper authValue={loggedOutAuth} />); rerender(<TestWrapper authValue={loggedOutAuth} />);
await waitFor(() => { await waitFor(() => {
expect(within(createPanel).queryByText('RPG 退出缓存作品')).toBeNull(); expect(within(createPanel).queryByText('RPG 退出缓存作品')).toBeNull();
expect(within(createPanel).queryByText('大鱼退出缓存作品')).toBeNull();
expect(within(createPanel).queryByText('拼图退出缓存作品')).toBeNull(); expect(within(createPanel).queryByText('拼图退出缓存作品')).toBeNull();
}); });
expect(within(createPanel).getByText('还没有作品')).toBeTruthy(); expect(within(createPanel).getByText('还没有作品')).toBeTruthy();
@@ -1597,7 +1610,7 @@ test('published puzzle works appear on home and category public shelves', async
).toBeGreaterThan(0); ).toBeGreaterThan(0);
}); });
test('published big fish works appear on home and category public shelves', async () => { test('published big fish works stay hidden from platform home and category shelves', async () => {
const user = userEvent.setup(); const user = userEvent.setup();
const publishedBigFishWork: BigFishWorkSummary = { const publishedBigFishWork: BigFishWorkSummary = {
workId: 'big-fish-work-public-1', workId: 'big-fish-work-public-1',
@@ -1623,20 +1636,17 @@ test('published big fish works appear on home and category public shelves', asyn
render(<TestWrapper />); render(<TestWrapper />);
await waitFor(() => { await waitFor(() => {
expect(screen.getAllByText('机械深海 大鱼吃小鱼').length).toBeGreaterThan( expect(listBigFishGallery).not.toHaveBeenCalled();
0,
);
}); });
expect(screen.queryByText('机械深海 大鱼吃小鱼')).toBeNull();
await user.click(screen.getByRole('button', { name: '分类' })); await user.click(screen.getByRole('button', { name: '分类' }));
const categoryPanel = getPlatformTabPanel('category'); const categoryPanel = getPlatformTabPanel('category');
expect(within(categoryPanel).queryByText('机械深海 大鱼吃小鱼')).toBeNull();
expect( expect(
within(categoryPanel).getAllByText('机械深海 大鱼吃小鱼').length, within(categoryPanel).queryAllByRole('button', { name: //u }).length,
).toBeGreaterThan(0); ).toBe(0);
expect(
within(categoryPanel).getAllByRole('button', { name: //u }).length,
).toBeGreaterThan(0);
}); });
test('published puzzle detail returns to the source platform tab', async () => { test('published puzzle detail returns to the source platform tab', async () => {
@@ -1853,31 +1863,15 @@ test('new creation entry maps raw bearer token errors to user-facing auth copy',
expect(screen.queryByText('缺少 Authorization Bearer Token')).toBeNull(); expect(screen.queryByText('缺少 Authorization Bearer Token')).toBeNull();
}); });
test('big fish creation timeout exits busy state and shows a readable error', async () => { test('hidden big fish creation entry does not render in platform create hub', async () => {
const user = userEvent.setup(); const user = userEvent.setup();
vi.mocked(createBigFishCreationSession).mockRejectedValueOnce(
Object.assign(new Error('请求超时15000ms'), {
name: 'TimeoutError',
}),
);
render(<TestWrapper withAuth />); render(<TestWrapper withAuth />);
await openCreationHub(user); await openCreationHub(user);
const button = screen.getByRole('button', { name: //u }); expect(screen.queryByRole('button', { name: //u })).toBeNull();
await user.click(button); expect(createBigFishCreationSession).not.toHaveBeenCalled();
await waitFor(() => {
expect(
within(getPlatformTabPanel('create')).getAllByText(
'开启大鱼吃小鱼创作工作台超时,请确认运行时后端已启动后重试。',
).length,
).toBeGreaterThan(0);
});
expect((button as HTMLButtonElement).disabled).toBe(false);
expect(screen.queryByText(//u)).toBeNull();
}); });
test('puzzle creation timeout exits busy state and shows a readable error', async () => { test('puzzle creation timeout exits busy state and shows a readable error', async () => {
@@ -2086,163 +2080,6 @@ test('public code search opens a published big fish work by BF code', async () =
expect(getRpgEntryWorldGalleryDetailByCode).not.toHaveBeenCalled(); expect(getRpgEntryWorldGalleryDetailByCode).not.toHaveBeenCalled();
}); });
test('big fish draft card restores the bound agent session and opens the result view', async () => {
const user = userEvent.setup();
vi.mocked(listBigFishWorks).mockResolvedValue({
items: [
{
workId: 'big-fish-work-big-fish-session-1',
sourceSessionId: 'big-fish-session-1',
ownerUserId: 'user-1',
title: '机械深海 大鱼吃小鱼',
subtitle: '机械微生物吞并进化 · 偏爽快节奏',
summary: '机械微生物吞并进化',
coverImageSrc: null,
status: 'draft',
updatedAt: '2026-04-22T12:10:00.000Z',
publishReady: false,
levelCount: 8,
levelMainImageReadyCount: 0,
levelMotionReadyCount: 0,
backgroundReady: false,
},
],
});
render(<TestWrapper withAuth />);
await openCreationHub(user);
const title = await screen.findByText('机械深海 大鱼吃小鱼');
const card = title.closest('.platform-surface');
if (!(card instanceof HTMLElement)) {
throw new Error('Missing big fish draft card');
}
await user.click(card);
await waitFor(() => {
expect(getBigFishCreationSession).toHaveBeenCalledWith(
'big-fish-session-1',
);
});
expect(await screen.findByText('大鱼吃小鱼结果页')).toBeTruthy();
expect(screen.getByText('机械深海 大鱼吃小鱼')).toBeTruthy();
await user.click(screen.getByRole('button', { name: '返回' }));
expect(
await screen.findByText('大鱼吃小鱼工作区big-fish-session-1'),
).toBeTruthy();
expect(screen.queryByText(//u)).toBeNull();
expect(screen.getByText('我想做机械深海里微生物互相吞并进化。')).toBeTruthy();
});
test('big fish result publish action refreshes creation works', async () => {
const user = userEvent.setup();
const baseBigFishSession = (
await getBigFishCreationSession('big-fish-session-1')
).session;
vi.mocked(getBigFishCreationSession).mockClear();
vi.mocked(listBigFishWorks).mockClear();
vi.mocked(listBigFishGallery).mockClear();
const publishedBigFishSession = {
...baseBigFishSession,
stage: 'published',
publishReady: true,
assetCoverage: {
levelMainImageReadyCount: 8,
levelMotionReadyCount: 16,
backgroundReady: true,
requiredLevelCount: 8,
publishReady: true,
blockers: [],
},
updatedAt: '2026-04-22T12:20:00.000Z',
};
vi.mocked(executeBigFishCreationAction).mockResolvedValue({
session: publishedBigFishSession,
});
vi.mocked(listBigFishWorks)
.mockResolvedValueOnce({
items: [
{
workId: 'big-fish-work-big-fish-session-1',
sourceSessionId: 'big-fish-session-1',
ownerUserId: 'user-1',
title: '机械深海 大鱼吃小鱼',
subtitle: '机械微生物吞并进化 · 偏爽快节奏',
summary: '机械微生物吞并进化',
coverImageSrc: null,
status: 'draft',
updatedAt: '2026-04-22T12:10:00.000Z',
publishReady: true,
levelCount: 8,
levelMainImageReadyCount: 8,
levelMotionReadyCount: 16,
backgroundReady: true,
},
],
})
.mockResolvedValue({
items: [
{
workId: 'big-fish-work-big-fish-session-1',
sourceSessionId: 'big-fish-session-1',
ownerUserId: 'user-1',
title: '机械深海 大鱼吃小鱼',
subtitle: '机械微生物吞并进化 · 偏爽快节奏',
summary: '机械微生物吞并进化',
coverImageSrc: null,
status: 'published',
updatedAt: '2026-04-22T12:20:00.000Z',
publishReady: true,
levelCount: 8,
levelMainImageReadyCount: 8,
levelMotionReadyCount: 16,
backgroundReady: true,
},
],
});
render(<TestWrapper withAuth />);
await openCreationHub(user);
const title = await screen.findByText('机械深海 大鱼吃小鱼');
const card = title.closest('.platform-surface');
if (!(card instanceof HTMLElement)) {
throw new Error('Missing big fish draft card');
}
await user.click(card);
await waitFor(() => {
expect(getBigFishCreationSession).toHaveBeenCalledWith(
'big-fish-session-1',
);
});
vi.mocked(listBigFishWorks).mockClear();
expect(await screen.findByText('大鱼吃小鱼结果页')).toBeTruthy();
await user.click(await screen.findByRole('button', { name: '发布' }));
await waitFor(() => {
expect(executeBigFishCreationAction).toHaveBeenCalledWith(
'big-fish-session-1',
{
action: 'big_fish_publish_game',
},
);
});
await waitFor(() => {
expect(listBigFishWorks).toHaveBeenCalled();
});
await waitFor(() => {
expect(listBigFishGallery).toHaveBeenCalled();
});
});
test('starting draft generation leaves the agent workspace and shows the generation progress view', async () => { test('starting draft generation leaves the agent workspace and shows the generation progress view', async () => {
const user = userEvent.setup(); const user = userEvent.setup();

View File

@@ -156,7 +156,6 @@ export function getLeadingAttributeSlot(
export function buildSchemaSummary(schema: WorldAttributeSchema, limit = 6) { export function buildSchemaSummary(schema: WorldAttributeSchema, limit = 6) {
return schema.slots.slice(0, limit).map(slot => ({ return schema.slots.slice(0, limit).map(slot => ({
name: slot.name, name: slot.name,
definition: slot.definition,
})); }));
} }

View File

@@ -50,18 +50,6 @@ function toText(value: unknown, fallback: string) {
return normalized || fallback; return normalized || fallback;
} }
function toStringArray(value: unknown, fallback: string[]) {
if (!Array.isArray(value)) {
return [...fallback];
}
const normalized = value
.map(item => toOptionalText(item))
.filter(Boolean);
return normalized.length > 0 ? [...new Set(normalized)] : [...fallback];
}
export function coerceWorldAttributeSchema( export function coerceWorldAttributeSchema(
raw: unknown, raw: unknown,
fallback: WorldAttributeSchema, fallback: WorldAttributeSchema,
@@ -79,7 +67,6 @@ export function coerceWorldAttributeSchema(
schemaVersion: typeof raw.schemaVersion === 'number' && Number.isFinite(raw.schemaVersion) && raw.schemaVersion > 0 schemaVersion: typeof raw.schemaVersion === 'number' && Number.isFinite(raw.schemaVersion) && raw.schemaVersion > 0
? Math.max(1, Math.round(raw.schemaVersion)) ? Math.max(1, Math.round(raw.schemaVersion))
: fallback.schemaVersion, : fallback.schemaVersion,
schemaName: toOptionalText(raw.schemaName) || fallback.schemaName,
generatedFrom: { generatedFrom: {
...fallback.generatedFrom, ...fallback.generatedFrom,
worldName: toText(rawGeneratedFrom.worldName, fallback.generatedFrom.worldName), worldName: toText(rawGeneratedFrom.worldName, fallback.generatedFrom.worldName),
@@ -93,12 +80,6 @@ export function coerceWorldAttributeSchema(
...fallbackSlot, ...fallbackSlot,
slotId: fallbackSlot.slotId, slotId: fallbackSlot.slotId,
name: toText(rawSlot.name, fallbackSlot.name), name: toText(rawSlot.name, fallbackSlot.name),
definition: toText(rawSlot.definition, fallbackSlot.definition),
positiveSignals: toStringArray(rawSlot.positiveSignals, fallbackSlot.positiveSignals),
negativeSignals: toStringArray(rawSlot.negativeSignals, fallbackSlot.negativeSignals),
combatUseText: toText(rawSlot.combatUseText, fallbackSlot.combatUseText),
socialUseText: toText(rawSlot.socialUseText, fallbackSlot.socialUseText),
explorationUseText: toText(rawSlot.explorationUseText, fallbackSlot.explorationUseText),
}; };
}), }),
}; };
@@ -151,17 +132,6 @@ export function validateWorldAttributeSchema(schema: WorldAttributeSchema) {
issues.push(`attribute name "${trimmedName}" contains banned legacy term`); issues.push(`attribute name "${trimmedName}" contains banned legacy term`);
} }
if (!slot.definition.trim()) {
issues.push(`slot ${slot.slotId} is missing a definition`);
}
if (/|||/u.test(slot.definition)) {
issues.push(`slot ${slot.slotId} definition is too derivative`);
}
if (!slot.combatUseText.trim() || !slot.socialUseText.trim() || !slot.explorationUseText.trim()) {
issues.push(`slot ${slot.slotId} must describe combat, social, and exploration usage`);
}
}); });
return issues; return issues;

View File

@@ -85,7 +85,6 @@ export type BuildDamageBreakdown = {
export type BuildContributionAttributeRow = { export type BuildContributionAttributeRow = {
slotId: string; slotId: string;
label: string; label: string;
definition: string;
similarity: number; similarity: number;
weight: number; weight: number;
value: number; value: number;
@@ -104,7 +103,6 @@ export type OutgoingDamageResult = {
type BuildContributionTarget = { type BuildContributionTarget = {
slotId: string; slotId: string;
label: string; label: string;
definition: string;
}; };
type ResolvedTagAffinity = { type ResolvedTagAffinity = {
@@ -312,7 +310,6 @@ function resolveContributionTargets(
return schema.slots.map((slot) => ({ return schema.slots.map((slot) => ({
slotId: slot.slotId, slotId: slot.slotId,
label: slot.name, label: slot.name,
definition: slot.definition,
})) satisfies BuildContributionTarget[]; })) satisfies BuildContributionTarget[];
} }
@@ -550,7 +547,6 @@ export function getBuildContributionAttributeRows(
return { return {
slotId: target.slotId, slotId: target.slotId,
label: target.label, label: target.label,
definition: target.definition,
similarity: roundNumber( similarity: roundNumber(
row.attributeSimilarities?.[target.slotId] ?? 0, row.attributeSimilarities?.[target.slotId] ?? 0,
4, 4,

View File

@@ -60,12 +60,6 @@ function buildSlotSemanticVector(slot: WorldAttributeSlot, index: number) {
const sourceText = [ const sourceText = [
slot.slotId, slot.slotId,
slot.name, slot.name,
slot.definition,
slot.combatUseText,
slot.socialUseText,
slot.explorationUseText,
...(slot.positiveSignals ?? []),
...(slot.negativeSignals ?? []),
].join(' '); ].join(' ');
const semanticVector: AttributeVector = {}; const semanticVector: AttributeVector = {};

View File

@@ -5,7 +5,8 @@ import type { FunctionDocumentationEntry } from '../types';
* camp_travel_home_scene * camp_travel_home_scene
* *
* 从营地与同伴对话结束后,正式前往角色主线场景的控制 function。 * 从营地与同伴对话结束后,正式前往角色主线场景的控制 function。
* 这里除了元信息,也直接收口了它的按钮构造判定 helper * 中文注释:前端只保留按钮构造判定 helper 和视觉元信息;
* 正式场景迁移、遭遇预览与快照写入统一由后端 resolver 承接。
*/ */
export const CAMP_TRAVEL_HOME_OPTION_VISUALS: StoryOption['visuals'] = { export const CAMP_TRAVEL_HOME_OPTION_VISUALS: StoryOption['visuals'] = {
playerAnimation: AnimationState.RUN, playerAnimation: AnimationState.RUN,
@@ -41,10 +42,10 @@ export const CAMP_TRAVEL_HOME_FUNCTION: FunctionDocumentationEntry = {
source: 'src/data/functionCatalog/flow/campTravelHomeScene.ts', source: 'src/data/functionCatalog/flow/campTravelHomeScene.ts',
summary: '营地开场后的专用旅行控制项。', summary: '营地开场后的专用旅行控制项。',
detailedDescription: detailedDescription:
'它负责把开局同伴营地流程平稳切到角色真正的起始场景,并清理当前营地 encounter、战斗态和镜头残留状态。', '它负责把开局同伴营地流程平稳切到角色真正的起始场景;正式目标场景、encounter 清理、战斗态清理和镜头残留状态由后端 resolver 写入。',
trigger: '常见于开局同伴营地对话后的跟进选项。', trigger: '常见于开局同伴营地对话后的跟进选项。',
execution: execution:
'点击后不会走普通 state function 结算,而是执行一次定制场景迁移和历史写入。', '点击后作为服务端 runtime function id 提交到 /api/runtime/story/actions/resolve由后端执行定制场景迁移和历史写入。',
result: '玩家会离开营地进入角色主场景,正式开始该角色的冒险线。', result: '玩家会离开营地进入角色主场景,正式开始该角色的冒险线。',
active: true, active: true,
runtime: { runtime: {
@@ -52,11 +53,11 @@ export const CAMP_TRAVEL_HOME_FUNCTION: FunctionDocumentationEntry = {
uiMode: 'none', uiMode: 'none',
visuals: CAMP_TRAVEL_HOME_OPTION_VISUALS, visuals: CAMP_TRAVEL_HOME_OPTION_VISUALS,
executor: executor:
'src/hooks/rpg-runtime-story/choiceActions.ts -> handleCampTravelHome', 'server-rs/crates/api-server/src/runtime_story/compat.rs -> resolve_camp_travel_home_scene_action',
animationNote: animationNote:
'先播放营地离场的 run 演出,再切到正式场景并生成 encounter preview。', '前端保留 run 视觉元信息;正式状态以服务端 hydrated snapshot 为准。',
storyNote: storyNote:
'通过 commitGeneratedStateWithEncounterEntry 写入离营结果,并在新场景继续后续剧情。', '后端写入离营结果、生成 encounter preview,并在新场景继续后续剧情。',
uiNote: '这是专用旅行流程,不会打开 modal。', uiNote: '这是专用旅行流程,不会打开 modal。',
}, },
}; };

View File

@@ -1,8 +1,9 @@
import { existsSync } from 'node:fs'; import { existsSync } from 'node:fs';
import { SERVER_RUNTIME_FUNCTION_IDS } from '../../../packages/shared/src/contracts/rpgRuntimeStoryAction';
import { describe, expect, it } from 'vitest'; import { describe, expect, it } from 'vitest';
import { SERVER_RUNTIME_FUNCTION_IDS } from '../../../packages/shared/src/contracts/rpgRuntimeStoryAction';
import type { Encounter, GameState, InventoryItem } from '../../types';
import { import {
ALL_FUNCTION_DOCUMENTATION, ALL_FUNCTION_DOCUMENTATION,
buildCampTravelHomeOption, buildCampTravelHomeOption,
@@ -11,6 +12,7 @@ import {
buildNpcPreviewTalkOption, buildNpcPreviewTalkOption,
buildNpcRecruitModalState, buildNpcRecruitModalState,
buildNpcTradeModalState, buildNpcTradeModalState,
CAMP_TRAVEL_HOME_FUNCTION,
CONTINUE_ADVENTURE_FUNCTION, CONTINUE_ADVENTURE_FUNCTION,
getFunctionDocumentationById, getFunctionDocumentationById,
isNpcPreviewTalkOption, isNpcPreviewTalkOption,
@@ -18,7 +20,6 @@ import {
shouldNpcRecruitOpenModal, shouldNpcRecruitOpenModal,
} from './index'; } from './index';
import { RPG_FUNCTION_RUNTIME_OVERVIEW } from './runtimeIndex'; import { RPG_FUNCTION_RUNTIME_OVERVIEW } from './runtimeIndex';
import type { Encounter, GameState, InventoryItem } from '../../types';
function createEncounter(overrides: Partial<Encounter> = {}): Encounter { function createEncounter(overrides: Partial<Encounter> = {}): Encounter {
return { return {
@@ -103,6 +104,12 @@ describe('functionCatalog', () => {
expect(campTravelOption.functionId).toBe('camp_travel_home_scene'); expect(campTravelOption.functionId).toBe('camp_travel_home_scene');
expect(campTravelOption.actionText).toBe('前往 竹林古道'); expect(campTravelOption.actionText).toBe('前往 竹林古道');
expect(campTravelOption.detailText).toBe('离开营地,前往 竹林古道。'); expect(campTravelOption.detailText).toBe('离开营地,前往 竹林古道。');
expect(CAMP_TRAVEL_HOME_FUNCTION.runtime?.executor).toContain(
'server-rs/crates/api-server/src/runtime_story/compat.rs',
);
expect(CAMP_TRAVEL_HOME_FUNCTION.detailedDescription).toContain(
'后端 resolver',
);
}); });
it('builds npc preview talk options from the current encounter', () => { it('builds npc preview talk options from the current encounter', () => {

View File

@@ -248,7 +248,7 @@ export function buildEncounterAttributeRumors(
return getSortedAttributeEntries(profile, schemaContext.schema) return getSortedAttributeEntries(profile, schemaContext.schema)
.slice(0, options.limit ?? 2) .slice(0, options.limit ?? 2)
.map(entry => `${entry.slot.name}${entry.slot.definition}`); .map(entry => entry.slot.name);
} }
export function buildGiftAffinityInsight( export function buildGiftAffinityInsight(

View File

@@ -10,7 +10,6 @@ export const WORLD_TEMPLATE_ATTRIBUTE_SCHEMAS: Record<
id: 'schema:wuxia:v1', id: 'schema:wuxia:v1',
worldId: WorldType.WUXIA, worldId: WorldType.WUXIA,
schemaVersion: 1, schemaVersion: 1,
schemaName: '江湖六脉',
generatedFrom: { generatedFrom: {
worldType: WorldType.WUXIA, worldType: WorldType.WUXIA,
worldName: '武侠', worldName: '武侠',
@@ -22,62 +21,26 @@ export const WORLD_TEMPLATE_ATTRIBUTE_SCHEMAS: Record<
{ {
slotId: 'axis_a', slotId: 'axis_a',
name: '骨势', name: '骨势',
definition: '扛压、顶冲、硬吃风险也不退的势头。',
positiveSignals: ['扛压', '硬桥硬马', '稳住正面'],
negativeSignals: ['虚浮', '怯退', '一碰就散'],
combatUseText: '顶住正面压力、换伤不退、撑住阵线。',
socialUseText: '在强压场面里不露怯,给人可靠或强硬之感。',
explorationUseText: '穿越险路、硬顶机关、承受高压环境。',
}, },
{ {
slotId: 'axis_b', slotId: 'axis_b',
name: '身法', name: '身法',
definition: '腾挪、抢位、换线、把握出手节奏的能力。',
positiveSignals: ['快', '轻灵', '抢位'],
negativeSignals: ['迟缓', '失位', '笨重'],
combatUseText: '切线换位、闪转腾挪、争夺先手。',
socialUseText: '应变快,擅长观察气口并顺势接话。',
explorationUseText: '攀越、潜入、追踪与复杂地形穿行。',
}, },
{ {
slotId: 'axis_c', slotId: 'axis_c',
name: '眼脉', name: '眼脉',
definition: '看破破绽、拆招、识局、看穿人心的能力。',
positiveSignals: ['识局', '洞察', '拆招'],
negativeSignals: ['迟钝', '误判', '看不透'],
combatUseText: '抓破绽、拆套路、找出最该切入的位置。',
socialUseText: '判断弦外之音、试探真假、识别来意。',
explorationUseText: '识破机关、辨认痕迹、看懂异状。',
}, },
{ {
slotId: 'axis_d', slotId: 'axis_d',
name: '心焰', name: '心焰',
definition: '决断、压迫、胆气、在局面中立住自身意志的能力。',
positiveSignals: ['胆气', '决断', '压迫'],
negativeSignals: ['犹疑', '软弱', '易被动摇'],
combatUseText: '逼迫对手、强行推进、在关键时刻拍板。',
socialUseText: '立威、定调、在谈判里压住场子。',
explorationUseText: '在未知风险前保持决断,不被局势拖死。',
}, },
{ {
slotId: 'axis_e', slotId: 'axis_e',
name: '尘缘', name: '尘缘',
definition: '与人事、情面、承诺、牵引关系打交道的能力。',
positiveSignals: ['通人情', '会安抚', '懂交换'],
negativeSignals: ['生硬', '失礼', '不近人情'],
combatUseText: '借势协同、读懂同伴与对手的关系脉络。',
socialUseText: '安抚、求助、结盟、维系承诺与信任。',
explorationUseText: '从传闻、人脉和地方关系里打开线索。',
}, },
{ {
slotId: 'axis_f', slotId: 'axis_f',
name: '玄息', name: '玄息',
definition: '调息、稳态、久战、把自身维持在可用状态的能力。',
positiveSignals: ['稳', '续战', '调息'],
negativeSignals: ['紊乱', '易崩', '续不上'],
combatUseText: '续战、回气、稳住节奏与状态。',
socialUseText: '遇事不乱,语气和姿态都更沉稳可信。',
explorationUseText: '长线跋涉、在恶劣环境下维持专注与状态。',
}, },
], ],
}, },
@@ -85,7 +48,6 @@ export const WORLD_TEMPLATE_ATTRIBUTE_SCHEMAS: Record<
id: 'schema:xianxia:v1', id: 'schema:xianxia:v1',
worldId: WorldType.XIANXIA, worldId: WorldType.XIANXIA,
schemaVersion: 1, schemaVersion: 1,
schemaName: '灵界六轴',
generatedFrom: { generatedFrom: {
worldType: WorldType.XIANXIA, worldType: WorldType.XIANXIA,
worldName: '仙侠', worldName: '仙侠',
@@ -97,62 +59,26 @@ export const WORLD_TEMPLATE_ATTRIBUTE_SCHEMAS: Record<
{ {
slotId: 'axis_a', slotId: 'axis_a',
name: '道骨', name: '道骨',
definition: '承载道压与高强度冲击的底子。',
positiveSignals: ['承压', '根基稳', '扛得住'],
negativeSignals: ['根基浅', '易溃', '承载不足'],
combatUseText: '扛住灵压、正面承受高强度对撞。',
socialUseText: '让人感到根基扎实,值得托付重事。',
explorationUseText: '承受秘境、禁制与裂隙带来的压迫。',
}, },
{ {
slotId: 'axis_b', slotId: 'axis_b',
name: '灵行', name: '灵行',
definition: '位移、御空、转场、抢占天时地利的能力。',
positiveSignals: ['位移', '御空', '机动'],
negativeSignals: ['迟滞', '失位', '转场慢'],
combatUseText: '抢位、御空、快速重整战场位置。',
socialUseText: '反应轻快,擅长顺势接住局面的变化。',
explorationUseText: '穿越高危地形、裂隙、云海与复杂禁区。',
}, },
{ {
slotId: 'axis_c', slotId: 'axis_c',
name: '识海', name: '识海',
definition: '解析禁制、洞察因果、识破虚实的能力。',
positiveSignals: ['洞察', '解构', '看破'],
negativeSignals: ['迷失', '误判', '看不清'],
combatUseText: '识破术理、找出因果节点与破绽。',
socialUseText: '更容易辨认真话、虚言与隐藏动机。',
explorationUseText: '解读阵纹、禁制、旧史与环境异象。',
}, },
{ {
slotId: 'axis_d', slotId: 'axis_d',
name: '劫纹', name: '劫纹',
definition: '在高危变化中强行推进、改写局势的能力。',
positiveSignals: ['强推', '决断', '逆转'],
negativeSignals: ['畏缩', '迟疑', '不敢碰变局'],
combatUseText: '在高压窗口里压上去,逼出变化与突破。',
socialUseText: '在关键谈判中拍板,推动他人表态。',
explorationUseText: '面对异变与风险时敢于推进关键节点。',
}, },
{ {
slotId: 'axis_e', slotId: 'axis_e',
name: '心契', name: '心契',
definition: '与他者、器物、灵兽、誓约建立共鸣的能力。',
positiveSignals: ['共鸣', '结契', '安抚'],
negativeSignals: ['隔阂', '生硬', '难以共振'],
combatUseText: '与器物、灵兽、同伴形成协同与共鸣。',
socialUseText: '建立信任、誓约与更深层的关系连结。',
explorationUseText: '借由共鸣打开封印、回应遗物或安抚异兽。',
}, },
{ {
slotId: 'axis_f', slotId: 'axis_f',
name: '玄息', name: '玄息',
definition: '循环灵息、稳住心神、让自身持续在线的能力。',
positiveSignals: ['稳态', '回转', '续航'],
negativeSignals: ['紊乱', '枯竭', '失衡'],
combatUseText: '维持灵息循环、拖住长线压力与消耗。',
socialUseText: '气息沉稳,不轻易乱阵脚或露出破绽。',
explorationUseText: '在漫长探索与灵潮侵蚀中维持可行动状态。',
}, },
], ],
}, },

View File

@@ -21,7 +21,6 @@ import { createStoryChoiceActions } from './choiceActions';
vi.mock('./storyChoiceRuntime', async () => { vi.mock('./storyChoiceRuntime', async () => {
return { return {
runCampTravelHomeChoice: vi.fn(),
runServerRuntimeChoiceAction: runServerRuntimeChoiceActionMock, runServerRuntimeChoiceAction: runServerRuntimeChoiceActionMock,
shouldOpenLocalRuntimeNpcModal: (option: StoryOption) => shouldOpenLocalRuntimeNpcModal: (option: StoryOption) =>
( (
@@ -811,4 +810,97 @@ describe('createStoryChoiceActions', () => {
expect(buildResolvedChoiceState).not.toHaveBeenCalled(); expect(buildResolvedChoiceState).not.toHaveBeenCalled();
expect(playResolvedChoice).not.toHaveBeenCalled(); expect(playResolvedChoice).not.toHaveBeenCalled();
}); });
it('routes camp_travel_home_scene to the backend resolver instead of the legacy local travel branch', async () => {
const state = {
...createBaseState(),
inBattle: false,
currentBattleNpcId: null,
currentNpcBattleMode: null,
currentEncounter: {
kind: 'npc' as const,
id: 'npc-camp',
npcName: '营地同伴',
npcDescription: '准备一起出发的同伴',
npcAvatar: '伴',
context: '营地',
hostile: false,
},
npcInteractionActive: false,
sceneHostileNpcs: [],
currentScenePreset: {
id: 'wuxia-border-camp',
name: '边关营地',
description: '营火未熄。',
imageSrc: '',
connectedSceneIds: ['wuxia-palace-court'],
connections: [],
forwardSceneId: 'wuxia-palace-court',
treasureHints: [],
npcs: [],
},
} satisfies GameState;
const option: StoryOption = {
...createBattleOption('camp_travel_home_scene'),
actionText: '前往宫苑内庭',
text: '前往宫苑内庭',
};
const buildResolvedChoiceState = vi.fn();
const playResolvedChoice = vi.fn();
const commitGeneratedStateWithEncounterEntry = vi.fn();
isRpgRuntimeServerFunctionIdMock.mockReturnValue(true);
const { handleChoice } = createStoryChoiceActions({
gameState: state,
currentStory: createFallbackStory('营地对话已经收束。'),
isLoading: false,
setGameState: vi.fn(),
setCurrentStory: vi.fn(),
setAiError: vi.fn(),
setIsLoading: vi.fn(),
setBattleReward: vi.fn(),
buildResolvedChoiceState,
playResolvedChoice,
buildStoryContextFromState: vi.fn(),
buildStoryFromResponse: vi.fn((_, __, response) => response),
buildFallbackStoryForState: vi.fn(() => createFallbackStory()),
generateStoryForState: vi.fn(),
getAvailableOptionsForState: vi.fn(() => [option]),
getStoryGenerationHostileNpcs: vi.fn(() => []),
getResolvedSceneHostileNpcs: vi.fn(() => []),
buildNpcStory: vi.fn(() => createFallbackStory()),
handleNpcBattleConversationContinuation: vi.fn(() => false),
updateQuestLog: vi.fn((inputState: GameState) => inputState),
incrementRuntimeStats: vi.fn((inputState: GameState) => inputState),
getCampCompanionTravelScene: vi.fn(() => {
throw new Error('legacy camp travel resolver should not run');
}),
enterNpcInteraction: vi.fn(() => false),
handleNpcInteraction: vi.fn(() => false),
handleTreasureInteraction: vi.fn(() => false),
commitGeneratedStateWithEncounterEntry,
finalizeNpcBattleResult: vi.fn(() => null),
isContinueAdventureOption: vi.fn(() => false),
isCampTravelHomeOption: vi.fn(() => true),
isRegularNpcEncounter: neverNpcEncounter,
isNpcEncounter: neverNpcEncounter,
npcPreviewTalkFunctionId: 'npc_preview_talk',
fallbackCompanionName: '同伴',
turnVisualMs: 820,
});
await handleChoice(option);
expect(runServerRuntimeChoiceActionMock).toHaveBeenCalledWith(
expect.objectContaining({
gameState: state,
option,
character: state.playerCharacter,
}),
);
expect(commitGeneratedStateWithEncounterEntry).not.toHaveBeenCalled();
expect(buildResolvedChoiceState).not.toHaveBeenCalled();
expect(playResolvedChoice).not.toHaveBeenCalled();
});
}); });

View File

@@ -20,7 +20,6 @@ import type {
} from './progressionActions'; } from './progressionActions';
import { runLocalStoryChoiceContinuation } from './storyChoiceContinuation'; import { runLocalStoryChoiceContinuation } from './storyChoiceContinuation';
import { import {
runCampTravelHomeChoice,
runServerRuntimeChoiceAction, runServerRuntimeChoiceAction,
shouldOpenLocalRuntimeNpcModal, shouldOpenLocalRuntimeNpcModal,
} from './storyChoiceRuntime'; } from './storyChoiceRuntime';
@@ -99,18 +98,13 @@ export function createStoryChoiceActions({
handleNpcBattleConversationContinuation, handleNpcBattleConversationContinuation,
updateQuestLog, updateQuestLog,
incrementRuntimeStats, incrementRuntimeStats,
getCampCompanionTravelScene,
enterNpcInteraction, enterNpcInteraction,
handleNpcInteraction, handleNpcInteraction,
handleTreasureInteraction, handleTreasureInteraction,
commitGeneratedStateWithEncounterEntry,
finalizeNpcBattleResult, finalizeNpcBattleResult,
isContinueAdventureOption, isContinueAdventureOption,
isCampTravelHomeOption,
isRegularNpcEncounter, isRegularNpcEncounter,
isNpcEncounter,
npcPreviewTalkFunctionId, npcPreviewTalkFunctionId,
fallbackCompanionName,
turnVisualMs, turnVisualMs,
}: { }: {
gameState: GameState; gameState: GameState;
@@ -140,13 +134,13 @@ export function createStoryChoiceActions({
handleNpcBattleConversationContinuation: HandleNpcBattleConversationContinuation; handleNpcBattleConversationContinuation: HandleNpcBattleConversationContinuation;
updateQuestLog: UpdateQuestLog; updateQuestLog: UpdateQuestLog;
incrementRuntimeStats: IncrementRuntimeStats; incrementRuntimeStats: IncrementRuntimeStats;
getCampCompanionTravelScene: (state: GameState, character: Character) => GameState['currentScenePreset'] | null; getCampCompanionTravelScene?: (state: GameState, character: Character) => GameState['currentScenePreset'] | null;
enterNpcInteraction: (encounter: Encounter, actionText: string) => boolean; enterNpcInteraction: (encounter: Encounter, actionText: string) => boolean;
handleNpcInteraction: (option: StoryOption) => boolean | Promise<boolean>; handleNpcInteraction: (option: StoryOption) => boolean | Promise<boolean>;
handleTreasureInteraction: ( handleTreasureInteraction: (
option: StoryOption, option: StoryOption,
) => void | Promise<void> | boolean | Promise<boolean>; ) => void | Promise<void> | boolean | Promise<boolean>;
commitGeneratedStateWithEncounterEntry: CommitGeneratedStateWithEncounterEntry; commitGeneratedStateWithEncounterEntry?: CommitGeneratedStateWithEncounterEntry;
finalizeNpcBattleResult: ( finalizeNpcBattleResult: (
state: GameState, state: GameState,
character: Character, character: Character,
@@ -154,11 +148,11 @@ export function createStoryChoiceActions({
battleOutcome: GameState['currentNpcBattleOutcome'], battleOutcome: GameState['currentNpcBattleOutcome'],
) => { nextState: GameState; resultText: string } | null; ) => { nextState: GameState; resultText: string } | null;
isContinueAdventureOption: (option: StoryOption) => boolean; isContinueAdventureOption: (option: StoryOption) => boolean;
isCampTravelHomeOption: (option: StoryOption) => boolean; isCampTravelHomeOption?: (option: StoryOption) => boolean;
isRegularNpcEncounter: (encounter: GameState['currentEncounter']) => encounter is Encounter; isRegularNpcEncounter: (encounter: GameState['currentEncounter']) => encounter is Encounter;
isNpcEncounter: (encounter: GameState['currentEncounter']) => encounter is Encounter; isNpcEncounter?: (encounter: GameState['currentEncounter']) => encounter is Encounter;
npcPreviewTalkFunctionId: string; npcPreviewTalkFunctionId: string;
fallbackCompanionName: string; fallbackCompanionName?: string;
turnVisualMs: number; turnVisualMs: number;
}) { }) {
const handleChoice = async (option: StoryOption) => { const handleChoice = async (option: StoryOption) => {
@@ -191,25 +185,6 @@ export function createStoryChoiceActions({
return; return;
} }
if (isCampTravelHomeOption(option)) {
await runCampTravelHomeChoice({
gameState,
option,
character,
setBattleReward,
setAiError,
setIsLoading,
setGameState,
incrementRuntimeStats,
getCampCompanionTravelScene,
commitGeneratedStateWithEncounterEntry,
isNpcEncounter,
fallbackCompanionName,
turnVisualMs,
});
return;
}
if (shouldOpenLocalRuntimeNpcModal(option)) { if (shouldOpenLocalRuntimeNpcModal(option)) {
setAiError(null); setAiError(null);
await handleNpcInteraction(option); await handleNpcInteraction(option);

View File

@@ -1,16 +1,6 @@
import { import {
buildEncounterEntryState,
hasEncounterEntity,
} from '../../data/encounterTransition';
import {
CALL_OUT_ENTRY_X_METERS,
createSceneEncounterPreview,
resolveSceneEncounterPreview,
} from '../../data/sceneEncounterPreviews';
import {
AnimationState, AnimationState,
Character, Character,
Encounter,
GameState, GameState,
StoryMoment, StoryMoment,
StoryOption, StoryOption,
@@ -18,24 +8,12 @@ import {
import { resolveRpgRuntimeChoice } from '.'; import { resolveRpgRuntimeChoice } from '.';
import type { BattleRewardSummary } from './uiTypes'; import type { BattleRewardSummary } from './uiTypes';
type RuntimeStatsIncrements = Partial<
Pick<
GameState['runtimeStats'],
'hostileNpcsDefeated' | 'questsAccepted' | 'itemsUsed' | 'scenesTraveled'
>
>;
type BuildFallbackStoryForState = ( type BuildFallbackStoryForState = (
state: GameState, state: GameState,
character: Character, character: Character,
fallbackText?: string, fallbackText?: string,
) => StoryMoment; ) => StoryMoment;
type IncrementRuntimeStats = (
state: GameState,
increments: RuntimeStatsIncrements,
) => GameState;
function sleep(ms: number) { function sleep(ms: number) {
return new Promise((resolve) => globalThis.setTimeout(resolve, ms)); return new Promise((resolve) => globalThis.setTimeout(resolve, ms));
} }
@@ -54,126 +32,6 @@ export function shouldOpenLocalRuntimeNpcModal(option: StoryOption) {
); );
} }
export async function runCampTravelHomeChoice(params: {
gameState: GameState;
option: StoryOption;
character: Character;
setBattleReward: (reward: BattleRewardSummary | null) => void;
setAiError: (message: string | null) => void;
setIsLoading: (loading: boolean) => void;
setGameState: (state: GameState) => void;
incrementRuntimeStats: IncrementRuntimeStats;
getCampCompanionTravelScene: (
state: GameState,
character: Character,
) => GameState['currentScenePreset'] | null;
commitGeneratedStateWithEncounterEntry: (
entryState: GameState,
resolvedState: GameState,
character: Character,
actionText: string,
resultText: string,
lastFunctionId?: string,
) => Promise<void> | void;
isNpcEncounter: (
encounter: GameState['currentEncounter'],
) => encounter is Encounter;
fallbackCompanionName: string;
turnVisualMs: number;
}) {
const targetScene = params.getCampCompanionTravelScene(
params.gameState,
params.character,
);
if (!targetScene) {
return false;
}
params.setBattleReward(null);
params.setAiError(null);
const companionName = params.isNpcEncounter(params.gameState.currentEncounter)
? params.gameState.currentEncounter.npcName
: params.fallbackCompanionName;
const travelRunState: GameState = {
...params.gameState,
ambientIdleMode: undefined,
currentEncounter: null,
npcInteractionActive: false,
sceneHostileNpcs: [],
playerX: 0,
playerFacing: 'right' as const,
animationState: AnimationState.RUN,
playerActionMode: 'idle' as const,
activeCombatEffects: [],
scrollWorld: true,
inBattle: false,
lastObserveSignsSceneId: null,
lastObserveSignsReport: null,
currentBattleNpcId: null,
currentNpcBattleMode: null,
currentNpcBattleOutcome: null,
sparReturnEncounter: null,
sparPlayerHpBefore: null,
sparPlayerMaxHpBefore: null,
sparStoryHistoryBefore: null,
};
const travelBaseState: GameState = params.incrementRuntimeStats(
{
...params.gameState,
ambientIdleMode: undefined,
currentScenePreset: targetScene,
currentEncounter: null,
npcInteractionActive: false,
sceneHostileNpcs: [],
playerX: 0,
playerFacing: 'right' as const,
animationState: AnimationState.IDLE,
playerActionMode: 'idle' as const,
activeCombatEffects: [],
scrollWorld: false,
inBattle: false,
lastObserveSignsSceneId: null,
lastObserveSignsReport: null,
currentBattleNpcId: null,
currentNpcBattleMode: null,
currentNpcBattleOutcome: null,
sparReturnEncounter: null,
sparPlayerHpBefore: null,
sparPlayerMaxHpBefore: null,
sparStoryHistoryBefore: null,
},
{
scenesTraveled: 1,
},
);
const travelPreviewState: GameState = {
...travelBaseState,
...createSceneEncounterPreview(travelBaseState),
};
const resolvedState = hasEncounterEntity(travelPreviewState)
? resolveSceneEncounterPreview(travelPreviewState)
: travelBaseState;
const entryState = buildEncounterEntryState(
resolvedState,
CALL_OUT_ENTRY_X_METERS,
);
params.setIsLoading(true);
params.setGameState(travelRunState);
await sleep(params.turnVisualMs);
await params.commitGeneratedStateWithEncounterEntry(
entryState,
resolvedState,
params.character,
params.option.actionText,
`You and ${companionName} leave camp and formally step into ${targetScene.name} to begin the adventure.`,
params.option.functionId,
);
return true;
}
export async function runServerRuntimeChoiceAction(params: { export async function runServerRuntimeChoiceAction(params: {
gameState: GameState; gameState: GameState;
currentStory: StoryMoment | null; currentStory: StoryMoment | null;

View File

@@ -91,7 +91,6 @@ function buildSavedProfile(options: {
id: 'schema:test', id: 'schema:test',
worldId: 'CUSTOM', worldId: 'CUSTOM',
schemaVersion: 1, schemaVersion: 1,
schemaName: '测试',
generatedFrom: { generatedFrom: {
worldType: WorldType.CUSTOM, worldType: WorldType.CUSTOM,
worldName: '回潮群岛', worldName: '回潮群岛',

View File

@@ -10,14 +10,12 @@ import {detectCustomWorldThemeMode} from './customWorldTheme';
function buildSchema( function buildSchema(
input: AttributeSchemaGenerationInput, input: AttributeSchemaGenerationInput,
schemaName: string,
slots: WorldAttributeSlot[], slots: WorldAttributeSlot[],
): WorldAttributeSchema { ): WorldAttributeSchema {
return { return {
id: `schema:${input.worldType.toLowerCase()}:${schemaName}`, id: `schema:${input.worldType.toLowerCase()}:${input.worldName}`,
worldId: input.worldType === WorldType.CUSTOM ? `custom:${input.worldName}` : input.worldType, worldId: input.worldType === WorldType.CUSTOM ? `custom:${input.worldName}` : input.worldType,
schemaVersion: 1, schemaVersion: 1,
schemaName,
generatedFrom: { generatedFrom: {
worldType: input.worldType, worldType: input.worldType,
worldName: input.worldName, worldName: input.worldName,
@@ -40,62 +38,57 @@ function buildCustomThemeSlots(input: AttributeSchemaGenerationInput) {
if (themeMode === 'mythic') { if (themeMode === 'mythic') {
return { return {
schemaName: '叙境六维',
slots: [ slots: [
{ slotId: 'axis_a', name: '体魄', definition: '承受正面压力与长期消耗的底子。', positiveSignals: ['稳固', '抗压'], negativeSignals: ['脆弱', '虚浮'], combatUseText: '扛住冲击、保持站位。', socialUseText: '给人可靠、能顶事的感觉。', explorationUseText: '在漫长旅途中维持可行动状态。' }, { slotId: 'axis_a', name: '体魄' },
{ slotId: 'axis_b', name: '身法', definition: '换位、腾挪、抢时机与穿行环境的能力。', positiveSignals: ['灵动', '迅捷'], negativeSignals: ['迟滞', '笨拙'], combatUseText: '变线、闪避、抢位和追击。', socialUseText: '反应快,懂得顺势调整说法。', explorationUseText: '穿越复杂地形与危险通路。' }, { slotId: 'axis_b', name: '身法' },
{ slotId: 'axis_c', name: '识见', definition: '看清局势、拆解线索与判断轻重缓急的能力。', positiveSignals: ['洞察', '判断'], negativeSignals: ['误判', '迟钝'], combatUseText: '看穿敌方破绽与局势变化。', socialUseText: '识别真假、试探与隐藏立场。', explorationUseText: '整理线索、辨认路径与推断风险。' }, { slotId: 'axis_c', name: '识见' },
{ slotId: 'axis_d', name: '胆魄', definition: '在高压局势里依然敢于推进和拍板的力量。', positiveSignals: ['果断', '压场'], negativeSignals: ['退缩', '犹疑'], combatUseText: '顶着压力推进战局。', socialUseText: '在僵局里定调并逼出回应。', explorationUseText: '面对未知异象仍敢继续前探。' }, { slotId: 'axis_d', name: '胆魄' },
{ slotId: 'axis_e', name: '牵引', definition: '与人、物、线索和环境建立联动的能力。', positiveSignals: ['协同', '共鸣'], negativeSignals: ['脱节', '孤立'], combatUseText: '借协同和牵制形成连锁。', socialUseText: '建立合作、说服和互信。', explorationUseText: '从人情、物件和场景之间串起通路。' }, { slotId: 'axis_e', name: '牵引' },
{ slotId: 'axis_f', name: '定力', definition: '在变化与消耗中稳住节奏、拉回状态的能力。', positiveSignals: ['稳定', '续航'], negativeSignals: ['失衡', '崩乱'], combatUseText: '久战不乱,能重新控住节奏。', socialUseText: '情绪稳定,不轻易被带偏。', explorationUseText: '在长线推进中持续保持判断和行动力。' }, { slotId: 'axis_f', name: '定力' },
] satisfies WorldAttributeSlot[], ] satisfies WorldAttributeSlot[],
}; };
} }
if (themeMode === 'machina') { if (themeMode === 'machina') {
return { return {
schemaName: '机潮六轴',
slots: [ slots: [
{ slotId: 'axis_a', name: '机锋', definition: '承受硬碰撞与机械压力的结构强度。', positiveSignals: ['硬度', '结构'], negativeSignals: ['脆裂', '松散'], combatUseText: '扛住正面撞击与重压。', socialUseText: '给人可靠、稳固、难被撼动的感觉。', explorationUseText: '在高压、坍塌与工业险境中撑住阵脚。' }, { slotId: 'axis_a', name: '机锋' },
{ slotId: 'axis_b', name: '步准', definition: '换位、校准、抢时机与精准位移的能力。', positiveSignals: ['校准', '位移'], negativeSignals: ['迟滞', '失准'], combatUseText: '快速转位、抢射界、控节奏。', socialUseText: '反应精确,不轻易露怯。', explorationUseText: '穿越机关、轨道与复杂装置。' }, { slotId: 'axis_b', name: '步准' },
{ slotId: 'axis_c', name: '算识', definition: '解析结构、演算路径、识别规律的能力。', positiveSignals: ['演算', '拆解'], negativeSignals: ['误算', '看不懂'], combatUseText: '读懂装置与敌方机制的薄弱点。', socialUseText: '判断局势、识别话术与利益结构。', explorationUseText: '解密、修复、校准与规划路径。' }, { slotId: 'axis_c', name: '算识' },
{ slotId: 'axis_d', name: '潮压', definition: '在高噪高压中强行推进局势的能力。', positiveSignals: ['推进', '压迫'], negativeSignals: ['退缩', '失控'], combatUseText: '顶着火力和混乱继续施压。', socialUseText: '在混乱场合里定调并逼出表态。', explorationUseText: '面对失控装置时敢于推进关键步骤。' }, { slotId: 'axis_d', name: '潮压' },
{ slotId: 'axis_e', name: '协频', definition: '与同伴、器械、网络或环境建立协同的能力。', positiveSignals: ['协同', '接驳'], negativeSignals: ['脱节', '孤立'], combatUseText: '与队友和装置形成联动收益。', socialUseText: '建立合作、交换与稳定配合。', explorationUseText: '接驳系统、调和多方资源与线索。' }, { slotId: 'axis_e', name: '协频' },
{ slotId: 'axis_f', name: '续载', definition: '维持负载、稳定输出、长线运转的能力。', positiveSignals: ['稳载', '续航'], negativeSignals: ['过热', '断载'], combatUseText: '稳住循环、维持持续输出与可操作状态。', socialUseText: '显得沉着、持重、不轻易失衡。', explorationUseText: '在长时间高负荷环境里持续工作。' }, { slotId: 'axis_f', name: '续载' },
] satisfies WorldAttributeSlot[], ] satisfies WorldAttributeSlot[],
}; };
} }
if (themeMode === 'tide') { if (themeMode === 'tide') {
return { return {
schemaName: '潮境六脉',
slots: [ slots: [
{ slotId: 'axis_a', name: '潮骨', definition: '扛住潮压与正面冲击的底子。', positiveSignals: ['承压', '稳'], negativeSignals: ['散', '弱'], combatUseText: '顶住正面浪涌与冲撞。', socialUseText: '给人能扛事的可靠感。', explorationUseText: '在风浪与湿重环境里稳住自己。' }, { slotId: 'axis_a', name: '潮骨' },
{ slotId: 'axis_b', name: '浪步', definition: '顺潮借势、换位穿行的能力。', positiveSignals: ['借势', '轻快'], negativeSignals: ['笨拙', '慢'], combatUseText: '借势滑开、切线、拉开距离。', socialUseText: '谈吐灵活,懂得顺势而为。', explorationUseText: '穿越港口、水路、雾区与复杂地形。' }, { slotId: 'axis_b', name: '浪步' },
{ slotId: 'axis_c', name: '舟识', definition: '辨流向、识潮眼、看穿变化的能力。', positiveSignals: ['辨向', '识局'], negativeSignals: ['迷失', '误读'], combatUseText: '抓住潮势变化和敌人的失衡时机。', socialUseText: '看懂局势、试探真假与留白。', explorationUseText: '辨认水路、雾障、潮汐与遗留痕迹。' }, { slotId: 'axis_c', name: '舟识' },
{ slotId: 'axis_d', name: '潮魄', definition: '在剧烈变化中仍敢推进的胆气。', positiveSignals: ['胆气', '压前'], negativeSignals: ['畏缩', '犹疑'], combatUseText: '借高压局势硬推突破口。', socialUseText: '在谈判或冲突里顶住对方气势。', explorationUseText: '面对陌生水域与异变仍敢向前。' }, { slotId: 'axis_d', name: '潮魄' },
{ slotId: 'axis_e', name: '契汐', definition: '与人、船、信物与约定形成牵引的能力。', positiveSignals: ['契合', '通人情'], negativeSignals: ['疏离', '难共鸣'], combatUseText: '借助协同与牵引打出连锁。', socialUseText: '善于结盟、安抚与做交换。', explorationUseText: '从航路、人情与旧约中打开局面。' }, { slotId: 'axis_e', name: '契汐' },
{ slotId: 'axis_f', name: '回澜', definition: '在漫长消耗中回稳状态、续住节奏的能力。', positiveSignals: ['回稳', '续航'], negativeSignals: ['紊乱', '断流'], combatUseText: '久战不乱,能把节奏重新拉回手里。', socialUseText: '遇事沉静,不易失态。', explorationUseText: '在漫长远行与恶劣天气里保有余力。' }, { slotId: 'axis_f', name: '回澜' },
] satisfies WorldAttributeSlot[], ] satisfies WorldAttributeSlot[],
}; };
} }
if (themeMode === 'rift') { if (themeMode === 'rift') {
return { return {
schemaName: '裂界六轴',
slots: [ slots: [
{ slotId: 'axis_a', name: '界躯', definition: '承受裂界冲击与异压侵蚀的底子。', positiveSignals: ['承载', '抗压'], negativeSignals: ['脆弱', '崩裂'], combatUseText: '扛住高强度裂界冲击。', socialUseText: '让人感到能镇住危险局面。', explorationUseText: '在异压、失衡环境下维持完整。' }, { slotId: 'axis_a', name: '界躯' },
{ slotId: 'axis_b', name: '裂步', definition: '穿梭边界、抢位、转场的能力。', positiveSignals: ['转场', '抢位'], negativeSignals: ['迟滞', '卡顿'], combatUseText: '借裂隙切位、抢身位与节奏。', socialUseText: '对局势变化响应很快。', explorationUseText: '穿越裂缝、断层与高危通路。' }, { slotId: 'axis_b', name: '裂步' },
{ slotId: 'axis_c', name: '界识', definition: '识别边界规律、虚实与因果的能力。', positiveSignals: ['辨识', '推断'], negativeSignals: ['错判', '看不清'], combatUseText: '洞察异界规律和对手的真空点。', socialUseText: '看破隐藏立场与不完整真话。', explorationUseText: '解读旧迹、裂痕和禁域法则。' }, { slotId: 'axis_c', name: '界识' },
{ slotId: 'axis_d', name: '界压', definition: '在失衡局势中强行立住意志与推进力。', positiveSignals: ['压上去', '定调'], negativeSignals: ['动摇', '失措'], combatUseText: '顶住异变推进攻势。', socialUseText: '在高压博弈中逼出答案。', explorationUseText: '面对危险异象仍敢推开下一层。' }, { slotId: 'axis_d', name: '界压' },
{ slotId: 'axis_e', name: '缚契', definition: '与他者、异物、誓约建立束缚或联结的能力。', positiveSignals: ['联结', '束约'], negativeSignals: ['排斥', '难联动'], combatUseText: '借共鸣与束缚形成协同或压制。', socialUseText: '建立合作、誓约与安抚关系。', explorationUseText: '唤醒遗物、安抚异种、触发响应。' }, { slotId: 'axis_e', name: '缚契' },
{ slotId: 'axis_f', name: '回脉', definition: '在紊乱环境中把自身重新拉回稳态的能力。', positiveSignals: ['回稳', '续住'], negativeSignals: ['失衡', '崩坏'], combatUseText: '抗住异压后迅速回到可战状态。', socialUseText: '情绪与气势都更稳。', explorationUseText: '在裂界侵蚀与长线压力里保持在线。' }, { slotId: 'axis_f', name: '回脉' },
] satisfies WorldAttributeSlot[], ] satisfies WorldAttributeSlot[],
}; };
} }
return { return {
schemaName: '叙境六维',
slots: getTemplateWorldAttributeSchema(WorldType.WUXIA).slots, slots: getTemplateWorldAttributeSchema(WorldType.WUXIA).slots,
}; };
} }
@@ -110,7 +103,7 @@ export function generateWorldAttributeSchema(input: AttributeSchemaGenerationInp
} }
const generated = buildCustomThemeSlots(input); const generated = buildCustomThemeSlots(input);
const schema = buildSchema(input, generated.schemaName, generated.slots); const schema = buildSchema(input, generated.slots);
const issues = validateWorldAttributeSchema(schema); const issues = validateWorldAttributeSchema(schema);
if (issues.length > 0) { if (issues.length > 0) {

View File

@@ -1478,7 +1478,7 @@ export function buildCustomWorldReferenceText(
`开局归处:${profile.camp?.name ?? '未设定'}${profile.camp?.description ? `${profile.camp.description}` : ''}`, `开局归处:${profile.camp?.name ?? '未设定'}${profile.camp?.description ? `${profile.camp.description}` : ''}`,
`题材适配层:${themePack.displayName};制度词汇 ${themePack.institutionLexicon.slice(0, 4).join('、')};禁忌词 ${themePack.tabooLexicon.slice(0, 4).join('、')};载体类型 ${themePack.artifactClasses.slice(0, 4).join('、')}`, `题材适配层:${themePack.displayName};制度词汇 ${themePack.institutionLexicon.slice(0, 4).join('、')};禁忌词 ${themePack.tabooLexicon.slice(0, 4).join('、')};载体类型 ${themePack.artifactClasses.slice(0, 4).join('、')}`,
`当前激活线程:\n${activeThreads.map((thread) => `- ${thread.title}${thread.summary}`).join('\n') || '- 暂无'}`, `当前激活线程:\n${activeThreads.map((thread) => `- ${thread.title}${thread.summary}`).join('\n') || '- 暂无'}`,
`世界属性轴:${profile.attributeSchema.slots.map((slot) => `${slot.name}${slot.definition}`).join('')}`, `世界属性轴:${profile.attributeSchema.slots.map((slot) => slot.name).join('')}`,
`可扮演角色档案:\n${playableNpcText || '- 暂无'}`, `可扮演角色档案:\n${playableNpcText || '- 暂无'}`,
`世界场景角色档案:\n${storyNpcText || '- 暂无'}`, `世界场景角色档案:\n${storyNpcText || '- 暂无'}`,
`关键场景档案:\n${landmarkText || '- 暂无'}`, `关键场景档案:\n${landmarkText || '- 暂无'}`,

View File

@@ -20,7 +20,6 @@ function createBaseProfile(): CustomWorldProfile {
id: 'schema:test', id: 'schema:test',
worldId: 'CUSTOM', worldId: 'CUSTOM',
schemaVersion: 1, schemaVersion: 1,
schemaName: '测试属性',
generatedFrom: { generatedFrom: {
worldType: WorldType.CUSTOM, worldType: WorldType.CUSTOM,
worldName: '潮雾群岛', worldName: '潮雾群岛',

View File

@@ -47,7 +47,6 @@ const sessionWithPreview: CustomWorldAgentSessionSnapshot = {
id: 'schema:draft:test', id: 'schema:draft:test',
worldId: 'custom:草稿', worldId: 'custom:草稿',
schemaVersion: 1, schemaVersion: 1,
schemaName: '草稿六维',
generatedFrom: { generatedFrom: {
worldType: 'CUSTOM', worldType: 'CUSTOM',
worldName: '只作为 fallback 的本地草稿名', worldName: '只作为 fallback 的本地草稿名',
@@ -59,62 +58,26 @@ const sessionWithPreview: CustomWorldAgentSessionSnapshot = {
{ {
slotId: 'axis_a', slotId: 'axis_a',
name: '稿骨', name: '稿骨',
definition: '草稿承压维度。',
positiveSignals: ['承压'],
negativeSignals: ['虚浮'],
combatUseText: '顶住正面压力。',
socialUseText: '稳住对话姿态。',
explorationUseText: '维持探索状态。',
}, },
{ {
slotId: 'axis_b', slotId: 'axis_b',
name: '稿步', name: '稿步',
definition: '草稿换位维度。',
positiveSignals: ['灵动'],
negativeSignals: ['迟滞'],
combatUseText: '快速换位。',
socialUseText: '顺势接话。',
explorationUseText: '穿越复杂路径。',
}, },
{ {
slotId: 'axis_c', slotId: 'axis_c',
name: '稿识', name: '稿识',
definition: '草稿洞察维度。',
positiveSignals: ['洞察'],
negativeSignals: ['误判'],
combatUseText: '看破破绽。',
socialUseText: '识别隐藏动机。',
explorationUseText: '整理线索。',
}, },
{ {
slotId: 'axis_d', slotId: 'axis_d',
name: '稿魄', name: '稿魄',
definition: '草稿推进维度。',
positiveSignals: ['果断'],
negativeSignals: ['犹疑'],
combatUseText: '推进突破口。',
socialUseText: '关键时刻定调。',
explorationUseText: '面对未知继续前探。',
}, },
{ {
slotId: 'axis_e', slotId: 'axis_e',
name: '稿契', name: '稿契',
definition: '草稿关系维度。',
positiveSignals: ['协同'],
negativeSignals: ['疏离'],
combatUseText: '形成协同收益。',
socialUseText: '建立信任交换。',
explorationUseText: '从关系打开线索。',
}, },
{ {
slotId: 'axis_f', slotId: 'axis_f',
name: '稿澜', name: '稿澜',
definition: '草稿续航维度。',
positiveSignals: ['回稳'],
negativeSignals: ['紊乱'],
combatUseText: '久战不乱。',
socialUseText: '情绪稳定。',
explorationUseText: '长线保持行动力。',
}, },
], ],
}, },

View File

@@ -17,12 +17,6 @@ export type AttributeVector = Record<string, number>;
export interface WorldAttributeSlot { export interface WorldAttributeSlot {
slotId: WorldAttributeSlotId; slotId: WorldAttributeSlotId;
name: string; name: string;
definition: string;
positiveSignals: string[];
negativeSignals: string[];
combatUseText: string;
socialUseText: string;
explorationUseText: string;
} }
export interface WorldAttributeSchema { export interface WorldAttributeSchema {
@@ -36,7 +30,6 @@ export interface WorldAttributeSchema {
tone: string; tone: string;
conflictCore: string; conflictCore: string;
}; };
schemaName?: string;
slots: WorldAttributeSlot[]; slots: WorldAttributeSlot[];
} }
@@ -155,6 +148,5 @@ export interface AttributeSchemaGenerationInput {
} }
export interface AttributeSchemaGenerationOutput { export interface AttributeSchemaGenerationOutput {
schemaName: string;
slots: WorldAttributeSlot[]; slots: WorldAttributeSlot[];
} }