1
This commit is contained in:
@@ -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):未引用垃圾、旧入口残留、前后端双份真相与后端迁移项的专项审计。
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
+28
-20
@@ -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 主链;本核验范围内不再保留前端正式状态裁决残留。**
|
||||
|
||||
@@ -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<string, number>;
|
||||
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:旧系统迁移期间双轨并存混乱
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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,不回退写死旧四维文案。
|
||||
|
||||
@@ -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(...)`。
|
||||
|
||||
@@ -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`。
|
||||
- 结果页/世界页展示六个自定义维度名,而非固定的力量、敏捷、智力、精神。
|
||||
- 结果页/世界页展示六个自定义维度名,而非固定的力量、敏捷、智力、精神;页面不展示维度说明、正负信号或用途说明。
|
||||
- 缺失或非法模型输出会被后端兜底为合法中文六维。
|
||||
|
||||
@@ -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[];
|
||||
}
|
||||
|
||||
|
||||
@@ -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: '在漫长远行与恶劣天气里保有余力。',
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
@@ -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<String>,
|
||||
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<String> {
|
||||
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<String, JsonValue>, key: &str) -> Option<String> {
|
||||
map.get(key)
|
||||
.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)
|
||||
}
|
||||
|
||||
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> {
|
||||
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")
|
||||
|
||||
@@ -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(),
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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 {
|
||||
|
||||
@@ -1383,9 +1383,6 @@ export function AdventureEntityModal({
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-2 text-[11px] leading-5 text-zinc-500">
|
||||
{attribute.definition}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -331,7 +331,7 @@ export function CharacterAttributeGrid({
|
||||
boostedCombatStats,
|
||||
resourceLabels,
|
||||
)
|
||||
: slot.combatUseText,
|
||||
: '',
|
||||
};
|
||||
});
|
||||
|
||||
@@ -364,9 +364,11 @@ export function CharacterAttributeGrid({
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-2 text-[10px] leading-relaxed text-sky-200/85">
|
||||
{effectText}
|
||||
</div>
|
||||
{effectText ? (
|
||||
<div className="mt-2 text-[10px] leading-relaxed text-sky-200/85">
|
||||
{effectText}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
),
|
||||
)}
|
||||
|
||||
@@ -561,9 +561,6 @@ export function CharacterPanel({
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-2 text-[11px] leading-5 text-zinc-500">
|
||||
{attribute.definition}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -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({
|
||||
<div className="text-[11px] font-bold tracking-[0.18em] text-zinc-500">
|
||||
角色维度
|
||||
</div>
|
||||
{profile.attributeSchema?.schemaName ? (
|
||||
<div className="text-xs leading-5 text-zinc-500">
|
||||
{profile.attributeSchema.schemaName}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="mt-3 grid grid-cols-2 gap-2 sm:grid-cols-3 xl:grid-cols-6">
|
||||
{attributeSlots.map((slot) => (
|
||||
@@ -999,9 +984,6 @@ export function CustomWorldEntityCatalog({
|
||||
<div className="text-sm font-semibold text-white">
|
||||
{slot.name}
|
||||
</div>
|
||||
<div className="mt-1 line-clamp-2 text-[11px] leading-5 text-zinc-400">
|
||||
{buildAttributeSlotSummary(slot) || slot.definition}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -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(
|
||||
<RpgCreationEntityEditorModal
|
||||
profile={createProfile()}
|
||||
target={{ kind: 'world' }}
|
||||
target={{ kind: 'foundation' }}
|
||||
onClose={() => {}}
|
||||
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 () => {
|
||||
|
||||
@@ -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(
|
||||
<CustomWorldCreationHub
|
||||
|
||||
@@ -43,8 +43,8 @@ test('creation hub draft card renders compiled work summary fields', () => {
|
||||
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', () => {
|
||||
|
||||
@@ -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 (
|
||||
// 移动端限制模块高度,模板入口改为横向滚动,避免挤占作品列表首屏空间。
|
||||
<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 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;
|
||||
|
||||
return (
|
||||
|
||||
@@ -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<typeof getVisiblePlatformCreationTypes>[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 (
|
||||
<UnifiedModal
|
||||
|
||||
@@ -122,6 +122,7 @@ import { useRpgCreationResultAutosave } from '../rpg-entry/useRpgCreationResultA
|
||||
import { useRpgCreationSessionController } from '../rpg-entry/useRpgCreationSessionController';
|
||||
import { PlatformEntryCreationTypeModal } from './PlatformEntryCreationTypeModal';
|
||||
import type { PlatformCreationTypeId } from './platformEntryCreationTypes';
|
||||
import { isPlatformCreationTypeVisible } from './platformEntryCreationTypes';
|
||||
import {
|
||||
PlatformEntryHomeView,
|
||||
type PlatformHomeTab,
|
||||
@@ -473,6 +474,7 @@ export function PlatformEntryFlowShellImpl({
|
||||
const [deletingCreationWorkId, setDeletingCreationWorkId] = useState<
|
||||
string | null
|
||||
>(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(() => {
|
||||
|
||||
@@ -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` 只控制平台入口是否展示,不影响既有玩法链路和路由能力。
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user