diff --git a/.tools/node-v22.22.2-win-x64/node.exe b/.tools/node-v22.22.2-win-x64/node.exe new file mode 100644 index 00000000..6b9c9140 Binary files /dev/null and b/.tools/node-v22.22.2-win-x64/node.exe differ diff --git a/docs/audits/CUSTOM_WORLD_PROFILE_MAPPING_AUDIT_2026-04-18.md b/docs/audits/CUSTOM_WORLD_PROFILE_MAPPING_AUDIT_2026-04-18.md new file mode 100644 index 00000000..f311e2d3 --- /dev/null +++ b/docs/audits/CUSTOM_WORLD_PROFILE_MAPPING_AUDIT_2026-04-18.md @@ -0,0 +1,621 @@ +# 世界 Profile 到预设内容与实时生成规则映射审计 + +更新时间:`2026-04-18` + +## 0. 审计目标 + +本次审计只回答一个问题: + +**当前仓库里的世界 profile 设定,是否已经完整、合理地映射到游戏的预设内容与实时生成内容规则中。** + +这里的“世界 profile”包含两层: + +1. `CustomWorldProfile` 顶层世界数据 +2. `ownedSettingLayers` 派生设定层 + +这里的“预设内容”包含: + +1. 角色运行时预设 +2. 场景预设 +3. 默认视觉与怪物匹配 +4. 初始装备 / 初始背包 / 经济与术语表现 + +这里的“实时生成规则”包含: + +1. 主剧情 prompt +2. NPC 对话 / 招募 / 私聊 prompt +3. 任务生成 +4. 运行时物品生成 +5. 故事线程、可见性、叙事 QA 与推进规则 + +--- + +## 1. 结论先行 + +结论不是“完全映射”,而是: + +**已完成基础映射,但没有达到“完全且合理”的程度。** + +当前状态更准确地说是: + +1. `世界基础骨架 -> 角色 / 场景 / 属性 / prompt` 这条主链已经打通。 +2. `叙事层 -> 主剧情/NPC 可见性规则` 已经有比较扎实的接入。 +3. `规则层 -> UI术语 / 经济 / 属性` 已经接入。 +4. 但 `模板兼容层` 仍然过强,跨题材世界会被粗暴压回 `WUXIA/XIANXIA`。 +5. 但 `后端运行时任务/物品模块` 只拿到了瘦身版 profile,没有真正吃到完整世界叙事层。 +6. 但 `世界级 items / faction / conflict` 仍然更多是文本种子,而不是可操作的游戏内容对象。 + +如果按结果判断: + +1. **预设内容映射:部分完整,约 70%。** +2. **实时生成规则映射:前端剧情主链较完整,后端运行时子链不完整,整体约 60%。** +3. **跨题材合理性:明显不足。** + +--- + +## 2. 本次审计覆盖的核心文件 + +类型与编译链: + +- `src/types/customWorld.ts` +- `src/services/customWorld.ts` +- `src/services/customWorldBuilder.ts` +- `src/services/customWorldOwnedSettingLayers.ts` +- `src/services/customWorldTheme.ts` + +预设内容落地: + +- `src/data/characterPresets.ts` +- `src/data/scenePresets.ts` +- `src/data/customWorldCharacterLoadout.ts` +- `src/data/customWorldRuntime.ts` +- `src/data/customWorldVisuals.ts` +- `src/data/customWorldNpcMonsters.ts` +- `src/data/worldAttributeSchemas.ts` +- `src/data/economy.ts` +- `src/services/customWorldPresentation.ts` + +实时生成规则: + +- `src/hooks/story/storyContextBuilder.ts` +- `src/services/prompt.ts` +- `src/services/characterChatPrompt.ts` +- `src/services/questPrompt.ts` +- `src/services/questDirector.ts` +- `src/services/runtimeItemAiPrompt.ts` +- `src/data/runtimeItemNarrative.ts` +- `src/services/storyEngine/themePack.ts` +- `src/services/storyEngine/worldStoryGraph.ts` +- `src/services/storyEngine/actorNarrativeProfile.ts` +- `src/services/storyEngine/knowledgeGraph.ts` +- `src/services/storyEngine/threadContract.ts` +- `src/services/storyEngine/visibilityEngine.ts` +- `src/services/storyEngine/authorialConstraintPack.ts` +- `src/hooks/story/progressionActions.ts` + +后端运行时链: + +- `server-node/src/modules/ai/customWorldOrchestrator.ts` +- `server-node/src/modules/runtime-item/runtimeItemModule.ts` +- `server-node/src/modules/quest/runtimeQuestModule.ts` +- `server-node/src/modules/runtime/runtimeSnapshotHydration.ts` + +--- + +## 3. 映射总表 + +| 设定层/字段 | 映射到预设内容 | 映射到实时生成规则 | 判断 | +| --- | --- | --- | --- | +| `name/subtitle/summary/tone/playerGoal` | 已映射到角色 opening、场景提示、视觉匹配、程序化物品关键词 | 已映射到主剧情 prompt、任务 prompt、物品 prompt、ThemePack/StoryGraph 派生 | 基本成立 | +| `templateWorldType/compatibilityTemplateWorldType` | 强影响角色模板、场景图参考池、怪物池、兼容 schema | 影响 ThemePack fallback 与部分运行时回退 | 已接入,但合理性不足 | +| `majorFactions/coreConflicts` | 主要进入 ThemePack / StoryGraph / tension state,未落成具体 faction 实体 | 影响 authorial constraints、线程图谱、任务与剧情语义 | 有映射,但偏文本种子 | +| `camp` | 已映射为开局 camp scene、camp 图、camp 连接 | 通过世界参考文本和开局内容进入 prompt | 成立 | +| `attributeSchema` | 已映射到角色/NPC 属性、战斗面板、属性展示 | 已映射到 prompt 属性描述与运行时计算 | 成立 | +| `ownedSettingLayers.ruleProfile.resourceLabels` | 已映射到 UI 血量/法力/货币等术语 | 主要通过 UI/经济层体现,prompt 侧间接使用 | 成立 | +| `ownedSettingLayers.ruleProfile.economyProfile.initialCurrency` | 已映射到初始货币与快照恢复 | 对运行时奖励规则影响弱,更多是初始化 | 成立但范围有限 | +| `playableNpcs` | 已映射到可玩角色预设、技能变体、初始物品、home scene | 已映射到剧情 prompt、私聊 prompt、叙事档案 | 成立 | +| `storyNpcs` | 已映射到场景 NPC、怪物判定、角色运行时预设 | 已映射到遭遇 prompt、任务发布者、叙事可见性 | 成立 | +| `landmarks` | 已映射到 scene presets、连接网络、场景视觉、treasure hints | 已映射到世界参考文本、scene residues、故事线程关联 | 成立 | +| `items` | 生成主链默认清空,世界级 item 几乎未形成正式内容层 | `knowledgeFacts` 可支持 item,但主链无内容可用 | 映射明显不足 | +| `themePack/expressionProfile` | 已映射到视觉/命名/技能名/场景语义 | 已映射到 prompt 基调、reveal 风格、故事图谱 | 成立 | +| `referenceProfile.roleArchetypes` | 已映射到角色模板骨架选择 | 运行时规则直接消费较少 | 部分成立 | +| `referenceProfile.sceneBuckets` | 已映射到场景默认图匹配 | 运行时 prompt 直接消费较少 | 部分成立 | +| `referenceProfile.creatureArchetypes` | 已映射到怪物 preset 池筛选 | 运行时规则间接消费 | 部分成立 | +| `storyGraph` | 预设层主要影响 narrative residues 与 faction tension | 已映射到 active threads、constraints、visibility、chapter/campaign | 成立 | +| `narrativeProfile` | 已映射到 scene NPC 简介和遭遇资料 | 已映射到 prompt 可见性、任务/物品关系生成 | 成立 | +| `knowledgeFacts` | 不直接生成预设内容 | 已映射到 visibility slice 与 prompt 裁剪 | 成立 | +| `threadContracts` | 不直接生成预设内容 | 已映射到 story signal / thread update / QA | 成立 | +| `creatorIntent/anchorPack/lockState/anchorContent` | 主要留在创作工作区与结果页整理 | 几乎不直接进入正式游戏运行时 | 创作层有用,运行时映射弱 | + +--- + +## 4. 已经成立的映射链 + +## 4.1 世界基础骨架已经能稳定进入角色、场景与剧情主链 + +`CustomWorldProfile` 的基础字段已经不是“只存档不消费”的状态。 + +它们已经实际进入: + +1. 角色开局文案与 opening 动机 +2. 角色技能变体 +3. 场景预设名称、描述、连接、treasure hints +4. 主剧情 prompt 中的世界补充档案 +5. 私聊 / 任务 / 运行时物品 prompt 的世界摘要 + +这说明: + +**世界 profile 的基础文本层已经真正进入游戏主链。** + +--- + +## 4.2 规则层已经落到真实游戏表现 + +`ownedSettingLayers.ruleProfile` 目前已真实影响: + +1. `attributeSchema` + - 角色/NPC 属性计算 + - prompt 中的属性描述 + - 面板展示 +2. `resourceLabels` + - HP/MP/伤害/冷却/货币等 UI 术语 +3. `economyProfile.initialCurrency` + - 自定义世界初始货币 + - 快照恢复时的默认初始化 + +这部分不是空壳。 + +**规则层已经从 profile 进入真实结算和 UI。** + +--- + +## 4.3 叙事层已经进入 prompt 可见性与推进规则 + +`themePack -> storyGraph -> narrativeProfile -> knowledgeFacts -> visibilitySlice` +这条链已经是当前自定义世界最完整的一条映射链。 + +它已经支撑: + +1. NPC 首遇/低披露 prompt 裁剪 +2. 当前线程可见性控制 +3. 当前压力、错位、禁区、已解锁章节等信息分层 +4. 章节/战役/约束/QA 的继续推进 + +这说明: + +**世界 profile 里的叙事层不只是展示文本,而是真的在控制“模型这轮能知道什么、不能知道什么”。** + +--- + +## 5. 关键问题 + +## 5.1 高优先级问题:模板兼容层仍然是二元锚点,跨题材世界会被错误压缩 + +当前主生成链仍要求模型输出: + +- `templateWorldType: WUXIA | XIANXIA` + +而兼容解析也会把世界最终压回: + +1. `arcane -> XIANXIA` +2. 其它几乎全部回到 `WUXIA` + +这会直接影响: + +1. 角色模板骨架选择 +2. 场景默认图参考池 +3. 怪物 preset 池 +4. 兼容性 fallback + +问题不在“有兼容字段”,而在于: + +**当前兼容字段仍然过度参与真实内容映射。** + +对现代金融、科幻 AI 战争、校园、都市、调查等题材来说,这种二元压缩并不合理。 + +仓库日志里已经出现了典型样本: + +1. 股市世界被要求产出 `XIANXIA` +2. AI 战争世界也被要求产出 `XIANXIA` +3. 魔法科技融合世界被要求产出 `WUXIA` + +这意味着: + +**世界 profile 虽然支持跨题材文本输入,但底层预设内容映射仍带着明显的“武侠/仙侠残余偏置”。** + +相关文件: + +- `server-node/src/modules/ai/customWorldOrchestrator.ts` +- `src/services/customWorldTheme.ts` +- `src/data/customWorldVisuals.ts` +- `src/data/customWorldNpcMonsters.ts` +- `src/data/characterPresets.ts` + +判断: + +**这是当前“合理映射”最大缺口。** + +--- + +## 5.2 高优先级问题:后端运行时任务/物品模块只消费了瘦身版 world profile + +前端剧情主链里,`customWorldProfile` 会带着: + +1. `themePack` +2. `storyGraph` +3. `knowledgeFacts` +4. `threadContracts` +5. `ownedSettingLayers` + +但后端运行时模块里: + +1. `runtimeItemModule` 的 `customWorldProfile` 只有 `{ name, summary }` +2. `runtimeQuestModule` 的 `customWorldProfile` 也只有 `{ name, summary }` + +这直接导致后端运行时生成无法真正读取: + +1. 世界线程图谱 +2. 世界可见性事实 +3. 参考原型层 +4. 规则层 +5. 表达层 + +结果是: + +1. 主剧情/NPC prompt 已经较强依赖世界叙事层 +2. 但后端任务/物品生成还只是吃世界摘要 + +这会把系统拆成两种强度不同的世界消费链: + +1. 前端剧情链较“懂世界” +2. 后端运行时奖励链较“不懂世界” + +相关文件: + +- `src/hooks/story/storyContextBuilder.ts` +- `src/services/runtimeItemAiPrompt.ts` +- `src/services/questPrompt.ts` +- `server-node/src/modules/runtime-item/runtimeItemModule.ts` +- `server-node/src/modules/quest/runtimeQuestModule.ts` + +判断: + +**世界 profile 到实时生成规则的映射,在后端链路上是不完整的。** + +--- + +## 5.3 高优先级问题:世界级 `items` 没有真正接进主生成链 + +类型里存在: + +- `CustomWorldProfile.items` + +构建器也支持: + +1. item 归一化 +2. `attributeResonance` +3. item knowledge facts + +但真正的世界生成主链里: + +1. orchestrator prompt 明确要求不要预生成物品档案 +2. `attachRuntimeGenerationMetadata(...)` 会把 `items` 直接压成空数组 + +这会带来两个结果: + +1. 世界 profile 的“世界级物品层”几乎为空 +2. 运行时背包、掉落、交易更多依赖程序化生成和角色初始物品 + +于是目前的物品系统更像: + +1. 有角色初始物品 +2. 有运行时程序化物品 +3. 但没有稳定的“世界物品图谱” + +这意味着: + +**世界 profile 在物品层没有形成完整映射。** + +相关文件: + +- `server-node/src/modules/ai/customWorldOrchestrator.ts` +- `src/services/customWorldBuilder.ts` +- `src/data/customWorldRuntime.ts` +- `src/data/customWorldCharacterLoadout.ts` +- `src/services/storyEngine/knowledgeGraph.ts` + +判断: + +**这是“预设内容”和“实时生成规则”共同缺失的一块。** + +--- + +## 5.4 中优先级问题:`majorFactions/coreConflicts` 仍然是文本种子,不是可操作游戏对象 + +当前 `majorFactions` 与 `coreConflicts` 已经被大量消费,但主要消费方式是: + +1. 拼进 `ThemePack` +2. 派生 `WorldStoryGraph` +3. 派生 `AuthorialConstraintPack` +4. 派生 `FactionTensionState` + +问题在于它们还没有形成: + +1. 可索引 faction 实体 +2. faction 与 NPC 的显式归属关系 +3. faction 与场景/商店/敌对阵营的显式绑定 +4. 冲突与任务/势力状态的强约束关系 + +当前更多是: + +**“文本里提到过 -> 图谱做字符串匹配 -> 运行时拿去写 prompt”** + +而不是: + +**“世界里真的存在这些派系与冲突对象,并驱动交互规则”** + +相关文件: + +- `src/services/storyEngine/themePack.ts` +- `src/services/storyEngine/worldStoryGraph.ts` +- `src/services/storyEngine/factionTensionState.ts` + +判断: + +**已有映射,但离“完全落地”为具体游戏内容对象还有明显距离。** + +--- + +## 5.5 中优先级问题:场景预设会额外注入模板怪物,弱化 landmark 的原始设定控制力 + +`buildCustomScenePresets(profile)` 在每个 landmark scene 中,除了把 `landmark.sceneNpcIds` 指向的角色放进去,还会: + +1. 从怪物 preset 池按 scene index 截两只怪 +2. 直接拼进 `combinedNpcs` + +这意味着即使 profile 本身没有明确要求某个 landmark 出现这些敌对实体,运行时场景仍会被额外补入模板怪物。 + +这会导致: + +1. 场景内容不完全由 `landmark + storyNpcs` 决定 +2. 地标设定与实际可战斗内容之间存在偏移 +3. 跨题材世界会更容易被模板怪物池拖偏 + +相关文件: + +- `src/data/scenePresets.ts` +- `src/data/customWorldNpcMonsters.ts` + +判断: + +**landmark 到实际场景实体池的映射,不是完全忠实映射,而是“设定 + 模板补丁”。** + +--- + +## 5.6 中优先级问题:后端运行时物品线程并不是真正世界线程 + +前端剧情链里的 `activeThreadIds` 来自: + +1. `storyEngineMemory` +2. `storyGraph` +3. `knowledgeFacts` +4. `visibilitySlice` + +但后端 `runtimeItemModule` 的 loose context 里,`activeThreadIds` 只是: + +1. `thread:${encounter.id}` +2. 或 `thread:${scene.id}` + +这不是世界线程图谱,而是临时合成 id。 + +结果是: + +1. 名义上后端物品模块也有“active threads” +2. 实际上它拿到的并不是 `WorldStoryGraph` 中的真实线程 + +这会让运行时物品的“为什么现在出现”更像局部上下文推断,而不是来自世界故事结构。 + +相关文件: + +- `server-node/src/modules/runtime-item/runtimeItemModule.ts` + +判断: + +**这是实时生成规则层的结构性弱映射。** + +--- + +## 5.7 中优先级问题:`referenceProfile.roleArchetypes` 只从 playableNpcs 派生,storyNpcs 覆盖不够 + +当前 `roleArchetypes` 的编译来源是: + +1. `profile.playableNpcs.slice(0, 6)` + +而不是: + +1. `playableNpcs + storyNpcs` 的综合原型池 + +这导致两个问题: + +1. 世界里的长尾 story NPC 原型没有进入 reference archetype 编译 +2. 某些场景角色/怪物/平民的模板骨架选择更多依赖启发式 fallback + +这会让: + +1. 可玩角色映射较稳定 +2. 长尾场景角色映射不够稳定 + +相关文件: + +- `src/services/customWorldOwnedSettingLayers.ts` +- `src/services/customWorldReferenceSignals.ts` +- `src/data/characterPresets.ts` + +判断: + +**参考层映射存在明显“主角优先、长尾不足”的偏差。** + +--- + +## 5.8 低优先级问题:创作元数据并未进入正式游戏运行时 + +`creatorIntent / anchorPack / lockState / anchorContent` 当前主要服务于: + +1. 创作工作区 +2. Agent session +3. 结果页和编辑器 + +它们对正式运行时的直接作用主要是: + +1. 参与 `ownedSettingLayers` 的编译 + +但不会直接变成: + +1. 正式战斗规则 +2. 场景交互规则 +3. faction 状态 +4. 任务目标约束 + +这不一定是 bug,但如果把“世界 profile 设定”理解为所有 profile 元数据,那么: + +**创作层数据目前并没有完整进入游戏运行时。** + +--- + +## 6. 分层判断 + +## 6.1 预设内容映射判断 + +### 已经合理接入的部分 + +1. 可玩角色 +2. 场景角色 +3. 地标场景 +4. 属性 schema +5. 资源术语 +6. 初始货币 +7. camp 开局归处 +8. 默认场景图匹配 + +### 仍然不足的部分 + +1. 世界级 items +2. faction 实体化 +3. 冲突到任务/场景状态的强绑定 +4. 跨题材世界的模板偏置问题 +5. 地标与怪物注入之间的忠实性 + +结论: + +**预设内容层是“能跑且已有骨架”,但还不是“设定完全落地”。** + +--- + +## 6.2 实时生成规则映射判断 + +### 已经合理接入的部分 + +1. 主剧情 prompt +2. NPC 可见性控制 +3. 私聊与对话 prompt +4. 叙事线程图谱 +5. 事实图谱 +6. 作者性约束与 QA + +### 仍然不足的部分 + +1. 后端任务模块 world profile 过瘦 +2. 后端物品模块 world profile 过瘦 +3. 后端物品线程是伪线程 +4. 世界级 item 图谱为空 +5. faction/conflict 仍偏语义层,不够规则化 + +结论: + +**实时生成规则层呈现出“前端剧情链强、后端奖励链弱”的不均衡状态。** + +--- + +## 7. 最终判定 + +如果问题是: + +**“世界 profile 设定是否已经完全地、合理地映射到游戏的预设内容、实时生成内容规则中?”** + +我的结论是: + +**没有。** + +更准确地说: + +1. 已经完成了主干映射。 +2. 但还没有完成全量映射。 +3. 也还没有完成跨题材下的合理映射。 + +当前系统最准确的状态是: + +**世界 profile 已经成为真实驱动源之一,但还没有成为所有预设内容与实时规则的一致单一真相源。** + +--- + +## 8. 建议的修复优先级 + +## P1:先补“真实消费不完整”的链路 + +1. 让后端 `runtimeItemModule` / `runtimeQuestModule` 接收完整 `customWorldProfile` 子集 +2. 至少补进: + - `ownedSettingLayers` + - `storyGraph` + - `knowledgeFacts` + - `themePack` + - `majorFactions/coreConflicts` + +--- + +## P1:把 `templateWorldType` 退回兼容字段,而不是主导字段 + +1. 生成期保留兼容输出 +2. 运行时优先读取: + - `ownedSettingLayers` + - `themeMode` + - `referenceProfile` +3. 不再让 `WUXIA/XIANXIA` 主导现代/科幻/海洋/裂界世界的视觉与怪物选择 + +--- + +## P1:补世界级 item 层 + +1. 允许世界生成阶段产出一批世界级 items seed +2. 让 `knowledgeFacts / runtime item / quest reward / treasure hint` 能挂到这些 seed 上 +3. 形成“世界物件图谱”,而不是只有角色初始物品和程序化临时物品 + +--- + +## P2:把 faction/conflict 从文本种子升级成结构对象 + +1. faction 实体 +2. faction -> NPC 归属 +3. faction -> 场景控制 +4. conflict -> 任务/线程/场景压力绑定 + +--- + +## P2:去掉地标场景里的固定模板怪物补丁式注入 + +1. 优先使用 landmark 自己的敌对角色设计 +2. 模板怪物只作为缺口补位 +3. 补位也要受 landmark/theme/thread 约束 + +--- + +## P3:扩充 reference archetype 的来源 + +1. role archetypes 不只从 playableNpcs 编 +2. storyNpcs 也应参与 archetype 归纳 +3. 为平民、敌对、怪物、势力成员建立更细 archetype + +--- + +## 9. 一句话总评 + +**当前世界 profile 已经能驱动游戏,但还没有彻底收束成“所有预设内容与实时生成规则都优先读取它”的单一真相源。主链可用,边链仍散,跨题材合理性仍偏弱。** diff --git a/docs/planning/CUSTOM_WORLD_PROFILE_MAPPING_OPTIMIZATION_PLAN_2026-04-18.md b/docs/planning/CUSTOM_WORLD_PROFILE_MAPPING_OPTIMIZATION_PLAN_2026-04-18.md new file mode 100644 index 00000000..b50cdc0e --- /dev/null +++ b/docs/planning/CUSTOM_WORLD_PROFILE_MAPPING_OPTIMIZATION_PLAN_2026-04-18.md @@ -0,0 +1,608 @@ +# 世界 Profile 映射优化方案(2026-04-18) + +更新时间:`2026-04-18` + +## 0. 文档目标 + +这份文档基于以下结论继续推进: + +- `docs/audits/CUSTOM_WORLD_PROFILE_MAPPING_AUDIT_2026-04-18.md` 已确认,当前世界 `profile` 已打通主干映射,但还没有成为“所有预设内容与实时生成规则共同优先读取的单一真相源”。 +- 当前最主要的缺口不是“没有世界设定”,而是“世界设定没有被所有运行时链路以同等强度消费”。 +- 优化重点应优先放在后端运行时消费链、跨题材兼容链、世界级内容对象化三条主线上,而不是继续堆新预设内容。 + +本文目标不是重复审计结论,而是把问题整理成: + +1. 分优先级的落地改造项 +2. 每一项的目标状态 +3. 需要修改的关键模块 +4. 具体实施步骤 +5. 验收标准 +6. 风险与回滚策略 + +--- + +## 1. 目标状态 + +本轮优化完成后,系统应达到以下状态: + +1. `CustomWorldProfile` 成为预设内容和实时生成规则的统一主数据源。 +2. 前端剧情链、后端任务链、后端物品链消费的是同一套世界语义,而不是“前端懂世界、后端只懂摘要”。 +3. `templateWorldType` 只承担兼容字段职责,不再主导跨题材世界的角色、场景、怪物和视觉映射。 +4. `items / factions / conflicts / threads` 不再只是文本种子,而是能驱动运行时行为的结构对象。 +5. landmark、场景、怪物、掉落、任务、奖励之间的对应关系优先由世界 profile 决定,模板补位只在缺口场景下触发。 + +--- + +## 2. 优先级总览 + +## P0:先补“真实消费不完整”的运行时主链 + +- 让后端 `runtimeItemModule` / `runtimeQuestModule` 接入完整可控的世界 profile 子集。 +- 去掉后端运行时里“伪线程 id 冒充真实线程”的弱映射。 +- 建立后端世界消费 contract,统一前后端字段裁剪方式。 + +## P1:再补“世界设定被模板压扁”的兼容链与物品链 + +- 把 `templateWorldType` 退回兼容层,不再主导真实映射。 +- 为世界级 `items` 建立 seed 与引用链,补齐“世界物件图谱”。 +- 让任务奖励、场景宝藏、运行时物品生成优先读取世界物品种子。 + +## P2:把文本种子升级为游戏对象 + +- 把 `majorFactions / coreConflicts` 升级成可索引、可关联、可推进的结构对象。 +- 调整 landmark 场景怪物注入逻辑,停止“模板怪物硬补丁”主导场景内容。 +- 让世界冲突、势力归属真正影响任务、场景压力、敌对关系。 + +## P3:补齐长尾映射与创作层沉淀 + +- 扩展 `referenceProfile.roleArchetypes` 的来源覆盖,补上 `storyNpcs` 长尾 archetype。 +- 重新定义 `creatorIntent / anchorPack / lockState / anchorContent` 中哪些字段需要进入运行时。 +- 为后续第四阶段的世界编辑器、运行时回写和世界演化能力预留稳定接口。 + +--- + +## 3. 设计原则 + +1. 前端只做表现与编排,世界规则解释、任务生成、物品生成、状态持久化都在 Express 后端完成。 +2. 优先改造现有链路与现有类型,不新造平行系统。 +3. 兼容字段只做 fallback,不反向主导真实世界内容。 +4. 所有新增结构都必须能同时服务“预设内容生成”和“运行时规则生成”,避免再次分叉。 +5. 每一个优化项都必须带测试入口、验收口径和回滚开关。 + +--- + +## 4. P0 优化方案:补齐后端真实世界消费链 + +## P0-1:建立统一的运行时世界消费 Contract + +### 问题定义 + +当前前端剧情主链可以拿到较完整的 `customWorldProfile`,但后端任务与物品模块只消费 `{ name, summary }` 这类瘦身字段,导致同一个世界在不同链路里语义强度不一致。 + +### 目标状态 + +- 后端所有运行时生成模块统一读取 `RuntimeWorldContext`。 +- `RuntimeWorldContext` 来源于 `CustomWorldProfile` 的受控裁剪,而不是各模块各自手写轻量字段。 +- 前后端消费世界信息时,共享同一套字段分层。 + +### 关键文件 + +- `E:\Repos\Genarrative\server-node\src\modules\ai\customWorldOrchestrator.ts` +- `E:\Repos\Genarrative\server-node\src\modules\runtime-item\runtimeItemModule.ts` +- `E:\Repos\Genarrative\server-node\src\modules\quest\runtimeQuestModule.ts` +- `E:\Repos\Genarrative\src\types\customWorld.ts` +- `E:\Repos\Genarrative\src\hooks\story\storyContextBuilder.ts` + +### 实施步骤 + +1. 在后端新增统一的 `RuntimeWorldContext` 类型与构建函数。 +2. 首批纳入字段: + - `name` + - `summary` + - `themePack` + - `storyGraph` + - `knowledgeFacts` + - `threadContracts` + - `majorFactions` + - `coreConflicts` + - `ownedSettingLayers.ruleProfile` + - `referenceProfile` +3. 在 orchestrator 层完成一次裁剪与归一化,禁止 `runtimeItemModule`、`runtimeQuestModule` 自己拼 world summary。 +4. 为后端 prompt 构造器增加“世界上下文来源检查”,缺字段时直接走 fallback 分支并记录日志。 +5. 将前端剧情链现有世界裁剪逻辑与后端世界裁剪逻辑对齐,避免出现同字段两套解释。 + +### 前后端职责 + +- 前端:只传递当前线程、当前场景、当前 encounter、当前角色状态等调用上下文。 +- 后端:解释世界 profile、构造任务 prompt、构造物品 prompt、产出最终结构化结果。 + +### 验收标准 + +- 后端任务和物品生成日志中可看到相同世界的 `themePack / activeThreads / ruleProfile` 已进入 prompt 上下文。 +- 同一世界下,前端剧情 prompt 与后端任务/物品 prompt 的核心势力、冲突、世界基调不再明显割裂。 +- 缺失字段时有稳定 fallback,不会因为世界 profile 某块为空直接报错。 + +### 风险与回滚 + +- 风险:一次性带入字段过多,可能导致 prompt 膨胀。 +- 回滚策略:保留 `RuntimeWorldContextLite` 开关,出现 token 或稳定性问题时,可按模块退回精简字段集。 + +--- + +## P0-2:让后端 active threads 读取真实故事线程 + +### 问题定义 + +当前后端运行时物品模块里的 `activeThreadIds` 只是 `thread:${encounter.id}` 或 `thread:${scene.id}` 这类伪 id,不是真正来自 `storyGraph` 的线程对象。 + +### 目标状态 + +- 后端运行时统一读取真实 `storyGraph` 线程。 +- 若当前 scene / encounter 未命中真实线程,再进入显式 fallback。 +- 运行时物品、任务奖励、事件物件都能解释“为什么此刻出现”。 + +### 关键文件 + +- `E:\Repos\Genarrative\server-node\src\modules\runtime-item\runtimeItemModule.ts` +- `E:\Repos\Genarrative\src\services\storyEngine\worldStoryGraph.ts` +- `E:\Repos\Genarrative\src\services\storyEngine\threadContract.ts` + +### 实施步骤 + +1. 在后端新增 `resolveActiveWorldThreads(...)`,输入 scene、encounter、knowledge facts、runtime memory。 +2. 线程命中优先顺序: + - 当前 scene 关联线程 + - 当前 encounter / npc 关联线程 + - 当前已激活 `threadContracts` + - 当前 `knowledgeFacts` 所属线程 + - fallback synthetic thread +3. synthetic thread 必须显式标记 `source: fallback`,防止伪装成真实世界线程。 +4. 物品与任务模块统一消费 `ResolvedThreadRef[]`,不再直接消费裸字符串 id。 + +### 验收标准 + +- 后端日志中可区分真实线程与 fallback 线程。 +- 运行时物品说明、任务目标和世界冲突有明确对应,不再只围绕当前场景 id 即时拼接。 + +### 风险与回滚 + +- 风险:现有 scene / encounter 到 thread 的映射不完整,可能导致命中率不高。 +- 回滚策略:保留 synthetic fallback,但必须在日志和结构体中显式标识来源。 + +--- + +## P0-3:先补基础验证链,避免后端世界消费升级后失控 + +### 问题定义 + +世界上下文一旦进入后端主链,后续所有任务和物品生成都将更依赖字段质量;若没有结构化校验,很容易引入“字段有了但解释不一致”的新问题。 + +### 目标状态 + +- 为 `RuntimeWorldContext` 建立结构校验与测试样例。 +- 世界映射升级后可以快速回归。 + +### 实施步骤 + +1. 为 `RuntimeWorldContext` 增加 schema 校验或最小断言层。 +2. 建立 3 组回归世界: + - 武侠 + - 现代都市 + - 科幻/魔法混合 +3. 增加任务模块、物品模块的世界消费快照测试。 +4. 对“缺 `storyGraph`、缺 `items`、缺 `factions`”的情况补边界测试。 + +### 验收标准 + +- 世界消费链新增测试可稳定通过。 +- 新增字段不会让现有预设世界直接失效。 + +--- + +## 5. P1 优化方案:弱化模板主导,补齐世界物件图谱 + +## P1-1:把 `templateWorldType` 退回兼容层 + +### 问题定义 + +当前 `templateWorldType / compatibilityTemplateWorldType` 仍然过度参与角色模板、场景视觉、怪物池、fallback 映射,导致跨题材世界被粗暴压回 `WUXIA/XIANXIA`。 + +### 目标状态 + +- `templateWorldType` 仅用于兼容旧模板与最低保底 fallback。 +- 真实映射优先读取 `ownedSettingLayers`、`themePack`、`referenceProfile`、世界表达层信号。 +- 新世界生成不再被强制压成二元题材。 + +### 关键文件 + +- `E:\Repos\Genarrative\server-node\src\modules\ai\customWorldOrchestrator.ts` +- `E:\Repos\Genarrative\src\services\customWorldTheme.ts` +- `E:\Repos\Genarrative\src\data\customWorldVisuals.ts` +- `E:\Repos\Genarrative\src\data\customWorldNpcMonsters.ts` +- `E:\Repos\Genarrative\src\data\characterPresets.ts` + +### 实施步骤 + +1. 梳理所有直接读取 `templateWorldType` 的模块,按用途拆成: + - 必须兼容旧内容 + - 实际主导内容生成 +2. 新增 `worldFlavorProfile` 或等价归一层,从以下信号综合得出世界风味: + - 世界主题摘要 + - `themePack` + - `expressionProfile` + - `ownedSettingLayers` + - `referenceProfile.sceneBuckets` + - `referenceProfile.creatureArchetypes` +3. 将视觉、怪物、角色模板选择改为优先读取 `worldFlavorProfile`。 +4. `templateWorldType` 只在真实信号不足时参与兜底。 +5. 补 5 类题材测试样本: + - 现代金融 + - 赛博 AI 战争 + - 校园悬疑 + - 海洋奇幻 + - 仙侠科技混合 + +### 验收标准 + +- 非武侠/仙侠世界不再默认掉进 `WUXIA/XIANXIA` preset 池。 +- 视觉、怪物、角色原型对同一世界的选择结果更加一致。 +- 旧世界内容仍能被兼容字段兜住。 + +### 风险与回滚 + +- 风险:移除模板主导后,部分老世界可能暂时失去稳定视觉锚点。 +- 回滚策略:保留兼容开关,允许单模块临时回退到旧模板选择逻辑。 + +--- + +## P1-2:建立世界级 Item Seed 与 World Item Graph + +### 问题定义 + +`CustomWorldProfile.items` 虽然在类型层存在,但主链会主动清空,导致世界级物品图谱缺席,任务奖励、宝藏、掉落、交易更依赖运行时临时生成。 + +### 目标状态 + +- 世界生成阶段可产出一批结构化 `item seeds`。 +- `item seeds` 能挂接到 `knowledgeFacts`、`landmarks`、`factions`、`threads`、`treasure hints`。 +- 运行时物品生成优先从世界级物件图谱中取材,而不是完全现场即兴。 + +### 关键文件 + +- `E:\Repos\Genarrative\server-node\src\modules\ai\customWorldOrchestrator.ts` +- `E:\Repos\Genarrative\src\services\customWorldBuilder.ts` +- `E:\Repos\Genarrative\src\data\customWorldRuntime.ts` +- `E:\Repos\Genarrative\src\services\storyEngine\knowledgeGraph.ts` +- `E:\Repos\Genarrative\server-node\src\modules\runtime-item\runtimeItemModule.ts` + +### 实施步骤 + +1. 停止在主链里无条件把 `items` 压空。 +2. 新增 `WorldItemSeed` 结构,最少包含: + - `id` + - `name` + - `category` + - `rarity` + - `originType` + - `relatedFactionIds` + - `relatedThreadIds` + - `relatedLandmarkIds` + - `knowledgeSummary` +3. 世界生成阶段只产出“有限世界物件 seed”,不预生成全量成品装备。 +4. 运行时物品模块新增两段式生成: + - 先从 `WorldItemSeed` 中挑选相关 seed + - 再根据当前场景和奖励语境编译成最终 runtime item +5. 任务奖励、宝藏提示、交易货单优先引用世界 item seed。 + +### 验收标准 + +- 同一世界内的物品来源能够被追溯到特定势力、地标或线程。 +- 宝藏、任务奖励和偶发掉落之间开始出现统一世界物件语义。 +- 即使不预生成大量装备,系统也已具备稳定的世界物件骨架。 + +### 风险与回滚 + +- 风险:若 seed 太多,会拖慢世界生成与存档体积。 +- 回滚策略:限制每个世界的 seed 上限,只保留高价值世界物件骨架。 + +--- + +## P1-3:统一“任务奖励 / 宝藏 / 掉落 / 交易”的物品取材顺序 + +### 问题定义 + +即使补上世界 `items`,如果各入口仍各自即时生成,世界物件图谱仍然无法变成真实运行时约束。 + +### 目标状态 + +- 物品相关入口统一读取同一套 world-first 取材顺序。 + +### 实施步骤 + +1. 制定统一物品取材优先级: + - 世界线程相关 seed + - 当前场景相关 seed + - 当前势力相关 seed + - 当前角色 build / 属性补位型 seed + - 程序化 fallback +2. 将该顺序接入: + - runtime item + - quest reward + - treasure hint + - shop inventory +3. 为每种入口记录 `itemSourceType`,便于后续审计。 + +### 验收标准 + +- 不同入口产出的物品不再像来自多个无关池子。 +- 同一阶段的奖励和世界冲突、场景语境具有明显一致性。 + +--- + +## 6. P2 优化方案:把文本世界升级成可操作对象世界 + +## P2-1:把 `majorFactions / coreConflicts` 升级成结构对象 + +### 问题定义 + +当前势力与冲突已能进入 `ThemePack`、`StoryGraph`、`FactionTensionState`,但本质仍偏文本语义层,尚未形成可索引、可归属、可推进的游戏对象。 + +### 目标状态 + +- faction 成为正式实体。 +- conflict 成为正式实体。 +- NPC、scene、shop、quest、thread 可以明确挂接到 faction/conflict。 + +### 关键文件 + +- `E:\Repos\Genarrative\src\services\storyEngine\themePack.ts` +- `E:\Repos\Genarrative\src\services\storyEngine\worldStoryGraph.ts` +- `E:\Repos\Genarrative\src\services\storyEngine\factionTensionState.ts` +- `E:\Repos\Genarrative\server-node\src\modules\quest\runtimeQuestModule.ts` + +### 实施步骤 + +1. 新增 `WorldFaction`、`WorldConflict` 结构类型。 +2. `majorFactions` 进入标准化流程,最少补: + - `id` + - `name` + - `publicImage` + - `stanceTags` + - `homeLandmarkIds` + - `relatedNpcIds` +3. `coreConflicts` 进入标准化流程,最少补: + - `id` + - `title` + - `parties` + - `stakes` + - `relatedThreadIds` + - `pressureLevel` +4. 任务模块改为先选 conflict,再派生 quest intent。 +5. scene / npc / 商店 / 敌对阵营状态优先读取 faction 归属。 + +### 验收标准 + +- 任一任务都可追溯到某个 faction 或 conflict,而不只是笼统“世界 tension”。 +- NPC 与场景可以明确展示势力归属或冲突立场。 + +### 风险与回滚 + +- 风险:结构化后,旧世界数据可能缺字段。 +- 回滚策略:保留旧字符串字段作为输入源,通过标准化编译器补齐对象字段。 + +--- + +## P2-2:修正 landmark 场景中的模板怪物硬注入 + +### 问题定义 + +当前 landmark scene 在放入 `landmark.sceneNpcIds` 对应实体之外,还会从模板怪物池额外拼入敌对实体,导致地标设定与实际战斗实体池出现偏移。 + +### 目标状态 + +- landmark 场景内容优先由 `landmark + storyNpcs + factions + conflicts` 决定。 +- 模板怪物仅在内容缺口时补位。 +- 补位结果也必须受世界题材和当前线程约束。 + +### 关键文件 + +- `E:\Repos\Genarrative\src\data\scenePresets.ts` +- `E:\Repos\Genarrative\src\data\customWorldNpcMonsters.ts` + +### 实施步骤 + +1. 将场景实体池拆成三层: + - 显式指定实体 + - 世界关系推导实体 + - 模板补位实体 +2. 补位触发条件必须显式化,例如: + - 当前场景无敌对实体 + - 当前任务需要战斗对象 + - 当前地标存在压力标签但无实体承载 +3. 模板补位时必须额外经过: + - 世界风味过滤 + - faction/conflict 过滤 + - landmark 标签过滤 +4. 记录实体来源,供后续审计。 + +### 验收标准 + +- landmark 场景中的实体构成更忠于世界原始设定。 +- 非武侠/仙侠世界不再被模板怪物池轻易拖偏。 + +--- + +## P2-3:让冲突与势力真实进入任务和场景状态推进 + +### 问题定义 + +即使 faction/conflict 对象化,如果任务与场景状态推进仍然只吃文本 tension,总体改善也会有限。 + +### 目标状态 + +- 冲突和势力状态能驱动任务来源、场景压力、敌我关系和奖励语境。 + +### 实施步骤 + +1. 任务生成先选 conflict,再根据 `pressureLevel`、`party`、`stakes` 生成目标和阶段。 +2. 场景压力读取当前 `conflict` 和 `faction tension`,决定: + - 敌对出现概率 + - 商店风格 + - 场景描述张力 +3. 奖励语义优先与 conflict/faction 绑定。 + +### 验收标准 + +- 同一冲突升温后,任务、场景、物品奖励能出现同步变化。 + +--- + +## 7. P3 优化方案:补齐长尾 archetype 与创作层沉淀 + +## P3-1:扩展 `referenceProfile.roleArchetypes` 来源 + +### 问题定义 + +当前 `roleArchetypes` 主要从 `playableNpcs.slice(0, 6)` 派生,导致可玩角色映射稳定,但长尾 `storyNpcs`、平民、敌对成员、怪物的 archetype 覆盖不足。 + +### 目标状态 + +- `roleArchetypes` 来源覆盖 `playableNpcs + storyNpcs`。 +- 长尾角色也能沉淀为稳定 archetype。 + +### 关键文件 + +- `E:\Repos\Genarrative\src\services\customWorldOwnedSettingLayers.ts` +- `E:\Repos\Genarrative\src\services\customWorldReferenceSignals.ts` +- `E:\Repos\Genarrative\src\data\characterPresets.ts` + +### 实施步骤 + +1. 调整 archetype 编译输入,加入 `storyNpcs`。 +2. 按角色用途拆 archetype 桶: + - playable + - civilian + - hostile + - monster + - faction member +3. 为长尾 archetype 增加抽样上限与去重规则,避免噪声过大。 + +### 验收标准 + +- 非主角型角色在模板选择时命中率更高。 +- 长尾场景角色的表现更稳定,不再过度依赖 heuristics fallback。 + +--- + +## P3-2:重新定义哪些创作层元数据需要进入运行时 + +### 问题定义 + +`creatorIntent / anchorPack / lockState / anchorContent` 当前主要停留在创作与编辑器链路,对正式运行时作用较弱。 + +### 目标状态 + +- 明确区分“只服务创作编辑”的元数据和“应进入运行时约束”的元数据。 +- 防止未来编辑器功能越做越多,但运行时仍读不到关键锚点。 + +### 实施步骤 + +1. 对四类元数据逐项分层: + - 创作态专用 + - 编译态可消费 + - 运行时必须消费 +2. 首批建议进入运行时的内容: + - 不可违背的世界禁令 + - 必须保留的主势力/主线锚点 + - 必须保留的世界表达边界 +3. 将运行时需要消费的部分编译进 `ownedSettingLayers` 或 `RuntimeWorldContext`,不要让运行时直接读取编辑器原始字段。 + +### 验收标准 + +- 世界编辑器中被锁定的高优先级设定,能在任务、剧情、物品和场景中持续生效。 +- 创作层字段不会原样泄漏进运行时,仍保持清晰编译边界。 + +--- + +## 8. 推荐实施顺序 + +## 第一阶段:先打通后端世界消费主链 + +1. `P0-1` 统一 `RuntimeWorldContext` +2. `P0-2` 接入真实线程解析 +3. `P0-3` 补回归验证链 + +## 第二阶段:弱化模板主导,补齐世界物件骨架 + +1. `P1-1` 模板退回兼容层 +2. `P1-2` 建立世界 item seed +3. `P1-3` 统一物品入口取材顺序 + +## 第三阶段:把世界对象化 + +1. `P2-1` faction/conflict 对象化 +2. `P2-2` 修正 landmark 怪物注入 +3. `P2-3` 接入任务与场景状态推进 + +## 第四阶段:补齐长尾映射与创作层沉淀 + +1. `P3-1` 扩展 archetype 来源 +2. `P3-2` 明确创作层入运行时边界 + +--- + +## 9. 里程碑拆分建议 + +## M1:世界消费对齐 + +- 输出物:`RuntimeWorldContext`、后端任务/物品世界消费升级、真实线程解析 +- 结果判定:前后端都能围绕同一世界线程与规则说话 + +## M2:跨题材映射纠偏 + +- 输出物:`worldFlavorProfile`、模板兼容层瘦身、跨题材测试集 +- 结果判定:现代/科幻/混合题材不再被压回武侠/仙侠 + +## M3:世界物件与势力冲突对象化 + +- 输出物:`WorldItemSeed`、`WorldFaction`、`WorldConflict` +- 结果判定:任务、场景、物品奖励开始围绕世界对象图谱运转 + +## M4:长尾稳定化 + +- 输出物:扩展 archetype、运行时锚点编译边界 +- 结果判定:长尾 NPC 与世界编辑器高优先级设定都能稳定进入运行时 + +--- + +## 10. 验收口径 + +本轮优化建议至少按以下 8 条验收: + +1. 后端任务与物品模块都已消费统一 `RuntimeWorldContext`。 +2. 后端 `activeThreadIds` 已能区分真实线程与 fallback 线程。 +3. 非武侠/仙侠世界的视觉、怪物、角色模板选择明显更加合理。 +4. 世界 profile 中的 `items` 已形成有限但真实可用的 `item seed` 图谱。 +5. faction/conflict 已可被任务、场景、物品奖励直接引用。 +6. landmark 场景的实体池优先由地标与世界对象决定,不再被模板怪物硬主导。 +7. `storyNpcs` 已进入 archetype 编译链。 +8. 高优先级创作锚点已通过编译层进入运行时,而不是停留在编辑器态。 + +--- + +## 11. 本轮不建议优先做的事 + +- 不建议先扩充更多世界 preset、怪物 preset、场景 preset。 +- 不建议先美化编辑器文案或增加说明性 UI 文本。 +- 不建议在前端直接补更多规则判断,避免继续把世界逻辑留在表现层。 +- 不建议在没有统一后端世界 contract 之前分别微调 quest prompt 和 item prompt。 + +原因很明确: + +**当前最缺的不是内容数量,而是“世界设定是否真的被一致消费”。在这一层没补齐前,继续加内容只会把映射偏差放大。** + +--- + +## 12. 一句话结论 + +这轮优化最应该优先做的,不是继续往世界编辑器里加更多设定项,而是先把 `CustomWorldProfile -> 后端运行时任务/物品 -> 场景/NPC/奖励` 这条真实消费主链补齐;只有先把世界 profile 收束成单一真相源,后续跨题材世界、世界级物件、势力冲突和长尾 NPC 映射才会越做越稳。 diff --git a/packages/shared/src/assets/qwenSprite.ts b/packages/shared/src/assets/qwenSprite.ts index 796dea4e..24ee57da 100644 --- a/packages/shared/src/assets/qwenSprite.ts +++ b/packages/shared/src/assets/qwenSprite.ts @@ -95,27 +95,10 @@ export const QWEN_SPRITE_ACTION_TEMPLATES: QwenSpriteActionTemplate[] = [ ]; const CHIBI_STYLE_TEXT = - 'Q版大头身动作角色,头身比固定控制在 2 到 3 头身,头部占比明显更大,类似横版像素 RPG 可扮演角色的比例,不要写实长身比例。'; + 'Q版大头身动作角色,头身比固定控制在 2 到 2.5 头身,头部占比明显更大,肩宽和骨盆都更收紧,躯干与四肢短而紧凑,接近经典横版像素动作角色的身体比例,不要写实长身比例。'; const PIXEL_STYLE_TEXT = - '参考项目内可扮演角色的像素风动作角色画风,整体是像素游戏角色设计方向,身体始终朝右,适合横版动作 sprite 资产。'; -const SIDE_FACING_RIGHT_TEXT = - '角色采用横版动作素材常用的右向斜侧身站姿,身体整体朝右,但保留少量正面信息,能读到面部轮廓与胸肩结构,不是完全 90 度纯右视图,也不是正面立绘。'; -const SUBJECT_ONLY_TEXT = - '画面中只保留单个角色主体,不要额外人物、动物、召唤物、载具或陪体。'; -const CLEAN_BACKGROUND_TEXT = - '背景固定为纯绿色绿幕,只作为抠像底色,不出现建筑、室内布景、风景、地面道具、漂浮物、烟雾叙事元素或其他角色以外的场景内容。'; -const STYLE_REFERENCE_SCOPE_TEXT = - '参考图不仅用于约束像素画风、颜色组织、头身比例、右朝向和镜头语言,还要严格约束身体结构骨架。生成结果必须优先沿用参考图的人形动作角色身体结构,包括头、躯干、双臂、双腿、站姿重心和整体头身比;可以变化发型、服装、主题配饰和材质,但不要脱离参考图的人形身体结构。'; -const CONCEPT_INTERPRETATION_TEXT = - '请先拆解设定中的“身份词、主题词、身体结构词”。如果文字设定没有明确要求非人身体结构,默认优先生成人形拟人化角色,让主题词主要体现在服装、头饰、冠冕、权杖、纹样、材质和发光装饰上。'; -const HUMANLIKE_PRIORITY_TEXT = - '默认优先使用参考图对应的人类或类人动作角色骨架,保持清楚的头、躯干、手臂和双腿轮廓,这样更适合横版动作 sprite。只有当文字设定明确要求鱼尾、触手身体、伞盖头部、无双腿、漂浮体、凝胶体或其他非人结构时,才改为对应非人身体。'; -const CONCEPT_HIERARCHY_TEXT = - '视觉优先级应当是:身体结构词第一,身份词第二,主题词第三。没有明确身体结构词时,默认用人形拟人化表现,再把主题词转译成服装和装饰。'; -const THEME_APPLICATION_BOUNDARY_TEXT = - '主题词默认只作用在角色自身的服装剪裁、材质、纹样、饰品、武器和发光细节上,不要把主题词自动扩写成背景建筑、自然场景、漂浮装饰或额外环境物件。'; -const CHIBI_CHARACTER_TEXT = - '即使角色带有怪物或海洋主题,也优先做成 Q版可爱的人形动作角色,方便读图和后续动画化。'; + '像素风画风,整体是像素游戏角色设计方向,深色粗轮廓配合清晰大色块,侧脸五官简化,发型、服装、配饰优先形成醒目剪影,身体始终朝右,适合横版动作 sprite 资产。'; + export function getActionTemplateById(id: QwenSpriteActionTemplateId) { return ( @@ -130,9 +113,9 @@ export function buildMasterPrompt(characterBrief: string) { `视角要求:角色采用横版动作素材常用的右向斜侧身站姿,身体整体朝右,但保留少量正面信息,能读到面部轮廓与胸肩结构,不是完全 90 度纯右视图,也不是正面立绘。`, `主体要求:画面中只保留单个角色主体,不要额外人物、动物、召唤物、载具或陪体。`, `画面要求:1:1 正方形画布,画面中心构图,角色主体完整置于画面中央,不要裁切主体顶部和底部,不要镜头透视,不要特写。背景固定为纯绿色绿幕,只作为抠像底色,不出现建筑、室内布景、风景、地面道具、漂浮物、烟雾叙事元素或其他角色以外的场景内容。`, - `风格要求:Q版大头身动作角色,头身比固定控制在 2 到 3 头身,头部占比明显更大,类似横版像素 RPG 可扮演角色的比例,不要写实长身比例。高可读性游戏角色设定图,偏像素动画前置设计稿,形体清晰,服装层次明确,道具/权杖/武器如有则存在关系合理,便于后续连续动作生成。`, - '请先拆解设定中的“身份词、主题词、身体结构词”。如果文字设定没有明确要求非人身体结构,默认优先使用参考图对应的人类或类人动作角色骨架,保持清楚的头、躯干、手臂和双腿轮廓,只有当文字设定明确要求鱼尾、触手身体、伞盖头部、无双腿、漂浮体、凝胶体或其他非人结构时,才改为对应非人身体。', - '主题词默认只作用在角色自身的服装剪裁、材质、纹样、饰品、武器和发光细节上,不要把主题词自动扩写成背景建筑、自然场景、漂浮装饰或额外环境物件。', + `风格要求:Q版大头身动作角色,清爽可爱,头身比固定控制在 2 到 2.5 头身,头部占比明显更大,肩宽和骨盆都更收紧,躯干与四肢短而紧凑。深色粗轮廓配合清晰大色块,侧脸五官简化,发型、服装、配饰优先形成醒目剪影,适合横版动作 sprite 资产。高可读性游戏角色设定图,形体清晰,服装层次明确,便于后续连续动作生成。`, + '请先拆解设定中的“身份词、主题词、身体结构词”。如果文字设定没有明确要求非人身体结构,默认优先使用参考图对应的人类或类人动作角色骨架,保持清楚的头、躯干、手臂和双腿轮廓,只有当文字设定明确要求非人结构时,才改为对应非人身体。', + '主题词默认只作用在角色自身的服装剪裁、材质、纹样、饰品、发光细节上,不要把主题词自动扩写成背景建筑、自然场景、漂浮装饰或额外环境物件。', '视觉优先级应当是:身体结构词第一,身份词第二,主题词第三。没有明确身体结构词时,默认用人形拟人化表现,再把主题词转译成服装和装饰。', characterBrief.trim(), ] @@ -152,7 +135,7 @@ export function buildVideoActionPrompt(options: { `视角要求:角色采用横版动作素材常用的右向斜侧身站姿,身体整体朝右,但保留少量正面信息,能读到面部轮廓与胸肩结构,不是完全 90 度纯右视图,也不是正面立绘。`, `主体要求:画面中只保留单个角色主体,不要额外人物、动物、召唤物、载具或陪体。`, `画面要求:1:1 正方形画布,画面中心构图,角色主体完整置于画面中央,不要裁切主体顶部和底部,不要镜头透视,不要特写。背景固定为纯绿色绿幕,只作为抠像底色,不出现建筑、室内布景、风景、地面道具、漂浮物、烟雾叙事元素或其他角色以外的场景内容。`, - `风格要求:Q版大头身动作角色,头身比固定控制在 2 到 3 头身,头部占比明显更大,类似横版像素 RPG 可扮演角色的比例,不要写实长身比例。高可读性游戏角色设定图,偏像素动画前置设计稿,形体清晰,服装层次明确,道具/权杖/武器如有则存在关系合理,便于后续连续动作生成。`, + `风格要求:${CHIBI_STYLE_TEXT} ${PIXEL_STYLE_TEXT} 清爽可爱,高可读性游戏角色设定图,偏像素动画前置设计稿,形体清晰,服装层次明确,道具/权杖/武器如有则存在关系合理,便于后续连续动作生成。`, `动作结构:${options.actionTemplate.sequenceLines.join(';')}。结尾要求:${options.actionTemplate.ending}。`, options.useChromaKey ? '背景为纯绿色绿幕,无其他人物和场景元素,方便后期抽帧与抠像。' diff --git a/public/generated-character-drafts/story-npc-1/workflow-cache.json b/public/generated-character-drafts/story-npc-1/workflow-cache.json index 56bc64f8..eec4d4dd 100644 --- a/public/generated-character-drafts/story-npc-1/workflow-cache.json +++ b/public/generated-character-drafts/story-npc-1/workflow-cache.json @@ -1,7 +1,7 @@ { - "characterId": "story-npc-艾莉丝-1", + "characterId": "story-npc-萧震-1", "visualPromptText": "机甲战士", - "animationPromptText": "", + "animationPromptText": "擅长近战,以萧家绝学为主,战斗时气势磅礴,招式刚猛有力 探寻失落信标遗迹,提升萧家实力,对抗新威胁势力 沉稳老练,处事果断,对家族后人严厉又关心,面对危机临危不乱", "visualDrafts": [ { "id": "candidate-1", @@ -73,5 +73,5 @@ "previewVideoPath": "/generated-character-drafts/story-npc-1/animation/hurt/animation-video-1776486975795/preview.mp4" } }, - "updatedAt": "2026-04-18T04:37:03.271Z" + "updatedAt": "2026-04-18T05:23:09.801Z" } diff --git a/server-node/src/app.test.ts b/server-node/src/app.test.ts index 09b1786d..e65a6d69 100644 --- a/server-node/src/app.test.ts +++ b/server-node/src/app.test.ts @@ -3131,6 +3131,196 @@ test('runtime snapshot persistence accepts null currentStory payloads', async () }); }); +test('runtime snapshot persistence syncs custom world asset configs into snapshot and profile storage', async () => { + await withTestServer( + 'persistence-custom-world-assets', + async ({ baseUrl, context }) => { + const entry = await authEntry( + baseUrl, + 'playercustomworldassets', + 'secret123', + ); + + const saveResponse = await httpRequest( + `${baseUrl}/api/runtime/save/snapshot`, + withBearer(entry.token, { + method: 'PUT', + body: JSON.stringify({ + gameState: { + currentScene: 'Story', + worldType: 'CUSTOM', + playerCharacter: { + id: 'playable-asset-role', + portrait: + '/generated-characters/playable-asset-role/visual/visual-1/master.png', + generatedVisualAssetId: 'visual-1', + generatedAnimationSetId: 'animation-set-1', + animationMap: { + idle: { + folder: 'idle', + prefix: 'Idle', + frames: 4, + basePath: + '/generated-animations/playable-asset-role/animation-set-1/idle', + }, + }, + }, + currentScenePreset: { + id: 'custom-scene-landmark-1', + name: '潮声断桥', + description: '旧桥横在潮雾之上。', + imageSrc: + '/generated-custom-world-scenes/cw-profile-asset/landmark-1/scene.png', + }, + customWorldProfile: { + id: 'cw-profile-asset', + name: '潮雾裂港', + subtitle: '退潮时响起旧讯号', + summary: '雾与潮共同切开港湾边境。', + tone: '冷潮压城,旧案未散', + playerGoal: '追出失落讯标的去向', + settingText: '一座被潮雾与旧讯号撕开的港湾世界。', + templateWorldType: 'WUXIA', + compatibilityTemplateWorldType: 'WUXIA', + majorFactions: ['潮关守备'], + coreConflicts: ['讯标争夺'], + playableNpcs: [ + { + id: 'playable-asset-role', + name: '沈潮', + title: '归港行者', + role: '可扮演角色', + description: '总盯着退潮后的暗线。', + backstory: '他从失讯后的航路里活着回来。', + personality: '谨慎克制', + motivation: '找回失落讯标', + combatStyle: '借潮势游走压制', + initialAffinity: 18, + relationshipHooks: ['识得旧港规矩'], + tags: ['潮港', '追迹'], + backstoryReveal: { + publicSummary: '他像一直在等潮声回信。', + chapters: [], + }, + skills: [], + initialItems: [], + }, + ], + storyNpcs: [], + items: [], + camp: { + name: '归潮居', + description: '退潮后还能落脚的旧屋。', + dangerLevel: 'low', + }, + landmarks: [ + { + id: 'landmark-1', + name: '潮声断桥', + description: '旧桥横在潮雾之上。', + dangerLevel: 'medium', + sceneNpcIds: [], + connections: [], + }, + ], + attributeSchema: { + slots: [], + }, + }, + }, + bottomTab: 'adventure', + currentStory: { + text: '潮声还在桥下回荡。', + options: [], + }, + }), + }), + ); + + const savePayload = (await saveResponse.json()) as { + gameState: { + customWorldProfile: { + playableNpcs: Array<{ + imageSrc?: string; + generatedVisualAssetId?: string; + generatedAnimationSetId?: string; + animationMap?: Record; + }>; + landmarks: Array<{ + imageSrc?: string; + }>; + } | null; + }; + }; + + assert.equal(saveResponse.status, 200); + assert.equal( + savePayload.gameState.customWorldProfile?.playableNpcs[0]?.imageSrc, + '/generated-characters/playable-asset-role/visual/visual-1/master.png', + ); + assert.equal( + savePayload.gameState.customWorldProfile?.playableNpcs[0] + ?.generatedVisualAssetId, + 'visual-1', + ); + assert.equal( + savePayload.gameState.customWorldProfile?.playableNpcs[0] + ?.generatedAnimationSetId, + 'animation-set-1', + ); + assert.equal( + savePayload.gameState.customWorldProfile?.playableNpcs[0]?.animationMap + ?.idle?.basePath, + '/generated-animations/playable-asset-role/animation-set-1/idle', + ); + assert.equal( + savePayload.gameState.customWorldProfile?.landmarks[0]?.imageSrc, + '/generated-custom-world-scenes/cw-profile-asset/landmark-1/scene.png', + ); + + const persistedRows = await context.db.query<{ + payload: { + playableNpcs?: Array<{ + imageSrc?: string; + generatedVisualAssetId?: string; + generatedAnimationSetId?: string; + animationMap?: Record; + }>; + landmarks?: Array<{ + imageSrc?: string; + }>; + }; + }>( + `SELECT payload_json AS payload + FROM custom_world_profiles + WHERE user_id = $1 + AND profile_id = $2`, + [entry.user.id, 'cw-profile-asset'], + ); + + assert.equal(persistedRows.rows.length, 1); + assert.equal( + persistedRows.rows[0]?.payload?.playableNpcs?.[0]?.imageSrc, + '/generated-characters/playable-asset-role/visual/visual-1/master.png', + ); + assert.equal( + persistedRows.rows[0]?.payload?.playableNpcs?.[0] + ?.generatedAnimationSetId, + 'animation-set-1', + ); + assert.equal( + persistedRows.rows[0]?.payload?.playableNpcs?.[0]?.animationMap?.idle + ?.basePath, + '/generated-animations/playable-asset-role/animation-set-1/idle', + ); + assert.equal( + persistedRows.rows[0]?.payload?.landmarks?.[0]?.imageSrc, + '/generated-custom-world-scenes/cw-profile-asset/landmark-1/scene.png', + ); + }, + ); +}); + test('runtime snapshot persistence returns hydrated snapshots for frontend restore flows', async () => { await withTestServer('persistence-hydrated-snapshot', async ({ baseUrl }) => { const entry = await authEntry( diff --git a/server-node/src/modules/assets/characterAssetRoutes.test.ts b/server-node/src/modules/assets/characterAssetRoutes.test.ts index abec89fa..d6a3f4f0 100644 --- a/server-node/src/modules/assets/characterAssetRoutes.test.ts +++ b/server-node/src/modules/assets/characterAssetRoutes.test.ts @@ -238,7 +238,9 @@ test('character visual generation converts public reference images into data url assert.match(content[0]?.text ?? '', /右向斜侧身/u); assert.match(content[0]?.text ?? '', /纯绿色绿幕/u); assert.match(content[0]?.text ?? '', /1:1 正方形画幅|1:1 正方形画布/u); - assert.match(content[0]?.text ?? '', /2 到 3 头身/u); + assert.match(content[0]?.text ?? '', /2 到 2\.5 头身|2 到 3 头身/u); + assert.match(content[0]?.text ?? '', /躯干与四肢短而紧凑/u); + assert.match(content[0]?.text ?? '', /深色粗轮廓配合清晰大色块/u); assert.match(content[0]?.text ?? '', /不要把主题词自动扩写成背景建筑/u); assert.doesNotMatch(content[0]?.text ?? '', /水母国王/u); assert.match(content[1]?.image ?? '', /^data:image\/png;base64,/u); @@ -287,7 +289,9 @@ test('character prompt bundle generation falls back to local defaults when llm c assert.match(payload.visualPromptText, /港口向导/u); assert.match(payload.visualPromptText, /右向斜侧身/u); assert.match(payload.visualPromptText, /纯绿色绿幕/u); - assert.match(payload.visualPromptText, /2 到 3 头身/u); + assert.match(payload.visualPromptText, /2 到 2\.5 头身/u); + assert.match(payload.visualPromptText, /躯干与四肢短而紧凑/u); + assert.match(payload.visualPromptText, /深色粗轮廓配合清晰大色块/u); assert.match(payload.animationPromptText, /动作/u); assert.match(payload.scenePromptText, /场景/u); }); @@ -431,6 +435,104 @@ test('character workflow cache skips rewriting unchanged payloads', async () => ); }); +test('character workflow cache stays isolated for different character ids', async () => { + const tempRoot = fs.mkdtempSync( + path.join(os.tmpdir(), 'genarrative-character-workflow-cache-isolated-'), + ); + + await withAssetRouteServer( + createTestConfig(tempRoot, 'http://127.0.0.1:9/api/v1'), + async (assetBaseUrl) => { + const firstPayload = { + characterId: '巡海夜灯', + visualPromptText: '夜灯守望者', + animationPromptText: '短刀前压,动作克制', + visualDrafts: [ + { + id: 'draft-1', + label: '候选 1', + imageSrc: '/generated-character-drafts/sea-lantern/draft-1.png', + width: 1024, + height: 1024, + }, + ], + selectedVisualDraftId: 'draft-1', + selectedAnimation: 'idle', + }; + const secondPayload = { + characterId: '雾港引路人', + visualPromptText: '雾港引路者', + animationPromptText: '提灯侧身,站姿稳定', + visualDrafts: [ + { + id: 'draft-2', + label: '候选 2', + imageSrc: '/generated-character-drafts/fog-guide/draft-2.png', + width: 1024, + height: 1024, + }, + ], + selectedVisualDraftId: 'draft-2', + selectedAnimation: 'run', + }; + + const firstSaveResponse = await fetch( + `${assetBaseUrl}/api/assets/character-workflow-cache`, + { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(firstPayload), + }, + ); + assert.equal(firstSaveResponse.status, 200); + + const secondSaveResponse = await fetch( + `${assetBaseUrl}/api/assets/character-workflow-cache`, + { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(secondPayload), + }, + ); + assert.equal(secondSaveResponse.status, 200); + + const firstReadResponse = await fetch( + `${assetBaseUrl}/api/assets/character-workflow-cache/${encodeURIComponent(firstPayload.characterId)}`, + ); + assert.equal(firstReadResponse.status, 200); + const firstReadPayload = (await firstReadResponse.json()) as { + cache: { + characterId: string; + visualPromptText: string; + } | null; + }; + + const secondReadResponse = await fetch( + `${assetBaseUrl}/api/assets/character-workflow-cache/${encodeURIComponent(secondPayload.characterId)}`, + ); + assert.equal(secondReadResponse.status, 200); + const secondReadPayload = (await secondReadResponse.json()) as { + cache: { + characterId: string; + visualPromptText: string; + } | null; + }; + + assert.equal(firstReadPayload.cache?.characterId, firstPayload.characterId); + assert.equal(firstReadPayload.cache?.visualPromptText, firstPayload.visualPromptText); + assert.equal(secondReadPayload.cache?.characterId, secondPayload.characterId); + assert.equal( + secondReadPayload.cache?.visualPromptText, + secondPayload.visualPromptText, + ); + }, + ); +}); + test('character animation publish returns frame dimensions in animation map', async () => { const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'genarrative-character-animation-publish-')); @@ -755,6 +857,103 @@ test('character animation non-loop image-to-video uses first and last master fra ); }); +test('character animation die image-to-video does not send a last frame reference', async () => { + const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'genarrative-character-kf2v-die-')); + const publicDir = path.join(tempRoot, 'public'); + fs.mkdirSync(publicDir, { recursive: true }); + fs.writeFileSync(path.join(publicDir, 'visual.png'), PNG_BUFFER); + + let videoSynthesisPayloadText = ''; + + await withHttpServer( + (dashScopeBaseUrl) => async (req, res) => { + const url = new URL(req.url || '/', dashScopeBaseUrl); + + if ( + req.method === 'POST' && + url.pathname === '/api/v1/services/aigc/image2video/video-synthesis' + ) { + videoSynthesisPayloadText = (await readRequestBody(req)).toString('utf8'); + sendJson(res, { + output: { + task_id: 'video-task-kf2v-die-1', + }, + }); + return; + } + + if (req.method === 'GET' && url.pathname === '/api/v1/tasks/video-task-kf2v-die-1') { + sendJson(res, { + output: { + task_status: 'SUCCEEDED', + video_url: `${dashScopeBaseUrl}/downloads/preview.mp4`, + }, + }); + return; + } + + if (req.method === 'GET' && url.pathname === '/downloads/preview.mp4') { + res.statusCode = 200; + res.setHeader('Content-Type', 'video/mp4'); + res.end(MP4_BUFFER); + return; + } + + res.statusCode = 404; + res.end('not found'); + }, + async (dashScopeBaseUrl) => { + const config = createTestConfig(tempRoot, `${dashScopeBaseUrl}/api/v1`); + await withAssetRouteServer(config, async (assetBaseUrl) => { + const response = await fetch( + `${assetBaseUrl}/api/assets/character-animation/generate`, + { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + characterId: 'harbor-guide', + strategy: 'image-to-video', + animation: 'die', + promptText: '倒地后停在终止姿态', + characterBriefText: '旧港守望者', + visualSource: '/visual.png', + referenceImageDataUrls: [], + referenceVideoDataUrls: [], + frameCount: 8, + fps: 8, + durationSeconds: 4, + loop: false, + useChromaKey: true, + resolution: '720P', + imageSequenceModel: 'wan2.7-image-pro', + videoModel: 'wan2.7-i2v', + referenceVideoModel: 'wan2.7-r2v', + motionTransferModel: 'wan2.2-animate-move', + }), + }, + ); + + assert.equal(response.status, 200); + + const videoPayload = JSON.parse(videoSynthesisPayloadText) as { + model: string; + input: { + first_frame_url?: string; + last_frame_url?: string; + prompt?: string; + }; + }; + assert.equal(videoPayload.model, 'wan2.2-kf2v-flash'); + assert.match(videoPayload.input.first_frame_url ?? '', /^data:image\/png;base64,/u); + assert.equal(videoPayload.input.last_frame_url, undefined); + assert.match(videoPayload.input.prompt ?? '', /尾帧停在死亡结束姿态/u); + }); + }, + ); +}); + test('character animation loop image-to-video uses wan2.6-i2v-flash with img_url only', async () => { const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'genarrative-character-i2v-loop-')); const publicDir = path.join(tempRoot, 'public'); diff --git a/server-node/src/modules/assets/characterAssetRoutes.ts b/server-node/src/modules/assets/characterAssetRoutes.ts index ed2a2113..85c86603 100644 --- a/server-node/src/modules/assets/characterAssetRoutes.ts +++ b/server-node/src/modules/assets/characterAssetRoutes.ts @@ -1,3 +1,4 @@ +import { createHash } from 'node:crypto'; import { mkdir, readFile, writeFile } from 'node:fs/promises'; import http, { type IncomingMessage, @@ -349,7 +350,7 @@ function buildFallbackCharacterPromptBundle(params: { return { visualPromptText: [ `${characterAnchor},${roleAnchor}。`, - '单人全身,2D 横版 RPG 角色标准设定图,1:1 正方形画幅,头身比控制在 2 到 3 头身,右向斜侧身站立,身体整体朝右但保留少量正面信息,脚底完整可见,服装、发型、武器和轮廓稳定清楚。', + '单人全身,2D 横版 RPG 角色标准设定图,1:1 正方形画幅,头身比控制在 2 到 2.5 头身,偏大头身,靠头部、发型、服装、配饰表现角色记忆点,躯干与四肢短而紧凑,五官简化,深色粗轮廓配合清晰大色块,右向斜侧身站立,身体整体朝右但保留少量正面信息,服装、发型、轮廓稳定清楚。', `外观气质围绕:${descriptionAnchor}。`, combatAnchor ? `战斗识别点:${combatAnchor}。` : '', tagAnchor, @@ -359,7 +360,7 @@ function buildFallbackCharacterPromptBundle(params: { .join(' '), animationPromptText: [ `${characterAnchor}的核心动作试片。`, - '保持同一角色的服装、发型、武器和体型一致,镜头稳定,侧身朝右,动作连贯。', + '保持同一角色的服装、发型、体型一致,镜头稳定,侧身朝右,动作连贯。', combatAnchor ? `动作气质参考:${combatAnchor}。` : '', params.personality ? `角色气质补充:${params.personality}。` : '', '发力起手明确,过程干净,收招利落,避免漂移和变形。', @@ -560,11 +561,17 @@ function getJobRecordPath( } function getCharacterWorkflowCachePath(rootDir: string, characterId: string) { + const readableSegment = sanitizePathSegment(characterId); + const characterCacheKey = createHash('sha256') + .update(characterId, 'utf8') + .digest('hex') + .slice(0, 24); + return path.resolve( rootDir, 'public', 'generated-character-drafts', - sanitizePathSegment(characterId), + `${readableSegment}-${characterCacheKey}`, 'workflow-cache.json', ); } @@ -1163,7 +1170,9 @@ function buildNpcAnimationPrompt(options: { const actionDetailText = sanitizeAnimationPromptText(options.promptText, 140); const loopRule = options.loop ? '这是循环动作,直接进入动作循环中段,不要开场静止站桩,不要把主参考图原样作为第一帧。' - : '这是非循环动作,首帧和尾帧都要回到参考主图角色形象,中段完成动作变化。'; + : options.animation === 'die' + ? '这是死亡终结动作,首帧参考主图角色形象即可,尾帧停在死亡结束姿态,不要回到主图形象。' + : '这是非循环动作,首帧和尾帧都要回到参考主图角色形象,中段完成动作变化。'; if (options.actionTemplateId) { return [ @@ -1963,9 +1972,10 @@ async function handleGenerateCharacterAnimation( `${characterId}-${animation}-visual`, await resolveMediaSourcePayload(rootDir, visualSource), ); - const resolvedLastFrameSource = !loop - ? lastFrameImageDataUrl || visualSource - : ''; + const resolvedLastFrameSource = + !loop && animation !== 'die' + ? lastFrameImageDataUrl || visualSource + : ''; const lastFrameRef = resolvedLastFrameSource ? isKf2vFlash ? await resolveMediaSourceAsDataUrl(rootDir, resolvedLastFrameSource) @@ -2730,7 +2740,9 @@ async function handleGetCharacterWorkflowCache( sendJson(res, 200, { ok: true, cache: - isRecordValue(cache) && typeof cache.characterId === 'string' + isRecordValue(cache) && + typeof cache.characterId === 'string' && + cache.characterId === characterId ? cache : null, }); diff --git a/server-node/src/modules/story/storyActionRoutes.ts b/server-node/src/modules/story/storyActionRoutes.ts index 57c24ed9..f9bc124c 100644 --- a/server-node/src/modules/story/storyActionRoutes.ts +++ b/server-node/src/modules/story/storyActionRoutes.ts @@ -42,6 +42,7 @@ export function createStoryActionRoutes(context: AppContext) { response, await resolveRuntimeStoryAction({ runtimeRepository: context.runtimeRepository, + llmClient: context.llmClient, userId: request.userId!, request: payload, }), diff --git a/server-node/src/modules/story/storyActionService.ts b/server-node/src/modules/story/storyActionService.ts index 7bc4b906..1dff3ff5 100644 --- a/server-node/src/modules/story/storyActionService.ts +++ b/server-node/src/modules/story/storyActionService.ts @@ -2,12 +2,19 @@ import type { RuntimeBattlePresentation, RuntimeStoryActionRequest, RuntimeStoryActionResponse, + RuntimeStoryOptionView, RuntimeStoryPatch, } from '../../../../packages/shared/src/contracts/story.js'; import { conflict, invalidRequest } from '../../errors.js'; import type { RuntimeRepositoryPort } from '../../repositories/runtimeRepository.js'; +import type { UpstreamLlmClient } from '../../services/llmClient.js'; +import { + buildStrictNpcChatDialoguePrompt, + NPC_CHAT_DIALOGUE_STRICT_SYSTEM_PROMPT, +} from '../ai/chatPromptBuilders.js'; +import { generateNextStoryFromOrchestrator } from '../ai/storyOrchestrator.js'; import { resolveCombatAction } from '../combat/combatResolutionService.js'; -import { resolveInventoryStoryAction, isSupportedInventoryStoryFunctionId } from '../inventory/inventoryStoryActionService.js'; +import { isSupportedInventoryStoryFunctionId,resolveInventoryStoryAction } from '../inventory/inventoryStoryActionService.js'; import { ensureNpcInventorySessionState, isSupportedNpcInventoryStoryFunctionId, @@ -21,12 +28,15 @@ import { isSupportedQuestStoryFunctionId, resolveQuestStoryAction, } from '../quest/questStoryActionService.js'; +import { + hydrateSavedSnapshot, + normalizeSavedSnapshotPayload, +} from '../runtime/runtimeSnapshotHydration.js'; import { isSupportedTreasureStoryFunctionId, resolveTreasureStoryAction, } from '../runtime-item/treasureStoryActionService.js'; import { - TASK6_DEFERRED_FUNCTION_IDS, appendStoryHistory, buildAvailableOptions, buildLegacyCurrentStory, @@ -37,14 +47,11 @@ import { isStoryFunctionId, isTask5FunctionId, loadRuntimeSession, + type RuntimeSession, setEncounterNpcState, syncRawGameState, - type RuntimeSession, + TASK6_DEFERRED_FUNCTION_IDS, } from './runtimeSession.js'; -import { - hydrateSavedSnapshot, - normalizeSavedSnapshotPayload, -} from '../runtime/runtimeSnapshotHydration.js'; type StoryResolution = { actionText: string; @@ -55,6 +62,36 @@ type StoryResolution = { toast?: string | null; }; +type JsonRecord = Record; + +type StoryOptionLike = { + functionId: string; + actionText: string; +}; + +type GeneratedStoryPayload = { + storyText: string; + historyResultText: string; + presentationOptions: RuntimeStoryOptionView[]; + savedCurrentStory: JsonRecord; +}; + +const CONTINUE_ADVENTURE_OPTION = { + functionId: 'story_continue_adventure', + actionText: '继续冒险', + detailText: '展开刚刚已经准备好的后续选项。', + scope: 'story', +} satisfies RuntimeStoryOptionView; + +const DEFAULT_STORY_OPTION_VISUALS = { + playerAnimation: 'idle', + playerMoveMeters: 0, + playerOffsetY: 0, + playerFacing: 'right', + scrollWorld: false, + monsterChanges: [], +} as const; + function resolveActionText(defaultText: string, request: RuntimeStoryActionRequest) { const payload = request.action.payload; const optionText = @@ -65,6 +102,354 @@ function resolveActionText(defaultText: string, request: RuntimeStoryActionReque return optionText || defaultText; } +function isObject(value: unknown): value is JsonRecord { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function readString(value: unknown) { + return typeof value === 'string' && value.trim() ? value.trim() : ''; +} + +function readArray(value: unknown) { + return Array.isArray(value) ? value : []; +} + +function buildStoryOptionInteraction( + session: RuntimeSession, + option: RuntimeStoryOptionView, +) { + const encounter = session.currentEncounter; + + if (encounter?.kind === 'npc') { + const npcId = encounter.id || encounter.npcName; + const npcActionMap: Record = { + npc_chat: { kind: 'npc', npcId, action: 'chat' }, + npc_help: { kind: 'npc', npcId, action: 'help' }, + npc_fight: { kind: 'npc', npcId, action: 'fight' }, + npc_leave: { kind: 'npc', npcId, action: 'leave' }, + npc_recruit: { kind: 'npc', npcId, action: 'recruit' }, + npc_spar: { kind: 'npc', npcId, action: 'spar' }, + npc_trade: { kind: 'npc', npcId, action: 'trade' }, + npc_gift: { kind: 'npc', npcId, action: 'gift' }, + npc_quest_accept: { kind: 'npc', npcId, action: 'quest_accept' }, + npc_quest_turn_in: { kind: 'npc', npcId, action: 'quest_turn_in' }, + }; + + return npcActionMap[option.functionId]; + } + + if (encounter?.kind === 'treasure') { + const treasureActionMap: Record = { + treasure_secure: { kind: 'treasure', action: 'secure' }, + treasure_inspect: { kind: 'treasure', action: 'inspect' }, + treasure_leave: { kind: 'treasure', action: 'leave' }, + }; + + return treasureActionMap[option.functionId]; + } + + return undefined; +} + +function buildStoryOptionFromRuntimeOption( + session: RuntimeSession, + option: RuntimeStoryOptionView, +) { + const detailParts = [option.detailText, option.disabled ? option.reason : null] + .filter(Boolean) + .join(' '); + + return { + functionId: option.functionId, + actionText: option.actionText, + text: option.actionText, + detailText: detailParts || undefined, + visuals: DEFAULT_STORY_OPTION_VISUALS, + interaction: buildStoryOptionInteraction(session, option), + } satisfies JsonRecord; +} + +function buildStoryOptionsFromRuntimeOptions( + session: RuntimeSession, + options: RuntimeStoryOptionView[], +) { + return options + .filter((option) => !option.disabled) + .map((option) => buildStoryOptionFromRuntimeOption(session, option)); +} + +function escapeRegExp(value: string) { + return value.replace(/[\\^$*+?.()|[\]{}]/g, '\\$&'); +} + +function normalizeDialogueSpeakerName(rawSpeakerName: string) { + return rawSpeakerName + .trim() + .replace( + /^[[\]{}()<>\u300a\u300b\u300c\u300d\u300e\u300f\u3010\u3011\uFF08\uFF09]+/u, + '', + ) + .replace( + /[[\]{}()<>\u300a\u300b\u300c\u300d\u300e\u300f\u3010\u3011\uFF08\uFF09]+$/u, + '', + ) + .replace(/^(?:\u540c\u4f34|\u961f\u53cb)\s*/u, '') + .trim(); +} + +function parseDialogueTurns(text: string, npcName: string) { + const turns: JsonRecord[] = []; + const dialogueColonPattern = '(?:\\uFF1A|:)'; + const playerPrefixPattern = new RegExp( + '^(?:\\\\u4f60|\\\\u73a9\\\\u5bb6|\\\\u4e3b\\\\u89d2)\\\\s*' + + dialogueColonPattern + + '\\\\s*(.+)$', + 'u', + ); + const npcPrefixPattern = new RegExp( + '^' + + escapeRegExp(npcName) + + '\\\\s*' + + dialogueColonPattern + + '\\\\s*(.+)$', + 'u', + ); + const namedSpeakerPattern = new RegExp( + '^(.{1,24}?)\\\\s*' + dialogueColonPattern + '\\\\s*(.+)$', + 'u', + ); + const lines = text + .replace(/\r/g, '') + .split('\n') + .map((line) => line.trim()) + .filter(Boolean); + + for (const line of lines) { + const playerMatch = line.match(playerPrefixPattern); + const playerText = playerMatch?.[1]?.trim(); + if (playerText) { + turns.push({ speaker: 'player', text: playerText }); + continue; + } + + const npcMatch = line.match(npcPrefixPattern); + const npcText = npcMatch?.[1]?.trim(); + if (npcText) { + turns.push({ speaker: 'npc', speakerName: npcName, text: npcText }); + continue; + } + + const namedSpeakerMatch = line.match(namedSpeakerPattern); + if (namedSpeakerMatch?.[1] && namedSpeakerMatch[2]) { + const speakerName = normalizeDialogueSpeakerName(namedSpeakerMatch[1]); + const speakerText = namedSpeakerMatch[2].trim(); + + if (speakerName && speakerText) { + turns.push({ + speaker: speakerName === npcName ? 'npc' : 'companion', + speakerName, + text: speakerText, + }); + continue; + } + } + + if (line.startsWith('你:') || line.startsWith('你:')) { + turns.push({ speaker: 'player', text: line.slice(2).trim() }); + continue; + } + + if (line.startsWith(npcName + ':') || line.startsWith(npcName + ':')) { + turns.push({ + speaker: 'npc', + speakerName: npcName, + text: line.slice(npcName.length + 1).trim(), + }); + continue; + } + + const lastTurn = turns[turns.length - 1]; + if (lastTurn && typeof lastTurn.text === 'string') { + lastTurn.text += line; + } + } + + return turns.filter( + (turn) => typeof turn.text === 'string' && turn.text.length > 0, + ); +} + +function buildDialogueCurrentStory(params: { + session: RuntimeSession; + npcName: string; + text: string; + deferredOptions: RuntimeStoryOptionView[]; +}) { + return { + text: params.text, + options: buildStoryOptionsFromRuntimeOptions( + params.session, + [CONTINUE_ADVENTURE_OPTION], + ), + displayMode: 'dialogue', + dialogue: parseDialogueTurns(params.text, params.npcName), + streaming: false, + deferredOptions: buildStoryOptionsFromRuntimeOptions( + params.session, + params.deferredOptions, + ), + } satisfies JsonRecord; +} + +function buildStoryPromptContext(session: RuntimeSession, extras: JsonRecord = {}) { + const scenePreset = isObject(session.rawGameState.currentScenePreset) + ? session.rawGameState.currentScenePreset + : null; + + return { + sceneName: + readString(scenePreset?.name) || + readString(session.rawGameState.currentScene) || + '当前区域', + sceneDescription: + readString(scenePreset?.description) || + readString(session.rawGameState.sceneDescription) || + '周围气氛仍在继续变化。', + encounterName: session.currentEncounter?.npcName || null, + encounterId: session.currentEncounter?.id || null, + playerHp: session.playerHp, + playerMaxHp: session.playerMaxHp, + playerMana: session.playerMana, + playerMaxMana: session.playerMaxMana, + inBattle: session.inBattle, + pendingSceneEncounter: false, + ...extras, + } satisfies JsonRecord; +} + +function buildHistoryMoments( + session: RuntimeSession, + appendedEntries: Array<{ text: string; historyRole: 'action' | 'result' }>, +) { + return [ + ...session.storyHistory.map((entry) => ({ + text: entry.text, + historyRole: entry.historyRole, + })), + ...appendedEntries, + ]; +} + +function buildPromptOptions(options: RuntimeStoryOptionView[]) { + return options + .filter((option) => !option.disabled) + .map((option) => ({ + functionId: option.functionId, + actionText: option.actionText, + text: option.actionText, + })); +} + +function mergeGeneratedRuntimeOptions( + baseOptions: RuntimeStoryOptionView[], + generatedOptions: StoryOptionLike[], +) { + if (generatedOptions.length === 0) { + return baseOptions; + } + + const buckets = new Map(); + baseOptions.forEach((option) => { + const bucket = buckets.get(option.functionId) ?? []; + bucket.push(option); + buckets.set(option.functionId, bucket); + }); + + const resolved: RuntimeStoryOptionView[] = []; + const consumed = new Set(); + generatedOptions.forEach((option) => { + const bucket = buckets.get(option.functionId); + const matched = bucket?.shift(); + if (!matched) { + return; + } + + consumed.add(matched); + resolved.push({ + ...matched, + actionText: readString(option.actionText) || matched.actionText, + }); + }); + + if (resolved.length === 0) { + return baseOptions; + } + + const remaining = baseOptions.filter((option) => !consumed.has(option)); + return [...resolved, ...remaining]; +} + +function buildOpeningCampChatContext(session: RuntimeSession) { + const encounter = session.currentEncounter; + if (!encounter || encounter.kind !== 'npc') { + return {}; + } + + const rawEncounter = isObject(session.rawGameState.currentEncounter) + ? session.rawGameState.currentEncounter + : null; + if (readString(rawEncounter?.specialBehavior) !== 'camp_companion') { + return {}; + } + + const npcState = getEncounterNpcState(session); + if (!npcState || npcState.chattedCount > 2) { + return {}; + } + + const openingActionText = `在营地与 ${encounter.npcName} 交换开场判断`; + let openingDialogue = ''; + + for (let index = 0; index < session.storyHistory.length - 1; index += 1) { + const entry = session.storyHistory[index]; + if (!entry || entry.historyRole !== 'action' || entry.text !== openingActionText) { + continue; + } + + for (let nextIndex = index + 1; nextIndex < session.storyHistory.length; nextIndex += 1) { + const nextEntry = session.storyHistory[nextIndex]; + if (!nextEntry) { + continue; + } + if (nextEntry.historyRole === 'action') { + break; + } + if (nextEntry.text.trim()) { + openingDialogue = nextEntry.text.trim(); + break; + } + } + + if (openingDialogue) { + break; + } + } + + if (!openingDialogue) { + return {}; + } + + const playerCharacter = isObject(session.rawGameState.playerCharacter) + ? session.rawGameState.playerCharacter + : null; + const playerName = readString(playerCharacter?.name) || '你'; + + return { + openingCampBackground: `${playerName} 在营地里和 ${encounter.npcName} 终于真正把注意力放回彼此身上,周围暂时没有新的打扰。`, + openingCampDialogue: openingDialogue, + }; +} + function normalizeStatusPatch(session: RuntimeSession) { return { type: 'status_changed', @@ -109,6 +494,128 @@ function buildFallbackStoryText(session: RuntimeSession) { return '当前故事状态已经同步到后端,接下来可以继续推进这一轮运行时动作。'; } +async function generateNpcDialoguePayload(params: { + llmClient: UpstreamLlmClient; + session: RuntimeSession; + actionText: string; + resultText: string; +}): Promise { + const encounter = params.session.currentEncounter; + const playerCharacter = isObject(params.session.rawGameState.playerCharacter) + ? params.session.rawGameState.playerCharacter + : null; + if (!encounter || encounter.kind !== 'npc' || !playerCharacter || !params.session.worldType) { + return null; + } + + const history = buildHistoryMoments(params.session, [ + { text: params.actionText, historyRole: 'action' }, + { text: params.resultText, historyRole: 'result' }, + ]); + const availableOptions = buildAvailableOptions(params.session); + const dialogueText = ( + await params.llmClient.requestMessageContent({ + systemPrompt: NPC_CHAT_DIALOGUE_STRICT_SYSTEM_PROMPT, + userPrompt: buildStrictNpcChatDialoguePrompt({ + worldType: params.session.worldType, + character: playerCharacter, + encounter: params.session.rawGameState.currentEncounter ?? {}, + monsters: readArray(params.session.rawGameState.sceneHostileNpcs).filter(isObject), + history, + context: buildStoryPromptContext(params.session, { + ...buildOpeningCampChatContext(params.session), + }), + topic: params.actionText, + resultSummary: params.resultText, + }), + debugLabel: 'runtime.npc_chat.dialogue', + }) + ).trim(); + + const finalDialogueText = dialogueText || params.resultText; + let deferredOptions = availableOptions; + + try { + const nextStory = await generateNextStoryFromOrchestrator( + params.llmClient, + params.session.worldType, + playerCharacter, + readArray(params.session.rawGameState.sceneHostileNpcs).filter(isObject), + history, + params.actionText, + buildStoryPromptContext(params.session, { + lastFunctionId: 'npc_chat', + ...buildOpeningCampChatContext(params.session), + }), + { + availableOptions: buildPromptOptions(availableOptions), + }, + ); + deferredOptions = mergeGeneratedRuntimeOptions( + availableOptions, + nextStory.options as StoryOptionLike[], + ); + } catch { + deferredOptions = availableOptions; + } + + return { + storyText: finalDialogueText, + historyResultText: finalDialogueText, + presentationOptions: [CONTINUE_ADVENTURE_OPTION], + savedCurrentStory: buildDialogueCurrentStory({ + session: params.session, + npcName: encounter.npcName, + text: finalDialogueText, + deferredOptions, + }), + }; +} + +async function generateReasonedStoryPayload(params: { + llmClient: UpstreamLlmClient; + session: RuntimeSession; + actionText: string; + resultText: string; +}): Promise { + const playerCharacter = isObject(params.session.rawGameState.playerCharacter) + ? params.session.rawGameState.playerCharacter + : null; + if (!playerCharacter || !params.session.worldType) { + return null; + } + + const availableOptions = buildAvailableOptions(params.session); + const history = buildHistoryMoments(params.session, [ + { text: params.actionText, historyRole: 'action' }, + { text: params.resultText, historyRole: 'result' }, + ]); + const nextStory = await generateNextStoryFromOrchestrator( + params.llmClient, + params.session.worldType, + playerCharacter, + readArray(params.session.rawGameState.sceneHostileNpcs).filter(isObject), + history, + params.actionText, + buildStoryPromptContext(params.session), + { + availableOptions: buildPromptOptions(availableOptions), + }, + ); + const resolvedOptions = mergeGeneratedRuntimeOptions( + availableOptions, + nextStory.options as StoryOptionLike[], + ); + const storyText = readString(nextStory.storyText) || params.resultText; + + return { + storyText, + historyResultText: storyText, + presentationOptions: resolvedOptions, + savedCurrentStory: buildLegacyCurrentStory(storyText, resolvedOptions), + }; +} + function resolveStoryFlowAction( session: RuntimeSession, functionId: string, @@ -212,6 +719,7 @@ function resolveStoryFlowAction( export async function resolveRuntimeStoryAction(params: { runtimeRepository: RuntimeRepositoryPort; + llmClient?: UpstreamLlmClient; userId: string; request: RuntimeStoryActionRequest; }) { @@ -295,16 +803,63 @@ export async function resolveRuntimeStoryAction(params: { battle: resolution.battle ?? null, }); - const actionText = resolveActionText(resolution.actionText, params.request); - const storyText = resolution.storyText ?? resolution.resultText; - - appendStoryHistory(session, actionText, resolution.resultText); + let actionText = resolveActionText(resolution.actionText, params.request); + if ( + functionId === 'story_opening_camp_dialogue' && + session.currentEncounter?.kind === 'npc' + ) { + actionText = `在营地与 ${session.currentEncounter.npcName} 交换开场判断`; + } + let storyText = resolution.storyText ?? resolution.resultText; + let historyResultText = resolution.resultText; session.runtimeVersion += 1; session.sessionId = params.request.sessionId; syncRawGameState(session); ensureNpcInventorySessionState(session); - const options = buildAvailableOptions(session); + let options = buildAvailableOptions(session); + let savedCurrentStory: JsonRecord = buildLegacyCurrentStory(storyText, options); + + if ( + params.llmClient && + (functionId === 'npc_chat' || functionId === 'story_opening_camp_dialogue') + ) { + try { + const generatedPayload = await generateNpcDialoguePayload({ + llmClient: params.llmClient, + session, + actionText, + resultText: resolution.resultText, + }); + if (generatedPayload) { + storyText = generatedPayload.storyText; + historyResultText = generatedPayload.historyResultText; + options = generatedPayload.presentationOptions; + savedCurrentStory = generatedPayload.savedCurrentStory; + } + } catch { + savedCurrentStory = buildLegacyCurrentStory(storyText, options); + } + } else if (params.llmClient && isCombatFunctionId(functionId)) { + try { + const generatedPayload = await generateReasonedStoryPayload({ + llmClient: params.llmClient, + session, + actionText, + resultText: resolution.resultText, + }); + if (generatedPayload) { + storyText = generatedPayload.storyText; + historyResultText = generatedPayload.historyResultText; + options = generatedPayload.presentationOptions; + savedCurrentStory = generatedPayload.savedCurrentStory; + } + } catch { + savedCurrentStory = buildLegacyCurrentStory(storyText, options); + } + } + + appendStoryHistory(session, actionText, historyResultText); syncRawGameState(session); const persistedSnapshot = await params.runtimeRepository.putSnapshot( @@ -313,7 +868,7 @@ export async function resolveRuntimeStoryAction(params: { savedAt: new Date().toISOString(), bottomTab: session.snapshotBottomTab, gameState: session.rawGameState, - currentStory: buildLegacyCurrentStory(storyText, options), + currentStory: savedCurrentStory, }), ); @@ -333,7 +888,7 @@ export async function resolveRuntimeStoryAction(params: { { type: 'story_history_append', actionText, - resultText: resolution.resultText, + resultText: historyResultText, }, ...resolution.patches, ], diff --git a/server-node/src/repositories/runtimeRepository.ts b/server-node/src/repositories/runtimeRepository.ts index eb9b60a7..666b456a 100644 --- a/server-node/src/repositories/runtimeRepository.ts +++ b/server-node/src/repositories/runtimeRepository.ts @@ -340,6 +340,199 @@ function buildBuiltinWorldTitle(worldType: string) { } } +function looksLikeGeneratedAssetPath(value: string) { + return /^\/generated-/u.test(value); +} + +function mergeSnapshotRoleAssets( + role: Record, + assets: { + imageSrc?: string | null; + generatedVisualAssetId?: string | null; + generatedAnimationSetId?: string | null; + animationMap?: Record | null; + }, +) { + let changed = false; + const nextRole: Record = { ...role }; + const nextImageSrc = readString(assets.imageSrc); + const nextGeneratedVisualAssetId = readString(assets.generatedVisualAssetId); + const nextGeneratedAnimationSetId = readString( + assets.generatedAnimationSetId, + ); + const nextAnimationMap = asRecord(assets.animationMap); + + if (nextImageSrc && readString(role.imageSrc) !== nextImageSrc) { + nextRole.imageSrc = nextImageSrc; + changed = true; + } + + if ( + nextGeneratedVisualAssetId && + readString(role.generatedVisualAssetId) !== nextGeneratedVisualAssetId + ) { + nextRole.generatedVisualAssetId = nextGeneratedVisualAssetId; + changed = true; + } + + if ( + nextGeneratedAnimationSetId && + readString(role.generatedAnimationSetId) !== nextGeneratedAnimationSetId + ) { + nextRole.generatedAnimationSetId = nextGeneratedAnimationSetId; + changed = true; + } + + if (nextAnimationMap && Object.keys(nextAnimationMap).length > 0) { + nextRole.animationMap = { + ...(asRecord(role.animationMap) ?? {}), + ...nextAnimationMap, + }; + changed = true; + } + + return changed ? nextRole : role; +} + +function syncSnapshotRoleAssetsIntoProfile( + profile: Record, + roleId: string, + assets: Parameters[1], +) { + if (!roleId) { + return profile; + } + + let changed = false; + const syncRoleArray = (value: unknown) => { + if (!Array.isArray(value)) { + return value; + } + + return value.map((entry) => { + if (!asRecord(entry) || readString(entry.id) !== roleId) { + return entry; + } + + const nextEntry = mergeSnapshotRoleAssets(entry, assets); + if (nextEntry !== entry) { + changed = true; + } + return nextEntry; + }); + }; + + const nextPlayableNpcs = syncRoleArray(profile.playableNpcs); + const nextStoryNpcs = syncRoleArray(profile.storyNpcs); + + return changed + ? { + ...profile, + playableNpcs: nextPlayableNpcs, + storyNpcs: nextStoryNpcs, + } + : profile; +} + +function syncSnapshotSceneImageIntoProfile( + profile: Record, + sceneId: string, + imageSrc: string, +) { + if (!sceneId || !imageSrc) { + return profile; + } + + if (sceneId === 'custom-scene-camp') { + const currentCamp = asRecord(profile.camp) ?? {}; + if (readString(currentCamp.imageSrc) === imageSrc) { + return profile; + } + + return { + ...profile, + camp: { + ...currentCamp, + imageSrc, + }, + }; + } + + const landmarkMatch = /^custom-scene-landmark-(\d+)$/u.exec(sceneId); + if (!landmarkMatch || !Array.isArray(profile.landmarks)) { + return profile; + } + + const landmarkIndex = Number.parseInt(landmarkMatch[1] ?? '', 10) - 1; + if ( + !Number.isInteger(landmarkIndex) || + landmarkIndex < 0 || + landmarkIndex >= profile.landmarks.length + ) { + return profile; + } + + const currentLandmark = asRecord(profile.landmarks[landmarkIndex]); + if (!currentLandmark || readString(currentLandmark.imageSrc) === imageSrc) { + return profile; + } + + const nextLandmarks = [...profile.landmarks]; + nextLandmarks[landmarkIndex] = { + ...currentLandmark, + imageSrc, + }; + + return { + ...profile, + landmarks: nextLandmarks, + }; +} + +function syncSnapshotCustomWorldProfile(gameState: unknown) { + const currentGameState = asRecord(gameState); + const currentProfile = asRecord(currentGameState?.customWorldProfile); + if (!currentGameState || !currentProfile) { + return gameState; + } + + let nextProfile = currentProfile; + const playerCharacter = asRecord(currentGameState.playerCharacter); + const playerCharacterId = readString(playerCharacter?.id); + const playerPortrait = readString(playerCharacter?.portrait); + const playerAnimationMap = asRecord(playerCharacter?.animationMap); + const playerHasGeneratedAssets = + Boolean(readString(playerCharacter?.generatedVisualAssetId)) || + Boolean(readString(playerCharacter?.generatedAnimationSetId)) || + Boolean(playerAnimationMap && Object.keys(playerAnimationMap).length > 0) || + looksLikeGeneratedAssetPath(playerPortrait); + + nextProfile = syncSnapshotRoleAssetsIntoProfile(nextProfile, playerCharacterId, { + imageSrc: playerHasGeneratedAssets ? playerPortrait : null, + generatedVisualAssetId: + readString(playerCharacter?.generatedVisualAssetId) || null, + generatedAnimationSetId: + readString(playerCharacter?.generatedAnimationSetId) || null, + animationMap: playerAnimationMap, + }); + + const currentScenePreset = asRecord(currentGameState.currentScenePreset); + nextProfile = syncSnapshotSceneImageIntoProfile( + nextProfile, + readString(currentScenePreset?.id), + readString(currentScenePreset?.imageSrc), + ); + + if (nextProfile === currentProfile) { + return currentGameState; + } + + return { + ...currentGameState, + customWorldProfile: nextProfile, + }; +} + function resolveProfileWorldSnapshotMeta( snapshot: SavedSnapshot, ): ProfileWorldSnapshotMeta | null { @@ -595,6 +788,67 @@ export class RuntimeRepository implements RuntimeRepositoryPort { }); } + private async syncCustomWorldProfileFromSnapshot( + userId: string, + snapshot: SavedSnapshot, + ) { + const gameState = asRecord(snapshot.gameState); + const customWorldProfile = asRecord(gameState?.customWorldProfile); + const profileId = readString(customWorldProfile?.id); + + if (!customWorldProfile || !profileId) { + return; + } + + const payload = normalizeStoredProfile(profileId, customWorldProfile); + const metadata = extractCustomWorldLibraryMetadata(payload); + const syncedAt = snapshot.savedAt || new Date().toISOString(); + + await this.db.query( + `INSERT INTO custom_world_profiles ( + user_id, + profile_id, + payload_json, + updated_at, + author_display_name, + world_name, + subtitle, + summary_text, + cover_image_src, + theme_mode, + playable_npc_count, + landmark_count, + deleted_at + ) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, NULL) + ON CONFLICT (user_id, profile_id) DO UPDATE SET + payload_json = EXCLUDED.payload_json, + updated_at = EXCLUDED.updated_at, + deleted_at = NULL, + world_name = EXCLUDED.world_name, + subtitle = EXCLUDED.subtitle, + summary_text = EXCLUDED.summary_text, + cover_image_src = EXCLUDED.cover_image_src, + theme_mode = EXCLUDED.theme_mode, + playable_npc_count = EXCLUDED.playable_npc_count, + landmark_count = EXCLUDED.landmark_count`, + [ + userId, + profileId, + payload, + syncedAt, + '玩家', + metadata.worldName, + metadata.subtitle, + metadata.summaryText, + metadata.coverImageSrc, + metadata.themeMode, + metadata.playableNpcCount, + metadata.landmarkCount, + ], + ); + } + async getSnapshot(userId: string) { const result = await this.db.query( `SELECT version, @@ -625,7 +879,7 @@ export class RuntimeRepository implements RuntimeRepositoryPort { const snapshot = { version: SAVE_SNAPSHOT_VERSION, savedAt: payload.savedAt, - gameState: payload.gameState, + gameState: syncSnapshotCustomWorldProfile(payload.gameState), bottomTab: payload.bottomTab, currentStory: payload.currentStory, } satisfies SavedSnapshot; @@ -668,6 +922,7 @@ export class RuntimeRepository implements RuntimeRepositoryPort { } satisfies SavedSnapshot; await this.syncProfileDashboardFromSnapshot(userId, persistedSnapshot); + await this.syncCustomWorldProfileFromSnapshot(userId, persistedSnapshot); return persistedSnapshot; } diff --git a/server-node/src/services/customWorldEntityGenerationService.ts b/server-node/src/services/customWorldEntityGenerationService.ts index d0e3002e..f2655ae4 100644 --- a/server-node/src/services/customWorldEntityGenerationService.ts +++ b/server-node/src/services/customWorldEntityGenerationService.ts @@ -451,24 +451,9 @@ function buildFallbackRoleDraft( : `长期活跃于当前世界暗面,能补足场景视角的关键角色。`, 60, ), - visualDescription: clampText( - kind === 'playable' - ? `他保留着适合长期同行的鲜明外形识别点,服装、装备和体态都能直接看出其职责、出身和会如何与玩家并肩行动。` - : `他身上带着与当前局势强绑定的外观痕迹,衣着、器具和整体气质会暴露其长期活动环境与所站的位置。`, - 96, - ), - actionDescription: clampText( - kind === 'playable' - ? '动作表现偏向协作推进与稳定压制,起手克制,发力明确,收招干净。' - : '动作表现偏向试探、牵制与借势,节奏谨慎,但关键时刻会突然加重攻击或位移。', - 72, - ), - sceneVisualDescription: clampText( - profile.landmarks[0]?.description - ? `他的主要活动空间与${profile.landmarks[0].name}相连,场景里能看到${profile.landmarks[0].description}` - : `他的主要活动空间与${profile.name}当前冲突线直接相关,环境里会留下势力痕迹、旧装置和仍在运转的局势线索。`, - 96, - ), + visualDescription: '', + actionDescription: '', + sceneVisualDescription: '', backstory: clampText( `他与${profile.name}当前正在扩张的冲突链紧密相连,知道一些还未公开的内情。`, 80, @@ -567,10 +552,7 @@ function buildFallbackLandmarkDraft(profile: ParsedProfile) { `承接${profile.name}当前主冲突的一处关键新场景,适合继续向外扩张世界关系网。`, 72, ), - visualDescription: clampText( - `这里延续${profile.name}当前主冲突的视觉气质,能看到明确的空间层次、可站立地面、核心建筑或地貌,以及仍在运转的局势痕迹。`, - 88, - ), + visualDescription: '', dangerLevel: 'medium', sceneNpcNames, connections: targetLandmarkNames.map((targetLandmarkName, index) => ({ diff --git a/server-node/src/services/sceneImageService.test.ts b/server-node/src/services/sceneImageService.test.ts index c679006f..f139780d 100644 --- a/server-node/src/services/sceneImageService.test.ts +++ b/server-node/src/services/sceneImageService.test.ts @@ -112,7 +112,7 @@ test('generateSceneImage uses wan2.2-t2i-flash text-to-image payload and saves t if ( req.method === 'POST' && - url.pathname === '/api/v1/services/aigc/image-generation/generation' + url.pathname === '/api/v1/services/aigc/text2image/image-synthesis' ) { sendJson(res, { output: { @@ -168,27 +168,23 @@ test('generateSceneImage uses wan2.2-t2i-flash text-to-image payload and saves t assert.equal(result.actualPrompt, '整理后的场景提示词'); const createRequest = capturedRequests.find( - (entry) => entry.pathname === '/api/v1/services/aigc/image-generation/generation', + (entry) => entry.pathname === '/api/v1/services/aigc/text2image/image-synthesis', ); assert.ok(createRequest?.bodyText); const createPayload = JSON.parse(createRequest.bodyText) as { model: string; input: { - messages: Array<{ - content: Array<{ text?: string; image?: string }>; - }>; - }; - parameters: { + prompt: string; negative_prompt?: string; }; + parameters: Record; }; - const content = createPayload.input.messages[0]?.content ?? []; assert.equal(createPayload.model, 'wan2.2-t2i-flash'); - assert.equal(content[0]?.text, '海雾港口像素风场景'); - assert.equal(content.length, 1); - assert.equal(createPayload.parameters.negative_prompt, '模糊'); + assert.equal(createPayload.input.prompt, '海雾港口像素风场景'); + assert.equal(createPayload.input.negative_prompt, '模糊'); + assert.equal(createPayload.parameters.size, '1280*720'); const savedImagePath = path.join(tempRoot, 'public', result.imageSrc.slice(1)); assert.equal(fs.existsSync(savedImagePath), true); diff --git a/server-node/src/services/sceneImageService.ts b/server-node/src/services/sceneImageService.ts index 98e6532c..16068715 100644 --- a/server-node/src/services/sceneImageService.ts +++ b/server-node/src/services/sceneImageService.ts @@ -130,34 +130,32 @@ async function createSceneImageTask(params: { payload: z.infer; }) { const { baseUrl, apiKey, payload } = params; - const response = await fetch(`${baseUrl}/services/aigc/image-generation/generation`, { - method: 'POST', - headers: { - Authorization: `Bearer ${apiKey}`, - 'Content-Type': 'application/json', - 'X-DashScope-Async': 'enable', + const response = await fetch( + `${baseUrl}/services/aigc/text2image/image-synthesis`, + { + method: 'POST', + headers: { + Authorization: `Bearer ${apiKey}`, + 'Content-Type': 'application/json', + 'X-DashScope-Async': 'enable', + }, + body: JSON.stringify({ + model: payload.model, + input: { + prompt: payload.prompt, + ...(payload.negativePrompt + ? { negative_prompt: payload.negativePrompt } + : {}), + }, + parameters: { + n: 1, + size: payload.size, + prompt_extend: true, + watermark: false, + }, + }), }, - body: JSON.stringify({ - model: payload.model, - input: { - messages: [ - { - role: 'user', - content: [{ text: payload.prompt }], - }, - ], - }, - parameters: { - n: 1, - size: payload.size, - prompt_extend: true, - watermark: false, - ...(payload.negativePrompt - ? { negative_prompt: payload.negativePrompt } - : {}), - }, - }), - }); + ); const responseText = await response.text(); if (!response.ok) { diff --git a/src/components/CharacterAnimator.tsx b/src/components/CharacterAnimator.tsx index 3e69f2d6..c540c6ff 100644 --- a/src/components/CharacterAnimator.tsx +++ b/src/components/CharacterAnimator.tsx @@ -45,14 +45,20 @@ export const CharacterAnimator: React.FC = ({ imageClassName, playbackRate = 1, }) => { - const [frameIndex, setFrameIndex] = useState(1); const config = character.animationMap?.[state] ?? DEFAULT_ANIMATIONS[state] ?? character.animationMap?.[AnimationState.IDLE] ?? DEFAULT_ANIMATIONS[AnimationState.IDLE]; - const startFrame = config.startFrame ?? 1; - const frameCount = config.frames; + const startFrame = + typeof config.startFrame === 'number' && Number.isFinite(config.startFrame) + ? Math.max(1, Math.floor(config.startFrame)) + : 1; + const [frameIndex, setFrameIndex] = useState(startFrame); + const frameCount = + typeof config.frames === 'number' && Number.isFinite(config.frames) + ? Math.max(1, Math.floor(config.frames)) + : 1; const fps = typeof config.fps === 'number' && Number.isFinite(config.fps) ? Math.max(1, config.fps) @@ -72,26 +78,33 @@ export const CharacterAnimator: React.FC = ({ fps, effectivePlaybackRate, ].join('::'); + const endFrame = startFrame + frameCount - 1; + const intervalDelay = Math.max( + 40, + Math.round(1000 / (fps * effectivePlaybackRate)), + ); useEffect(() => { - setFrameIndex(startFrame); + setFrameIndex((current) => (current === startFrame ? current : startFrame)); + }, [animationSignature, startFrame]); + useEffect(() => { if (frameCount <= 1) return; - const endFrame = startFrame + frameCount - 1; - const interval = window.setInterval(() => { - setFrameIndex(prev => { - return prev >= endFrame ? startFrame : prev + 1; + setFrameIndex((current) => { + if (current < startFrame || current > endFrame) { + return startFrame; + } + return current >= endFrame ? startFrame : current + 1; }); - }, Math.max(40, Math.round(1000 / (fps * effectivePlaybackRate)))); + }, intervalDelay); return () => window.clearInterval(interval); }, [ - animationSignature, - effectivePlaybackRate, - fps, + endFrame, frameCount, + intervalDelay, startFrame, ]); diff --git a/src/components/CompanionCampModal.tsx b/src/components/CompanionCampModal.tsx index ca8ef8d3..8e0848ab 100644 --- a/src/components/CompanionCampModal.tsx +++ b/src/components/CompanionCampModal.tsx @@ -286,8 +286,11 @@ export function CompanionCampModal({
营地气氛
- {campMoments.map(moment => ( -
+ {campMoments.map((moment, index) => ( +
{moment}
))} diff --git a/src/components/CustomWorldEntityCatalog.tsx b/src/components/CustomWorldEntityCatalog.tsx index 1c35b315..25178368 100644 --- a/src/components/CustomWorldEntityCatalog.tsx +++ b/src/components/CustomWorldEntityCatalog.tsx @@ -11,6 +11,7 @@ import { useState, } from 'react'; +import { ROLE_TEMPLATE_CHARACTERS } from '../data/characterPresets'; import { getCustomWorldSceneRelativePositionLabel } from '../data/customWorldSceneGraph'; import { resolveCustomWorldCampSceneImage, @@ -184,6 +185,14 @@ function EmptyState({ title }: { title: string }) { ); } +function buildFallbackRenderKey( + value: string | null | undefined, + fallback: string, +) { + const normalizedValue = value?.trim(); + return normalizedValue ? normalizedValue : fallback; +} + function NewBadge() { return ( @@ -349,6 +358,43 @@ function compactTextList(values: Array) { return values.map((value) => value?.trim() ?? '').filter(Boolean); } +function buildPlayableRoleCardDescription( + role: CustomWorldProfile['playableNpcs'][number], +) { + const summary = + role.description.trim() || + role.backstoryReveal.publicSummary.trim() || + role.backstory.trim() || + role.motivation.trim(); + + return compactTextList([role.title || role.role, summary]).join(' / '); +} + +function resolvePlayableRolePreviewImage( + role: CustomWorldProfile['playableNpcs'][number], + previewCharacter: Character | null, +) { + if (previewCharacter?.portrait?.trim()) { + return previewCharacter.portrait; + } + + if (previewCharacter?.avatar?.trim()) { + return previewCharacter.avatar; + } + + if (role.imageSrc?.trim()) { + return role.imageSrc; + } + + const template = role.templateCharacterId + ? ROLE_TEMPLATE_CHARACTERS.find( + (character) => character.id === role.templateCharacterId, + ) ?? null + : null; + + return template?.portrait ?? ''; +} + function toText(value: unknown) { return typeof value === 'string' ? value.trim() : ''; } @@ -1165,166 +1211,95 @@ export function CustomWorldEntityCatalog({ progress={pendingGeneratedEntity.progress} /> ) : null} -
- {readOnly - ? '当前是草稿结果预览,可先浏览角色结构,再回到工作区继续精修。' - : '可扮演角色支持新增、删除与更换外观模板。'} -
{filteredPlayable.length === 0 ? ( ) : ( - filteredPlayable.map((role) => { + filteredPlayable.map((role, index) => { const previewCharacter = previewCharacterById.get(role.id) ?? null; + const previewImageSrc = resolvePlayableRolePreviewImage( + role, + previewCharacter, + ); + const description = buildPlayableRoleCardDescription(role); return ( -
-
+ : null} - actions={ - readOnly ? ( - - onEditTarget({ - kind: 'playable', - mode: 'edit', - id: role.id, - }) - } - tone="sky" - > - 查看详情 - + isSelectionMode={false} + isSelected={false} + layout="compact" + mediaClassName="h-[4.75rem] w-[4.75rem] sm:h-[5.25rem] sm:w-[5.25rem]" + onClick={() => + onEditTarget({ + kind: 'playable', + mode: 'edit', + id: role.id, + }) + } + media={ + previewCharacter ? ( + + ) : previewImageSrc ? ( + {role.name} ) : ( -
- - onEditTarget({ - kind: 'playable', - mode: 'edit', - id: role.id, - }) - } - tone="sky" - > - 编辑 - - removePlayable(role.id, role.name)} - tone="rose" - > - 删除 - +
+ {role.name.slice(0, 4) || '角色'}
) } - > -
-
- {previewCharacter ? ( - - ) : null} + /> +
+ {lockedCharacterNames.has(role.name.trim()) ? ( + + 创作者锁定 + + ) : null} + + 初始好感 {role.initialAffinity} + + {role.generatedVisualAssetId ? ( + + 已生成主图 + + ) : null} + {role.tags.slice(0, 2).map((tag) => ( + + {tag} + + ))} + {!readOnly ? ( +
+ removePlayable(role.id, role.name)} + tone="rose" + > + 删除 +
-
- {lockedCharacterNames.has(role.name.trim()) ? ( -
- 创作者锁定角色 -
- ) : null} -
- {role.description} -
-
- {role.backstory} -
-
- 公开背景: - {role.backstoryReveal.publicSummary || '未填写'} -
-
-
- 身份:{role.role} -
-
- 初始好感:{role.initialAffinity} -
-
- 性格:{role.personality} -
-
- 战斗:{role.combatStyle} -
-
-
- 动机:{role.motivation} -
-
-
- 好感背景章节 -
-
- {role.backstoryReveal.chapters.map((chapter) => ( -
- {chapter.affinityRequired} 好感 ·{' '} - {chapter.title}:{chapter.teaser} -
- ))} -
-
-
-
- 技能 -
-
- {role.skills.map((skill) => ( -
- {skill.name} · {skill.style}:{skill.summary} -
- ))} -
-
-
-
- 初始物品 -
-
- {role.initialItems.map((item) => ( -
- {item.name} x{item.quantity} · {item.category} ·{' '} - {item.rarity}:{item.description} -
- ))} -
-
-
- {role.tags.map((tag) => ( - - {tag} - - ))} -
-
-
-
+ ) : null} +
); }) @@ -1344,8 +1319,13 @@ export function CustomWorldEntityCatalog({ {filteredStory.length === 0 ? ( ) : ( - filteredStory.map((npc) => ( -
+ filteredStory.map((npc, index) => ( +
) : ( - filteredSceneEntries.map((scene) => ( -
+ filteredSceneEntries.map((scene, index) => ( +
{ + const actual = await vi.importActual( + '../data/characterPresets', + ); + + return { + ...actual, + buildCustomWorldPlayableCharacters: vi.fn(() => []), + }; +}); vi.mock('./CharacterAnimator', () => ({ CharacterAnimator: () =>
角色预览
, })); +vi.mock('../services/aiService', () => ({ + generateCustomWorldSceneImage: vi.fn(), + generateCustomWorldSceneNpc: vi.fn(), +})); + vi.mock('./CustomWorldNpcVisualEditor', () => ({ CustomWorldNpcPortrait: ({ npc }: { npc: { name: string } }) => (
{npc.name}
@@ -136,6 +157,94 @@ function createProfile(): CustomWorldProfile { } as unknown as CustomWorldProfile; } +function createProfileWithLandmark(): CustomWorldProfile { + return { + ...createProfile(), + storyNpcs: [ + createStoryRole('story-1', '顾潮音'), + createStoryRole('story-2', '闻雪汀'), + createStoryRole('story-3', '谢孤灯'), + ], + landmarks: [ + { + id: 'landmark-1', + name: '沉钟栈桥', + description: '旧钟与潮声常年相撞的码头栈桥。', + dangerLevel: 'medium', + imageSrc: '/generated-custom-world-scenes/original-scene.png', + sceneNpcIds: ['story-1', 'story-2', 'story-3'], + connections: [], + }, + ], + } as unknown as CustomWorldProfile; +} + +function LandmarkEditorFlowHarness() { + const [profile, setProfile] = useState(createProfileWithLandmark()); + const [target, setTarget] = useState({ + kind: 'landmark', + mode: 'edit', + id: 'landmark-1', + }); + + return ( + <> + {}} + onEditTarget={setTarget} + onProfileChange={setProfile} + onDeleteStoryNpcs={() => {}} + onDeleteLandmarks={() => {}} + /> + setTarget(null)} + onProfileChange={setProfile} + /> + + ); +} + +function CampEditorFlowHarness() { + const [profile, setProfile] = useState({ + ...createProfileWithLandmark(), + camp: { + name: '潮灯居', + description: '玩家最初落脚的旧灯塔内院。', + dangerLevel: 'medium', + imageSrc: '/generated-custom-world-scenes/original-camp.png', + }, + }); + const [target, setTarget] = useState({ + kind: 'camp', + }); + + return ( + <> + {}} + onEditTarget={setTarget} + onProfileChange={setProfile} + onDeleteStoryNpcs={() => {}} + onDeleteLandmarks={() => {}} + /> + setTarget(null)} + onProfileChange={setProfile} + /> + + ); +} + test('playable角色打开AI工坊后不会自动关闭', async () => { const user = userEvent.setup(); const handleClose = vi.fn(); @@ -179,3 +288,263 @@ test('场景角色打开AI工坊后不会自动关闭', async () => { expect(handleClose).not.toHaveBeenCalled(); }); + +test('可扮演角色未修改时右上角关闭不会弹确认', async () => { + const user = userEvent.setup(); + const handleClose = vi.fn(); + + render( + , + ); + + await user.click(screen.getByRole('button', { name: '关闭' })); + + expect(handleClose).toHaveBeenCalledTimes(1); + expect(screen.queryByText('确认关闭')).toBeNull(); +}); + +test('可扮演角色修改后右上角关闭才弹确认', async () => { + const user = userEvent.setup(); + const handleClose = vi.fn(); + + render( + , + ); + + const nameInput = screen.getByDisplayValue('沈砺'); + await user.clear(nameInput); + await user.type(nameInput, '沈砺·改'); + await user.click(screen.getByRole('button', { name: '关闭' })); + + expect(handleClose).not.toHaveBeenCalled(); + expect(screen.getAllByText('确认关闭').length).toBeGreaterThan(0); +}); + +test('场景角色未修改时右上角关闭不会弹确认', async () => { + const user = userEvent.setup(); + const handleClose = vi.fn(); + + render( + , + ); + + await user.click(screen.getByRole('button', { name: '关闭' })); + + expect(handleClose).toHaveBeenCalledTimes(1); + expect(screen.queryByText('确认关闭')).toBeNull(); +}); + +test('场景角色修改后右上角关闭才弹确认', async () => { + const user = userEvent.setup(); + const handleClose = vi.fn(); + + render( + , + ); + + const nameInput = screen.getByDisplayValue('顾潮音'); + await user.clear(nameInput); + await user.type(nameInput, '顾潮音·改'); + await user.click(screen.getByRole('button', { name: '关闭' })); + + expect(handleClose).not.toHaveBeenCalled(); + expect(screen.getAllByText('确认关闭').length).toBeGreaterThan(0); +}); + +test('可扮演角色列表使用缩略卡片并点击进入编辑', async () => { + const user = userEvent.setup(); + const handleEditTarget = vi.fn(); + + render( + {}} + onEditTarget={handleEditTarget} + onProfileChange={vi.fn()} + onDeleteStoryNpcs={() => {}} + onDeleteLandmarks={() => {}} + />, + ); + + expect(screen.queryByText(/公开背景/u)).toBeNull(); + + await user.click(screen.getByRole('button', { name: /沈砺/u })); + + expect(handleEditTarget).toHaveBeenCalledWith({ + kind: 'playable', + mode: 'edit', + id: 'playable-1', + }); +}); + +test('实体目录在空 id 列表项下不会触发重复 key 警告', () => { + const consoleErrorSpy = vi + .spyOn(console, 'error') + .mockImplementation(() => undefined); + + render( + {}} + onEditTarget={() => {}} + onProfileChange={vi.fn()} + onDeleteStoryNpcs={() => {}} + onDeleteLandmarks={() => {}} + />, + ); + + expect(screen.getByRole('button', { name: /沈砺/u })).toBeTruthy(); + expect(screen.getByRole('button', { name: /闻潮/u })).toBeTruthy(); + + const duplicateKeyCalls = consoleErrorSpy.mock.calls.filter((call) => + call.some( + (arg) => + typeof arg === 'string' && + arg.includes('Encountered two children with the same key'), + ), + ); + + expect(duplicateKeyCalls).toHaveLength(0); +}); + +test('场景图片保存后会同步更新编辑页和场景列表', async () => { + const aiService = await import('../services/aiService'); + vi.mocked(aiService.generateCustomWorldSceneImage).mockClear(); + vi.mocked(aiService.generateCustomWorldSceneImage).mockResolvedValue({ + imageSrc: '/generated-custom-world-scenes/updated-scene.png', + assetId: 'asset-1', + model: 'wan2.2-t2i-flash', + size: '1280*720', + taskId: 'task-1', + prompt: '更新后的场景图', + }); + + const user = userEvent.setup(); + + render(); + + const initialListImage = screen.getByRole('img', { name: '沉钟栈桥' }); + expect(initialListImage.getAttribute('src')).toBe( + '/generated-custom-world-scenes/original-scene.png', + ); + + await user.click(screen.getByRole('button', { name: 'AI生成' })); + await waitFor(() => { + expect(screen.getByText('智能生成:沉钟栈桥')).toBeTruthy(); + }); + + await user.click(screen.getByRole('button', { name: '开始生成' })); + await waitFor(() => { + expect(aiService.generateCustomWorldSceneImage).toHaveBeenCalledTimes(1); + }); + + await user.click(screen.getByRole('button', { name: '保存' })); + + await waitFor(() => { + expect(screen.queryByText('智能生成:沉钟栈桥')).toBeNull(); + }); + + await waitFor(() => { + expect(screen.getByRole('img', { name: '场景图片' }).getAttribute('src')).toBe( + '/generated-custom-world-scenes/updated-scene.png', + ); + }); + + await user.click(screen.getByRole('button', { name: /保存修改/u })); + + await waitFor(() => { + expect(screen.queryByRole('img', { name: '场景图片' })).toBeNull(); + }); + + await waitFor(() => { + expect(screen.getByRole('img', { name: '沉钟栈桥' }).getAttribute('src')).toBe( + '/generated-custom-world-scenes/updated-scene.png', + ); + }); +}); + +test('开局场景图片保存后会同步更新编辑页和场景列表', async () => { + const aiService = await import('../services/aiService'); + vi.mocked(aiService.generateCustomWorldSceneImage).mockClear(); + vi.mocked(aiService.generateCustomWorldSceneImage).mockResolvedValue({ + imageSrc: '/generated-custom-world-scenes/updated-camp.png', + assetId: 'asset-camp-1', + model: 'wan2.2-t2i-flash', + size: '1280*720', + taskId: 'task-camp-1', + prompt: '更新后的开局场景图', + }); + + const user = userEvent.setup(); + + render(); + + const initialListImage = screen.getByRole('img', { name: '潮灯居' }); + expect(initialListImage.getAttribute('src')).toBe( + '/generated-custom-world-scenes/original-camp.png', + ); + + await user.click(screen.getByRole('button', { name: 'AI生成' })); + await waitFor(() => { + expect(screen.getByText('智能生成:潮灯居')).toBeTruthy(); + }); + + await user.click(screen.getByRole('button', { name: '开始生成' })); + await waitFor(() => { + expect(aiService.generateCustomWorldSceneImage).toHaveBeenCalledTimes(1); + }); + + await user.click(screen.getByRole('button', { name: '保存' })); + + await waitFor(() => { + expect(screen.queryByText('智能生成:潮灯居')).toBeNull(); + }); + + await waitFor(() => { + expect(screen.getByRole('img', { name: '场景图片' }).getAttribute('src')).toBe( + '/generated-custom-world-scenes/updated-camp.png', + ); + }); + + await user.click(screen.getByRole('button', { name: /保存修改/u })); + + await waitFor(() => { + expect(screen.queryByRole('img', { name: '场景图片' })).toBeNull(); + }); + + await waitFor(() => { + expect(screen.getByRole('img', { name: '潮灯居' }).getAttribute('src')).toBe( + '/generated-custom-world-scenes/updated-camp.png', + ); + }); +}); diff --git a/src/components/CustomWorldEntityEditorModal.tsx b/src/components/CustomWorldEntityEditorModal.tsx index 151d4ef8..538e75f2 100644 --- a/src/components/CustomWorldEntityEditorModal.tsx +++ b/src/components/CustomWorldEntityEditorModal.tsx @@ -1,6 +1,6 @@ import type { ChangeEvent } from 'react'; import type { CSSProperties } from 'react'; -import { Children, type ReactNode, useEffect, useMemo, useState } from 'react'; +import { Children, type ReactNode, useEffect, useMemo, useRef, useState } from 'react'; import { createPortal } from 'react-dom'; import { AFFINITY_BACKSTORY_CHAPTER_THRESHOLDS } from '../data/affinityLevels'; @@ -352,9 +352,29 @@ function syncLandmarksWithStoryNpcs( }); } +function buildDraftSyncToken(value: unknown) { + try { + const serialized = JSON.stringify(value); + return serialized ?? 'undefined'; + } catch { + return String(value); + } +} + function useDraft(value: T) { + const syncToken = useMemo(() => buildDraftSyncToken(value), [value]); const [draft, setDraft] = useState(value); - useEffect(() => setDraft(value), [value]); + const lastSyncedTokenRef = useRef(syncToken); + + useEffect(() => { + if (lastSyncedTokenRef.current === syncToken) { + return; + } + + lastSyncedTokenRef.current = syncToken; + setDraft(value); + }, [syncToken, value]); + return [draft, setDraft] as const; } @@ -421,6 +441,7 @@ function ModalShell({ type="button" onClick={onClose} disabled={disableClose} + aria-label="关闭" className={`rounded-full border border-white/10 bg-black/20 p-2 text-zinc-400 transition-colors hover:text-white ${disableClose ? 'cursor-not-allowed opacity-45' : ''}`} > @@ -495,6 +516,7 @@ function CompactDialogShell({ type="button" onClick={onClose} disabled={disableClose} + aria-label="关闭" className={`rounded-full border border-white/10 bg-black/20 p-2 text-zinc-400 transition-colors hover:text-white ${disableClose ? 'cursor-not-allowed opacity-45' : ''}`} > @@ -843,7 +865,7 @@ function ScenePresetPickerModal({ const isSelected = src === selectedSrc; return (