From 0f013b6eeeca13cd0c84032c6dd71afbb3a2d6a1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=AB=98=E7=89=A9?= <253518756@qq.com> Date: Tue, 28 Apr 2026 20:25:37 +0800 Subject: [PATCH] 1 --- docs/audits/README.md | 2 +- docs/audits/engineering/README.md | 2 +- ...D_MIGRATION_COMPLETION_CHECK_2026-04-28.md | 48 +- ...ED_ROLE_ATTRIBUTE_SYSTEM_PRD_2026-04-02.md | 78 +-- .../FUNCTION_SCRIPT_CATALOG_2026-04-04.md | 12 +- ...RLD_ATTRIBUTE_SCHEMA_EDITING_2026-04-26.md | 34 +- ...ORY_ENGINE_BACKEND_MIGRATION_2026-04-28.md | 26 + ..._ATTRIBUTE_SCHEMA_GENERATION_2026-04-26.md | 6 +- .../shared/src/contracts/rpgAgentDraft.ts | 7 - .../src/contracts/rpgCreationFixtures.ts | 37 -- .../src/custom_world_foundation_draft.rs | 95 +-- .../src/prompt/rpg/foundation_draft.rs | 19 +- .../api-server/src/runtime_story/compat.rs | 568 +++++++++++++++++- .../src/runtime_story/compat/tests.rs | 170 ++++++ src/components/AdventureEntityModal.tsx | 3 - src/components/CharacterInfoShared.tsx | 10 +- src/components/CharacterPanel.tsx | 3 - src/components/CustomWorldEntityCatalog.tsx | 18 - .../CustomWorldEntityEditorModal.test.tsx | 66 +- ...ustomWorldCreationHub.interaction.test.tsx | 2 +- .../CustomWorldCreationHub.test.tsx | 2 +- .../CustomWorldCreationStartCard.tsx | 8 +- .../PlatformEntryCreationTypeModal.tsx | 8 +- .../PlatformEntryFlowShellImpl.tsx | 76 ++- .../platformEntryCreationTypes.ts | 15 + .../RpgCreationEntityEditorShared.tsx | 80 +-- .../RpgEntryCharacterSelectView.test.tsx | 36 -- ...gEntryFlowShell.agent.interaction.test.tsx | 213 +------ src/data/attributeResolver.ts | 1 - src/data/attributeValidation.ts | 30 - src/data/buildDamage.ts | 4 - src/data/buildTagAttributeAffinity.ts | 6 - .../flow/campTravelHomeScene.ts | 13 +- .../functionCatalog/functionCatalog.test.ts | 11 +- src/data/npcAttributeInsights.ts | 2 +- src/data/worldAttributeSchemas.ts | 74 --- .../rpg-runtime-story/choiceActions.test.ts | 94 ++- src/hooks/rpg-runtime-story/choiceActions.ts | 35 +- .../rpg-runtime-story/storyChoiceRuntime.ts | 142 ----- src/hooks/useGameFlow.customWorld.test.tsx | 1 - src/services/attributeSchemaGenerator.ts | 59 +- src/services/customWorld.ts | 2 +- src/services/customWorldCover.test.ts | 1 - .../rpgCreationPreviewAdapter.test.ts | 37 -- src/types/attributes.ts | 8 - 45 files changed, 1117 insertions(+), 1047 deletions(-) diff --git a/docs/audits/README.md b/docs/audits/README.md index 374d64c4..d967f955 100644 --- a/docs/audits/README.md +++ b/docs/audits/README.md @@ -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_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_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-19.md](./engineering/ENGINEERING_CLEANUP_AND_BACKEND_BOUNDARY_AUDIT_2026-04-19.md):未引用垃圾、旧入口残留、前后端双份真相与后端迁移项的专项审计。 diff --git a/docs/audits/engineering/README.md b/docs/audits/engineering/README.md index d41e9f58..6a9ddc2a 100644 --- a/docs/audits/engineering/README.md +++ b/docs/audits/engineering/README.md @@ -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) 这一版专项扫描 `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) - 这一版复核 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) 这一版是本轮前端越界逻辑专项审计,专门汇总当前仍应继续迁到 `server-rs` 的运行时、鉴权、生成编排与本地真相残留。 7. [ENGINEERING_DEAD_CODE_CLEANUP_BATCH_D_2026-04-21.md](./ENGINEERING_DEAD_CODE_CLEANUP_BATCH_D_2026-04-21.md) diff --git a/docs/audits/engineering/RPG_FRONTEND_SCRIPT_BACKEND_MIGRATION_COMPLETION_CHECK_2026-04-28.md b/docs/audits/engineering/RPG_FRONTEND_SCRIPT_BACKEND_MIGRATION_COMPLETION_CHECK_2026-04-28.md index 129cdfda..424a1cc5 100644 --- a/docs/audits/engineering/RPG_FRONTEND_SCRIPT_BACKEND_MIGRATION_COMPLETION_CHECK_2026-04-28.md +++ b/docs/audits/engineering/RPG_FRONTEND_SCRIPT_BACKEND_MIGRATION_COMPLETION_CHECK_2026-04-28.md @@ -4,18 +4,18 @@ 本次按 `RPG_FRONTEND_SCRIPT_BACKEND_MIGRATION_AUDIT_2026-04-28.md` 中列出的应迁后端项逐项检查当前代码。 -结论:**应迁移项尚未全部迁移完成。** +结论:**应迁移项已全部迁移完成。** 当前状态: -1. `已完成`:9 项。 -2. `部分完成`:1 项。 +1. `已完成`:10 项。 +2. `部分完成`:0 项。 3. `未发现完全未启动`:0 项。 本轮重新核查的变化: 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. 核验口径 @@ -43,7 +43,7 @@ | `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` 自动保存整份运行时快照 | 已完成 | `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` 正式状态构造函数已删除。 | | `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`。 | @@ -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` 角色资产工坊默认 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: @@ -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。 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`。 -2. `server-rs/crates/api-server/src/runtime_story/compat.rs` 已有 `camp_travel_home_scene` resolver 分支,但当前只清理 encounter,并未承接前端旧分支里的目标场景、encounter preview 与完整离营故事提交。 -3. `src/hooks/rpg-runtime-story/choiceActions.ts` 仍在 `isRpgRuntimeServerFunctionId(...)` 之前判断 `isCampTravelHomeOption(...)`,并调用 `runCampTravelHomeChoice(...)`。 -4. `src/hooks/rpg-runtime-story/storyChoiceRuntime.ts` 的 `runCampTravelHomeChoice(...)` 会在浏览器中决定目标场景、清理战斗/遭遇、递增 `scenesTraveled`、构造 encounter preview,并通过 `commitGeneratedStateWithEncounterEntry(...)` 写入后续故事。 +2. `server-rs/crates/api-server/src/runtime_story/compat.rs` 的 `camp_travel_home_scene` resolver 已承接前端旧分支的正式状态职责:解析目标场景、写入 `currentScenePreset`、清理战斗/遭遇残留、递增 `scenesTraveled`、生成 encounter preview,并让后续故事和持久化继续走后端 snapshot 主链。 +3. 目标场景解析以后端为准:优先接收兼容 payload 中的 `targetSceneId`,其次使用内置角色主场景映射,自定义世界按角色与 landmark 绑定解析,再回退到当前场景前向连接或首个冒险场景。 +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. 这条链不是纯动画表现,而是正式场景迁移、运行时统计、遭遇预览和后续故事提交。 -2. 它已经具备服务端 function id 身份,却没有统一走 `/api/runtime/story/actions/resolve`,因此仍不满足“前端只提交 action,后端返回 hydrated snapshot”的边界。 +1. `camp_travel_home_scene` 不再由浏览器决定目标场景、运行时统计或 encounter preview。 +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 已收口到后端 @@ -156,14 +164,14 @@ 4. 角色资产工坊 modal 中的 prompt 输入框与缓存保存:这是用户正在编辑的 UI 草稿,默认 prompt 和合并规则已由后端 workflow 输出。 5. `playServerBattlePresentation(...)`:只播放临时动画态,最终 `GameState/currentStory` 仍以服务端 snapshot 为准。 -## 6. 下一步建议 +## 6. 后续建议 -推荐按风险顺序继续: +本轮核验范围内的应迁项已经收口。后续建议转为质量维护: -1. 将 `camp_travel_home_scene` 点击链统一改为 `runServerRuntimeChoiceAction(...)` / `/api/runtime/story/actions/resolve`。 -2. 扩展 `server-rs/crates/api-server/src/runtime_story/compat.rs` 中的 `camp_travel_home_scene` resolver,让目标场景、encounter preview、`scenesTraveled`、故事提交和快照持久化全部由后端完成。 -3. 补齐前端测试,锁定 `camp_travel_home_scene` 不再调用 `runCampTravelHomeChoice(...)`;补齐后端 route 级测试,覆盖离营后 hydrated snapshot 字段。 +1. 继续把 function catalog / 旧文档里“本地规则结算”的历史描述逐批改成当前后端归属,避免误导后续开发。 +2. 新增 runtime function 时先补后端 resolver / view / contract,再让前端接展示入口,保持“前端不造正式状态”的边界。 +3. 对仍保留的前端本地 continuation 只允许处理非服务端 function id;凡进入 `SERVER_RUNTIME_FUNCTION_IDS` 的动作都应有 route 级测试。 ## 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 主链;本核验范围内不再保留前端正式状态裁决残留。** diff --git a/docs/prd/AI_NATIVE_UNIFIED_ROLE_ATTRIBUTE_SYSTEM_PRD_2026-04-02.md b/docs/prd/AI_NATIVE_UNIFIED_ROLE_ATTRIBUTE_SYSTEM_PRD_2026-04-02.md index 4ffb2bcd..b18bc353 100644 --- a/docs/prd/AI_NATIVE_UNIFIED_ROLE_ATTRIBUTE_SYSTEM_PRD_2026-04-02.md +++ b/docs/prd/AI_NATIVE_UNIFIED_ROLE_ATTRIBUTE_SYSTEM_PRD_2026-04-02.md @@ -74,7 +74,7 @@ 新系统必须满足: 1. 可解释:玩家能理解“为什么这个角色擅长这个”“为什么这个 NPC 会喜欢这种行为”“为什么这个怪物在这个世界里是这种威胁”。 -2. 可生成:自定义世界可以稳定生成新属性名称与定义。 +2. 可生成:自定义世界可以稳定生成新属性名称。 3. 可校验:AI 输出不能直接裸写进运行时,必须经过本地验证。 4. 可复用:同一套属性 schema 能进入角色、怪物、技能、Build、物品、对话 prompt。 5. 可迁移:能从当前四维属性 / 标签 / 怪物 preset 平滑过渡。 @@ -165,12 +165,6 @@ type WorldAttributeSchema = { slots: Array<{ slotId: 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`。 -2. `name` 是世界内真实显示名称,例如武侠里可能不是“力量”,而是“骨势”“身法”。 -3. `definition` 必须描述角色本质倾向,而不是派生战斗数值。 -4. 每个属性都必须能解释: - - 战斗中的体现 - - 对话中的体现 - - 探索中的体现 +2. 本世界六维名称在创作、提示词输出、解析后保存的数据中只保留 `name`;其他定义、信号和用途说明字段不再进入 schema。 +3. `name` 是世界内真实显示名称,例如武侠里可能不是“力量”,而是“骨势”“身法”。 +4. 六个名称需要能支撑战斗、对话、探索的叙事理解,但这些说明由下游运行时按场景生成,不写入 schema。 ### 禁止项 @@ -247,16 +238,9 @@ type AttributeSchemaGenerationInput = { ```ts type AttributeSchemaGenerationOutput = { - schemaName: string; slots: Array<{ slotId: string; name: string; - definition: string; - positiveSignals: string[]; - negativeSignals: string[]; - combatUseText: string; - socialUseText: string; - explorationUseText: string; }>; }; ``` @@ -267,13 +251,9 @@ AI 输出后必须通过本地校验: 1. 属性数量必须等于 6。 2. `name` 需唯一,长度建议 `2~4` 个中文字符。 -3. `definition` 不得出现“提升攻击力 / 提升防御力 / 提升生命值”这类派生描述。 -4. 每个属性都必须同时具备: - - 一个战斗说明 - - 一个社交说明 - - 一个探索说明 -5. 任意两条属性定义关键词重叠度不能过高。 -6. 若校验失败: +3. `name` 不得出现“生命 / 法力 / 护甲 / 攻击 / 防御 / 力量 / 敏捷 / 智力 / 精神”这类旧四维或派生资源词。 +4. 任意两个属性名称不能重复,也不能只做同义换皮。 +5. 若校验失败: - 预设世界回退到固化 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 type PromptAttributeSummary = { - schemaName: string; slots: Array<{ name: string; - definition: string; }>; actorTopAttributes: string[]; targetTopAttributes?: string[]; @@ -726,12 +704,6 @@ export type AttributeVector = Record; export interface WorldAttributeSlot { slotId: string; name: string; - definition: string; - positiveSignals: string[]; - negativeSignals: string[]; - combatUseText: string; - socialUseText: string; - explorationUseText: string; } export interface WorldAttributeSchema { @@ -1017,8 +989,8 @@ behaviorVectors: Array<{ 对策: -1. 增加本地定义重叠校验 -2. 强制每个属性都写战斗 / 社交 / 探索三种说明 +1. 增加本地名称重复、旧词和同义换皮校验 +2. 提示词要求六个名称覆盖不同叙事气质,避免全部落在同一种行动倾向上 3. 首版预设世界采用固化 schema,不在运行时漂移 ### 风险 2:过度抽象,策划难以配置 @@ -1029,8 +1001,8 @@ behaviorVectors: Array<{ 对策: -1. 每个属性附带定义、正反信号、示例行为 -2. 编辑器永远显示 `name + definition` +1. 编辑器只显示并编辑六个属性名称 +2. 解释文本由技能 / 标签 / 物品等下游按具体场景生成 3. 所有技能 / 标签 / 物品的属性向量都显示人类可读解释 ### 风险 3:旧系统迁移期间双轨并存混乱 diff --git a/docs/reference/FUNCTION_SCRIPT_CATALOG_2026-04-04.md b/docs/reference/FUNCTION_SCRIPT_CATALOG_2026-04-04.md index e422d142..5625b6d4 100644 --- a/docs/reference/FUNCTION_SCRIPT_CATALOG_2026-04-04.md +++ b/docs/reference/FUNCTION_SCRIPT_CATALOG_2026-04-04.md @@ -112,7 +112,7 @@ - `npc_help` 脚本:`src/data/functionCatalog/npc/npcHelp.ts` - 说明:向 NPC 寻求补给、回复或支援的 function。奖励由本地规则稳定计算,避免帮助收益被模型临场漂移。 + 说明:向 NPC 寻求补给、回复或支援的 function。正式奖励、资源变化与 one-shot 状态由后端 runtime action resolver 稳定计算,避免帮助收益被模型临场漂移。 - `npc_chat` 脚本:`src/data/functionCatalog/npc/npcChat.ts` @@ -140,7 +140,7 @@ - `npc_quest_accept` 脚本:`src/data/functionCatalog/npc/npcQuestAccept.ts` - 说明:正式接下 NPC 委托的 function。它把本地生成的任务写入 quest log,并让剧情承接“玩家已经答应处理这件事”。 + 说明:正式接下 NPC 委托的 function。它把后端 pending quest offer 写入 quest log,并让剧情承接“玩家已经答应处理这件事”。 - `npc_quest_turn_in` 脚本:`src/data/functionCatalog/npc/npcQuestTurnIn.ts` @@ -172,7 +172,7 @@ - `camp_travel_home_scene` 脚本:`src/data/functionCatalog/flow/campTravelHomeScene.ts` - 说明:营地开场或同伴交流结束后,正式前往角色主场景的流程项。它负责定制化场景迁移和状态清理,不属于普通 state function。 + 说明:营地开场或同伴交流结束后,正式前往角色主场景的流程项。前端脚本只保留按钮与视觉元信息,目标场景、状态清理、encounter preview、`scenesTraveled` 与快照持久化由后端 runtime action resolver 负责。 - `story_opening_camp_dialogue` 脚本:`src/data/functionCatalog/flow/storyOpeningCampDialogue.ts` @@ -182,7 +182,7 @@ - `inventory_use` 脚本:`src/data/functionCatalog/panel/inventoryUse.ts` - 说明:在背包面板里使用药品、灵力物或 build buff 物品的 function。它先由本地规则结算资源变化,再把结果记入故事历史。 + 说明:在背包面板里使用药品、灵力物或 build buff 物品的 function。前端只提交物品动作,资源变化、数量扣减、build buff 与故事历史由后端 resolver 写入。 - `equipment_equip` 脚本:`src/data/functionCatalog/panel/equipmentEquip.ts` @@ -190,7 +190,7 @@ - `equipment_unequip` 脚本:`src/data/functionCatalog/panel/equipmentUnequip.ts` - 说明:从装备槽位卸下物品的 function。它确保卸装结果由本地规则严格处理,不会破坏背包数量和 loadout 一致性。 + 说明:从装备槽位卸下物品的 function。后端 resolver 负责卸装结果、背包数量和 loadout 一致性。 - `forge_craft` 脚本:`src/data/functionCatalog/panel/forgeCraft.ts` @@ -198,7 +198,7 @@ - `forge_dismantle` 脚本:`src/data/functionCatalog/panel/forgeDismantle.ts` - 说明:在锻造面板中拆解物品回收材料的 function。拆解产出由本地锻造规则控制,避免与物品设计脱节。 + 说明:在锻造面板中拆解物品回收材料的 function。拆解产出由后端锻造 resolver 控制,避免与物品设计脱节。 - `forge_reforge` 脚本:`src/data/functionCatalog/panel/forgeReforge.ts` diff --git a/docs/technical/RPG_CREATION_WORLD_ATTRIBUTE_SCHEMA_EDITING_2026-04-26.md b/docs/technical/RPG_CREATION_WORLD_ATTRIBUTE_SCHEMA_EDITING_2026-04-26.md index ea8827e6..9f48cfa0 100644 --- a/docs/technical/RPG_CREATION_WORLD_ATTRIBUTE_SCHEMA_EDITING_2026-04-26.md +++ b/docs/technical/RPG_CREATION_WORLD_ATTRIBUTE_SCHEMA_EDITING_2026-04-26.md @@ -6,44 +6,36 @@ 统一角色属性系统把一个世界中“角色能力如何被理解”收口到 `CustomWorldProfile.attributeSchema.slots`。这六个 slot 是世界级设定,不是单个角色自己的六个字段。 -当前结果页世界页可以展示角色维度,但编辑世界信息时只能修改世界名称、概述、基调、目标等文本,尚不能手动修订六个维度本身的信息。 +当前结果页世界页可以展示角色维度。旧方案曾允许编辑维度定义、正负信号和战斗/社交/探索用途,但这些字段会让创作和提示词下游过早背负规则说明。本轮收缩为只允许修订六个维度名称。 ## 2. 本次目标 -在“编辑世界信息”独立面板中允许用户编辑六个角色维度的信息: +在“编辑基本设定”独立面板中允许用户编辑六个角色维度名称: -1. 修改 `attributeSchema.slots` 中每个维度的 `name`、`definition`、`positiveSignals`、`negativeSignals`、`combatUseText`、`socialUseText`、`explorationUseText`。 +1. 只修改 `attributeSchema.slots` 中每个维度的 `name`。 2. 不在可扮演角色或场景角色编辑器中新增单角色六维数值编辑。 3. 保存时同步更新 `profile.attributeSchema`。 4. 若 `profile.ownedSettingLayers.ruleProfile.attributeSchema` 存在,同步写入同一份 schema,避免世界档案和设定层出现双源漂移。 -5. 前端只负责编辑结构化文本,不新增属性结算逻辑。 +5. 前端只负责编辑名称,不新增属性结算逻辑,也不再保存维度说明、正负信号和用途文本。 ## 3. 交互设计 入口位置: -- 世界页点击“世界概述”里的编辑按钮 -- 打开现有“编辑世界信息”面板 +- 世界页点击“基本设定”里的编辑按钮 +- 打开现有“编辑基本设定”面板 - 在基础世界文本字段下方增加“角色维度”区块 -每个维度展示并允许编辑: - -- 维度名称 -- 定义 -- 正向信号 -- 负向信号 -- 战斗体现 -- 社交体现 -- 探索体现 - -正向信号与负向信号使用逗号、中文逗号或换行拆分成数组。 +每个维度只展示并允许编辑“维度名称”。 ## 4. 数据落点 保存路径: -- `profile.attributeSchema.slots[n]` -- `profile.ownedSettingLayers.ruleProfile.attributeSchema.slots[n]`,仅当 `ownedSettingLayers` 已存在时同步 +- `profile.attributeSchema.slots[n].name` +- `profile.ownedSettingLayers.ruleProfile.attributeSchema.slots[n].name`,仅当 `ownedSettingLayers` 已存在时同步 + +系统仍保留 `slotId` 作为稳定键,解析旧草稿时会丢弃旧 `definition`、`positiveSignals`、`negativeSignals`、`combatUseText`、`socialUseText`、`explorationUseText` 字段。 不修改: @@ -52,7 +44,7 @@ ## 5. 验收 -1. 世界信息面板能看到六个角色维度。 -2. 修改任一维度名称、定义、信号或三类用途说明后,保存到 `profile.attributeSchema.slots`。 +1. 基本设定面板能看到六个角色维度名称。 +2. 修改任一维度名称后,保存到 `profile.attributeSchema.slots`,且不会写回旧说明字段。 3. 编辑角色自身时不出现单角色六维数值输入区。 4. UI 仍读取当前世界 schema,不回退写死旧四维文案。 diff --git a/docs/technical/RPG_RUNTIME_STORY_ENGINE_BACKEND_MIGRATION_2026-04-28.md b/docs/technical/RPG_RUNTIME_STORY_ENGINE_BACKEND_MIGRATION_2026-04-28.md index dc06ebb6..73287dbe 100644 --- a/docs/technical/RPG_RUNTIME_STORY_ENGINE_BACKEND_MIGRATION_2026-04-28.md +++ b/docs/technical/RPG_RUNTIME_STORY_ENGINE_BACKEND_MIGRATION_2026-04-28.md @@ -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` 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` + +### 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(...)`。 diff --git a/docs/technical/RPG_WORLD_DRAFT_ATTRIBUTE_SCHEMA_GENERATION_2026-04-26.md b/docs/technical/RPG_WORLD_DRAFT_ATTRIBUTE_SCHEMA_GENERATION_2026-04-26.md index 7d01b30c..f710e4bd 100644 --- a/docs/technical/RPG_WORLD_DRAFT_ATTRIBUTE_SCHEMA_GENERATION_2026-04-26.md +++ b/docs/technical/RPG_WORLD_DRAFT_ATTRIBUTE_SCHEMA_GENERATION_2026-04-26.md @@ -7,7 +7,7 @@ RPG Agent 生成世界草稿时,前端会把 `draftProfile` 归一化成 `Cust ## 落地约束 - `draftProfile.attributeSchema` 是世界草稿真相源的一部分,必须随 foundation draft 一起生成并保存。 -- 六维固定使用 `axis_a` 到 `axis_f` 六个槽位,但 `schemaName`、每个槽位 `name` 和说明必须贴合本次世界设定。 +- 六维固定使用 `axis_a` 到 `axis_f` 六个系统槽位,但创作、提示词输出、解析后保存的数据只保留每个槽位的 `name`。`slotId` 由系统补齐用于数值映射,不要求模型理解或生成额外说明字段。 - 维度名不得沿用通用旧词:生命、法力、护甲、攻击、防御、力量、敏捷、智力、精神。 - 若模型遗漏或结构不合规,后端必须生成中文兜底属性体系,不能让前端只靠固定模板补齐。 - 世界页面的“世界”页签必须展示当前 `attributeSchema.slots` 的六个名称,作为玩家进入世界前可见的规则信号。 @@ -25,7 +25,7 @@ RPG Agent 生成世界草稿时,前端会把 `draftProfile` 归一化成 `Cust 3. `server-rs/crates/api-server/src/custom_world_foundation_draft.rs` - `normalize_framework_shape()` 归一化 `attributeSchema`。 - `build_foundation_draft_profile_from_framework()` 将归一化后的 `attributeSchema` 写入 `draftProfile`。 - - 新增兜底生成器,基于世界名、基调、目标、冲突和种子文本生成六个中文维度。 + - 新增兜底生成器,基于世界名、基调、目标、冲突和种子文本生成六个中文维度名称。 4. `src/components/CustomWorldEntityCatalog.tsx` - 在世界页签增加“角色维度”区域,直接渲染 `profile.attributeSchema.slots` 的六个名称。 @@ -33,5 +33,5 @@ RPG Agent 生成世界草稿时,前端会把 `draftProfile` 归一化成 `Cust ## 验收 - 新生成的 RPG 世界草稿 JSON 顶层包含 `attributeSchema.slots.length === 6`。 -- 结果页/世界页展示六个自定义维度名,而非固定的力量、敏捷、智力、精神。 +- 结果页/世界页展示六个自定义维度名,而非固定的力量、敏捷、智力、精神;页面不展示维度说明、正负信号或用途说明。 - 缺失或非法模型输出会被后端兜底为合法中文六维。 diff --git a/packages/shared/src/contracts/rpgAgentDraft.ts b/packages/shared/src/contracts/rpgAgentDraft.ts index fa92f66d..dcf52e93 100644 --- a/packages/shared/src/contracts/rpgAgentDraft.ts +++ b/packages/shared/src/contracts/rpgAgentDraft.ts @@ -130,12 +130,6 @@ export interface RpgAgentFoundationDraftCamp { export interface RpgAgentWorldAttributeSlot { slotId: 'axis_a' | 'axis_b' | 'axis_c' | 'axis_d' | 'axis_e' | 'axis_f'; name: string; - definition: string; - positiveSignals: string[]; - negativeSignals: string[]; - combatUseText: string; - socialUseText: string; - explorationUseText: string; } export interface RpgAgentWorldAttributeSchema { @@ -149,7 +143,6 @@ export interface RpgAgentWorldAttributeSchema { tone: string; conflictCore: string; }; - schemaName?: string; slots: RpgAgentWorldAttributeSlot[]; } diff --git a/packages/shared/src/contracts/rpgCreationFixtures.ts b/packages/shared/src/contracts/rpgCreationFixtures.ts index d6ce2fd8..913216a8 100644 --- a/packages/shared/src/contracts/rpgCreationFixtures.ts +++ b/packages/shared/src/contracts/rpgCreationFixtures.ts @@ -182,7 +182,6 @@ export function createRpgAgentFoundationDraftProfileFixture(): RpgAgentFoundatio id: 'schema:rpg-agent:tide-fixture', worldId: 'custom:潮雾列岛', schemaVersion: 1, - schemaName: '潮雾六脉', generatedFrom: { worldType: 'CUSTOM', worldName: '潮雾列岛', @@ -194,62 +193,26 @@ export function createRpgAgentFoundationDraftProfileFixture(): RpgAgentFoundatio { 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: '在漫长远行与恶劣天气里保有余力。', }, ], }, diff --git a/server-rs/crates/api-server/src/custom_world_foundation_draft.rs b/server-rs/crates/api-server/src/custom_world_foundation_draft.rs index 83a5dbe2..d52a7748 100644 --- a/server-rs/crates/api-server/src/custom_world_foundation_draft.rs +++ b/server-rs/crates/api-server/src/custom_world_foundation_draft.rs @@ -870,24 +870,6 @@ fn normalize_world_attribute_schema( normalized_slots.push(json!({ "slotId": slot_id, "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) .filter(|value| *value > 0) .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": { "worldType": "CUSTOM", "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), "worldId": format!("custom:{world_name}"), "schemaVersion": 1, - "schemaName": build_attribute_schema_name(framework, setting_text), "generatedFrom": { "worldType": "CUSTOM", "worldName": world_name, @@ -954,35 +932,20 @@ fn build_fallback_world_attribute_schema(framework: &JsonValue, setting_text: &s "conflictCore": conflict_core, }, "slots": [ - build_attribute_slot("axis_a", format!("{prefix}骨"), format!("承受{prefix}压、正面冲击与长期消耗的底子。"), ["承压", "稳阵"], ["虚浮", "易散"], "顶住正面压力并守住行动空间。", "在强压场面里保持可信和稳固。", "穿过危险环境时维持身体与装备状态。"), - build_attribute_slot("axis_b", format!("{prefix_alt}步"), format!("顺应{prefix_alt}势、换位穿行与抢占时机的能力。"), ["借势", "轻快"], ["迟滞", "失位"], "切线换位、闪避、追击和抢先手。", "反应灵活,能顺势调整话术。", "穿越复杂地形、封锁线与危险通路。"), - build_attribute_slot("axis_c", format!("{prefix}识"), "看清局势、线索、虚实与隐藏代价的能力。", ["洞察", "辨伪"], ["误读", "迟钝"], "识破破绽并判断战局变化。", "听出隐瞒、试探与交换空间。", "整理线索、辨认路径并推断风险。"), - build_attribute_slot("axis_d", format!("{prefix_alt}魄"), "在高压变化里仍能推进目标和拍板的胆气。", ["果断", "压前"], ["犹疑", "退缩"], "顶着高压窗口推进突破口。", "在谈判或对峙中定调。", "面对未知异象仍敢继续前探。"), - build_attribute_slot("axis_e", format!("{prefix}契"), "与人、物、誓约、地方关系建立牵引的能力。", ["协同", "守诺"], ["疏离", "失信"], "借同伴协同与牵制形成连锁。", "安抚、结盟、交换与维系信任。", "从人情、传闻和旧物中打开线索。"), - build_attribute_slot("axis_f", format!("回{prefix_alt}"), "在长线消耗和局势反复中回稳节奏的能力。", ["回稳", "续航"], ["紊乱", "断续"], "久战不乱,把节奏重新拉回手里。", "情绪稳定,不轻易被带偏。", "在漫长探索与恶劣环境里保有余力。"), + build_attribute_slot("axis_a", format!("{prefix}骨")), + build_attribute_slot("axis_b", format!("{prefix_alt}步")), + build_attribute_slot("axis_c", format!("{prefix}识")), + build_attribute_slot("axis_d", format!("{prefix_alt}魄")), + build_attribute_slot("axis_e", format!("{prefix}契")), + build_attribute_slot("axis_f", format!("回{prefix_alt}")), ], }) } -fn build_attribute_slot( - slot_id: &str, - name: String, - definition: impl Into, - positive_signals: [&str; 2], - negative_signals: [&str; 2], - combat_use_text: &str, - social_use_text: &str, - exploration_use_text: &str, -) -> JsonValue { +fn build_attribute_slot(slot_id: &str, name: String) -> JsonValue { json!({ "slotId": slot_id, "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 { let mut terms = Vec::new(); let chinese_chars = source @@ -1062,12 +1011,6 @@ fn is_invalid_attribute_name(name: &str, seen_names: &[String]) -> bool { .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, key: &str) -> Option { map.get(key) .and_then(JsonValue::as_str) @@ -1076,18 +1019,6 @@ fn json_map_text(map: &JsonMap, key: &str) -> Option .map(ToOwned::to_owned) } -fn json_map_string_array(map: &JsonMap, key: &str) -> Option> { - 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::>(); - if items.is_empty() { None } else { Some(items) } -} - fn first_json_string(value: &JsonValue, key: &str) -> Option { value .get(key) @@ -2492,7 +2423,7 @@ mod tests { request_capture.clone(), vec![ 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( r#"{"playableNpcs":[{"name":"岑灯","title":"返乡守灯人","role":"主角代理","description":"追查旧案的人","visualDescription":"灰蓝旧灯披风压着海盐痕,腰侧挂旧海图筒和短灯杖。","actionDescription":"举灯照海图,短杖点地辨认潮声。","sceneVisualDescription":"旧灯塔回廊被海雾压低,墙上挂满潮湿航线图。","initialAffinity":24,"relationshipHooks":["旧案牵连"],"tags":["守灯人"]}]}"#, @@ -2595,6 +2526,16 @@ mod tests { .and_then(JsonValue::as_str), 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!( draft_profile .get("worldHook") diff --git a/server-rs/crates/api-server/src/prompt/rpg/foundation_draft.rs b/server-rs/crates/api-server/src/prompt/rpg/foundation_draft.rs index ac1c8565..945581f5 100644 --- a/server-rs/crates/api-server/src/prompt/rpg/foundation_draft.rs +++ b/server-rs/crates/api-server/src/prompt/rpg/foundation_draft.rs @@ -21,14 +21,13 @@ pub(crate) fn build_custom_world_framework_prompt(setting_text: &str) -> String " \"majorFactions\": [\"势力甲\", \"势力乙\"],".to_string(), " \"coreConflicts\": [\"冲突甲\", \"冲突乙\"],".to_string(), " \"attributeSchema\": {".to_string(), - " \"schemaName\": \"本世界六维名称\",".to_string(), " \"slots\": [".to_string(), - " { \"slotId\": \"axis_a\", \"name\": \"维度名\", \"definition\": \"维度定义\", \"positiveSignals\": [\"正向表现\"], \"negativeSignals\": [\"负向表现\"], \"combatUseText\": \"战斗用途\", \"socialUseText\": \"社交用途\", \"explorationUseText\": \"探索用途\" },".to_string(), - " { \"slotId\": \"axis_b\", \"name\": \"维度名\", \"definition\": \"维度定义\", \"positiveSignals\": [\"正向表现\"], \"negativeSignals\": [\"负向表现\"], \"combatUseText\": \"战斗用途\", \"socialUseText\": \"社交用途\", \"explorationUseText\": \"探索用途\" },".to_string(), - " { \"slotId\": \"axis_c\", \"name\": \"维度名\", \"definition\": \"维度定义\", \"positiveSignals\": [\"正向表现\"], \"negativeSignals\": [\"负向表现\"], \"combatUseText\": \"战斗用途\", \"socialUseText\": \"社交用途\", \"explorationUseText\": \"探索用途\" },".to_string(), - " { \"slotId\": \"axis_d\", \"name\": \"维度名\", \"definition\": \"维度定义\", \"positiveSignals\": [\"正向表现\"], \"negativeSignals\": [\"负向表现\"], \"combatUseText\": \"战斗用途\", \"socialUseText\": \"社交用途\", \"explorationUseText\": \"探索用途\" },".to_string(), - " { \"slotId\": \"axis_e\", \"name\": \"维度名\", \"definition\": \"维度定义\", \"positiveSignals\": [\"正向表现\"], \"negativeSignals\": [\"负向表现\"], \"combatUseText\": \"战斗用途\", \"socialUseText\": \"社交用途\", \"explorationUseText\": \"探索用途\" },".to_string(), - " { \"slotId\": \"axis_f\", \"name\": \"维度名\", \"definition\": \"维度定义\", \"positiveSignals\": [\"正向表现\"], \"negativeSignals\": [\"负向表现\"], \"combatUseText\": \"战斗用途\", \"socialUseText\": \"社交用途\", \"explorationUseText\": \"探索用途\" }".to_string(), + " { \"name\": \"维度名\" },".to_string(), + " { \"name\": \"维度名\" },".to_string(), + " { \"name\": \"维度名\" },".to_string(), + " { \"name\": \"维度名\" },".to_string(), + " { \"name\": \"维度名\" },".to_string(), + " { \"name\": \"维度名\" }".to_string(), " ]".to_string(), " },".to_string(), " \"camp\": {".to_string(), @@ -45,9 +44,9 @@ pub(crate) fn build_custom_world_framework_prompt(setting_text: &str) -> String "- camp 只表示玩家开局时的落脚处占位,更接近归舍、住处、栖居、前哨居所这类“家/归处”的概念;不要在这一步生成开局场景任务、三幕事件或三幕背景。".to_string(), "- 不要输出 playableNpcs、storyNpcs、landmarks、items,也不要输出任何角色和地图细节。".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(), - "- 每个属性维度definition都要像RPG游戏属性名,同时能服务战斗、社交、探索三种场景,definition、combatUseText、socialUseText、explorationUseText 必须贴合本世界主题。".to_string(), + "- 不要在 attributeSchema.slots 内输出 definition、positiveSignals、negativeSignals、combatUseText、socialUseText、explorationUseText 或其他说明字段。".to_string(), "- 世界设定必须直接源自玩家输入,不要脱离主题乱扩写。".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(), @@ -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。", "不要输出 playableNpcs、storyNpcs、landmarks、items 或任何其他字段。", "majorFactions 与 coreConflicts 必须是字符串数组。", - "attributeSchema 必须是对象,且包含 schemaName 与 slots;slots 必须恰好 6 个,slotId 固定为 axis_a 到 axis_f。", + "attributeSchema 必须是对象,且只包含 slots;slots 必须恰好 6 个,每个 slot 只保留 name。", "camp 必须是对象,且只包含:name、description。", "原始文本:", response_text.trim(), diff --git a/server-rs/crates/api-server/src/runtime_story/compat.rs b/server-rs/crates/api-server/src/runtime_story/compat.rs index d2181cd5..0df019d7 100644 --- a/server-rs/crates/api-server/src/runtime_story/compat.rs +++ b/server-rs/crates/api-server/src/runtime_story/compat.rs @@ -678,22 +678,7 @@ fn resolve_runtime_story_choice_action( 2, "你把眼前局势先讲清楚,对方终于愿意把第一轮判断说出口。", ), - "camp_travel_home_scene" => { - clear_encounter_state(game_state); - Ok(StoryResolution { - action_text: resolve_action_text("返回营地", request), - result_text: "你主动结束了当前遭遇,把节奏带回了更安全的营地。".to_string(), - story_text: None, - presentation_options: None, - saved_current_story: None, - patches: vec![ - build_status_patch(game_state), - RuntimeStoryPatch::EncounterChanged { encounter_id: None }, - ], - battle: None, - toast: None, - }) - } + "camp_travel_home_scene" => resolve_camp_travel_home_scene_action(game_state, request), "idle_call_out" => Ok(simple_story_resolution( game_state, 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 { + 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 { + 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 { + // 中文注释:旧前端如果补传 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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::>(); + 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 { + 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 { + 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 { + 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 { + [ + 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 { let current_scene = read_object_field(game_state, "currentScenePreset")?; let current_scene_id = read_optional_string_field(current_scene, "id"); diff --git a/server-rs/crates/api-server/src/runtime_story/compat/tests.rs b/server-rs/crates/api-server/src/runtime_story/compat/tests.rs index 23b0299b..2b7b60ae 100644 --- a/server-rs/crates/api-server/src/runtime_story/compat/tests.rs +++ b/server-rs/crates/api-server/src/runtime_story/compat/tests.rs @@ -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] fn runtime_story_npc_help_is_one_shot_and_restores_resources() { let request = RuntimeStoryActionRequest { diff --git a/src/components/AdventureEntityModal.tsx b/src/components/AdventureEntityModal.tsx index fb5e9a77..81fb8c2a 100644 --- a/src/components/AdventureEntityModal.tsx +++ b/src/components/AdventureEntityModal.tsx @@ -1383,9 +1383,6 @@ export function AdventureEntityModal({ )} -
- {attribute.definition} -
))} diff --git a/src/components/CharacterInfoShared.tsx b/src/components/CharacterInfoShared.tsx index d07c98ba..424d5511 100644 --- a/src/components/CharacterInfoShared.tsx +++ b/src/components/CharacterInfoShared.tsx @@ -331,7 +331,7 @@ export function CharacterAttributeGrid({ boostedCombatStats, resourceLabels, ) - : slot.combatUseText, + : '', }; }); @@ -364,9 +364,11 @@ export function CharacterAttributeGrid({ -
- {effectText} -
+ {effectText ? ( +
+ {effectText} +
+ ) : null} ), )} diff --git a/src/components/CharacterPanel.tsx b/src/components/CharacterPanel.tsx index aef98529..20bc6383 100644 --- a/src/components/CharacterPanel.tsx +++ b/src/components/CharacterPanel.tsx @@ -561,9 +561,6 @@ export function CharacterPanel({ )} -
- {attribute.definition} -
))} diff --git a/src/components/CustomWorldEntityCatalog.tsx b/src/components/CustomWorldEntityCatalog.tsx index 3acaca42..681948db 100644 --- a/src/components/CustomWorldEntityCatalog.tsx +++ b/src/components/CustomWorldEntityCatalog.tsx @@ -545,16 +545,6 @@ function buildLandmarkSearchText( ].join(' '); } -function buildAttributeSlotSummary( - slot: CustomWorldProfile['attributeSchema']['slots'][number], -) { - return compactTextList([ - slot.combatUseText, - slot.socialUseText, - slot.explorationUseText, - ]).join(' / '); -} - export function CustomWorldEntityCatalog({ profile, previewCharacters, @@ -984,11 +974,6 @@ export function CustomWorldEntityCatalog({
角色维度
- {profile.attributeSchema?.schemaName ? ( -
- {profile.attributeSchema.schemaName} -
- ) : null}
{attributeSlots.map((slot) => ( @@ -999,9 +984,6 @@ export function CustomWorldEntityCatalog({
{slot.name}
-
- {buildAttributeSlotSummary(slot) || slot.definition} -
))} diff --git a/src/components/CustomWorldEntityEditorModal.test.tsx b/src/components/CustomWorldEntityEditorModal.test.tsx index 4493dc2a..a1a3d61a 100644 --- a/src/components/CustomWorldEntityEditorModal.test.tsx +++ b/src/components/CustomWorldEntityEditorModal.test.tsx @@ -2,7 +2,6 @@ import { cleanup, - fireEvent, render, screen, waitFor, @@ -198,7 +197,6 @@ function createProfile(): CustomWorldProfile { attributeSchema: { id: 'schema-1', worldId: 'world-1', - schemaName: '潮雾六维', schemaVersion: 1, generatedFrom: { worldType: 'WUXIA', @@ -211,62 +209,26 @@ function createProfile(): CustomWorldProfile { { 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: '长线跋涉。', }, ], }, @@ -753,7 +715,7 @@ test('基本设定目标打开独立编辑面板', () => { expect(screen.queryByText('编辑世界信息')).toBeNull(); }); -test('世界信息面板可以编辑六个角色维度信息', async () => { +test('基本设定面板只编辑六个角色维度名称', async () => { const user = userEvent.setup(); const savedProfileRef: { current: CustomWorldProfile | null } = { current: null, @@ -762,7 +724,7 @@ test('世界信息面板可以编辑六个角色维度信息', async () => { render( {}} onProfileChange={(profile) => { savedProfileRef.current = profile; @@ -775,33 +737,15 @@ test('世界信息面板可以编辑六个角色维度信息', async () => { await user.clear(nameInputs[0]!); await user.type(nameInputs[0]!, '潮骨'); - const definitionFields = screen.getAllByLabelText('定义'); - await user.clear(definitionFields[0]!); - await user.type(definitionFields[0]!, '顶住潮压并正面推进的角色底色。'); - - const positiveSignalFields = screen.getAllByLabelText('正向信号'); - fireEvent.change(positiveSignalFields[0]!, { - target: { value: '硬顶, 护阵' }, - }); - - const combatFields = screen.getAllByLabelText('战斗体现'); - await user.clear(combatFields[0]!); - await user.type(combatFields[0]!, '正面压线与护住阵脚。'); + expect(screen.queryByLabelText('定义')).toBeNull(); + expect(screen.queryByLabelText('正向信号')).toBeNull(); + expect(screen.queryByLabelText('战斗体现')).toBeNull(); await user.click(screen.getByRole('button', { name: /保存修改/u })); 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 () => { diff --git a/src/components/custom-world-home/CustomWorldCreationHub.interaction.test.tsx b/src/components/custom-world-home/CustomWorldCreationHub.interaction.test.tsx index 614cd559..1741438e 100644 --- a/src/components/custom-world-home/CustomWorldCreationHub.interaction.test.tsx +++ b/src/components/custom-world-home/CustomWorldCreationHub.interaction.test.tsx @@ -55,8 +55,8 @@ test('creation hub reflects updated draft title summary and counts after rerende expect(screen.getByText('角色 3')).toBeTruthy(); expect(screen.getByText('地点 4')).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.queryByRole('button', { name: /大鱼吃小鱼/u })).toBeNull(); rerender( { expect(html).toContain('玩家是失职返乡的守灯人'); expect(html).toContain('守灯会与沉船商盟争夺航道解释权'); expect(html).toContain('角色扮演 RPG'); - expect(html).toContain('大鱼吃小鱼'); expect(html).toContain('拼图玩法'); + expect(html).not.toContain('大鱼吃小鱼'); }); test('creation hub renders puzzle works in the same unified list with puzzle tag', () => { diff --git a/src/components/custom-world-home/CustomWorldCreationStartCard.tsx b/src/components/custom-world-home/CustomWorldCreationStartCard.tsx index ce9a5012..0601f53d 100644 --- a/src/components/custom-world-home/CustomWorldCreationStartCard.tsx +++ b/src/components/custom-world-home/CustomWorldCreationStartCard.tsx @@ -1,7 +1,7 @@ import { ArrowRight } from 'lucide-react'; import { - PLATFORM_CREATION_TYPES, + getVisiblePlatformCreationTypes, type PlatformCreationTypeId, } from '../platform-entry/platformEntryCreationTypes'; @@ -16,6 +16,10 @@ export function CustomWorldCreationStartCard({ error = null, onCreateType, }: CustomWorldCreationStartCardProps) { + // 创作首页首屏卡带与创作类型弹层保持同一份展示口径, + // 避免某个玩法只在其中一个入口被隐藏而出现状态漂移。 + const visibleCreationTypes = getVisiblePlatformCreationTypes(); + return ( // 移动端限制模块高度,模板入口改为横向滚动,避免挤占作品列表首屏空间。
@@ -34,7 +38,7 @@ export function CustomWorldCreationStartCard({
- {PLATFORM_CREATION_TYPES.map((item) => { + {visibleCreationTypes.map((item) => { const disabled = item.locked || busy; return ( diff --git a/src/components/platform-entry/PlatformEntryCreationTypeModal.tsx b/src/components/platform-entry/PlatformEntryCreationTypeModal.tsx index 2ab34ca1..735a21c7 100644 --- a/src/components/platform-entry/PlatformEntryCreationTypeModal.tsx +++ b/src/components/platform-entry/PlatformEntryCreationTypeModal.tsx @@ -1,7 +1,7 @@ import { ArrowRight } from 'lucide-react'; import { UnifiedModal } from '../common/UnifiedModal'; -import { PLATFORM_CREATION_TYPES } from './platformEntryCreationTypes'; +import { getVisiblePlatformCreationTypes } from './platformEntryCreationTypes'; export interface PlatformEntryCreationTypeModalProps { isOpen: boolean; @@ -14,7 +14,7 @@ export interface PlatformEntryCreationTypeModalProps { } function CreationTypeCard(props: { - item: (typeof PLATFORM_CREATION_TYPES)[number]; + item: ReturnType[number]; busy: boolean; onSelect: () => void; }) { @@ -81,9 +81,7 @@ export function PlatformEntryCreationTypeModal({ // 平台入口只渲染当前允许展示的创作类型; // 被隐藏的玩法仍保留既有实现与路由,不在这里删除能力本体。 - const visibleCreationTypes = PLATFORM_CREATION_TYPES.filter( - (item) => !item.hidden, - ); + const visibleCreationTypes = getVisiblePlatformCreationTypes(); return ( (null); + const isBigFishCreationVisible = isPlatformCreationTypeVisible('big-fish'); const hadReadableProtectedDataRef = useRef(false); const hasInitialAgentSession = Boolean( readCustomWorldAgentUiState().activeSessionId && @@ -660,7 +662,9 @@ export function PlatformEntryFlowShellImpl({ await Promise.allSettled([ platformBootstrap.refreshPublishedGallery(), platformBootstrap.refreshCustomWorldWorks(), - refreshBigFishGallery(), + isBigFishCreationVisible + ? refreshBigFishGallery() + : Promise.resolve([] as BigFishWorkSummary[]), refreshPuzzleGallery(), ]); return latestSession; @@ -716,9 +720,9 @@ export function PlatformEntryFlowShellImpl({ }, [agentResultPreview]); const featuredGalleryEntries = useMemo(() => { - const bigFishPublicEntries = bigFishGalleryEntries.map( - mapBigFishWorkToPlatformGalleryCard, - ); + const bigFishPublicEntries = isBigFishCreationVisible + ? bigFishGalleryEntries.map(mapBigFishWorkToPlatformGalleryCard) + : []; const puzzlePublicEntries = puzzleGalleryEntries.map( mapPuzzleWorkToPlatformGalleryCard, ); @@ -727,6 +731,7 @@ export function PlatformEntryFlowShellImpl({ [...bigFishPublicEntries, ...puzzlePublicEntries], ).slice(0, 6); }, [ + isBigFishCreationVisible, bigFishGalleryEntries, platformBootstrap.publishedGalleryEntries, puzzleGalleryEntries, @@ -736,11 +741,14 @@ export function PlatformEntryFlowShellImpl({ mergePlatformPublicGalleryEntries( platformBootstrap.publishedGalleryEntries, [ - ...bigFishGalleryEntries.map(mapBigFishWorkToPlatformGalleryCard), + ...(isBigFishCreationVisible + ? bigFishGalleryEntries.map(mapBigFishWorkToPlatformGalleryCard) + : []), ...puzzleGalleryEntries.map(mapPuzzleWorkToPlatformGalleryCard), ], ), [ + isBigFishCreationVisible, bigFishGalleryEntries, platformBootstrap.publishedGalleryEntries, puzzleGalleryEntries, @@ -986,7 +994,6 @@ export function PlatformEntryFlowShellImpl({ const bigFishError = bigFishFlow.error; const setBigFishError = bigFishFlow.setError; const isBigFishBusy = bigFishFlow.isBusy; - const setIsBigFishBusy = bigFishFlow.setIsBusy; const streamingBigFishReplyText = bigFishFlow.streamingReplyText; const isStreamingBigFishReply = bigFishFlow.isStreamingReply; @@ -1892,10 +1899,17 @@ export function PlatformEntryFlowShellImpl({ useEffect(() => { if (selectionStage === 'platform') { - void refreshBigFishGallery(); + if (isBigFishCreationVisible) { + void refreshBigFishGallery(); + } void refreshPuzzleGallery(); } - }, [refreshBigFishGallery, refreshPuzzleGallery, selectionStage]); + }, [ + isBigFishCreationVisible, + refreshBigFishGallery, + refreshPuzzleGallery, + selectionStage, + ]); useEffect(() => { if ( @@ -1914,6 +1928,7 @@ export function PlatformEntryFlowShellImpl({ useEffect(() => { if ( + isBigFishCreationVisible && (platformBootstrap.platformTab === 'create' || selectionStage === 'platform') && platformBootstrap.canReadProtectedData @@ -1921,6 +1936,7 @@ export function PlatformEntryFlowShellImpl({ void refreshBigFishShelf(); } }, [ + isBigFishCreationVisible, platformBootstrap.canReadProtectedData, platformBootstrap.platformTab, refreshBigFishShelf, @@ -1955,7 +1971,9 @@ export function PlatformEntryFlowShellImpl({ resolveRpgCreationErrorMessage(error, '读取创作作品列表失败。'), ); }); - void refreshBigFishShelf(); + if (isBigFishCreationVisible) { + void refreshBigFishShelf(); + } void refreshPuzzleShelf(); }} createError={ @@ -1991,20 +2009,32 @@ export function PlatformEntryFlowShellImpl({ handleExperienceRpgWork(item); }} rpgLibraryEntries={platformBootstrap.savedCustomWorldEntries} - bigFishItems={bigFishWorks} - onOpenBigFishDetail={(item) => { - runProtectedAction(() => { - void openBigFishDraft(item); - }); - }} - onExperienceBigFish={(item) => { - runProtectedAction(() => { - void startBigFishRunFromWork(item); - }); - }} - onDeleteBigFish={(item) => { - handleDeleteBigFishWork(item); - }} + bigFishItems={isBigFishCreationVisible ? bigFishWorks : []} + onOpenBigFishDetail={ + isBigFishCreationVisible + ? (item) => { + runProtectedAction(() => { + void openBigFishDraft(item); + }); + } + : undefined + } + onExperienceBigFish={ + isBigFishCreationVisible + ? (item) => { + runProtectedAction(() => { + void startBigFishRunFromWork(item); + }); + } + : null + } + onDeleteBigFish={ + isBigFishCreationVisible + ? (item) => { + handleDeleteBigFishWork(item); + } + : null + } puzzleItems={puzzleWorks} onOpenPuzzleDetail={(item) => { runProtectedAction(() => { diff --git a/src/components/platform-entry/platformEntryCreationTypes.ts b/src/components/platform-entry/platformEntryCreationTypes.ts index 236cba6f..62a7575e 100644 --- a/src/components/platform-entry/platformEntryCreationTypes.ts +++ b/src/components/platform-entry/platformEntryCreationTypes.ts @@ -14,6 +14,21 @@ export type PlatformCreationTypeCard = { 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` 只控制平台入口是否展示,不影响既有玩法链路和路由能力。 diff --git a/src/components/rpg-creation-editor/RpgCreationEntityEditorShared.tsx b/src/components/rpg-creation-editor/RpgCreationEntityEditorShared.tsx index db5fac0d..d2659796 100644 --- a/src/components/rpg-creation-editor/RpgCreationEntityEditorShared.tsx +++ b/src/components/rpg-creation-editor/RpgCreationEntityEditorShared.tsx @@ -5007,83 +5007,19 @@ function WorldAttributeSchemaEditor({ }; return ( - -
+ +
{value.slots.map((slot) => (
-
- - updateSlot(slot.slotId, { name })} - /> - - -