Persist custom world asset configs in runtime snapshots
This commit is contained in:
BIN
.tools/node-v22.22.2-win-x64/node.exe
Normal file
BIN
.tools/node-v22.22.2-win-x64/node.exe
Normal file
Binary file not shown.
621
docs/audits/CUSTOM_WORLD_PROFILE_MAPPING_AUDIT_2026-04-18.md
Normal file
621
docs/audits/CUSTOM_WORLD_PROFILE_MAPPING_AUDIT_2026-04-18.md
Normal file
@@ -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 已经能驱动游戏,但还没有彻底收束成“所有预设内容与实时生成规则都优先读取它”的单一真相源。主链可用,边链仍散,跨题材合理性仍偏弱。**
|
||||||
@@ -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 映射才会越做越稳。
|
||||||
@@ -95,27 +95,10 @@ export const QWEN_SPRITE_ACTION_TEMPLATES: QwenSpriteActionTemplate[] = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
const CHIBI_STYLE_TEXT =
|
const CHIBI_STYLE_TEXT =
|
||||||
'Q版大头身动作角色,头身比固定控制在 2 到 3 头身,头部占比明显更大,类似横版像素 RPG 可扮演角色的比例,不要写实长身比例。';
|
'Q版大头身动作角色,头身比固定控制在 2 到 2.5 头身,头部占比明显更大,肩宽和骨盆都更收紧,躯干与四肢短而紧凑,接近经典横版像素动作角色的身体比例,不要写实长身比例。';
|
||||||
const PIXEL_STYLE_TEXT =
|
const PIXEL_STYLE_TEXT =
|
||||||
'参考项目内可扮演角色的像素风动作角色画风,整体是像素游戏角色设计方向,身体始终朝右,适合横版动作 sprite 资产。';
|
'像素风画风,整体是像素游戏角色设计方向,深色粗轮廓配合清晰大色块,侧脸五官简化,发型、服装、配饰优先形成醒目剪影,身体始终朝右,适合横版动作 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版可爱的人形动作角色,方便读图和后续动画化。';
|
|
||||||
|
|
||||||
export function getActionTemplateById(id: QwenSpriteActionTemplateId) {
|
export function getActionTemplateById(id: QwenSpriteActionTemplateId) {
|
||||||
return (
|
return (
|
||||||
@@ -130,9 +113,9 @@ export function buildMasterPrompt(characterBrief: string) {
|
|||||||
`视角要求:角色采用横版动作素材常用的右向斜侧身站姿,身体整体朝右,但保留少量正面信息,能读到面部轮廓与胸肩结构,不是完全 90 度纯右视图,也不是正面立绘。`,
|
`视角要求:角色采用横版动作素材常用的右向斜侧身站姿,身体整体朝右,但保留少量正面信息,能读到面部轮廓与胸肩结构,不是完全 90 度纯右视图,也不是正面立绘。`,
|
||||||
`主体要求:画面中只保留单个角色主体,不要额外人物、动物、召唤物、载具或陪体。`,
|
`主体要求:画面中只保留单个角色主体,不要额外人物、动物、召唤物、载具或陪体。`,
|
||||||
`画面要求:1:1 正方形画布,画面中心构图,角色主体完整置于画面中央,不要裁切主体顶部和底部,不要镜头透视,不要特写。背景固定为纯绿色绿幕,只作为抠像底色,不出现建筑、室内布景、风景、地面道具、漂浮物、烟雾叙事元素或其他角色以外的场景内容。`,
|
`画面要求:1:1 正方形画布,画面中心构图,角色主体完整置于画面中央,不要裁切主体顶部和底部,不要镜头透视,不要特写。背景固定为纯绿色绿幕,只作为抠像底色,不出现建筑、室内布景、风景、地面道具、漂浮物、烟雾叙事元素或其他角色以外的场景内容。`,
|
||||||
`风格要求:Q版大头身动作角色,头身比固定控制在 2 到 3 头身,头部占比明显更大,类似横版像素 RPG 可扮演角色的比例,不要写实长身比例。高可读性游戏角色设定图,偏像素动画前置设计稿,形体清晰,服装层次明确,道具/权杖/武器如有则存在关系合理,便于后续连续动作生成。`,
|
`风格要求:Q版大头身动作角色,清爽可爱,头身比固定控制在 2 到 2.5 头身,头部占比明显更大,肩宽和骨盆都更收紧,躯干与四肢短而紧凑。深色粗轮廓配合清晰大色块,侧脸五官简化,发型、服装、配饰优先形成醒目剪影,适合横版动作 sprite 资产。高可读性游戏角色设定图,形体清晰,服装层次明确,便于后续连续动作生成。`,
|
||||||
'请先拆解设定中的“身份词、主题词、身体结构词”。如果文字设定没有明确要求非人身体结构,默认优先使用参考图对应的人类或类人动作角色骨架,保持清楚的头、躯干、手臂和双腿轮廓,只有当文字设定明确要求鱼尾、触手身体、伞盖头部、无双腿、漂浮体、凝胶体或其他非人结构时,才改为对应非人身体。',
|
'请先拆解设定中的“身份词、主题词、身体结构词”。如果文字设定没有明确要求非人身体结构,默认优先使用参考图对应的人类或类人动作角色骨架,保持清楚的头、躯干、手臂和双腿轮廓,只有当文字设定明确要求非人结构时,才改为对应非人身体。',
|
||||||
'主题词默认只作用在角色自身的服装剪裁、材质、纹样、饰品、武器和发光细节上,不要把主题词自动扩写成背景建筑、自然场景、漂浮装饰或额外环境物件。',
|
'主题词默认只作用在角色自身的服装剪裁、材质、纹样、饰品、发光细节上,不要把主题词自动扩写成背景建筑、自然场景、漂浮装饰或额外环境物件。',
|
||||||
'视觉优先级应当是:身体结构词第一,身份词第二,主题词第三。没有明确身体结构词时,默认用人形拟人化表现,再把主题词转译成服装和装饰。',
|
'视觉优先级应当是:身体结构词第一,身份词第二,主题词第三。没有明确身体结构词时,默认用人形拟人化表现,再把主题词转译成服装和装饰。',
|
||||||
characterBrief.trim(),
|
characterBrief.trim(),
|
||||||
]
|
]
|
||||||
@@ -152,7 +135,7 @@ export function buildVideoActionPrompt(options: {
|
|||||||
`视角要求:角色采用横版动作素材常用的右向斜侧身站姿,身体整体朝右,但保留少量正面信息,能读到面部轮廓与胸肩结构,不是完全 90 度纯右视图,也不是正面立绘。`,
|
`视角要求:角色采用横版动作素材常用的右向斜侧身站姿,身体整体朝右,但保留少量正面信息,能读到面部轮廓与胸肩结构,不是完全 90 度纯右视图,也不是正面立绘。`,
|
||||||
`主体要求:画面中只保留单个角色主体,不要额外人物、动物、召唤物、载具或陪体。`,
|
`主体要求:画面中只保留单个角色主体,不要额外人物、动物、召唤物、载具或陪体。`,
|
||||||
`画面要求:1:1 正方形画布,画面中心构图,角色主体完整置于画面中央,不要裁切主体顶部和底部,不要镜头透视,不要特写。背景固定为纯绿色绿幕,只作为抠像底色,不出现建筑、室内布景、风景、地面道具、漂浮物、烟雾叙事元素或其他角色以外的场景内容。`,
|
`画面要求:1:1 正方形画布,画面中心构图,角色主体完整置于画面中央,不要裁切主体顶部和底部,不要镜头透视,不要特写。背景固定为纯绿色绿幕,只作为抠像底色,不出现建筑、室内布景、风景、地面道具、漂浮物、烟雾叙事元素或其他角色以外的场景内容。`,
|
||||||
`风格要求:Q版大头身动作角色,头身比固定控制在 2 到 3 头身,头部占比明显更大,类似横版像素 RPG 可扮演角色的比例,不要写实长身比例。高可读性游戏角色设定图,偏像素动画前置设计稿,形体清晰,服装层次明确,道具/权杖/武器如有则存在关系合理,便于后续连续动作生成。`,
|
`风格要求:${CHIBI_STYLE_TEXT} ${PIXEL_STYLE_TEXT} 清爽可爱,高可读性游戏角色设定图,偏像素动画前置设计稿,形体清晰,服装层次明确,道具/权杖/武器如有则存在关系合理,便于后续连续动作生成。`,
|
||||||
`动作结构:${options.actionTemplate.sequenceLines.join(';')}。结尾要求:${options.actionTemplate.ending}。`,
|
`动作结构:${options.actionTemplate.sequenceLines.join(';')}。结尾要求:${options.actionTemplate.ending}。`,
|
||||||
options.useChromaKey
|
options.useChromaKey
|
||||||
? '背景为纯绿色绿幕,无其他人物和场景元素,方便后期抽帧与抠像。'
|
? '背景为纯绿色绿幕,无其他人物和场景元素,方便后期抽帧与抠像。'
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"characterId": "story-npc-艾莉丝-1",
|
"characterId": "story-npc-萧震-1",
|
||||||
"visualPromptText": "机甲战士",
|
"visualPromptText": "机甲战士",
|
||||||
"animationPromptText": "",
|
"animationPromptText": "擅长近战,以萧家绝学为主,战斗时气势磅礴,招式刚猛有力 探寻失落信标遗迹,提升萧家实力,对抗新威胁势力 沉稳老练,处事果断,对家族后人严厉又关心,面对危机临危不乱",
|
||||||
"visualDrafts": [
|
"visualDrafts": [
|
||||||
{
|
{
|
||||||
"id": "candidate-1",
|
"id": "candidate-1",
|
||||||
@@ -73,5 +73,5 @@
|
|||||||
"previewVideoPath": "/generated-character-drafts/story-npc-1/animation/hurt/animation-video-1776486975795/preview.mp4"
|
"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"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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<string, { basePath?: string }>;
|
||||||
|
}>;
|
||||||
|
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<string, { basePath?: string }>;
|
||||||
|
}>;
|
||||||
|
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 () => {
|
test('runtime snapshot persistence returns hydrated snapshots for frontend restore flows', async () => {
|
||||||
await withTestServer('persistence-hydrated-snapshot', async ({ baseUrl }) => {
|
await withTestServer('persistence-hydrated-snapshot', async ({ baseUrl }) => {
|
||||||
const entry = await authEntry(
|
const entry = await authEntry(
|
||||||
|
|||||||
@@ -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 ?? '', /纯绿色绿幕/u);
|
assert.match(content[0]?.text ?? '', /纯绿色绿幕/u);
|
||||||
assert.match(content[0]?.text ?? '', /1:1 正方形画幅|1:1 正方形画布/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.match(content[0]?.text ?? '', /不要把主题词自动扩写成背景建筑/u);
|
||||||
assert.doesNotMatch(content[0]?.text ?? '', /水母国王/u);
|
assert.doesNotMatch(content[0]?.text ?? '', /水母国王/u);
|
||||||
assert.match(content[1]?.image ?? '', /^data:image\/png;base64,/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, /右向斜侧身/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.animationPromptText, /动作/u);
|
||||||
assert.match(payload.scenePromptText, /场景/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 () => {
|
test('character animation publish returns frame dimensions in animation map', async () => {
|
||||||
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'genarrative-character-animation-publish-'));
|
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 () => {
|
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 tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'genarrative-character-i2v-loop-'));
|
||||||
const publicDir = path.join(tempRoot, 'public');
|
const publicDir = path.join(tempRoot, 'public');
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { createHash } from 'node:crypto';
|
||||||
import { mkdir, readFile, writeFile } from 'node:fs/promises';
|
import { mkdir, readFile, writeFile } from 'node:fs/promises';
|
||||||
import http, {
|
import http, {
|
||||||
type IncomingMessage,
|
type IncomingMessage,
|
||||||
@@ -349,7 +350,7 @@ function buildFallbackCharacterPromptBundle(params: {
|
|||||||
return {
|
return {
|
||||||
visualPromptText: [
|
visualPromptText: [
|
||||||
`${characterAnchor},${roleAnchor}。`,
|
`${characterAnchor},${roleAnchor}。`,
|
||||||
'单人全身,2D 横版 RPG 角色标准设定图,1:1 正方形画幅,头身比控制在 2 到 3 头身,右向斜侧身站立,身体整体朝右但保留少量正面信息,脚底完整可见,服装、发型、武器和轮廓稳定清楚。',
|
'单人全身,2D 横版 RPG 角色标准设定图,1:1 正方形画幅,头身比控制在 2 到 2.5 头身,偏大头身,靠头部、发型、服装、配饰表现角色记忆点,躯干与四肢短而紧凑,五官简化,深色粗轮廓配合清晰大色块,右向斜侧身站立,身体整体朝右但保留少量正面信息,服装、发型、轮廓稳定清楚。',
|
||||||
`外观气质围绕:${descriptionAnchor}。`,
|
`外观气质围绕:${descriptionAnchor}。`,
|
||||||
combatAnchor ? `战斗识别点:${combatAnchor}。` : '',
|
combatAnchor ? `战斗识别点:${combatAnchor}。` : '',
|
||||||
tagAnchor,
|
tagAnchor,
|
||||||
@@ -359,7 +360,7 @@ function buildFallbackCharacterPromptBundle(params: {
|
|||||||
.join(' '),
|
.join(' '),
|
||||||
animationPromptText: [
|
animationPromptText: [
|
||||||
`${characterAnchor}的核心动作试片。`,
|
`${characterAnchor}的核心动作试片。`,
|
||||||
'保持同一角色的服装、发型、武器和体型一致,镜头稳定,侧身朝右,动作连贯。',
|
'保持同一角色的服装、发型、体型一致,镜头稳定,侧身朝右,动作连贯。',
|
||||||
combatAnchor ? `动作气质参考:${combatAnchor}。` : '',
|
combatAnchor ? `动作气质参考:${combatAnchor}。` : '',
|
||||||
params.personality ? `角色气质补充:${params.personality}。` : '',
|
params.personality ? `角色气质补充:${params.personality}。` : '',
|
||||||
'发力起手明确,过程干净,收招利落,避免漂移和变形。',
|
'发力起手明确,过程干净,收招利落,避免漂移和变形。',
|
||||||
@@ -560,11 +561,17 @@ function getJobRecordPath(
|
|||||||
}
|
}
|
||||||
|
|
||||||
function getCharacterWorkflowCachePath(rootDir: string, characterId: string) {
|
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(
|
return path.resolve(
|
||||||
rootDir,
|
rootDir,
|
||||||
'public',
|
'public',
|
||||||
'generated-character-drafts',
|
'generated-character-drafts',
|
||||||
sanitizePathSegment(characterId),
|
`${readableSegment}-${characterCacheKey}`,
|
||||||
'workflow-cache.json',
|
'workflow-cache.json',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1163,7 +1170,9 @@ function buildNpcAnimationPrompt(options: {
|
|||||||
const actionDetailText = sanitizeAnimationPromptText(options.promptText, 140);
|
const actionDetailText = sanitizeAnimationPromptText(options.promptText, 140);
|
||||||
const loopRule = options.loop
|
const loopRule = options.loop
|
||||||
? '这是循环动作,直接进入动作循环中段,不要开场静止站桩,不要把主参考图原样作为第一帧。'
|
? '这是循环动作,直接进入动作循环中段,不要开场静止站桩,不要把主参考图原样作为第一帧。'
|
||||||
: '这是非循环动作,首帧和尾帧都要回到参考主图角色形象,中段完成动作变化。';
|
: options.animation === 'die'
|
||||||
|
? '这是死亡终结动作,首帧参考主图角色形象即可,尾帧停在死亡结束姿态,不要回到主图形象。'
|
||||||
|
: '这是非循环动作,首帧和尾帧都要回到参考主图角色形象,中段完成动作变化。';
|
||||||
|
|
||||||
if (options.actionTemplateId) {
|
if (options.actionTemplateId) {
|
||||||
return [
|
return [
|
||||||
@@ -1963,9 +1972,10 @@ async function handleGenerateCharacterAnimation(
|
|||||||
`${characterId}-${animation}-visual`,
|
`${characterId}-${animation}-visual`,
|
||||||
await resolveMediaSourcePayload(rootDir, visualSource),
|
await resolveMediaSourcePayload(rootDir, visualSource),
|
||||||
);
|
);
|
||||||
const resolvedLastFrameSource = !loop
|
const resolvedLastFrameSource =
|
||||||
? lastFrameImageDataUrl || visualSource
|
!loop && animation !== 'die'
|
||||||
: '';
|
? lastFrameImageDataUrl || visualSource
|
||||||
|
: '';
|
||||||
const lastFrameRef = resolvedLastFrameSource
|
const lastFrameRef = resolvedLastFrameSource
|
||||||
? isKf2vFlash
|
? isKf2vFlash
|
||||||
? await resolveMediaSourceAsDataUrl(rootDir, resolvedLastFrameSource)
|
? await resolveMediaSourceAsDataUrl(rootDir, resolvedLastFrameSource)
|
||||||
@@ -2730,7 +2740,9 @@ async function handleGetCharacterWorkflowCache(
|
|||||||
sendJson(res, 200, {
|
sendJson(res, 200, {
|
||||||
ok: true,
|
ok: true,
|
||||||
cache:
|
cache:
|
||||||
isRecordValue(cache) && typeof cache.characterId === 'string'
|
isRecordValue(cache) &&
|
||||||
|
typeof cache.characterId === 'string' &&
|
||||||
|
cache.characterId === characterId
|
||||||
? cache
|
? cache
|
||||||
: null,
|
: null,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ export function createStoryActionRoutes(context: AppContext) {
|
|||||||
response,
|
response,
|
||||||
await resolveRuntimeStoryAction({
|
await resolveRuntimeStoryAction({
|
||||||
runtimeRepository: context.runtimeRepository,
|
runtimeRepository: context.runtimeRepository,
|
||||||
|
llmClient: context.llmClient,
|
||||||
userId: request.userId!,
|
userId: request.userId!,
|
||||||
request: payload,
|
request: payload,
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -2,12 +2,19 @@ import type {
|
|||||||
RuntimeBattlePresentation,
|
RuntimeBattlePresentation,
|
||||||
RuntimeStoryActionRequest,
|
RuntimeStoryActionRequest,
|
||||||
RuntimeStoryActionResponse,
|
RuntimeStoryActionResponse,
|
||||||
|
RuntimeStoryOptionView,
|
||||||
RuntimeStoryPatch,
|
RuntimeStoryPatch,
|
||||||
} from '../../../../packages/shared/src/contracts/story.js';
|
} from '../../../../packages/shared/src/contracts/story.js';
|
||||||
import { conflict, invalidRequest } from '../../errors.js';
|
import { conflict, invalidRequest } from '../../errors.js';
|
||||||
import type { RuntimeRepositoryPort } from '../../repositories/runtimeRepository.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 { resolveCombatAction } from '../combat/combatResolutionService.js';
|
||||||
import { resolveInventoryStoryAction, isSupportedInventoryStoryFunctionId } from '../inventory/inventoryStoryActionService.js';
|
import { isSupportedInventoryStoryFunctionId,resolveInventoryStoryAction } from '../inventory/inventoryStoryActionService.js';
|
||||||
import {
|
import {
|
||||||
ensureNpcInventorySessionState,
|
ensureNpcInventorySessionState,
|
||||||
isSupportedNpcInventoryStoryFunctionId,
|
isSupportedNpcInventoryStoryFunctionId,
|
||||||
@@ -21,12 +28,15 @@ import {
|
|||||||
isSupportedQuestStoryFunctionId,
|
isSupportedQuestStoryFunctionId,
|
||||||
resolveQuestStoryAction,
|
resolveQuestStoryAction,
|
||||||
} from '../quest/questStoryActionService.js';
|
} from '../quest/questStoryActionService.js';
|
||||||
|
import {
|
||||||
|
hydrateSavedSnapshot,
|
||||||
|
normalizeSavedSnapshotPayload,
|
||||||
|
} from '../runtime/runtimeSnapshotHydration.js';
|
||||||
import {
|
import {
|
||||||
isSupportedTreasureStoryFunctionId,
|
isSupportedTreasureStoryFunctionId,
|
||||||
resolveTreasureStoryAction,
|
resolveTreasureStoryAction,
|
||||||
} from '../runtime-item/treasureStoryActionService.js';
|
} from '../runtime-item/treasureStoryActionService.js';
|
||||||
import {
|
import {
|
||||||
TASK6_DEFERRED_FUNCTION_IDS,
|
|
||||||
appendStoryHistory,
|
appendStoryHistory,
|
||||||
buildAvailableOptions,
|
buildAvailableOptions,
|
||||||
buildLegacyCurrentStory,
|
buildLegacyCurrentStory,
|
||||||
@@ -37,14 +47,11 @@ import {
|
|||||||
isStoryFunctionId,
|
isStoryFunctionId,
|
||||||
isTask5FunctionId,
|
isTask5FunctionId,
|
||||||
loadRuntimeSession,
|
loadRuntimeSession,
|
||||||
|
type RuntimeSession,
|
||||||
setEncounterNpcState,
|
setEncounterNpcState,
|
||||||
syncRawGameState,
|
syncRawGameState,
|
||||||
type RuntimeSession,
|
TASK6_DEFERRED_FUNCTION_IDS,
|
||||||
} from './runtimeSession.js';
|
} from './runtimeSession.js';
|
||||||
import {
|
|
||||||
hydrateSavedSnapshot,
|
|
||||||
normalizeSavedSnapshotPayload,
|
|
||||||
} from '../runtime/runtimeSnapshotHydration.js';
|
|
||||||
|
|
||||||
type StoryResolution = {
|
type StoryResolution = {
|
||||||
actionText: string;
|
actionText: string;
|
||||||
@@ -55,6 +62,36 @@ type StoryResolution = {
|
|||||||
toast?: string | null;
|
toast?: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type JsonRecord = Record<string, unknown>;
|
||||||
|
|
||||||
|
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) {
|
function resolveActionText(defaultText: string, request: RuntimeStoryActionRequest) {
|
||||||
const payload = request.action.payload;
|
const payload = request.action.payload;
|
||||||
const optionText =
|
const optionText =
|
||||||
@@ -65,6 +102,354 @@ function resolveActionText(defaultText: string, request: RuntimeStoryActionReque
|
|||||||
return optionText || defaultText;
|
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<string, JsonRecord> = {
|
||||||
|
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<string, JsonRecord> = {
|
||||||
|
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<string, RuntimeStoryOptionView[]>();
|
||||||
|
baseOptions.forEach((option) => {
|
||||||
|
const bucket = buckets.get(option.functionId) ?? [];
|
||||||
|
bucket.push(option);
|
||||||
|
buckets.set(option.functionId, bucket);
|
||||||
|
});
|
||||||
|
|
||||||
|
const resolved: RuntimeStoryOptionView[] = [];
|
||||||
|
const consumed = new Set<RuntimeStoryOptionView>();
|
||||||
|
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) {
|
function normalizeStatusPatch(session: RuntimeSession) {
|
||||||
return {
|
return {
|
||||||
type: 'status_changed',
|
type: 'status_changed',
|
||||||
@@ -109,6 +494,128 @@ function buildFallbackStoryText(session: RuntimeSession) {
|
|||||||
return '当前故事状态已经同步到后端,接下来可以继续推进这一轮运行时动作。';
|
return '当前故事状态已经同步到后端,接下来可以继续推进这一轮运行时动作。';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function generateNpcDialoguePayload(params: {
|
||||||
|
llmClient: UpstreamLlmClient;
|
||||||
|
session: RuntimeSession;
|
||||||
|
actionText: string;
|
||||||
|
resultText: string;
|
||||||
|
}): Promise<GeneratedStoryPayload | null> {
|
||||||
|
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<GeneratedStoryPayload | null> {
|
||||||
|
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(
|
function resolveStoryFlowAction(
|
||||||
session: RuntimeSession,
|
session: RuntimeSession,
|
||||||
functionId: string,
|
functionId: string,
|
||||||
@@ -212,6 +719,7 @@ function resolveStoryFlowAction(
|
|||||||
|
|
||||||
export async function resolveRuntimeStoryAction(params: {
|
export async function resolveRuntimeStoryAction(params: {
|
||||||
runtimeRepository: RuntimeRepositoryPort;
|
runtimeRepository: RuntimeRepositoryPort;
|
||||||
|
llmClient?: UpstreamLlmClient;
|
||||||
userId: string;
|
userId: string;
|
||||||
request: RuntimeStoryActionRequest;
|
request: RuntimeStoryActionRequest;
|
||||||
}) {
|
}) {
|
||||||
@@ -295,16 +803,63 @@ export async function resolveRuntimeStoryAction(params: {
|
|||||||
battle: resolution.battle ?? null,
|
battle: resolution.battle ?? null,
|
||||||
});
|
});
|
||||||
|
|
||||||
const actionText = resolveActionText(resolution.actionText, params.request);
|
let actionText = resolveActionText(resolution.actionText, params.request);
|
||||||
const storyText = resolution.storyText ?? resolution.resultText;
|
if (
|
||||||
|
functionId === 'story_opening_camp_dialogue' &&
|
||||||
appendStoryHistory(session, actionText, resolution.resultText);
|
session.currentEncounter?.kind === 'npc'
|
||||||
|
) {
|
||||||
|
actionText = `在营地与 ${session.currentEncounter.npcName} 交换开场判断`;
|
||||||
|
}
|
||||||
|
let storyText = resolution.storyText ?? resolution.resultText;
|
||||||
|
let historyResultText = resolution.resultText;
|
||||||
session.runtimeVersion += 1;
|
session.runtimeVersion += 1;
|
||||||
session.sessionId = params.request.sessionId;
|
session.sessionId = params.request.sessionId;
|
||||||
|
|
||||||
syncRawGameState(session);
|
syncRawGameState(session);
|
||||||
ensureNpcInventorySessionState(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);
|
syncRawGameState(session);
|
||||||
|
|
||||||
const persistedSnapshot = await params.runtimeRepository.putSnapshot(
|
const persistedSnapshot = await params.runtimeRepository.putSnapshot(
|
||||||
@@ -313,7 +868,7 @@ export async function resolveRuntimeStoryAction(params: {
|
|||||||
savedAt: new Date().toISOString(),
|
savedAt: new Date().toISOString(),
|
||||||
bottomTab: session.snapshotBottomTab,
|
bottomTab: session.snapshotBottomTab,
|
||||||
gameState: session.rawGameState,
|
gameState: session.rawGameState,
|
||||||
currentStory: buildLegacyCurrentStory(storyText, options),
|
currentStory: savedCurrentStory,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -333,7 +888,7 @@ export async function resolveRuntimeStoryAction(params: {
|
|||||||
{
|
{
|
||||||
type: 'story_history_append',
|
type: 'story_history_append',
|
||||||
actionText,
|
actionText,
|
||||||
resultText: resolution.resultText,
|
resultText: historyResultText,
|
||||||
},
|
},
|
||||||
...resolution.patches,
|
...resolution.patches,
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -340,6 +340,199 @@ function buildBuiltinWorldTitle(worldType: string) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function looksLikeGeneratedAssetPath(value: string) {
|
||||||
|
return /^\/generated-/u.test(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function mergeSnapshotRoleAssets(
|
||||||
|
role: Record<string, unknown>,
|
||||||
|
assets: {
|
||||||
|
imageSrc?: string | null;
|
||||||
|
generatedVisualAssetId?: string | null;
|
||||||
|
generatedAnimationSetId?: string | null;
|
||||||
|
animationMap?: Record<string, unknown> | null;
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
let changed = false;
|
||||||
|
const nextRole: Record<string, unknown> = { ...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<string, unknown>,
|
||||||
|
roleId: string,
|
||||||
|
assets: Parameters<typeof mergeSnapshotRoleAssets>[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<string, unknown>,
|
||||||
|
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(
|
function resolveProfileWorldSnapshotMeta(
|
||||||
snapshot: SavedSnapshot,
|
snapshot: SavedSnapshot,
|
||||||
): ProfileWorldSnapshotMeta | null {
|
): 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) {
|
async getSnapshot(userId: string) {
|
||||||
const result = await this.db.query<SnapshotRow>(
|
const result = await this.db.query<SnapshotRow>(
|
||||||
`SELECT version,
|
`SELECT version,
|
||||||
@@ -625,7 +879,7 @@ export class RuntimeRepository implements RuntimeRepositoryPort {
|
|||||||
const snapshot = {
|
const snapshot = {
|
||||||
version: SAVE_SNAPSHOT_VERSION,
|
version: SAVE_SNAPSHOT_VERSION,
|
||||||
savedAt: payload.savedAt,
|
savedAt: payload.savedAt,
|
||||||
gameState: payload.gameState,
|
gameState: syncSnapshotCustomWorldProfile(payload.gameState),
|
||||||
bottomTab: payload.bottomTab,
|
bottomTab: payload.bottomTab,
|
||||||
currentStory: payload.currentStory,
|
currentStory: payload.currentStory,
|
||||||
} satisfies SavedSnapshot;
|
} satisfies SavedSnapshot;
|
||||||
@@ -668,6 +922,7 @@ export class RuntimeRepository implements RuntimeRepositoryPort {
|
|||||||
} satisfies SavedSnapshot;
|
} satisfies SavedSnapshot;
|
||||||
|
|
||||||
await this.syncProfileDashboardFromSnapshot(userId, persistedSnapshot);
|
await this.syncProfileDashboardFromSnapshot(userId, persistedSnapshot);
|
||||||
|
await this.syncCustomWorldProfileFromSnapshot(userId, persistedSnapshot);
|
||||||
|
|
||||||
return persistedSnapshot;
|
return persistedSnapshot;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -451,24 +451,9 @@ function buildFallbackRoleDraft(
|
|||||||
: `长期活跃于当前世界暗面,能补足场景视角的关键角色。`,
|
: `长期活跃于当前世界暗面,能补足场景视角的关键角色。`,
|
||||||
60,
|
60,
|
||||||
),
|
),
|
||||||
visualDescription: clampText(
|
visualDescription: '',
|
||||||
kind === 'playable'
|
actionDescription: '',
|
||||||
? `他保留着适合长期同行的鲜明外形识别点,服装、装备和体态都能直接看出其职责、出身和会如何与玩家并肩行动。`
|
sceneVisualDescription: '',
|
||||||
: `他身上带着与当前局势强绑定的外观痕迹,衣着、器具和整体气质会暴露其长期活动环境与所站的位置。`,
|
|
||||||
96,
|
|
||||||
),
|
|
||||||
actionDescription: clampText(
|
|
||||||
kind === 'playable'
|
|
||||||
? '动作表现偏向协作推进与稳定压制,起手克制,发力明确,收招干净。'
|
|
||||||
: '动作表现偏向试探、牵制与借势,节奏谨慎,但关键时刻会突然加重攻击或位移。',
|
|
||||||
72,
|
|
||||||
),
|
|
||||||
sceneVisualDescription: clampText(
|
|
||||||
profile.landmarks[0]?.description
|
|
||||||
? `他的主要活动空间与${profile.landmarks[0].name}相连,场景里能看到${profile.landmarks[0].description}`
|
|
||||||
: `他的主要活动空间与${profile.name}当前冲突线直接相关,环境里会留下势力痕迹、旧装置和仍在运转的局势线索。`,
|
|
||||||
96,
|
|
||||||
),
|
|
||||||
backstory: clampText(
|
backstory: clampText(
|
||||||
`他与${profile.name}当前正在扩张的冲突链紧密相连,知道一些还未公开的内情。`,
|
`他与${profile.name}当前正在扩张的冲突链紧密相连,知道一些还未公开的内情。`,
|
||||||
80,
|
80,
|
||||||
@@ -567,10 +552,7 @@ function buildFallbackLandmarkDraft(profile: ParsedProfile) {
|
|||||||
`承接${profile.name}当前主冲突的一处关键新场景,适合继续向外扩张世界关系网。`,
|
`承接${profile.name}当前主冲突的一处关键新场景,适合继续向外扩张世界关系网。`,
|
||||||
72,
|
72,
|
||||||
),
|
),
|
||||||
visualDescription: clampText(
|
visualDescription: '',
|
||||||
`这里延续${profile.name}当前主冲突的视觉气质,能看到明确的空间层次、可站立地面、核心建筑或地貌,以及仍在运转的局势痕迹。`,
|
|
||||||
88,
|
|
||||||
),
|
|
||||||
dangerLevel: 'medium',
|
dangerLevel: 'medium',
|
||||||
sceneNpcNames,
|
sceneNpcNames,
|
||||||
connections: targetLandmarkNames.map((targetLandmarkName, index) => ({
|
connections: targetLandmarkNames.map((targetLandmarkName, index) => ({
|
||||||
|
|||||||
@@ -112,7 +112,7 @@ test('generateSceneImage uses wan2.2-t2i-flash text-to-image payload and saves t
|
|||||||
|
|
||||||
if (
|
if (
|
||||||
req.method === 'POST' &&
|
req.method === 'POST' &&
|
||||||
url.pathname === '/api/v1/services/aigc/image-generation/generation'
|
url.pathname === '/api/v1/services/aigc/text2image/image-synthesis'
|
||||||
) {
|
) {
|
||||||
sendJson(res, {
|
sendJson(res, {
|
||||||
output: {
|
output: {
|
||||||
@@ -168,27 +168,23 @@ test('generateSceneImage uses wan2.2-t2i-flash text-to-image payload and saves t
|
|||||||
assert.equal(result.actualPrompt, '整理后的场景提示词');
|
assert.equal(result.actualPrompt, '整理后的场景提示词');
|
||||||
|
|
||||||
const createRequest = capturedRequests.find(
|
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);
|
assert.ok(createRequest?.bodyText);
|
||||||
|
|
||||||
const createPayload = JSON.parse(createRequest.bodyText) as {
|
const createPayload = JSON.parse(createRequest.bodyText) as {
|
||||||
model: string;
|
model: string;
|
||||||
input: {
|
input: {
|
||||||
messages: Array<{
|
prompt: string;
|
||||||
content: Array<{ text?: string; image?: string }>;
|
|
||||||
}>;
|
|
||||||
};
|
|
||||||
parameters: {
|
|
||||||
negative_prompt?: string;
|
negative_prompt?: string;
|
||||||
};
|
};
|
||||||
|
parameters: Record<string, unknown>;
|
||||||
};
|
};
|
||||||
|
|
||||||
const content = createPayload.input.messages[0]?.content ?? [];
|
|
||||||
assert.equal(createPayload.model, 'wan2.2-t2i-flash');
|
assert.equal(createPayload.model, 'wan2.2-t2i-flash');
|
||||||
assert.equal(content[0]?.text, '海雾港口像素风场景');
|
assert.equal(createPayload.input.prompt, '海雾港口像素风场景');
|
||||||
assert.equal(content.length, 1);
|
assert.equal(createPayload.input.negative_prompt, '模糊');
|
||||||
assert.equal(createPayload.parameters.negative_prompt, '模糊');
|
assert.equal(createPayload.parameters.size, '1280*720');
|
||||||
|
|
||||||
const savedImagePath = path.join(tempRoot, 'public', result.imageSrc.slice(1));
|
const savedImagePath = path.join(tempRoot, 'public', result.imageSrc.slice(1));
|
||||||
assert.equal(fs.existsSync(savedImagePath), true);
|
assert.equal(fs.existsSync(savedImagePath), true);
|
||||||
|
|||||||
@@ -130,34 +130,32 @@ async function createSceneImageTask(params: {
|
|||||||
payload: z.infer<typeof sceneImageSchema>;
|
payload: z.infer<typeof sceneImageSchema>;
|
||||||
}) {
|
}) {
|
||||||
const { baseUrl, apiKey, payload } = params;
|
const { baseUrl, apiKey, payload } = params;
|
||||||
const response = await fetch(`${baseUrl}/services/aigc/image-generation/generation`, {
|
const response = await fetch(
|
||||||
method: 'POST',
|
`${baseUrl}/services/aigc/text2image/image-synthesis`,
|
||||||
headers: {
|
{
|
||||||
Authorization: `Bearer ${apiKey}`,
|
method: 'POST',
|
||||||
'Content-Type': 'application/json',
|
headers: {
|
||||||
'X-DashScope-Async': 'enable',
|
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();
|
const responseText = await response.text();
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
|
|||||||
@@ -45,14 +45,20 @@ export const CharacterAnimator: React.FC<CharacterAnimatorProps> = ({
|
|||||||
imageClassName,
|
imageClassName,
|
||||||
playbackRate = 1,
|
playbackRate = 1,
|
||||||
}) => {
|
}) => {
|
||||||
const [frameIndex, setFrameIndex] = useState(1);
|
|
||||||
const config =
|
const config =
|
||||||
character.animationMap?.[state] ??
|
character.animationMap?.[state] ??
|
||||||
DEFAULT_ANIMATIONS[state] ??
|
DEFAULT_ANIMATIONS[state] ??
|
||||||
character.animationMap?.[AnimationState.IDLE] ??
|
character.animationMap?.[AnimationState.IDLE] ??
|
||||||
DEFAULT_ANIMATIONS[AnimationState.IDLE];
|
DEFAULT_ANIMATIONS[AnimationState.IDLE];
|
||||||
const startFrame = config.startFrame ?? 1;
|
const startFrame =
|
||||||
const frameCount = config.frames;
|
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 =
|
const fps =
|
||||||
typeof config.fps === 'number' && Number.isFinite(config.fps)
|
typeof config.fps === 'number' && Number.isFinite(config.fps)
|
||||||
? Math.max(1, config.fps)
|
? Math.max(1, config.fps)
|
||||||
@@ -72,26 +78,33 @@ export const CharacterAnimator: React.FC<CharacterAnimatorProps> = ({
|
|||||||
fps,
|
fps,
|
||||||
effectivePlaybackRate,
|
effectivePlaybackRate,
|
||||||
].join('::');
|
].join('::');
|
||||||
|
const endFrame = startFrame + frameCount - 1;
|
||||||
|
const intervalDelay = Math.max(
|
||||||
|
40,
|
||||||
|
Math.round(1000 / (fps * effectivePlaybackRate)),
|
||||||
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setFrameIndex(startFrame);
|
setFrameIndex((current) => (current === startFrame ? current : startFrame));
|
||||||
|
}, [animationSignature, startFrame]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
if (frameCount <= 1) return;
|
if (frameCount <= 1) return;
|
||||||
|
|
||||||
const endFrame = startFrame + frameCount - 1;
|
|
||||||
|
|
||||||
const interval = window.setInterval(() => {
|
const interval = window.setInterval(() => {
|
||||||
setFrameIndex(prev => {
|
setFrameIndex((current) => {
|
||||||
return prev >= endFrame ? startFrame : prev + 1;
|
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);
|
return () => window.clearInterval(interval);
|
||||||
}, [
|
}, [
|
||||||
animationSignature,
|
endFrame,
|
||||||
effectivePlaybackRate,
|
|
||||||
fps,
|
|
||||||
frameCount,
|
frameCount,
|
||||||
|
intervalDelay,
|
||||||
startFrame,
|
startFrame,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
|||||||
@@ -286,8 +286,11 @@ export function CompanionCampModal({
|
|||||||
<div className="border-t border-white/10 px-5 py-4">
|
<div className="border-t border-white/10 px-5 py-4">
|
||||||
<div className="mb-3 text-xs font-bold text-white">营地气氛</div>
|
<div className="mb-3 text-xs font-bold text-white">营地气氛</div>
|
||||||
<div className="grid gap-3 md:grid-cols-3">
|
<div className="grid gap-3 md:grid-cols-3">
|
||||||
{campMoments.map(moment => (
|
{campMoments.map((moment, index) => (
|
||||||
<div key={moment} className="rounded-xl border border-white/8 bg-black/18 px-4 py-3 text-sm leading-relaxed text-zinc-300">
|
<div
|
||||||
|
key={`camp-moment-${index}-${moment}`}
|
||||||
|
className="rounded-xl border border-white/8 bg-black/18 px-4 py-3 text-sm leading-relaxed text-zinc-300"
|
||||||
|
>
|
||||||
{moment}
|
{moment}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
useState,
|
useState,
|
||||||
} from 'react';
|
} from 'react';
|
||||||
|
|
||||||
|
import { ROLE_TEMPLATE_CHARACTERS } from '../data/characterPresets';
|
||||||
import { getCustomWorldSceneRelativePositionLabel } from '../data/customWorldSceneGraph';
|
import { getCustomWorldSceneRelativePositionLabel } from '../data/customWorldSceneGraph';
|
||||||
import {
|
import {
|
||||||
resolveCustomWorldCampSceneImage,
|
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() {
|
function NewBadge() {
|
||||||
return (
|
return (
|
||||||
<span className="rounded-full border border-amber-300/24 bg-amber-500/12 px-2.5 py-1 text-[10px] font-semibold text-amber-100">
|
<span className="rounded-full border border-amber-300/24 bg-amber-500/12 px-2.5 py-1 text-[10px] font-semibold text-amber-100">
|
||||||
@@ -349,6 +358,43 @@ function compactTextList(values: Array<string | null | undefined>) {
|
|||||||
return values.map((value) => value?.trim() ?? '').filter(Boolean);
|
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) {
|
function toText(value: unknown) {
|
||||||
return typeof value === 'string' ? value.trim() : '';
|
return typeof value === 'string' ? value.trim() : '';
|
||||||
}
|
}
|
||||||
@@ -1165,166 +1211,95 @@ export function CustomWorldEntityCatalog({
|
|||||||
progress={pendingGeneratedEntity.progress}
|
progress={pendingGeneratedEntity.progress}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
<div className="rounded-2xl border border-white/8 bg-black/20 px-4 py-3 text-sm text-zinc-300">
|
|
||||||
{readOnly
|
|
||||||
? '当前是草稿结果预览,可先浏览角色结构,再回到工作区继续精修。'
|
|
||||||
: '可扮演角色支持新增、删除与更换外观模板。'}
|
|
||||||
</div>
|
|
||||||
{filteredPlayable.length === 0 ? (
|
{filteredPlayable.length === 0 ? (
|
||||||
<EmptyState title="当前没有符合搜索条件的可扮演角色。" />
|
<EmptyState title="当前没有符合搜索条件的可扮演角色。" />
|
||||||
) : (
|
) : (
|
||||||
filteredPlayable.map((role) => {
|
filteredPlayable.map((role, index) => {
|
||||||
const previewCharacter =
|
const previewCharacter =
|
||||||
previewCharacterById.get(role.id) ?? null;
|
previewCharacterById.get(role.id) ?? null;
|
||||||
|
const previewImageSrc = resolvePlayableRolePreviewImage(
|
||||||
|
role,
|
||||||
|
previewCharacter,
|
||||||
|
);
|
||||||
|
const description = buildPlayableRoleCardDescription(role);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={role.id}>
|
<div
|
||||||
<Section
|
key={buildFallbackRenderKey(
|
||||||
|
role.id,
|
||||||
|
`playable-role-${index}-${role.name.trim() || 'unnamed'}`,
|
||||||
|
)}
|
||||||
|
className="space-y-2"
|
||||||
|
>
|
||||||
|
<CatalogCard
|
||||||
title={role.name}
|
title={role.name}
|
||||||
subtitle={role.title}
|
description={description || '暂无描述'}
|
||||||
badge={recentPlayableIdSet.has(role.id) ? <NewBadge /> : null}
|
badge={recentPlayableIdSet.has(role.id) ? <NewBadge /> : null}
|
||||||
actions={
|
isSelectionMode={false}
|
||||||
readOnly ? (
|
isSelected={false}
|
||||||
<SmallButton
|
layout="compact"
|
||||||
onClick={() =>
|
mediaClassName="h-[4.75rem] w-[4.75rem] sm:h-[5.25rem] sm:w-[5.25rem]"
|
||||||
onEditTarget({
|
onClick={() =>
|
||||||
kind: 'playable',
|
onEditTarget({
|
||||||
mode: 'edit',
|
kind: 'playable',
|
||||||
id: role.id,
|
mode: 'edit',
|
||||||
})
|
id: role.id,
|
||||||
}
|
})
|
||||||
tone="sky"
|
}
|
||||||
>
|
media={
|
||||||
查看详情
|
previewCharacter ? (
|
||||||
</SmallButton>
|
<CharacterAnimator
|
||||||
|
state={AnimationState.RUN}
|
||||||
|
character={previewCharacter}
|
||||||
|
className="h-full w-full"
|
||||||
|
imageClassName="object-bottom"
|
||||||
|
/>
|
||||||
|
) : previewImageSrc ? (
|
||||||
|
<img
|
||||||
|
src={previewImageSrc}
|
||||||
|
alt={role.name}
|
||||||
|
className="h-full w-full object-cover object-top"
|
||||||
|
/>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex h-full w-full items-center justify-center bg-black/30 px-3 text-center text-xs font-semibold tracking-[0.16em] text-zinc-400">
|
||||||
<SmallButton
|
{role.name.slice(0, 4) || '角色'}
|
||||||
onClick={() =>
|
|
||||||
onEditTarget({
|
|
||||||
kind: 'playable',
|
|
||||||
mode: 'edit',
|
|
||||||
id: role.id,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
tone="sky"
|
|
||||||
>
|
|
||||||
编辑
|
|
||||||
</SmallButton>
|
|
||||||
<SmallButton
|
|
||||||
onClick={() => removePlayable(role.id, role.name)}
|
|
||||||
tone="rose"
|
|
||||||
>
|
|
||||||
删除
|
|
||||||
</SmallButton>
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
>
|
/>
|
||||||
<div className="flex flex-col gap-3 sm:flex-row">
|
<div className="flex flex-wrap items-center gap-2 px-1">
|
||||||
<div className="flex h-28 w-28 shrink-0 items-end justify-center overflow-hidden rounded-2xl border border-white/10 bg-black/35">
|
{lockedCharacterNames.has(role.name.trim()) ? (
|
||||||
{previewCharacter ? (
|
<span className="rounded-full border border-amber-300/18 bg-amber-500/10 px-2.5 py-1 text-[10px] text-amber-100">
|
||||||
<CharacterAnimator
|
创作者锁定
|
||||||
state={AnimationState.RUN}
|
</span>
|
||||||
character={previewCharacter}
|
) : null}
|
||||||
className="h-full w-full"
|
<span className="rounded-full border border-white/10 bg-black/20 px-2.5 py-1 text-[10px] text-zinc-300">
|
||||||
imageClassName="object-bottom"
|
初始好感 {role.initialAffinity}
|
||||||
/>
|
</span>
|
||||||
) : null}
|
{role.generatedVisualAssetId ? (
|
||||||
|
<span className="rounded-full border border-emerald-400/20 bg-emerald-500/10 px-2.5 py-1 text-[10px] text-emerald-100">
|
||||||
|
已生成主图
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
{role.tags.slice(0, 2).map((tag) => (
|
||||||
|
<span
|
||||||
|
key={`${role.id}-${tag}`}
|
||||||
|
className="rounded-full border border-white/10 bg-black/20 px-2.5 py-1 text-[10px] text-zinc-300"
|
||||||
|
>
|
||||||
|
{tag}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
{!readOnly ? (
|
||||||
|
<div className="ml-auto">
|
||||||
|
<SmallButton
|
||||||
|
onClick={() => removePlayable(role.id, role.name)}
|
||||||
|
tone="rose"
|
||||||
|
>
|
||||||
|
删除
|
||||||
|
</SmallButton>
|
||||||
</div>
|
</div>
|
||||||
<div className="min-w-0 flex-1">
|
) : null}
|
||||||
{lockedCharacterNames.has(role.name.trim()) ? (
|
</div>
|
||||||
<div className="mb-2 inline-flex rounded-full border border-amber-300/18 bg-amber-500/10 px-2.5 py-1 text-[10px] text-amber-100">
|
|
||||||
创作者锁定角色
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
<div className="text-sm leading-6 text-zinc-300">
|
|
||||||
{role.description}
|
|
||||||
</div>
|
|
||||||
<div className="mt-2 text-xs leading-6 text-zinc-400">
|
|
||||||
{role.backstory}
|
|
||||||
</div>
|
|
||||||
<div className="mt-3 rounded-xl border border-sky-300/12 bg-sky-500/8 px-3 py-2 text-xs leading-6 text-sky-50/95">
|
|
||||||
公开背景:
|
|
||||||
{role.backstoryReveal.publicSummary || '未填写'}
|
|
||||||
</div>
|
|
||||||
<div className="mt-3 grid gap-2 sm:grid-cols-2">
|
|
||||||
<div className="rounded-xl border border-white/8 bg-black/20 px-3 py-2 text-xs leading-6 text-zinc-300">
|
|
||||||
身份:{role.role}
|
|
||||||
</div>
|
|
||||||
<div className="rounded-xl border border-white/8 bg-black/20 px-3 py-2 text-xs leading-6 text-zinc-300">
|
|
||||||
初始好感:{role.initialAffinity}
|
|
||||||
</div>
|
|
||||||
<div className="rounded-xl border border-white/8 bg-black/20 px-3 py-2 text-xs leading-6 text-zinc-300">
|
|
||||||
性格:{role.personality}
|
|
||||||
</div>
|
|
||||||
<div className="rounded-xl border border-white/8 bg-black/20 px-3 py-2 text-xs leading-6 text-zinc-300">
|
|
||||||
战斗:{role.combatStyle}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="mt-3 rounded-xl border border-white/8 bg-black/20 px-3 py-2 text-xs leading-6 text-zinc-300">
|
|
||||||
动机:{role.motivation}
|
|
||||||
</div>
|
|
||||||
<div className="mt-3 rounded-xl border border-white/8 bg-black/20 px-3 py-3">
|
|
||||||
<div className="text-[11px] font-bold tracking-[0.16em] text-zinc-400">
|
|
||||||
好感背景章节
|
|
||||||
</div>
|
|
||||||
<div className="mt-2 space-y-2">
|
|
||||||
{role.backstoryReveal.chapters.map((chapter) => (
|
|
||||||
<div
|
|
||||||
key={`${role.id}-${chapter.id}`}
|
|
||||||
className="rounded-xl border border-white/8 bg-black/20 px-3 py-2 text-xs leading-6 text-zinc-300"
|
|
||||||
>
|
|
||||||
{chapter.affinityRequired} 好感 ·{' '}
|
|
||||||
{chapter.title}:{chapter.teaser}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="mt-3 rounded-xl border border-white/8 bg-black/20 px-3 py-3">
|
|
||||||
<div className="text-[11px] font-bold tracking-[0.16em] text-zinc-400">
|
|
||||||
技能
|
|
||||||
</div>
|
|
||||||
<div className="mt-2 space-y-2">
|
|
||||||
{role.skills.map((skill) => (
|
|
||||||
<div
|
|
||||||
key={`${role.id}-${skill.id}`}
|
|
||||||
className="rounded-xl border border-white/8 bg-black/20 px-3 py-2 text-xs leading-6 text-zinc-300"
|
|
||||||
>
|
|
||||||
{skill.name} · {skill.style}:{skill.summary}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="mt-3 rounded-xl border border-white/8 bg-black/20 px-3 py-3">
|
|
||||||
<div className="text-[11px] font-bold tracking-[0.16em] text-zinc-400">
|
|
||||||
初始物品
|
|
||||||
</div>
|
|
||||||
<div className="mt-2 space-y-2">
|
|
||||||
{role.initialItems.map((item) => (
|
|
||||||
<div
|
|
||||||
key={`${role.id}-${item.id}`}
|
|
||||||
className="rounded-xl border border-white/8 bg-black/20 px-3 py-2 text-xs leading-6 text-zinc-300"
|
|
||||||
>
|
|
||||||
{item.name} x{item.quantity} · {item.category} ·{' '}
|
|
||||||
{item.rarity}:{item.description}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="mt-3 flex flex-wrap gap-2">
|
|
||||||
{role.tags.map((tag) => (
|
|
||||||
<span
|
|
||||||
key={`${role.id}-${tag}`}
|
|
||||||
className="rounded-full border border-white/10 bg-black/20 px-2.5 py-1 text-[10px] text-zinc-300"
|
|
||||||
>
|
|
||||||
{tag}
|
|
||||||
</span>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Section>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})
|
})
|
||||||
@@ -1344,8 +1319,13 @@ export function CustomWorldEntityCatalog({
|
|||||||
{filteredStory.length === 0 ? (
|
{filteredStory.length === 0 ? (
|
||||||
<EmptyState title="当前没有符合搜索条件的场景角色。" />
|
<EmptyState title="当前没有符合搜索条件的场景角色。" />
|
||||||
) : (
|
) : (
|
||||||
filteredStory.map((npc) => (
|
filteredStory.map((npc, index) => (
|
||||||
<div key={npc.id}>
|
<div
|
||||||
|
key={buildFallbackRenderKey(
|
||||||
|
npc.id,
|
||||||
|
`story-npc-${index}-${npc.name.trim() || 'unnamed'}`,
|
||||||
|
)}
|
||||||
|
>
|
||||||
<CatalogCard
|
<CatalogCard
|
||||||
title={npc.name}
|
title={npc.name}
|
||||||
description={npc.description}
|
description={npc.description}
|
||||||
@@ -1399,8 +1379,13 @@ export function CustomWorldEntityCatalog({
|
|||||||
{filteredSceneEntries.length === 0 ? (
|
{filteredSceneEntries.length === 0 ? (
|
||||||
<EmptyState title="当前没有符合搜索条件的场景。" />
|
<EmptyState title="当前没有符合搜索条件的场景。" />
|
||||||
) : (
|
) : (
|
||||||
filteredSceneEntries.map((scene) => (
|
filteredSceneEntries.map((scene, index) => (
|
||||||
<div key={scene.id}>
|
<div
|
||||||
|
key={buildFallbackRenderKey(
|
||||||
|
scene.id,
|
||||||
|
`scene-entry-${index}-${scene.name.trim() || scene.kind}`,
|
||||||
|
)}
|
||||||
|
>
|
||||||
<CatalogCard
|
<CatalogCard
|
||||||
title={scene.name}
|
title={scene.name}
|
||||||
description={
|
description={
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import { render, screen, waitFor } from '@testing-library/react';
|
import { render, screen, waitFor } from '@testing-library/react';
|
||||||
import userEvent from '@testing-library/user-event';
|
import userEvent from '@testing-library/user-event';
|
||||||
|
import { useState } from 'react';
|
||||||
import { expect, test, vi } from 'vitest';
|
import { expect, test, vi } from 'vitest';
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
@@ -9,12 +10,32 @@ import type {
|
|||||||
CustomWorldPlayableNpc,
|
CustomWorldPlayableNpc,
|
||||||
CustomWorldProfile,
|
CustomWorldProfile,
|
||||||
} from '../types';
|
} from '../types';
|
||||||
import { CustomWorldEntityEditorModal } from './CustomWorldEntityEditorModal';
|
import { CustomWorldEntityCatalog } from './CustomWorldEntityCatalog';
|
||||||
|
import {
|
||||||
|
CustomWorldEntityEditorModal,
|
||||||
|
type CustomWorldEditorTarget,
|
||||||
|
} from './CustomWorldEntityEditorModal';
|
||||||
|
|
||||||
|
vi.mock('../data/characterPresets', async () => {
|
||||||
|
const actual = await vi.importActual<typeof import('../data/characterPresets')>(
|
||||||
|
'../data/characterPresets',
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
...actual,
|
||||||
|
buildCustomWorldPlayableCharacters: vi.fn(() => []),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
vi.mock('./CharacterAnimator', () => ({
|
vi.mock('./CharacterAnimator', () => ({
|
||||||
CharacterAnimator: () => <div>角色预览</div>,
|
CharacterAnimator: () => <div>角色预览</div>,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
vi.mock('../services/aiService', () => ({
|
||||||
|
generateCustomWorldSceneImage: vi.fn(),
|
||||||
|
generateCustomWorldSceneNpc: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
vi.mock('./CustomWorldNpcVisualEditor', () => ({
|
vi.mock('./CustomWorldNpcVisualEditor', () => ({
|
||||||
CustomWorldNpcPortrait: ({ npc }: { npc: { name: string } }) => (
|
CustomWorldNpcPortrait: ({ npc }: { npc: { name: string } }) => (
|
||||||
<div>{npc.name}</div>
|
<div>{npc.name}</div>
|
||||||
@@ -136,6 +157,94 @@ function createProfile(): CustomWorldProfile {
|
|||||||
} as unknown as 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<CustomWorldEditorTarget | null>({
|
||||||
|
kind: 'landmark',
|
||||||
|
mode: 'edit',
|
||||||
|
id: 'landmark-1',
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<CustomWorldEntityCatalog
|
||||||
|
profile={profile}
|
||||||
|
previewCharacters={[]}
|
||||||
|
activeTab="landmarks"
|
||||||
|
onActiveTabChange={() => {}}
|
||||||
|
onEditTarget={setTarget}
|
||||||
|
onProfileChange={setProfile}
|
||||||
|
onDeleteStoryNpcs={() => {}}
|
||||||
|
onDeleteLandmarks={() => {}}
|
||||||
|
/>
|
||||||
|
<CustomWorldEntityEditorModal
|
||||||
|
profile={profile}
|
||||||
|
target={target}
|
||||||
|
onClose={() => 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<CustomWorldEditorTarget | null>({
|
||||||
|
kind: 'camp',
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<CustomWorldEntityCatalog
|
||||||
|
profile={profile}
|
||||||
|
previewCharacters={[]}
|
||||||
|
activeTab="landmarks"
|
||||||
|
onActiveTabChange={() => {}}
|
||||||
|
onEditTarget={setTarget}
|
||||||
|
onProfileChange={setProfile}
|
||||||
|
onDeleteStoryNpcs={() => {}}
|
||||||
|
onDeleteLandmarks={() => {}}
|
||||||
|
/>
|
||||||
|
<CustomWorldEntityEditorModal
|
||||||
|
profile={profile}
|
||||||
|
target={target}
|
||||||
|
onClose={() => setTarget(null)}
|
||||||
|
onProfileChange={setProfile}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
test('playable角色打开AI工坊后不会自动关闭', async () => {
|
test('playable角色打开AI工坊后不会自动关闭', async () => {
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
const handleClose = vi.fn();
|
const handleClose = vi.fn();
|
||||||
@@ -179,3 +288,263 @@ test('场景角色打开AI工坊后不会自动关闭', async () => {
|
|||||||
|
|
||||||
expect(handleClose).not.toHaveBeenCalled();
|
expect(handleClose).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('可扮演角色未修改时右上角关闭不会弹确认', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
const handleClose = vi.fn();
|
||||||
|
|
||||||
|
render(
|
||||||
|
<CustomWorldEntityEditorModal
|
||||||
|
profile={createProfile()}
|
||||||
|
target={{ kind: 'playable', mode: 'edit', id: 'playable-1' }}
|
||||||
|
onClose={handleClose}
|
||||||
|
onProfileChange={vi.fn()}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
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(
|
||||||
|
<CustomWorldEntityEditorModal
|
||||||
|
profile={createProfile()}
|
||||||
|
target={{ kind: 'playable', mode: 'edit', id: 'playable-1' }}
|
||||||
|
onClose={handleClose}
|
||||||
|
onProfileChange={vi.fn()}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
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(
|
||||||
|
<CustomWorldEntityEditorModal
|
||||||
|
profile={createProfile()}
|
||||||
|
target={{ kind: 'story', mode: 'edit', id: 'story-1' }}
|
||||||
|
onClose={handleClose}
|
||||||
|
onProfileChange={vi.fn()}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
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(
|
||||||
|
<CustomWorldEntityEditorModal
|
||||||
|
profile={createProfile()}
|
||||||
|
target={{ kind: 'story', mode: 'edit', id: 'story-1' }}
|
||||||
|
onClose={handleClose}
|
||||||
|
onProfileChange={vi.fn()}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
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(
|
||||||
|
<CustomWorldEntityCatalog
|
||||||
|
profile={createProfile()}
|
||||||
|
previewCharacters={[]}
|
||||||
|
activeTab="playable"
|
||||||
|
onActiveTabChange={() => {}}
|
||||||
|
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(
|
||||||
|
<CustomWorldEntityCatalog
|
||||||
|
profile={{
|
||||||
|
...createProfile(),
|
||||||
|
playableNpcs: [
|
||||||
|
createPlayableRole('', '沈砺'),
|
||||||
|
createPlayableRole('', '闻潮'),
|
||||||
|
],
|
||||||
|
}}
|
||||||
|
previewCharacters={[]}
|
||||||
|
activeTab="playable"
|
||||||
|
onActiveTabChange={() => {}}
|
||||||
|
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(<LandmarkEditorFlowHarness />);
|
||||||
|
|
||||||
|
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(<CampEditorFlowHarness />);
|
||||||
|
|
||||||
|
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',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import type { ChangeEvent } from 'react';
|
import type { ChangeEvent } from 'react';
|
||||||
import type { CSSProperties } 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 { createPortal } from 'react-dom';
|
||||||
|
|
||||||
import { AFFINITY_BACKSTORY_CHAPTER_THRESHOLDS } from '../data/affinityLevels';
|
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<T>(value: T) {
|
function useDraft<T>(value: T) {
|
||||||
|
const syncToken = useMemo(() => buildDraftSyncToken(value), [value]);
|
||||||
const [draft, setDraft] = useState(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;
|
return [draft, setDraft] as const;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -421,6 +441,7 @@ function ModalShell({
|
|||||||
type="button"
|
type="button"
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
disabled={disableClose}
|
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' : ''}`}
|
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' : ''}`}
|
||||||
>
|
>
|
||||||
<PixelIcon src={CHROME_ICONS.close} className="h-4 w-4" />
|
<PixelIcon src={CHROME_ICONS.close} className="h-4 w-4" />
|
||||||
@@ -495,6 +516,7 @@ function CompactDialogShell({
|
|||||||
type="button"
|
type="button"
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
disabled={disableClose}
|
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' : ''}`}
|
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' : ''}`}
|
||||||
>
|
>
|
||||||
<PixelIcon src={CHROME_ICONS.close} className="h-4 w-4" />
|
<PixelIcon src={CHROME_ICONS.close} className="h-4 w-4" />
|
||||||
@@ -843,7 +865,7 @@ function ScenePresetPickerModal({
|
|||||||
const isSelected = src === selectedSrc;
|
const isSelected = src === selectedSrc;
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
key={src}
|
key={`preset-image-${index}-${src || 'empty'}`}
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
onSelect(src);
|
onSelect(src);
|
||||||
@@ -1666,7 +1688,7 @@ function SaveBar({
|
|||||||
showClose?: boolean;
|
showClose?: boolean;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<div className="sticky bottom-0 z-10 -mx-4 border-t border-white/10 bg-[linear-gradient(180deg,rgba(8,10,17,0.2)_0%,rgba(8,10,17,0.96)_28%)] px-4 pb-[calc(env(safe-area-inset-bottom,0px)+0.35rem)] pt-3 backdrop-blur sm:static sm:mx-0 sm:border-0 sm:bg-transparent sm:px-0 sm:pb-0 sm:pt-2 sm:backdrop-blur-0">
|
<div className="sticky bottom-0 z-10 -mx-4 border-t border-white/10 bg-[linear-gradient(180deg,rgba(8,10,17,0)_0%,rgba(8,10,17,0.84)_42%,rgba(8,10,17,0.96)_100%)] px-4 pb-[calc(env(safe-area-inset-bottom,0px)+0.2rem)] pt-2 backdrop-blur sm:static sm:mx-0 sm:border-0 sm:bg-transparent sm:px-0 sm:pb-0 sm:pt-2 sm:backdrop-blur-0">
|
||||||
<div
|
<div
|
||||||
className={`flex flex-col gap-3 ${
|
className={`flex flex-col gap-3 ${
|
||||||
extraAction
|
extraAction
|
||||||
@@ -2813,7 +2835,11 @@ function CampSceneEditor({
|
|||||||
onSaveProfile: (profile: CustomWorldProfile) => void;
|
onSaveProfile: (profile: CustomWorldProfile) => void;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
}) {
|
}) {
|
||||||
const [draft, setDraft] = useDraft(resolveCustomWorldCampScene(profile));
|
const initialCampScene = useMemo(
|
||||||
|
() => resolveCustomWorldCampScene(profile),
|
||||||
|
[profile],
|
||||||
|
);
|
||||||
|
const [draft, setDraft] = useDraft(initialCampScene);
|
||||||
const [isPresetPickerOpen, setIsPresetPickerOpen] = useState(false);
|
const [isPresetPickerOpen, setIsPresetPickerOpen] = useState(false);
|
||||||
const [isAiGenerateOpen, setIsAiGenerateOpen] = useState(false);
|
const [isAiGenerateOpen, setIsAiGenerateOpen] = useState(false);
|
||||||
const [isCloseConfirmOpen, setIsCloseConfirmOpen] = useState(false);
|
const [isCloseConfirmOpen, setIsCloseConfirmOpen] = useState(false);
|
||||||
@@ -2833,8 +2859,8 @@ function CampSceneEditor({
|
|||||||
[draft],
|
[draft],
|
||||||
);
|
);
|
||||||
const initialSnapshot = useMemo(
|
const initialSnapshot = useMemo(
|
||||||
() => JSON.stringify(resolveCustomWorldCampScene(profile)),
|
() => JSON.stringify(initialCampScene),
|
||||||
[profile],
|
[initialCampScene],
|
||||||
);
|
);
|
||||||
const hasUnsavedChanges = draftSnapshot !== initialSnapshot;
|
const hasUnsavedChanges = draftSnapshot !== initialSnapshot;
|
||||||
const campSceneDraft = useMemo<CustomWorldLandmark>(
|
const campSceneDraft = useMemo<CustomWorldLandmark>(
|
||||||
@@ -3002,6 +3028,9 @@ function PlayableNpcEditor({
|
|||||||
const [draft, setDraft] = useDraft(npc);
|
const [draft, setDraft] = useDraft(npc);
|
||||||
const [isAiAssetStudioOpen, setIsAiAssetStudioOpen] = useState(false);
|
const [isAiAssetStudioOpen, setIsAiAssetStudioOpen] = useState(false);
|
||||||
const [isCloseConfirmOpen, setIsCloseConfirmOpen] = useState(false);
|
const [isCloseConfirmOpen, setIsCloseConfirmOpen] = useState(false);
|
||||||
|
const initialSnapshot = useMemo(() => JSON.stringify(npc), [npc]);
|
||||||
|
const draftSnapshot = useMemo(() => JSON.stringify(draft), [draft]);
|
||||||
|
const hasUnsavedChanges = draftSnapshot !== initialSnapshot;
|
||||||
const selectedTemplate =
|
const selectedTemplate =
|
||||||
ROLE_TEMPLATE_CHARACTERS.find(
|
ROLE_TEMPLATE_CHARACTERS.find(
|
||||||
(character) => character.id === draft.templateCharacterId,
|
(character) => character.id === draft.templateCharacterId,
|
||||||
@@ -3027,6 +3056,10 @@ function PlayableNpcEditor({
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
const handleRequestClose = () => {
|
const handleRequestClose = () => {
|
||||||
|
if (!hasUnsavedChanges) {
|
||||||
|
onClose();
|
||||||
|
return;
|
||||||
|
}
|
||||||
setIsCloseConfirmOpen(true);
|
setIsCloseConfirmOpen(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -3300,6 +3333,9 @@ function StoryNpcEditor({
|
|||||||
const [isVisualEditorOpen, setIsVisualEditorOpen] = useState(false);
|
const [isVisualEditorOpen, setIsVisualEditorOpen] = useState(false);
|
||||||
const [isAiAssetStudioOpen, setIsAiAssetStudioOpen] = useState(false);
|
const [isAiAssetStudioOpen, setIsAiAssetStudioOpen] = useState(false);
|
||||||
const [isCloseConfirmOpen, setIsCloseConfirmOpen] = useState(false);
|
const [isCloseConfirmOpen, setIsCloseConfirmOpen] = useState(false);
|
||||||
|
const initialSnapshot = useMemo(() => JSON.stringify(npc), [npc]);
|
||||||
|
const draftSnapshot = useMemo(() => JSON.stringify(draft), [draft]);
|
||||||
|
const hasUnsavedChanges = draftSnapshot !== initialSnapshot;
|
||||||
const roleOptions = useMemo(
|
const roleOptions = useMemo(
|
||||||
() =>
|
() =>
|
||||||
[...profile.playableNpcs, ...profile.storyNpcs]
|
[...profile.playableNpcs, ...profile.storyNpcs]
|
||||||
@@ -3319,6 +3355,10 @@ function StoryNpcEditor({
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
const handleRequestClose = () => {
|
const handleRequestClose = () => {
|
||||||
|
if (!hasUnsavedChanges) {
|
||||||
|
onClose();
|
||||||
|
return;
|
||||||
|
}
|
||||||
setIsCloseConfirmOpen(true);
|
setIsCloseConfirmOpen(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -48,6 +48,14 @@ function getProgressPercentage(progress: CustomWorldGenerationProgress | null) {
|
|||||||
return Math.max(0, Math.min(100, progress?.overallProgress ?? 0));
|
return Math.max(0, Math.min(100, progress?.overallProgress ?? 0));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function buildFallbackRenderKey(
|
||||||
|
value: string | null | undefined,
|
||||||
|
fallback: string,
|
||||||
|
) {
|
||||||
|
const normalizedValue = value?.trim();
|
||||||
|
return normalizedValue ? normalizedValue : fallback;
|
||||||
|
}
|
||||||
|
|
||||||
export function CustomWorldGenerationView({
|
export function CustomWorldGenerationView({
|
||||||
settingText,
|
settingText,
|
||||||
anchorEntries = [],
|
anchorEntries = [],
|
||||||
@@ -171,9 +179,9 @@ export function CustomWorldGenerationView({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-4 space-y-2 xl:min-h-0 xl:flex-1 xl:overflow-y-auto xl:pr-1">
|
<div className="mt-4 space-y-2 xl:min-h-0 xl:flex-1 xl:overflow-y-auto xl:pr-1">
|
||||||
{steps.map((step) => (
|
{steps.map((step, index) => (
|
||||||
<div
|
<div
|
||||||
key={step.id}
|
key={buildFallbackRenderKey(step.id, `progress-step-${index}`)}
|
||||||
className={`rounded-2xl border px-4 py-3 transition-colors ${
|
className={`rounded-2xl border px-4 py-3 transition-colors ${
|
||||||
step.status === 'completed'
|
step.status === 'completed'
|
||||||
? 'border-emerald-400/16 bg-emerald-500/8'
|
? 'border-emerald-400/16 bg-emerald-500/8'
|
||||||
@@ -269,9 +277,12 @@ export function CustomWorldGenerationView({
|
|||||||
</div>
|
</div>
|
||||||
{hasStructuredAnchors ? (
|
{hasStructuredAnchors ? (
|
||||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||||
{anchorEntries.map((entry) => (
|
{anchorEntries.map((entry, index) => (
|
||||||
<div
|
<div
|
||||||
key={entry.id}
|
key={buildFallbackRenderKey(
|
||||||
|
entry.id,
|
||||||
|
`anchor-entry-${index}`,
|
||||||
|
)}
|
||||||
className="rounded-2xl border border-white/8 bg-black/22 px-4 py-4"
|
className="rounded-2xl border border-white/8 bg-black/22 px-4 py-4"
|
||||||
>
|
>
|
||||||
<div className="text-[11px] font-bold tracking-[0.18em] text-zinc-500">
|
<div className="text-[11px] font-bold tracking-[0.18em] text-zinc-500">
|
||||||
|
|||||||
@@ -3,8 +3,8 @@ import {
|
|||||||
RefreshCcw,
|
RefreshCcw,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import {
|
import {
|
||||||
type CSSProperties,
|
|
||||||
type ChangeEvent,
|
type ChangeEvent,
|
||||||
|
type CSSProperties,
|
||||||
type ReactNode,
|
type ReactNode,
|
||||||
useEffect,
|
useEffect,
|
||||||
useMemo,
|
useMemo,
|
||||||
@@ -15,8 +15,8 @@ import { createPortal } from 'react-dom';
|
|||||||
import { ROLE_TEMPLATE_CHARACTERS } from '../data/characterPresets';
|
import { ROLE_TEMPLATE_CHARACTERS } from '../data/characterPresets';
|
||||||
import {
|
import {
|
||||||
AnimationState,
|
AnimationState,
|
||||||
type CharacterAnimationConfig,
|
|
||||||
type Character,
|
type Character,
|
||||||
|
type CharacterAnimationConfig,
|
||||||
} from '../types';
|
} from '../types';
|
||||||
import {
|
import {
|
||||||
buildAnimationClipFromVideoSource,
|
buildAnimationClipFromVideoSource,
|
||||||
@@ -358,6 +358,37 @@ function buildRoleCharacterBrief(
|
|||||||
.join('\n');
|
.join('\n');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isLegacyGeneratedVisualDescription(value: string) {
|
||||||
|
const normalized = value.trim();
|
||||||
|
if (!normalized) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'2D 横版 RPG',
|
||||||
|
'纯绿色绿幕',
|
||||||
|
'2 到 2.5 头身',
|
||||||
|
'深色粗轮廓',
|
||||||
|
'身体整体朝右',
|
||||||
|
'脚底完整可见',
|
||||||
|
].some((marker) => normalized.includes(marker));
|
||||||
|
}
|
||||||
|
|
||||||
|
function isLegacyGeneratedActionDescription(value: string) {
|
||||||
|
const normalized = value.trim();
|
||||||
|
if (!normalized) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'动作气质参考:',
|
||||||
|
'发力起手明确',
|
||||||
|
'收招利落',
|
||||||
|
'动作表现偏向',
|
||||||
|
'起手克制',
|
||||||
|
].some((marker) => normalized.includes(marker));
|
||||||
|
}
|
||||||
|
|
||||||
function mergeRole<T extends EditableCustomWorldRole>(
|
function mergeRole<T extends EditableCustomWorldRole>(
|
||||||
role: T,
|
role: T,
|
||||||
patch: Partial<T>,
|
patch: Partial<T>,
|
||||||
@@ -712,10 +743,16 @@ export function CustomWorldRoleAssetStudioModal({
|
|||||||
});
|
});
|
||||||
setWorkingRole(nextRole);
|
setWorkingRole(nextRole);
|
||||||
setVisualPromptText(
|
setVisualPromptText(
|
||||||
cache.visualPromptText || initialPromptBundle.visualPromptText,
|
cache.visualPromptText &&
|
||||||
|
!isLegacyGeneratedVisualDescription(cache.visualPromptText)
|
||||||
|
? cache.visualPromptText
|
||||||
|
: initialPromptBundle.visualPromptText,
|
||||||
);
|
);
|
||||||
setAnimationPromptText(
|
setAnimationPromptText(
|
||||||
cache.animationPromptText || initialPromptBundle.animationPromptText,
|
cache.animationPromptText &&
|
||||||
|
!isLegacyGeneratedActionDescription(cache.animationPromptText)
|
||||||
|
? cache.animationPromptText
|
||||||
|
: initialPromptBundle.animationPromptText,
|
||||||
);
|
);
|
||||||
setVisualDrafts(cache.visualDrafts ?? []);
|
setVisualDrafts(cache.visualDrafts ?? []);
|
||||||
setSelectedVisualDraftId(
|
setSelectedVisualDraftId(
|
||||||
@@ -904,6 +941,8 @@ export function CustomWorldRoleAssetStudioModal({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const isLoopAction = config.loop;
|
const isLoopAction = config.loop;
|
||||||
|
const shouldUseLastFrameReference =
|
||||||
|
!isLoopAction && config.animation !== AnimationState.DIE;
|
||||||
|
|
||||||
const result = await generateCharacterAnimationDraft({
|
const result = await generateCharacterAnimationDraft({
|
||||||
characterId: workingRole.id,
|
characterId: workingRole.id,
|
||||||
@@ -915,7 +954,9 @@ export function CustomWorldRoleAssetStudioModal({
|
|||||||
visualSource: workingRole.imageSrc,
|
visualSource: workingRole.imageSrc,
|
||||||
referenceImageDataUrls: [],
|
referenceImageDataUrls: [],
|
||||||
referenceVideoDataUrls: [],
|
referenceVideoDataUrls: [],
|
||||||
lastFrameImageDataUrl: isLoopAction ? undefined : workingRole.imageSrc,
|
lastFrameImageDataUrl: shouldUseLastFrameReference
|
||||||
|
? workingRole.imageSrc
|
||||||
|
: undefined,
|
||||||
frameCount: config.frameCount,
|
frameCount: config.frameCount,
|
||||||
fps: config.fps,
|
fps: config.fps,
|
||||||
durationSeconds: config.durationSeconds,
|
durationSeconds: config.durationSeconds,
|
||||||
@@ -1108,12 +1149,12 @@ export function CustomWorldRoleAssetStudioModal({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Field label="形象提示词">
|
<Field label="形象描述">
|
||||||
<TextArea
|
<TextArea
|
||||||
value={visualPromptText}
|
value={visualPromptText}
|
||||||
onChange={setVisualPromptText}
|
onChange={setVisualPromptText}
|
||||||
rows={6}
|
rows={6}
|
||||||
placeholder="角色形象提示词会先按设定自动生成,也可以继续手动细化。"
|
placeholder="这里默认展示角色形象描述,也可以继续手动细化。"
|
||||||
/>
|
/>
|
||||||
</Field>
|
</Field>
|
||||||
|
|
||||||
@@ -1296,12 +1337,12 @@ export function CustomWorldRoleAssetStudioModal({
|
|||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Field label="动作提示词">
|
<Field label="动作描述">
|
||||||
<TextArea
|
<TextArea
|
||||||
value={animationPromptText}
|
value={animationPromptText}
|
||||||
onChange={setAnimationPromptText}
|
onChange={setAnimationPromptText}
|
||||||
rows={5}
|
rows={5}
|
||||||
placeholder="角色动作提示词会先按设定自动生成,也可以继续手动细化。"
|
placeholder="这里默认展示角色动作描述,也可以继续手动细化。"
|
||||||
/>
|
/>
|
||||||
</Field>
|
</Field>
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { describe, expect, it } from 'vitest';
|
|||||||
import { buildDefaultRolePromptBundle } from './customWorldRolePromptDefaults';
|
import { buildDefaultRolePromptBundle } from './customWorldRolePromptDefaults';
|
||||||
|
|
||||||
describe('buildDefaultRolePromptBundle', () => {
|
describe('buildDefaultRolePromptBundle', () => {
|
||||||
it('prefers model-generated role descriptions instead of rule-based assembly', () => {
|
it('uses model-generated role descriptions directly', () => {
|
||||||
const result = buildDefaultRolePromptBundle({
|
const result = buildDefaultRolePromptBundle({
|
||||||
name: '沈砺',
|
name: '沈砺',
|
||||||
title: '灰炬向导',
|
title: '灰炬向导',
|
||||||
@@ -28,7 +28,7 @@ describe('buildDefaultRolePromptBundle', () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('falls back to compact role descriptions without reintroducing built-in prompt rules', () => {
|
it('falls back to existing entity descriptions without assembling new rules', () => {
|
||||||
const result = buildDefaultRolePromptBundle({
|
const result = buildDefaultRolePromptBundle({
|
||||||
name: '顾潮音',
|
name: '顾潮音',
|
||||||
title: '港口守望者',
|
title: '港口守望者',
|
||||||
@@ -41,11 +41,11 @@ describe('buildDefaultRolePromptBundle', () => {
|
|||||||
tags: ['潮雾港', '守望', '旧案'],
|
tags: ['潮雾港', '守望', '旧案'],
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(result.visualPromptText).toContain('总在潮雾港高处盯着来往船影的守望者。');
|
expect(result.visualPromptText).toBe('总在潮雾港高处盯着来往船影的守望者。');
|
||||||
expect(result.animationPromptText).toContain('长枪封线后借高差压制。');
|
expect(result.animationPromptText).toBe('长枪封线后借高差压制。');
|
||||||
expect(result.scenePromptText).toContain('他把许多没说出口的旧案痕迹留在港口高处。');
|
expect(result.scenePromptText).toBe('他把许多没说出口的旧案痕迹留在港口高处。');
|
||||||
expect(result.visualPromptText).not.toContain('2D 横版 RPG');
|
expect(result.visualPromptText).not.toContain('经典横版像素动作角色');
|
||||||
expect(result.visualPromptText).not.toContain('纯绿色绿幕');
|
expect(result.visualPromptText).not.toContain('深色粗轮廓配合清晰大色块');
|
||||||
expect(result.visualPromptText).not.toContain('提示词');
|
expect(result.visualPromptText).not.toContain('提示词');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -23,52 +23,35 @@ function cleanSeedText(value: string | undefined, maxLength: number) {
|
|||||||
return (value ?? '').replace(/\s+/gu, ' ').trim().slice(0, maxLength);
|
return (value ?? '').replace(/\s+/gu, ' ').trim().slice(0, maxLength);
|
||||||
}
|
}
|
||||||
|
|
||||||
function compactDescription(parts: Array<string | undefined>, maxLength: number) {
|
function pickFirstDescription(
|
||||||
return parts
|
values: Array<string | undefined>,
|
||||||
.map((item) => cleanSeedText(item, maxLength))
|
maxLength: number,
|
||||||
.filter(Boolean)
|
) {
|
||||||
.join(' ')
|
for (const value of values) {
|
||||||
.slice(0, maxLength);
|
const normalized = cleanSeedText(value, maxLength);
|
||||||
|
if (normalized) {
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return '';
|
||||||
}
|
}
|
||||||
|
|
||||||
export function buildDefaultRolePromptBundle(
|
export function buildDefaultRolePromptBundle(
|
||||||
role: PromptDefaultRole,
|
role: PromptDefaultRole,
|
||||||
): CustomWorldRolePromptBundle {
|
): CustomWorldRolePromptBundle {
|
||||||
const roleLabel = [cleanSeedText(role.name, 40), cleanSeedText(role.title, 40)]
|
|
||||||
.filter(Boolean)
|
|
||||||
.join(',');
|
|
||||||
const fallbackVisualDescription = compactDescription(
|
|
||||||
[
|
|
||||||
roleLabel || cleanSeedText(role.role, 40),
|
|
||||||
role.description,
|
|
||||||
role.personality,
|
|
||||||
role.tags && role.tags.length > 0 ? role.tags.slice(0, 8).join('、') : '',
|
|
||||||
],
|
|
||||||
220,
|
|
||||||
);
|
|
||||||
const fallbackActionDescription = compactDescription(
|
|
||||||
[
|
|
||||||
role.actionDescription,
|
|
||||||
role.combatStyle,
|
|
||||||
role.motivation,
|
|
||||||
role.personality,
|
|
||||||
],
|
|
||||||
180,
|
|
||||||
);
|
|
||||||
const generatedSceneDescription = cleanSeedText(role.sceneVisualDescription, 220);
|
|
||||||
const fallbackSceneDescription = compactDescription(
|
|
||||||
[
|
|
||||||
role.backstory,
|
|
||||||
role.description,
|
|
||||||
role.motivation,
|
|
||||||
],
|
|
||||||
220,
|
|
||||||
);
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
visualPromptText:
|
visualPromptText: pickFirstDescription(
|
||||||
cleanSeedText(role.visualDescription, 220) || fallbackVisualDescription,
|
[role.visualDescription, role.description],
|
||||||
animationPromptText: fallbackActionDescription,
|
220,
|
||||||
scenePromptText: generatedSceneDescription || fallbackSceneDescription,
|
),
|
||||||
|
animationPromptText: pickFirstDescription(
|
||||||
|
[role.actionDescription, role.combatStyle],
|
||||||
|
180,
|
||||||
|
),
|
||||||
|
scenePromptText: pickFirstDescription(
|
||||||
|
[role.sceneVisualDescription, role.backstory],
|
||||||
|
220,
|
||||||
|
),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,15 @@
|
|||||||
|
/* @vitest-environment jsdom */
|
||||||
|
|
||||||
|
import { render, screen } from '@testing-library/react';
|
||||||
import { renderToStaticMarkup } from 'react-dom/server';
|
import { renderToStaticMarkup } from 'react-dom/server';
|
||||||
import { expect, test } from 'vitest';
|
import { afterEach, expect, test, vi } from 'vitest';
|
||||||
|
|
||||||
import { CustomWorldAgentClarificationPanel } from './CustomWorldAgentClarificationPanel';
|
import { CustomWorldAgentClarificationPanel } from './CustomWorldAgentClarificationPanel';
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
test('clarification panel shows pending questions and ready state', () => {
|
test('clarification panel shows pending questions and ready state', () => {
|
||||||
const pendingHtml = renderToStaticMarkup(
|
const pendingHtml = renderToStaticMarkup(
|
||||||
<CustomWorldAgentClarificationPanel
|
<CustomWorldAgentClarificationPanel
|
||||||
@@ -44,3 +51,48 @@ test('clarification panel shows pending questions and ready state', () => {
|
|||||||
expect(pendingHtml).toContain('玩家是谁,故事开场时卡在什么处境里');
|
expect(pendingHtml).toContain('玩家是谁,故事开场时卡在什么处境里');
|
||||||
expect(readyHtml).toContain('当前设定已齐备,可以进入下一阶段');
|
expect(readyHtml).toContain('当前设定已齐备,可以进入下一阶段');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('falls back to stable keys when clarification ids are empty', () => {
|
||||||
|
const consoleErrorSpy = vi
|
||||||
|
.spyOn(console, 'error')
|
||||||
|
.mockImplementation(() => undefined);
|
||||||
|
|
||||||
|
render(
|
||||||
|
<CustomWorldAgentClarificationPanel
|
||||||
|
readiness={{
|
||||||
|
isReady: false,
|
||||||
|
completedKeys: [],
|
||||||
|
missingKeys: ['player_premise', 'core_conflict'],
|
||||||
|
}}
|
||||||
|
pendingClarifications={[
|
||||||
|
{
|
||||||
|
id: '',
|
||||||
|
label: '玩家身份与开局',
|
||||||
|
question: '玩家是谁,故事开场时卡在什么处境里?',
|
||||||
|
targetKey: 'player_premise',
|
||||||
|
priority: 2,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '',
|
||||||
|
label: '核心冲突',
|
||||||
|
question: '第一阶段最直接撞上的冲突是什么?',
|
||||||
|
targetKey: 'core_conflict',
|
||||||
|
priority: 1,
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByText(/玩家身份与开局/u)).toBeTruthy();
|
||||||
|
expect(screen.getByText(/核心冲突/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);
|
||||||
|
});
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ export function CustomWorldAgentClarificationPanel({
|
|||||||
<div className="mt-4 space-y-2">
|
<div className="mt-4 space-y-2">
|
||||||
{pendingClarifications.slice(0, 3).map((item, index) => (
|
{pendingClarifications.slice(0, 3).map((item, index) => (
|
||||||
<div
|
<div
|
||||||
key={item.id}
|
key={item.id.trim() || `clarification-${item.targetKey}-${index}`}
|
||||||
className="rounded-[1.15rem] border border-white/8 bg-white/5 px-3 py-3"
|
className="rounded-[1.15rem] border border-white/8 bg-white/5 px-3 py-3"
|
||||||
>
|
>
|
||||||
<div className="flex items-start justify-between gap-3">
|
<div className="flex items-start justify-between gap-3">
|
||||||
|
|||||||
@@ -44,7 +44,6 @@ import {
|
|||||||
} from '../../services/customWorldAgentUiState';
|
} from '../../services/customWorldAgentUiState';
|
||||||
import {
|
import {
|
||||||
buildCustomWorldCreatorIntentFoundationText,
|
buildCustomWorldCreatorIntentFoundationText,
|
||||||
buildCustomWorldCreatorIntentGenerationText,
|
|
||||||
} from '../../services/customWorldCreatorIntent';
|
} from '../../services/customWorldCreatorIntent';
|
||||||
import {
|
import {
|
||||||
clearPlatformBrowseHistory,
|
clearPlatformBrowseHistory,
|
||||||
@@ -151,14 +150,6 @@ function buildOptimisticAgentMessage(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildAgentSeedTextFromProfile(profile: CustomWorldProfile) {
|
|
||||||
return (
|
|
||||||
buildCustomWorldCreatorIntentGenerationText(profile.creatorIntent).trim() ||
|
|
||||||
buildCustomWorldCreatorIntentFoundationText(profile.creatorIntent).trim() ||
|
|
||||||
profile.settingText.trim()
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizeAgentBackedProfile(profile: CustomWorldProfile) {
|
function normalizeAgentBackedProfile(profile: CustomWorldProfile) {
|
||||||
const foundationText = buildCustomWorldCreatorIntentFoundationText(
|
const foundationText = buildCustomWorldCreatorIntentFoundationText(
|
||||||
profile.creatorIntent,
|
profile.creatorIntent,
|
||||||
@@ -784,29 +775,6 @@ export function PreGameSelectionFlow({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const continueWorldInAgent = async (
|
|
||||||
profile = generatedCustomWorldProfile,
|
|
||||||
) => {
|
|
||||||
if (!profile || isCreatingAgentSession) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isAgentDraftResultView && activeAgentSessionId) {
|
|
||||||
setGeneratedCustomWorldProfile(null);
|
|
||||||
setCustomWorldError(null);
|
|
||||||
setCustomWorldAutoSaveError(null);
|
|
||||||
setCustomWorldAutoSaveState('idle');
|
|
||||||
setAgentDraftGenerationStartedAt(null);
|
|
||||||
setCustomWorldGenerationViewSource(null);
|
|
||||||
setCustomWorldResultViewSource(null);
|
|
||||||
setPlatformTab('create');
|
|
||||||
setSelectionStage('agent-workspace');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
await openRpgAgentWorkspace(buildAgentSeedTextFromProfile(profile));
|
|
||||||
};
|
|
||||||
|
|
||||||
const submitAgentMessage = async (
|
const submitAgentMessage = async (
|
||||||
payload: SendCustomWorldAgentMessageRequest,
|
payload: SendCustomWorldAgentMessageRequest,
|
||||||
) => {
|
) => {
|
||||||
@@ -961,10 +929,6 @@ export function PreGameSelectionFlow({
|
|||||||
openCreationTypePicker();
|
openCreationTypePicker();
|
||||||
};
|
};
|
||||||
|
|
||||||
const editCustomWorldSetting = () => {
|
|
||||||
void continueWorldInAgent();
|
|
||||||
};
|
|
||||||
|
|
||||||
const openLibraryDetail = (
|
const openLibraryDetail = (
|
||||||
entry: CustomWorldLibraryEntry<CustomWorldProfile>,
|
entry: CustomWorldLibraryEntry<CustomWorldProfile>,
|
||||||
) => {
|
) => {
|
||||||
@@ -1441,9 +1405,7 @@ export function PreGameSelectionFlow({
|
|||||||
? leaveAgentDraftResult
|
? leaveAgentDraftResult
|
||||||
: leaveCustomWorldResult
|
: leaveCustomWorldResult
|
||||||
}
|
}
|
||||||
onEditSetting={
|
onEditSetting={undefined}
|
||||||
isAgentDraftResultView ? undefined : editCustomWorldSetting
|
|
||||||
}
|
|
||||||
onRegenerate={undefined}
|
onRegenerate={undefined}
|
||||||
onContinueExpand={undefined}
|
onContinueExpand={undefined}
|
||||||
onEnterWorld={() => {
|
onEnterWorld={() => {
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
import { rehydrateSavedSnapshot } from '../../persistence/runtimeSnapshot';
|
import { rehydrateSavedSnapshot } from '../../persistence/runtimeSnapshot';
|
||||||
import type { HydratedSavedGameSnapshot } from '../../persistence/runtimeSnapshotTypes';
|
import type { HydratedSavedGameSnapshot } from '../../persistence/runtimeSnapshotTypes';
|
||||||
import {
|
import {
|
||||||
buildStoryMomentFromRuntimeOptions,
|
|
||||||
getRuntimeClientVersion,
|
getRuntimeClientVersion,
|
||||||
getRuntimeSessionId,
|
getRuntimeSessionId,
|
||||||
getRuntimeStoryState,
|
getRuntimeStoryState,
|
||||||
resolveRuntimeStoryAction,
|
resolveRuntimeStoryAction,
|
||||||
|
resolveRuntimeStoryMoment,
|
||||||
type RuntimeStoryChoicePayload,
|
type RuntimeStoryChoicePayload,
|
||||||
type RuntimeStoryResponse,
|
type RuntimeStoryResponse,
|
||||||
} from '../../services/runtimeStoryService';
|
} from '../../services/runtimeStoryService';
|
||||||
@@ -38,10 +38,11 @@ export async function loadServerRuntimeOptionCatalog(params: {
|
|||||||
const response = await getRuntimeStoryState(
|
const response = await getRuntimeStoryState(
|
||||||
getRuntimeSessionId(params.gameState),
|
getRuntimeSessionId(params.gameState),
|
||||||
);
|
);
|
||||||
const options = buildStoryMomentFromRuntimeOptions({
|
const options = resolveRuntimeStoryMoment({
|
||||||
storyText: response.presentation.storyText,
|
response,
|
||||||
options: getRuntimeResponseOptions(response),
|
hydratedSnapshot: response.snapshot,
|
||||||
gameState: params.gameState,
|
fallbackGameState: params.gameState,
|
||||||
|
fallbackStoryText: response.presentation.storyText,
|
||||||
}).options;
|
}).options;
|
||||||
|
|
||||||
return options.length > 0 ? options : null;
|
return options.length > 0 ? options : null;
|
||||||
@@ -70,14 +71,15 @@ export async function resumeServerRuntimeStory(
|
|||||||
const runtimeOptions = getRuntimeResponseOptions(response);
|
const runtimeOptions = getRuntimeResponseOptions(response);
|
||||||
const nextStory =
|
const nextStory =
|
||||||
response.presentation.storyText || runtimeOptions.length > 0
|
response.presentation.storyText || runtimeOptions.length > 0
|
||||||
? buildStoryMomentFromRuntimeOptions({
|
? resolveRuntimeStoryMoment({
|
||||||
storyText:
|
response,
|
||||||
|
hydratedSnapshot: resumedSnapshot,
|
||||||
|
fallbackGameState: hydratedSnapshot.gameState,
|
||||||
|
fallbackStoryText:
|
||||||
response.presentation.storyText ||
|
response.presentation.storyText ||
|
||||||
resumedSnapshot.currentStory?.text ||
|
resumedSnapshot.currentStory?.text ||
|
||||||
hydratedSnapshot.currentStory?.text ||
|
hydratedSnapshot.currentStory?.text ||
|
||||||
'',
|
'',
|
||||||
options: runtimeOptions,
|
|
||||||
gameState: resumedSnapshot.gameState,
|
|
||||||
})
|
})
|
||||||
: resumedSnapshot.currentStory;
|
: resumedSnapshot.currentStory;
|
||||||
|
|
||||||
@@ -111,13 +113,14 @@ export async function resolveServerRuntimeChoice(params: {
|
|||||||
return {
|
return {
|
||||||
response,
|
response,
|
||||||
hydratedSnapshot,
|
hydratedSnapshot,
|
||||||
nextStory: buildStoryMomentFromRuntimeOptions({
|
nextStory: resolveRuntimeStoryMoment({
|
||||||
storyText:
|
response,
|
||||||
|
hydratedSnapshot,
|
||||||
|
fallbackGameState: params.gameState,
|
||||||
|
fallbackStoryText:
|
||||||
response.presentation.storyText ||
|
response.presentation.storyText ||
|
||||||
hydratedSnapshot.currentStory?.text ||
|
hydratedSnapshot.currentStory?.text ||
|
||||||
params.option.actionText,
|
params.option.actionText,
|
||||||
options: getRuntimeResponseOptions(response),
|
|
||||||
gameState: hydratedSnapshot.gameState,
|
|
||||||
}),
|
}),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ vi.mock('./apiClient', async () => {
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
import { AnimationState } from '../types';
|
||||||
import {
|
import {
|
||||||
buildStoryMomentFromRuntimeOptions,
|
buildStoryMomentFromRuntimeOptions,
|
||||||
getRuntimeClientVersion,
|
getRuntimeClientVersion,
|
||||||
@@ -19,9 +20,9 @@ import {
|
|||||||
isServerRuntimeFunctionId,
|
isServerRuntimeFunctionId,
|
||||||
isTask5RuntimeFunctionId,
|
isTask5RuntimeFunctionId,
|
||||||
resolveRuntimeStoryAction,
|
resolveRuntimeStoryAction,
|
||||||
|
resolveRuntimeStoryMoment,
|
||||||
shouldUseServerRuntimeOptions,
|
shouldUseServerRuntimeOptions,
|
||||||
} from './runtimeStoryService';
|
} from './runtimeStoryService';
|
||||||
import { AnimationState } from '../types';
|
|
||||||
|
|
||||||
describe('runtimeStoryService', () => {
|
describe('runtimeStoryService', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
@@ -246,4 +247,99 @@ describe('runtimeStoryService', () => {
|
|||||||
action: 'trade',
|
action: 'trade',
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('prefers the richer snapshot story when the server persisted dialogue mode', () => {
|
||||||
|
const story = resolveRuntimeStoryMoment({
|
||||||
|
response: {
|
||||||
|
sessionId: 'runtime-main',
|
||||||
|
serverVersion: 4,
|
||||||
|
viewModel: {
|
||||||
|
player: { hp: 10, maxHp: 10, mana: 5, maxMana: 5 },
|
||||||
|
encounter: null,
|
||||||
|
companions: [],
|
||||||
|
availableOptions: [],
|
||||||
|
status: {
|
||||||
|
inBattle: false,
|
||||||
|
npcInteractionActive: true,
|
||||||
|
currentNpcBattleMode: null,
|
||||||
|
currentNpcBattleOutcome: null,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
presentation: {
|
||||||
|
actionText: '继续交谈',
|
||||||
|
resultText: '后端已结算',
|
||||||
|
storyText: '普通文本',
|
||||||
|
options: [],
|
||||||
|
battle: null,
|
||||||
|
toast: null,
|
||||||
|
},
|
||||||
|
patches: [],
|
||||||
|
snapshot: {
|
||||||
|
version: 2,
|
||||||
|
savedAt: '2026-04-08T00:00:00.000Z',
|
||||||
|
bottomTab: 'adventure',
|
||||||
|
gameState: {} as never,
|
||||||
|
currentStory: {
|
||||||
|
text: '你:先把话说开。\n梁伯:那我就直说了。',
|
||||||
|
options: [],
|
||||||
|
displayMode: 'dialogue',
|
||||||
|
dialogue: [
|
||||||
|
{ speaker: 'player', text: '先把话说开。' },
|
||||||
|
{ speaker: 'npc', speakerName: '梁伯', text: '那我就直说了。' },
|
||||||
|
],
|
||||||
|
deferredOptions: [
|
||||||
|
{
|
||||||
|
functionId: 'npc_chat',
|
||||||
|
actionText: '继续交谈',
|
||||||
|
text: '继续交谈',
|
||||||
|
visuals: {
|
||||||
|
playerAnimation: AnimationState.IDLE,
|
||||||
|
playerMoveMeters: 0,
|
||||||
|
playerOffsetY: 0,
|
||||||
|
playerFacing: 'right',
|
||||||
|
scrollWorld: false,
|
||||||
|
monsterChanges: [],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
} as never,
|
||||||
|
},
|
||||||
|
hydratedSnapshot: {
|
||||||
|
version: 2,
|
||||||
|
savedAt: '2026-04-08T00:00:00.000Z',
|
||||||
|
bottomTab: 'adventure',
|
||||||
|
gameState: {} as never,
|
||||||
|
currentStory: {
|
||||||
|
text: '你:先把话说开。\n梁伯:那我就直说了。',
|
||||||
|
options: [],
|
||||||
|
displayMode: 'dialogue',
|
||||||
|
dialogue: [
|
||||||
|
{ speaker: 'player', text: '先把话说开。' },
|
||||||
|
{ speaker: 'npc', speakerName: '梁伯', text: '那我就直说了。' },
|
||||||
|
],
|
||||||
|
deferredOptions: [
|
||||||
|
{
|
||||||
|
functionId: 'npc_chat',
|
||||||
|
actionText: '继续交谈',
|
||||||
|
text: '继续交谈',
|
||||||
|
visuals: {
|
||||||
|
playerAnimation: AnimationState.IDLE,
|
||||||
|
playerMoveMeters: 0,
|
||||||
|
playerOffsetY: 0,
|
||||||
|
playerFacing: 'right',
|
||||||
|
scrollWorld: false,
|
||||||
|
monsterChanges: [],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
} as never,
|
||||||
|
fallbackStoryText: '普通文本',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(story.displayMode).toBe('dialogue');
|
||||||
|
expect(story.deferredOptions).toHaveLength(1);
|
||||||
|
expect(story.text).toContain('梁伯');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -168,6 +168,45 @@ export function buildStoryMomentFromRuntimeOptions(params: {
|
|||||||
} satisfies StoryMoment;
|
} satisfies StoryMoment;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function shouldPreferSnapshotStory(story: StoryMoment | null) {
|
||||||
|
return Boolean(
|
||||||
|
story &&
|
||||||
|
(
|
||||||
|
story.displayMode === 'dialogue' ||
|
||||||
|
story.deferredOptions?.length ||
|
||||||
|
story.dialogue?.length
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveRuntimeStoryMoment(params: {
|
||||||
|
response: RuntimeStoryResponse;
|
||||||
|
hydratedSnapshot: HydratedSavedGameSnapshot;
|
||||||
|
fallbackGameState?: Pick<GameState, 'currentEncounter'>;
|
||||||
|
fallbackStoryText?: string;
|
||||||
|
}) {
|
||||||
|
if (shouldPreferSnapshotStory(params.hydratedSnapshot.currentStory)) {
|
||||||
|
return params.hydratedSnapshot.currentStory!;
|
||||||
|
}
|
||||||
|
|
||||||
|
const options =
|
||||||
|
params.response.viewModel.availableOptions.length > 0
|
||||||
|
? params.response.viewModel.availableOptions
|
||||||
|
: params.response.presentation.options;
|
||||||
|
|
||||||
|
return buildStoryMomentFromRuntimeOptions({
|
||||||
|
storyText:
|
||||||
|
params.response.presentation.storyText ||
|
||||||
|
params.hydratedSnapshot.currentStory?.text ||
|
||||||
|
params.fallbackStoryText ||
|
||||||
|
'',
|
||||||
|
options,
|
||||||
|
gameState: params.hydratedSnapshot.gameState.currentEncounter
|
||||||
|
? params.hydratedSnapshot.gameState
|
||||||
|
: params.fallbackGameState,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export async function getRuntimeStoryState(
|
export async function getRuntimeStoryState(
|
||||||
sessionId: string,
|
sessionId: string,
|
||||||
options: RuntimeStoryServiceOptions = {},
|
options: RuntimeStoryServiceOptions = {},
|
||||||
|
|||||||
Reference in New Issue
Block a user