1
Some checks failed
CI / verify (push) Has been cancelled

This commit is contained in:
2026-04-20 21:06:48 +08:00
parent 1c72066bab
commit 75944b1f1f
102 changed files with 9648 additions and 1540 deletions

View File

@@ -0,0 +1,391 @@
# Agent 聊天到草稿生成到进入游戏世界链路审计
更新时间:`2026-04-20`
## 0. 审计目标
本次审计只看一条链:
`Agent 聊天 -> 世界草稿生成 -> 结果页/作品库 -> 进入游戏世界`
聚焦回答四类问题:
1. 哪些数据在链路中断掉了
2. 哪些地方在代码里同时存在多条 pipeline
3. 哪些字段、功能、组件已经变成冗余或主链弱消费
4. 哪些能力在 contract、PRD 或代码结构里已经定义,但并没有真正实装到当前游戏主流程
---
## 1. 结论先行
当前系统还没有形成“Agent 会话是唯一真相源、发布后再进入世界”的单一主链,而是处在多条 pipeline 并存、多个桥接层临时粘合的状态。
最关键的结论有 8 条:
1. 当前至少并存 `5` 条相关 pipeline其中真正影响可玩流程的主链至少有 `3` 条。
2. 最大的数据断点是:`CustomWorldAgentSessionSnapshot.draftProfile` 不直接进入 runtime前端 `buildCustomWorldProfileFromAgentDraft()` 会先把它本地编译成 legacy `CustomWorldProfile`,后面的结果页、自动保存、进入世界都只认这个 legacy profile。
3. 服务端内部也存在一次“先编成 legacy runtime profile再转回 foundation draft”的双重编译`draftProfile.legacyResultProfile` 是这个桥接层留下来的强耦合字段。
4. `packages/shared/src/contracts/customWorldAgent.ts``server-node/src/routes/customWorldAgent.ts``server-node/src/services/customWorldAgentOrchestrator.ts` 三层定义不一致,`publish_world / generate_scene_assets / sync_scene_assets / expand_long_tail / lock_cards / unlock_cards / regenerate_scope` 等关键动作没有形成真实可用链路。
5. `CustomWorldResultView.tsx` 仍保留“直接对 legacy profile 生成角色/地点、直接编辑 profile”的旧流程会绕过 Agent session是当前最明显的并行 pipeline 和冗余功能源。
6. “进入世界”和“发布世界”目前是两套平行逻辑。Agent 草稿结果页可以自动保存并直接进入世界,但 `publish_world` action 仍不可用,`qualityFindings / blocker` 校验也没有真正接入。
7. `listCustomWorldWorks()``CustomWorldWorkSummaryService` 已能聚合 Agent 草稿和已发布 profile但平台 `create` tab 仍主要展示 `myEntries`Agent draft session 不能自然回到主入口,恢复创作主要依赖 `activeSessionId`
8. Agent 工作区主 UI 只接了头部、进度、线程、输入框、操作横幅等极简子集PRD 里规划的锁定条、草稿抽屉、详情面板、澄清面板、快捷动作、发布校验结果等大部分还没有真正进入当前游戏主流程。
---
## 2. 目标链路
`docs/prd/AI_NATIVE_AGENT_FIRST_CUSTOM_WORLD_CREATOR_PRD_2026-04-12.md``docs/prd/AI_NATIVE_AGENT_FIRST_EIGHT_ANCHOR_CO_CREATION_FLOW_PRD_2026-04-16.md`,目标链路应当是:
```text
Agent 对话
-> Express 后端维护结构化 eight-anchor / creatorIntent / lockState / draftSnapshot
-> foundation draft
-> 角色资产工坊 / 场景资产工坊
-> sync 回 Agent session draft
-> expand long tail
-> publish_world
-> 服务端执行 quality / blocker 校验
-> 服务端编译最终 CustomWorldProfile
-> 持久化到世界库
-> 进入世界
```
这条目标链路有 4 个硬约束:
1. Express 后端才是真实状态源,前端只负责展示和输入,不负责结构化草稿编译。
2. 未发布的 Agent 草稿不应该直接污染正式世界库,主入口里应该通过“继续创作”恢复。
3. 进入世界前应先经过 `publish_world`,并由发布校验阻止缺角色资产、缺场景资产、缺主线第一幕等 blocker。
4. 结果页不再是旧自定义世界编辑器的平移副本,而应更接近“最终预览 / 发布确认 / 进入世界”的收口层。
---
## 3. 当前真实链路
## 3.1 Agent 会话草稿链
当前新链路实际是:
```text
PreGameSelectionFlow.tsx
-> /api/runtime/custom-world/agent/sessions
-> CustomWorldAgentSessionStore
-> CustomWorldAgentOrchestrator
-> CustomWorldAgentFoundationDraftService
-> CustomWorldAgentAutoAssetService
-> session.draftProfile / draftCards / assetCoverage
-> 前端 buildCustomWorldProfileFromAgentDraft()
-> generatedCustomWorldProfile
-> upsertCustomWorldProfile()
-> handleCustomWorldSelect(profile)
-> runtime
```
关键特点:
1. Agent session 不是 runtime 直接消费的对象。
2. Agent 草稿完成后,会在前端先转成 `CustomWorldProfile`
3. 结果页阶段会自动调用 `upsertCustomWorldProfile()`,把当前 profile 写进 `custom-world-library`
4. “进入世界”按钮直接把这个 profile 送给 `handleCustomWorldSelect(...)`,不需要 `publish_world`
主要证据:
- `src/components/game-shell/PreGameSelectionFlow.tsx`
- `src/services/customWorldAgentDraftResult.ts`
- `src/hooks/useGameFlow.ts`
## 3.2 旧自定义世界 session 链
旧链路仍然完整存在:
```text
aiService.generateCustomWorldProfile()
-> /api/runtime/custom-world/sessions
-> answerCustomWorldSessionQuestion()
-> /generate/stream
-> generateCustomWorldProfile()
-> CustomWorldProfile
-> 结果页 / 作品库 / 进入世界
```
关键特点:
1. `src/services/aiService.ts` 里的 `generateCustomWorldProfile()` 仍然会创建旧 `custom-world/sessions`
2. 前端会先根据 `world_hook / player_premise / opening_situation / core_conflict` 自动补默认回答,再触发流式生成。
3. 这条链已经与 Agent 八锚点链并行存在,且依然可用。
主要证据:
- `src/services/aiService.ts`
- `server-node/src/routes/runtimeRoutes.ts`
- `server-node/src/services/customWorldSessionStore.ts`
## 3.3 已保存 profile / 作品库链
当前作品库链是:
```text
custom-world-library
-> upsert / delete / publish / unpublish
-> PlatformHomeView / saved profile detail
-> CustomWorldResultView
-> handleCustomWorldSelect(profile)
```
关键特点:
1. 这条链直接消费 `CustomWorldProfile`,不依赖 Agent session。
2. Agent 结果页自动保存后,也会落入这条链。
3. `publish/unpublish` 作用在作品库 profile 上,而不是 Agent session 上。
主要证据:
- `server-node/src/routes/runtimeRoutes.ts`
- `src/components/game-shell/PlatformHomeView.tsx`
- `src/components/game-shell/PreGameSelectionFlow.tsx`
## 3.4 结果页 legacy profile 直改链
`CustomWorldResultView.tsx` 仍保留旧能力:
1. `generateCustomWorldPlayableNpc({ profile })`
2. `generateCustomWorldStoryNpc({ profile })`
3. `generateCustomWorldLandmark({ profile })`
4. `CustomWorldEntityEditorModal`
这意味着结果页不仅是预览层还是一套独立的“legacy profile 直改工作台”。这一套能力不会回写 Agent session 的结构化状态,也不会走 Agent action route。
主要证据:
- `src/components/CustomWorldResultView.tsx`
- `src/services/aiService.ts`
## 3.5 创作中心 works 聚合链
后端已经能聚合两类作品:
1. `sourceType: 'agent_session'`
2. `sourceType: 'published_profile'`
但主平台 `create` tab 现在仍主要展示 `myEntries`,没有把 `CustomWorldCreationHub.tsx` 作为主入口接上。
这导致:
1. works 聚合链存在
2. create tab 真实消费的是另一条链
3. Agent draft session 的继续创作入口没有真正收口到主平台
主要证据:
- `server-node/src/services/customWorldWorkSummaryService.ts`
- `src/components/custom-world-home/CustomWorldCreationHub.tsx`
- `src/components/game-shell/PlatformHomeView.tsx`
---
## 4. 数据断点
| 断点 | 当前现状 | 影响 | 主要证据 |
| --- | --- | --- | --- |
| Agent session -> runtime | `buildCustomWorldProfileFromAgentDraft()` 在前端把 `session.draftProfile` 编译成 legacy `CustomWorldProfile`,后续结果页、自动保存、进入世界都只认 profile | 后端不再是最终唯一真相源,前端承担了结构化编译与字段裁决,容易产生字段丢失、语义漂移、状态失真 | `src/components/game-shell/PreGameSelectionFlow.tsx``src/services/customWorldAgentDraftResult.ts` |
| foundation draft 内部双重编译 | `CustomWorldAgentFoundationDraftService` 会先 `buildCompiledCustomWorldProfile(...)`,再 `convertRuntimeProfileToFoundationDraft(...)`,并把结果塞进 `legacyResultProfile` | Agent draft 不是原生生成,而是绕了一次 legacy profile再回 draft后续桥接层依赖这个字段继续工作 | `server-node/src/services/customWorldAgentFoundationDraftService.ts` |
| 创作态元数据进入最终 profile | 前端桥接时会把 `anchorContent / creatorIntent / anchorPack / lockState` 一并塞进 legacy profile同时固定写入 `generationMode: 'fast'``generationStatus: 'key_only'` | 创作态数据污染运行时 profile 存储;`generationMode / generationStatus` 还会覆盖真实阶段语义 | `src/services/customWorldAgentDraftResult.ts` |
| Agent session 元数据在结果页后被截断 | `draftCards / pendingClarifications / suggestedActions / qualityFindings / checkpoints / operations` 大多停留在 session 层;结果页与 runtime 只继续消费 profile | 进入结果页后Agent 会话层的大量结构化上下文被切断,发布门槛、锁定、局部重生成等信息无法自然继承 | `packages/shared/src/contracts/customWorldAgent.ts``server-node/src/services/customWorldAgentSessionStore.ts``src/components/custom-world-agent/CustomWorldAgentWorkspace.tsx` |
| works 聚合 -> 平台 create tab | 后端 `listCustomWorldWorkSummaries(...)` 能返回 draft 与 published但 create tab 仍只渲染 `myEntries` | Agent draft session 无法稳定出现在主入口“我的创作”里,恢复创作入口割裂 | `server-node/src/services/customWorldWorkSummaryService.ts``src/components/game-shell/PlatformHomeView.tsx` |
| 发布状态 -> 可玩状态 | 结果页会自动 `upsertCustomWorldProfile()` 并允许直接 `onEnterWorld`;但 `publish_world` action 仍不可用 | “可玩”与“已发布”没有统一门槛,发布校验无法阻止未完成草稿进入世界 | `src/components/game-shell/PreGameSelectionFlow.tsx``server-node/src/services/customWorldAgentOrchestrator.ts` |
---
## 5. 多条 Pipeline
## 5.1 主链级 pipeline
| pipeline | 真相源 | 当前是否在主流程可达 | 问题 |
| --- | --- | --- | --- |
| Agent 会话草稿链 | `CustomWorldAgentSessionStore` + `draftProfile` | 是 | 后半段通过前端桥接成 legacy profile未形成端到端单一真相源 |
| 旧 custom-world session 链 | `CustomWorldSessionStore` | 是 | 与 Agent 八锚点链重复,且前端仍在补默认回答 |
| 已保存 / 已发布 profile 链 | `custom-world-library` 中的 `CustomWorldProfile` | 是 | 与 Agent draft session 发布链平行存在 |
| 结果页 legacy profile 直改链 | 结果页本地 `profile` | 是 | 绕过 Agent session属于并行编辑器 |
| works 创作中心链 | `listCustomWorldWorks()` 聚合数据 | 否,主平台未接主入口 | 后端已有聚合,但 UI 没真正切过去 |
## 5.2 资产子链 pipeline
资产相关还存在“自动补齐”和“人工工坊写回”并存:
1. `draft_foundation` 后,`CustomWorldAgentAutoAssetService` 会自动补角色主图和幕背景图。
2. 角色资产又存在 `generate_role_assets -> sync_role_assets` 的手动工坊写回链。
3. 场景资产在 contract 层定义了 `generate_scene_assets / sync_scene_assets`,但主 action 链未打通。
这导致当前资产链不是一条统一 pipeline而是
```text
自动补角色 / 自动补幕背景
并存
手动角色工坊 -> sync_role_assets
缺失
手动场景工坊 -> sync_scene_assets
```
主要证据:
- `server-node/src/services/customWorldAgentAutoAssetService.ts`
- `server-node/src/services/customWorldAgentRoleAssetStateService.ts`
- `packages/shared/src/contracts/customWorldAgent.ts`
- `server-node/src/services/customWorldAgentOrchestrator.ts`
---
## 6. 冗余字段与主链悬空字段
这里区分两类:
1. 已经明显承担桥接残留职责的冗余字段
2. 在 contract / session 里存在,但当前主流程几乎不消费的悬空字段
| 字段 | 类型 | 当前状态 | 判断 |
| --- | --- | --- | --- |
| `draftProfile.legacyResultProfile` | 桥接残留字段 | foundation draft 服务端先生成 legacy runtime profile再把它塞回 draft前端桥接又优先读它 | 明显冗余,属于临时兼容字段,不应长期成为主链依赖 |
| `generationMode: 'fast'` | 固定写死字段 | `buildCustomWorldProfileFromAgentDraft()` 固定写入 | 不是草稿真实状态,更像桥接层补丁 |
| `generationStatus: 'key_only'` | 固定写死字段 | `buildCustomWorldProfileFromAgentDraft()` 固定写入 | 同上,会掩盖真实生成阶段 |
| `anchorContent / creatorIntent / anchorPack / lockState` 被直接塞进 legacy profile | 创作态元数据 | 会跟随自动保存一起写进作品库 profile但 runtime 并不以这些字段为正式运行时输入 | 当前更像创作态元数据泄漏进运行时 profile |
| `qualityFindings` | session / contract 字段 | contract、session store、测试里存在但没形成生成、渲染、发布阻断闭环 | 当前主链悬空 |
| `checkpoints` | session 字段 | session store 会记录,但主工作区和结果页没有真实展示入口 | 当前主链悬空 |
| `suggestedActions` | session 字段 | session 会生成,但主工作区没有接 `QuickActions` 等面板 | 当前主链悬空 |
| `pendingClarifications` | session 字段 | session 有数据,但澄清面板未接入主工作区 | 当前主链悬空 |
| `operations` 历史 | session 字段 | 主工作区只展示当前 `activeOperation` 横幅,不展示完整历史 | 当前主链弱消费 |
| `roleAssetSummaryLabel / cover* / counts` 等 works 字段 | works 聚合字段 | 后端能返回,但主平台 create tab 没走 `works` 入口 | 当前主链弱消费 |
---
## 7. 冗余功能与冗余组件
## 7.1 冗余功能
| 功能 | 当前状态 | 问题 |
| --- | --- | --- |
| 结果页直接生成 playable/story/landmark | `CustomWorldResultView.tsx` 仍可直接调用 AI 生成 | 与 Agent 对象精修链重复,且不会同步回 session |
| 结果页直接编辑 `CustomWorldProfile` | `CustomWorldEntityEditorModal` 仍挂在结果页 | 把结果页继续维持成旧编辑器,而不是 Agent 流程的收口层 |
| 旧 `custom-world/sessions` 世界生成 | `aiService.generateCustomWorldProfile()` 仍完整可用 | 与 Agent 八锚点世界创建重复 |
| 作品库 `publish/unpublish` 与 Agent `publish_world` | 两套“发布”概念并行 | 一套作用于 library profile一套想作用于 Agent session但后者还未打通 |
| 结果页自动保存 | `generatedCustomWorldProfile` 变化时自动 `upsertCustomWorldProfile()` | 让“草稿保存”“作品库存档”“正式发布”语义混在一起 |
## 7.2 冗余或未接线组件
`src/components/custom-world-agent/CustomWorldAgentWorkspace.tsx` 当前只真正接了:
1. `CustomWorldAgentHeader`
2. `EightAnchorProgressBar`
3. `CustomWorldAgentOperationBanner`
4. `CustomWorldAgentThread`
5. `CustomWorldAgentComposer`
但同目录下已经存在且主工作区未接线的组件包括:
1. `CustomWorldAgentLockBar.tsx`
2. `CustomWorldAgentDraftDrawer.tsx`
3. `CustomWorldAgentDraftDetailPanel.tsx`
4. `CustomWorldAgentQuickActions.tsx`
5. `CustomWorldAgentSummaryPanel.tsx`
6. `CustomWorldAgentIntentSummaryPanel.tsx`
7. `CustomWorldAgentClarificationPanel.tsx`
8. `CustomWorldGenerateEntityModal.tsx`
另外,`src/components/custom-world-home/CustomWorldCreationHub.tsx` 也已存在,但平台 `create` tab 还没有把它接成主入口。
---
## 8. 当前没有真正实装到游戏主流程中的项
| 能力 | 设计 / 定义位置 | 当前状态 |
| --- | --- | --- |
| `publish_world` 真正发布链 | contract、PRD、route、orchestrator | route 能接orchestrator 直接 `throw badRequest('publish_world is not available in phase5')` |
| `generate_scene_assets` | contract、PRD | contract 定义了,但 action route 未接,主链无执行实现 |
| `sync_scene_assets` | contract、PRD | contract 定义了,但 action route 未接,主链无执行实现 |
| `expand_long_tail` | contract、PRD | contract 定义了,但主 action 链未接 |
| `lock_cards / unlock_cards` | contract、PRD | contract 定义了,但 route / UI / orchestrator 主链未接 |
| `regenerate_scope` | contract、PRD | contract 定义了,但 route / UI / orchestrator 主链未接 |
| `qualityFindings` 与 blocker 发布门禁 | contract、PRD、技术进度文档 | 字段存在,但没有真实的生成、展示、阻止发布闭环 |
| 场景资产工坊从 Agent workspace 打开并写回 | PRD | 主工作区未接详情面板与场景资产 action |
| 通过 works 统一恢复 Agent draft / 已发布作品 | works service + creation hub | 后端已有聚合,主平台入口未收口 |
| 发布前只允许预览、发布后再进入世界 | PRD | 当前 Agent 草稿结果页可自动保存并直接进入世界 |
补充说明:
`docs/technical/SCENE_MULTI_ACT_CREATOR_IMPLEMENTATION_PROGRESS_2026-04-20.md` 已明确写到,发布期 `qualityFindings / blocker` 正式接入仍未完成,这与当前代码状态一致。
---
## 9. 优先级建议
## P0先收一条真正的单一主链
建议明确把下面这条定为唯一正式主链:
```text
Agent session
-> 服务端 draft snapshot
-> 服务端质量检查 / 发布动作
-> 服务端编译 final CustomWorldProfile
-> 世界库
-> runtime
```
对应动作:
1. 结果页不再承担“主编辑器”职责,至少对 Agent draft 结果页关闭 legacy profile 直改能力。
2. 用服务端 preview / compile 接口替代前端 `buildCustomWorldProfileFromAgentDraft()` 的最终裁决职责。
3. `publish_world` 打通后,再决定是否允许“发布后立即进入世界”。
## P0把“进入世界”和“发布世界”重新绑回同一门槛
建议收口为:
1. 未发布 Agent 草稿只能继续创作或查看预览。
2. 只有 `publish_world` 成功后,才产出正式 `CustomWorldProfile` 并允许主入口进入世界。
3. `qualityFindings / blocker` 必须在 foundation draft 完成、资产写回后、publish 前持续重跑。
## P1决定旧 world session 流程的命运
当前最容易继续制造重复复杂度的是旧 `custom-world/sessions` 链。
建议二选一:
1. 明确保留为“快速世界生成兼容模式”,但从主入口降级。
2. 明确进入淘汰路径,逐步下线 `generateCustomWorldProfile()` 这条旧链。
不建议继续让它和 Agent 八锚点链同时作为主入口长期并存。
## P1把 works 创作中心接回主平台
建议:
1. 平台 `create` tab 改成消费 `listCustomWorldWorks()`
2. 草稿 session 通过“继续创作”恢复。
3. 已发布 profile 通过“进入世界”或“查看详情”进入。
4. `myEntries` 退回为作品库子集,而不是 create tab 的唯一数据源。
## P1补齐 Agent workspace 的最小闭环
建议优先接上:
1. `CustomWorldAgentQuickActions`
2. `CustomWorldAgentDraftDrawer`
3. `CustomWorldAgentDraftDetailPanel`
4. `CustomWorldAgentClarificationPanel`
如果这几个面板不接上,`suggestedActions / pendingClarifications / draftCards` 这些 session 字段会长期处于悬空状态。
## P2等主链收口后再清桥接字段
下面这些字段不建议现在立刻删,但应在主链收口后尽快移除:
1. `draftProfile.legacyResultProfile`
2. 前端桥接里固定写死的 `generationMode / generationStatus`
3. 仅为兼容旧编辑器而塞进 legacy profile 的创作态元数据
---
## 10. 一句话总评
当前“Agent 聊天 -> 草稿生成 -> 进入世界”已经能跑通一条可玩链,但它还不是 PRD 要求的“后端单一真相源 + 发布门禁收口”的正式链路,而是 `Agent session``legacy profile``旧 session``作品库` 四层并存、靠前端桥接和结果页兼容能力临时拼起来的过渡态。

View File

@@ -17,7 +17,7 @@
结论不是“只有一套 prompt”而是 结论不是“只有一套 prompt”而是
**当前角色资产链路至少有两层 prompt且这两层在仓库里被不同文件承担** **当前角色资产链路仍然有两层 prompt但“默认描述文本”已经统一成单一主源**
### 1.1 默认描述文本层 ### 1.1 默认描述文本层
@@ -95,10 +95,6 @@
## 2.2 生成默认角色形象描述文本的提示词在哪 ## 2.2 生成默认角色形象描述文本的提示词在哪
当前仓库需要分两种情况:
### 情况 A当前自定义世界资产工坊真实主链
当前资产工坊默认输入框实际使用: 当前资产工坊默认输入框实际使用:
- `src/prompts/customWorldRolePromptDefaults.ts` - `src/prompts/customWorldRolePromptDefaults.ts`
@@ -110,34 +106,6 @@
- `role.visualDescription` - `role.visualDescription`
- 或回退到 `role.description` - 或回退到 `role.description`
### 情况 B仓库里保留的“默认 bundle 编译接口”
仓库里仍保留一条后端接口:
- `/api/assets/character-prompts/generate`
对应文件:
- `server-node/src/prompts/characterAssetPrompts.ts`
这条链使用:
- `CHARACTER_PROMPT_BUNDLE_SYSTEM_PROMPT`
- `buildCharacterPromptBundleUserPrompt`
它的职责是:
**让 LLM 从角色卡摘要里编译出一组默认文本 bundle。**
但当前实际问题是:
**自定义世界角色资产工坊初始化默认值,并没有走这条接口。**
因此当前状态更准确地说是:
- 仓库里有一条“LLM 编译默认文本 bundle”的保留链
- 但当前资产工坊真实初始默认值主链,走的是前端本地映射
--- ---
## 3. 角色动作生成链路 ## 3. 角色动作生成链路
@@ -181,17 +149,6 @@
这仍然是**默认描述文本层**,不是最终动作模型 prompt。 这仍然是**默认描述文本层**,不是最终动作模型 prompt。
仓库里也保留了 LLM 编译 bundle 的接口链:
- `CHARACTER_PROMPT_BUNDLE_SYSTEM_PROMPT`
- `buildCharacterPromptBundleUserPrompt`
这条链也会生成:
- `animationPromptText`
但当前资产工坊真实初始默认值并没有实际调用它。
--- ---
## 4. `characterAssetPrompts.ts` 里的 `visualPromptText` / `animationPromptText` 到底是什么 ## 4. `characterAssetPrompts.ts` 里的 `visualPromptText` / `animationPromptText` 到底是什么
@@ -264,32 +221,30 @@
## 6. 冗余流程与当前问题 ## 6. 冗余流程与当前问题
## 6.1 明确存在的冗余点:默认 bundle 双链并存 ## 6.1 默认描述文本双链已收口
当前仓库里“默认描述文本”其实有两套来源 此前默认描述文本同时存在
### 第一套:前端本地字段映射 1. 前端本地字段映射
2. 后端 bundle 编译接口
本轮已经统一为:
- `src/prompts/customWorldRolePromptDefaults.ts` - `src/prompts/customWorldRolePromptDefaults.ts`
### 第二套:后端 LLM bundle 编译接口 也就是:
- `server-node/src/prompts/characterAssetPrompts.ts` **默认描述文本现在只有一条真实主源。**
- `/api/assets/character-prompts/generate`
问题不在于“两套都存在”,而在于 对应变化
**当前自定义世界资产工坊真实默认值只走第一套,第二套保留但没有进入当前主 UI 链。** 1. 不再保留后端独立的默认 bundle 编译接口。
2. 不再保留前端对应的 bundle 生成 API 壳层。
这意味着: 3. `server-node/src/prompts/characterAssetPrompts.ts` 只保留正式模型 prompt builder。
1. 从业务视角看,默认描述文本存在双份真相。
2. 从维护视角看,两个地方都在描述 `visualPromptText / animationPromptText / scenePromptText` 的生成语义。
3. 从测试视角看,后端 bundle 接口仍有测试,但 UI 主链没有使用它。
判断: 判断:
**这是当前最明显的冗余流程** **默认描述文本层的双份真相已经被消除**
## 6.2 `scenePromptText` 结构存在,但当前资产工坊没有完整承接 ## 6.2 `scenePromptText` 结构存在,但当前资产工坊没有完整承接
@@ -338,13 +293,13 @@
因此它们不能算“无效代码”。 因此它们不能算“无效代码”。
真正更接近“保留接口但未进入当前 UI 主链”的,是 真正已经被清理掉的保留链路,是此前未接入主 UI 的默认 bundle 接口
- `CHARACTER_PROMPT_BUNDLE_SYSTEM_PROMPT` - `CHARACTER_PROMPT_BUNDLE_SYSTEM_PROMPT`
- `buildCharacterPromptBundleUserPrompt` - `buildCharacterPromptBundleUserPrompt`
- `/api/assets/character-prompts/generate` - `/api/assets/character-prompts/generate`
这套链路仍有测试、仍可工作,但当前不属于自定义世界资产工坊的真实默认值主链 这套链路已经不再保留在当前仓库主线中
--- ---
@@ -352,11 +307,9 @@
如果后续要继续收口,建议按顺序处理: 如果后续要继续收口,建议按顺序处理:
1. 先明确“资产工坊默认值唯一主源”到底选前端本地映射还是后端 LLM bundle 接口 1. 继续以前端本地映射作为默认描述文本唯一主源
2. 如果继续保留前端本地映射为主链,则把后端 bundle 接口标注为备用 / 实验 / 非主链能力 2. `scenePromptText` 做完整承接,不要继续停留在结构存在但 UI 不消费的状态
3. 如果准备切回后端 bundle 接口为主链,则要把当前 UI 初始化逻辑真正接上,并补场景描述输入框闭环 3. 继续保留 `packages/shared/src/prompts/qwenSprite.ts` 与工具链 prompt 分层,但在文档里强制写清“正式主链 / 工具链”边界
4.`scenePromptText` 做完整承接,不要继续停留在结构存在但 UI 不消费的状态。
5. 继续保留 `packages/shared/src/prompts/qwenSprite.ts` 与工具链 prompt 分层,但在文档里强制写清“正式主链 / 工具链”边界。
--- ---
@@ -376,4 +329,4 @@
一句话总结就是: 一句话总结就是:
**当前角色资产系统把“默认描述文本”和“正式模型 prompt”拆成了两层这是合理的真正的问题不是有两层,而是“默认描述文本层”现在同时保留了前端本地映射和后端 LLM 编译两条链,而当前 UI 主链只用了前者,导致出现明显的冗余和认知混乱** **当前角色资产系统把“默认描述文本”和“正式模型 prompt”拆成了两层这是合理的默认描述文本层已经统一为前端本地映射单一主源,当前剩余主要问题不再是双主源,而是 `scenePromptText` 仍未形成完整 UI 闭环**

View File

@@ -15,6 +15,7 @@
- [FUNCTION_RUNTIME_FULL_TEST_AUDIT_2026-04-16.md](./FUNCTION_RUNTIME_FULL_TEST_AUDIT_2026-04-16.md)Function 运行时完整测试、服务端承接验证与当前门禁缺口。 - [FUNCTION_RUNTIME_FULL_TEST_AUDIT_2026-04-16.md](./FUNCTION_RUNTIME_FULL_TEST_AUDIT_2026-04-16.md)Function 运行时完整测试、服务端承接验证与当前门禁缺口。
- [ITEM_AND_BUILD_PRD_AUDIT_2026-04-05.md](./ITEM_AND_BUILD_PRD_AUDIT_2026-04-05.md):物品生成与 Build 标签系统对 PRD 的落地情况。 - [ITEM_AND_BUILD_PRD_AUDIT_2026-04-05.md](./ITEM_AND_BUILD_PRD_AUDIT_2026-04-05.md):物品生成与 Build 标签系统对 PRD 的落地情况。
- [CUSTOM_WORLD_CREATOR_TOOL_AUDIT_2026-04-08.md](./CUSTOM_WORLD_CREATOR_TOOL_AUDIT_2026-04-08.md):自定义世界创作工具当前问题、体验断层和优化优先级审计。 - [CUSTOM_WORLD_CREATOR_TOOL_AUDIT_2026-04-08.md](./CUSTOM_WORLD_CREATOR_TOOL_AUDIT_2026-04-08.md):自定义世界创作工具当前问题、体验断层和优化优先级审计。
- [AGENT_TO_DRAFT_TO_WORLD_PIPELINE_AUDIT_2026-04-20.md](./AGENT_TO_DRAFT_TO_WORLD_PIPELINE_AUDIT_2026-04-20.md)Agent 聊天、草稿生成、作品库存储与进入世界之间的断点、多 pipeline、冗余与未实装项审计。
- [CHARACTER_ASSET_PROMPT_CHAIN_AUDIT_2026-04-20.md](./CHARACTER_ASSET_PROMPT_CHAIN_AUDIT_2026-04-20.md):角色资产默认描述文本、正式图像/动作 prompt、共享模板与保留接口的分层与冗余审计。 - [CHARACTER_ASSET_PROMPT_CHAIN_AUDIT_2026-04-20.md](./CHARACTER_ASSET_PROMPT_CHAIN_AUDIT_2026-04-20.md):角色资产默认描述文本、正式图像/动作 prompt、共享模板与保留接口的分层与冗余审计。
- [engineering/ENGINEERING_CLEANUP_AND_BACKEND_BOUNDARY_AUDIT_2026-04-20.md](./engineering/ENGINEERING_CLEANUP_AND_BACKEND_BOUNDARY_AUDIT_2026-04-20.md):对 `2026-04-19` 工程清理审计的当前仓库复核,区分已完成项、仍存边界问题和新的热点迁移。 - [engineering/ENGINEERING_CLEANUP_AND_BACKEND_BOUNDARY_AUDIT_2026-04-20.md](./engineering/ENGINEERING_CLEANUP_AND_BACKEND_BOUNDARY_AUDIT_2026-04-20.md):对 `2026-04-19` 工程清理审计的当前仓库复核,区分已完成项、仍存边界问题和新的热点迁移。
- [engineering/ENGINEERING_CLEANUP_AND_BACKEND_BOUNDARY_AUDIT_2026-04-19.md](./engineering/ENGINEERING_CLEANUP_AND_BACKEND_BOUNDARY_AUDIT_2026-04-19.md):未引用垃圾、旧入口残留、前后端双份真相与后端迁移项的专项审计。 - [engineering/ENGINEERING_CLEANUP_AND_BACKEND_BOUNDARY_AUDIT_2026-04-19.md](./engineering/ENGINEERING_CLEANUP_AND_BACKEND_BOUNDARY_AUDIT_2026-04-19.md):未引用垃圾、旧入口残留、前后端双份真相与后端迁移项的专项审计。

View File

@@ -0,0 +1,369 @@
# 当前工程优化点盘点2026-04-20
更新时间:`2026-04-20`
## 0. 盘点目标
这份文档用于回答一个更直接的问题:
**基于当前仓库状态,接下来最值得投入工程时间的优化点是什么。**
本轮只做文档盘点,不直接修改业务代码;结论同时参考了当前工作区现状。
需要注意,仓库当前存在一批未提交改动,尤其集中在 `custom world``assets``platform shell` 相关模块,所以本文更强调“优先级与切入方式”,而不是要求做大范围整仓改写。
---
## 1. 当前快照
## 1.1 本轮复核方式
本轮主要复核了以下内容:
1. 现有工程优化审计文档与目录索引
2. `package.json``vite.config.ts``.eslintrc.cjs` 等门禁脚本
3. 当前前端、后端、脚本目录的大文件热点
4. 运行时、鉴权、自定义世界、资产链路的边界实现
5. 当前 `typecheck / lint / build` 状态
---
## 1.2 当前门禁结果
| 项目 | 结果 | 当前判断 |
| --- | --- | --- |
| `npm run typecheck` | 失败 | 当前第一优先级问题,类型基线已失真 |
| `npm run lint:eslint` | 失败 | `136` 个 error、`4` 个 warning`95` 个可自动修复 |
| `npm run build` | 通过 | 发布链路未红,但体积压力仍明显存在 |
### 关键说明
当前状态和 `2026-04-10` 那轮“build warning 直接拦截”的状态不同:
1. **构建现在可以通过。**
2. **真正变成第一阻塞项的是 `typecheck` 与 `lint`。**
3. **构建虽然通过但主包、功能包、CSS 体积依然偏重,说明性能类优化仍然值得做。**
---
## 1.3 当前热点文件快照
本轮按源码目录统计的大文件热点如下:
| 文件 | 当前行数 | 判断 |
| --- | --- | --- |
| `src/components/CustomWorldEntityEditorModal.tsx` | `6122` | 当前前端最大热点 |
| `server-node/src/app.test.ts` | `3568` | 后端测试聚合度过高 |
| `server-node/src/modules/assets/characterAssetRoutes.ts` | `2802` | 资产路由职责过重 |
| `src/services/ai.ts` | `2432` | 浏览器侧 AI 编排仍然偏重 |
| `server-node/src/modules/story/storyActionRoutes.test.ts` | `2402` | 运行时路由测试聚合度过高 |
| `src/data/npcInteractions.ts` | `2274` | NPC 规则数据仍然集中 |
| `src/prompts/storyPromptBuilders.ts` | `1728` | prompt 构造成为新的复杂度中心 |
| `server-node/src/modules/custom-world/runtimeProfile.ts` | `1623` | custom world runtime 编译热点 |
| `src/hooks/story/npcEncounterActions.ts` | `1582` | NPC 行动流仍然偏重 |
| `src/components/game-shell/PlatformHomeView.tsx` | `1474` | 平台首页壳层继续膨胀 |
| `src/components/game-shell/PreGameSelectionFlow.tsx` | `1418` | 前置选择流程职责过多 |
| `src/services/customWorld.ts` | `1383` | 自定义世界服务虽然收缩,但仍偏大 |
---
## 2. 结论先行
当前仓库的优化重点,已经不是“继续清旧 Vite 插件链路”或者“继续讨论前后端是否要分离”。
更准确地说,当前最值得做的优化点已经收敛成四类:
1. **先恢复可信的工程基线。**
`typecheck``lint` 当前都是红线,继续扩功能会放大返工成本。
2. **拆掉正在持续膨胀的新热点。**
热点已经从早期运行时主链,迁移到 `custom world``asset routes``platform shell``prompt builders`
3. **继续把前端退出“运行时真相”和“鉴权真相”。**
当前前端仍保留本地快照镜像与自动登录凭证持久化。
4. **补一轮入口归档,减少疑似孤岛模块和大测试聚合文件。**
这部分不一定最急,但会持续拉低仓库可维护性。
一句话判断:
**当前最值得投入的不是横向加功能,而是把质量门禁重新拉绿,再把 custom world / asset / platform 这批新复杂度中心拆开。**
---
## 3. 优化点清单
## 3.1 P0先恢复类型基线
这是当前最优先的工程优化点。
### 证据
`npm run typecheck` 当前失败,主要问题集中在两类:
1. `CustomWorldCampScene` 结构漂移
- `src/components/CustomWorldEntityEditorModal.test.tsx`
- `src/data/customWorldLibrary.ts`
- `src/services/customWorld.ts`
- `src/services/customWorldCamp.ts`
2. 局部实现与类型定义不同步
- `src/components/auth/AccountModal.test.tsx` 的测试数据缺少新增字段
- `src/components/game-canvas/GameCanvasShared.tsx` 引用了未定义的 `DEFAULT_IMAGE_STYLE`
### 影响
1. 类型系统已经不能提供可信回归信号。
2. 自定义世界链路当前正在迭代,如果继续在红线状态叠加修改,后续会反复出现“改 A 崩 B”的情况。
3. 测试 fixture 与正式类型脱节,会让测试文件逐渐失去文档价值。
### 建议
1. 先补一个统一的 `CustomWorldCampScene` 构造/归一化入口,禁止在多个文件里手写不完整字面量。
2.`auth``custom world` 的测试 fixture 改成工厂函数,避免字段新增后多处漏改。
3. 单独清掉 `GameCanvasShared.tsx` 这类“编译即失败”的确定性问题,优先恢复 `typecheck` 绿色基线。
---
## 3.2 P0恢复 lint 可信度,区分机械问题和真实问题
这项和类型基线同级。
### 证据
`npm run lint:eslint` 当前结果是:
- `136` 个 error
- `4` 个 warning
- 其中 `95` 个问题可自动修复
当前 lint 问题明显分成两层:
1. 机械问题
- import 排序
- export 排序
- 未使用导入
2. 真实问题
- `server-node/src/modules/inventory/inventoryStoryActionService.ts` 出现 React Hook 规则错误
- `server-node/src/migrate.ts` 仍触发 `no-console`
- `packages/shared/src/http.ts` 触发 `@typescript-eslint/ban-types`
- 若干文件存在真正未使用变量、转义和规则误配问题
### 影响
1. 当前 lint 信号噪音仍然较高,不利于 review。
2. 真实问题会被大量机械问题掩盖。
3. 团队会更倾向于跳过 lint而不是信任 lint。
### 建议
1. 先跑一轮仅机械修复的清理批次,优先吃掉 import sort、unused imports 这类低风险项。
2. 再单独处理 Hook 误用、共享契约类型、脚本规则豁免这类语义问题。
3. 之后把“自动可修复问题”与“必须人工处理的问题”拆成两个门禁视角,减少下次再次堆积。
---
## 3.3 P1拆 custom world / asset / platform 新热点
这是当前最有性价比的结构性优化点。
### 证据
当前复杂度最高的业务热点,已经集中在这些模块:
1. `src/components/CustomWorldEntityEditorModal.tsx`
2. `server-node/src/modules/assets/characterAssetRoutes.ts`
3. `src/services/ai.ts`
4. `src/prompts/storyPromptBuilders.ts`
5. `server-node/src/modules/custom-world/runtimeProfile.ts`
6. `src/components/game-shell/PlatformHomeView.tsx`
7. `src/components/game-shell/PreGameSelectionFlow.tsx`
8. `src/hooks/story/npcEncounterActions.ts`
### 问题本质
这些文件并不是单纯“代码多”,而是同时承载了多类职责:
1. UI 状态
2. 领域规则
3. 请求编排
4. 文本构造
5. 运行时映射
6. 面板切换与流程控制
### 建议
1. `CustomWorldEntityEditorModal.tsx`
- 先按“实体列表/表单区/资源区/高级设置/预览区”拆组件
- 再把数据准备与提交编排抽成 hook
2. `characterAssetRoutes.ts`
- 拆成 route、prompt payload、job orchestration、产物发布、错误响应五层
3. `PlatformHomeView.tsx``PreGameSelectionFlow.tsx`
- 把页面壳层、数据加载、卡片渲染、弹层控制拆开
4. `storyPromptBuilders.ts``runtimeProfile.ts`
- 把“模板片段”“上下文归一化”“规则裁剪”“最终拼接”分层
---
## 3.4 P1继续控制构建产物体积
构建虽通过,但体积已经给出明显信号。
### 当前证据
本轮 `npm run build` 输出里,几个值得关注的点是:
1. `dist/assets/AuthenticatedApp-*.js``794.77 kB`
2. `dist/assets/index-*.js``197.44 kB`
3. `dist/assets/CustomWorldResultView-*.js``163.38 kB`
4. `dist/assets/ai-*.js``131.73 kB`
5. `dist/assets/PreGameSelectionFlow-*.js``96.39 kB`
6. `dist/assets/index-*.css``201.44 kB`
### 影响
1. 虽然还没触发新的 build gate 红线,但首屏、缓存和移动端体验会继续承压。
2. `AuthenticatedApp` 主包偏大,说明平台壳层仍然装入了过多首屏不必需能力。
3. CSS 体积继续上涨,说明样式正在跨模块相互堆叠。
### 建议
1. 继续把 custom world、asset studio、平台详情页、角色资产工具从主壳层路径中抽离。
2. 审查 `ai.ts``custom world result view``pregame selection` 是否还能再延迟加载。
3. 对全局样式做一次按模块归属清理,减少公共样式无限增长。
---
## 3.5 P1继续收紧前端与后端边界
这项已经不是“要不要做”的问题,而是“还剩多少尾巴没收完”。
### 当前证据
1. `src/services/apiClient.ts`
- 当前仍把 `access token`
- 自动登录用户名
- 自动登录密码
写入 `window.localStorage`
2. `src/hooks/story/runtimeStoryCoordinator.ts`
- 当前仍会在调用后端运行时前先 `putSaveSnapshot(...)`
- 响应后继续 `rehydrateSavedSnapshot(...)`
3. `src/hooks/story/npcEncounterActions.ts`
- 当前仍从前端动作流触发 `generateQuestForNpcEncounter(...)`
- 说明 NPC 任务“换单/重抽”分支尚未完全后端化
### 影响
1. 前端仍保留了一部分运行时真相与鉴权真相。
2. 自动登录凭证持久化在边界和安全上都不理想。
3. 运行时快照前置写入,会让“前端镜像状态”和“后端会话状态”继续纠缠。
### 建议
1. 优先移除自动登录用户名/密码本地持久化,收敛到服务端 session / refresh 机制。
2. 把运行时快照改为“展示缓存”而不是“提交前真相源”。
3. 把 NPC 任务更换动作补齐到后端 runtime/session 边界,不再由前端直接发起生成决策。
---
## 3.6 P2做一次疑似孤岛模块与旧入口归档
这项不一定最紧急,但现在做会明显降低后续维护噪音。
### 当前现象
从当前入口关系看,以下模块值得做一次正式复核:
1. `src/components/GameShell.tsx`
2. `src/components/custom-world-home/CustomWorldCreationHub.tsx`
3. `src/components/custom-world-home/CustomWorldCreationLauncherModal.tsx`
4. `src/components/custom-world-agent/CustomWorldAgentLauncherModal.tsx`
5. `src/components/custom-world-agent/CustomWorldAgentDraftDrawer.tsx`
6. `src/hooks/story/storyBootstrap.ts`
7. `src/hooks/useEquipmentFlow.ts`
8. `src/hooks/useForgeFlow.ts`
9. `src/hooks/useInventoryFlow.ts`
10. `src/services/typewriter.ts`
### 当前判断
这批模块不一定全部是垃圾代码,但至少说明一件事:
**仓库里仍然存在一批“不是正式入口、也没有清晰归档标签”的过渡实现。**
### 建议
把这类模块统一分成三类:
1. 正式保留并接回入口
2. 明确标记为实验稿
3. 直接归档或删除
这样可以减少后续开发时的误判成本。
---
## 3.7 P2拆测试聚合文件恢复测试的定位能力
当前测试文件也已经出现“大一统热点”。
### 证据
1. `server-node/src/app.test.ts``3568`
2. `server-node/src/modules/story/storyActionRoutes.test.ts``2402`
3. `server-node/src/modules/assets/characterAssetRoutes.test.ts``1235`
4. `src/hooks/story/npcEncounterActions.test.ts``1199`
### 影响
1. 失败定位成本高。
2. fixture 复用差,字段一变容易整片测试跟着漂移。
3. 测试文件本身开始变成新的维护热点。
### 建议
1. 按领域动作拆测试文件,而不是继续堆到单一总测文件中。
2. 补 fixture builder / factory减少字面量散落。
3.`runtime / auth / custom world / assets` 这几条链路增加更明确的契约测试分层。
---
## 4. 推荐执行顺序
如果只按工程收益排序,建议按下面的顺序推进:
1. 先修 `typecheck`
2. 再把 `lint` 分成机械修复和语义修复两轮
3. 然后拆 `custom world / asset / platform` 热点
4. 再继续收前端运行时与鉴权边界
5. 最后处理孤岛模块归档和测试拆分
---
## 5. 当前不建议优先做的事
1. 不建议在 `typecheck``lint` 仍为红线时继续横向扩功能。
2. 不建议直接在 `CustomWorldEntityEditorModal.tsx``characterAssetRoutes.ts``PlatformHomeView.tsx` 里继续堆新逻辑。
3. 不建议把 bundle 体积问题简单理解为“先放宽阈值”,当前更适合继续拆职责和延迟加载。
4. 不建议在未确认入口关系前随手删除可疑旧模块,先做归档分类更稳。
---
## 6. 本文依据
文档依据:
1. `docs/audits/engineering/README.md`
2. `docs/audits/engineering/CURRENT_ENGINEERING_OPTIMIZATION_PRIORITIES_2026-04-10.md`
3. `docs/audits/engineering/ENGINEERING_CLEANUP_AND_BACKEND_BOUNDARY_AUDIT_2026-04-20.md`
4. `docs/experience/PROJECT_WORK_EXPERIENCE_PLAYBOOK.md`
当前仓库复核依据:
1. `package.json`
2. `.eslintrc.cjs`
3. `vite.config.ts`
4. `scripts/build-gate.mjs`
5. `src/App.tsx`
6. `src/services/apiClient.ts`
7. `src/hooks/story/runtimeStoryCoordinator.ts`
8. `src/hooks/story/npcEncounterActions.ts`
9. 当前源码大文件体量扫描结果
10. `npm run typecheck`
11. `npm run lint:eslint`
12. `npm run build`

View File

@@ -4,25 +4,29 @@
## 当前推荐入口 ## 当前推荐入口
1. [ENGINEERING_CLEANUP_AND_BACKEND_BOUNDARY_AUDIT_2026-04-20.md](./ENGINEERING_CLEANUP_AND_BACKEND_BOUNDARY_AUDIT_2026-04-20.md) 1. [CURRENT_ENGINEERING_OPTIMIZATION_OPPORTUNITIES_2026-04-20.md](./CURRENT_ENGINEERING_OPTIMIZATION_OPPORTUNITIES_2026-04-20.md)
这一版是面向当前仓库状态的优化点盘点,适合直接拿来排优先级和拆执行批次。
2. [ENGINEERING_CLEANUP_AND_BACKEND_BOUNDARY_AUDIT_2026-04-20.md](./ENGINEERING_CLEANUP_AND_BACKEND_BOUNDARY_AUDIT_2026-04-20.md)
这一版是对 `2026-04-19` 基线的当前仓库复核,明确哪些问题已经处理、哪些表述需要纠正、热点又迁移到了哪里。 这一版是对 `2026-04-19` 基线的当前仓库复核,明确哪些问题已经处理、哪些表述需要纠正、热点又迁移到了哪里。
2. [ENGINEERING_CLEANUP_AND_BACKEND_BOUNDARY_AUDIT_2026-04-19.md](./ENGINEERING_CLEANUP_AND_BACKEND_BOUNDARY_AUDIT_2026-04-19.md) 3. [ENGINEERING_CLEANUP_AND_BACKEND_BOUNDARY_AUDIT_2026-04-19.md](./ENGINEERING_CLEANUP_AND_BACKEND_BOUNDARY_AUDIT_2026-04-19.md)
这一版保留原始问题快照和执行回填,适合回看“为什么会有这轮清理与边界收口”。 这一版保留原始问题快照和执行回填,适合回看“为什么会有这轮清理与边界收口”。
3. [ENGINEERING_OPTIMIZATION_REVIEW_2026-04-01.md](./ENGINEERING_OPTIMIZATION_REVIEW_2026-04-01.md) 4. [ENGINEERING_OPTIMIZATION_REVIEW_2026-04-01.md](./ENGINEERING_OPTIMIZATION_REVIEW_2026-04-01.md)
这一版最适合作为当前工程基线,重点从“是否真正绿色”“门禁有没有覆盖真实风险”来判断仓库状态。 这一版最适合作为当前工程基线,重点从“是否真正绿色”“门禁有没有覆盖真实风险”来判断仓库状态。
4. [ENGINEERING_OPTIMIZATION_REVIEW_2026-03-30.md](./ENGINEERING_OPTIMIZATION_REVIEW_2026-03-30.md) 5. [ENGINEERING_OPTIMIZATION_REVIEW_2026-03-30.md](./ENGINEERING_OPTIMIZATION_REVIEW_2026-03-30.md)
适合回看运行时主链路、Story/Combat 边界、分层过渡期问题。 适合回看运行时主链路、Story/Combat 边界、分层过渡期问题。
5. [ENGINEERING_OPTIMIZATION_REVIEW_2026-03-29.md](./ENGINEERING_OPTIMIZATION_REVIEW_2026-03-29.md) 6. [ENGINEERING_OPTIMIZATION_REVIEW_2026-03-29.md](./ENGINEERING_OPTIMIZATION_REVIEW_2026-03-29.md)
适合看第一轮系统性工程扫描,了解最早的问题基线。 适合看第一轮系统性工程扫描,了解最早的问题基线。
## 融合结论 ## 融合结论
- 当前仓库已经完成“旧 dev 插件链路删除、根目录噪音清理、`server-node -> src/**` 反向依赖切断”这批第一阶段任务。 - 当前仓库已经完成“旧 dev 插件链路删除、根目录噪音清理、`server-node -> src/**` 反向依赖切断”这批第一阶段任务。
- 当前如果想直接判断“今天先优化什么”,优先看 `CURRENT_ENGINEERING_OPTIMIZATION_OPPORTUNITIES_2026-04-20.md`
- 当前的新重点已经进一步收敛到三类:未接线孤岛模块、前端残留的运行时/鉴权真相、热点向 prompt/runtime profile/平台入口壳层迁移。 - 当前的新重点已经进一步收敛到三类:未接线孤岛模块、前端残留的运行时/鉴权真相、热点向 prompt/runtime profile/平台入口壳层迁移。
- 三轮结论是一致收敛的:问题不在“有没有开始工程化”,而在“工程化是否真正覆盖了最危险的主链路”。 - 三轮结论是一致收敛的:问题不在“有没有开始工程化”,而在“工程化是否真正覆盖了最危险的主链路”。
- 最新一轮已经把关注点集中到质量门禁、真实绿色基线、关键模块豁免和 build warning 上。 - 最新一轮已经把关注点集中到质量门禁、真实绿色基线、关键模块豁免和 build warning 上。
- `2026-04-19` 这一轮把问题压实到了四类:仓库噪音、旧 dev 入口残留、前端越界运行时逻辑、巨型热点文件。 - `2026-04-19` 这一轮把问题压实到了四类:仓库噪音、旧 dev 入口残留、前端越界运行时逻辑、巨型热点文件。
- `2026-04-20` 这一轮进一步确认:前两类已经阶段性完成,当前真正剩下的是边界尾巴和新热点迁移。 - `2026-04-20` 这一轮进一步确认:前两类已经阶段性完成,当前真正剩下的是边界尾巴和新热点迁移。
- 如果只是为了判断现在先做什么,直接从 `2026-04-01` 开始即可。 - 如果只是为了判断现在先做什么,直接从 `2026-04-01` 开始即可。
- 如果是要看当前清理和边界收口的最新状态,优先看 `2026-04-20` - 如果是要看当前清理和边界收口的最新状态,优先看 `ENGINEERING_CLEANUP_AND_BACKEND_BOUNDARY_AUDIT_2026-04-20.md`
- 如果是要看“当前可执行的优化点清单”,优先看 `CURRENT_ENGINEERING_OPTIMIZATION_OPPORTUNITIES_2026-04-20.md`
- 如果是要做长期重构方案,再按 `2026-03-29 -> 2026-03-30 -> 2026-04-01 -> 2026-04-19 -> 2026-04-20` 的顺序回看演进。 - 如果是要做长期重构方案,再按 `2026-03-29 -> 2026-03-30 -> 2026-04-01 -> 2026-04-19 -> 2026-04-20` 的顺序回看演进。

View File

@@ -141,6 +141,25 @@
- 后续如果继续调整平台主 Tab 视觉,优先改 `src/index.css` 的平台主题 token 和 remap 规则;只有 token 无法表达时,再做局部组件样式补丁,避免亮色主题再次出现“页面整体是亮的,但局部卡片仍是暗的”。 - 后续如果继续调整平台主 Tab 视觉,优先改 `src/index.css` 的平台主题 token 和 remap 规则;只有 token 无法表达时,再做局部组件样式补丁,避免亮色主题再次出现“页面整体是亮的,但局部卡片仍是暗的”。
- 参考图方向已明确:平台亮色主题应以白色为主底色,粉红只承担背景气氛和重点 CTA不应让整页主壳继续像深粉底板。 - 参考图方向已明确:平台亮色主题应以白色为主底色,粉红只承担背景气氛和重点 CTA不应让整页主壳继续像深粉底板。
- 移动端底部 `platform-bottom-nav` 的 Tab 激活态必须与默认态使用同一套盒模型;边框要预占位,不能在 onPress / active 时临时增加边框导致按钮尺寸和留白跳变。 - 移动端底部 `platform-bottom-nav` 的 Tab 激活态必须与默认态使用同一套盒模型;边框要预占位,不能在 onPress / active 时临时增加边框导致按钮尺寸和留白跳变。
- 2026-04-20 第二轮细查补色时,继续把 `PlatformWorldDetailView.tsx``PlatformHomeView.tsx``PreGameSelectionFlow.tsx` 里落在白底/浅底面板上的标题、说明、次级标签、搜索栏和加载兜底文本显式切回平台亮色 token避免亮色主题下继续出现浅底白字或过浅灰字。
- 2026-04-20 第三轮修正方向后,平台首页移动端底部 `platform-bottom-nav` 的高度、内边距、按钮圆角、图标尺寸、标签字号统一收口到 `src/index.css` token`PlatformHomeView.tsx` 只保留结构类,避免 `h-14`、容器 padding、按钮内部内容间距和 active 底座各自维护半套尺寸,导致选中态看起来比 Tab 槽位更矮或更高。
- 2026-04-20 第四轮把平台亮色主题顶部过重的红色收轻:`--platform-body-fill``--platform-hero-fill``--platform-shell-glow-*``--platform-surface-glow-*` 改成更接近暖白 + 浅珊瑚的低饱和版本,首页 / 创作页 / 详情页 Hero 覆层统一改走 `--platform-hero-overlay-strong`,避免组件里继续写死高饱和粉红渐变。
---
## 11. 2026-04-20 创作 Agent 聊天工作台亮色主题补色
- `src/components/custom-world-agent/*` 这一条创作 Agent 工作台链路已统一切回 `platform-subpanel``platform-input``platform-button``platform-banner``platform-progress-track`,亮色主题下不再继续裸露 `bg-[#111318]``bg-black/*``bg-white/*``text-white` 这类历史深色残留。
- 聊天线程中的用户气泡、助手气泡、系统消息、推荐回复按钮、流式回复态统一映射到平台 token后续如果继续调整创作 Agent 聊天视觉,优先改平台 token 或平台 class不要在组件里再单独写一套聊天色板。
- 顶栏、操作横幅、进度条、输入框的状态色统一复用平台亮暗主题变量,避免再次出现“页面整体已切亮色,但 Agent 局部还是旧暗色弹层”的割裂感。
---
## 12. 2026-04-20 NPC 聊天退出恢复与文本阅读性修正
- `AdventurePanel.tsx` 的叙事 `storyText` 已取消斜体,改为更大的正文尺寸,避免长段阅读时发飘。
- 冒险面板里的 `actionText` 统一上调到聊天态同级字号;`detailText` 不再默认渲染,保持底部选项区更清爽。
- `npcEncounterActions.ts` 在“退出聊天”后重新续写剧情时,会优先把当前故事里最近一轮已经呈现给玩家的非聊天选项文案并回 `optionCatalog`,避免高好感聊天收束后又退回 NPC 静态 fallback 文案。
--- ---

View File

@@ -0,0 +1,388 @@
# 当前 Agent 创作流程优化执行规划(大白话版)
更新时间:`2026-04-20`
## 先把话说死
这轮不再加新流程。
不再新增一套创作动线。
不再为了“更完整”继续把 PRD 里没落完的所有阶段、面板、动作全补出来。
不再把前端创作工具改成另一套长得不一样的新系统。
这轮只做一件事:
**把现在这条你已经满意的前端创作流程,收紧、理顺、删重、补通,让它从“能跑”变成“稳、顺、好维护、不会自己打自己”。**
这份规划就是基于 [AGENT_TO_DRAFT_TO_WORLD_PIPELINE_AUDIT_2026-04-20.md](../audits/AGENT_TO_DRAFT_TO_WORLD_PIPELINE_AUDIT_2026-04-20.md) 里已经确认的问题,重新收束出来的一版执行方案。
---
## 1. 现在最大的问题,用大白话讲是什么
不是界面丑。
不是步骤不够多。
不是入口不够花。
而是现在这条链里,很多地方在“一个流程里混着好几套脑子”。
具体就是:
1. 用户明明在走 Agent 创作,但走到一半,很多关键数据又偷偷变成了 old profile 流程在接手。
2. 明明已经有 Agent session 这条主线,但结果页、作品库、旧生成接口、旧编辑器能力还都在同时干活。
3. 明明有“发布世界”这个概念,但现在实际上“不发布也能直接进入世界”。
4. 明明有一些 session 内的数据,比如建议动作、草稿卡、澄清项、质量检查,结果走到结果页之后就像断电了一样,后面没人接着用。
5. 明明有些功能已经决定这轮不做,但代码里和文档里还留着很多“半做不做”的说法,会持续误导后续开发。
所以这轮优化的目标不是“让系统更大”,而是:
**让整条现有流程只认一条主线,别再一会儿 Agent、一会儿 legacy、一会儿旧 session、一会儿作品库各管各的。**
---
## 2. 这轮优化后的目标状态
我们要收敛到下面这个状态:
```text
用户进入 Agent 创作
-> 在现有工作区里聊天和生成草稿
-> 草稿整理完成后进入现有结果页
-> 结果页只做预览、少量必要确认、进入世界
-> 进入世界时走一条明确、统一、可解释的数据链
-> 平台“我的创作”能稳定找回这份草稿或这份作品
```
注意,这里有两个关键词:
1. **还是现有动线**
2. **但背后的数据链要统一**
也就是说:
前端看上去可以几乎不换流程。
但后面谁是真相源、谁负责编译、谁负责保存、谁负责恢复、哪些能力要删掉,必须彻底讲清楚。
---
## 3. 这轮不做什么
为了避免后面又做散,先把“不做什么”写清楚。
### 3.1 不新增新的大流程
不做这些:
1. 不再新增“另一个 Agent 创作工作台”
2. 不再新增“另一套草稿结果页”
3. 不再新增“另一条作品发布流程”
4. 不再新增“另一套创作中心入口”
### 3.2 不为了补 PRD 而硬补所有未完成能力
不做这些:
1. 不把所有 `lock / unlock / regenerate_scope / expand_long_tail / scene asset pipeline` 一次性全打完
2. 不为了“文档里写过”就把所有没接线面板都接进来
3. 不把当前工作区重新改造成一个更复杂的大后台
### 3.3 不把结果页继续当旧编辑器扩写
这轮明确不再鼓励:
1. 在结果页继续加更多直接生成角色 / 地点的按钮
2. 在结果页继续加更多直接改 legacy profile 的编辑能力
3. 让结果页承担越来越重的“补世界”职责
一句话:
**结果页要收口,不要继续发散。**
---
## 4. 接下来真正要做的 5 件事
## 4.1 第一件事:先定一条唯一主链,别再多套数据同时接力
这是第一优先级,也是最重要的一件事。
现在的问题不是“没东西可用”,而是“能用的东西太多了,而且互相抢活”。
接下来要明确:
1. 当前 Agent 创作流程里,`Agent session` 才是草稿阶段的真相源。
2. 结果页只是这份草稿的展示和收口,不应该变成另一套独立编辑器。
3. 进入世界时,只能走一条明确的编译出口,不能这里转一次、那里改一次、最后谁改得晚听谁的。
用大白话讲,就是:
**从聊天开始到点“进入世界”为止,中间只能有一条主水管。**
不能再出现:
1. Agent session 里一份数据
2. 前端桥接后又一份 profile
3. 结果页本地改完又一份 profile
4. 自动保存到作品库后再来一份 profile
这样下去,后面谁出 bug 都说不清到底是哪一层改坏的。
所以这一阶段的目标不是改 UI而是先把话语权统一
1. 草稿阶段谁说了算
2. 进入世界前谁负责最终编译
3. 作品库里保存的到底是“正式世界”还是“当前草稿快照”
这件事不解决,后面所有优化都会继续打架。
---
## 4.2 第二件事:把结果页收口,只保留当前流程真正需要的事
现在结果页的问题很简单:
它干的活太多了。
它现在既像:
1. 草稿预览页
2. 旧自定义世界编辑器
3. AI 补角色/补地点的入口
4. 自动保存中转页
5. 进入世界前的最后一跳
这就是为什么它会越来越乱。
这轮要做的不是重做结果页,而是收口结果页。
收口方向很明确:
1. 结果页继续保留现在你满意的浏览和进入世界体验
2. 但要逐步去掉“它自己偷偷改世界结构”的能力
3. 让它更像“当前草稿的总览页”,而不是“另一套世界编辑器”
用大白话讲:
**结果页负责看,不负责偷偷再造一遍世界。**
所以这里建议后续逐步处理:
1. 把结果页里那些直接生成 playable/story/landmark 的旧能力下掉
2. 把直接改 legacy profile 的重编辑能力从结果页移走或收紧
3. 让“去 Agent 调整设定”真的是回主流程调,而不是结果页自己补完半套流程
这一步做完的好处很直接:
1. 结果页职责会清楚很多
2. 进入世界前的状态会更稳定
3. 不会再出现“用户以为还在 Agent 流里,实际上已经走到 legacy 编辑器里了”
---
## 4.3 第三件事:平台入口统一,不让草稿恢复和作品查看继续割裂
现在还有一个体验问题不是流程长短的问题,而是入口不统一。
简单说就是:
1. 后端已经能区分“草稿”和“已发布作品”
2. 但平台页里“我的创作”主要还在看 `myEntries`
3. Agent 草稿并不能自然地稳定出现在同一个主入口里
这会带来两个问题:
1. 用户做了一半的草稿,不容易稳定找回来
2. 系统里其实已经有创作中心能力,但主入口没认它
所以接下来要做的不是新做一个创作中心,而是:
**把已经存在的聚合能力真正接回现在的平台入口。**
用户看到的应该是:
1. 还没进世界的草稿,可以继续创作
2. 已经成型的作品,可以查看或进入世界
而不是:
1. 草稿在一套地方
2. 已保存作品在另一套地方
3. 恢复创作还得靠 sessionId 或隐藏状态兜底
这一步的核心价值不是“新功能”,而是“东西别丢、入口别分裂、用户心智别断”。
---
## 4.4 第四件事:删掉重复 pipeline不再同时养两三套创作生成链
这一步很关键,而且一定要明确态度:
**既然你已经决定当前前端创作流程满意,那就不能继续默认保留那么多并行旧链。**
现在最典型的重复链有:
1. Agent 创作链
2.`custom-world/sessions` 世界生成链
3. 结果页 legacy profile 直改链
它们的共同问题是:
1. 都能生成世界
2. 都能改世界
3. 但不是同一套状态模型
4. 后期维护会越来越痛苦
所以这一步不是说要立刻把所有旧东西物理删除,而是要明确分层:
1. 哪条是当前正式主链
2. 哪条是兼容链
3. 哪条只是暂时留着,但不能再往上继续加功能
用大白话讲,就是:
**该扶正的扶正,该降级的降级,该冻结的冻结。**
尤其是旧 `custom-world/sessions` 这条链,如果还要保留,也只能是兼容入口,不能再和 Agent 主链平起平坐。
---
## 4.5 第五件事:把文稿里那些“这轮不做”的未完成项从主叙事里移掉
这是你这次特别强调的点,我完全同意,而且它很重要。
现在很多文稿的问题不是“写错了”,而是:
1. 写了很多理论上该有的能力
2. 但当前版本并不准备继续往那个方向扩
3. 结果文档会不断把团队拉回“是不是还要把这些补完”的思路里
这会直接制造两种问题:
1. 开发判断会飘
2. 后续审计会永远得到“未完成项很多”的结论
所以这轮文档治理要做的,不是把文稿全删空,而是分清三类内容:
1. **当前版本要继续优化的**
2. **当前版本明确不做、先冻结的**
3. **未来可以再看,但这轮不纳入执行规划的**
用大白话讲:
**文档也要学会闭嘴。**
不是所有想过的东西,都要继续挂在当前版本的主任务里。
---
## 5. 推荐执行顺序
这轮建议按下面顺序推进,不建议乱穿插。
## 第一阶段:先收主链
先做:
1. 定义当前正式主链
2. 明确 Agent session、结果页、作品库、进入世界之间谁负责什么
3. 停止继续增强结果页里的 legacy 编辑能力
这一阶段的目标是:
**先让水管只有一根。**
## 第二阶段:再收结果页和平台入口
再做:
1. 结果页职责收口
2. 平台“我的创作”入口统一
3. 草稿恢复和作品查看走同一套入口认知
这一阶段的目标是:
**让用户走起来更顺,让系统找回内容更稳定。**
## 第三阶段:再处理旧 pipeline 的降级和冻结
再做:
1.`custom-world/sessions` 链降级
2. 结果页直改 profile 的旧能力收紧
3. 兼容链保留边界写清楚
这一阶段的目标是:
**减少系统自己和自己打架。**
## 第四阶段:最后做文档清理
最后做:
1. 把当前版本不再追的未完成项,从主规划文稿里移出去
2. 把“未来也许做”从“这轮要做”里拆开
3. 让所有当前规划只服务当前版本
这一阶段的目标是:
**让接下来所有开发都围绕同一套现实目标执行。**
---
## 6. 每个阶段做完以后,应该看到什么效果
## 阶段一做完
应该看到:
1. 代码里谁是主链一眼能看明白
2. 不会再出现一会儿 Agent、一会儿 legacy profile 接管全局的情况
3. 进入世界时的数据来源更清楚
## 阶段二做完
应该看到:
1. 结果页更干净
2. 平台页更容易找回自己的创作
3. 用户对“草稿”“作品”“进入世界”这三个概念不会混
## 阶段三做完
应该看到:
1. 重复 pipeline 明显减少
2. 旧链不再继续吞主流程职责
3. 后续开发不会再不知道该往哪条链上接
## 阶段四做完
应该看到:
1. 文档和代码目标一致
2. 团队不会再被一堆“理论上应该补”的项拉偏
3. 后续迭代能真正围绕“优化已有流程”推进
---
## 7. 这轮最重要的判断标准
这轮不是看我们补了多少功能。
这轮的判断标准应该是下面 5 条:
1. 用户现在这条创作流程有没有被打断
2. 同一个世界的数据是不是只走一条清楚的主链
3. 结果页是不是还在偷偷承担旧编辑器职责
4. 平台入口能不能稳定找回草稿和作品
5. 文档是不是已经不再推动大家去补这轮明确不做的东西
如果这 5 条做好了,就说明这轮方向是对的。
---
## 8. 一句话总结
接下来的优化,不是再发明一套更复杂的创作流程,而是把当前你已经满意的这条前端动线背后的数据链、入口、职责和文档全部收紧到同一个方向上:
**少一点并行、少一点桥接、少一点重复、少一点“半做半留”,把现有流程真正打磨成一条稳定主链。**

View File

@@ -3,6 +3,7 @@
## 当前入口 ## 当前入口
- [CURRENT_GAME_ITERATION_PRIORITIES_2026-04-03.md](./CURRENT_GAME_ITERATION_PRIORITIES_2026-04-03.md):当前阶段最值得优先做什么、为什么,以及它和审计/PRD 的对应关系。 - [CURRENT_GAME_ITERATION_PRIORITIES_2026-04-03.md](./CURRENT_GAME_ITERATION_PRIORITIES_2026-04-03.md):当前阶段最值得优先做什么、为什么,以及它和审计/PRD 的对应关系。
- [CURRENT_AGENT_CREATION_FLOW_OPTIMIZATION_PLAN_2026-04-20.md](./CURRENT_AGENT_CREATION_FLOW_OPTIMIZATION_PLAN_2026-04-20.md):在不新增前端创作流程的前提下,围绕当前 Agent 创作动线做收口、删重、补通和文档收束的大白话执行规划。
- [EXPRESS_BACKEND_REFACTOR_PLAN_2026-04-08.md](./EXPRESS_BACKEND_REFACTOR_PLAN_2026-04-08.md):基于“前端只做表现、逻辑与数据全部后端化”的工程重构规划。 - [EXPRESS_BACKEND_REFACTOR_PLAN_2026-04-08.md](./EXPRESS_BACKEND_REFACTOR_PLAN_2026-04-08.md):基于“前端只做表现、逻辑与数据全部后端化”的工程重构规划。
- [EXPRESS_BACKEND_PARALLEL_WORKSTREAM_PLAN_2026-04-08.md](./EXPRESS_BACKEND_PARALLEL_WORKSTREAM_PLAN_2026-04-08.md):将后端化重构拆成可并行推进、尽量不冲突的任务流与协作顺序。 - [EXPRESS_BACKEND_PARALLEL_WORKSTREAM_PLAN_2026-04-08.md](./EXPRESS_BACKEND_PARALLEL_WORKSTREAM_PLAN_2026-04-08.md):将后端化重构拆成可并行推进、尽量不冲突的任务流与协作顺序。
- [BEIJING_POLICY_APPLICATION_OVERVIEW_13_21_24_2026-04-14.md](./BEIJING_POLICY_APPLICATION_OVERVIEW_13_21_24_2026-04-14.md):北京市方向 13 / 21 / 24 的统一判断、共用材料框架和准备顺序。 - [BEIJING_POLICY_APPLICATION_OVERVIEW_13_21_24_2026-04-14.md](./BEIJING_POLICY_APPLICATION_OVERVIEW_13_21_24_2026-04-14.md):北京市方向 13 / 21 / 24 的统一判断、共用材料框架和准备顺序。

View File

@@ -825,6 +825,32 @@ isCardDetailLoading: boolean;
## 12. 接口与交互时序 ## 12. 接口与交互时序
## 12.0 生成草稿进度阶段
第三阶段的“整理一版世界底稿”不应再只显示粗略四阶段,而应贴近真实执行链路。
前端进度条至少按以下顺序归并展示:
1. 接收生成请求
2. 整理世界骨架
3. 生成可扮演角色
4. 生成场景角色
5. 生成关键场景
6. 建立场景连接
7. 补全可扮演角色细节
8. 补全场景角色细节
9. 编译世界底稿
10. 生成角色主形象
11. 生成幕背景图
12. 编译草稿卡
13. 准备精修工作区
说明:
1. 前端步骤名优先复用服务端 `phaseLabel` 的真实语义,不再单独发明一套四段式文案。
2. 如果服务端处于批处理阶段,顶部 `phaseLabel` / `phaseDetail` 继续直接显示当前批次信息。
3. 自动补主形象与幕背景图也属于草稿生成链路的一部分,不能在进度 UI 中被误折叠成“已完成”后的隐藏耗时。
## 12.1 生成底稿时序 ## 12.1 生成底稿时序
```text ```text

View File

@@ -315,10 +315,10 @@ UI 主标题建议:
按优先级取: 按优先级取:
1. `draftProfile.cover.imageSrc`,当 `sourceType``uploaded / generated` 1. `draftProfile.cover.imageSrc`,当 `sourceType``uploaded / generated`
2. `draftProfile.camp.imageSrc` 作为默认封面底图 2. `draftProfile.sceneChapterBlueprints[0].acts[0].backgroundImageSrc` 作为默认封面底图
3. 默认封面底图上叠加 `draftProfile.cover.characterRoleIds` 对应的角色主形象 3. 默认封面底图上叠加 `draftProfile.cover.characterRoleIds` 对应的角色主形象
4. 若未显式指定角色,则按 `playableNpcs` 顺序取前 `3` 个有主图的角色 4. 若未显式指定角色,则按 `playableNpcs` 顺序取前 `3` 个有主图的角色
5. 若开局场景为空,则回退到第一张场景图;再不行才回退到首个角色主图或默认占位图 5. 若开局场景第一幕图片为空,则依次回退到 `draftProfile.camp.imageSrc`、首个 `landmark.imageSrc`首个角色主图或默认占位图
### 草稿卡片主操作 ### 草稿卡片主操作
@@ -361,10 +361,10 @@ UI 主标题建议:
按优先级取: 按优先级取:
1. `CustomWorldProfile.cover.imageSrc`,当 `sourceType``uploaded / generated` 1. `CustomWorldProfile.cover.imageSrc`,当 `sourceType``uploaded / generated`
2. 开局场景作为默认封面底图 2. 开局场景第一幕图片作为默认封面底图
3. 默认封面底图上叠加 `cover.characterRoleIds` 指定的角色主形象 3. 默认封面底图上叠加 `cover.characterRoleIds` 指定的角色主形象
4. 若未显式指定角色,则按 `playableNpcs` 顺序取前 `3` 个有主图的角色 4. 若未显式指定角色,则按 `playableNpcs` 顺序取前 `3` 个有主图的角色
5. 若默认底图不可用,再回退到第一可扮演角色立绘或默认占位图 5. 若默认底图不可用,则依次回退到 `camp.imageSrc`、首个 `landmark.imageSrc`第一可扮演角色立绘或默认占位图
## 7.3 作品封面属性 ## 7.3 作品封面属性
@@ -387,7 +387,7 @@ interface CustomWorldCoverProfile {
1. `sourceType = default` 1. `sourceType = default`
- 表示继续使用系统默认封面布局 - 表示继续使用系统默认封面布局
- `imageSrc` 不作为最终封面图使用 - `imageSrc` 不作为最终封面图使用
- 底图固定取“开局场景 - 底图固定优先取“开局场景第一幕图片
- 前景角色取 `characterRoleIds` - 前景角色取 `characterRoleIds`
2. `sourceType = uploaded` 2. `sourceType = uploaded`
@@ -399,24 +399,26 @@ interface CustomWorldCoverProfile {
- 表示作者通过 AI 生成了一张最终封面 - 表示作者通过 AI 生成了一张最终封面
- 卡片与详情页直接显示 `imageSrc` - 卡片与详情页直接显示 `imageSrc`
- 不再叠加默认角色前景 - 不再叠加默认角色前景
- 生成时允许作者补一句封面描述,系统会结合世界主题、场景素材、角色素材共同构图
## 7.4 默认封面布局 ## 7.4 默认封面布局
默认封面布局不是单纯“取开局场景图”,而是: 默认封面布局不是单纯“取开局场景图”,而是:
```text ```text
开局场景 开局场景第一幕图片
+ 前景主角色主形象 2~3 个 + 前景主角色主形象 2~3 个
+ 用于列表卡片和作品详情的统一封面预览 + 用于列表卡片和作品详情的统一封面预览
``` ```
明确规则: 明确规则:
1. 默认封面底图固定优先取 `camp.imageSrc` 1. 默认封面底图固定优先取 `sceneChapterBlueprints[0].acts[0].backgroundImageSrc`
2. 默认前景角色固定从 `playableNpcs` 中取前 `3` 个有主图的角色 2. 若开局场景第一幕图片不存在,则依次回退到 `camp.imageSrc` 与首个 `landmark.imageSrc`
3. 若作者在 `cover.characterRoleIds` 中显式指定角色,则优先按指定顺序展示 3. 默认前景角色固定从 `playableNpcs` 中取前 `3` 个有主图的角色
4. 前端只负责把后端给出的“底图 + 角色主图列表”渲染成封面,不在前端做封面规则推理 4. 若作者在 `cover.characterRoleIds` 中显式指定角色,则优先按指定顺序展示
5. 已上传或已生成的最终封面,直接作为成品图显示,不再做默认布局叠加 5. 前端只负责把后端给出的“底图 + 角色主图列表”渲染成封面,不在前端做封面规则推理
6. 已上传或已生成的最终封面,直接作为成品图显示,不再做默认布局叠加
## 7.5 作者操作 ## 7.5 作者操作
@@ -433,6 +435,64 @@ interface CustomWorldCoverProfile {
2. 重置为默认后,`sourceType` 回到 `default` 2. 重置为默认后,`sourceType` 回到 `default`
3. 草稿与已发布作品都读取同一份封面属性,不允许出现“草稿页是一个封面、发布后又自动换另一张”的漂移 3. 草稿与已发布作品都读取同一份封面属性,不允许出现“草稿页是一个封面、发布后又自动换另一张”的漂移
## 7.6 AI 封面生成与上传约束
### AI 生成输入
作者点击 `AI 生成封面` 后,面板至少支持 3 类输入:
1. 一句封面图描述
2. 可选参考图
3. 角色出镜选择
系统生成封面时,后端必须自动拼接以下上下文,而不是只吃用户那一句描述:
1. 世界名、副标题、世界概述、主题基调、玩家目标
2. 开局场景第一幕标题、摘要、背景图
3. 营地图与关键场景图
4. 可扮演角色主形象
5. 已选择的封面角色顺序
结论:
**封面 AI 生成必须是“用户一句描述 + 系统世界素材拼接”的生成链,而不是裸 prompt。**
### AI 生成结果要求
1. 默认生成尺寸固定为 `16:9`
2. 第一版统一生成 `1600 × 900`
3. 结果图不允许出现标题字、水印、按钮、UI 边框
4. 结果图要优先满足移动端卡片裁切后的主体可读性
5. 结果图保存后直接写入作品封面资产目录
### 上传封面要求
作者点击 `上传封面` 后,不能直接把原图原样落库,必须经过独立裁剪面板处理。
裁剪链路要求:
1. 上传后先进入独立裁剪面板
2. 裁剪框比例固定为 `16:9`
3. 作者只能平移和缩放,不允许自由改比例
4. 裁剪完成后,再提交给后端保存
### 上传大小与格式限制
第一版约束:
1. 仅支持 `png / jpg / webp`
2. 上传原图大小上限固定为 `10 MB`
3. 后端落库前必须统一裁剪并缩放到 `1600 × 900`
4. 后端保存时需要做体积压缩,目标成品图不超过 `1.5 MB`
5. 若压缩后仍超过限制,返回明确错误,不允许静默保存超标文件
### 前后端分工
1. 前端负责提供裁剪交互、预览与提交裁剪框
2. 后端负责最终裁剪、缩放、压缩和大小校验
3. 前端不能直接把本地裁剪结果当最终事实来源
4. 同一张上传封面在草稿页、作品库、详情页必须读取同一后端成品地址
### 已发布卡片主操作 ### 已发布卡片主操作
第一版必须有: 第一版必须有:

View File

@@ -14,7 +14,7 @@
## 1. 一句话定义 ## 1. 一句话定义
玩家点击 `npc_chat` 后,进入 NPC 聊天模式;每次只完成一轮“玩家输入 -> NPC 回复 -> 关系变化消息 -> 下一轮 3 个建议选项 + 1 个自定义输入”,直到玩家主动退出聊天。 玩家点击 `npc_chat` 后,进入 NPC 聊天模式;每次只完成一轮“玩家输入 -> NPC 回复 -> 角色形象播出好感度变化特效 -> 下一轮 3 个建议选项 + 1 个自定义输入”,直到玩家主动退出聊天。
--- ---
@@ -42,7 +42,7 @@
4. 每轮结束后稳定出现 `3` 个建议续聊选项。 4. 每轮结束后稳定出现 `3` 个建议续聊选项。
5. 每轮结束后稳定出现 `1` 个自定义输入框。 5. 每轮结束后稳定出现 `1` 个自定义输入框。
6. 玩家选择建议项或提交自定义输入后,继续在同一消息队列中续写。 6. 玩家选择建议项或提交自定义输入后,继续在同一消息队列中续写。
7. 好感度增减必须作为“系统消息插入到对话消息队列中 7. 好感度增减不能再作为聊天系统消息插入队列,必须改为角色形象上的数值特效反馈
8. NPC 回复必须支持流式传输,并在前端边接收边解析显示。 8. NPC 回复必须支持流式传输,并在前端边接收边解析显示。
9. 背包按钮所在行的最右侧必须新增“退出聊天”按钮。 9. 背包按钮所在行的最右侧必须新增“退出聊天”按钮。
10. 退出聊天后恢复普通冒险态,不保留当前聊天输入框与聊天建议项。 10. 退出聊天后恢复普通冒险态,不保留当前聊天输入框与聊天建议项。
@@ -82,7 +82,7 @@
1. 玩家通过“建议选项”或“自定义输入”提交一句话。 1. 玩家通过“建议选项”或“自定义输入”提交一句话。
2. 玩家消息立即进入消息队列。 2. 玩家消息立即进入消息队列。
3. NPC 回复开始流式显示。 3. NPC 回复开始流式显示。
4. 流式结束后,如果有关系变化,插入一条系统消息 4. 流式结束后,如果有关系变化,在角色形象上播放一次对应的数值特效
5. 系统刷新下一轮 `3` 个建议选项。 5. 系统刷新下一轮 `3` 个建议选项。
6. 系统保留自定义输入入口,等待下一轮。 6. 系统保留自定义输入入口,等待下一轮。
@@ -117,9 +117,17 @@
1. 聊天态下,消息区按时间顺序展示: 1. 聊天态下,消息区按时间顺序展示:
- 玩家消息 - 玩家消息
- NPC 消息 - NPC 消息
- 系统关系变化消息 2. 好感度变化不再插入聊天消息流。
2. 系统关系变化消息必须和普通消息共用同一消息流容器 3. 消息区不额外追加“关系升温 / 关系转冷”类文字提示
3. 系统关系变化消息视觉上应与玩家/NPC 气泡有明确区分。
### 角色形象特效
1. 若本轮好感度提升,必须在当前聊天对象的角色形象上飞出心形正向特效。
2. 正向特效内必须显示本轮增加数值,例如:`+3`
3. 若本轮好感度下降,必须在当前聊天对象的角色形象上飞出负向特效。
4. 负向特效内必须显示本轮减少数值,例如:`-2`
5. 特效应挂载在当前角色形象区域,而不是消息区、选项区或额外弹窗。
6. 同一轮只播一次最近结算结果,不重复插入历史文本。
### 底部按钮区 ### 底部按钮区
@@ -162,7 +170,7 @@
2. 渲染当前消息队列。 2. 渲染当前消息队列。
3. 发送玩家本轮输入。 3. 发送玩家本轮输入。
4. 接收流式事件并实时更新 NPC 当前回复文本。 4. 接收流式事件并实时更新 NPC 当前回复文本。
5. 渲染系统关系变化消息 5. 渲染角色形象上的好感度变化特效
6. 渲染下一轮 `3` 个建议项与自定义输入框。 6. 渲染下一轮 `3` 个建议项与自定义输入框。
前端不负责: 前端不负责:
@@ -209,7 +217,7 @@ type StoryNpcChatState = {
## 8.2 聊天消息结构 ## 8.2 聊天消息结构
消息队列需要支持系统消息: 消息队列继续支持系统消息,用于战斗结算、流程收束等非好感提示
```ts ```ts
type StoryDialogueTurn = { type StoryDialogueTurn = {
@@ -223,7 +231,24 @@ type StoryDialogueTurn = {
要求: 要求:
1. `system` 只用于关系变化、系统反馈类消息。 1. `system` 只用于关系变化、系统反馈类消息。
2. `affinityDelta` 仅在关系变化消息中写入 2. `affinityDelta` 不再用于向聊天消息流插入好感提示
新增聊天态特效事件:
```ts
type StoryNpcAffinityEffect = {
eventId: string;
npcId: string;
delta: number;
};
```
要求:
1. 仅在本轮聊天真实发生好感变化时写入。
2. `delta > 0` 表示正向心形特效。
3. `delta < 0` 表示负向减少特效。
4. 该事件只负责驱动角色形象表现,不负责生成消息文本。
## 8.3 单轮接口契约 ## 8.3 单轮接口契约

View File

@@ -24,6 +24,10 @@
**每个场景由创作者在工具中配置为 `2~5` 幕;每一幕都绑定独立背景图和相遇 NPC 顺序;每一幕的第一个 NPC 视为主角色;运行时按幕切换背景和可遇对象,并根据主角色当前好感度裁决聊天轮数与第 5 轮收束方式。** **每个场景由创作者在工具中配置为 `2~5` 幕;每一幕都绑定独立背景图和相遇 NPC 顺序;每一幕的第一个 NPC 视为主角色;运行时按幕切换背景和可遇对象,并根据主角色当前好感度裁决聊天轮数与第 5 轮收束方式。**
本次还追加一条必须和草稿生成阶段一起落地的约束:
**Agent 在生成第一版世界草稿时,默认只生成 `1` 个可扮演角色、`2` 个场景章节、每个场景章节固定 `3` 幕、`5~10` 个场景角色;并且要在草稿生成过程中基于底层剧情引擎判定每一幕该由哪些角色出演、背景应该是什么样,再自动生成每幕背景图和每个角色的主形象。动作资产本期不生成。**
补充口径修正: 补充口径修正:
1. `scene_chapter` 在本期继续保留为数据层 / 编译层 / 运行时层概念。 1. `scene_chapter` 在本期继续保留为数据层 / 编译层 / 运行时层概念。
@@ -32,8 +36,10 @@
4. 每一幕的 NPC 配置区必须直接叠在当前幕背景预览上,以“对面角色站位”的方式呈现;三个站位既是预览,也是编辑入口。 4. 每一幕的 NPC 配置区必须直接叠在当前幕背景预览上,以“对面角色站位”的方式呈现;三个站位既是预览,也是编辑入口。
5. 幕编辑站位中每个角色只显示角色形象与名称,不展示额外信息块、规则说明或说明性标签。 5. 幕编辑站位中每个角色只显示角色形象与名称,不展示额外信息块、规则说明或说明性标签。
6. 幕内小预览的构图固定为左侧玩家、右侧当前幕角色;右侧三个站位采用一前两后。 6. 幕内小预览的构图固定为左侧玩家、右侧当前幕角色;右侧三个站位采用一前两后。
前排主角色的 y 轴必须与玩家角色对齐后排两个角色必须同一列、x 轴对齐,上下分布,且后排整体的 y 轴中点与前排主角色保持一致。
7. 新建幕默认仅预置 1 个主角色槽位内容,其余槽位留空,等待创作者补充。 7. 新建幕默认仅预置 1 个主角色槽位内容,其余槽位留空,等待创作者补充。
8. 角色名称显示在角色形象上方,角色渲染不附带方形 UI 底板。 8. 角色名称显示在角色形象上方,角色渲染不附带方形 UI 底板。
9. 世界档案的场景详情页不再单独展示“场景图片”和“场景内 NPC”字段相关兼容数据统一由多幕配置自动同步回场景对象。
这份文档必须能直接指导后续创作工具和游戏流程改造,避免需求落地漂移。 这份文档必须能直接指导后续创作工具和游戏流程改造,避免需求落地漂移。
@@ -59,6 +65,21 @@
8. 好感度小于 `0` 的主角色,在相遇后最多只允许聊天 `5` 轮,第 `5` 轮必须输出一段为后续剧情开展铺垫的收束回应。 8. 好感度小于 `0` 的主角色,在相遇后最多只允许聊天 `5` 轮,第 `5` 轮必须输出一段为后续剧情开展铺垫的收束回应。
9. 前端继续只负责展示,幕切换、聊天限制、幕进度与数据裁决全部由 Express 后端负责。 9. 前端继续只负责展示,幕切换、聊天限制、幕进度与数据裁决全部由 Express 后端负责。
10. 默认复用现有创作页面、草稿抽屉、详情弹层、场景章节和聊天流程,不新开独立系统或新页面。 10. 默认复用现有创作页面、草稿抽屉、详情弹层、场景章节和聊天流程,不新开独立系统或新页面。
11. 第一版世界草稿默认规模必须收束为:
- `1` 个可扮演角色
- `2` 个场景章节
- 每个场景章节固定 `3`
- `5~10` 个场景角色
12. 草稿生成阶段必须由后端基于底层剧情引擎直接判定每一幕的:
- 出演角色顺序
- 主角色
- 幕目标
- 幕背景语义
13. 草稿生成完成时,系统必须自动产出:
- 每一幕对应的背景图
- 每个场景角色的主形象
- 可扮演角色的主形象
14. 本期不生成动作、不生成动作预览、不生成动作发布资产。
--- ---
@@ -74,6 +95,7 @@
6. 不把“规则说明文案”默认堆到创作页或游戏 UI 面板里。 6. 不把“规则说明文案”默认堆到创作页或游戏 UI 面板里。
7. 不把“点击配置”实现成在当前卡片下面继续展开大段内容。 7. 不把“点击配置”实现成在当前卡片下面继续展开大段内容。
8. 不重写现有高好感委托链路,只在本次规则下明确它什么时候还能触发。 8. 不重写现有高好感委托链路,只在本次规则下明确它什么时候还能触发。
9. 不在草稿生成阶段默认补动作、待机、攻击、跑动或技能动作素材。
--- ---
@@ -177,6 +199,124 @@
3. 主角色承担该幕默认的首次相遇、聊天轮数裁决和幕推进优先级。 3. 主角色承担该幕默认的首次相遇、聊天轮数裁决和幕推进优先级。
4. 其余 NPC 视为辅助相遇角色,不直接承担本次“好感度聊天轮数规则”。 4. 其余 NPC 视为辅助相遇角色,不直接承担本次“好感度聊天轮数规则”。
## 5.4 开局场景与普通场景的统一规则
本次新增一条必须落实到代码与数据结构的约束:
**开局场景不是一套特殊场景系统,只是“玩家开局所处的那一个场景”。**
因此,开局场景在创作工具、数据结构、保存链路、运行时编译上,都必须与普通场景保持同一套配置参数。
明确要求如下:
1. 开局场景允许配置的字段必须与普通场景一致,至少包括:
- `name`
- `description`
- `dangerLevel`
- `imageSrc`
- `sceneNpcIds`
- `connections`
- `sceneChapterBlueprints` 对应的多幕配置
2. 场景配置面板中,开局场景必须复用普通场景同级的配置 UI而不是继续保留一套缩水版表单。
3. 开局场景与普通场景的唯一产品差异,只能是:
- 它是玩家进入世界时默认所在的初始场景
- 它在列表或运行时可带“开局场景 / 初始场景”语义标记
4. 除“初始所在场景”语义之外,不允许再因为它是开局场景而裁掉 NPC、连接、多幕、危险度等配置能力。
5. 为兼容现有数据,当前 `camp` 字段可以继续保留,但其承载的结构必须与普通场景对齐,不能再是阉割版场景结构。
6. 运行时编译时,开局场景也必须按普通场景规则参与:
- 场景 NPC 池编译
- 场景连接编译
- 多幕蓝图读取
- 场景图片 / 残痕 / 预览数据生成
一句话约束:
**“开局场景”是场景身份,不是场景能力分支。**
## 5.5 草稿默认规模与自动资产策略
为了让“生成游戏设定草稿”真正变成一个可直接进入精修的起点,而不是一份需要继续手动补骨架的半成品,本次新增下面这些硬约束:
### 5.5.1 第一版草稿固定规模
第一版 Agent 世界草稿必须默认产出:
1. `1` 个可扮演角色
2. `5~10` 个场景角色
3. `2` 个场景章节
4. 每个场景章节固定 `3`
这里不再沿用旧的“多 playable / 多 landmarks 先铺开”的策略。
原因:
1. 当前创作工作区已经进入“先收关键锚点、再逐步扩写”的阶段。
2. 一次铺太多 playable、场景和长尾对象会稀释创作者对第一版底稿的掌控感。
3. 本期还要把幕级背景图和角色主形象自动挂回草稿,如果对象规模不收束,等待时间和生成成本都会直接失控。
### 5.5.2 幕级出演角色与背景必须由剧情引擎判定
这次不允许继续使用“先生场景,再把同一组 sceneNpcIds 平铺复制到所有幕里”的宽松策略。
后端在生成 `scene chapter -> act` 时,必须基于底层剧情引擎已有结构综合裁定:
1. `storyGraph.visibleThreads / hiddenThreads`
2. 角色 `narrativeProfile / threadIds`
3. 地点 `linkedLandmarkIds / linkedThreadIds`
4. 当前场景章节的 `summary / actGoal / transitionHook`
最少要做出下面这几个结论:
1. 这一幕优先让哪些角色出场
2. 谁是该幕主角色
3. 这一幕的压力核心是什么
4. 这一幕的背景图应该突出什么空间氛围、危险感和叙事残痕
一句话要求:
**每一幕的演员和背景,不是静态复制,而是“线程压力 + 角色挂钩 + 地点语义”联合裁定的结果。**
### 5.5.3 自动生成的资产范围
第一版草稿生成成功后,后端必须自动继续生成并写回:
1. 每一幕的背景图
2. 每个场景角色的主形象
3. 可扮演角色的主形象
本期明确不做:
1. 不自动生成动作
2. 不自动生成精灵表
3. 不自动生成技能动作
4. 不自动生成 run / attack / hurt / die
也就是说,本期资产策略是:
**只产主形象和幕背景,不产动作。**
### 5.5.4 自动资产生成的回写要求
自动资产生成后,草稿层必须直接带回:
1. 角色:
- `imageSrc`
- `generatedVisualAssetId`
2. 场景幕:
- `backgroundImageSrc`
- `backgroundAssetId`
3. 资产覆盖摘要:
- 角色主形象是否就绪
- 场景幕背景是否就绪
这样创作者一进入草稿精修工作区,就能直接看到:
1. 角色已经带主形象
2. 每个场景章节的每一幕已经带背景图
3. 当前草稿哪些资产还缺失
而不是先看到一堆空白占位,再手工逐个点生成。
--- ---
## 6. 数据结构要求 ## 6. 数据结构要求
@@ -353,10 +493,14 @@ type NpcChatTurnResult = {
场景编辑弹层至少展示: 场景编辑弹层至少展示:
1. 场景名称与描述 1. 场景名称与描述
2. 场景主图 2. 多幕配置区块
3. 场景内 NPC 3. 场景连接关系
4. 多幕配置区块
5. 场景连接关系 补充约束:
1. “场景图片”不再作为场景详情页里的独立字段展示,创作者只能通过每一幕的“配置背景”入口管理视觉。
2. “场景内 NPC”不再作为场景详情页里的独立字段展示创作者只能通过每一幕角色槽位配置相遇 NPC。
3. 为兼容现有运行时与旧数据结构,场景对象上的 `imageSrc / sceneNpcIds` 仍然保留,但必须由多幕配置自动回填,前台不再暴露单独编辑控件。
多幕区块至少展示: 多幕区块至少展示:
@@ -573,6 +717,10 @@ interface SceneActRuntimeState {
- 自定义输入框隐藏 - 自定义输入框隐藏
- 当前聊天态结束 - 当前聊天态结束
- 恢复普通冒险态或进入后续 action 选择 - 恢复普通冒险态或进入后续 action 选择
7. 如果玩家在当前幕主角色的本地战斗中取胜NPC 会重新开启一段战后聊天:
- 这段聊天必须把刚刚那场交锋的结果摘要与关键战斗日志带入上下文
- 如果该主角色此时好感仍然 `< 0`,则战后聊天依然只允许 `5` 轮,并从战后这次重新开启时重新计数
-`5` 轮结束后 NPC 离开当前对话态,玩家可以继续承接后续 action并在满足推进条件时前往下一幕
## 9.5 第 5 轮的“铺垫”定义 ## 9.5 第 5 轮的“铺垫”定义

View File

@@ -0,0 +1,412 @@
# TXT 模式核心玩法 PRD2026-04-20
更新时间:`2026-04-20`
## 0. 文档目的
这份 PRD 只定义 `Interactive-fiction-frontend` + `Interactive-fiction-backend` 中 TXT 模式在 `Genarrative` 落地时的**核心玩法闭环**。
这次明确**不讨论平台层功能**,包括但不限于:
1. 平台首页
2. 平台详情页
3. 平台广场
4. 平台作品库
5. 平台浏览历史
6. 平台钱包与扣费落地
7. 平台统一存档页
8. 平台账号与公开态策略
本稿只关心一件事:
**把外部仓库 TXT 模式最小可玩的创作与运行闭环原样迁入,形成一个可以独立验证的视觉小说核心玩法。**
---
## 1. 一句话定义
TXT 模式核心玩法是一个包含“创作编辑器 -> 测试体验 -> 正式运行 -> 多槽位存档 -> 历史重生成”的视觉小说玩法闭环。
---
## 2. 本次目标
本次只做下面这些核心目标:
1. 支持创建 TXT 模式作品。
2. 支持 TXT 模式作品的完整创作流程。
3. 支持创作者测试体验。
4. 支持玩家正式游玩。
5. 支持文本模式运行。
6. 支持双会话机制。
7. 支持 5 槽位存档。
8. 支持通过 `saveId` 读档创建会话。
9. 支持历史记录查看。
10. 支持历史重生成。
11. 保留外部 TXT 模式提示词正文与功能需求,不改词、不改规则。
12. 仅替换文本生成接口与生图接口为本项目现有能力。
---
## 3. 明确不做
本次 PRD 明确不做下面这些事:
1. 不做平台首页融合。
2. 不做平台详情页融合。
3. 不做平台广场、作品库、公开浏览。
4. 不做平台浏览历史。
5. 不做平台统一钱包与扣费实现。
6. 不做平台统一存档页。
7. 不做回放。
8. 不做平台层账户策略改造。
9. 不做其它玩法的统一抽象。
10. 不把 TXT 模式扩展成平台总线工程。
一句话约束:
**先把 TXT 模式本体做成,再谈平台层融合。**
---
## 4. 核心范围
## 4.1 创作链路
本次必须完整保留外部 TXT 模式创作主链:
1. 选择 TXT 模式。
2. 进入创作编辑器。
3. 通过以下方式之一创建底稿:
- 文档上传
- 一句话生成
- 空白创建
4. 编辑以下内容:
- 世界观
- 角色
- 场景
5. 发起测试体验。
6. 完成作品保存。
## 4.2 运行链路
本次必须完整保留外部 TXT 模式运行主链:
1. 新的开始
2. 继续体验
3. 读取存档
4. 进入运行时页面
5. 普通模式 / 文本模式
6. 历史记录
7. 存档管理
8. 设置
9. 属性面板白名单
10. 历史重生成
---
## 5. 核心玩法冻结边界
后续实现时,以下内容必须按外部 TXT 模式原样保留:
1. 编辑器步骤顺序。
2. 双会话机制。
3. 流式动作协议事件:
- `start`
- `raw_text`
- `step`
- `complete`
- `data`
- `error`
- `done`
4. 5 槽位存档。
5. 通过 `saveId` 读档创建会话。
6. 存档载荷中的:
- `stateLite`
- `historyTail`
7. 历史重生成语义。
8. 属性面板白名单。
9. 默认主 prompt 选择语义。
10. prompt 正文。
---
## 6. 创作编辑器需求
## 6.1 创建方式
编辑器必须支持 3 种创建方式:
1. 上传文档
2. 一句话生成
3. 空白创建
三者都必须进入同一套 TXT 模式编辑器,而不是三套分裂流程。
## 6.2 编辑器模块
编辑器至少必须包含以下模块:
1. 世界观编辑
2. 角色编辑
3. 场景编辑
4. 测试体验入口
5. 保存/发布前准备入口
## 6.3 前后端边界
编辑器侧遵守下面这条边界:
1. 前端只负责编辑器表现与输入采集。
2. 作品结构校验、编译、测试会话创建、正式数据写入由后端负责。
---
## 7. 双会话机制
TXT 模式核心玩法必须完整保留双会话机制。
## 7.1 玩家游玩会话
玩家游玩会话用于:
1. 正式开始作品
2. 正式继续体验
3. 正式游玩推进
## 7.2 创作者测试/读档会话
创作者测试/读档会话用于:
1. 编辑器内测试体验
2. 指定存档加载
3. 非正式发布态验证
## 7.3 禁止简化
禁止把这两类会话合并成一类“统一 session”。
---
## 8. 运行时页面需求
## 8.1 页面能力面
运行时页面至少要有:
1. 普通模式
2. 文本模式
3. 历史记录面板
4. 存档面板
5. 设置面板
6. 属性面板
## 8.2 文本模式
文本模式必须按外部 TXT 模式语义保留:
1. 独立于普通模式的显示区域
2. 与运行时主状态同步
3. 可消费流式动作结果
## 8.3 属性面板
属性面板必须保留外部 TXT 模式的白名单语义:
1. 白名单用户显示
2. 非白名单用户不显示
---
## 9. 流式动作协议
TXT 模式核心玩法必须保留外部流式动作协议。
## 9.1 事件类型
服务端必须推送下列事件:
1. `start`
2. `raw_text`
3. `step`
4. `complete`
5. `data`
6. `error`
7. `done`
## 9.2 前端职责
前端只负责:
1. 发起动作请求
2. 解析流式事件
3. 渲染逐步结果
前端不负责:
1. 本地推进正式运行状态
2. 本地拼接替代结果
---
## 10. 存档机制
## 10.1 槽位规则
每个作品最多保留 5 个槽位。
必须支持:
1. 新建存档
2. 覆盖存档
3. 读取指定槽位
## 10.2 存档载荷
存档内容不能只有摘要,至少必须包含:
1. `stateLite`
2. `historyTail`
## 10.3 读档语义
读取存档不是恢复前端本地状态,而是:
1. 传入 `saveId`
2. 由后端创建新的测试/读档会话
3. 前端消费会话结果
---
## 11. 历史机制
## 11.1 历史记录
运行时必须支持查看已有历史记录。
## 11.2 历史重生成
运行时必须支持历史重生成。
其语义必须是:
1. 用户选择某个历史节点
2. 服务端基于该节点上下文重新生成后续
3. 新结果成为新的有效运行轨迹
禁止把它简化成普通“再来一次下一步”。
---
## 12. 提示词与模型调用
## 12.1 Prompt 规则
后续实现时必须遵守:
1. 外部 TXT 模式 prompt 正文不改。
2. 外部 TXT 模式 prompt 规则不改。
3. 默认 prompt 选择语义不改。
## 12.2 允许替换的部分
只允许替换下面两项底层能力:
1. 文本生成接口
- 替换为 `server-node/src/services/llmClient.ts`
2. 生图接口
- 替换为 `server-node/src/services/sceneImageService.ts`
除此之外,不允许借“接入本项目能力”之名修改玩法需求。
---
## 13. 核心后端职责
TXT 模式核心玩法的正式运行真相必须在后端。
后端至少负责:
1. 会话创建
2. prompt 装载与上下文拼接
3. 流式动作生成
4. 存档读写
5. `saveId` 读档会话创建
6. 历史重生成
7. 属性白名单裁决
---
## 14. 核心前端职责
前端只负责:
1. 编辑器页面表现
2. 运行时页面表现
3. 文本模式显示
4. 流式事件解析
5. 历史/存档/设置面板打开与关闭
前端不负责:
1. 正式运行时结算
2. 会话语义判定
3. 存档内容拼接
4. 历史重生成裁决
---
## 15. 建议影响文件
本次核心玩法落地,优先会影响下面这些区域:
前端:
1. `src/components/game-shell/PlatformCreationTypeModal.tsx`
2. `src/services/aiService.ts`
3. 新增 TXT 模式编辑器页面或模块
4. 新增 TXT 模式运行时页面或模块
5. 新增 TXT 模式 SSE 解析模块
后端:
1. `server-node/src/routes/runtimeRoutes.ts`
2. `server-node/src/services/llmClient.ts`
3. `server-node/src/services/sceneImageService.ts`
4. `server-node/src/repositories/runtimeRepository.ts`
5. `server-node/src/prompts/`
6. 新增 TXT 模式 services / contracts / repository modules
---
## 16. 验收标准
满足下面这些结果时,视为核心玩法闭环成立:
1. 可以创建 TXT 模式作品。
2. 可以通过上传文档、一句话生成、空白创建进入同一编辑器。
3. 编辑器可编辑世界观、角色、场景。
4. 编辑器内可发起测试体验。
5. 可创建正式游玩会话。
6. 可创建测试/读档会话。
7. 运行时支持普通模式与文本模式。
8. 流式动作协议按外部事件名工作。
9. 可查看历史记录。
10. 可执行历史重生成。
11. 支持 5 槽位存档。
12. 支持通过 `saveId` 读档创建会话。
13. 存档包含 `stateLite + historyTail`
14. 属性面板白名单逻辑生效。
15. prompt 正文与功能需求未被改写。
---
## 17. 本稿结论
这次先不要把 TXT 模式做成平台工程。
先把下面这条链做通:
**创建 TXT 作品 -> 编辑世界/角色/场景 -> 测试体验 -> 正式游玩 -> 文本模式 -> 多槽位存档 -> 历史重生成**
只要这条链通了TXT 模式核心玩法就成立;平台层融合、详情页整合、钱包接入、统一存档页,都可以放到下一阶段再做。

View File

@@ -59,7 +59,7 @@
| `server-node/src/prompts/customWorldEntityPrompts.ts` | 世界编辑器实体生成 | `CUSTOM_WORLD_ENTITY_GENERATOR_SYSTEM_PROMPT``buildPlayablePrompt``buildStoryPrompt``buildLandmarkPrompt` | | `server-node/src/prompts/customWorldEntityPrompts.ts` | 世界编辑器实体生成 | `CUSTOM_WORLD_ENTITY_GENERATOR_SYSTEM_PROMPT``buildPlayablePrompt``buildStoryPrompt``buildLandmarkPrompt` |
| `server-node/src/prompts/customWorldSceneNpcPrompts.ts` | 世界编辑器场景 NPC | `CUSTOM_WORLD_SCENE_NPC_SYSTEM_PROMPT``buildCustomWorldSceneNpcPrompt` | | `server-node/src/prompts/customWorldSceneNpcPrompts.ts` | 世界编辑器场景 NPC | `CUSTOM_WORLD_SCENE_NPC_SYSTEM_PROMPT``buildCustomWorldSceneNpcPrompt` |
| `server-node/src/prompts/eightAnchorPrompts.ts` | 八锚点共创 | `BASE_SYSTEM_PROMPT``GLOBAL_HARD_RULES``MODE_RULES``USER_SIGNAL_RULES``buildPromptDynamicStateInferencePrompt``buildEightAnchorSingleTurnPrompt` | | `server-node/src/prompts/eightAnchorPrompts.ts` | 八锚点共创 | `BASE_SYSTEM_PROMPT``GLOBAL_HARD_RULES``MODE_RULES``USER_SIGNAL_RULES``buildPromptDynamicStateInferencePrompt``buildEightAnchorSingleTurnPrompt` |
| `server-node/src/prompts/characterAssetPrompts.ts` | 角色形象 / 动作资产生成 | `CHARACTER_PROMPT_BUNDLE_SYSTEM_PROMPT``buildFallbackCharacterPromptBundle``buildCharacterPromptBundleUserPrompt``buildNpcVisualPrompt``buildNpcAnimationPrompt``buildArkCharacterAnimationPrompt` | | `server-node/src/prompts/characterAssetPrompts.ts` | 角色形象 / 动作资产生成 | `buildNpcVisualPrompt``buildNpcAnimationPrompt``buildArkCharacterAnimationPrompt``buildImageSequencePrompt``buildNpcVisualNegativePrompt` |
### 3.2 前端 ### 3.2 前端
@@ -72,7 +72,7 @@
| `src/prompts/customWorldPrompts.ts` | 自定义世界分阶段生成 + 场景背景图 | 多个 `buildCustomWorld*Prompt``DEFAULT_CUSTOM_WORLD_SCENE_IMAGE_NEGATIVE_PROMPT` | | `src/prompts/customWorldPrompts.ts` | 自定义世界分阶段生成 + 场景背景图 | 多个 `buildCustomWorld*Prompt``DEFAULT_CUSTOM_WORLD_SCENE_IMAGE_NEGATIVE_PROMPT` |
| `src/prompts/customWorldOrchestratorPrompts.ts` | 世界 JSON 修复 / JSON only | `CUSTOM_WORLD_JSON_REPAIR_SYSTEM_PROMPT``CUSTOM_WORLD_GENERATION_JSON_ONLY_SYSTEM_PROMPT` | | `src/prompts/customWorldOrchestratorPrompts.ts` | 世界 JSON 修复 / JSON only | `CUSTOM_WORLD_JSON_REPAIR_SYSTEM_PROMPT``CUSTOM_WORLD_GENERATION_JSON_ONLY_SYSTEM_PROMPT` |
| `src/prompts/storyOrchestratorPrompts.ts` | 剧情中文修复 | `STORY_LANGUAGE_REPAIR_SYSTEM_PROMPT` | | `src/prompts/storyOrchestratorPrompts.ts` | 剧情中文修复 | `STORY_LANGUAGE_REPAIR_SYSTEM_PROMPT` |
| `src/prompts/customWorldRolePromptDefaults.ts` | 角色资产工作台默认词 | `buildDefaultRolePromptBundle` | | `src/prompts/customWorldRolePromptDefaults.ts` | 角色资产工作台默认词唯一主源 | `buildDefaultRolePromptBundle` |
| `src/prompts/customWorldEntityActionPrompts.ts` | 编辑器技能动作词 | `buildSkillActionPrompt` | | `src/prompts/customWorldEntityActionPrompts.ts` | 编辑器技能动作词 | `buildSkillActionPrompt` |
| `src/prompts/qwenSpriteSheetToolPrompts.ts` | Qwen 精灵图工具 prompt 模型 | 主 prompt / sheet prompt / repair prompt / negative prompt 系列 | | `src/prompts/qwenSpriteSheetToolPrompts.ts` | Qwen 精灵图工具 prompt 模型 | 主 prompt / sheet prompt / repair prompt / negative prompt 系列 |

View File

@@ -0,0 +1,174 @@
# 世界草稿自动资产可见性修复说明 2026-04-20
更新时间:`2026-04-20`
## 1. 问题现象
在世界草稿生成完成后,用户反馈:
1. 草稿里看不到角色主形象
2. 场景里看不到每一幕的背景图
这类反馈容易被误判成“自动资产没有生成”,但实际排查后发现,问题主要集中在**结果页展示链路**,同时叠加了一个**fallback 资源不可预览**的问题。
## 2. 链路排查结论
本轮检查后确认:
1. 服务端自动资产服务会把角色主形象写回 `draftProfile.playableNpcs[].imageSrc / generatedVisualAssetId`
2. 服务端自动资产服务会把幕背景图写回 `draftProfile.sceneChapters[].acts[].backgroundImageSrc / backgroundAssetId`
3. `agent draft -> result profile` 的适配层也会保留这些字段
真正的问题出在后续两个环节。
## 3. 根因
### 3.1 结果页可扮演角色卡优先用了运行时预览
结果页 `CustomWorldEntityCatalog` 的可扮演角色卡,之前优先显示:
1. `previewCharacter`
2. 再回退到 `role.imageSrc`
这会导致:
1. 草稿里已经有真实生成主图
2. 但界面仍优先渲染模板/运行时预览角色
3. 用户视觉上看不到最新生成主形象
### 3.2 场景页没有把多幕背景图真正展示出来
结果页 `场景` Tab 之前只展示:
1. 开局场景
2. 地点卡
但没有把:
`sceneChapterBlueprints[].acts[].backgroundImageSrc`
按可见结构渲染到结果页中。
因此即使后端已经生成并回写每一幕背景图,用户仍然只能看到“场景主图/地点图”,看不到“每一幕的图”。
### 3.3 fallback 自动资产写回的是 `.txt`
在没有 DashScope 图像能力时,`CustomWorldAgentAutoAssetService` 的 fallback 生成器之前会写:
1. 角色主形象:`master.txt`
2. 幕背景图:`scene.txt`
这虽然保证了字段被回写,但前端无法把 `.txt` 当图片展示,于是会进一步加重“好像没生成”的感知。
### 3.4 Agent 结果页入口优先读取 legacyResultProfile遮蔽了最新资产字段
世界草稿结果页不是直接读取当前 `draftProfile`,而是先经过:
1. `buildCustomWorldProfileFromAgentDraft`
2. `normalizeCustomWorldProfileRecord`
如果 `draftProfile.legacyResultProfile` 存在,旧逻辑会直接优先返回这份历史编译结果。
但自动资产服务在 Phase3/Phase4 后续补齐时,更新的是当前 `draftProfile` 中的:
1. `playableNpcs[].imageSrc / generatedVisualAssetId`
2. `storyNpcs[].imageSrc / generatedVisualAssetId`
3. `landmarks[].imageSrc`
4. `sceneChapters[].acts[].backgroundImageSrc / backgroundAssetId`
这会导致:
1. 服务端真实已经生成并回写了最新角色主图和分幕图
2. 结果页入口却仍然取到一份更早的 `legacyResultProfile`
3. 页面看到的是“旧草稿快照”,不是“当前带资产的草稿结果”
因此用户会表现为“完全看不到这轮刚生成出来的图片”。
## 4. 修复策略
### 4.1 结果页角色卡优先显示真实生成主图
`src/components/CustomWorldEntityCatalog.tsx` 中调整逻辑:
1.`role.imageSrc` 已存在,则优先显示该图片
2. 只有在缺失真实主图时,才回退到运行时角色预览
这样可扮演角色卡能直接展示当前草稿回写的角色主形象。
### 4.2 场景列表改为只展示场景卡,章节内容留在二级页
结合后续体验反馈,本轮又进一步收口了结果页结构:
1. `结果页 -> 场景列表` 不再直接展开章节与分幕内容
2. 场景列表卡片只负责展示:
- 场景名
- 场景摘要
- 场景图
3. 场景卡图片优先取该场景章节的首幕 `backgroundImageSrc`
4. 若首幕图缺失,再回退到场景主图 / 地标图
5. 章节标题、幕标题、幕目标等信息只在点击场景后的二级编辑页中查看
这样结果页列表保持清爽,但用户仍然能在列表里直接看到当前场景已生成的图片。
### 4.3 fallback 改为可显示 PNG
`server-node/src/services/customWorldAgentAutoAssetService.ts` 中调整 fallback
1. 不再写 `master.txt / scene.txt`
2. 改为写合法可显示的占位 `png`
3. prompt 信息单独写进 `manifest.json`
4. 角色主形象 fallback PNG 统一输出为 `1:1`
这样即使当前环境没有真实图像生成能力,草稿层也仍然会回写“前端能直接显示的图片资源”。
### 4.4 结果页读取 legacy profile 时强制合并当前草稿的最新资产字段
`src/services/customWorldAgentDraftResult.ts` 中补上合并逻辑:
1. 若存在 `legacyResultProfile`,继续保留它的完整运行时字段
2. 但会把当前 `draftProfile` 里最新回写的角色主图、地标图、分幕图再覆盖回结果页 profile
3. 这样结果页既不会丢失旧 runtime profile 的完整结构,也不会再被旧快照遮蔽最新图片资产
这一层修的是结果页真实入口,而不是仅修展示组件。
## 5. 影响范围
本次修复涉及:
1. `src/components/CustomWorldEntityCatalog.tsx`
2. `src/components/CustomWorldResultView.test.tsx`
3. `src/services/customWorldAgentDraftResult.test.ts`
4. `server-node/src/services/customWorldAgentAutoAssetService.ts`
5. `server-node/src/services/customWorldAgentAutoAssetService.test.ts`
6. `docs/technical/CUSTOM_WORLD_AUTO_ASSET_VISIBILITY_FIX_2026-04-20.md`
7. 历史 saved profile 资产同步脚本 / 数据修复动作
## 6. 验收标准
修复后需要满足:
1. 世界草稿结果页的可扮演角色卡能直接看到生成主形象
2. 世界草稿结果页的场景列表能直接看到场景图片,且优先展示首幕背景图
3. 场景章节与分幕内容只在场景二级页中展示
3. `agent draft -> result profile` 不会丢失角色主图与幕背景字段
4. fallback 环境下回写的仍是前端可显示图片,而不是文本文件
5. 角色主形象 fallback PNG 尺寸必须满足 `1:1`
6. 即使存在 `legacyResultProfile`,结果页也必须展示当前草稿最新同步的角色主图与幕背景图
## 6.1 历史保存档案补充结论
本轮在真实 PostgreSQL 数据中又确认了一类历史问题:
1. `agent session` 中的草稿资产字段可能已经补齐
2. 但较早时刻自动保存过的 `custom_world_profiles.payload_json` 仍停留在旧路径
3. 用户如果从作品库打开的是 saved profile就会继续看到旧图或空图
因此这次修复除了改默认生成与展示逻辑,还需要对受影响的历史 saved profile 做一次同步刷新。
## 7. 后续建议
后续继续迭代这条链路时,建议保持:
1. “资产已生成”必须和“用户已看见”同时验证,不能只验证字段回写
2. 结果页与草稿工作区都要把多幕背景视为正式资产,不要只停留在编辑弹层里
3. 所有 fallback 资源都应保持为 UI 可直接消费的媒体格式

View File

@@ -0,0 +1,149 @@
# 世界草稿生成失败与等待页卡住问题分析 2026-04-20
更新时间:`2026-04-20`
## 1. 问题背景
本次问题表现为:
1. 世界草稿生成过程中实际已经失败。
2. 前端等待页仍然停留在“编译草稿卡”步骤。
3. 用户感知为“卡住不动”,而不是“这一轮失败了,可以返回或重试”。
这个问题不是单点 bug而是由两类问题叠加造成
1. 前端进度映射把 `failed + progress=100` 误解释成“已经跑到最后一个步骤附近”,导致视觉上像卡在 `编译草稿卡`
2. 服务端 `draft_foundation` 主链把自动资产补齐也视为硬依赖,一旦角色主形象或场景幕背景图失败,就会把整版世界底稿一起打成失败。
## 2. 本次链路梳理结论
当前世界草稿生成主链路为:
```text
foundation_review
-> draft_foundation action
-> foundation draft service 生成世界底稿结构
-> auto asset service 补角色主图与幕背景图
-> draft compiler 编译 draftCards
-> session 进入 object_refining
-> 前端等待页切到结果或回工作区
```
其中真正的“必须成功”主链只有两段:
1. `foundation draft` 结构生成成功。
2. `draftCards` 编译成功。
角色主图与幕背景图属于增强链路,不应该阻断世界底稿首版落地。
## 3. 根因拆解
### 3.1 前端等待页状态映射问题
`src/services/customWorldAgentGenerationProgress.ts` 之前只对以下情况做了显式处理:
1. `completed`
2. 匹配某个 `phaseLabel`
3. 兜底按 `progress` 推断当前步骤
但没有对 `failed` 做单独分支。
于是当后端返回:
```text
status = failed
progress = 100
phaseLabel = 底稿生成失败
```
前端仍会按 `progress=100` 去推断步骤,结果高概率落在末尾附近,视觉上就像卡在:
```text
编译草稿卡
```
### 3.2 服务端把增强链路当成硬依赖
`server-node/src/services/customWorldAgentOrchestrator.ts` 中的 `processDraftFoundationOperation` 会在世界底稿生成后调用:
```ts
autoAssetService.populateDraftAssets(...)
```
`populateDraftAssets` 之前的行为是:
1. 角色主图生成失败直接 throw
2. 场景幕背景图生成失败直接 throw
于是自动资产失败会直接中断后续:
```text
draft compiler
session replaceDerivedState
checkpoint
assistant summary
operation completed
```
这会把“本来已经可以用的世界底稿”一并拖死。
## 4. 修复策略
### 4.1 前端修复
`src/services/customWorldAgentGenerationProgress.ts` 中新增显式失败步骤:
1. `failed` 状态不再走 `progress` 推断。
2. 失败时固定返回 `phaseId = failed`
3. 等待页保留已有步骤清单,但不再假装仍有某一步处于 active。
修复后,等待页会明确展示:
1. 当前状态已失败
2. 失败文案与失败详情
3. 用户可以返回工作区或重新生成
### 4.2 服务端修复
`server-node/src/services/customWorldAgentAutoAssetService.ts` 中把自动资产改成“尽力而为”:
1. 角色主形象生成失败时不再 throw而是记录 warning。
2. 幕背景图生成失败时不再 throw而是记录 warning。
3. 主链继续编译 `draftCards`,并让 `assetCoverage` 明确标记哪些资产仍缺失。
`server-node/src/services/customWorldAgentOrchestrator.ts` 中:
1. 如果草稿主链成功,只是资产补齐未完成,则 operation 仍记为 `completed`
2. `phaseDetail` 增加“有若干项资产补齐待后续处理”的说明。
3. assistant summary 也同步说明“这不影响继续精修世界底稿”。
4. 真正失败时,优先保留当前失败阶段的 `phaseLabel/phaseDetail`,避免统一抹平成模糊的“底稿生成失败”。
## 5. 影响范围
本次修改影响以下模块:
1. `src/services/customWorldAgentGenerationProgress.ts`
2. `src/services/customWorldAgentGenerationProgress.test.ts`
3. `server-node/src/services/customWorldAgentAutoAssetService.ts`
4. `server-node/src/services/customWorldAgentAutoAssetService.test.ts`
5. `server-node/src/services/customWorldAgentOrchestrator.ts`
6. `server-node/src/services/customWorldAgentPhase3.test.ts`
## 6. 验收标准
修复后需要满足:
1. 世界草稿主链失败时,等待页明确显示失败,而不是视觉上卡在 `编译草稿卡`
2. 自动资产生成失败时,世界底稿和草稿卡仍然要能生成完成。
3. session 能进入 `object_refining`,用户可以先继续精修结构内容。
4. `assetCoverage` 能反映未补齐的角色图/场景图缺口。
5. 助手消息和 operation 文案都能把“主链完成,增强链路待补”表达清楚。
## 7. 后续建议
后续若继续增强这条链路,建议保持以下原则:
1. 世界结构生成、草稿卡编译属于主链,必须最稳。
2. 角色图、动作、场景图、长尾补齐都属于增强链路,应允许降级。
3. 所有等待页都要有显式失败态映射,禁止仅靠 `progress` 推断最终阶段。
4. 操作失败时优先保留最后一个真实阶段标签,方便定位到底是结构生成失败、资产生成失败,还是写回失败。

View File

@@ -0,0 +1,115 @@
# 自定义世界 Phase4 数量字段语义对齐说明 2026-04-20
更新时间:`2026-04-20`
## 1. 背景
在排查世界草稿生成等待页问题后,继续执行 `server-node` 相关测试时,发现 Phase4 仍有两处失败:
1. `generate_characters` 后草稿作品卡的角色数量没有按预期增长。
2. `generate_landmarks` 的 HTTP 用例对地点数量使用了过时的固定基线。
这两个问题本质上都和“数量字段语义不一致”有关。
## 2. 当前产品语义
创作中心草稿卡在前端展示的是:
```text
角色 X
地点 Y
```
这里的“角色”从产品感知上表示:
**当前草稿中已经长出来、可继续精修的全部角色对象数量。**
而不是:
**仅 playable / 仅主角位角色数量。**
对应地,“地点”表示:
**当前草稿中已经存在的地点对象数量。**
## 3. 之前的偏差
### 3.1 角色数量偏差
`server-node/src/services/customWorldWorkSummaryService.ts` 在草稿态里原先只统计:
```ts
draftProfile.playableNpcs.length
```
但 Phase4 `generate_characters` 的实现是把新增角色插入:
```ts
draftProfile.storyNpcs
```
所以会出现:
1. 角色卡确实新增了
2. `storyNpcs` 也确实变多了
3. 创作中心草稿卡上的“角色数”却没有同步增加
这会让产品表现和数据真实状态不一致。
### 3.2 地点数量断言偏差
`server-node/src/app.test.ts``generate_landmarks` HTTP 用例里,之前写死了:
```ts
assert.ok((sessionPayload.draftProfile?.landmarks?.length ?? 0) >= 6);
```
但当前基础草稿阶段的地点基线已经调整为:
```ts
FOUNDATION_DRAFT_LANDMARK_COUNT = 2
```
所以 Phase4 的正确断言应该是:
```text
在当前会话已有地点基线上,再新增 2 个地点
```
而不是继续沿用旧版本里“基础草稿默认至少 4 个地点”的固定假设。
## 4. 本次修正
### 4.1 草稿摘要角色数
草稿态 `playableNpcCount` 在工作摘要里继续沿用既有字段名,但统计语义调整为:
```text
全部草稿角色数量 = playableNpcs + storyNpcs 去重后的总数
```
原因:
1. 前端现有 contract 和展示字段已经复用 `playableNpcCount`
2. 这次目标是最小修复,不额外扩 contract
3. 草稿态 UI 标签本身展示的是“角色”,不是“可扮演角色”
因此这次保留字段名,修正其在草稿态的统计语义。
### 4.2 Phase4 地点断言
`generate_landmarks` 的 HTTP 用例改为基于当前会话的 `draftProfile.landmarks.length` 做增量校验:
```text
新增后数量 >= 基线数量 + 2
```
这样可以避免未来基础草稿默认地点数再次调整时Phase4 用例继续被写死基线误伤。
## 5. 约束建议
后续涉及草稿作品卡数量字段时,统一遵守:
1. 草稿态“角色”展示全部草稿角色数,不只统计 playable。
2. 已发布态如果 UI 明确写“可扮演角色”,再单独按 playable 统计。
3. 所有数量断言优先使用“当前基线 + 增量”的写法,不要硬编码旧阶段默认数量。

View File

@@ -77,7 +77,7 @@ src/prompts/
- `customWorldSceneNpcPrompts.ts` - `customWorldSceneNpcPrompts.ts`
- 世界编辑器场景 NPC 生成 prompt - 世界编辑器场景 NPC 生成 prompt
- `characterAssetPrompts.ts` - `characterAssetPrompts.ts`
- 角色主图 / 动作试片 / 角色关联场景 prompt - 角色主图 / 动作试片正式生成 prompt
- `eightAnchorPrompts.ts` - `eightAnchorPrompts.ts`
- 八锚点状态推断、模式规则与正式单轮共创 prompt - 八锚点状态推断、模式规则与正式单轮共创 prompt
- `src/prompts/customWorldPrompts.ts` - `src/prompts/customWorldPrompts.ts`
@@ -85,7 +85,7 @@ src/prompts/
- `src/prompts/qwenSpriteSheetToolPrompts.ts` - `src/prompts/qwenSpriteSheetToolPrompts.ts`
- 精灵图工具主词 / 分镜词 / 修帧词 / 负面词 - 精灵图工具主词 / 分镜词 / 修帧词 / 负面词
- `src/prompts/customWorldRolePromptDefaults.ts` - `src/prompts/customWorldRolePromptDefaults.ts`
- 角色资产工作台默认 prompt 种子 - 角色资产工作台默认 prompt 种子唯一主源
- `src/prompts/customWorldEntityActionPrompts.ts` - `src/prompts/customWorldEntityActionPrompts.ts`
- 编辑器技能动作 prompt - 编辑器技能动作 prompt
- `packages/shared/src/prompts/qwenSprite.ts` - `packages/shared/src/prompts/qwenSprite.ts`

View File

@@ -6,6 +6,10 @@
- [REPO_NOISE_CLEANUP_BASELINE_2026-04-19.md](./REPO_NOISE_CLEANUP_BASELINE_2026-04-19.md):落实工程清理审计第一阶段后的仓库噪音清理范围、忽略规则闭合点与后续约束。 - [REPO_NOISE_CLEANUP_BASELINE_2026-04-19.md](./REPO_NOISE_CLEANUP_BASELINE_2026-04-19.md):落实工程清理审计第一阶段后的仓库噪音清理范围、忽略规则闭合点与后续约束。
- [PROMPT_DIRECTORY_MANAGEMENT_2026-04-19.md](./PROMPT_DIRECTORY_MANAGEMENT_2026-04-19.md):后端提示词收口到 `server-node/src/prompts/` 的目录方案、兼容策略与后续新增规则。 - [PROMPT_DIRECTORY_MANAGEMENT_2026-04-19.md](./PROMPT_DIRECTORY_MANAGEMENT_2026-04-19.md):后端提示词收口到 `server-node/src/prompts/` 的目录方案、兼容策略与后续新增规则。
- [CUSTOM_WORLD_DRAFT_GENERATION_FAILURE_ANALYSIS_AND_FIX_2026-04-20.md](./CUSTOM_WORLD_DRAFT_GENERATION_FAILURE_ANALYSIS_AND_FIX_2026-04-20.md):世界草稿生成失败后等待页误显示为“卡在编译草稿卡”的根因拆解、主链与增强链路边界,以及本次修复策略。
- [CUSTOM_WORLD_AUTO_ASSET_VISIBILITY_FIX_2026-04-20.md](./CUSTOM_WORLD_AUTO_ASSET_VISIBILITY_FIX_2026-04-20.md):世界草稿里“资产已生成但结果页看不到”的根因拆解,包含角色主形象展示、分幕背景露出和 fallback 资源格式修复。
- [CUSTOM_WORLD_PHASE4_COUNT_SEMANTICS_ALIGNMENT_2026-04-20.md](./CUSTOM_WORLD_PHASE4_COUNT_SEMANTICS_ALIGNMENT_2026-04-20.md)Phase4 新增角色/地点后草稿作品卡数量统计与测试断言的语义对齐说明。
- [TXT_MODE_VISUAL_NOVEL_MIGRATION_EXECUTION_PLAN_2026-04-20.md](./TXT_MODE_VISUAL_NOVEL_MIGRATION_EXECUTION_PLAN_2026-04-20.md):把外部仓库 TXT 模式完整迁入当前项目的冻结边界、模块映射、分阶段计划与验收清单。
- [NODE_SERVER_KNOWLEDGE_GRAPH_2026-04-08.md](./NODE_SERVER_KNOWLEDGE_GRAPH_2026-04-08.md):当前 Node 运行时后端的技术栈、入口、鉴权、存储与接口知识图谱。 - [NODE_SERVER_KNOWLEDGE_GRAPH_2026-04-08.md](./NODE_SERVER_KNOWLEDGE_GRAPH_2026-04-08.md):当前 Node 运行时后端的技术栈、入口、鉴权、存储与接口知识图谱。
- [EXPRESS_BACKEND_INTEGRATION_FREEZE_2026-04-09.md](./EXPRESS_BACKEND_INTEGRATION_FREEZE_2026-04-09.md)Express 后端当前 contract 冻结版本、热点文件编辑规则与集成窗口清单。 - [EXPRESS_BACKEND_INTEGRATION_FREEZE_2026-04-09.md](./EXPRESS_BACKEND_INTEGRATION_FREEZE_2026-04-09.md)Express 后端当前 contract 冻结版本、热点文件编辑规则与集成窗口清单。
- [EXPRESS_BACKEND_WORKSTREAM_AUDIT_2026-04-09.md](./EXPRESS_BACKEND_WORKSTREAM_AUDIT_2026-04-09.md):按并行工作流文档逐项核对后的完成度审计与剩余收口点。 - [EXPRESS_BACKEND_WORKSTREAM_AUDIT_2026-04-09.md](./EXPRESS_BACKEND_WORKSTREAM_AUDIT_2026-04-09.md):按并行工作流文档逐项核对后的完成度审计与剩余收口点。

View File

@@ -69,6 +69,11 @@
7. 角色槽位会把第一槽位写回 `primaryNpcId`,其余槽位顺序压缩写回 `encounterNpcIds` 7. 角色槽位会把第一槽位写回 `primaryNpcId`,其余槽位顺序压缩写回 `encounterNpcIds`
8. 每幕已补上“幕预览”入口,点击后会以独立全屏层启动当前幕运行时预览 8. 每幕已补上“幕预览”入口,点击后会以独立全屏层启动当前幕运行时预览
9. 保存场景时会把幕配置同步写回 `CustomWorldProfile.sceneChapterBlueprints` 9. 保存场景时会把幕配置同步写回 `CustomWorldProfile.sceneChapterBlueprints`
10. 世界档案里的场景详情页已移除“场景图片”和“场景内 NPC”字段相关兼容字段改为从多幕配置自动同步回 `imageSrc / sceneNpcIds`
补充一条等待页体验收口:
10. 世界草稿生成等待页的第二模块标题已从“当前锚点信息”收口为“当前世界信息”,不再显示辅助说明小字,也不再在该模块头部提供“回到工作区”按钮,避免等待态出现重复返回入口
## 2.5 运行时基础层 ## 2.5 运行时基础层
@@ -82,6 +87,7 @@
6. 幕编辑中的 3 个角色槽位已进一步收敛成贴在背景图上的站位式角色预览,交互与幕预览保持同一位置语义,只显示角色形象与名称 6. 幕编辑中的 3 个角色槽位已进一步收敛成贴在背景图上的站位式角色预览,交互与幕预览保持同一位置语义,只显示角色形象与名称
7. 幕预览运行时已补 custom world NPC 的视觉兜底链路,优先使用 `visual / imageSrc` 渲染,避免角色形象或动画空白 7. 幕预览运行时已补 custom world NPC 的视觉兜底链路,优先使用 `visual / imageSrc` 渲染,避免角色形象或动画空白
8. 当前幕小预览已调整为左侧玩家、右侧敌对/相遇角色的构图NPC 站位采用一前两后 8. 当前幕小预览已调整为左侧玩家、右侧敌对/相遇角色的构图NPC 站位采用一前两后
前排主角色与玩家角色保持同一 y 轴后排两个角色改为同一列、x 轴对齐并上下分布,且后排整体 y 轴中点与前排主角色一致
9. 新增幕默认只带 1 个主角色,后续槽位由创作者按需补充 9. 新增幕默认只带 1 个主角色,后续槽位由创作者按需补充
10. 小预览里的名字已移动到角色头顶,角色渲染不再带方形底板,避免遮挡场景背景 10. 小预览里的名字已移动到角色头顶,角色渲染不再带方形底板,避免遮挡场景背景
@@ -95,6 +101,9 @@
4.`5` 轮会由后端 prompt 强约束生成“铺垫式收束”回复,不再继续生成下一轮聊天建议 4.`5` 轮会由后端 prompt 强约束生成“铺垫式收束”回复,不再继续生成下一轮聊天建议
5.`5` 轮返回后,前端会自动清掉 `npcChatState`,隐藏输入框,并给出 `继续` 的后续推进入口 5.`5` 轮返回后,前端会自动清掉 `npcChatState`,隐藏输入框,并给出 `继续` 的后续推进入口
6. Adventure 面板会显示当前幕标题与有限聊天剩余轮数 6. Adventure 面板会显示当前幕标题与有限聊天剩余轮数
7. 当前幕主角色在本地战斗胜利后,会重新回到 NPC 聊天态,而不是直接掉回普通剧情续写
8. 战后重新开启的聊天会把“战斗结果摘要 + 最近战斗日志”一起写入 `npc_chat` 上下文,保证 NPC 能承接刚刚那场交锋继续说话
9. 若该主角色当前好感仍小于 `0`,战后重新开启的聊天仍按 `5` 轮有限聊天处理,轮数从战后这次重开重新计算
## 3. 当前仍未完成 ## 3. 当前仍未完成

File diff suppressed because it is too large Load Diff

View File

@@ -377,7 +377,10 @@ export interface CustomWorldRoleAssetSummary {
export interface CustomWorldSceneAssetSummary { export interface CustomWorldSceneAssetSummary {
sceneId: string; sceneId: string;
sceneName: string; sceneName: string;
actId?: string | null;
actTitle?: string | null;
imageSrc?: string | null; imageSrc?: string | null;
assetId?: string | null;
status: 'missing' | 'ready'; status: 'missing' | 'ready';
nextPointCost: number; nextPointCost: number;
} }

View File

@@ -180,6 +180,7 @@ export type NpcChatTurnRequest<
TStoryMoment = unknown, TStoryMoment = unknown,
TContext = unknown, TContext = unknown,
TConversationTurn = unknown, TConversationTurn = unknown,
TCombatContext = unknown,
TNpcState = unknown, TNpcState = unknown,
TQuestOfferState = unknown, TQuestOfferState = unknown,
TQuestOfferEncounter = unknown, TQuestOfferEncounter = unknown,
@@ -194,6 +195,7 @@ export type NpcChatTurnRequest<
context: TContext; context: TContext;
conversationHistory?: TConversationTurn[]; conversationHistory?: TConversationTurn[];
dialogue?: TConversationTurn[]; dialogue?: TConversationTurn[];
combatContext?: TCombatContext | null;
playerMessage: string; playerMessage: string;
npcState: TNpcState; npcState: TNpcState;
npcInitiatesConversation?: boolean; npcInitiatesConversation?: boolean;

View File

@@ -20,6 +20,7 @@
"pino-http": "^10.5.0", "pino-http": "^10.5.0",
"pino-roll": "^3.1.0", "pino-roll": "^3.1.0",
"pngjs": "^7.0.0", "pngjs": "^7.0.0",
"sharp": "^0.34.5",
"zod": "^4.1.8" "zod": "^4.1.8"
}, },
"devDependencies": { "devDependencies": {
@@ -703,6 +704,519 @@
"node": ">=18" "node": ">=18"
} }
}, },
"node_modules/@img/colour": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz",
"integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==",
"license": "MIT",
"engines": {
"node": ">=18"
}
},
"node_modules/@img/sharp-darwin-arm64": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.34.5.tgz",
"integrity": "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==",
"cpu": [
"arm64"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-darwin-arm64": "1.2.4"
}
},
"node_modules/@img/sharp-darwin-x64": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.34.5.tgz",
"integrity": "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==",
"cpu": [
"x64"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-darwin-x64": "1.2.4"
}
},
"node_modules/@img/sharp-libvips-darwin-arm64": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.2.4.tgz",
"integrity": "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==",
"cpu": [
"arm64"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"darwin"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-darwin-x64": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.2.4.tgz",
"integrity": "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==",
"cpu": [
"x64"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"darwin"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linux-arm": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.2.4.tgz",
"integrity": "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==",
"cpu": [
"arm"
],
"libc": [
"glibc"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linux-arm64": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.2.4.tgz",
"integrity": "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==",
"cpu": [
"arm64"
],
"libc": [
"glibc"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linux-ppc64": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.2.4.tgz",
"integrity": "sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==",
"cpu": [
"ppc64"
],
"libc": [
"glibc"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linux-riscv64": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.2.4.tgz",
"integrity": "sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==",
"cpu": [
"riscv64"
],
"libc": [
"glibc"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linux-s390x": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.2.4.tgz",
"integrity": "sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==",
"cpu": [
"s390x"
],
"libc": [
"glibc"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linux-x64": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.2.4.tgz",
"integrity": "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==",
"cpu": [
"x64"
],
"libc": [
"glibc"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linuxmusl-arm64": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.2.4.tgz",
"integrity": "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==",
"cpu": [
"arm64"
],
"libc": [
"musl"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linuxmusl-x64": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.2.4.tgz",
"integrity": "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==",
"cpu": [
"x64"
],
"libc": [
"musl"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-linux-arm": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.34.5.tgz",
"integrity": "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==",
"cpu": [
"arm"
],
"libc": [
"glibc"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linux-arm": "1.2.4"
}
},
"node_modules/@img/sharp-linux-arm64": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.34.5.tgz",
"integrity": "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==",
"cpu": [
"arm64"
],
"libc": [
"glibc"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linux-arm64": "1.2.4"
}
},
"node_modules/@img/sharp-linux-ppc64": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.34.5.tgz",
"integrity": "sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==",
"cpu": [
"ppc64"
],
"libc": [
"glibc"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linux-ppc64": "1.2.4"
}
},
"node_modules/@img/sharp-linux-riscv64": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.34.5.tgz",
"integrity": "sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==",
"cpu": [
"riscv64"
],
"libc": [
"glibc"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linux-riscv64": "1.2.4"
}
},
"node_modules/@img/sharp-linux-s390x": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.34.5.tgz",
"integrity": "sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==",
"cpu": [
"s390x"
],
"libc": [
"glibc"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linux-s390x": "1.2.4"
}
},
"node_modules/@img/sharp-linux-x64": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.34.5.tgz",
"integrity": "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==",
"cpu": [
"x64"
],
"libc": [
"glibc"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linux-x64": "1.2.4"
}
},
"node_modules/@img/sharp-linuxmusl-arm64": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.34.5.tgz",
"integrity": "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==",
"cpu": [
"arm64"
],
"libc": [
"musl"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linuxmusl-arm64": "1.2.4"
}
},
"node_modules/@img/sharp-linuxmusl-x64": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.34.5.tgz",
"integrity": "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==",
"cpu": [
"x64"
],
"libc": [
"musl"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linuxmusl-x64": "1.2.4"
}
},
"node_modules/@img/sharp-wasm32": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.34.5.tgz",
"integrity": "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==",
"cpu": [
"wasm32"
],
"license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT",
"optional": true,
"dependencies": {
"@emnapi/runtime": "^1.7.0"
},
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-win32-arm64": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.34.5.tgz",
"integrity": "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==",
"cpu": [
"arm64"
],
"license": "Apache-2.0 AND LGPL-3.0-or-later",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-win32-ia32": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.34.5.tgz",
"integrity": "sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==",
"cpu": [
"ia32"
],
"license": "Apache-2.0 AND LGPL-3.0-or-later",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-win32-x64": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.34.5.tgz",
"integrity": "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==",
"cpu": [
"x64"
],
"license": "Apache-2.0 AND LGPL-3.0-or-later",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@napi-rs/wasm-runtime": { "node_modules/@napi-rs/wasm-runtime": {
"version": "0.2.12", "version": "0.2.12",
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-0.2.12.tgz", "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-0.2.12.tgz",
@@ -1328,6 +1842,15 @@
"npm": "1.2.8000 || >= 1.4.16" "npm": "1.2.8000 || >= 1.4.16"
} }
}, },
"node_modules/detect-libc": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
"integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
"license": "Apache-2.0",
"engines": {
"node": ">=8"
}
},
"node_modules/discontinuous-range": { "node_modules/discontinuous-range": {
"version": "1.0.0", "version": "1.0.0",
"resolved": "https://registry.npmjs.org/discontinuous-range/-/discontinuous-range-1.0.0.tgz", "resolved": "https://registry.npmjs.org/discontinuous-range/-/discontinuous-range-1.0.0.tgz",
@@ -2473,6 +2996,18 @@
"node": ">=11.0.0" "node": ">=11.0.0"
} }
}, },
"node_modules/semver": {
"version": "7.7.4",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz",
"integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==",
"license": "ISC",
"bin": {
"semver": "bin/semver.js"
},
"engines": {
"node": ">=10"
}
},
"node_modules/send": { "node_modules/send": {
"version": "0.19.2", "version": "0.19.2",
"resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz", "resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz",
@@ -2541,6 +3076,50 @@
"integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==",
"license": "ISC" "license": "ISC"
}, },
"node_modules/sharp": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.5.tgz",
"integrity": "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==",
"hasInstallScript": true,
"license": "Apache-2.0",
"dependencies": {
"@img/colour": "^1.0.0",
"detect-libc": "^2.1.2",
"semver": "^7.7.3"
},
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-darwin-arm64": "0.34.5",
"@img/sharp-darwin-x64": "0.34.5",
"@img/sharp-libvips-darwin-arm64": "1.2.4",
"@img/sharp-libvips-darwin-x64": "1.2.4",
"@img/sharp-libvips-linux-arm": "1.2.4",
"@img/sharp-libvips-linux-arm64": "1.2.4",
"@img/sharp-libvips-linux-ppc64": "1.2.4",
"@img/sharp-libvips-linux-riscv64": "1.2.4",
"@img/sharp-libvips-linux-s390x": "1.2.4",
"@img/sharp-libvips-linux-x64": "1.2.4",
"@img/sharp-libvips-linuxmusl-arm64": "1.2.4",
"@img/sharp-libvips-linuxmusl-x64": "1.2.4",
"@img/sharp-linux-arm": "0.34.5",
"@img/sharp-linux-arm64": "0.34.5",
"@img/sharp-linux-ppc64": "0.34.5",
"@img/sharp-linux-riscv64": "0.34.5",
"@img/sharp-linux-s390x": "0.34.5",
"@img/sharp-linux-x64": "0.34.5",
"@img/sharp-linuxmusl-arm64": "0.34.5",
"@img/sharp-linuxmusl-x64": "0.34.5",
"@img/sharp-wasm32": "0.34.5",
"@img/sharp-win32-arm64": "0.34.5",
"@img/sharp-win32-ia32": "0.34.5",
"@img/sharp-win32-x64": "0.34.5"
}
},
"node_modules/side-channel": { "node_modules/side-channel": {
"version": "1.1.0", "version": "1.1.0",
"resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz",

View File

@@ -23,6 +23,7 @@
"pino-http": "^10.5.0", "pino-http": "^10.5.0",
"pino-roll": "^3.1.0", "pino-roll": "^3.1.0",
"pngjs": "^7.0.0", "pngjs": "^7.0.0",
"sharp": "^0.34.5",
"zod": "^4.1.8" "zod": "^4.1.8"
}, },
"devDependencies": { "devDependencies": {

View File

@@ -3148,9 +3148,9 @@ test('custom world agent generate_landmarks action appends landmark cards over h
baseUrl, baseUrl,
token: entry.token, token: entry.token,
}); });
const baselineLandmarkCount = session.draftCards.filter( const baselineLandmarkCount =
(card) => card.kind === 'landmark', session.draftProfile?.landmarks?.length ??
).length; session.draftCards.filter((card) => card.kind === 'landmark').length;
const anchorCardId = const anchorCardId =
session.draftCards.find((card) => card.kind === 'character')?.id ?? session.draftCards.find((card) => card.kind === 'character')?.id ??
session.draftCards.find((card) => card.kind === 'thread')?.id; session.draftCards.find((card) => card.kind === 'thread')?.id;
@@ -3211,7 +3211,10 @@ test('custom world agent generate_landmarks action appends landmark cards over h
}; };
assert.equal(sessionResponse.status, 200); assert.equal(sessionResponse.status, 200);
assert.ok((sessionPayload.draftProfile?.landmarks?.length ?? 0) >= 6); assert.ok(
(sessionPayload.draftProfile?.landmarks?.length ?? 0) >=
baselineLandmarkCount + 2,
);
assert.ok( assert.ok(
sessionPayload.draftCards.filter((card) => card.kind === 'landmark') sessionPayload.draftCards.filter((card) => card.kind === 'landmark')
.length >= .length >=

View File

@@ -578,6 +578,14 @@ test('chat orchestrator force closes the fifth hostile primary-npc turn with for
monsters: [], monsters: [],
history: [], history: [],
context: createStoryContext(), context: createStoryContext(),
combatContext: {
summary: '你刚在断桥口压住了断桥客的刀势,逼得他不得不重新开口。',
logLines: [
'你先一步抢进桥心,逼开了对方的起手。',
'断桥客被逼退到桥栏边,终于没有再出下一刀。',
],
battleOutcome: 'victory',
},
conversationHistory: [ conversationHistory: [
{ speaker: 'player', text: '你一直躲着不说完。' }, { speaker: 'player', text: '你一直躲着不说完。' },
{ speaker: 'npc', text: '有些话说完了,人也就该死了。' }, { speaker: 'npc', text: '有些话说完了,人也就该死了。' },
@@ -658,6 +666,15 @@ test('chat orchestrator force closes the fifth hostile primary-npc turn with for
assert.equal(requestMessageCount, 0); assert.equal(requestMessageCount, 0);
assert.match(capturedReplyPrompts[0] ?? '', //u); assert.match(capturedReplyPrompts[0] ?? '', //u);
assert.match(capturedReplyPrompts[0] ?? '', //u); assert.match(capturedReplyPrompts[0] ?? '', //u);
assert.match(capturedReplyPrompts[0] ?? '', //u);
assert.match(
capturedReplyPrompts[0] ?? '',
//u,
);
assert.match(
capturedReplyPrompts[0] ?? '',
//u,
);
const eventText = responseChunks.join(''); const eventText = responseChunks.join('');
const completeBlock = eventText const completeBlock = eventText

View File

@@ -375,59 +375,6 @@ test('character visual generation converts public reference images into data url
); );
}); });
test('character prompt bundle generation falls back to local defaults when llm client is unavailable', async () => {
const tempRoot = fs.mkdtempSync(
path.join(os.tmpdir(), 'genarrative-character-prompt-bundle-'),
);
await withAssetRouteServer(
createTestConfig(tempRoot, 'http://127.0.0.1:9/api/v1'),
async (assetBaseUrl) => {
const response = await fetch(
`${assetBaseUrl}/api/assets/character-prompts/generate`,
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
roleKind: 'story',
characterName: '港口向导',
roleTitle: '潮灯守望者',
roleLabel: '旧港引路人',
description: '熟悉黑潮与暗礁,身上带着潮雾气息。',
backstory: '常年守在废弃灯塔附近,为误入者指路。',
personality: '冷静克制,但会在关键时刻出手。',
motivation: '想守住最后一段仍能靠岸的航道。',
combatStyle: '短刀与信号灯配合,动作利落。',
tags: ['潮雾', '守望', '引路'],
characterBriefText:
'角色名称:港口向导\n角色头衔潮灯守望者\n世界身份旧港引路人',
}),
},
);
assert.equal(response.status, 200);
const payload = (await response.json()) as {
source: string;
visualPromptText: string;
animationPromptText: string;
scenePromptText: string;
};
assert.equal(payload.source, 'fallback');
assert.match(payload.visualPromptText, //u);
assert.match(payload.visualPromptText, //u);
assert.match(payload.visualPromptText, /绿绿/u);
assert.match(payload.visualPromptText, /2 2\.5 /u);
assert.match(payload.visualPromptText, //u);
assert.match(payload.visualPromptText, //u);
assert.match(payload.animationPromptText, //u);
assert.match(payload.scenePromptText, //u);
},
);
});
test('character workflow cache persists unsaved studio state', async () => { test('character workflow cache persists unsaved studio state', async () => {
const tempRoot = fs.mkdtempSync( const tempRoot = fs.mkdtempSync(
path.join(os.tmpdir(), 'genarrative-character-workflow-cache-'), path.join(os.tmpdir(), 'genarrative-character-workflow-cache-'),

View File

@@ -18,25 +18,17 @@ import { PNG } from 'pngjs';
import { removeBackgroundFromRgba } from '../../../../packages/shared/src/assets/chromaKey.js'; import { removeBackgroundFromRgba } from '../../../../packages/shared/src/assets/chromaKey.js';
import { parseApiErrorMessage } from '../../../../packages/shared/src/http.js'; import { parseApiErrorMessage } from '../../../../packages/shared/src/http.js';
import { parseJsonResponseText } from '../../../../packages/shared/src/llm/parsers.js';
import type { AppConfig } from '../../config.js'; import type { AppConfig } from '../../config.js';
import { import {
buildArkCharacterAnimationPrompt, buildArkCharacterAnimationPrompt,
buildCharacterPromptBundleUserPrompt,
buildFallbackCharacterPromptBundle,
buildFallbackModerationSafeAnimationPrompt, buildFallbackModerationSafeAnimationPrompt,
buildImageSequencePrompt, buildImageSequencePrompt,
buildNpcAnimationPrompt, buildNpcAnimationPrompt,
buildNpcVisualNegativePrompt, buildNpcVisualNegativePrompt,
buildNpcVisualPrompt, buildNpcVisualPrompt,
CHARACTER_PROMPT_BUNDLE_SYSTEM_PROMPT,
type CharacterPromptBundle,
sanitizeCharacterPromptBundle,
} from '../../prompts/characterAssetPrompts.js'; } from '../../prompts/characterAssetPrompts.js';
import type { UpstreamLlmClient } from '../../services/llmClient.js'; import type { UpstreamLlmClient } from '../../services/llmClient.js';
const CHARACTER_PROMPT_BUNDLE_GENERATE_PATH =
'/api/assets/character-prompts/generate';
const CHARACTER_WORKFLOW_CACHE_PATH = '/api/assets/character-workflow-cache'; const CHARACTER_WORKFLOW_CACHE_PATH = '/api/assets/character-workflow-cache';
const CHARACTER_VISUAL_GENERATE_PATH = '/api/assets/character-visual/generate'; const CHARACTER_VISUAL_GENERATE_PATH = '/api/assets/character-visual/generate';
const CHARACTER_VISUAL_PUBLISH_PATH = '/api/assets/character-visual/publish'; const CHARACTER_VISUAL_PUBLISH_PATH = '/api/assets/character-visual/publish';
@@ -1050,106 +1042,6 @@ function getLowestSupportedVideoResolution(model: string, fallback: string) {
} }
} }
async function handleGenerateCharacterPromptBundle(
config: AppConfig,
req: IncomingMessage & { body?: unknown },
res: ServerResponse,
llmClient?: UpstreamLlmClient | null,
) {
if (req.method !== 'POST') {
sendJson(res, 405, { error: { message: 'Method Not Allowed' } });
return;
}
const body = await readJsonBody(req);
const roleKind =
typeof body.roleKind === 'string' && body.roleKind.trim()
? body.roleKind.trim()
: 'story';
const characterBriefText = clampPromptSeedText(body.characterBriefText, 2400);
const characterName = clampPromptSeedText(body.characterName, 40);
const roleTitle = clampPromptSeedText(body.roleTitle, 60);
const roleLabel = clampPromptSeedText(body.roleLabel, 60);
const description = clampPromptSeedText(body.description, 240);
const backstory = clampPromptSeedText(body.backstory, 320);
const personality = clampPromptSeedText(body.personality, 180);
const motivation = clampPromptSeedText(body.motivation, 180);
const combatStyle = clampPromptSeedText(body.combatStyle, 180);
const tags = isStringArray(body.tags)
? body.tags
.map((item) => clampPromptSeedText(item, 24))
.filter(Boolean)
.slice(0, 8)
: [];
if (!characterBriefText) {
sendJson(res, 400, {
error: { message: '生成默认提示词前需要提供角色设定摘要。' },
});
return;
}
const fallbackBundle = buildFallbackCharacterPromptBundle({
characterName,
roleKind,
roleTitle,
roleLabel,
description,
backstory,
personality,
motivation,
combatStyle,
tags,
});
const llmApiKey =
typeof config.llm?.apiKey === 'string' ? config.llm.apiKey.trim() : '';
const llmModel =
typeof config.llm?.model === 'string' ? config.llm.model : '';
if (!llmClient || !llmApiKey) {
sendJson(res, 200, {
ok: true,
...fallbackBundle,
});
return;
}
try {
const responseText = await llmClient.requestMessageContent({
systemPrompt: CHARACTER_PROMPT_BUNDLE_SYSTEM_PROMPT,
userPrompt: buildCharacterPromptBundleUserPrompt({
roleKind,
characterBriefText,
characterName,
roleTitle,
roleLabel,
description,
backstory,
personality,
motivation,
combatStyle,
tags,
}),
debugLabel: 'character-prompt-bundle',
timeoutMs: 30000,
});
sendJson(res, 200, {
ok: true,
...sanitizeCharacterPromptBundle(
parseJsonResponseText(responseText),
fallbackBundle,
llmModel,
),
});
} catch {
sendJson(res, 200, {
ok: true,
...fallbackBundle,
});
}
}
async function writeDraftBinaryFile( async function writeDraftBinaryFile(
rootDir: string, rootDir: string,
relativePath: string, relativePath: string,
@@ -3107,12 +2999,6 @@ export function createCharacterAssetRoutes(
return handleSaveCharacterWorkflowCache(config, request, response); return handleSaveCharacterWorkflowCache(config, request, response);
}), }),
); );
router.use(
CHARACTER_PROMPT_BUNDLE_GENERATE_PATH,
toExpressHandler((request, response) =>
handleGenerateCharacterPromptBundle(config, request, response, llmClient),
),
);
router.use( router.use(
CHARACTER_VISUAL_GENERATE_PATH, CHARACTER_VISUAL_GENERATE_PATH,
toExpressHandler((request, response) => toExpressHandler((request, response) =>

View File

@@ -572,9 +572,13 @@ function buildFallbackCustomWorldCampScene(profile: {
} as const; } as const;
return { return {
id: 'custom-scene-camp',
name: fallbackName, name: fallbackName,
description: descriptionByMode[themeMode], description: descriptionByMode[themeMode],
dangerLevel: 'low', dangerLevel: 'low',
sceneNpcIds: [],
connections: [],
narrativeResidues: null,
}; };
} }
@@ -1034,9 +1038,27 @@ function normalizeCampOutline(
: {}; : {};
return { return {
id: toText(item.id) || fallback.id,
name: toText(item.name) || fallback.name, name: toText(item.name) || fallback.name,
description: toText(item.description) || fallback.description, description: toText(item.description) || fallback.description,
visualDescription: toText(item.visualDescription) || undefined,
dangerLevel: toText(item.dangerLevel) || fallback.dangerLevel, dangerLevel: toText(item.dangerLevel) || fallback.dangerLevel,
imageSrc: toText(item.imageSrc) || undefined,
sceneNpcIds: toStringArray(item.sceneNpcIds),
connections: toRecordArray(item.connections)
.map((connection) => ({
targetLandmarkName:
toText(connection.targetLandmarkName) ||
toText(connection.target) ||
toText(connection.sceneName),
relativePosition:
toText(connection.relativePosition) ||
toText(connection.position) ||
'forward',
summary:
toText(connection.summary) || toText(connection.description),
}))
.filter((connection) => connection.targetLandmarkName),
}; };
} }
@@ -1502,10 +1524,22 @@ function normalizeCampScene(
: {}; : {};
return { return {
id: toText(item.id) || fallback.id,
name: toText(item.name) || fallback.name, name: toText(item.name) || fallback.name,
description: toText(item.description) || fallback.description, description: toText(item.description) || fallback.description,
visualDescription: toText(item.visualDescription) || undefined,
dangerLevel: toText(item.dangerLevel) || fallback.dangerLevel, dangerLevel: toText(item.dangerLevel) || fallback.dangerLevel,
imageSrc: toText(item.imageSrc) || undefined, imageSrc: toText(item.imageSrc) || undefined,
sceneNpcIds: toStringArray(item.sceneNpcIds),
connections: toRecordArray(item.connections)
.map((connection) => ({
targetLandmarkId: toText(connection.targetLandmarkId),
relativePosition:
toText(connection.relativePosition) || toText(connection.position) || 'forward',
summary: toText(connection.summary) || toText(connection.description),
}))
.filter((connection) => connection.targetLandmarkId),
narrativeResidues: null,
}; };
} }

View File

@@ -244,6 +244,7 @@ export interface SceneActBlueprint {
summary: string; summary: string;
stageCoverage: SceneActStage[]; stageCoverage: SceneActStage[];
backgroundImageSrc?: string | null; backgroundImageSrc?: string | null;
backgroundAssetId?: string | null;
encounterNpcIds: string[]; encounterNpcIds: string[];
primaryNpcId: string; primaryNpcId: string;
linkedThreadIds: string[]; linkedThreadIds: string[];
@@ -263,10 +264,21 @@ export interface SceneChapterBlueprint {
} }
export interface CustomWorldCampScene { export interface CustomWorldCampScene {
id: string;
name: string; name: string;
description: string; description: string;
visualDescription?: string;
dangerLevel: string; dangerLevel: string;
imageSrc?: string; imageSrc?: string;
sceneNpcIds: string[];
connections: CustomWorldSceneConnection[];
narrativeResidues?:
| Array<{
summary?: string;
changeHint?: string;
hiddenTruth?: string;
}>
| null;
} }
export interface CustomWorldLandmark { export interface CustomWorldLandmark {

View File

@@ -7,20 +7,17 @@ import {
/** /**
* 角色资产正式 prompt 主源。 * 角色资产正式 prompt 主源。
* *
* 这份脚本同时承担两层职责: * 这份脚本当前只承担“正式模型 prompt 层”职责:
* 1. 角色卡 -> 默认资产描述文本 * - buildNpcVisualPrompt
* - 产出 visualPromptText / animationPromptText / scenePromptText * - buildNpcAnimationPrompt
* - 这层本质上是在“编译默认描述文本”,不是最终直接发给图像模型的完整 prompt * - buildArkCharacterAnimationPrompt
* 2. 默认描述文本 -> 正式模型 prompt * - buildImageSequencePrompt
* - buildNpcVisualPrompt / buildNpcAnimationPrompt / buildArkCharacterAnimationPrompt
* - 这层才是正式发给图像 / 动作模型的 prompt 组装入口
* *
* 当前仓库状态需要特别区分: * 当前仓库状态需要特别区分:
* - 当前自定义世界角色资产工坊默认输入框,实际直接使用前端 * - 当前自定义世界角色资产工坊默认输入框,实际直接使用前端
* src/prompts/customWorldRolePromptDefaults.ts * src/prompts/customWorldRolePromptDefaults.ts
* - 本文件里的 CHARACTER_PROMPT_BUNDLE_SYSTEM_PROMPT 及其生成接口 * - 默认描述文本的唯一主源已经统一为前端本地映射,
* /api/assets/character-prompts/generate 目前仍保留、可用、且有测试覆盖, * 不再保留后端独立 bundle 编译接口
* 但不是当前资产工坊初始默认值的主链来源
* - 当前正式角色主图与动作生成,仍然走本文件里的正式 prompt builder * - 当前正式角色主图与动作生成,仍然走本文件里的正式 prompt builder
*/ */
function clampPromptSeedText(value: unknown, maxLength: number) { function clampPromptSeedText(value: unknown, maxLength: number) {
@@ -31,147 +28,6 @@ function clampPromptSeedText(value: unknown, maxLength: number) {
return value.replace(/\s+/gu, ' ').trim().slice(0, maxLength); return value.replace(/\s+/gu, ' ').trim().slice(0, maxLength);
} }
export const CHARACTER_PROMPT_BUNDLE_SYSTEM_PROMPT = `你是 RPG 角色资产提示词编译器。
你会收到一个角色设定摘要,请为当前项目生成 3 段可直接交给资产生成模型的中文提示词。
你必须只输出一个 JSON 对象,不要输出 Markdown、代码块、注释或解释。
输出格式必须严格为:
{
"visualPromptText": "角色主图提示词",
"animationPromptText": "角色动作提示词",
"scenePromptText": "角色关联场景提示词"
}
硬性约束:
- 所有字段都必须是自然中文。
- visualPromptText 用于角色主图候选必须是角色标准设定图而不是场景海报突出单人全身、右向斜侧身站姿、脚底完整可见、服装武器轮廓稳定、纯绿色绿幕背景、1:1 画幅。
- visualPromptText 里的主题词只能落在角色自身的服装、发型、材质、纹样、饰品、武器和发光细节上,不要自动补出建筑、风景、漂浮物、烟雾或其他角色以外的场景元素。
- visualPromptText 要明确“身体整体朝右,但保留少量正面信息”,避免生成完全 90 度纯右视图。
- animationPromptText 用于角色动作试片,必须突出发力方式、动作气质、连贯性、同一角色一致性,不要写镜头切换。
- scenePromptText 用于该角色关联的场景背景,必须突出角色首次登场或主活动区域的环境气质与空间结构,适配横版 RPG 场景。
- 三段提示词都要可直接使用,不要编号,不要加字段名解释,不要输出负面提示词。`;
export type CharacterPromptBundle = {
visualPromptText: string;
animationPromptText: string;
scenePromptText: string;
source: 'llm' | 'fallback';
model: string | null;
};
/**
* 当默认描述文本编译接口不可用,或当前环境不走 LLM 编译时,
* 用角色卡字段本地拼出一份可直接使用的默认文本 bundle。
*
* 这份返回值属于“默认描述文本层”:
* - visualPromptText: 给角色主图用的默认描述
* - animationPromptText: 给动作试片用的默认描述
* - scenePromptText: 给角色关联场景用的默认描述
*
* 它不是最终发给正式图像 / 动作模型的完整 prompt。
*/
export function buildFallbackCharacterPromptBundle(params: {
characterName: string;
roleKind: string;
roleTitle: string;
roleLabel: string;
description: string;
backstory: string;
personality: string;
motivation: string;
combatStyle: string;
tags: string[];
}) {
const roleAnchor =
[params.roleTitle, params.roleLabel].filter(Boolean).join(' / ') ||
(params.roleKind === 'playable' ? '可扮演角色' : '场景角色');
const characterAnchor = params.characterName || '该角色';
const descriptionAnchor =
params.description || params.backstory || params.personality || '气质鲜明';
const combatAnchor =
params.combatStyle || params.motivation || '动作发力清晰';
const tagAnchor =
params.tags.length > 0 ? `保留 ${params.tags.join('、')} 的识别点。` : '';
return {
visualPromptText: [
`${characterAnchor}${roleAnchor}`,
'单人全身2D 横版 RPG 角色标准设定图1:1 正方形画幅,头身比控制在 2 到 2.5 头身,偏大头身,靠头部、发型、服装、配饰表现角色记忆点,躯干与四肢短而紧凑,五官简化,深色粗轮廓配合清晰大色块,右向斜侧身站立,身体整体朝右但保留少量正面信息,服装、发型、轮廓稳定清楚。',
`外观气质围绕:${descriptionAnchor}`,
combatAnchor ? `战斗识别点:${combatAnchor}` : '',
tagAnchor,
'背景固定为纯绿色绿幕,不带建筑、风景、漂浮物和其他场景元素,方便自动抠像,不做正面立绘,不做完全 90 度纯右视图,不做夸张透视。',
]
.filter(Boolean)
.join(' '),
animationPromptText: [
`${characterAnchor}的核心动作试片。`,
'保持同一角色的服装、发型、体型一致,镜头稳定,侧身朝右,动作连贯。',
combatAnchor ? `动作气质参考:${combatAnchor}` : '',
params.personality ? `角色气质补充:${params.personality}` : '',
'发力起手明确,过程干净,收招利落,避免漂移和变形。',
]
.filter(Boolean)
.join(' '),
scenePromptText: [
`${characterAnchor}关联主场景,适合作为首次登场区域或常驻活动空间。`,
'16:9 横版 RPG 场景背景,上下分区清楚,上半部分表现中远景氛围,下半部分是可站立地面。',
`场景叙事气质围绕:${descriptionAnchor}`,
params.backstory ? `背景线索可参考:${params.backstory}` : '',
params.motivation
? `环境中可埋入与当前目标相关的暗示:${params.motivation}`
: '',
'整体风格克制统一,适合剧情探索与战斗底图。',
]
.filter(Boolean)
.join(' '),
source: 'fallback' as const,
model: null,
};
}
function sanitizePromptBundleValue(
value: unknown,
fallback: string,
maxLength: number,
) {
const normalized = clampPromptSeedText(value, maxLength);
return normalized || fallback;
}
/**
* 将 LLM 返回的默认文本 bundle 规整成稳定结构。
*
* 这里只负责兜底、限长和字段补齐,不负责把 bundle 进一步编译成
* 正式图像 / 动作生成 prompt。
*/
export function sanitizeCharacterPromptBundle(
value: unknown,
fallback: CharacterPromptBundle,
model: string,
) {
const record = isRecordValue(value) ? value : {};
return {
visualPromptText: sanitizePromptBundleValue(
record.visualPromptText,
fallback.visualPromptText,
280,
),
animationPromptText: sanitizePromptBundleValue(
record.animationPromptText,
fallback.animationPromptText,
280,
),
scenePromptText: sanitizePromptBundleValue(
record.scenePromptText,
fallback.scenePromptText,
320,
),
source: 'llm' as const,
model: model.trim() || null,
};
}
function sanitizeAnimationPromptText(value: string, maxLength: number) { function sanitizeAnimationPromptText(value: string, maxLength: number) {
return value return value
.replace(/\s+/gu, ' ') .replace(/\s+/gu, ' ')
@@ -197,48 +53,6 @@ function buildCompactAnimationCharacterBrief(value: string) {
.join(''); .join('');
} }
/**
* 默认文本 bundle 的 user prompt。
*
* 这段文本只用于让 LLM 从角色卡摘要里提炼出
* visualPromptText / animationPromptText / scenePromptText 三段默认描述,
* 不是正式图像模型或动作模型的 system prompt。
*/
export function buildCharacterPromptBundleUserPrompt(params: {
roleKind: string;
characterBriefText: string;
characterName: string;
roleTitle: string;
roleLabel: string;
description: string;
backstory: string;
personality: string;
motivation: string;
combatStyle: string;
tags: string[];
}) {
return [
'请根据下面的角色卡摘要,编译一组默认资产提示词。',
'提示词用于当前项目的角色主图、动作试片和角色关联场景背景。',
'请保留该角色的身份识别点、气质、战斗方式与世界感,不要空泛套模板。',
'',
`角色类型:${params.roleKind === 'playable' ? '可扮演角色' : '场景角色'}`,
params.characterName ? `角色名称:${params.characterName}` : '',
params.roleTitle ? `角色头衔:${params.roleTitle}` : '',
params.roleLabel ? `世界身份:${params.roleLabel}` : '',
params.description ? `角色描述:${params.description}` : '',
params.backstory ? `角色背景:${params.backstory}` : '',
params.personality ? `角色性格:${params.personality}` : '',
params.motivation ? `角色动机:${params.motivation}` : '',
params.combatStyle ? `战斗风格:${params.combatStyle}` : '',
params.tags.length > 0 ? `角色标签:${params.tags.join('、')}` : '',
'',
'角色卡全文:',
params.characterBriefText,
]
.filter(Boolean)
.join('\n');
}
/** /**
* 正式角色主图 prompt 编译入口。 * 正式角色主图 prompt 编译入口。

View File

@@ -212,6 +212,34 @@ function describeNpcConversationHistory(history: unknown, npcName: string) {
: '当前聊天记录:暂无。'; : '当前聊天记录:暂无。';
} }
function describeNpcCombatContext(combatContext: unknown) {
const record = asRecord(combatContext);
const summary = readString(record?.summary);
const battleOutcome = readString(record?.battleOutcome);
const logLines = readStringArray(record?.logLines).slice(0, 6);
if (!summary && logLines.length === 0) {
return null;
}
const outcomeText =
battleOutcome === 'spar_complete'
? '切磋刚刚结束。'
: battleOutcome === 'victory'
? '战斗刚刚分出胜负。'
: null;
return [
'刚刚结束的交锋:',
outcomeText,
summary ? `- 结果摘要:${summary}` : null,
...(logLines.length > 0
? ['- 战斗日志:', ...logLines.map((line) => ` - ${line}`)]
: []),
]
.filter(Boolean)
.join('\n');
}
function describeSceneContext(context: unknown) { function describeSceneContext(context: unknown) {
const record = asRecord(context); const record = asRecord(context);
const sceneName = readString(record?.sceneName) ?? '当前区域'; const sceneName = readString(record?.sceneName) ?? '当前区域';
@@ -510,10 +538,12 @@ export function buildNpcChatTurnReplyPrompt(
context?.firstContactRelationStance, context?.firstContactRelationStance,
); );
const playerMessage = payload.playerMessage.trim(); const playerMessage = payload.playerMessage.trim();
const combatContextBlock = describeNpcCombatContext(payload.combatContext);
return [ return [
buildNpcDialoguePromptBase(payload), buildNpcDialoguePromptBase(payload),
describeNpcConversationHistory(conversationHistory, encounter.npcName), describeNpcConversationHistory(conversationHistory, encounter.npcName),
combatContextBlock,
openingCampBackground ? `营地开场背景:${openingCampBackground}` : null, openingCampBackground ? `营地开场背景:${openingCampBackground}` : null,
openingCampDialogue ? `刚刚发生的第一段对话:${openingCampDialogue}` : null, openingCampDialogue ? `刚刚发生的第一段对话:${openingCampDialogue}` : null,
`当前关系值:${affinity}`, `当前关系值:${affinity}`,
@@ -574,10 +604,12 @@ export function buildNpcChatTurnSuggestionPrompt(
Array.isArray(payload.conversationHistory) && payload.conversationHistory.length > 0 Array.isArray(payload.conversationHistory) && payload.conversationHistory.length > 0
? payload.conversationHistory ? payload.conversationHistory
: payload.dialogue ?? payload.conversationHistory ?? []; : payload.dialogue ?? payload.conversationHistory ?? [];
const combatContextBlock = describeNpcCombatContext(payload.combatContext);
return [ return [
buildNpcDialoguePromptBase(payload), buildNpcDialoguePromptBase(payload),
describeNpcConversationHistory(conversationHistory, encounter.npcName), describeNpcConversationHistory(conversationHistory, encounter.npcName),
combatContextBlock,
`玩家刚刚说:${payload.playerMessage}`, `玩家刚刚说:${payload.playerMessage}`,
`NPC 刚刚回复:${npcReply}`, `NPC 刚刚回复:${npcReply}`,
`请围绕刚刚这轮对话,为玩家生成 3 条下一轮可以直接说出口的中文接话短句。`, `请围绕刚刚这轮对话,为玩家生成 3 条下一轮可以直接说出口的中文接话短句。`,

View File

@@ -0,0 +1,76 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { buildCustomWorldCoverImageSrc, resolveCustomWorldCoverPresentation } from './customWorldLibraryMetadata.js';
function createProfile() {
return {
id: 'profile-cover-test',
name: '潮雾群岛',
subtitle: '封面规则测试',
summary: '验证作品库封面优先级。',
tone: '潮湿、压抑',
playerGoal: '查明旧航道真相。',
playableNpcs: [
{
id: 'playable-1',
name: '林潮',
imageSrc: '/images/roles/linchao.webp',
},
],
camp: {
imageSrc: '/images/camp/camp.webp',
},
landmarks: [
{
imageSrc: '/images/landmark/docks.webp',
},
],
sceneChapterBlueprints: [
{
id: 'scene-chapter-1',
acts: [
{
id: 'act-1',
backgroundImageSrc: '/images/scene/act-1.webp',
backgroundAssetId: 'asset-scene-act-1',
},
],
},
],
};
}
test('resolveCustomWorldCoverPresentation 优先使用开局场景第一幕图片', () => {
const profile = createProfile();
const result = resolveCustomWorldCoverPresentation(profile);
assert.equal(result.imageSrc, '/images/scene/act-1.webp');
assert.equal(result.renderMode, 'scene_with_roles');
assert.deepEqual(result.characterImageSrcs, ['/images/roles/linchao.webp']);
});
test('buildCustomWorldCoverImageSrc 在第一幕图片缺失时按营地图与地标图回退', () => {
const profile = createProfile();
profile.sceneChapterBlueprints = [
{
id: 'scene-chapter-1',
acts: [
{
id: 'act-1',
backgroundImageSrc: '',
backgroundAssetId: '',
},
],
},
];
assert.equal(buildCustomWorldCoverImageSrc(profile), '/images/camp/camp.webp');
profile.camp = {
imageSrc: '',
};
assert.equal(buildCustomWorldCoverImageSrc(profile), '/images/landmark/docks.webp');
});

View File

@@ -39,7 +39,23 @@ function normalizeCoverCharacterRoleIds(
return [...availableIds].slice(0, 3); return [...availableIds].slice(0, 3);
} }
function resolveOpeningSceneFirstActImageSrc(profile: CustomWorldProfileRecord) {
const sceneChapters = readArray(profile.sceneChapterBlueprints);
const firstSceneChapter = sceneChapters.find(isRecord) ?? null;
const firstAct = firstSceneChapter
? readArray(firstSceneChapter.acts).find(isRecord) ?? null
: null;
return firstAct ? readImageSrc(firstAct.backgroundImageSrc) : null;
}
function resolveOpeningSceneImageSrc(profile: CustomWorldProfileRecord) { function resolveOpeningSceneImageSrc(profile: CustomWorldProfileRecord) {
// 默认封面优先取开局场景第一幕图,保证创作草稿、作品库和正式结果页看到的是同一张“开场镜头”。
const firstActImage = resolveOpeningSceneFirstActImageSrc(profile);
if (firstActImage) {
return firstActImage;
}
const campImage = isRecord(profile.camp) const campImage = isRecord(profile.camp)
? readImageSrc(profile.camp.imageSrc) ? readImageSrc(profile.camp.imageSrc)
: null; : null;

View File

@@ -13,6 +13,7 @@ import { SmsAuthEventRepository } from './repositories/smsAuthEventRepository.js
import { UserRepository } from './repositories/userRepository.js'; import { UserRepository } from './repositories/userRepository.js';
import { UserSessionRepository } from './repositories/userSessionRepository.js'; import { UserSessionRepository } from './repositories/userSessionRepository.js';
import { CaptchaChallengeStore } from './services/captchaChallengeStore.js'; import { CaptchaChallengeStore } from './services/captchaChallengeStore.js';
import { CustomWorldAgentAutoAssetService } from './services/customWorldAgentAutoAssetService.js';
import { CustomWorldAgentOrchestrator } from './services/customWorldAgentOrchestrator.js'; import { CustomWorldAgentOrchestrator } from './services/customWorldAgentOrchestrator.js';
import { CustomWorldAgentSessionStore } from './services/customWorldAgentSessionStore.js'; import { CustomWorldAgentSessionStore } from './services/customWorldAgentSessionStore.js';
import { CustomWorldSessionStore } from './services/customWorldSessionStore.js'; import { CustomWorldSessionStore } from './services/customWorldSessionStore.js';
@@ -83,6 +84,23 @@ export async function createAppContext(config: AppConfig = loadConfig()) {
const customWorldAgentSessions = new CustomWorldAgentSessionStore( const customWorldAgentSessions = new CustomWorldAgentSessionStore(
runtimeRepository, runtimeRepository,
); );
const autoAssetService = new CustomWorldAgentAutoAssetService(
config,
config.dashScope.apiKey.trim()
? CustomWorldAgentAutoAssetService.createDashScopeCharacterVisualGenerator(
config,
)
: CustomWorldAgentAutoAssetService.createFallbackCharacterVisualGenerator(
config,
),
config.dashScope.apiKey.trim()
? CustomWorldAgentAutoAssetService.createDashScopeSceneActBackgroundGenerator(
config,
)
: CustomWorldAgentAutoAssetService.createFallbackSceneActBackgroundGenerator(
config,
),
);
const context: AppContext = { const context: AppContext = {
config, config,
logger, logger,
@@ -102,6 +120,9 @@ export async function createAppContext(config: AppConfig = loadConfig()) {
config.llm.apiKey.trim() config.llm.apiKey.trim()
? new UpstreamLlmClient(config, logger) ? new UpstreamLlmClient(config, logger)
: null, : null,
{
autoAssetService,
},
), ),
smsVerificationService: createSmsVerificationService(config, logger), smsVerificationService: createSmsVerificationService(config, logger),
wechatAuthService: createWechatAuthService(config, logger), wechatAuthService: createWechatAuthService(config, logger),

View File

@@ -25,6 +25,14 @@ test('npc chat turn schema normalizes player and dialogue aliases', () => {
text: '你刚才那句话是什么意思?', text: '你刚才那句话是什么意思?',
}, },
], ],
combatContext: {
summary: '你刚和柳无声短兵相接,胜负已分,但话还没有说完。',
logLines: [
'你侧身避开他的第一刀,反手逼退一步。',
'柳无声被逼到桌角,终于没有继续出手。',
],
battleOutcome: 'victory',
},
playerMessage: '你能说得再明白一点吗?', playerMessage: '你能说得再明白一点吗?',
npcState: { npcState: {
affinity: 4, affinity: 4,
@@ -60,6 +68,14 @@ test('npc chat turn schema normalizes player and dialogue aliases', () => {
text: '你刚才那句话是什么意思?', text: '你刚才那句话是什么意思?',
}, },
]); ]);
assert.equal(
payload.combatContext?.summary,
'你刚和柳无声短兵相接,胜负已分,但话还没有说完。',
);
assert.deepEqual(payload.combatContext?.logLines, [
'你侧身避开他的第一刀,反手逼退一步。',
'柳无声被逼到桌角,终于没有继续出手。',
]);
assert.equal(payload.questOfferContext?.turnCount, 2); assert.equal(payload.questOfferContext?.turnCount, 2);
assert.equal(payload.chatDirective?.sceneActId, 'scene-inn-act-1'); assert.equal(payload.chatDirective?.sceneActId, 'scene-inn-act-1');
assert.equal(payload.chatDirective?.remainingTurns, 3); assert.equal(payload.chatDirective?.remainingTurns, 3);

View File

@@ -46,6 +46,12 @@ const npcChatQuestOfferContextSchema = z.object({
turnCount: z.number().int().nonnegative(), turnCount: z.number().int().nonnegative(),
}); });
const npcChatCombatContextSchema = z.object({
summary: z.string().trim().min(1),
logLines: z.array(z.string().trim().min(1)).default([]),
battleOutcome: z.enum(['victory', 'spar_complete']),
});
export const characterChatReplyRequestSchema = baseCharacterChatSchema.extend({ export const characterChatReplyRequestSchema = baseCharacterChatSchema.extend({
conversationSummary: z.string().optional().default(''), conversationSummary: z.string().optional().default(''),
playerMessage: z.string().trim().min(1), playerMessage: z.string().trim().min(1),
@@ -73,6 +79,7 @@ export const npcChatTurnRequestSchema = baseNpcChatSchema
.extend({ .extend({
conversationHistory: z.array(jsonObjectSchema).optional(), conversationHistory: z.array(jsonObjectSchema).optional(),
dialogue: z.array(jsonObjectSchema).optional(), dialogue: z.array(jsonObjectSchema).optional(),
combatContext: npcChatCombatContextSchema.nullable().optional(),
playerMessage: z.string().trim().min(1), playerMessage: z.string().trim().min(1),
npcState: jsonObjectSchema, npcState: jsonObjectSchema,
npcInitiatesConversation: z.boolean().optional(), npcInitiatesConversation: z.boolean().optional(),

View File

@@ -0,0 +1,396 @@
import assert from 'node:assert/strict';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import test from 'node:test';
import sharp from 'sharp';
import type { AppConfig } from '../config.js';
import { CustomWorldAgentAutoAssetService } from './customWorldAgentAutoAssetService.js';
function createTestConfig(testName: string): AppConfig {
const projectRoot = fs.mkdtempSync(
path.join(os.tmpdir(), `genarrative-auto-assets-${testName}-`),
);
return {
nodeEnv: 'test',
projectRoot,
publicDir: path.join(projectRoot, 'public'),
logsDir: path.join(projectRoot, 'logs'),
dataDir: path.join(projectRoot, 'data'),
rawEnv: {},
databaseUrl: 'pg-mem://auto-assets',
serverAddr: ':0',
logLevel: 'silent',
editorApiEnabled: true,
assetsApiEnabled: true,
jwtSecret: 'test',
jwtExpiresIn: '7d',
jwtIssuer: 'test',
llm: {
baseUrl: 'https://example.invalid',
apiKey: '',
model: 'test-model',
},
dashScope: {
baseUrl: 'https://example.invalid',
apiKey: '',
imageModel: 'test-image-model',
requestTimeoutMs: 1000,
},
smsAuth: {
enabled: false,
provider: 'mock',
endpoint: '',
accessKeyId: '',
accessKeySecret: '',
signName: '',
templateCode: '',
templateParamKey: '',
countryCode: '86',
schemeName: '',
codeLength: 6,
codeType: 1,
validTimeSeconds: 300,
intervalSeconds: 60,
duplicatePolicy: 1,
caseAuthPolicy: 1,
returnVerifyCode: false,
mockVerifyCode: '123456',
maxSendPerPhonePerDay: 20,
maxSendPerIpPerHour: 30,
maxVerifyFailuresPerPhonePerHour: 12,
maxVerifyFailuresPerIpPerHour: 24,
captchaTtlSeconds: 180,
captchaTriggerVerifyFailuresPerPhone: 3,
captchaTriggerVerifyFailuresPerIp: 5,
blockPhoneFailureThreshold: 6,
blockIpFailureThreshold: 10,
blockPhoneDurationMinutes: 30,
blockIpDurationMinutes: 30,
},
wechatAuth: {
enabled: false,
provider: 'mock',
appId: '',
appSecret: '',
authorizeEndpoint: '',
accessTokenEndpoint: '',
userInfoEndpoint: '',
callbackPath: '',
defaultRedirectPath: '/',
mockUserId: '',
mockUnionId: '',
mockDisplayName: '',
mockAvatarUrl: '',
},
authSession: {
refreshCookieName: 'refresh_token',
refreshSessionTtlDays: 30,
refreshCookieSecure: false,
refreshCookieSameSite: 'Lax',
refreshCookiePath: '/',
},
};
}
test('auto asset service populates role visuals and scene act backgrounds', async () => {
const config = createTestConfig('populate');
const service = new CustomWorldAgentAutoAssetService(
config,
CustomWorldAgentAutoAssetService.createFallbackCharacterVisualGenerator(config),
CustomWorldAgentAutoAssetService.createFallbackSceneActBackgroundGenerator(config),
);
const result = await service.populateDraftAssets({
draftProfile: {
name: '雾港列岛',
subtitle: '守灯人与失序航道',
summary: '潮雾、盐火和旧航道互相绞紧的海岛世界。',
tone: '冷峻、克制、海风里带着锈味',
playerGoal: '先在旧灯塔一带站稳,再找出谁在提前布网。',
majorFactions: [],
coreConflicts: ['守灯会与沉船商盟正在争夺旧航道解释权'],
playableNpcs: [
{
id: 'role-playable',
name: '沈砺',
title: '失职守灯人',
role: '可扮演角色',
publicIdentity: '曾经的守灯人,如今回到失序海域前线。',
currentPressure: '必须在旧友和旧职责之间重新站位。',
relationToPlayer: '玩家本人',
threadIds: ['thread-main'],
summary: '他是玩家在这次风暴里的第一视角。',
},
],
storyNpcs: [
{
id: 'role-story-1',
name: '林潮',
title: '码头引路人',
role: '场景角色',
publicIdentity: '码头上最懂回潮时间的人。',
currentPressure: '决定今晚要不要让人进港。',
relationToPlayer: '先帮一把,再继续试探。',
threadIds: ['thread-main'],
summary: '他是第一幕的引路人。',
},
],
landmarks: [
{
id: 'scene-dock',
name: '潮汐码头',
purpose: '承接第一章的主要碰撞。',
mood: '潮声压低,封锁正在加重。',
importance: '这里是玩家开局必须接住的门槛。',
characterIds: ['role-story-1'],
threadIds: ['thread-main'],
summary: '码头上的第一次碰撞会直接决定后续节奏。',
},
],
factions: [],
threads: [
{
id: 'thread-main',
title: '旧航道争夺',
type: 'main',
conflict: '守灯会与沉船商盟正在争夺旧航道解释权',
characterIds: ['role-playable', 'role-story-1'],
landmarkIds: ['scene-dock'],
summary: '整条主线都围绕旧航道解释权改写展开。',
},
],
chapters: [],
sceneChapters: [
{
id: 'scene-chapter-dock',
sceneId: 'scene-dock',
sceneName: '潮汐码头',
title: '潮汐码头章节',
summary: '三幕推进码头章节。',
linkedThreadIds: ['thread-main'],
linkedLandmarkIds: ['scene-dock'],
acts: [
{
id: 'dock-act-1',
title: '雾里靠岸',
summary: '先由林潮把玩家带进港口节拍。',
stageCoverage: ['opening'],
backgroundImageSrc: null,
backgroundAssetId: null,
encounterNpcIds: ['role-story-1', 'role-playable'],
primaryNpcId: 'role-story-1',
linkedThreadIds: ['thread-main'],
actGoal: '接住第一幕入口压力',
transitionHook: '下一幕开始会有人继续封锁码头。',
advanceRule: 'after_primary_contact',
},
{
id: 'dock-act-2',
title: '封锁加压',
summary: '第二幕把封锁真正抬上台面。',
stageCoverage: ['expansion', 'turning_point'],
backgroundImageSrc: null,
backgroundAssetId: null,
encounterNpcIds: ['role-story-1', 'role-playable'],
primaryNpcId: 'role-story-1',
linkedThreadIds: ['thread-main'],
actGoal: '把冲突推高',
transitionHook: '第三幕要把下一跳抛给玩家。',
advanceRule: 'after_active_step_complete',
},
{
id: 'dock-act-3',
title: '潮线收束',
summary: '第三幕负责把这章收住。',
stageCoverage: ['climax', 'aftermath'],
backgroundImageSrc: null,
backgroundAssetId: null,
encounterNpcIds: ['role-story-1', 'role-playable'],
primaryNpcId: 'role-story-1',
linkedThreadIds: ['thread-main'],
actGoal: '完成章节收束',
transitionHook: '把下一跳交给玩家。',
advanceRule: 'after_chapter_resolution',
},
],
},
],
worldHook: '雾港列岛',
playerPremise: '被迫返乡的失职守灯人',
openingSituation: '玩家正站在即将熄灭的旧灯塔上。',
iconicElements: ['潮雾钟声', '盐火灯塔'],
sourceAnchorSummary: '海岛悬疑,冷峻克制。',
},
});
assert.equal(result.assetCoverage.allRoleAssetsReady, true);
assert.equal(result.assetCoverage.allSceneAssetsReady, true);
assert.equal(result.assetCoverage.sceneAssets.length, 3);
assert.deepEqual(result.warnings, []);
assert.ok(
result.draftProfile.playableNpcs.every(
(role) => typeof role.imageSrc === 'string' && typeof role.generatedVisualAssetId === 'string',
),
);
assert.ok(
result.draftProfile.playableNpcs.every((role) =>
role.imageSrc?.endsWith('.png') ?? false,
),
);
const playableImageSrc = result.draftProfile.playableNpcs[0]?.imageSrc;
assert.ok(playableImageSrc);
const playableImageMetadata = await sharp(
path.join(config.publicDir, playableImageSrc.replace(/^\/+/u, '')),
).metadata();
assert.equal(playableImageMetadata.width, 1024);
assert.equal(playableImageMetadata.height, 1024);
assert.ok(
result.draftProfile.sceneChapters.every((chapter) =>
chapter.acts.every(
(act) =>
typeof act.backgroundImageSrc === 'string' &&
typeof act.backgroundAssetId === 'string',
),
),
);
assert.ok(
result.draftProfile.sceneChapters.every((chapter) =>
chapter.acts.every((act) => act.backgroundImageSrc?.endsWith('.png') ?? false),
),
);
});
test('auto asset service degrades gracefully when asset generators fail', async () => {
const config = createTestConfig('degrade');
const service = new CustomWorldAgentAutoAssetService(
config,
async () => {
throw new Error('visual generator unavailable');
},
async () => {
throw new Error('scene generator unavailable');
},
);
const result = await service.populateDraftAssets({
draftProfile: {
name: '雾港列岛',
subtitle: '守灯人与失序航道',
summary: '潮雾、盐火和旧航道互相绞紧的海岛世界。',
tone: '冷峻、克制、海风里带着锈味',
playerGoal: '先在旧灯塔一带站稳,再找出谁在提前布网。',
majorFactions: [],
coreConflicts: ['守灯会与沉船商盟正在争夺旧航道解释权'],
playableNpcs: [
{
id: 'role-playable',
name: '沈砺',
title: '失职守灯人',
role: '可扮演角色',
publicIdentity: '曾经的守灯人,如今回到失序海域前线。',
currentPressure: '必须在旧友和旧职责之间重新站位。',
relationToPlayer: '玩家本人',
threadIds: ['thread-main'],
summary: '他是玩家在这次风暴里的第一视角。',
},
],
storyNpcs: [],
landmarks: [
{
id: 'scene-dock',
name: '潮汐码头',
purpose: '承接第一章的主要碰撞。',
mood: '潮声压低,封锁正在加重。',
importance: '这里是玩家开局必须接住的门槛。',
characterIds: ['role-playable'],
threadIds: ['thread-main'],
summary: '码头上的第一次碰撞会直接决定后续节奏。',
},
],
factions: [],
threads: [
{
id: 'thread-main',
title: '旧航道争夺',
type: 'main',
conflict: '守灯会与沉船商盟正在争夺旧航道解释权',
characterIds: ['role-playable'],
landmarkIds: ['scene-dock'],
summary: '整条主线都围绕旧航道解释权改写展开。',
},
],
chapters: [],
sceneChapters: [
{
id: 'scene-chapter-dock',
sceneId: 'scene-dock',
sceneName: '潮汐码头',
title: '潮汐码头章节',
summary: '单章测试。',
linkedThreadIds: ['thread-main'],
linkedLandmarkIds: ['scene-dock'],
acts: [
{
id: 'dock-act-1',
title: '雾里靠岸',
summary: '先接住入口。',
stageCoverage: ['opening'],
backgroundImageSrc: null,
backgroundAssetId: null,
encounterNpcIds: ['role-playable'],
primaryNpcId: 'role-playable',
linkedThreadIds: ['thread-main'],
actGoal: '接住入口压力',
transitionHook: '继续推进。',
advanceRule: 'after_primary_contact',
},
{
id: 'dock-act-2',
title: '封锁加压',
summary: '继续抬高冲突。',
stageCoverage: ['turning_point'],
backgroundImageSrc: null,
backgroundAssetId: null,
encounterNpcIds: ['role-playable'],
primaryNpcId: 'role-playable',
linkedThreadIds: ['thread-main'],
actGoal: '继续推进',
transitionHook: '继续推进。',
advanceRule: 'after_active_step_complete',
},
],
},
],
worldHook: '雾港列岛',
playerPremise: '被迫返乡的失职守灯人',
openingSituation: '玩家正站在即将熄灭的旧灯塔上。',
iconicElements: ['潮雾钟声'],
sourceAnchorSummary: '海岛悬疑,冷峻克制。',
},
});
assert.equal(result.assetCoverage.allRoleAssetsReady, true);
assert.equal(result.assetCoverage.allSceneAssetsReady, true);
assert.deepEqual(result.warnings, []);
assert.ok(
result.draftProfile.playableNpcs.every((role) =>
role.imageSrc?.endsWith('.png') ?? false,
),
);
const fallbackPlayableImageSrc = result.draftProfile.playableNpcs[0]?.imageSrc;
assert.ok(fallbackPlayableImageSrc);
const fallbackPlayableImageMetadata = await sharp(
path.join(config.publicDir, fallbackPlayableImageSrc.replace(/^\/+/u, '')),
).metadata();
assert.equal(fallbackPlayableImageMetadata.width, 1024);
assert.equal(fallbackPlayableImageMetadata.height, 1024);
assert.ok(
result.draftProfile.sceneChapters.every((chapter) =>
chapter.acts.every((act) => act.backgroundImageSrc?.endsWith('.png') ?? false),
),
);
});

View File

@@ -0,0 +1,771 @@
import crypto from 'node:crypto';
import fs from 'node:fs';
import path from 'node:path';
import sharp from 'sharp';
import type {
CustomWorldAssetCoverageSummary,
CustomWorldFoundationDraftCharacter,
CustomWorldFoundationDraftProfile,
CustomWorldFoundationDraftSceneAct,
CustomWorldSceneAssetSummary,
} from '../../../packages/shared/src/contracts/customWorldAgent.js';
import {
buildNpcVisualNegativePrompt,
buildNpcVisualPrompt,
} from '../prompts/characterAssetPrompts.js';
import type { AppConfig } from '../config.js';
type DraftProgressPayload = {
phaseLabel: string;
phaseDetail: string;
progress: number;
};
type DraftProgressCallback = (
payload: DraftProgressPayload,
) => void | Promise<void>;
export type CharacterVisualGenerator = (params: {
role: CustomWorldFoundationDraftCharacter;
draftProfile: CustomWorldFoundationDraftProfile;
}) => Promise<{
imageSrc: string;
generatedVisualAssetId: string;
}>;
export type SceneActBackgroundGenerator = (params: {
draftProfile: CustomWorldFoundationDraftProfile;
sceneName: string;
act: CustomWorldFoundationDraftSceneAct;
primaryRoleName: string;
supportRoleNames: string[];
}) => Promise<{
imageSrc: string;
assetId: string;
}>;
function toText(value: unknown) {
return typeof value === 'string' ? value.trim() : '';
}
function sanitizeSegment(value: string, fallback: string) {
const normalized = value
.trim()
.replace(/[^\w\u4e00-\u9fa5-]+/gu, '-')
.replace(/^-+|-+$/gu, '')
.slice(0, 48);
return normalized || fallback;
}
function normalizeDashScopeBaseUrl(value: string) {
return value.replace(/\/+$/u, '');
}
function createGeneratedAssetId(prefix: string) {
return `${prefix}-${Date.now().toString(36)}-${crypto.randomBytes(3).toString('hex')}`;
}
async function writePlaceholderPng(params: {
outputPath: string;
width: number;
height: number;
rgb: [number, number, number];
}) {
const [r, g, b] = params.rgb;
await sharp({
create: {
width: params.width,
height: params.height,
channels: 3,
background: { r, g, b },
},
})
.png()
.toFile(params.outputPath);
}
function collectStringsByKey(
value: unknown,
targetKey: string,
results: string[],
) {
if (typeof value === 'string') {
return;
}
if (Array.isArray(value)) {
value.forEach((entry) => collectStringsByKey(entry, targetKey, results));
return;
}
if (!value || typeof value !== 'object') {
return;
}
Object.entries(value).forEach(([key, nestedValue]) => {
if (
key === targetKey &&
typeof nestedValue === 'string' &&
nestedValue.trim()
) {
results.push(nestedValue.trim());
return;
}
collectStringsByKey(nestedValue, targetKey, results);
});
}
function findFirstStringByKey(value: unknown, targetKey: string) {
const results: string[] = [];
collectStringsByKey(value, targetKey, results);
return results[0] ?? '';
}
function extractTaskId(payload: Record<string, unknown>) {
return findFirstStringByKey(payload, 'task_id');
}
function extractImageUrls(payload: Record<string, unknown>) {
const urls: string[] = [];
collectStringsByKey(payload, 'image', urls);
collectStringsByKey(payload, 'url', urls);
return [...new Set(urls)];
}
function buildRoleVisualSeedText(
role: CustomWorldFoundationDraftCharacter,
draftProfile: CustomWorldFoundationDraftProfile,
) {
return [
`世界:${draftProfile.name}`,
`世界摘要:${draftProfile.summary}`,
`角色名:${role.name}`,
`称号:${role.title}`,
`身份:${role.role}`,
`公开身份:${role.publicIdentity}`,
role.publicMask ? `第一印象:${role.publicMask}` : '',
`当前压力:${role.currentPressure}`,
role.hiddenHook ? `隐藏钩子:${role.hiddenHook}` : '',
`与玩家关系:${role.relationToPlayer}`,
`角色摘要:${role.summary}`,
]
.filter(Boolean)
.join('\n');
}
async function createFallbackCharacterVisual(params: {
config: AppConfig;
role: CustomWorldFoundationDraftCharacter;
}) {
const assetId = createGeneratedAssetId('draft-role-visual');
const roleSegment = sanitizeSegment(params.role.id || params.role.name, 'role');
const relativeDir = path.join(
'generated-characters',
roleSegment,
'visual',
assetId,
);
const outputDir = path.join(params.config.publicDir, relativeDir);
fs.mkdirSync(outputDir, { recursive: true });
const fileName = 'master.png';
const filePath = path.join(outputDir, fileName);
await writePlaceholderPng({
outputPath: filePath,
width: 1024,
height: 1024,
rgb: [78, 134, 220],
});
return {
imageSrc: `/${relativeDir.replace(/\\/gu, '/')}/${fileName}`,
generatedVisualAssetId: assetId,
};
}
function buildSceneActPrompt(params: {
draftProfile: CustomWorldFoundationDraftProfile;
sceneName: string;
act: CustomWorldFoundationDraftSceneAct;
primaryRoleName: string;
supportRoleNames: string[];
}) {
return [
`${params.draftProfile.name}`,
`${params.sceneName}`,
`${params.act.title}`,
`${params.act.summary}`,
`${params.act.actGoal}`,
`${params.act.transitionHook}`,
`${params.primaryRoleName || '待补主角色'}`,
params.supportRoleNames.length > 0
? `${params.supportRoleNames.join('、')}`
: '',
`${params.draftProfile.tone}`,
` UI`,
]
.filter(Boolean)
.join('\n');
}
async function createDashScopeTextToImageTask(params: {
config: AppConfig;
prompt: string;
negativePrompt?: string;
size: string;
model: string;
}) {
const response = await fetch(
`${normalizeDashScopeBaseUrl(params.config.dashScope.baseUrl)}/services/aigc/text2image/image-synthesis`,
{
method: 'POST',
headers: {
Authorization: `Bearer ${params.config.dashScope.apiKey}`,
'Content-Type': 'application/json',
'X-DashScope-Async': 'enable',
},
body: JSON.stringify({
model: params.model,
input: {
prompt: params.prompt,
...(params.negativePrompt
? { negative_prompt: params.negativePrompt }
: {}),
},
parameters: {
n: 1,
size: params.size,
prompt_extend: true,
watermark: false,
},
}),
},
);
const responseText = await response.text();
if (!response.ok) {
throw new Error(responseText || '创建图像生成任务失败。');
}
const payload = JSON.parse(responseText) as Record<string, unknown>;
const taskId = extractTaskId(payload);
if (!taskId) {
throw new Error('图像生成任务未返回 task_id。');
}
return taskId;
}
async function waitForDashScopeImage(params: {
config: AppConfig;
taskId: string;
}) {
const deadline = Date.now() + params.config.dashScope.requestTimeoutMs;
const baseUrl = normalizeDashScopeBaseUrl(params.config.dashScope.baseUrl);
while (Date.now() < deadline) {
const pollResponse = await fetch(`${baseUrl}/tasks/${params.taskId}`, {
headers: {
Authorization: `Bearer ${params.config.dashScope.apiKey}`,
},
});
const pollText = await pollResponse.text();
if (!pollResponse.ok) {
throw new Error(pollText || '查询图像生成任务失败。');
}
const pollPayload = JSON.parse(pollText) as Record<string, unknown>;
const status = findFirstStringByKey(pollPayload, 'task_status').trim();
if (status === 'SUCCEEDED') {
const imageUrl = extractImageUrls(pollPayload)[0] ?? '';
const actualPrompt = findFirstStringByKey(pollPayload, 'actual_prompt').trim();
if (!imageUrl) {
throw new Error('图像生成任务成功,但未返回图片地址。');
}
return {
imageUrl,
actualPrompt,
};
}
if (status === 'FAILED' || status === 'UNKNOWN') {
throw new Error(pollText || '图像生成任务失败。');
}
await new Promise((resolve) => setTimeout(resolve, 2000));
}
throw new Error('图像生成任务超时。');
}
async function saveRemoteImage(params: {
config: AppConfig;
imageUrl: string;
relativeDir: string;
fileBaseName: string;
manifest: Record<string, unknown>;
}) {
const response = await fetch(params.imageUrl);
if (!response.ok) {
throw new Error('下载生成图片失败。');
}
const buffer = Buffer.from(await response.arrayBuffer());
const contentType = response.headers.get('content-type') || '';
const extension = contentType.includes('png')
? 'png'
: contentType.includes('webp')
? 'webp'
: 'jpg';
const outputDir = path.join(params.config.publicDir, params.relativeDir);
fs.mkdirSync(outputDir, { recursive: true });
const fileName = `${params.fileBaseName}.${extension}`;
const filePath = path.join(outputDir, fileName);
fs.writeFileSync(filePath, buffer);
fs.writeFileSync(
path.join(outputDir, 'manifest.json'),
`${JSON.stringify(params.manifest, null, 2)}\n`,
'utf8',
);
return `/${path.join(params.relativeDir, fileName).replace(/\\/gu, '/')}`;
}
function findRoleById(
draftProfile: CustomWorldFoundationDraftProfile,
roleId: string,
) {
return [...draftProfile.playableNpcs, ...draftProfile.storyNpcs].find(
(role) => role.id === roleId,
);
}
export class CustomWorldAgentAutoAssetService {
constructor(
private readonly config: AppConfig | null = null,
private readonly characterVisualGenerator?: CharacterVisualGenerator | null,
private readonly sceneActBackgroundGenerator?: SceneActBackgroundGenerator | null,
) {}
async populateDraftAssets(params: {
draftProfile: CustomWorldFoundationDraftProfile;
onProgress?: DraftProgressCallback;
}): Promise<{
draftProfile: CustomWorldFoundationDraftProfile;
assetCoverage: CustomWorldAssetCoverageSummary;
warnings: string[];
}> {
const nextDraftProfile: CustomWorldFoundationDraftProfile = JSON.parse(
JSON.stringify(params.draftProfile),
) as CustomWorldFoundationDraftProfile;
const roles = [...nextDraftProfile.playableNpcs, ...nextDraftProfile.storyNpcs];
const sceneAssetSummaries: CustomWorldSceneAssetSummary[] = [];
const warnings: string[] = [];
const totalRoleCount = roles.length;
const totalActCount = nextDraftProfile.sceneChapters.reduce(
(sum, chapter) => sum + chapter.acts.length,
0,
);
let completedRoleCount = 0;
let completedActCount = 0;
for (const role of roles) {
if (!role.imageSrc || !role.generatedVisualAssetId) {
try {
const generatedVisual = this.characterVisualGenerator
? await this.characterVisualGenerator({
role,
draftProfile: nextDraftProfile,
})
: this.config
? await createFallbackCharacterVisual({
config: this.config,
role,
})
: null;
if (generatedVisual) {
role.imageSrc = generatedVisual.imageSrc;
role.generatedVisualAssetId = generatedVisual.generatedVisualAssetId;
}
} catch (error) {
try {
const fallbackVisual = this.config
? await createFallbackCharacterVisual({
config: this.config,
role,
})
: null;
if (fallbackVisual) {
role.imageSrc = fallbackVisual.imageSrc;
role.generatedVisualAssetId =
fallbackVisual.generatedVisualAssetId;
} else {
warnings.push(
`角色主形象生成失败:${role.name}${error instanceof Error ? error.message : 'unknown error'}`,
);
}
} catch (fallbackError) {
// 角色主形象属于增强链路,主生成与回退都失败时仅记录告警,不阻断世界底稿主链。
warnings.push(
`角色主形象生成失败:${role.name}${fallbackError instanceof Error ? fallbackError.message : error instanceof Error ? error.message : 'unknown error'}`,
);
}
}
}
completedRoleCount += 1;
if (params.onProgress) {
await params.onProgress({
phaseLabel: '生成角色主形象',
phaseDetail: `正在生成角色主形象 ${completedRoleCount}/${totalRoleCount}${role.name}`,
progress:
97 +
Math.min(
1,
Math.round((completedRoleCount / Math.max(1, totalRoleCount)) * 1),
),
});
}
}
for (const sceneChapter of nextDraftProfile.sceneChapters) {
for (const act of sceneChapter.acts) {
let imageSrc = toText(act.backgroundImageSrc) || null;
let assetId = toText(act.backgroundAssetId) || null;
const primaryRole = findRoleById(
nextDraftProfile,
act.primaryNpcId || act.encounterNpcIds[0] || '',
);
const supportRoleNames = act.encounterNpcIds
.slice(1)
.map((roleId) => findRoleById(nextDraftProfile, roleId)?.name || '')
.filter(Boolean);
if (!imageSrc && this.sceneActBackgroundGenerator) {
try {
const result = await this.sceneActBackgroundGenerator({
draftProfile: nextDraftProfile,
sceneName: sceneChapter.sceneName,
act,
primaryRoleName: primaryRole?.name || '',
supportRoleNames,
});
imageSrc = result.imageSrc;
assetId = result.assetId;
act.backgroundImageSrc = result.imageSrc;
act.backgroundAssetId = result.assetId;
} catch (error) {
try {
const fallbackScene = this.config
? await CustomWorldAgentAutoAssetService.createFallbackSceneActBackgroundGenerator(
this.config,
)({
draftProfile: nextDraftProfile,
sceneName: sceneChapter.sceneName,
act,
primaryRoleName: primaryRole?.name || '',
supportRoleNames,
})
: null;
if (fallbackScene) {
imageSrc = fallbackScene.imageSrc;
assetId = fallbackScene.assetId;
act.backgroundImageSrc = fallbackScene.imageSrc;
act.backgroundAssetId = fallbackScene.assetId;
} else {
warnings.push(
`幕背景图生成失败:${sceneChapter.sceneName} / ${act.title}${error instanceof Error ? error.message : 'unknown error'}`,
);
}
} catch (fallbackError) {
// 幕图失败允许草稿继续生成;只有主生成与回退都失败时才保留缺口告警。
warnings.push(
`幕背景图生成失败:${sceneChapter.sceneName} / ${act.title}${fallbackError instanceof Error ? fallbackError.message : error instanceof Error ? error.message : 'unknown error'}`,
);
}
}
}
sceneAssetSummaries.push({
sceneId: sceneChapter.sceneId,
sceneName: sceneChapter.sceneName,
actId: act.id,
actTitle: act.title,
imageSrc,
assetId,
status: imageSrc ? 'ready' : 'missing',
nextPointCost: imageSrc ? 0 : 12,
});
completedActCount += 1;
if (params.onProgress) {
await params.onProgress({
phaseLabel: '生成幕背景图',
phaseDetail: `正在生成幕背景图 ${completedActCount}/${totalActCount}${sceneChapter.sceneName} · ${act.title}`,
progress:
98 +
Math.min(
1,
Math.round((completedActCount / Math.max(1, totalActCount)) * 1),
),
});
}
}
}
const roleAssets = roles.map((role) => ({
roleId: role.id,
roleName: role.name,
roleKind: nextDraftProfile.playableNpcs.some((entry) => entry.id === role.id)
? ('playable' as const)
: ('story' as const),
priorityTier: nextDraftProfile.playableNpcs.some((entry) => entry.id === role.id)
? ('hero' as const)
: ('featured' as const),
portraitPath: role.imageSrc || null,
generatedVisualAssetId: role.generatedVisualAssetId || null,
generatedAnimationSetId: role.generatedAnimationSetId || null,
status: role.imageSrc && role.generatedVisualAssetId ? 'visual_ready' : 'missing',
missingAnimations: [],
nextPointCost: role.imageSrc && role.generatedVisualAssetId ? 0 : 20,
}));
return {
draftProfile: nextDraftProfile,
assetCoverage: {
roleAssets,
sceneAssets: sceneAssetSummaries,
allRoleAssetsReady:
roleAssets.length > 0 &&
roleAssets.every((entry) => entry.status !== 'missing'),
allSceneAssetsReady:
sceneAssetSummaries.length > 0 &&
sceneAssetSummaries.every((entry) => entry.status === 'ready'),
},
warnings,
};
}
static createFallbackCharacterVisualGenerator(config: AppConfig): CharacterVisualGenerator {
return async ({ role, draftProfile }) => {
const assetId = createGeneratedAssetId('draft-role-visual');
const roleSegment = sanitizeSegment(role.id || role.name, 'role');
const relativeDir = path.join(
'generated-characters',
roleSegment,
'visual',
assetId,
);
const outputDir = path.join(config.publicDir, relativeDir);
fs.mkdirSync(outputDir, { recursive: true });
const fileName = 'master.png';
await writePlaceholderPng({
outputPath: path.join(outputDir, fileName),
width: 1024,
height: 1024,
rgb: [78, 134, 220],
});
const finalPrompt = buildNpcVisualPrompt(
buildRoleVisualSeedText(role, draftProfile),
);
fs.writeFileSync(
path.join(outputDir, 'manifest.json'),
`${JSON.stringify(
{
assetId,
roleId: role.id,
roleName: role.name,
prompt: finalPrompt,
fallback: true,
createdAt: new Date().toISOString(),
},
null,
2,
)}\n`,
'utf8',
);
return {
imageSrc: `/${relativeDir.replace(/\\/gu, '/')}/${fileName}`,
generatedVisualAssetId: assetId,
};
};
}
static createDashScopeCharacterVisualGenerator(
config: AppConfig,
): CharacterVisualGenerator {
return async ({ role, draftProfile }) => {
const prompt = buildNpcVisualPrompt(
buildRoleVisualSeedText(role, draftProfile),
);
const assetId = `draft-role-visual-${Date.now().toString(36)}`;
const roleSegment = sanitizeSegment(role.id || role.name, 'role');
const taskId = await createDashScopeTextToImageTask({
config,
prompt,
negativePrompt: buildNpcVisualNegativePrompt(),
size: '1024*1024',
model: config.dashScope.imageModel || 'qwen-image-2.0',
});
const { imageUrl, actualPrompt } = await waitForDashScopeImage({
config,
taskId,
});
const relativeDir = path.join(
'generated-characters',
roleSegment,
'visual',
assetId,
);
const imageSrc = await saveRemoteImage({
config,
imageUrl,
relativeDir,
fileBaseName: 'master',
manifest: {
assetId,
taskId,
roleId: role.id,
roleName: role.name,
prompt,
actualPrompt,
createdAt: new Date().toISOString(),
},
});
return {
imageSrc,
generatedVisualAssetId: assetId,
};
};
}
static createFallbackSceneActBackgroundGenerator(
config: AppConfig,
): SceneActBackgroundGenerator {
return async ({
draftProfile,
sceneName,
act,
primaryRoleName,
supportRoleNames,
}) => {
const finalPrompt = buildSceneActPrompt({
draftProfile,
sceneName,
act,
primaryRoleName,
supportRoleNames,
});
const assetId = createGeneratedAssetId('draft-scene-act');
const sceneSegment = sanitizeSegment(act.sceneId || sceneName, 'scene');
const actSegment = sanitizeSegment(act.id || act.title, 'act');
const relativeDir = path.join(
'generated-custom-world-scenes',
sceneSegment,
actSegment,
assetId,
);
const outputDir = path.join(config.publicDir, relativeDir);
fs.mkdirSync(outputDir, { recursive: true });
const fileName = 'scene.png';
await writePlaceholderPng({
outputPath: path.join(outputDir, fileName),
width: 1280,
height: 720,
rgb: [34, 52, 88],
});
fs.writeFileSync(
path.join(outputDir, 'manifest.json'),
`${JSON.stringify(
{
assetId,
sceneName,
actId: act.id,
actTitle: act.title,
prompt: finalPrompt,
fallback: true,
createdAt: new Date().toISOString(),
},
null,
2,
)}\n`,
'utf8',
);
return {
imageSrc: `/${relativeDir.replace(/\\/gu, '/')}/${fileName}`,
assetId,
};
};
}
static createDashScopeSceneActBackgroundGenerator(
config: AppConfig,
): SceneActBackgroundGenerator {
return async ({
draftProfile,
sceneName,
act,
primaryRoleName,
supportRoleNames,
}) => {
const prompt = buildSceneActPrompt({
draftProfile,
sceneName,
act,
primaryRoleName,
supportRoleNames,
});
const assetId = createGeneratedAssetId('draft-scene-act');
const sceneSegment = sanitizeSegment(act.sceneId || sceneName, 'scene');
const actSegment = sanitizeSegment(act.id || act.title, 'act');
const taskId = await createDashScopeTextToImageTask({
config,
prompt,
size: '1280*720',
model: config.dashScope.imageModel || 'wan2.2-t2i-flash',
});
const { imageUrl, actualPrompt } = await waitForDashScopeImage({
config,
taskId,
});
const relativeDir = path.join(
'generated-custom-world-scenes',
sceneSegment,
actSegment,
assetId,
);
const imageSrc = await saveRemoteImage({
config,
imageUrl,
relativeDir,
fileBaseName: 'scene',
manifest: {
assetId,
taskId,
sceneName,
actId: act.id,
actTitle: act.title,
prompt,
actualPrompt,
createdAt: new Date().toISOString(),
},
});
return {
imageSrc,
assetId,
};
};
}
}

View File

@@ -1024,8 +1024,8 @@ function buildWorldWarnings(profile: CustomWorldFoundationDraftProfile) {
if (totalCharacters < 3) { if (totalCharacters < 3) {
warnings.push('关键角色数量还偏少,建议继续补角色关系网。'); warnings.push('关键角色数量还偏少,建议继续补角色关系网。');
} }
if (profile.landmarks.length < 4) { if (profile.landmarks.length < 2) {
warnings.push('关键地点仍然偏少,第一版游历路径还不够饱满。'); warnings.push('关键地点仍然偏少,第一版场景章节还不够饱满。');
} }
return warnings; return warnings;
} }

View File

@@ -4,6 +4,7 @@ import type {
CustomWorldFoundationDraftFaction, CustomWorldFoundationDraftFaction,
CustomWorldFoundationDraftLandmark, CustomWorldFoundationDraftLandmark,
CustomWorldFoundationDraftProfile, CustomWorldFoundationDraftProfile,
CustomWorldFoundationDraftSceneChapter,
CustomWorldFoundationDraftThread, CustomWorldFoundationDraftThread,
EightAnchorContent, EightAnchorContent,
} from '../../../packages/shared/src/contracts/customWorldAgent.js'; } from '../../../packages/shared/src/contracts/customWorldAgent.js';
@@ -575,10 +576,25 @@ function buildCharacters(params: {
return dedupeStrings( return dedupeStrings(
characters.map((entry) => entry.name), characters.map((entry) => entry.name),
5, FOUNDATION_DRAFT_PLAYABLE_COUNT + FOUNDATION_DRAFT_STORY_COUNT,
).map((name) => characters.find((entry) => entry.name === name)!); ).map((name) => characters.find((entry) => entry.name === name)!);
} }
function splitDraftCharacters(params: {
characters: CustomWorldFoundationDraftCharacter[];
playableCount: number;
storyCount: number;
}) {
const playableNpcs = params.characters.slice(0, params.playableCount);
const storyNpcs = params.characters
.slice(params.playableCount, params.playableCount + params.storyCount);
return {
playableNpcs,
storyNpcs,
};
}
function buildCamp(params: { function buildCamp(params: {
openingSituation: string; openingSituation: string;
worldHook: string; worldHook: string;
@@ -776,9 +792,9 @@ function buildChapter(params: {
}; };
} }
const FOUNDATION_DRAFT_PLAYABLE_COUNT = 3; const FOUNDATION_DRAFT_PLAYABLE_COUNT = 1;
const FOUNDATION_DRAFT_STORY_COUNT = 6; const FOUNDATION_DRAFT_STORY_COUNT = 8;
const FOUNDATION_DRAFT_LANDMARK_COUNT = 4; const FOUNDATION_DRAFT_LANDMARK_COUNT = 2;
const FOUNDATION_ROLE_OUTLINE_BATCH_SIZE = 2; const FOUNDATION_ROLE_OUTLINE_BATCH_SIZE = 2;
const FOUNDATION_LANDMARK_BATCH_SIZE = 2; const FOUNDATION_LANDMARK_BATCH_SIZE = 2;
const FOUNDATION_ROLE_DETAIL_BATCH_SIZE = 2; const FOUNDATION_ROLE_DETAIL_BATCH_SIZE = 2;
@@ -798,6 +814,153 @@ type MergeableNamedRecord = {
name: string; name: string;
}; };
function buildFallbackSceneActStageCoverage(index: number, actCount: number) {
if (actCount <= 2) {
return index === 0
? (['opening', 'expansion'] as const)
: (['turning_point', 'climax', 'aftermath'] as const);
}
if (actCount === 3) {
return index === 0
? (['opening'] as const)
: index === 1
? (['expansion', 'turning_point'] as const)
: (['climax', 'aftermath'] as const);
}
if (actCount === 4) {
return index === 0
? (['opening'] as const)
: index === 1
? (['expansion'] as const)
: index === 2
? (['turning_point'] as const)
: (['climax', 'aftermath'] as const);
}
return (
[
['opening'],
['expansion'],
['turning_point'],
['climax'],
['aftermath'],
][index] ?? ['aftermath']
) as readonly string[];
}
function buildSceneChaptersFromDraft(params: {
landmarks: CustomWorldFoundationDraftLandmark[];
playableNpcs: CustomWorldFoundationDraftCharacter[];
storyNpcs: CustomWorldFoundationDraftCharacter[];
threads: CustomWorldFoundationDraftThread[];
}): CustomWorldFoundationDraftSceneChapter[] {
const leadPlayable = params.playableNpcs[0] ?? null;
const sceneRoles = params.storyNpcs;
return params.landmarks.slice(0, FOUNDATION_DRAFT_LANDMARK_COUNT).map((landmark, index) => {
const linkedThreadIds =
landmark.threadIds.length > 0
? landmark.threadIds.slice(0, 3)
: params.threads
.filter((thread) => thread.landmarkIds.includes(landmark.id))
.map((thread) => thread.id)
.slice(0, 3);
const baseNpcIds = landmark.characterIds.length > 0
? landmark.characterIds
: sceneRoles.slice(index * 3, index * 3 + 3).map((role) => role.id);
const uniqueNpcIds = [...new Set(baseNpcIds)].filter(Boolean);
const primaryIds = uniqueNpcIds.slice(0, 3);
const fallbackPrimaryIds = sceneRoles
.filter((role) => !primaryIds.includes(role.id))
.slice(0, 3 - primaryIds.length)
.map((role) => role.id);
const actPrimaryIds = [...primaryIds, ...fallbackPrimaryIds].slice(0, 3);
const supportPool = [
...uniqueNpcIds,
...sceneRoles.map((role) => role.id),
...(leadPlayable ? [leadPlayable.id] : []),
].filter(Boolean);
const acts = actPrimaryIds.map((primaryNpcId, actIndex) => {
const supportIds = supportPool.filter((roleId) => roleId !== primaryNpcId);
const orderedEncounterNpcIds = [
primaryNpcId,
...supportIds.slice(0, 2),
];
const primaryRole =
sceneRoles.find((role) => role.id === primaryNpcId) ?? leadPlayable;
const supportRoles = orderedEncounterNpcIds
.slice(1)
.map((roleId) =>
sceneRoles.find((role) => role.id === roleId) ??
(leadPlayable?.id === roleId ? leadPlayable : null),
)
.filter((role): role is CustomWorldFoundationDraftCharacter => Boolean(role));
return {
id: `${landmark.id}-act-${actIndex + 1}`,
title:
actIndex === 0
? `${landmark.name}起势`
: actIndex === 1
? `${landmark.name}承压`
: `${landmark.name}收束`,
summary: clampText(
[
actIndex === 0
? `这一幕先由${primaryRole?.name || '主角色'}把玩家带进${landmark.name}的当前压力。`
: actIndex === 1
? `${primaryRole?.name || '主角色'}会把${landmark.name}的冲突真正抬上台面。`
: `${primaryRole?.name || '主角色'}会负责把这一章收束并抛出下一跳。`,
landmark.summary,
].join(' '),
120,
),
stageCoverage: buildFallbackSceneActStageCoverage(actIndex, 3),
backgroundImageSrc: null,
backgroundAssetId: null,
encounterNpcIds: orderedEncounterNpcIds,
primaryNpcId,
linkedThreadIds,
actGoal:
actIndex === 0
? `让玩家先接住${landmark.name}的入口压力`
: actIndex === 1
? `${landmark.name}的冲突推到不可回避`
: `${landmark.name}这一章收住并抛向下一跳`,
transitionHook:
actIndex === 0
? `${supportRoles[0]?.name || '另一名角色'}会在这一幕后继续加压。`
: actIndex === 1
? `这一幕结束后,${primaryRole?.name || '主角色'}会逼玩家接住最终选择。`
: '这一幕结束后要把下一步去向和关系压力一起抛给玩家。',
advanceRule:
actIndex === 0
? 'after_primary_contact'
: actIndex === 2
? 'after_chapter_resolution'
: 'after_active_step_complete',
};
});
return {
id: `scene-chapter-${landmark.id}`,
sceneId: landmark.id,
sceneName: landmark.name,
title: `${landmark.name}章节`,
summary: clampText(
`${landmark.name}会按三幕推进:先起势、再承压、最后收束。`,
120,
),
linkedThreadIds,
linkedLandmarkIds: [landmark.id],
acts,
} satisfies CustomWorldFoundationDraftSceneChapter;
});
}
function getNamedRecordKey(value: unknown) { function getNamedRecordKey(value: unknown) {
return toText(value).replace(/\s+/gu, ''); return toText(value).replace(/\s+/gu, '');
} }
@@ -1533,6 +1696,12 @@ function convertRuntimeProfileToFoundationDraft(params: {
landmarks, landmarks,
threads, threads,
}); });
const sceneChapters = buildSceneChaptersFromDraft({
landmarks,
playableNpcs,
storyNpcs,
threads,
});
const anchorRecord = toRecord(params.anchorPack); const anchorRecord = toRecord(params.anchorPack);
return { return {
@@ -1571,6 +1740,7 @@ function convertRuntimeProfileToFoundationDraft(params: {
factions, factions,
threads, threads,
chapters: [chapter], chapters: [chapter],
sceneChapters,
worldHook: worldHook:
clampText(params.intent.worldHook || params.profile.summary, 72) || clampText(params.intent.worldHook || params.profile.summary, 72) ||
params.profile.summary, params.profile.summary,
@@ -1793,7 +1963,12 @@ export class CustomWorldAgentFoundationDraftService {
threads: baseThreads, threads: baseThreads,
coreConflicts, coreConflicts,
iconicElements, iconicElements,
}).slice(0, 5); }).slice(0, FOUNDATION_DRAFT_PLAYABLE_COUNT + FOUNDATION_DRAFT_STORY_COUNT);
const { playableNpcs, storyNpcs } = splitDraftCharacters({
characters,
playableCount: FOUNDATION_DRAFT_PLAYABLE_COUNT,
storyCount: FOUNDATION_DRAFT_STORY_COUNT,
});
const camp = buildCamp({ const camp = buildCamp({
openingSituation, openingSituation,
worldHook, worldHook,
@@ -1803,12 +1978,12 @@ export class CustomWorldAgentFoundationDraftService {
intent, intent,
camp, camp,
factions, factions,
characters, characters: [...playableNpcs, ...storyNpcs],
threads: baseThreads, threads: baseThreads,
coreConflicts, coreConflicts,
iconicElements, iconicElements,
openingSituation, openingSituation,
}).slice(0, 6); }).slice(0, FOUNDATION_DRAFT_LANDMARK_COUNT);
const threads = finalizeThreads({ const threads = finalizeThreads({
threads: baseThreads.slice(0, 4), threads: baseThreads.slice(0, 4),
characters, characters,
@@ -1818,7 +1993,7 @@ export class CustomWorldAgentFoundationDraftService {
worldName, worldName,
openingSituation, openingSituation,
playerGoal, playerGoal,
characters, characters: [...playableNpcs, ...storyNpcs],
landmarks, landmarks,
threads, threads,
}); });
@@ -1851,8 +2026,8 @@ export class CustomWorldAgentFoundationDraftService {
playerGoal, playerGoal,
majorFactions: factions.map((entry) => entry.name), majorFactions: factions.map((entry) => entry.name),
coreConflicts, coreConflicts,
playableNpcs: characters, playableNpcs,
storyNpcs: [], storyNpcs,
landmarks, landmarks,
camp, camp,
themePack: null, themePack: null,
@@ -1860,6 +2035,12 @@ export class CustomWorldAgentFoundationDraftService {
factions, factions,
threads, threads,
chapters: [chapter], chapters: [chapter],
sceneChapters: buildSceneChaptersFromDraft({
landmarks,
playableNpcs,
storyNpcs,
threads,
}),
worldHook, worldHook,
playerPremise, playerPremise,
openingSituation, openingSituation,

View File

@@ -17,6 +17,7 @@ import type {
import { badRequest, notFound } from '../errors.js'; import { badRequest, notFound } from '../errors.js';
import { prepareEventStreamResponse } from '../http.js'; import { prepareEventStreamResponse } from '../http.js';
import { CustomWorldAgentAssetBridgeService } from './customWorldAgentAssetBridgeService.js'; import { CustomWorldAgentAssetBridgeService } from './customWorldAgentAssetBridgeService.js';
import { CustomWorldAgentAutoAssetService } from './customWorldAgentAutoAssetService.js';
import { CustomWorldAgentChangeSummaryService } from './customWorldAgentChangeSummaryService.js'; import { CustomWorldAgentChangeSummaryService } from './customWorldAgentChangeSummaryService.js';
import { import {
buildPendingClarifications, buildPendingClarifications,
@@ -274,10 +275,12 @@ function buildWelcomeMessage(params: {
function buildFoundationDraftAssistantMessage(params: { function buildFoundationDraftAssistantMessage(params: {
relatedOperationId: string; relatedOperationId: string;
draftProfile: unknown; draftProfile: unknown;
warnings?: string[];
}) { }) {
const profile = normalizeFoundationDraftProfile(params.draftProfile); const profile = normalizeFoundationDraftProfile(params.draftProfile);
const leadCharacter = profile?.playableNpcs[0]; const leadCharacter = profile?.playableNpcs[0];
const leadLandmark = profile?.landmarks[0]; const leadLandmark = profile?.landmarks[0];
const warnings = (params.warnings ?? []).filter(Boolean);
return { return {
id: `message-${crypto.randomBytes(8).toString('hex')}`, id: `message-${crypto.randomBytes(8).toString('hex')}`,
@@ -288,6 +291,12 @@ function buildFoundationDraftAssistantMessage(params: {
'', '',
`当前已经落下来的第一批对象数量是:关键角色 ${profile?.playableNpcs.length ?? 0} 个,关键地点 ${profile?.landmarks.length ?? 0} 个,势力 ${profile?.factions.length ?? 0} 个。`, `当前已经落下来的第一批对象数量是:关键角色 ${profile?.playableNpcs.length ?? 0} 个,关键地点 ${profile?.landmarks.length ?? 0} 个,势力 ${profile?.factions.length ?? 0} 个。`,
`建议你先从“${profile?.name || '世界总卡'}”这张世界总卡看起${leadCharacter ? `,再顺着角色「${leadCharacter.name}」往下细修` : ''}${leadLandmark ? `,地点可以先看「${leadLandmark.name}` : ''}`, `建议你先从“${profile?.name || '世界总卡'}”这张世界总卡看起${leadCharacter ? `,再顺着角色「${leadCharacter.name}」往下细修` : ''}${leadLandmark ? `,地点可以先看「${leadLandmark.name}` : ''}`,
...(warnings.length > 0
? [
'',
`这一轮有 ${warnings.length} 项资产补齐未完成,但不影响世界底稿继续精修。`,
]
: []),
].join('\n'), ].join('\n'),
createdAt: new Date().toISOString(), createdAt: new Date().toISOString(),
relatedOperationId: params.relatedOperationId, relatedOperationId: params.relatedOperationId,
@@ -332,6 +341,8 @@ export class CustomWorldAgentOrchestrator {
private readonly assetBridgeService: CustomWorldAgentAssetBridgeService; private readonly assetBridgeService: CustomWorldAgentAssetBridgeService;
private readonly autoAssetService: CustomWorldAgentAutoAssetService | null;
private readonly eightAnchorSingleTurnService: EightAnchorSingleTurnService; private readonly eightAnchorSingleTurnService: EightAnchorSingleTurnService;
constructor( constructor(
@@ -339,6 +350,7 @@ export class CustomWorldAgentOrchestrator {
llmClient: UpstreamLlmClient | null = null, llmClient: UpstreamLlmClient | null = null,
options: { options: {
singleTurnLlmClient?: UpstreamLlmClient | null; singleTurnLlmClient?: UpstreamLlmClient | null;
autoAssetService?: CustomWorldAgentAutoAssetService | null;
} = {}, } = {},
) { ) {
this.foundationDraftService = new CustomWorldAgentFoundationDraftService( this.foundationDraftService = new CustomWorldAgentFoundationDraftService(
@@ -350,6 +362,8 @@ export class CustomWorldAgentOrchestrator {
); );
this.changeSummaryService = new CustomWorldAgentChangeSummaryService(); this.changeSummaryService = new CustomWorldAgentChangeSummaryService();
this.assetBridgeService = new CustomWorldAgentAssetBridgeService(); this.assetBridgeService = new CustomWorldAgentAssetBridgeService();
this.autoAssetService =
options.autoAssetService ?? null;
this.eightAnchorSingleTurnService = new EightAnchorSingleTurnService( this.eightAnchorSingleTurnService = new EightAnchorSingleTurnService(
(options.singleTurnLlmClient ?? llmClient) ?? undefined, (options.singleTurnLlmClient ?? llmClient) ?? undefined,
); );
@@ -844,9 +858,9 @@ export class CustomWorldAgentOrchestrator {
try { try {
await this.sessionStore.updateOperation(userId, sessionId, operationId, { await this.sessionStore.updateOperation(userId, sessionId, operationId, {
status: 'running', status: 'running',
phaseLabel: '生成世界底稿', phaseLabel: '整理世界骨架',
phaseDetail: '正在根据已确认设定编译第一版世界结构。', phaseDetail: '正在校验已确认锚点,并准备第一版世界框架生成链路。',
progress: 38, progress: 12,
}); });
await sleep(30); await sleep(30);
@@ -890,19 +904,44 @@ export class CustomWorldAgentOrchestrator {
}, },
}); });
const draftWithAssets = this.autoAssetService
? await this.autoAssetService.populateDraftAssets({
draftProfile,
onProgress: async (progress) => {
await this.sessionStore.updateOperation(
userId,
sessionId,
operationId,
{
status: 'running',
phaseLabel: progress.phaseLabel,
phaseDetail: progress.phaseDetail,
progress: progress.progress,
},
);
},
})
: {
draftProfile,
assetCoverage: rebuildRoleAssetCoverage(draftProfile),
warnings: [],
};
await this.sessionStore.updateOperation(userId, sessionId, operationId, { await this.sessionStore.updateOperation(userId, sessionId, operationId, {
phaseLabel: '编译草稿卡', phaseLabel: '编译草稿卡',
phaseDetail: '正在把世界底稿整理成可浏览的卡片摘要和详情结构。', phaseDetail: '正在把世界底稿整理成可浏览的卡片摘要和详情结构。',
progress: 98, progress: 98,
}); });
const draftCards = this.draftCompiler.compileDraftCards(draftProfile); const draftCards = this.draftCompiler.compileDraftCards(
const assetCoverage = rebuildRoleAssetCoverage(draftProfile); draftWithAssets.draftProfile,
);
const assetCoverage = draftWithAssets.assetCoverage;
const nextStage = 'object_refining' as const; const nextStage = 'object_refining' as const;
const nextSuggestedActions = buildSuggestedActions({ const nextSuggestedActions = buildSuggestedActions({
stage: nextStage, stage: nextStage,
isReady: true, isReady: true,
draftProfile, draftProfile: draftWithAssets.draftProfile,
draftCards, draftCards,
}); });
@@ -910,7 +949,8 @@ export class CustomWorldAgentOrchestrator {
stage: nextStage, stage: nextStage,
creatorIntent, creatorIntent,
anchorPack, anchorPack,
draftProfile: draftProfile as unknown as Record<string, unknown>, draftProfile:
draftWithAssets.draftProfile as unknown as Record<string, unknown>,
draftCards, draftCards,
assetCoverage, assetCoverage,
pendingClarifications: [], pendingClarifications: [],
@@ -925,22 +965,34 @@ export class CustomWorldAgentOrchestrator {
sessionId, sessionId,
buildFoundationDraftAssistantMessage({ buildFoundationDraftAssistantMessage({
relatedOperationId: operationId, relatedOperationId: operationId,
draftProfile, draftProfile: draftWithAssets.draftProfile,
warnings: draftWithAssets.warnings,
}), }),
); );
await this.sessionStore.updateOperation(userId, sessionId, operationId, { await this.sessionStore.updateOperation(userId, sessionId, operationId, {
status: 'completed', status: 'completed',
phaseLabel: '世界底稿已生成', phaseLabel: '世界底稿已生成',
phaseDetail: `第一版世界底稿和 ${draftCards.length} 张草稿卡已经整理完成。`, phaseDetail:
draftWithAssets.warnings.length > 0
? `第一版世界底稿和 ${draftCards.length} 张草稿卡已经整理完成,另有 ${draftWithAssets.warnings.length} 项资产补齐待后续处理。`
: `第一版世界底稿和 ${draftCards.length} 张草稿卡已经整理完成。`,
progress: 100, progress: 100,
error: null, error: null,
}); });
} catch (error) { } catch (error) {
const currentOperation = await this.sessionStore.getOperation(
userId,
sessionId,
operationId,
);
await this.sessionStore.updateOperation(userId, sessionId, operationId, { await this.sessionStore.updateOperation(userId, sessionId, operationId, {
status: 'failed', status: 'failed',
phaseLabel: '底稿生成失败', phaseLabel:
phaseDetail: '这一轮没有成功把设定编成世界底稿。', currentOperation?.phaseLabel?.trim() || '底稿生成失败',
phaseDetail:
currentOperation?.phaseDetail?.trim() ||
'这一轮没有成功把设定编成世界底稿。',
progress: 100, progress: 100,
error: error:
error instanceof Error ? error.message : 'draft foundation failed', error instanceof Error ? error.message : 'draft foundation failed',

View File

@@ -1,8 +1,14 @@
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import test from 'node:test'; import test from 'node:test';
import type { CustomWorldSessionRecord } from '../../../packages/shared/src/contracts/runtime.js'; import type { CustomWorldSessionRecord } from '../../../packages/shared/src/contracts/runtime.js';
import type { AppConfig } from '../config.js';
import type { RuntimeRepositoryPort } from '../repositories/runtimeRepository.js'; import type { RuntimeRepositoryPort } from '../repositories/runtimeRepository.js';
import { CustomWorldAgentAutoAssetService } from './customWorldAgentAutoAssetService.js';
import { normalizeFoundationDraftProfile } from './customWorldAgentDraftCompiler.js';
import { CustomWorldAgentOrchestrator } from './customWorldAgentOrchestrator.js'; import { CustomWorldAgentOrchestrator } from './customWorldAgentOrchestrator.js';
import { CustomWorldAgentSessionStore } from './customWorldAgentSessionStore.js'; import { CustomWorldAgentSessionStore } from './customWorldAgentSessionStore.js';
import { createTestCustomWorldAgentSingleTurnLlmClient } from './customWorldAgentTestHelpers.js'; import { createTestCustomWorldAgentSingleTurnLlmClient } from './customWorldAgentTestHelpers.js';
@@ -88,6 +94,102 @@ function createRuntimeRepositoryStub(): RuntimeRepositoryPort {
}; };
} }
function createAutoAssetTestConfig(testName: string): AppConfig {
const projectRoot = fs.mkdtempSync(
path.join(os.tmpdir(), `genarrative-agent-phase3-${testName}-`),
);
return {
nodeEnv: 'test',
projectRoot,
publicDir: path.join(projectRoot, 'public'),
logsDir: path.join(projectRoot, 'logs'),
dataDir: path.join(projectRoot, 'data'),
rawEnv: {},
databaseUrl: `pg-mem://${testName}`,
serverAddr: ':0',
logLevel: 'silent',
editorApiEnabled: true,
assetsApiEnabled: true,
jwtSecret: 'test',
jwtExpiresIn: '7d',
jwtIssuer: 'test',
llm: {
baseUrl: 'https://example.invalid',
apiKey: '',
model: 'test-model',
},
dashScope: {
baseUrl: 'https://example.invalid',
apiKey: '',
imageModel: 'test-image-model',
requestTimeoutMs: 1000,
},
smsAuth: {
enabled: false,
provider: 'mock',
endpoint: '',
accessKeyId: '',
accessKeySecret: '',
signName: '',
templateCode: '',
templateParamKey: '',
countryCode: '86',
schemeName: '',
codeLength: 6,
codeType: 1,
validTimeSeconds: 300,
intervalSeconds: 60,
duplicatePolicy: 1,
caseAuthPolicy: 1,
returnVerifyCode: false,
mockVerifyCode: '123456',
maxSendPerPhonePerDay: 20,
maxSendPerIpPerHour: 30,
maxVerifyFailuresPerPhonePerHour: 12,
maxVerifyFailuresPerIpPerHour: 24,
captchaTtlSeconds: 180,
captchaTriggerVerifyFailuresPerPhone: 3,
captchaTriggerVerifyFailuresPerIp: 5,
blockPhoneFailureThreshold: 6,
blockIpFailureThreshold: 10,
blockPhoneDurationMinutes: 30,
blockIpDurationMinutes: 30,
},
wechatAuth: {
enabled: false,
provider: 'mock',
appId: '',
appSecret: '',
authorizeEndpoint: '',
accessTokenEndpoint: '',
userInfoEndpoint: '',
callbackPath: '',
defaultRedirectPath: '/',
mockUserId: '',
mockUnionId: '',
mockDisplayName: '',
mockAvatarUrl: '',
},
authSession: {
refreshCookieName: 'refresh_token',
refreshSessionTtlDays: 30,
refreshCookieSecure: false,
refreshCookieSameSite: 'Lax',
refreshCookiePath: '/',
},
};
}
function createFallbackAutoAssetService(testName: string) {
const config = createAutoAssetTestConfig(testName);
return new CustomWorldAgentAutoAssetService(
config,
CustomWorldAgentAutoAssetService.createFallbackCharacterVisualGenerator(config),
CustomWorldAgentAutoAssetService.createFallbackSceneActBackgroundGenerator(config),
);
}
async function waitForOperation( async function waitForOperation(
orchestrator: CustomWorldAgentOrchestrator, orchestrator: CustomWorldAgentOrchestrator,
userId: string, userId: string,
@@ -161,6 +263,7 @@ test('phase3 ready session can execute draft_foundation and expose card detail',
const sessionStore = new CustomWorldAgentSessionStore(runtimeRepository); const sessionStore = new CustomWorldAgentSessionStore(runtimeRepository);
const orchestrator = new CustomWorldAgentOrchestrator(sessionStore, null, { const orchestrator = new CustomWorldAgentOrchestrator(sessionStore, null, {
singleTurnLlmClient: createTestCustomWorldAgentSingleTurnLlmClient(), singleTurnLlmClient: createTestCustomWorldAgentSingleTurnLlmClient(),
autoAssetService: createFallbackAutoAssetService('draft'),
}); });
const userId = 'user-phase3-draft'; const userId = 'user-phase3-draft';
const readySession = await createReadySession(orchestrator, userId); const readySession = await createReadySession(orchestrator, userId);
@@ -179,6 +282,16 @@ test('phase3 ready session can execute draft_foundation and expose card detail',
response.operation.operationId, response.operation.operationId,
); );
const snapshot = await orchestrator.getSessionSnapshot(userId, readySession.sessionId); const snapshot = await orchestrator.getSessionSnapshot(userId, readySession.sessionId);
const draftProfile = snapshot?.draftProfile as Record<string, unknown> | undefined;
const playableNpcs = Array.isArray(draftProfile?.playableNpcs)
? draftProfile?.playableNpcs
: [];
const storyNpcs = Array.isArray(draftProfile?.storyNpcs)
? draftProfile?.storyNpcs
: [];
const sceneChapters = Array.isArray(draftProfile?.sceneChapters)
? draftProfile?.sceneChapters
: [];
assert.equal(operation?.status, 'completed'); assert.equal(operation?.status, 'completed');
assert.equal(snapshot?.stage, 'object_refining'); assert.equal(snapshot?.stage, 'object_refining');
@@ -189,6 +302,23 @@ test('phase3 ready session can execute draft_foundation and expose card detail',
assert.ok(snapshot?.draftCards.some((card) => card.kind === 'landmark')); assert.ok(snapshot?.draftCards.some((card) => card.kind === 'landmark'));
assert.ok(snapshot?.draftCards.some((card) => card.kind === 'thread')); assert.ok(snapshot?.draftCards.some((card) => card.kind === 'thread'));
assert.ok(snapshot?.draftCards.some((card) => card.kind === 'chapter')); assert.ok(snapshot?.draftCards.some((card) => card.kind === 'chapter'));
assert.equal(playableNpcs.length, 1);
assert.ok(storyNpcs.length >= 4);
assert.equal(sceneChapters.length, 2);
assert.ok(
sceneChapters.every(
(entry) => Array.isArray((entry as { acts?: unknown[] }).acts) && ((entry as { acts?: unknown[] }).acts?.length ?? 0) === 3,
),
);
assert.ok(
playableNpcs.every(
(entry) =>
typeof (entry as { imageSrc?: unknown }).imageSrc === 'string' &&
typeof (entry as { generatedVisualAssetId?: unknown }).generatedVisualAssetId === 'string',
),
);
assert.ok((snapshot?.assetCoverage.sceneAssets.length ?? 0) >= 6);
assert.equal(snapshot?.assetCoverage.allSceneAssetsReady, true);
assert.equal( assert.equal(
typeof (snapshot?.draftProfile as Record<string, unknown>)?.name, typeof (snapshot?.draftProfile as Record<string, unknown>)?.name,
'string', 'string',
@@ -221,6 +351,7 @@ test('phase3 draft_foundation rejects not-ready session', async () => {
const sessionStore = new CustomWorldAgentSessionStore(runtimeRepository); const sessionStore = new CustomWorldAgentSessionStore(runtimeRepository);
const orchestrator = new CustomWorldAgentOrchestrator(sessionStore, null, { const orchestrator = new CustomWorldAgentOrchestrator(sessionStore, null, {
singleTurnLlmClient: createTestCustomWorldAgentSingleTurnLlmClient(), singleTurnLlmClient: createTestCustomWorldAgentSingleTurnLlmClient(),
autoAssetService: createFallbackAutoAssetService('not-ready'),
}); });
const userId = 'user-phase3-not-ready'; const userId = 'user-phase3-not-ready';
const createdSession = await orchestrator.createSession(userId, { const createdSession = await orchestrator.createSession(userId, {
@@ -241,6 +372,7 @@ test('phase3 work summaries prefer compiled foundation draft fields', async () =
const sessionStore = new CustomWorldAgentSessionStore(runtimeRepository); const sessionStore = new CustomWorldAgentSessionStore(runtimeRepository);
const orchestrator = new CustomWorldAgentOrchestrator(sessionStore, null, { const orchestrator = new CustomWorldAgentOrchestrator(sessionStore, null, {
singleTurnLlmClient: createTestCustomWorldAgentSingleTurnLlmClient(), singleTurnLlmClient: createTestCustomWorldAgentSingleTurnLlmClient(),
autoAssetService: createFallbackAutoAssetService('summary'),
}); });
const userId = 'user-phase3-summary'; const userId = 'user-phase3-summary';
const readySession = await createReadySession(orchestrator, userId); const readySession = await createReadySession(orchestrator, userId);
@@ -264,10 +396,70 @@ test('phase3 work summaries prefer compiled foundation draft fields', async () =
customWorldAgentSessions: sessionStore, customWorldAgentSessions: sessionStore,
}); });
const draft = items.find((item) => item.sessionId === readySession.sessionId); const draft = items.find((item) => item.sessionId === readySession.sessionId);
const compiledProfile = normalizeFoundationDraftProfile(
(
await orchestrator.getSessionSnapshot(userId, readySession.sessionId)
)?.draftProfile,
);
const totalRoleCount = [
...new Set(
[
...(compiledProfile?.playableNpcs ?? []),
...(compiledProfile?.storyNpcs ?? []),
].map((entry) => entry.id),
),
].length;
assert.ok(draft); assert.ok(draft);
assert.ok((draft?.playableNpcCount ?? 0) >= 3); assert.equal(draft?.playableNpcCount ?? 0, totalRoleCount);
assert.ok((draft?.landmarkCount ?? 0) >= 4); assert.equal(draft?.landmarkCount ?? 0, 2);
assert.match(draft?.summary ?? '', /||/u); assert.match(draft?.summary ?? '', /||/u);
assert.match(draft?.subtitle ?? '', /||/u); assert.match(draft?.subtitle ?? '', /||/u);
}); });
test('phase3 draft foundation still completes when auto asset generation fails', async () => {
const runtimeRepository = createRuntimeRepositoryStub();
const sessionStore = new CustomWorldAgentSessionStore(runtimeRepository);
const autoAssetService = new CustomWorldAgentAutoAssetService(
createAutoAssetTestConfig('asset-failure'),
async () => {
throw new Error('visual service timeout');
},
async () => {
throw new Error('scene service timeout');
},
);
const orchestrator = new CustomWorldAgentOrchestrator(sessionStore, null, {
singleTurnLlmClient: createTestCustomWorldAgentSingleTurnLlmClient(),
autoAssetService,
});
const userId = 'user-phase3-asset-failure';
const readySession = await createReadySession(orchestrator, userId);
const response = await orchestrator.executeAction(
userId,
readySession.sessionId,
{
action: 'draft_foundation',
},
);
const operation = await waitForOperation(
orchestrator,
userId,
readySession.sessionId,
response.operation.operationId,
);
const snapshot = await orchestrator.getSessionSnapshot(userId, readySession.sessionId);
assert.equal(operation?.status, 'completed');
assert.doesNotMatch(operation?.phaseDetail ?? '', //u);
assert.ok(snapshot?.draftCards.length);
assert.ok(
snapshot?.messages.every(
(message) =>
message.role !== 'assistant' || !message.text.includes('资产补齐未完成'),
),
);
assert.equal(snapshot?.assetCoverage.allRoleAssetsReady, true);
assert.equal(snapshot?.assetCoverage.allSceneAssetsReady, true);
});

View File

@@ -1,8 +1,13 @@
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import test from 'node:test'; import test from 'node:test';
import type { CustomWorldSessionRecord } from '../../../packages/shared/src/contracts/runtime.js'; import type { CustomWorldSessionRecord } from '../../../packages/shared/src/contracts/runtime.js';
import type { AppConfig } from '../config.js';
import type { RuntimeRepositoryPort } from '../repositories/runtimeRepository.js'; import type { RuntimeRepositoryPort } from '../repositories/runtimeRepository.js';
import { CustomWorldAgentAutoAssetService } from './customWorldAgentAutoAssetService.js';
import { normalizeFoundationDraftProfile } from './customWorldAgentDraftCompiler.js'; import { normalizeFoundationDraftProfile } from './customWorldAgentDraftCompiler.js';
import { CustomWorldAgentOrchestrator } from './customWorldAgentOrchestrator.js'; import { CustomWorldAgentOrchestrator } from './customWorldAgentOrchestrator.js';
import { CustomWorldAgentSessionStore } from './customWorldAgentSessionStore.js'; import { CustomWorldAgentSessionStore } from './customWorldAgentSessionStore.js';
@@ -88,6 +93,102 @@ function createRuntimeRepositoryStub(): RuntimeRepositoryPort {
}; };
} }
function createAutoAssetTestConfig(testName: string): AppConfig {
const projectRoot = fs.mkdtempSync(
path.join(os.tmpdir(), `genarrative-agent-phase5-${testName}-`),
);
return {
nodeEnv: 'test',
projectRoot,
publicDir: path.join(projectRoot, 'public'),
logsDir: path.join(projectRoot, 'logs'),
dataDir: path.join(projectRoot, 'data'),
rawEnv: {},
databaseUrl: `pg-mem://${testName}`,
serverAddr: ':0',
logLevel: 'silent',
editorApiEnabled: true,
assetsApiEnabled: true,
jwtSecret: 'test',
jwtExpiresIn: '7d',
jwtIssuer: 'test',
llm: {
baseUrl: 'https://example.invalid',
apiKey: '',
model: 'test-model',
},
dashScope: {
baseUrl: 'https://example.invalid',
apiKey: '',
imageModel: 'test-image-model',
requestTimeoutMs: 1000,
},
smsAuth: {
enabled: false,
provider: 'mock',
endpoint: '',
accessKeyId: '',
accessKeySecret: '',
signName: '',
templateCode: '',
templateParamKey: '',
countryCode: '86',
schemeName: '',
codeLength: 6,
codeType: 1,
validTimeSeconds: 300,
intervalSeconds: 60,
duplicatePolicy: 1,
caseAuthPolicy: 1,
returnVerifyCode: false,
mockVerifyCode: '123456',
maxSendPerPhonePerDay: 20,
maxSendPerIpPerHour: 30,
maxVerifyFailuresPerPhonePerHour: 12,
maxVerifyFailuresPerIpPerHour: 24,
captchaTtlSeconds: 180,
captchaTriggerVerifyFailuresPerPhone: 3,
captchaTriggerVerifyFailuresPerIp: 5,
blockPhoneFailureThreshold: 6,
blockIpFailureThreshold: 10,
blockPhoneDurationMinutes: 30,
blockIpDurationMinutes: 30,
},
wechatAuth: {
enabled: false,
provider: 'mock',
appId: '',
appSecret: '',
authorizeEndpoint: '',
accessTokenEndpoint: '',
userInfoEndpoint: '',
callbackPath: '',
defaultRedirectPath: '/',
mockUserId: '',
mockUnionId: '',
mockDisplayName: '',
mockAvatarUrl: '',
},
authSession: {
refreshCookieName: 'refresh_token',
refreshSessionTtlDays: 30,
refreshCookieSecure: false,
refreshCookieSameSite: 'Lax',
refreshCookiePath: '/',
},
};
}
function createFallbackAutoAssetService(testName: string) {
const config = createAutoAssetTestConfig(testName);
return new CustomWorldAgentAutoAssetService(
config,
CustomWorldAgentAutoAssetService.createFallbackCharacterVisualGenerator(config),
CustomWorldAgentAutoAssetService.createFallbackSceneActBackgroundGenerator(config),
);
}
async function waitForOperation( async function waitForOperation(
orchestrator: CustomWorldAgentOrchestrator, orchestrator: CustomWorldAgentOrchestrator,
userId: string, userId: string,
@@ -178,6 +279,7 @@ test('phase5 generate_role_assets only allows a single role and moves session in
const sessionStore = new CustomWorldAgentSessionStore(runtimeRepository); const sessionStore = new CustomWorldAgentSessionStore(runtimeRepository);
const orchestrator = new CustomWorldAgentOrchestrator(sessionStore, null, { const orchestrator = new CustomWorldAgentOrchestrator(sessionStore, null, {
singleTurnLlmClient: createTestCustomWorldAgentSingleTurnLlmClient(), singleTurnLlmClient: createTestCustomWorldAgentSingleTurnLlmClient(),
autoAssetService: createFallbackAutoAssetService('generate-role-assets'),
}); });
const userId = 'user-phase5-generate-role-assets'; const userId = 'user-phase5-generate-role-assets';
const session = await createObjectRefiningSession(orchestrator, userId); const session = await createObjectRefiningSession(orchestrator, userId);
@@ -217,6 +319,10 @@ test('phase5 generate_role_assets only allows a single role and moves session in
message.text.includes('角色资产工坊'), message.text.includes('角色资产工坊'),
), ),
); );
const preparedAssetSummary = snapshot?.assetCoverage.roleAssets.find(
(entry) => entry.roleId === characterIds[0],
);
assert.equal(preparedAssetSummary?.status, 'visual_ready');
}); });
test('phase5 sync_role_assets writes fields back, updates coverage and recompiles character cards', async () => { test('phase5 sync_role_assets writes fields back, updates coverage and recompiles character cards', async () => {
@@ -224,6 +330,7 @@ test('phase5 sync_role_assets writes fields back, updates coverage and recompile
const sessionStore = new CustomWorldAgentSessionStore(runtimeRepository); const sessionStore = new CustomWorldAgentSessionStore(runtimeRepository);
const orchestrator = new CustomWorldAgentOrchestrator(sessionStore, null, { const orchestrator = new CustomWorldAgentOrchestrator(sessionStore, null, {
singleTurnLlmClient: createTestCustomWorldAgentSingleTurnLlmClient(), singleTurnLlmClient: createTestCustomWorldAgentSingleTurnLlmClient(),
autoAssetService: createFallbackAutoAssetService('sync-role-assets'),
}); });
const userId = 'user-phase5-sync-role-assets'; const userId = 'user-phase5-sync-role-assets';
const session = await createObjectRefiningSession(orchestrator, userId); const session = await createObjectRefiningSession(orchestrator, userId);

View File

@@ -82,3 +82,48 @@ test('role asset summary treats idle and die as optional', () => {
assert.equal(summary.status, 'complete'); assert.equal(summary.status, 'complete');
assert.deepEqual(summary.missingAnimations, []); assert.deepEqual(summary.missingAnimations, []);
}); });
test('role asset coverage includes scene act background readiness', async () => {
const { rebuildRoleAssetCoverage } = await import(
'./customWorldAgentRoleAssetStateService.js'
);
const coverage = rebuildRoleAssetCoverage({
playableNpcs: [
{
id: 'role-playable',
name: '沈砺',
threadIds: ['thread-1'],
imageSrc: '/generated/role-playable.png',
generatedVisualAssetId: 'visual-role-playable',
skills: [],
},
],
storyNpcs: [],
sceneChapters: [
{
sceneId: 'scene-dock',
sceneName: '潮汐码头',
acts: [
{
id: 'scene-dock-act-1',
title: '雾里靠岸',
backgroundImageSrc: '/generated/scene-dock-act-1.png',
backgroundAssetId: 'scene-act-asset-1',
},
{
id: 'scene-dock-act-2',
title: '封锁加压',
backgroundImageSrc: '',
backgroundAssetId: '',
},
],
},
],
});
assert.equal(coverage.sceneAssets.length, 2);
assert.equal(coverage.sceneAssets[0]?.status, 'ready');
assert.equal(coverage.sceneAssets[1]?.status, 'missing');
assert.equal(coverage.allSceneAssetsReady, false);
});

View File

@@ -3,6 +3,7 @@ import type {
CustomWorldAssetPriorityTier, CustomWorldAssetPriorityTier,
CustomWorldRoleAssetStatus, CustomWorldRoleAssetStatus,
CustomWorldRoleAssetSummary, CustomWorldRoleAssetSummary,
CustomWorldSceneAssetSummary,
} from '../../../packages/shared/src/contracts/customWorldAgent.js'; } from '../../../packages/shared/src/contracts/customWorldAgent.js';
const REQUIRED_ROLE_ANIMATION_KEYS = ['run', 'attack'] as const; const REQUIRED_ROLE_ANIMATION_KEYS = ['run', 'attack'] as const;
@@ -26,6 +27,19 @@ type DraftRoleRecord = {
type DraftRoleKind = 'playable' | 'story'; type DraftRoleKind = 'playable' | 'story';
type DraftSceneActRecord = {
id: string;
title: string;
backgroundImageSrc?: string | null;
backgroundAssetId?: string | null;
};
type DraftSceneChapterRecord = {
sceneId: string;
sceneName: string;
acts: DraftSceneActRecord[];
};
type MergeRoleAssetIntoDraftProfilePayload = { type MergeRoleAssetIntoDraftProfilePayload = {
roleId: string; roleId: string;
portraitPath: string; portraitPath: string;
@@ -66,6 +80,17 @@ function toAnimationMap(value: unknown) {
return toRecord(value); return toRecord(value);
} }
function normalizeSceneActs(value: unknown) {
return toRecordArray(value)
.map((item, index) => ({
id: toText(item.id) || `act-${index + 1}`,
title: toText(item.title) || `${index + 1}`,
backgroundImageSrc: toText(item.backgroundImageSrc) || null,
backgroundAssetId: toText(item.backgroundAssetId) || null,
}))
.filter((item) => Boolean(item.id));
}
function hasAnimationAsset(entryValue: unknown) { function hasAnimationAsset(entryValue: unknown) {
const entry = toRecord(entryValue); const entry = toRecord(entryValue);
if (!entry) { if (!entry) {
@@ -194,6 +219,31 @@ function collectDraftRoles(profileInput: unknown) {
]; ];
} }
function collectDraftSceneChapters(profileInput: unknown) {
const profile = toRecord(profileInput);
if (!profile) {
return [] as DraftSceneChapterRecord[];
}
return toRecordArray(profile.sceneChapters)
.map((item, index) => {
const sceneId = toText(item.sceneId);
const sceneName = toText(item.sceneName) || toText(item.title);
const acts = normalizeSceneActs(item.acts);
if (!sceneId || acts.length === 0) {
return null;
}
return {
sceneId,
sceneName: sceneName || `场景 ${index + 1}`,
acts,
} satisfies DraftSceneChapterRecord;
})
.filter((item): item is DraftSceneChapterRecord => Boolean(item));
}
export function resolveRoleAssetStatusLabel( export function resolveRoleAssetStatusLabel(
status: CustomWorldRoleAssetStatus, status: CustomWorldRoleAssetStatus,
) { ) {
@@ -267,14 +317,36 @@ export function rebuildRoleAssetCoverage(
const roleAssets = collectDraftRoles(draftProfile).map((entry) => const roleAssets = collectDraftRoles(draftProfile).map((entry) =>
buildRoleAssetSummary(entry), buildRoleAssetSummary(entry),
); );
const sceneAssets: CustomWorldSceneAssetSummary[] = collectDraftSceneChapters(
draftProfile,
).flatMap((sceneChapter) =>
sceneChapter.acts.map((act) => {
const imageSrc = act.backgroundImageSrc ?? null;
const assetId = act.backgroundAssetId ?? null;
const ready = Boolean(imageSrc || assetId);
return {
sceneId: sceneChapter.sceneId,
sceneName: sceneChapter.sceneName,
actId: act.id,
actTitle: act.title,
imageSrc,
assetId,
status: ready ? 'ready' : 'missing',
nextPointCost: ready ? 0 : 12,
} satisfies CustomWorldSceneAssetSummary;
}),
);
return { return {
roleAssets, roleAssets,
sceneAssets: [], sceneAssets,
allRoleAssetsReady: allRoleAssetsReady:
roleAssets.length > 0 && roleAssets.length > 0 &&
roleAssets.every((entry) => entry.status === 'complete'), roleAssets.every((entry) => entry.status !== 'missing'),
allSceneAssetsReady: false, allSceneAssetsReady:
sceneAssets.length > 0 &&
sceneAssets.every((entry) => entry.status === 'ready'),
}; };
} }

View File

@@ -453,13 +453,18 @@ function buildCompatibleAssetCoverage(
) { ) {
const derivedCoverage = rebuildRoleAssetCoverage(draftProfile); const derivedCoverage = rebuildRoleAssetCoverage(draftProfile);
const existingCoverage = toRecord(record.assetCoverage); const existingCoverage = toRecord(record.assetCoverage);
const sceneAssets = Array.isArray(existingCoverage?.sceneAssets) const sceneAssets =
? existingCoverage.sceneAssets derivedCoverage.sceneAssets.length > 0
: []; ? derivedCoverage.sceneAssets
: Array.isArray(existingCoverage?.sceneAssets)
? existingCoverage.sceneAssets
: [];
const allSceneAssetsReady = const allSceneAssetsReady =
typeof existingCoverage?.allSceneAssetsReady === 'boolean' derivedCoverage.sceneAssets.length > 0
? existingCoverage.allSceneAssetsReady ? derivedCoverage.allSceneAssetsReady
: false; : typeof existingCoverage?.allSceneAssetsReady === 'boolean'
? existingCoverage.allSceneAssetsReady
: false;
return { return {
...derivedCoverage, ...derivedCoverage,

View File

@@ -0,0 +1,278 @@
import assert from 'node:assert/strict';
import fs from 'node:fs';
import {
createServer,
type IncomingMessage,
type ServerResponse,
} from 'node:http';
import os from 'node:os';
import path from 'node:path';
import test from 'node:test';
import sharp from 'sharp';
import { type AppConfig } from '../config.js';
import type { AppContext } from '../context.js';
import {
generateCustomWorldCoverImage,
uploadCustomWorldCoverImage,
} from './customWorldCoverAssetService.js';
function createTestConfig(
projectRoot: string,
dashScopeBaseUrl: string,
): AppConfig {
return {
projectRoot,
publicDir: path.join(projectRoot, 'public'),
dashScope: {
baseUrl: dashScopeBaseUrl,
apiKey: 'test-dashscope-key',
imageModel: 'wan2.2-t2i-flash',
requestTimeoutMs: 5_000,
},
} as AppConfig;
}
function sendJson(res: ServerResponse, payload: unknown) {
res.statusCode = 200;
res.setHeader('Content-Type', 'application/json; charset=utf-8');
res.end(JSON.stringify(payload));
}
function readRequestBody(req: IncomingMessage) {
return new Promise<Buffer>((resolve, reject) => {
const chunks: Buffer[] = [];
req.on('data', (chunk) => {
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
});
req.on('end', () => resolve(Buffer.concat(chunks)));
req.on('error', reject);
});
}
async function withHttpServer<T>(
buildHandler: (
baseUrl: string,
) => (req: IncomingMessage, res: ServerResponse) => void | Promise<void>,
run: (baseUrl: string) => Promise<T>,
) {
let handler: (
req: IncomingMessage,
res: ServerResponse,
) => void | Promise<void> = () => undefined;
const server = createServer((req, res) => {
Promise.resolve(handler(req, res)).catch((error) => {
res.statusCode = 500;
res.end(error instanceof Error ? error.stack : String(error));
});
});
await new Promise<void>((resolve) => {
server.listen(0, '127.0.0.1', () => resolve());
});
const address = server.address();
if (!address || typeof address === 'string') {
throw new Error('failed to resolve test server address');
}
const baseUrl = `http://127.0.0.1:${address.port}`;
handler = buildHandler(baseUrl);
try {
return await run(baseUrl);
} finally {
await new Promise<void>((resolve, reject) => {
server.close((error) => {
if (error) {
reject(error);
return;
}
resolve();
});
});
}
}
test('uploadCustomWorldCoverImage crops to 16:9 and saves a compressed webp cover', async () => {
const tempRoot = fs.mkdtempSync(
path.join(os.tmpdir(), 'genarrative-cover-upload-'),
);
const context = {
config: createTestConfig(tempRoot, 'http://127.0.0.1:9999/api/v1'),
} as AppContext;
const inputBuffer = await sharp({
create: {
width: 2400,
height: 1800,
channels: 3,
background: { r: 40, g: 78, b: 132 },
},
})
.jpeg({ quality: 92 })
.toBuffer();
const imageDataUrl = `data:image/jpeg;base64,${inputBuffer.toString('base64')}`;
const result = await uploadCustomWorldCoverImage(context, {
profileId: 'world-1',
worldName: '潮雾群岛',
imageDataUrl,
cropRect: {
x: 240,
y: 225,
width: 1920,
height: 1080,
},
});
assert.equal(result.sourceType, 'uploaded');
assert.match(result.imageSrc, /^\/generated-custom-world-covers\//u);
const savedPath = path.join(tempRoot, 'public', result.imageSrc.slice(1));
assert.equal(fs.existsSync(savedPath), true);
const metadata = await sharp(savedPath).metadata();
assert.equal(metadata.format, 'webp');
assert.equal(metadata.width, 1600);
assert.equal(metadata.height, 900);
assert.ok(fs.statSync(savedPath).size <= Math.floor(1.5 * 1024 * 1024));
});
test('generateCustomWorldCoverImage sends opening act and role images as reference images', async () => {
const tempRoot = fs.mkdtempSync(
path.join(os.tmpdir(), 'genarrative-cover-generate-'),
);
const publicDir = path.join(tempRoot, 'public');
fs.mkdirSync(path.join(publicDir, 'images', 'scene'), { recursive: true });
fs.mkdirSync(path.join(publicDir, 'images', 'roles'), { recursive: true });
const referenceBuffer = await sharp({
create: {
width: 64,
height: 64,
channels: 3,
background: { r: 80, g: 120, b: 160 },
},
})
.png()
.toBuffer();
fs.writeFileSync(
path.join(publicDir, 'images', 'scene', 'opening.png'),
referenceBuffer,
);
fs.writeFileSync(
path.join(publicDir, 'images', 'roles', 'lead.png'),
referenceBuffer,
);
const capturedBodies: string[] = [];
await withHttpServer(
(baseUrl) => async (req, res) => {
const url = new URL(req.url || '/', baseUrl);
if (
req.method === 'POST' &&
url.pathname === '/api/v1/services/aigc/multimodal-generation/generation'
) {
capturedBodies.push((await readRequestBody(req)).toString('utf8'));
sendJson(res, {
output: {
results: [
{
url: `${baseUrl}/downloads/cover.png`,
actual_prompt: '整理后的封面提示词',
},
],
},
});
return;
}
if (req.method === 'GET' && url.pathname === '/downloads/cover.png') {
res.statusCode = 200;
res.setHeader('Content-Type', 'image/png');
res.end(referenceBuffer);
return;
}
res.statusCode = 404;
res.end('not found');
},
async (dashScopeBaseUrl) => {
const context = {
config: createTestConfig(tempRoot, `${dashScopeBaseUrl}/api/v1`),
} as AppContext;
const result = await generateCustomWorldCoverImage(context, {
profile: {
id: 'world-1',
name: '潮雾群岛',
subtitle: '旧航道与沉钟回响',
summary: '用于验证封面参考素材收集。',
tone: '潮湿、压抑',
playerGoal: '查明旧航道真相',
settingText: '旧港与潮雾正在失衡。',
camp: null,
landmarks: [
{
id: 'landmark-1',
name: '沉钟码头',
description: '海雾压进旧码头。',
imageSrc: '/images/scene/opening.png',
},
],
playableNpcs: [
{
id: 'playable-1',
name: '林潮',
title: '守潮人',
role: '可扮演角色',
description: '站在最前面的主角色。',
imageSrc: '/images/roles/lead.png',
},
],
storyNpcs: [],
sceneChapterBlueprints: [
{
id: 'scene-chapter-1',
sceneId: 'landmark-1',
title: '沉钟码头',
summary: '玩家第一次登上旧码头。',
acts: [
{
id: 'act-1',
title: '雾里靠岸',
summary: '第一幕潮声压低,玩家刚踏上栈桥。',
backgroundImageSrc: '/images/scene/opening.png',
},
],
},
],
},
userPrompt: '像正式作品封面。',
referenceImageSrc: '',
characterRoleIds: ['playable-1'],
size: '1600*900',
});
assert.equal(result.sourceType, 'generated');
},
);
assert.equal(capturedBodies.length, 1);
const createPayload = JSON.parse(capturedBodies[0] ?? '{}') as {
input?: {
messages?: Array<{
content?: Array<{ image?: string; text?: string }>;
}>;
};
};
const content =
createPayload.input?.messages?.[0]?.content?.map((item) =>
item.image ? 'image' : item.text ? 'text' : 'unknown',
) ?? [];
assert.ok(content.filter((item) => item === 'image').length >= 2);
assert.equal(content[content.length - 1], 'text');
});

View File

@@ -2,6 +2,7 @@ import fs from 'node:fs';
import { readFile } from 'node:fs/promises'; import { readFile } from 'node:fs/promises';
import path from 'node:path'; import path from 'node:path';
import sharp from 'sharp';
import { z } from 'zod'; import { z } from 'zod';
import type { AppContext } from '../context.js'; import type { AppContext } from '../context.js';
@@ -33,6 +34,21 @@ const coverLandmarkSchema = z.object({
imageSrc: z.string().trim().optional().default(''), imageSrc: z.string().trim().optional().default(''),
}); });
const coverActSchema = z.object({
id: z.string().trim().optional().default(''),
title: z.string().trim().optional().default(''),
summary: z.string().trim().optional().default(''),
backgroundImageSrc: z.string().trim().optional().default(''),
});
const coverSceneChapterSchema = z.object({
id: z.string().trim().optional().default(''),
sceneId: z.string().trim().optional().default(''),
title: z.string().trim().optional().default(''),
summary: z.string().trim().optional().default(''),
acts: z.array(coverActSchema).optional().default([]),
});
const coverProfileSchema = z.object({ const coverProfileSchema = z.object({
id: z.string().trim().optional().default(''), id: z.string().trim().optional().default(''),
name: z.string().trim().optional().default(''), name: z.string().trim().optional().default(''),
@@ -44,6 +60,11 @@ const coverProfileSchema = z.object({
camp: coverCampSchema.nullable().optional(), camp: coverCampSchema.nullable().optional(),
landmarks: z.array(coverLandmarkSchema).optional().default([]), landmarks: z.array(coverLandmarkSchema).optional().default([]),
playableNpcs: z.array(coverRoleSchema).optional().default([]), playableNpcs: z.array(coverRoleSchema).optional().default([]),
storyNpcs: z.array(coverRoleSchema).optional().default([]),
sceneChapterBlueprints: z
.array(coverSceneChapterSchema)
.optional()
.default([]),
}); });
export const customWorldCoverImageSchema = z.object({ export const customWorldCoverImageSchema = z.object({
@@ -58,10 +79,26 @@ export const customWorldCoverUploadSchema = z.object({
profileId: z.string().trim().optional().default(''), profileId: z.string().trim().optional().default(''),
worldName: z.string().trim().optional().default(''), worldName: z.string().trim().optional().default(''),
imageDataUrl: z.string().trim().min(1), imageDataUrl: z.string().trim().min(1),
cropRect: z.object({
x: z.number().finite().min(0),
y: z.number().finite().min(0),
width: z.number().finite().positive(),
height: z.number().finite().positive(),
}),
}); });
type CoverProfile = z.infer<typeof coverProfileSchema>; type CoverProfile = z.infer<typeof coverProfileSchema>;
const COVER_OUTPUT_WIDTH = 1600;
const COVER_OUTPUT_HEIGHT = 900;
const COVER_UPLOAD_MAX_BYTES = 10 * 1024 * 1024;
const COVER_OUTPUT_MAX_BYTES = Math.floor(1.5 * 1024 * 1024);
type ParsedImageDataUrl = {
buffer: Buffer;
mimeType: string;
};
function parseImageDataUrl(source: string) { function parseImageDataUrl(source: string) {
const matched = /^data:(image\/[^;]+);base64,(.+)$/u.exec(source); const matched = /^data:(image\/[^;]+);base64,(.+)$/u.exec(source);
if (!matched) { if (!matched) {
@@ -74,6 +111,160 @@ function parseImageDataUrl(source: string) {
}; };
} }
function clampCoverText(value: string, maxLength: number) {
return value.trim().replace(/\s+/gu, ' ').slice(0, maxLength);
}
function resolveOpeningAct(profile: CoverProfile) {
return profile.sceneChapterBlueprints[0]?.acts[0] ?? null;
}
function collectCoverReferenceImageSrcs(
profile: CoverProfile,
requestedRoleIds: string[],
explicitReferenceImageSrc: string,
) {
const selectedRoles = resolveSelectedRoles(profile, requestedRoleIds);
const sceneImageSrc = clampCoverText(
resolveOpeningAct(profile)?.backgroundImageSrc ?? '',
240,
);
const roleImageSrcs = selectedRoles
.map((role) => clampCoverText(role.imageSrc, 240))
.filter(Boolean);
const campImageSrc = clampCoverText(profile.camp?.imageSrc ?? '', 240);
const landmarkImageSrc = profile.landmarks
.map((landmark) => clampCoverText(landmark.imageSrc, 240))
.filter(Boolean)[0] ?? '';
return [
clampCoverText(explicitReferenceImageSrc, 240),
sceneImageSrc,
...roleImageSrcs,
campImageSrc,
landmarkImageSrc,
].filter(
(source) =>
Boolean(source) && (source.startsWith('/') || source.startsWith('data:')),
);
}
function buildCoverPromptContext(profile: CoverProfile, requestedRoleIds: string[]) {
const openingAct = resolveOpeningAct(profile);
const selectedRoles = resolveSelectedRoles(profile, requestedRoleIds);
const roleSummary = selectedRoles
.map((role) =>
[
clampCoverText(role.name, 18),
clampCoverText(role.title || role.role, 24),
clampCoverText(role.description, 72),
]
.filter(Boolean)
.join(' / '),
)
.filter(Boolean)
.join('');
const storyRoleSummary = profile.storyNpcs
.slice(0, 4)
.map((role) =>
[clampCoverText(role.name, 18), clampCoverText(role.title || role.role, 24)]
.filter(Boolean)
.join(' / '),
)
.filter(Boolean)
.join('');
return {
openingActTitle: clampCoverText(openingAct?.title ?? '', 24),
openingActSummary: clampCoverText(openingAct?.summary ?? '', 96),
roleSummary,
storyRoleSummary,
landmarkSummary: profile.landmarks
.slice(0, 3)
.map((landmark) =>
[
clampCoverText(landmark.name, 18),
clampCoverText(landmark.description, 72),
]
.filter(Boolean)
.join(' / '),
)
.filter(Boolean)
.join(''),
};
}
async function optimizeUploadedCoverImage(
parsedDataUrl: ParsedImageDataUrl,
cropRect: z.infer<typeof customWorldCoverUploadSchema>['cropRect'],
) {
if (parsedDataUrl.buffer.byteLength > COVER_UPLOAD_MAX_BYTES) {
throw badRequest('上传封面原图不能超过 10 MB。');
}
const image = sharp(parsedDataUrl.buffer, { failOn: 'none' });
const metadata = await image.metadata();
const sourceWidth = metadata.width ?? 0;
const sourceHeight = metadata.height ?? 0;
if (sourceWidth <= 0 || sourceHeight <= 0) {
throw badRequest('无法解析上传封面的尺寸。');
}
const normalizedCrop = {
left: Math.max(0, Math.min(sourceWidth - 1, Math.floor(cropRect.x))),
top: Math.max(0, Math.min(sourceHeight - 1, Math.floor(cropRect.y))),
width: Math.max(1, Math.min(sourceWidth, Math.floor(cropRect.width))),
height: Math.max(1, Math.min(sourceHeight, Math.floor(cropRect.height))),
};
normalizedCrop.width = Math.min(
normalizedCrop.width,
sourceWidth - normalizedCrop.left,
);
normalizedCrop.height = Math.min(
normalizedCrop.height,
sourceHeight - normalizedCrop.top,
);
if (
normalizedCrop.width <= 0 ||
normalizedCrop.height <= 0 ||
normalizedCrop.width / normalizedCrop.height < 1.7 ||
normalizedCrop.width / normalizedCrop.height > 1.8
) {
throw badRequest('上传封面裁剪区域必须保持 16:9。');
}
const encodeWithQuality = async (quality: number) =>
image
.extract(normalizedCrop)
.resize(COVER_OUTPUT_WIDTH, COVER_OUTPUT_HEIGHT, {
fit: 'cover',
position: 'centre',
})
.webp({ quality, effort: 4 })
.toBuffer();
let optimizedBuffer = await encodeWithQuality(90);
for (
let quality = 84;
optimizedBuffer.byteLength > COVER_OUTPUT_MAX_BYTES && quality >= 60;
quality -= 8
) {
optimizedBuffer = await encodeWithQuality(quality);
}
if (optimizedBuffer.byteLength > COVER_OUTPUT_MAX_BYTES) {
throw badRequest('上传封面压缩后仍超过体积限制,请缩小裁剪范围或更换图片。');
}
return {
buffer: optimizedBuffer,
mimeType: 'image/webp',
extension: 'webp',
};
}
async function resolveReferenceImageAsDataUrl(rootDir: string, source: string) { async function resolveReferenceImageAsDataUrl(rootDir: string, source: string) {
const trimmedSource = source.trim(); const trimmedSource = source.trim();
if (!trimmedSource) { if (!trimmedSource) {
@@ -207,15 +398,7 @@ function buildCustomWorldCoverImagePrompt(
} = {}, } = {},
) { ) {
const openingScene = profile.camp ?? profile.landmarks[0] ?? null; const openingScene = profile.camp ?? profile.landmarks[0] ?? null;
const selectedRoles = resolveSelectedRoles(profile, requestedRoleIds); const promptContext = buildCoverPromptContext(profile, requestedRoleIds);
const roleSummary = selectedRoles
.map((role) =>
[role.name, role.title || role.role, role.description]
.filter(Boolean)
.join(' / '),
)
.filter(Boolean)
.join('');
return [ return [
'为 16:9 横版 RPG 作品生成一张高完成度封面图,用于创作列表与作品详情头图。', '为 16:9 横版 RPG 作品生成一张高完成度封面图,用于创作列表与作品详情头图。',
@@ -231,9 +414,13 @@ function buildCustomWorldCoverImagePrompt(
profile.summary ? `世界概述:${profile.summary}` : '', profile.summary ? `世界概述:${profile.summary}` : '',
profile.tone ? `整体基调:${profile.tone}` : '', profile.tone ? `整体基调:${profile.tone}` : '',
profile.playerGoal ? `主线目标:${profile.playerGoal}` : '', profile.playerGoal ? `主线目标:${profile.playerGoal}` : '',
promptContext.openingActTitle ? `开局第一幕标题:${promptContext.openingActTitle}` : '',
promptContext.openingActSummary ? `开局第一幕摘要:${promptContext.openingActSummary}` : '',
openingScene?.name ? `开局场景:${openingScene.name}` : '', openingScene?.name ? `开局场景:${openingScene.name}` : '',
openingScene?.description ? `场景描述:${openingScene.description}` : '', openingScene?.description ? `场景描述:${openingScene.description}` : '',
roleSummary ? `需要出现的角色主形象${roleSummary}` : '', promptContext.landmarkSummary ? `关键场景素材${promptContext.landmarkSummary}` : '',
promptContext.roleSummary ? `需要出现的角色主形象:${promptContext.roleSummary}` : '',
promptContext.storyRoleSummary ? `可辅助参考的场景角色:${promptContext.storyRoleSummary}` : '',
userPrompt ? `额外要求:${userPrompt}` : '', userPrompt ? `额外要求:${userPrompt}` : '',
'整体观感要像一张正式作品封面,主体明确,氛围饱满,人物与场景统一。', '整体观感要像一张正式作品封面,主体明确,氛围饱满,人物与场景统一。',
] ]
@@ -286,7 +473,7 @@ async function createCoverImageFromReference(params: {
apiKey: string; apiKey: string;
prompt: string; prompt: string;
size: string; size: string;
referenceImage: string; referenceImages: string[];
}) { }) {
const response = await fetch( const response = await fetch(
`${params.baseUrl}/services/aigc/multimodal-generation/generation`, `${params.baseUrl}/services/aigc/multimodal-generation/generation`,
@@ -303,7 +490,7 @@ async function createCoverImageFromReference(params: {
{ {
role: 'user', role: 'user',
content: [ content: [
{ image: params.referenceImage }, ...params.referenceImages.map((image) => ({ image })),
{ text: params.prompt }, { text: params.prompt },
], ],
}, },
@@ -419,11 +606,10 @@ export async function uploadCustomWorldCoverImage(
throw badRequest('上传封面必须是有效图片 Data URL。'); throw badRequest('上传封面必须是有效图片 Data URL。');
} }
const extension = parsedDataUrl.mimeType.includes('png') const optimizedImage = await optimizeUploadedCoverImage(
? 'png' parsedDataUrl,
: parsedDataUrl.mimeType.includes('webp') payload.cropRect,
? 'webp' );
: 'jpg';
const assetId = `custom-cover-upload-${Date.now()}`; const assetId = `custom-cover-upload-${Date.now()}`;
const worldSegment = sanitizeSegment( const worldSegment = sanitizeSegment(
payload.profileId || payload.worldName, payload.profileId || payload.worldName,
@@ -436,8 +622,8 @@ export async function uploadCustomWorldCoverImage(
); );
const outputDir = path.join(context.config.publicDir, relativeDir); const outputDir = path.join(context.config.publicDir, relativeDir);
fs.mkdirSync(outputDir, { recursive: true }); fs.mkdirSync(outputDir, { recursive: true });
const fileName = `cover.${extension}`; const fileName = `cover.${optimizedImage.extension}`;
fs.writeFileSync(path.join(outputDir, fileName), parsedDataUrl.buffer); fs.writeFileSync(path.join(outputDir, fileName), optimizedImage.buffer);
const imageSrc = `/${relativeDir.replace(/\\/gu, '/')}/${fileName}`; const imageSrc = `/${relativeDir.replace(/\\/gu, '/')}/${fileName}`;
fs.writeFileSync( fs.writeFileSync(
@@ -447,6 +633,8 @@ export async function uploadCustomWorldCoverImage(
assetId, assetId,
sourceType: 'uploaded', sourceType: 'uploaded',
imageSrc, imageSrc,
size: `${COVER_OUTPUT_WIDTH}*${COVER_OUTPUT_HEIGHT}`,
outputBytes: optimizedImage.buffer.byteLength,
worldName: payload.worldName, worldName: payload.worldName,
profileId: payload.profileId, profileId: payload.profileId,
createdAt: new Date().toISOString(), createdAt: new Date().toISOString(),
@@ -468,29 +656,33 @@ export async function generateCustomWorldCoverImage(
input: z.infer<typeof customWorldCoverImageSchema>, input: z.infer<typeof customWorldCoverImageSchema>,
) { ) {
const payload = customWorldCoverImageSchema.parse(input); const payload = customWorldCoverImageSchema.parse(input);
const referenceImageSources = collectCoverReferenceImageSrcs(
payload.profile,
payload.characterRoleIds,
payload.referenceImageSrc,
).slice(0, 6);
const prompt = buildCustomWorldCoverImagePrompt( const prompt = buildCustomWorldCoverImagePrompt(
payload.profile, payload.profile,
payload.characterRoleIds, payload.characterRoleIds,
payload.userPrompt, payload.userPrompt,
{ {
hasReferenceImage: Boolean(payload.referenceImageSrc.trim()), hasReferenceImage: referenceImageSources.length > 0,
}, },
); );
const baseUrl = context.config.dashScope.baseUrl.replace(/\/+$/u, ''); const baseUrl = context.config.dashScope.baseUrl.replace(/\/+$/u, '');
const referenceImage = payload.referenceImageSrc.trim() const referenceImages = await Promise.all(
? await resolveReferenceImageAsDataUrl( referenceImageSources.map((source) =>
context.config.projectRoot, resolveReferenceImageAsDataUrl(context.config.projectRoot, source),
payload.referenceImageSrc, ),
) );
: '';
if (referenceImage) { if (referenceImages.length > 0) {
const referenceResult = await createCoverImageFromReference({ const referenceResult = await createCoverImageFromReference({
baseUrl, baseUrl,
apiKey: context.config.dashScope.apiKey, apiKey: context.config.dashScope.apiKey,
prompt, prompt,
size: payload.size, size: payload.size,
referenceImage, referenceImages,
}); });
return saveGeneratedCoverAsset({ return saveGeneratedCoverAsset({

View File

@@ -95,14 +95,17 @@ function resolveDraftSummary(session: CustomWorldAgentSessionRecord) {
function resolveDraftCounts(session: CustomWorldAgentSessionRecord) { function resolveDraftCounts(session: CustomWorldAgentSessionRecord) {
const draftProfile = normalizeFoundationDraftProfile(session.draftProfile); const draftProfile = normalizeFoundationDraftProfile(session.draftProfile);
if (draftProfile) { if (draftProfile) {
return { // 草稿列表里的“角色”展示的是当前草稿中全部可编辑角色,而不是仅限可扮演角色。
playableNpcCount: [ const totalRoleCount = [
...new Set( ...new Set(
[...draftProfile.playableNpcs, ...draftProfile.storyNpcs].map( [...draftProfile.playableNpcs, ...draftProfile.storyNpcs].map(
(entry) => entry.id, (entry) => entry.id,
),
), ),
].length, ),
].length;
return {
playableNpcCount: totalRoleCount,
landmarkCount: draftProfile.landmarks.length, landmarkCount: draftProfile.landmarks.length,
}; };
} }

View File

@@ -27,13 +27,13 @@ function createCharacter(): Character {
} as Character; } as Character;
} }
test('adventure panel treats negative affinity updates as relationship change system messages', () => { test('adventure panel renders system turns without special relationship labels', () => {
const currentStory: StoryMoment = { const currentStory: StoryMoment = {
text: '你们的语气忽然冷了下来。', text: '你们的语气忽然冷了下来。',
displayMode: 'dialogue', displayMode: 'dialogue',
dialogue: [ dialogue: [
{ speaker: 'npc', speakerName: '柳无声', text: '这件事你最好别再追问。' }, { speaker: 'npc', speakerName: '柳无声', text: '这件事你最好别再追问。' },
{ speaker: 'system', text: '关系转冷 好感 -2', affinityDelta: -2 }, { speaker: 'system', text: '这轮交谈先在这里收束。' },
], ],
options: [], options: [],
}; };
@@ -102,8 +102,9 @@ test('adventure panel treats negative affinity updates as relationship change sy
/>, />,
); );
expect(html).toContain('关系变化'); expect(html).toContain('系统');
expect(html).toContain('关系转冷 好感 -2'); expect(html).toContain('这轮交谈先在这里收束。');
expect(html).not.toContain('关系变化');
}); });
test('adventure panel shows current act label and remaining turns for limited hostile npc chat', () => { test('adventure panel shows current act label and remaining turns for limited hostile npc chat', () => {

View File

@@ -157,9 +157,13 @@ test('adventure panel shows npc chat custom input and exit button in chat mode',
dialogue: [ dialogue: [
{ speaker: 'player', text: '你刚才那句话是什么意思?' }, { speaker: 'player', text: '你刚才那句话是什么意思?' },
{ speaker: 'npc', speakerName: '柳无声', text: '意思是这件事还没结束。' }, { speaker: 'npc', speakerName: '柳无声', text: '意思是这件事还没结束。' },
{ speaker: 'system', text: '关系升温 好感 +3', affinityDelta: 3 },
], ],
options: [optionA, optionB, optionC], options: [optionA, optionB, optionC],
npcAffinityEffect: {
eventId: 'effect-liu-1',
npcId: 'npc-liu',
delta: 3,
},
npcChatState: { npcChatState: {
npcId: 'npc-liu', npcId: 'npc-liu',
npcName: '柳无声', npcName: '柳无声',
@@ -178,6 +182,7 @@ test('adventure panel shows npc chat custom input and exit button in chat mode',
expect(html).toContain('输入你想对 TA 说的话'); expect(html).toContain('输入你想对 TA 说的话');
expect(html).toContain('发送'); expect(html).toContain('发送');
expect(html).not.toContain('换一换'); expect(html).not.toContain('换一换');
expect(html).not.toContain('关系升温');
}); });
test('adventure panel hides custom input and shows quest offer actions during npc quest offer mode', () => { test('adventure panel hides custom input and shows quest offer actions during npc quest offer mode', () => {
@@ -243,3 +248,19 @@ test('adventure panel hides custom input and shows quest offer actions during np
expect(html).not.toContain('发送'); expect(html).not.toContain('发送');
expect(html).not.toContain('输入你想对 TA 说的话'); expect(html).not.toContain('输入你想对 TA 说的话');
}); });
test('adventure panel renders narrative story text without italics and hides option detail text', () => {
const option = createOption('idle_observe_signs', '观察风里残下的痕迹');
option.detailText = '这段说明不应该继续出现在 UI 里。';
const currentStory: StoryMoment = {
text: '风从桥洞里灌过来,你把注意力重新放回脚下与前路。',
options: [option],
};
const html = renderPanel(currentStory, [option]);
expect(html).toContain('font-serif');
expect(html).not.toContain('italic');
expect(html).toContain('text-[15px]');
expect(html).not.toContain('这段说明不应该继续出现在 UI 里。');
});

View File

@@ -174,9 +174,7 @@ function getDialogueTurnBubbleClass(
turn: NonNullable<StoryMoment['dialogue']>[number], turn: NonNullable<StoryMoment['dialogue']>[number],
) { ) {
if (turn.speaker === 'system') { if (turn.speaker === 'system') {
return turn.affinityDelta && turn.affinityDelta > 0 return 'border-white/12 bg-white/[0.06] text-zinc-100';
? 'border-rose-400/30 bg-rose-500/12 text-rose-50'
: 'border-white/12 bg-white/[0.06] text-zinc-100';
} }
if (turn.speaker === 'player') { if (turn.speaker === 'player') {
@@ -212,7 +210,7 @@ function getDialogueTurnLabel(
turn: NonNullable<StoryMoment['dialogue']>[number], turn: NonNullable<StoryMoment['dialogue']>[number],
) { ) {
if (turn.speaker === 'system') { if (turn.speaker === 'system') {
return typeof turn.affinityDelta === 'number' ? '关系变化' : '系统'; return '系统';
} }
if (turn.speaker === 'player') { if (turn.speaker === 'player') {
@@ -1107,7 +1105,7 @@ export function AdventurePanel({
)} )}
</div> </div>
) : ( ) : (
<p className="font-serif text-sm italic leading-relaxed text-zinc-300"> <p className="font-serif text-[15px] leading-7 text-zinc-200 sm:text-base">
{currentStory.text} {currentStory.text}
</p> </p>
)} )}
@@ -1192,9 +1190,6 @@ export function AdventurePanel({
hasDeferredAdventureOptions && hasDeferredAdventureOptions &&
isContinueAdventureOption(option); isContinueAdventureOption(option);
const optionDisabled = option.disabled === true; const optionDisabled = option.disabled === true;
const compactOptionDetailText = option.disabledReason
? option.disabledReason
: getCompactOptionDetailText(option);
if (isDeferredContinueOption) { if (isDeferredContinueOption) {
return ( return (
@@ -1210,7 +1205,7 @@ export function AdventurePanel({
> >
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<span <span
className={`text-xs ${getOptionActionTextClass(option)}`} className={`text-sm sm:text-[15px] ${getOptionActionTextClass(option)}`}
> >
{option.actionText} {option.actionText}
</span> </span>
@@ -1237,7 +1232,7 @@ export function AdventurePanel({
> >
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<span <span
className={`${isNpcChatMode ? 'text-sm sm:text-[15px]' : 'text-xs'} ${getOptionActionTextClass(option)}`} className={`text-sm sm:text-[15px] ${getOptionActionTextClass(option)}`}
> >
{option.actionText} {option.actionText}
</span> </span>
@@ -1246,11 +1241,6 @@ export function AdventurePanel({
className="h-3 w-3 opacity-70 transition-opacity group-hover:opacity-100" className="h-3 w-3 opacity-70 transition-opacity group-hover:opacity-100"
/> />
</div> </div>
{!isNpcChatMode && compactOptionDetailText && (
<div className="mt-1 text-[10px] leading-relaxed text-zinc-500">
{compactOptionDetailText}
</div>
)}
{!isNpcChatMode && option.goalAffordance?.label && ( {!isNpcChatMode && option.goalAffordance?.label && (
<div <div
className={`mt-1 text-[10px] ${getOptionGoalAffordanceClass(option)}`} className={`mt-1 text-[10px] ${getOptionGoalAffordanceClass(option)}`}

View File

@@ -20,7 +20,13 @@ import {
import { resolveCustomWorldCampScene } from '../services/customWorldCamp'; import { resolveCustomWorldCampScene } from '../services/customWorldCamp';
import { resolveCustomWorldCoverPresentation } from '../services/customWorldCover'; import { resolveCustomWorldCoverPresentation } from '../services/customWorldCover';
import { normalizeCustomWorldCreatorIntent } from '../services/customWorldCreatorIntent'; import { normalizeCustomWorldCreatorIntent } from '../services/customWorldCreatorIntent';
import { AnimationState, Character, CustomWorldProfile } from '../types'; import {
AnimationState,
Character,
CustomWorldProfile,
type SceneActBlueprint,
type SceneChapterBlueprint,
} from '../types';
import { CharacterAnimator } from './CharacterAnimator'; import { CharacterAnimator } from './CharacterAnimator';
import { CustomWorldCoverArtwork } from './CustomWorldCoverArtwork'; import { CustomWorldCoverArtwork } from './CustomWorldCoverArtwork';
import type { CustomWorldEditorTarget } from './CustomWorldEntityEditorModal'; import type { CustomWorldEditorTarget } from './CustomWorldEntityEditorModal';
@@ -233,6 +239,104 @@ function PendingEntityCard({
); );
} }
function resolveSceneEntrySceneChapters(params: {
sceneChapters: CustomWorldProfile['sceneChapterBlueprints'];
sceneId: string;
sceneName: string;
}) {
const sceneChapters = params.sceneChapters ?? [];
const normalizedSceneId = params.sceneId.trim();
const normalizedSceneName = params.sceneName.trim();
const directMatches = sceneChapters.filter(
(chapter) => chapter.sceneId.trim() === normalizedSceneId,
);
if (directMatches.length > 0) {
return directMatches;
}
const linkedMatches = sceneChapters.filter((chapter) =>
chapter.linkedLandmarkIds.some(
(landmarkId) => landmarkId.trim() === normalizedSceneId,
),
);
if (linkedMatches.length > 0) {
return linkedMatches;
}
return sceneChapters.filter((chapter) => {
const chapterTitle = chapter.title.trim();
return (
chapterTitle === normalizedSceneName ||
chapter.summary.includes(normalizedSceneName) ||
chapter.acts.some(
(act) =>
act.title.includes(normalizedSceneName) ||
act.summary.includes(normalizedSceneName),
)
);
});
}
function buildSceneActParticipantText(
act: SceneActBlueprint,
roleById: Map<
string,
| CustomWorldProfile['playableNpcs'][number]
| CustomWorldProfile['storyNpcs'][number]
>,
) {
const primaryRoleName = roleById.get(act.primaryNpcId)?.name?.trim() || '';
const supportRoleNames = act.encounterNpcIds
.filter((roleId) => roleId !== act.primaryNpcId)
.map((roleId) => roleById.get(roleId)?.name?.trim() || '')
.filter(Boolean);
return compactTextList([
primaryRoleName ? `主角色:${primaryRoleName}` : '',
supportRoleNames.length > 0
? `相遇角色:${supportRoleNames.join('、')}`
: '',
]).join('');
}
function buildSceneChapterSearchText(
sceneChapters: SceneChapterBlueprint[],
roleById: Map<
string,
| CustomWorldProfile['playableNpcs'][number]
| CustomWorldProfile['storyNpcs'][number]
>,
) {
return sceneChapters
.flatMap((chapter) => [
chapter.title,
chapter.summary,
...chapter.acts.flatMap((act) => [
act.title,
act.summary,
act.actGoal,
act.transitionHook,
buildSceneActParticipantText(act, roleById),
]),
])
.filter(Boolean)
.join(' ');
}
function resolveSceneCardImage(params: {
sceneImageSrc?: string | null;
sceneChapters: SceneChapterBlueprint[];
}) {
const firstActImageSrc =
params.sceneChapters
.flatMap((chapter) => chapter.acts)
.map((act) => act.backgroundImageSrc?.trim() || '')
.find(Boolean) || '';
return firstActImageSrc || params.sceneImageSrc?.trim() || '';
}
function CatalogCard({ function CatalogCard({
title, title,
description, description,
@@ -370,6 +474,10 @@ function resolvePlayableRolePreviewImage(
role: CustomWorldProfile['playableNpcs'][number], role: CustomWorldProfile['playableNpcs'][number],
previewCharacter: Character | null, previewCharacter: Character | null,
) { ) {
if (role.imageSrc?.trim()) {
return role.imageSrc;
}
if (previewCharacter?.portrait?.trim()) { if (previewCharacter?.portrait?.trim()) {
return previewCharacter.portrait; return previewCharacter.portrait;
} }
@@ -378,10 +486,6 @@ function resolvePlayableRolePreviewImage(
return previewCharacter.avatar; return previewCharacter.avatar;
} }
if (role.imageSrc?.trim()) {
return role.imageSrc;
}
const template = role.templateCharacterId const template = role.templateCharacterId
? ROLE_TEMPLATE_CHARACTERS.find( ? ROLE_TEMPLATE_CHARACTERS.find(
(character) => character.id === role.templateCharacterId, (character) => character.id === role.templateCharacterId,
@@ -796,6 +900,16 @@ export function CustomWorldEntityCatalog({
() => new Map(profile.storyNpcs.map((npc) => [npc.id, npc])), () => new Map(profile.storyNpcs.map((npc) => [npc.id, npc])),
[profile.storyNpcs], [profile.storyNpcs],
); );
const roleById = useMemo(
() =>
new Map(
[...profile.playableNpcs, ...profile.storyNpcs].map((role) => [
role.id,
role,
]),
),
[profile.playableNpcs, profile.storyNpcs],
);
const landmarkById = useMemo( const landmarkById = useMemo(
() => new Map(profile.landmarks.map((landmark) => [landmark.id, landmark])), () => new Map(profile.landmarks.map((landmark) => [landmark.id, landmark])),
[profile.landmarks], [profile.landmarks],
@@ -876,22 +990,53 @@ export function CustomWorldEntityCatalog({
[profile.creatorIntent], [profile.creatorIntent],
); );
const filteredSceneEntries = useMemo(() => { const filteredSceneEntries = useMemo(() => {
const openingSceneChapters = resolveSceneEntrySceneChapters({
sceneChapters: profile.sceneChapterBlueprints,
sceneId: resolvedCampScene.id,
sceneName: resolvedCampScene.name,
});
const openingSceneEntry = { const openingSceneEntry = {
id: 'custom-world-opening-scene', id: resolvedCampScene.id,
kind: 'camp' as const, kind: 'camp' as const,
name: resolvedCampScene.name, name: resolvedCampScene.name,
description: resolvedCampScene.description, description: resolvedCampScene.description,
imageSrc: resolvedCampImageSrc, imageSrc: resolveSceneCardImage({
searchText: buildOpeningSceneSearchText(profile, resolvedCampScene), sceneImageSrc: resolvedCampImageSrc,
sceneChapters: openingSceneChapters,
}),
sceneChapters: openingSceneChapters,
searchText: [
buildOpeningSceneSearchText(profile, resolvedCampScene),
buildSceneChapterSearchText(openingSceneChapters, roleById),
]
.filter(Boolean)
.join(' '),
}; };
const landmarkEntries = filteredLandmarks.map((landmark) => ({ const landmarkEntries = profile.landmarks.map((landmark) => {
id: landmark.id, const sceneChapters = resolveSceneEntrySceneChapters({
kind: 'landmark' as const, sceneChapters: profile.sceneChapterBlueprints,
name: landmark.name, sceneId: landmark.id,
description: landmark.description, sceneName: landmark.name,
imageSrc: landmarkImageById.get(landmark.id) ?? landmark.imageSrc, });
searchText: buildLandmarkSearchText(landmark, storyNpcById, landmarkById),
})); return {
id: landmark.id,
kind: 'landmark' as const,
name: landmark.name,
description: landmark.description,
imageSrc: resolveSceneCardImage({
sceneImageSrc: landmarkImageById.get(landmark.id) ?? landmark.imageSrc,
sceneChapters,
}),
sceneChapters,
searchText: [
buildLandmarkSearchText(landmark, storyNpcById, landmarkById),
buildSceneChapterSearchText(sceneChapters, roleById),
]
.filter(Boolean)
.join(' '),
};
});
const recentEntries = landmarkEntries.filter((entry) => const recentEntries = landmarkEntries.filter((entry) =>
recentLandmarkIdSet.has(entry.id), recentLandmarkIdSet.has(entry.id),
); );
@@ -909,13 +1054,13 @@ export function CustomWorldEntityCatalog({
); );
}, [ }, [
deferredSearch, deferredSearch,
filteredLandmarks,
landmarkById, landmarkById,
landmarkImageById, landmarkImageById,
profile, profile,
recentLandmarkIdSet, recentLandmarkIdSet,
resolvedCampImageSrc, resolvedCampImageSrc,
resolvedCampScene, resolvedCampScene,
roleById,
storyNpcById, storyNpcById,
]); ]);
@@ -1281,7 +1426,13 @@ export function CustomWorldEntityCatalog({
}) })
} }
media={ media={
previewCharacter ? ( role.imageSrc?.trim() ? (
<img
src={role.imageSrc}
alt={role.name}
className="h-full w-full object-cover object-top"
/>
) : previewCharacter ? (
<CharacterAnimator <CharacterAnimator
state={AnimationState.RUN} state={AnimationState.RUN}
character={previewCharacter} character={previewCharacter}
@@ -1414,51 +1565,48 @@ export function CustomWorldEntityCatalog({
<EmptyState title="当前没有符合搜索条件的场景。" /> <EmptyState title="当前没有符合搜索条件的场景。" />
) : ( ) : (
filteredSceneEntries.map((scene, index) => ( filteredSceneEntries.map((scene, index) => (
<div <CatalogCard
key={buildFallbackRenderKey( key={buildFallbackRenderKey(
scene.id, scene.id,
`scene-entry-${index}-${scene.name.trim() || scene.kind}`, `scene-entry-${index}-${scene.name.trim() || scene.kind}`,
)} )}
> title={scene.name}
<CatalogCard description={
title={scene.name} scene.kind === 'camp'
description={ ? `开局场景 · ${scene.description}`
scene.kind === 'camp' : scene.description
? `开局场景 · ${scene.description}` }
: scene.description badge={
} scene.kind === 'landmark' && recentLandmarkIdSet.has(scene.id) ? (
badge={ <NewBadge />
scene.kind === 'landmark' && recentLandmarkIdSet.has(scene.id) ? ( ) : null
<NewBadge /> }
) : null isSelectionMode={scene.kind === 'landmark' && isBulkDeleteMode}
} isSelected={
isSelectionMode={scene.kind === 'landmark' && isBulkDeleteMode} scene.kind === 'landmark' &&
isSelected={ selectedBulkIds.includes(scene.id)
scene.kind === 'landmark' && }
selectedBulkIds.includes(scene.id) onClick={() =>
} scene.kind === 'camp'
onClick={() => ? onEditTarget({ kind: 'camp' })
scene.kind === 'camp' : isBulkDeleteMode
? onEditTarget({ kind: 'camp' }) ? toggleBulkSelected(scene.id)
: isBulkDeleteMode : onEditTarget({
? toggleBulkSelected(scene.id) kind: 'landmark',
: onEditTarget({ mode: 'edit',
kind: 'landmark', id: scene.id,
mode: 'edit', })
id: scene.id, }
}) media={
} <ImageFrame
media={ src={scene.imageSrc}
<ImageFrame alt={scene.name}
src={scene.imageSrc} fallbackLabel={scene.name.slice(0, 4) || '场景'}
alt={scene.name} tone="landscape"
fallbackLabel={scene.name.slice(0, 4) || '场景'} />
tone="landscape" }
/> disabled={scene.kind === 'camp' && isBulkDeleteMode}
} />
disabled={scene.kind === 'camp' && isBulkDeleteMode}
/>
</div>
)) ))
)} )}
</div> </div>

View File

@@ -15,6 +15,7 @@ import {
type CustomWorldEditorTarget, type CustomWorldEditorTarget,
CustomWorldEntityEditorModal, CustomWorldEntityEditorModal,
} from './CustomWorldEntityEditorModal'; } from './CustomWorldEntityEditorModal';
import * as customWorldCoverAssetService from '../services/customWorldCoverAssetService';
vi.mock('../data/characterPresets', async () => { vi.mock('../data/characterPresets', async () => {
const actual = await vi.importActual<typeof import('../data/characterPresets')>( const actual = await vi.importActual<typeof import('../data/characterPresets')>(
@@ -65,10 +66,6 @@ vi.mock('./game-shell/GameShellRuntime', () => ({
vi.mock('./asset-studio/characterAssetWorkflowPersistence', () => ({ vi.mock('./asset-studio/characterAssetWorkflowPersistence', () => ({
fetchCharacterWorkflowCache: vi.fn().mockResolvedValue({ cache: null }), fetchCharacterWorkflowCache: vi.fn().mockResolvedValue({ cache: null }),
generateCharacterPromptBundle: vi.fn().mockResolvedValue({
visualPromptText: '自动生成的形象提示词',
animationPromptText: '自动生成的动作提示词',
}),
saveCharacterWorkflowCache: vi.fn().mockResolvedValue(undefined), saveCharacterWorkflowCache: vi.fn().mockResolvedValue(undefined),
generateCharacterVisualCandidates: vi.fn(), generateCharacterVisualCandidates: vi.fn(),
publishCharacterVisualAsset: vi.fn(), publishCharacterVisualAsset: vi.fn(),
@@ -76,6 +73,11 @@ vi.mock('./asset-studio/characterAssetWorkflowPersistence', () => ({
publishCharacterAnimationAssets: vi.fn(), publishCharacterAnimationAssets: vi.fn(),
})); }));
vi.mock('../services/customWorldCoverAssetService', () => ({
generateCustomWorldCoverImage: vi.fn(),
uploadCustomWorldCoverImage: vi.fn(),
}));
function createBackstoryReveal() { function createBackstoryReveal() {
return { return {
publicSummary: '公开背景', publicSummary: '公开背景',
@@ -261,10 +263,19 @@ function CampEditorFlowHarness() {
const [profile, setProfile] = useState<CustomWorldProfile>({ const [profile, setProfile] = useState<CustomWorldProfile>({
...createProfileWithLandmark(), ...createProfileWithLandmark(),
camp: { camp: {
id: 'custom-scene-camp',
name: '潮灯居', name: '潮灯居',
description: '玩家最初落脚的旧灯塔内院。', description: '玩家最初落脚的旧灯塔内院。',
dangerLevel: 'medium', dangerLevel: 'medium',
imageSrc: '/generated-custom-world-scenes/original-camp.png', imageSrc: '/generated-custom-world-scenes/original-camp.png',
sceneNpcIds: ['story-1', 'story-2', 'story-3'],
connections: [
{
targetLandmarkId: 'landmark-1',
relativePosition: 'north',
summary: '北侧通往沉钟栈桥。',
},
],
}, },
}); });
const [target, setTarget] = useState<CustomWorldEditorTarget | null>({ const [target, setTarget] = useState<CustomWorldEditorTarget | null>({
@@ -273,6 +284,9 @@ function CampEditorFlowHarness() {
return ( return (
<> <>
<pre data-testid="camp-profile-json" className="hidden">
{JSON.stringify(profile)}
</pre>
<CustomWorldEntityCatalog <CustomWorldEntityCatalog
profile={profile} profile={profile}
previewCharacters={[]} previewCharacters={[]}
@@ -293,6 +307,44 @@ function CampEditorFlowHarness() {
); );
} }
function CoverEditorFlowHarness() {
const [profile, setProfile] = useState<CustomWorldProfile>({
...createProfileWithLandmark(),
cover: {
sourceType: 'default',
imageSrc: null,
characterRoleIds: ['playable-1'],
},
});
const [target, setTarget] = useState<CustomWorldEditorTarget | null>({
kind: 'cover',
});
return (
<>
<pre data-testid="cover-profile-json" className="hidden">
{JSON.stringify(profile)}
</pre>
<CustomWorldEntityEditorModal
profile={profile}
target={target}
onClose={() => setTarget(null)}
onProfileChange={setProfile}
/>
</>
);
}
function readCoverHarnessProfile() {
const content = screen.getByTestId('cover-profile-json').textContent;
return JSON.parse(content || '{}') as CustomWorldProfile;
}
function readCampHarnessProfile() {
const content = screen.getByTestId('camp-profile-json').textContent;
return JSON.parse(content || '{}') as CustomWorldProfile;
}
test('playable角色打开AI工坊后不会自动关闭', async () => { test('playable角色打开AI工坊后不会自动关闭', async () => {
const user = userEvent.setup(); const user = userEvent.setup();
const handleClose = vi.fn(); const handleClose = vi.fn();
@@ -506,6 +558,14 @@ test('场景图片保存后会同步更新编辑页和场景列表', async () =>
'/generated-custom-world-scenes/original-scene.png', '/generated-custom-world-scenes/original-scene.png',
); );
const firstActCard = getSceneActCard(0);
await user.click(within(firstActCard).getByRole('button', { name: '配置背景' }));
await waitFor(() => {
expect(screen.getByText('配置幕背景第1幕')).toBeTruthy();
});
expect(screen.queryByText('场景图片')).toBeNull();
expect(screen.queryByText('场景内 NPC')).toBeNull();
await user.click(screen.getByRole('button', { name: 'AI生成' })); await user.click(screen.getByRole('button', { name: 'AI生成' }));
await waitFor(() => { await waitFor(() => {
expect(screen.getByText('智能生成:沉钟栈桥')).toBeTruthy(); expect(screen.getByText('智能生成:沉钟栈桥')).toBeTruthy();
@@ -523,22 +583,29 @@ test('场景图片保存后会同步更新编辑页和场景列表', async () =>
}); });
await waitFor(() => { await waitFor(() => {
expect(screen.getByRole('img', { name: '场景图片' }).getAttribute('src')).toBe( expect(screen.getByRole('img', { name: '第1幕背景预览' }).getAttribute('src')).toBe(
'/generated-custom-world-scenes/updated-scene.png', '/generated-custom-world-scenes/updated-scene.png',
); );
}); });
await user.click(screen.getByRole('button', { name: //u })); await user.click(screen.getByRole('button', { name: '保存背景' }));
await waitFor(() => { await waitFor(() => {
expect(screen.queryByRole('img', { name: '场景图片' })).toBeNull(); expect(screen.queryByText('配置幕背景第1幕')).toBeNull();
}); });
await user.click(screen.getByRole('button', { name: //u }));
await waitFor(() => { await waitFor(() => {
expect(screen.getByRole('img', { name: '沉钟栈桥' }).getAttribute('src')).toBe( expect(screen.getByRole('img', { name: '沉钟栈桥' }).getAttribute('src')).toBe(
'/generated-custom-world-scenes/updated-scene.png', '/generated-custom-world-scenes/updated-scene.png',
); );
}); });
const savedProfile = readLandmarkHarnessProfile();
expect(savedProfile.landmarks[0]?.imageSrc).toBe(
'/generated-custom-world-scenes/updated-scene.png',
);
}); });
test('开局场景图片保存后会同步更新编辑页和场景列表', async () => { test('开局场景图片保存后会同步更新编辑页和场景列表', async () => {
@@ -562,6 +629,14 @@ test('开局场景图片保存后会同步更新编辑页和场景列表', async
'/generated-custom-world-scenes/original-camp.png', '/generated-custom-world-scenes/original-camp.png',
); );
const firstActCard = getSceneActCard(0);
await user.click(within(firstActCard).getByRole('button', { name: '配置背景' }));
await waitFor(() => {
expect(screen.getByText('配置幕背景第1幕')).toBeTruthy();
});
expect(screen.queryByText('场景图片')).toBeNull();
expect(screen.queryByText('场景内 NPC')).toBeNull();
await user.click(screen.getByRole('button', { name: 'AI生成' })); await user.click(screen.getByRole('button', { name: 'AI生成' }));
await waitFor(() => { await waitFor(() => {
expect(screen.getByText('智能生成:潮灯居')).toBeTruthy(); expect(screen.getByText('智能生成:潮灯居')).toBeTruthy();
@@ -579,22 +654,80 @@ test('开局场景图片保存后会同步更新编辑页和场景列表', async
}); });
await waitFor(() => { await waitFor(() => {
expect(screen.getByRole('img', { name: '场景图片' }).getAttribute('src')).toBe( expect(screen.getByRole('img', { name: '第1幕背景预览' }).getAttribute('src')).toBe(
'/generated-custom-world-scenes/updated-camp.png', '/generated-custom-world-scenes/updated-camp.png',
); );
}); });
await user.click(screen.getByRole('button', { name: //u })); await user.click(screen.getByRole('button', { name: '保存背景' }));
await waitFor(() => { await waitFor(() => {
expect(screen.queryByRole('img', { name: '场景图片' })).toBeNull(); expect(screen.queryByText('配置幕背景第1幕')).toBeNull();
}); });
await user.click(screen.getByRole('button', { name: //u }));
await waitFor(() => { await waitFor(() => {
expect(screen.getByRole('img', { name: '潮灯居' }).getAttribute('src')).toBe( expect(screen.getByRole('img', { name: '潮灯居' }).getAttribute('src')).toBe(
'/generated-custom-world-scenes/updated-camp.png', '/generated-custom-world-scenes/updated-camp.png',
); );
}); });
const savedProfile = readCampHarnessProfile();
expect(savedProfile.camp?.imageSrc).toBe(
'/generated-custom-world-scenes/updated-camp.png',
);
});
test('开局场景在场景配置面板中与普通场景使用同级参数并可保存', async () => {
const user = userEvent.setup();
render(<CampEditorFlowHarness />);
expect(screen.getByText('多幕配置')).toBeTruthy();
expect(screen.getByText('场景连接关系')).toBeTruthy();
expect(screen.queryByText('场景图片')).toBeNull();
expect(screen.queryByText('场景内 NPC')).toBeNull();
expect(screen.getAllByTestId('scene-act-card')).toHaveLength(3);
const firstActCard = getSceneActCard(0);
await user.click(within(firstActCard).getAllByTestId('scene-act-slot-button')[0]!);
await waitFor(() => {
expect(screen.getByText('配置角色第1幕 · 主角色槽位')).toBeTruthy();
});
await user.click(screen.getByRole('button', { name: //u }));
await user.click(screen.getByRole('button', { name: '保存角色' }));
await waitFor(() => {
expect(screen.queryByText('配置角色第1幕 · 主角色槽位')).toBeNull();
});
await user.click(screen.getByRole('button', { name: //u }));
await waitFor(() => {
expect(screen.queryByText('编辑场景:潮灯居')).toBeNull();
});
const savedProfile = readCampHarnessProfile();
const openingSceneChapter = savedProfile.sceneChapterBlueprints?.find(
(entry) => entry.sceneId === 'custom-scene-camp',
);
expect(savedProfile.camp?.sceneNpcIds).toHaveLength(3);
expect(savedProfile.camp?.sceneNpcIds).toEqual(
expect.arrayContaining(['story-1', 'story-2', 'story-3']),
);
expect(savedProfile.camp?.connections).toEqual([
{
targetLandmarkId: 'landmark-1',
relativePosition: 'north',
summary: '北侧通往沉钟栈桥。',
},
]);
expect(openingSceneChapter).toBeTruthy();
expect(openingSceneChapter?.acts[0]?.encounterNpcIds[0]).toBe('story-2');
expect(openingSceneChapter?.linkedLandmarkIds).toContain('custom-scene-camp');
}); });
test('场景编辑器会在场景内展示槽位化多幕配置并保存', async () => { test('场景编辑器会在场景内展示槽位化多幕配置并保存', async () => {
@@ -636,7 +769,7 @@ test('场景编辑器会在场景内展示槽位化多幕配置并保存', async
expect(screen.getByText('配置角色第1幕 · 主角色槽位')).toBeTruthy(); expect(screen.getByText('配置角色第1幕 · 主角色槽位')).toBeTruthy();
}); });
await user.click(screen.getByRole('button', { name: /[\s\S]*/u })); await user.click(screen.getByRole('button', { name: //u }));
await user.click(screen.getByRole('button', { name: '保存角色' })); await user.click(screen.getByRole('button', { name: '保存角色' }));
await waitFor(() => { await waitFor(() => {
@@ -679,7 +812,7 @@ test('场景多幕支持新增删除和调序', async () => {
await waitFor(() => { await waitFor(() => {
expect(screen.getByText('配置角色第2幕 · 主角色槽位')).toBeTruthy(); expect(screen.getByText('配置角色第2幕 · 主角色槽位')).toBeTruthy();
}); });
await user.click(screen.getByRole('button', { name: /[\s\S]*/u })); await user.click(screen.getByRole('button', { name: //u }));
await user.click(screen.getByRole('button', { name: '保存角色' })); await user.click(screen.getByRole('button', { name: '保存角色' }));
await user.click(within(secondActCard).getByRole('button', { name: '下移' })); await user.click(within(secondActCard).getByRole('button', { name: '下移' }));
@@ -721,3 +854,83 @@ test('场景幕预览会打开当前幕运行时面板', async () => {
expect(screen.queryByText('幕预览运行时')).toBeNull(); expect(screen.queryByText('幕预览运行时')).toBeNull();
}); });
}); });
test('作品封面上传会先进入 16:9 裁剪面板再提交到后端', async () => {
const uploadMock = vi
.mocked(customWorldCoverAssetService.uploadCustomWorldCoverImage)
.mockResolvedValue({
imageSrc: '/generated-custom-world-covers/world-1/uploaded/cover.webp',
assetId: 'custom-cover-upload-1',
sourceType: 'uploaded',
});
class MockFileReader {
result: string | null = null;
error: Error | null = null;
onload: null | (() => void) = null;
onerror: null | (() => void) = null;
readAsDataURL() {
this.result =
'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO7+7aQAAAAASUVORK5CYII=';
this.onload?.();
}
}
class MockImage {
onload: null | (() => void) = null;
onerror: null | (() => void) = null;
naturalWidth = 1920;
naturalHeight = 1080;
set src(_value: string) {
this.onload?.();
}
}
vi.stubGlobal('FileReader', MockFileReader as unknown as typeof FileReader);
vi.stubGlobal('Image', MockImage as unknown as typeof Image);
const user = userEvent.setup();
render(<CoverEditorFlowHarness />);
const input = document.querySelector('input[type="file"]') as HTMLInputElement | null;
expect(input).toBeTruthy();
if (!input) {
throw new Error('未找到封面上传输入框');
}
const file = new File(['cover'], 'cover.png', { type: 'image/png' });
await user.upload(input, file);
await waitFor(() => {
expect(screen.getByText('裁剪上传封面')).toBeTruthy();
});
await user.click(screen.getByRole('button', { name: '确认裁剪并上传' }));
await waitFor(() => {
expect(uploadMock).toHaveBeenCalledTimes(1);
});
const uploadPayload = uploadMock.mock.calls[0]?.[0];
expect(uploadPayload?.worldName).toBe('潮雾群岛');
expect(uploadPayload?.cropRect.width).toBeGreaterThan(0);
expect(uploadPayload?.cropRect.height).toBeGreaterThan(0);
await waitFor(() => {
expect(screen.queryByText('裁剪上传封面')).toBeNull();
});
await user.click(screen.getByRole('button', { name: //u }));
await waitFor(() => {
expect(screen.queryByText('编辑作品封面')).toBeNull();
});
const savedProfile = readCoverHarnessProfile();
expect(savedProfile.cover?.sourceType).toBe('uploaded');
expect(savedProfile.cover?.imageSrc).toBe(
'/generated-custom-world-covers/world-1/uploaded/cover.webp',
);
});

File diff suppressed because it is too large Load Diff

View File

@@ -14,11 +14,11 @@ interface CustomWorldGenerationViewProps {
onRetry: () => void; onRetry: () => void;
onInterrupt?: () => void; onInterrupt?: () => void;
backLabel?: string; backLabel?: string;
settingActionLabel?: string; settingActionLabel?: string | null;
retryLabel?: string; retryLabel?: string;
interruptLabel?: string; interruptLabel?: string;
settingTitle?: string; settingTitle?: string;
settingDescription?: string; settingDescription?: string | null;
progressTitle?: string; progressTitle?: string;
activeBadgeLabel?: string; activeBadgeLabel?: string;
pausedBadgeLabel?: string; pausedBadgeLabel?: string;
@@ -80,6 +80,11 @@ export function CustomWorldGenerationView({
const progressValue = getProgressPercentage(progress); const progressValue = getProgressPercentage(progress);
const steps = progress?.steps ?? []; const steps = progress?.steps ?? [];
const hasStructuredAnchors = anchorEntries.length > 0; const hasStructuredAnchors = anchorEntries.length > 0;
// 允许不同生成场景按需隐藏第二模块的说明和次级返回动作。
const normalizedSettingActionLabel = settingActionLabel?.trim() ?? '';
const normalizedSettingDescription = settingDescription?.trim() ?? '';
const hasSettingActionLabel = normalizedSettingActionLabel.length > 0;
const hasSettingDescription = normalizedSettingDescription.length > 0;
const estimatedWaitText = const estimatedWaitText =
progress?.estimatedRemainingMs != null progress?.estimatedRemainingMs != null
? `预计还需 ${formatDuration(progress.estimatedRemainingMs)}` ? `预计还需 ${formatDuration(progress.estimatedRemainingMs)}`
@@ -207,13 +212,15 @@ export function CustomWorldGenerationView({
<div className="mt-4 flex flex-col gap-3 sm:flex-row sm:flex-wrap sm:justify-end"> <div className="mt-4 flex flex-col gap-3 sm:flex-row sm:flex-wrap sm:justify-end">
{!isGenerating ? ( {!isGenerating ? (
<> <>
<button {hasSettingActionLabel ? (
type="button" <button
onClick={onEditSetting} type="button"
className="platform-button platform-button--ghost min-h-0 rounded-full px-4 py-2 text-sm" onClick={onEditSetting}
> className="platform-button platform-button--ghost min-h-0 rounded-full px-4 py-2 text-sm"
{settingActionLabel} >
</button> {normalizedSettingActionLabel}
</button>
) : null}
<button <button
type="button" type="button"
onClick={onRetry} onClick={onRetry}
@@ -240,18 +247,22 @@ export function CustomWorldGenerationView({
<div className="text-[11px] font-bold tracking-[0.2em] text-[var(--platform-cool-text)]"> <div className="text-[11px] font-bold tracking-[0.2em] text-[var(--platform-cool-text)]">
{settingTitle} {settingTitle}
</div> </div>
<div className="mt-1 text-sm text-zinc-400"> {hasSettingDescription ? (
{settingDescription} <div className="mt-1 text-sm text-zinc-400">
</div> {normalizedSettingDescription}
</div>
) : null}
</div> </div>
<button {hasSettingActionLabel ? (
type="button" <button
onClick={onEditSetting} type="button"
disabled={isGenerating} onClick={onEditSetting}
className={`platform-button platform-button--ghost min-h-0 px-3 py-1.5 text-[11px] ${isGenerating ? 'cursor-not-allowed opacity-40' : ''}`} disabled={isGenerating}
> className={`platform-button platform-button--ghost min-h-0 px-3 py-1.5 text-[11px] ${isGenerating ? 'cursor-not-allowed opacity-40' : ''}`}
{settingActionLabel} >
</button> {normalizedSettingActionLabel}
</button>
) : null}
</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">

View File

@@ -220,6 +220,33 @@ const baseProfile = {
connections: [], connections: [],
}, },
], ],
sceneChapterBlueprints: [
{
id: 'scene-chapter-1',
sceneId: 'landmark-1',
title: '沉钟栈桥章节',
summary: '围绕沉钟栈桥推进的三幕结构。',
linkedThreadIds: [],
linkedLandmarkIds: ['landmark-1'],
acts: [
{
id: 'scene-act-1',
sceneId: 'landmark-1',
title: '潮声逼近',
summary: '第一幕先把潮声与旧钟压上来。',
stageCoverage: ['opening'],
backgroundImageSrc: '/generated-custom-world-scenes/scene-act-1.png',
backgroundAssetId: 'scene-asset-1',
encounterNpcIds: ['story-1'],
primaryNpcId: 'story-1',
linkedThreadIds: [],
advanceRule: 'after_primary_contact',
actGoal: '接住首幕压力',
transitionHook: '继续逼近钟楼深处。',
},
],
},
],
creatorIntent: null, creatorIntent: null,
anchorPack: null, anchorPack: null,
lockState: null, lockState: null,
@@ -278,7 +305,7 @@ test('clicking新增可扮演角色 shows pending item, disables button, and mar
); );
await waitFor(() => { await waitFor(() => {
expect(screen.getByText('云止')).toBeTruthy(); expect(screen.getByRole('button', { name: //u })).toBeTruthy();
}); });
await waitFor(() => { await waitFor(() => {
@@ -304,3 +331,70 @@ test('world basic setting renders eight anchor fields and hides legacy parsed/so
expect(screen.getByText(//u)).toBeTruthy(); expect(screen.getByText(//u)).toBeTruthy();
expect(screen.getByText(/线/u)).toBeTruthy(); expect(screen.getByText(/线/u)).toBeTruthy();
}); });
test('playable tab prefers generated portrait over runtime preview placeholder', async () => {
const user = userEvent.setup();
const profile = {
...baseProfile,
playableNpcs: [
{
...createPlayableRole('playable-portrait', '云止'),
imageSrc: '/generated-characters/playable-portrait/master.png',
generatedVisualAssetId: 'visual-playable-portrait',
},
],
} as CustomWorldProfile;
render(
<CustomWorldResultView
profile={profile}
previewCharacters={[
{
id: 'playable-portrait',
name: '云止',
title: '同行者',
description: '预览角色',
backstory: '预览背景',
personality: '预览性格',
portrait: '/template/portrait.png',
avatar: '/template/avatar.png',
assetFolder: 'test',
assetVariant: 'Hero',
combatTags: [],
skills: [],
adventureOpenings: {},
} as never,
]}
isGenerating={false}
progress={0}
progressLabel=""
error={null}
onBack={() => {}}
onProfileChange={() => {}}
/>,
);
await user.click(screen.getByRole('button', { name: //u }));
const portrait = screen.getByRole('img', { name: '云止' });
expect((portrait as HTMLImageElement).getAttribute('src')).toBe(
'/generated-characters/playable-portrait/master.png',
);
expect(screen.getByText('已生成主图')).toBeTruthy();
});
test('landmark tab uses first act image as scene card preview and keeps chapter details out of list', async () => {
const user = userEvent.setup();
render(<ResultViewHarness />);
await user.click(screen.getByRole('button', { name: /\s*2/u }));
expect(screen.queryByText('沉钟栈桥章节')).toBeNull();
expect(screen.queryByText('潮声逼近')).toBeNull();
const sceneImage = screen.getByRole('img', { name: '沉钟栈桥' });
expect((sceneImage as HTMLImageElement).getAttribute('src')).toBe(
'/generated-custom-world-scenes/scene-act-1.png',
);
});

View File

@@ -54,6 +54,159 @@ type PendingGeneratedEntity = {
type RecentGeneratedIds = Record<EntityGenerationKind, string[]>; type RecentGeneratedIds = Record<EntityGenerationKind, string[]>;
type CustomWorldAssetDebugEntry = {
id: string;
label: string;
imageSrc: string;
kind: 'playable' | 'story' | 'landmark' | 'scene-act';
};
type AssetDebugLoadStatus = 'loading' | 'loaded' | 'error';
const CUSTOM_WORLD_ASSET_DEBUG_QUERY_KEY = 'debugCustomWorldAssets';
const CUSTOM_WORLD_ASSET_DEBUG_STORAGE_KEY =
'genarrative.debug.customWorldAssets';
function shouldEnableCustomWorldAssetDebugPanel() {
if (!import.meta.env.DEV || typeof window === 'undefined') {
return false;
}
const searchParams = new URLSearchParams(window.location.search);
if (searchParams.get(CUSTOM_WORLD_ASSET_DEBUG_QUERY_KEY) === '1') {
return true;
}
return (
window.localStorage.getItem(CUSTOM_WORLD_ASSET_DEBUG_STORAGE_KEY) === '1'
);
}
function collectCustomWorldAssetDebugEntries(
profile: CustomWorldProfile,
): CustomWorldAssetDebugEntry[] {
const playableEntries = profile.playableNpcs
.map((role) => {
const imageSrc = role.imageSrc?.trim() || '';
if (!imageSrc) {
return null;
}
return {
id: `playable:${role.id}`,
label: `${role.name}主形象`,
imageSrc,
kind: 'playable' as const,
};
})
.filter(
(entry): entry is CustomWorldAssetDebugEntry => Boolean(entry),
);
const storyEntries = profile.storyNpcs
.map((role) => {
const imageSrc = role.imageSrc?.trim() || '';
if (!imageSrc) {
return null;
}
return {
id: `story:${role.id}`,
label: `${role.name}场景角色主图`,
imageSrc,
kind: 'story' as const,
};
})
.filter(
(entry): entry is CustomWorldAssetDebugEntry => Boolean(entry),
);
const landmarkEntries = profile.landmarks
.map((landmark) => {
const imageSrc = landmark.imageSrc?.trim() || '';
if (!imageSrc) {
return null;
}
return {
id: `landmark:${landmark.id}`,
label: `${landmark.name}场景主图`,
imageSrc,
kind: 'landmark' as const,
};
})
.filter(
(entry): entry is CustomWorldAssetDebugEntry => Boolean(entry),
);
const sceneActEntries =
profile.sceneChapterBlueprints?.flatMap((chapter) =>
chapter.acts
.map((act) => {
const imageSrc = act.backgroundImageSrc?.trim() || '';
if (!imageSrc) {
return null;
}
return {
id: `scene-act:${chapter.id}:${act.id}`,
label: `${chapter.title || chapter.sceneId} / ${act.title}幕图`,
imageSrc,
kind: 'scene-act' as const,
};
})
.filter(
(entry): entry is CustomWorldAssetDebugEntry => Boolean(entry),
),
) ?? [];
return [
...playableEntries,
...storyEntries,
...landmarkEntries,
...sceneActEntries,
];
}
function resolveAssetDebugStatusLabel(status: AssetDebugLoadStatus | undefined) {
if (status === 'loaded') {
return '已加载';
}
if (status === 'error') {
return '加载失败';
}
return '检测中';
}
function resolveAssetDebugSummary(profile: CustomWorldProfile) {
return [
{
label: '可扮演角色主图',
value: `${profile.playableNpcs.filter((role) => Boolean(role.imageSrc?.trim())).length}/${profile.playableNpcs.length}`,
},
{
label: '场景角色主图',
value: `${profile.storyNpcs.filter((role) => Boolean(role.imageSrc?.trim())).length}/${profile.storyNpcs.length}`,
},
{
label: '场景主图',
value: `${profile.landmarks.filter((landmark) => Boolean(landmark.imageSrc?.trim())).length}/${profile.landmarks.length}`,
},
{
label: '分幕图',
value: `${profile.sceneChapterBlueprints?.reduce(
(sum, chapter) =>
sum +
chapter.acts.filter((act) => Boolean(act.backgroundImageSrc?.trim()))
.length,
0,
) ?? 0}/${
profile.sceneChapterBlueprints?.reduce(
(sum, chapter) => sum + chapter.acts.length,
0,
) ?? 0
}`,
},
];
}
function SmallButton({ function SmallButton({
onClick, onClick,
children, children,
@@ -236,6 +389,22 @@ export function CustomWorldResultView({
null, null,
); );
const pendingProgressTimerRef = useRef<number | null>(null); const pendingProgressTimerRef = useRef<number | null>(null);
const assetDebugEnabled = useMemo(
() => shouldEnableCustomWorldAssetDebugPanel(),
[],
);
const assetDebugEntries = useMemo(
() =>
assetDebugEnabled ? collectCustomWorldAssetDebugEntries(profile) : [],
[assetDebugEnabled, profile],
);
const assetDebugSummary = useMemo(
() => (assetDebugEnabled ? resolveAssetDebugSummary(profile) : []),
[assetDebugEnabled, profile],
);
const [assetDebugStatusMap, setAssetDebugStatusMap] = useState<
Record<string, AssetDebugLoadStatus>
>({});
const createTarget = useMemo( const createTarget = useMemo(
() => getCreateTargetByTab(activeTab), () => getCreateTargetByTab(activeTab),
@@ -254,6 +423,59 @@ export function CustomWorldResultView({
useEffect(() => () => stopPendingProgressTimer(), []); useEffect(() => () => stopPendingProgressTimer(), []);
useEffect(() => {
if (!assetDebugEnabled) {
setAssetDebugStatusMap({});
return;
}
if (assetDebugEntries.length === 0) {
setAssetDebugStatusMap({});
return;
}
let cancelled = false;
const cleanupList: Array<() => void> = [];
setAssetDebugStatusMap(
Object.fromEntries(
assetDebugEntries.map((entry) => [entry.id, 'loading' as const]),
),
);
assetDebugEntries.forEach((entry) => {
const image = new Image();
const updateStatus = (status: AssetDebugLoadStatus) => {
if (cancelled) {
return;
}
setAssetDebugStatusMap((current) => {
if (current[entry.id] === status) {
return current;
}
return {
...current,
[entry.id]: status,
};
});
};
image.onload = () => updateStatus('loaded');
image.onerror = () => updateStatus('error');
image.src = entry.imageSrc;
cleanupList.push(() => {
image.onload = null;
image.onerror = null;
});
});
return () => {
cancelled = true;
cleanupList.forEach((cleanup) => cleanup());
};
}, [assetDebugEnabled, assetDebugEntries]);
const startPendingProgress = (kind: EntityGenerationKind) => { const startPendingProgress = (kind: EntityGenerationKind) => {
stopPendingProgressTimer(); stopPendingProgressTimer();
setPendingGeneratedEntity(createPendingGeneratedEntity(kind)); setPendingGeneratedEntity(createPendingGeneratedEntity(kind));
@@ -445,6 +667,77 @@ export function CustomWorldResultView({
{localGenerationError} {localGenerationError}
</div> </div>
) : null} ) : null}
{assetDebugEnabled ? (
<div className="platform-surface platform-surface--soft mt-3 px-3.5 py-3">
<div className="flex items-start justify-between gap-3">
<div>
<div className="text-xs font-bold tracking-[0.16em] text-white">
</div>
<div className="mt-1 text-xs leading-6 text-zinc-500">
</div>
</div>
<div className="platform-pill platform-pill--neutral px-2.5 py-1 text-[10px]">
{assetDebugEntries.length}
</div>
</div>
<div className="mt-3 grid grid-cols-2 gap-2 xl:grid-cols-4">
{assetDebugSummary.map((entry) => (
<div
key={entry.label}
className="platform-subpanel rounded-2xl px-3 py-2"
>
<div className="text-[11px] text-zinc-500">{entry.label}</div>
<div className="mt-1 text-sm font-semibold text-white">
{entry.value}
</div>
</div>
))}
</div>
<div className="mt-3 space-y-2">
{assetDebugEntries.length > 0 ? (
assetDebugEntries.map((entry) => (
<div
key={entry.id}
className="platform-subpanel rounded-2xl px-3 py-2"
>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<div className="text-sm font-semibold text-white">
{entry.label}
</div>
<div className="mt-1 break-all text-[11px] leading-5 text-zinc-400">
{entry.imageSrc}
</div>
</div>
<div className="platform-pill platform-pill--neutral px-2.5 py-1 text-[10px]">
{resolveAssetDebugStatusLabel(
assetDebugStatusMap[entry.id],
)}
</div>
</div>
<div className="mt-2">
<a
href={entry.imageSrc}
target="_blank"
rel="noreferrer"
aria-label={`打开 ${entry.label}`}
className="text-xs font-semibold text-amber-200 underline decoration-white/20 underline-offset-2"
>
</a>
</div>
</div>
))
) : (
<div className="platform-subpanel rounded-2xl px-3 py-3 text-sm text-zinc-400">
profile
</div>
)}
</div>
</div>
) : null}
<div className="mt-4 flex flex-col gap-3"> <div className="mt-4 flex flex-col gap-3">
{profile.generationStatus === 'key_only' ? ( {profile.generationStatus === 'key_only' ? (

View File

@@ -283,6 +283,7 @@ export function GameShell({session, story, entry, companions, audio}: GameShellP
!gameState.playerCharacter; !gameState.playerCharacter;
const collapseTopStage = gameState.currentScene === 'Selection'; const collapseTopStage = gameState.currentScene === 'Selection';
const shouldHideStoryOptions = sceneTransitionPhase !== 'idle'; const shouldHideStoryOptions = sceneTransitionPhase !== 'idle';
const visibleStoryForRender = visibleCurrentStory;
const dialogueIndicator = useMemo(() => { const dialogueIndicator = useMemo(() => {
if (!isLoading || visibleCurrentStory?.displayMode !== 'dialogue' || visibleGameState.currentEncounter?.kind !== 'npc') { if (!isLoading || visibleCurrentStory?.displayMode !== 'dialogue' || visibleGameState.currentEncounter?.kind !== 'npc') {
@@ -431,6 +432,7 @@ export function GameShell({session, story, entry, companions, audio}: GameShellP
companions={canvasCompanionRenderStates} companions={canvasCompanionRenderStates}
npcStates={visibleGameState.npcStates} npcStates={visibleGameState.npcStates}
dialogueIndicator={dialogueIndicator} dialogueIndicator={dialogueIndicator}
npcAffinityEffect={visibleStoryForRender?.npcAffinityEffect ?? null}
onEntitySelect={setSelectedSceneEntity} onEntitySelect={setSelectedSceneEntity}
onSceneNameClick={() => setIsMapOpen(true)} onSceneNameClick={() => setIsMapOpen(true)}
sceneTransitionPhase={sceneTransitionPhase} sceneTransitionPhase={sceneTransitionPhase}
@@ -485,7 +487,7 @@ export function GameShell({session, story, entry, companions, audio}: GameShellP
</motion.div> </motion.div>
)} )}
{visibleGameState.playerCharacter && visibleCurrentStory && ( {visibleGameState.playerCharacter && visibleStoryForRender && (
<motion.div key="story-flow" initial={{opacity: 0}} animate={{opacity: 1}} className="flex h-full min-h-0 flex-col"> <motion.div key="story-flow" initial={{opacity: 0}} animate={{opacity: 1}} className="flex h-full min-h-0 flex-col">
<div className="story-top-tabs mb-3 grid grid-cols-3 gap-2 sm:gap-3"> <div className="story-top-tabs mb-3 grid grid-cols-3 gap-2 sm:gap-3">
<button <button
@@ -562,7 +564,7 @@ export function GameShell({session, story, entry, companions, audio}: GameShellP
<Suspense fallback={<PanelLoadingFallback label="正在加载冒险面板" />}> <Suspense fallback={<PanelLoadingFallback label="正在加载冒险面板" />}>
<AdventurePanel <AdventurePanel
aiError={aiError} aiError={aiError}
currentStory={visibleCurrentStory} currentStory={visibleStoryForRender}
isLoading={isLoading} isLoading={isLoading}
displayedOptions={displayedOptions} displayedOptions={displayedOptions}
hideOptions={shouldHideStoryOptions} hideOptions={shouldHideStoryOptions}

View File

@@ -6,8 +6,6 @@ import { fetchJson } from '../../editor/shared/jsonClient';
export const CHARACTER_VISUAL_GENERATE_API_PATH = export const CHARACTER_VISUAL_GENERATE_API_PATH =
ASSET_API_PATHS.characterVisualGenerate; ASSET_API_PATHS.characterVisualGenerate;
export const CHARACTER_PROMPT_BUNDLE_GENERATE_API_PATH =
ASSET_API_PATHS.characterPromptBundleGenerate;
export const CHARACTER_WORKFLOW_CACHE_API_PATH = export const CHARACTER_WORKFLOW_CACHE_API_PATH =
ASSET_API_PATHS.characterWorkflowCache; ASSET_API_PATHS.characterWorkflowCache;
export const CHARACTER_VISUAL_PUBLISH_API_PATH = export const CHARACTER_VISUAL_PUBLISH_API_PATH =
@@ -47,29 +45,6 @@ export type CharacterVisualDraft = {
height: number; height: number;
}; };
export type CharacterPromptBundlePayload = {
roleKind: 'playable' | 'story';
characterName: string;
roleTitle?: string;
roleLabel?: string;
description?: string;
backstory?: string;
personality?: string;
motivation?: string;
combatStyle?: string;
tags?: string[];
characterBriefText: string;
};
export type CharacterPromptBundleResult = {
ok: true;
visualPromptText: string;
animationPromptText: string;
scenePromptText: string;
source: 'llm' | 'fallback';
model: string | null;
};
export type CharacterAssetWorkflowCache = { export type CharacterAssetWorkflowCache = {
characterId: string; characterId: string;
visualPromptText: string; visualPromptText: string;
@@ -174,16 +149,6 @@ export async function generateCharacterVisualCandidates(
}>(CHARACTER_VISUAL_GENERATE_API_PATH, payload, '生成角色主形象失败'); }>(CHARACTER_VISUAL_GENERATE_API_PATH, payload, '生成角色主形象失败');
} }
export async function generateCharacterPromptBundle(
payload: CharacterPromptBundlePayload,
) {
return postApiJson<CharacterPromptBundleResult>(
CHARACTER_PROMPT_BUNDLE_GENERATE_API_PATH,
payload,
'生成默认提示词失败',
);
}
export async function fetchCharacterWorkflowCache(characterId: string) { export async function fetchCharacterWorkflowCache(characterId: string) {
return fetchJson<{ return fetchJson<{
ok: true; ok: true;

View File

@@ -44,7 +44,7 @@ export function CustomWorldAgentComposer({
return ( return (
<div className="shrink-0"> <div className="shrink-0">
<div className="platform-remap-surface relative"> <div className="platform-remap-surface platform-subpanel relative rounded-[1.5rem] p-1.5">
<textarea <textarea
ref={textareaRef} ref={textareaRef}
value={text} value={text}
@@ -58,13 +58,13 @@ export function CustomWorldAgentComposer({
rows={2} rows={2}
disabled={disabled} disabled={disabled}
placeholder="输入消息" placeholder="输入消息"
className="min-h-[5.5rem] w-full resize-none rounded-[1.35rem] border border-white/10 bg-[#111318]/92 px-4 pb-11 pr-18 pt-2.5 text-sm leading-5.5 text-white outline-none transition focus:border-emerald-300/35 disabled:cursor-not-allowed disabled:opacity-60" className="platform-input min-h-[5.5rem] resize-none rounded-[1.2rem] pb-12 pr-20 pt-3 text-sm leading-5.5 disabled:cursor-not-allowed disabled:opacity-60"
/> />
<button <button
type="button" type="button"
onClick={submit} onClick={submit}
disabled={disabled || !text.trim()} disabled={disabled || !text.trim()}
className="absolute bottom-2.5 right-2.5 rounded-full border border-emerald-300/20 bg-emerald-500/10 px-3 py-1.5 text-xs font-medium text-emerald-100 transition hover:text-white disabled:cursor-not-allowed disabled:opacity-45" className="platform-button platform-button--primary absolute bottom-3 right-3 h-9 min-h-0 rounded-full px-3 py-1.5 text-xs disabled:cursor-not-allowed disabled:opacity-45"
> >
</button> </button>

View File

@@ -4,11 +4,11 @@ type CustomWorldAgentHeaderProps = {
export function CustomWorldAgentHeader({ onBack }: CustomWorldAgentHeaderProps) { export function CustomWorldAgentHeader({ onBack }: CustomWorldAgentHeaderProps) {
return ( return (
<div className="platform-remap-surface flex items-center rounded-[1.5rem] border border-white/10 bg-[#111318]/95 px-4 py-3"> <div className="platform-remap-surface platform-subpanel flex items-center rounded-[1.5rem] px-4 py-3">
<button <button
type="button" type="button"
onClick={onBack} onClick={onBack}
className="rounded-full border border-white/10 bg-white/5 px-3 py-1.5 text-[11px] text-zinc-300 transition hover:text-white" className="platform-button platform-button--ghost h-9 min-h-0 rounded-full px-3 py-1.5 text-[11px]"
> >
</button> </button>

View File

@@ -35,37 +35,41 @@ export function CustomWorldAgentOperationBanner({
const isFailed = visibleOperation.status === 'failed'; const isFailed = visibleOperation.status === 'failed';
const isRunning = const isRunning =
visibleOperation.status === 'running' || visibleOperation.status === 'queued'; visibleOperation.status === 'running' || visibleOperation.status === 'queued';
// 操作横幅直接复用平台状态横幅,亮暗主题都从同一套 token 取色。
const bannerToneClass = isFailed
? 'platform-banner--danger'
: isRunning
? 'platform-banner--info'
: 'platform-banner--success';
const progressFillStyle = isFailed
? { background: 'linear-gradient(90deg, #fb7185 0%, #f43f5e 100%)' }
: isRunning
? { background: 'var(--platform-button-primary-fill)' }
: { background: 'linear-gradient(90deg, #86efac 0%, #34d399 100%)' };
return ( return (
<div <div
className={`platform-remap-surface rounded-[1.4rem] border px-4 py-4 ${ className={`platform-remap-surface platform-banner rounded-[1.4rem] px-4 py-4 ${bannerToneClass}`}
isFailed
? 'border-rose-400/20 bg-[#111318]/95'
: isRunning
? 'border-emerald-300/20 bg-[#111318]/95'
: 'border-emerald-300/20 bg-[#111318]/95'
}`}
> >
<div className="flex items-center justify-between gap-3"> <div className="flex items-center justify-between gap-3">
<div className="text-sm font-semibold text-white"> <div className="text-sm font-semibold">
{visibleOperation.phaseLabel} {visibleOperation.phaseLabel}
</div> </div>
<div className="text-xs text-zinc-300"> <div className="text-xs opacity-80">
{Math.max(0, Math.min(100, Math.round(visibleOperation.progress)))}% {Math.max(0, Math.min(100, Math.round(visibleOperation.progress)))}%
</div> </div>
</div> </div>
{visibleOperation.error ? ( {visibleOperation.error ? (
<div className="mt-2 text-sm text-zinc-200"> <div className="mt-2 text-sm opacity-90">
{visibleOperation.error} {visibleOperation.error}
</div> </div>
) : null} ) : null}
<div className="mt-3 h-2 overflow-hidden rounded-full bg-white/10"> <div className="platform-progress-track mt-3 h-2 overflow-hidden rounded-full">
<div <div
className={`h-full rounded-full transition-[width] duration-300 ${ className="h-full rounded-full transition-[width] duration-300"
isFailed ? 'bg-rose-300' : 'bg-emerald-300'
}`}
style={{ style={{
width: `${Math.max(8, Math.min(100, visibleOperation.progress))}%`, width: `${Math.max(8, Math.min(100, visibleOperation.progress))}%`,
...progressFillStyle,
}} }}
/> />
</div> </div>

View File

@@ -37,9 +37,9 @@ export function CustomWorldAgentThread({
}, [messages, streamingReplyText, isStreamingReply]); }, [messages, streamingReplyText, isStreamingReply]);
return ( return (
<div className="platform-remap-surface flex h-full min-h-0 flex-1 flex-col overflow-y-auto px-1 py-2 sm:px-2"> <div className="platform-remap-surface platform-subpanel flex h-full min-h-0 flex-1 flex-col overflow-y-auto rounded-[1.75rem] px-2 py-3 sm:px-3">
{messages.length === 0 ? ( {messages.length === 0 ? (
<div className="m-auto text-sm text-zinc-400"> <div className="m-auto text-sm text-[var(--platform-text-soft)]">
</div> </div>
) : ( ) : (
@@ -47,6 +47,12 @@ export function CustomWorldAgentThread({
{messages.map((message, index) => { {messages.map((message, index) => {
const isUser = message.role === 'user'; const isUser = message.role === 'user';
const isSystem = message.role === 'system'; const isSystem = message.role === 'system';
// 聊天气泡统一映射到平台主题 token避免亮色主题继续透出历史深色底。
const bubbleToneClass = isUser
? 'border border-[var(--platform-cool-border)] bg-[var(--platform-cool-bg)] text-[var(--platform-text-strong)]'
: isSystem
? 'border border-[var(--platform-warm-border)] bg-[var(--platform-warm-bg)] text-[var(--platform-warm-text)]'
: 'platform-subpanel text-[var(--platform-text-strong)]';
return ( return (
<div <div
@@ -56,13 +62,7 @@ export function CustomWorldAgentThread({
}`} }`}
> >
<div <div
className={`max-w-[90%] rounded-[1.4rem] px-4 py-3 text-sm leading-7 break-words sm:max-w-[82%] ${ className={`max-w-[90%] rounded-[1.4rem] px-4 py-3 text-sm leading-7 break-words sm:max-w-[82%] ${bubbleToneClass}`}
isUser
? 'border border-white/10 bg-white/10 text-zinc-50'
: isSystem
? 'border border-amber-300/16 bg-amber-500/10 text-amber-50'
: 'border border-white/10 bg-white/6 text-zinc-100'
}`}
> >
<div className="whitespace-pre-wrap">{message.text}</div> <div className="whitespace-pre-wrap">{message.text}</div>
{!isUser && {!isUser &&
@@ -74,7 +74,7 @@ export function CustomWorldAgentThread({
key={`recommended-reply-${replyIndex}-${reply}`} key={`recommended-reply-${replyIndex}-${reply}`}
type="button" type="button"
onClick={() => onRecommendedReply?.(reply)} onClick={() => onRecommendedReply?.(reply)}
className="rounded-[0.95rem] border border-white/10 bg-white/5 px-2.5 py-1.5 text-left text-[11px] leading-4.5 text-zinc-200 transition hover:border-emerald-300/25 hover:text-white" className="platform-button platform-button--ghost min-h-0 justify-start rounded-[0.95rem] px-2.5 py-1.5 text-left text-[11px] leading-4.5 whitespace-normal"
> >
{reply} {reply}
</button> </button>
@@ -87,17 +87,17 @@ export function CustomWorldAgentThread({
})} })}
{isStreamingReply ? ( {isStreamingReply ? (
<div className="flex justify-start"> <div className="flex justify-start">
<div className="max-w-[90%] rounded-[1.4rem] border border-white/10 bg-white/6 px-4 py-3 text-sm leading-7 text-zinc-100 sm:max-w-[82%]"> <div className="platform-subpanel max-w-[90%] rounded-[1.4rem] px-4 py-3 text-sm leading-7 text-[var(--platform-text-strong)] sm:max-w-[82%]">
{streamingReplyText ? ( {streamingReplyText ? (
<div className="whitespace-pre-wrap"> <div className="whitespace-pre-wrap">
{streamingReplyText} {streamingReplyText}
<span className="ml-1 inline-block h-4 w-1 animate-pulse rounded-full bg-emerald-200/80 align-[-2px]" /> <span className="ml-1 inline-block h-4 w-1 animate-pulse rounded-full bg-[var(--platform-cool-text)] align-[-2px]" />
</div> </div>
) : ( ) : (
<div className="flex items-center gap-1.5 py-1"> <div className="flex items-center gap-1.5 py-1">
<span className="h-2 w-2 animate-pulse rounded-full bg-zinc-300/70 [animation-delay:-0.2s]" /> <span className="h-2 w-2 animate-pulse rounded-full bg-[var(--platform-text-soft)] [animation-delay:-0.2s]" />
<span className="h-2 w-2 animate-pulse rounded-full bg-zinc-300/70 [animation-delay:-0.1s]" /> <span className="h-2 w-2 animate-pulse rounded-full bg-[var(--platform-text-soft)] [animation-delay:-0.1s]" />
<span className="h-2 w-2 animate-pulse rounded-full bg-zinc-300/70" /> <span className="h-2 w-2 animate-pulse rounded-full bg-[var(--platform-text-soft)]" />
</div> </div>
)} )}
</div> </div>

View File

@@ -42,7 +42,7 @@ export function CustomWorldAgentWorkspace({
}: CustomWorldAgentWorkspaceProps) { }: CustomWorldAgentWorkspaceProps) {
if (!session) { if (!session) {
return ( return (
<div className="platform-remap-surface mx-auto flex h-full w-full max-w-4xl items-center justify-center rounded-[1.75rem] border border-white/10 bg-black/20 px-6 py-8 text-center text-sm text-zinc-400"> <div className="platform-remap-surface platform-subpanel mx-auto flex h-full w-full max-w-4xl items-center justify-center rounded-[1.75rem] px-6 py-8 text-center text-sm text-[var(--platform-text-soft)]">
</div> </div>
); );

View File

@@ -46,36 +46,42 @@ export function EightAnchorProgressBar({
const normalizedProgress = clampProgress(progressPercent); const normalizedProgress = clampProgress(progressPercent);
const isCompleted = normalizedProgress >= 100; const isCompleted = normalizedProgress >= 100;
const canQuickFill = currentTurn >= 2; const canQuickFill = currentTurn >= 2;
const progressFillStyle = isCompleted
? { background: 'linear-gradient(90deg, #86efac 0%, #34d399 100%)' }
: { background: 'var(--platform-button-primary-fill)' };
return ( return (
<div className="platform-remap-surface rounded-[1.75rem] border border-white/10 bg-[#111318]/95 p-4"> <div className="platform-remap-surface platform-subpanel rounded-[1.75rem] p-4">
<div className="flex flex-col gap-3"> <div className="flex flex-col gap-3">
<div className="flex items-start justify-between gap-3"> <div className="flex items-start justify-between gap-3">
<div> <div>
<div className="text-xs font-semibold tracking-[0.14em] text-zinc-300"> <div className="text-xs font-semibold tracking-[0.14em] text-[var(--platform-text-base)]">
</div> </div>
<div className="mt-1 text-sm text-zinc-400"> <div className="mt-1 text-sm text-[var(--platform-text-soft)]">
{resolveProgressHint(normalizedProgress)} {resolveProgressHint(normalizedProgress)}
</div> </div>
</div> </div>
<div className="text-lg font-semibold text-white"> <div className="text-lg font-semibold text-[var(--platform-text-strong)]">
{normalizedProgress}% {normalizedProgress}%
</div> </div>
</div> </div>
<div className="h-3 overflow-hidden rounded-full bg-white/8"> <div className="platform-progress-track h-3 overflow-hidden rounded-full">
<div <div
className="h-full rounded-full bg-[linear-gradient(90deg,#d8ffd9_0%,#6ee7b7_45%,#34d399_100%)] transition-[width] duration-500" className="h-full rounded-full transition-[width] duration-500"
style={{ width: `${Math.max(6, normalizedProgress)}%` }} style={{
width: `${Math.max(6, normalizedProgress)}%`,
...progressFillStyle,
}}
/> />
</div> </div>
<div className="flex items-center justify-between gap-3"> <div className="flex flex-wrap items-center justify-between gap-3">
<button <button
type="button" type="button"
onClick={onSummaryClick} onClick={onSummaryClick}
disabled={disabled} disabled={disabled}
className="rounded-full border border-white/10 bg-white/5 px-3 py-1.5 text-xs text-zinc-200 transition hover:text-white disabled:cursor-not-allowed disabled:opacity-45" className="platform-button platform-button--ghost min-h-0 rounded-full px-3 py-1.5 text-xs disabled:cursor-not-allowed disabled:opacity-45"
> >
</button> </button>
@@ -84,7 +90,7 @@ export function EightAnchorProgressBar({
type="button" type="button"
onClick={onGenerateDraft} onClick={onGenerateDraft}
disabled={disabled} disabled={disabled}
className="flex min-h-[3rem] items-center justify-center rounded-[1.1rem] border border-emerald-300/25 bg-emerald-500/12 px-4 py-3 text-sm font-semibold text-emerald-50 transition hover:text-white disabled:cursor-not-allowed disabled:opacity-45" className="platform-button platform-button--primary min-h-[3rem] rounded-[1.1rem] px-4 py-3 text-sm disabled:cursor-not-allowed disabled:opacity-45"
> >
稿 稿
</button> </button>
@@ -93,7 +99,7 @@ export function EightAnchorProgressBar({
type="button" type="button"
onClick={onQuickFill} onClick={onQuickFill}
disabled={disabled} disabled={disabled}
className="rounded-full border border-white/10 bg-white/5 px-3 py-1.5 text-xs text-zinc-200 transition hover:text-white disabled:cursor-not-allowed disabled:opacity-45" className="platform-button platform-button--ghost min-h-0 rounded-full px-3 py-1.5 text-xs disabled:cursor-not-allowed disabled:opacity-45"
> >
</button> </button>

View File

@@ -0,0 +1,128 @@
import { renderToStaticMarkup } from 'react-dom/server';
import { describe, expect, it } from 'vitest';
import {
AnimationState,
type Character,
type Encounter,
type SceneHostileNpc,
} from '../../types';
import { GameCanvasEntityLayer } from './GameCanvasEntityLayer';
function createCharacter(): Character {
return {
id: 'hero',
name: '沈行',
title: '试剑客',
description: '测试主角',
backstory: '测试背景',
avatar: '/hero.png',
portrait: '/hero.png',
assetFolder: 'hero',
assetVariant: 'default',
attributes: {
strength: 10,
agility: 10,
intelligence: 8,
spirit: 9,
},
personality: 'calm',
skills: [],
adventureOpenings: {},
} as Character;
}
function createEncounter(overrides: Partial<Encounter> = {}): Encounter {
return {
id: 'npc-liu',
kind: 'npc',
npcName: '柳无声',
npcDescription: '桥口旧识',
npcAvatar: '/npc-liu.png',
context: '断桥',
...overrides,
};
}
function createHostileNpc(overrides: Partial<SceneHostileNpc> = {}): SceneHostileNpc {
return {
id: 'npc-liu',
name: '柳无声',
action: '对峙',
description: '桥口旧识',
animation: 'idle',
xMeters: 3,
yOffset: 0,
facing: 'left',
attackRange: 1,
speed: 1,
hp: 10,
maxHp: 10,
encounter: createEncounter(),
...overrides,
};
}
function renderEntityLayer(effectNpcId: string | null) {
return renderToStaticMarkup(
<GameCanvasEntityLayer
companions={[]}
currentScenePreset={null}
sceneTransitionToken={0}
isSceneTransitionEntering={false}
isSceneTransitionExiting={false}
transitionSweepPx={320}
sceneTransitionExitDurationS={0.2}
sceneTransitionEntryDurationS={0.2}
companionAnchorLeft="10%"
companionAnchorBottom="20%"
playerBottomOffsetPx={0}
sceneTransitionPhase="idle"
inBattle={false}
onEntitySelect={null}
playerLeft="20%"
playerCharacter={createCharacter()}
playerHp={100}
playerMaxHp={100}
effectivePlayerFacing="right"
effectivePlayerAnimationState={AnimationState.IDLE}
shouldShowPlayerDialogueIcon={false}
dialogueIndicator={null}
npcAffinityEffect={
effectNpcId
? {
eventId: 'effect-1',
npcId: effectNpcId,
delta: 3,
}
: null
}
sceneCombatants={[createHostileNpc()]}
monsters={[]}
getHostileNpcOuterLeft={() => '70%'}
groundBottom="18%"
stageLiftPx={68}
encounter={null}
sideAnchor="15%"
cameraAnchorX={0}
monsterAnchorMeters={3.2}
playerX={0}
/>,
);
}
describe('GameCanvasEntityLayer', () => {
it('renders affinity effect on the matching hostile npc', () => {
const html = renderEntityLayer('npc-liu');
expect(html).toContain('data-testid="npc-affinity-effect-npc-liu"');
expect(html).toContain('aria-label="好感度变化 +3"');
});
it('does not render affinity effect on a different npc', () => {
const html = renderEntityLayer('npc-other');
expect(html).not.toContain('npc-affinity-effect-npc-liu');
expect(html).not.toContain('好感度变化 +3');
});
});

View File

@@ -15,6 +15,7 @@ import {
import {HostileNpcAnimator} from '../HostileNpcAnimator'; import {HostileNpcAnimator} from '../HostileNpcAnimator';
import {MedievalNpcAnimator} from '../MedievalNpcAnimator'; import {MedievalNpcAnimator} from '../MedievalNpcAnimator';
import {getRenderableNpcFacing} from '../npcRenderUtils'; import {getRenderableNpcFacing} from '../npcRenderUtils';
import {NpcAffinityEffectBadge} from './NpcAffinityEffectBadge';
import { import {
DialogueBubbleIcon, DialogueBubbleIcon,
type GameCanvasEntitySelection, type GameCanvasEntitySelection,
@@ -66,6 +67,11 @@ interface GameCanvasEntityLayerProps {
showEncounter: boolean; showEncounter: boolean;
activeSpeaker?: 'player' | 'npc' | null; activeSpeaker?: 'player' | 'npc' | null;
} | null; } | null;
npcAffinityEffect?: {
eventId: string;
npcId: string;
delta: number;
} | null;
sceneCombatants: SceneHostileNpc[]; sceneCombatants: SceneHostileNpc[];
monsters: MonsterSpriteConfig[]; monsters: MonsterSpriteConfig[];
getHostileNpcOuterLeft: (hostileNpc: SceneHostileNpc) => string; getHostileNpcOuterLeft: (hostileNpc: SceneHostileNpc) => string;
@@ -101,6 +107,7 @@ export function GameCanvasEntityLayer({
effectivePlayerAnimationState, effectivePlayerAnimationState,
shouldShowPlayerDialogueIcon, shouldShowPlayerDialogueIcon,
dialogueIndicator = null, dialogueIndicator = null,
npcAffinityEffect = null,
sceneCombatants, sceneCombatants,
monsters, monsters,
getHostileNpcOuterLeft, getHostileNpcOuterLeft,
@@ -326,6 +333,10 @@ export function GameCanvasEntityLayer({
/> />
</div> </div>
)} )}
{/* 聊天好感变化要挂在当前角色形象上,而不是消息区里。 */}
{npcAffinityEffect?.npcId === (npcEncounter.id ?? npcEncounter.npcName) ? (
<NpcAffinityEffectBadge effect={npcAffinityEffect} />
) : null}
</SceneEntityButton> </SceneEntityButton>
</div> </div>
); );
@@ -436,6 +447,10 @@ export function GameCanvasEntityLayer({
/> />
</div> </div>
)} )}
{/* 和平相遇态同样沿用角色形象上的好感浮出特效。 */}
{npcAffinityEffect?.npcId === (encounter.id ?? encounter.npcName) ? (
<NpcAffinityEffectBadge effect={npcAffinityEffect} />
) : null}
</SceneEntityButton> </SceneEntityButton>
</div> </div>
); );

View File

@@ -39,6 +39,7 @@ export function GameCanvasRuntime({
activeCombatEffects = [], activeCombatEffects = [],
companions = [], companions = [],
dialogueIndicator = null, dialogueIndicator = null,
npcAffinityEffect = null,
onEntitySelect = null, onEntitySelect = null,
onSceneNameClick = null, onSceneNameClick = null,
sceneTransitionPhase = 'idle', sceneTransitionPhase = 'idle',
@@ -192,6 +193,7 @@ export function GameCanvasRuntime({
effectivePlayerAnimationState={effectivePlayerAnimationState} effectivePlayerAnimationState={effectivePlayerAnimationState}
shouldShowPlayerDialogueIcon={shouldShowPlayerDialogueIcon} shouldShowPlayerDialogueIcon={shouldShowPlayerDialogueIcon}
dialogueIndicator={dialogueIndicator} dialogueIndicator={dialogueIndicator}
npcAffinityEffect={npcAffinityEffect}
sceneCombatants={sceneHostileNpcs} sceneCombatants={sceneHostileNpcs}
monsters={monsters} monsters={monsters}
getHostileNpcOuterLeft={getHostileNpcOuterLeft} getHostileNpcOuterLeft={getHostileNpcOuterLeft}

View File

@@ -16,6 +16,7 @@ import {
Encounter, Encounter,
SceneHostileNpc, SceneHostileNpc,
ScenePresetInfo, ScenePresetInfo,
StoryNpcAffinityEffect,
StoryEngineMemoryState, StoryEngineMemoryState,
WorldType, WorldType,
} from '../../types'; } from '../../types';
@@ -54,6 +55,7 @@ export interface GameCanvasProps {
showEncounter: boolean; showEncounter: boolean;
activeSpeaker?: 'player' | 'npc' | null; activeSpeaker?: 'player' | 'npc' | null;
} | null; } | null;
npcAffinityEffect?: StoryNpcAffinityEffect | null;
onEntitySelect?: ((entity: GameCanvasEntitySelection) => void) | null; onEntitySelect?: ((entity: GameCanvasEntitySelection) => void) | null;
onSceneNameClick?: (() => void) | null; onSceneNameClick?: (() => void) | null;
sceneTransitionPhase?: 'idle' | 'exiting' | 'entering'; sceneTransitionPhase?: 'idle' | 'exiting' | 'entering';
@@ -68,6 +70,10 @@ export const ENTITY_CONTAINER_REM = 7;
export const ROLE_CHARACTER_FRAME_CLASS = 'flex h-28 w-28 items-end justify-center overflow-visible'; export const ROLE_CHARACTER_FRAME_CLASS = 'flex h-28 w-28 items-end justify-center overflow-visible';
export const ROLE_CHARACTER_SPRITE_CLASS = 'h-full w-full scale-[1.32] origin-bottom'; export const ROLE_CHARACTER_SPRITE_CLASS = 'h-full w-full scale-[1.32] origin-bottom';
export const GENERIC_NPC_SCENE_SCALE = 1.72; export const GENERIC_NPC_SCENE_SCALE = 1.72;
const DEFAULT_IMAGE_STYLE: React.CSSProperties = {
imageRendering: 'pixelated',
objectPosition: 'center bottom',
};
export const DEFAULT_COMBAT_HP_TOP_PX = -18; export const DEFAULT_COMBAT_HP_TOP_PX = -18;
export const CHARACTER_NPC_COMBAT_HP_TOP_PX = -2; export const CHARACTER_NPC_COMBAT_HP_TOP_PX = -2;
export const GENERIC_NPC_COMBAT_HP_TOP_PX = 94; export const GENERIC_NPC_COMBAT_HP_TOP_PX = 94;

View File

@@ -0,0 +1,59 @@
import { Heart } from 'lucide-react';
import { motion } from 'motion/react';
import type { StoryNpcAffinityEffect } from '../../types';
interface NpcAffinityEffectBadgeProps {
effect: StoryNpcAffinityEffect;
}
/**
* 聊天结算后的好感度浮出特效。
* 仅负责表现层,不承担任何数值计算。
*/
export function NpcAffinityEffectBadge({
effect,
}: NpcAffinityEffectBadgeProps) {
const isPositive = effect.delta > 0;
const deltaText = `${effect.delta > 0 ? '+' : ''}${effect.delta}`;
return (
<motion.div
key={effect.eventId}
initial={{ opacity: 0, y: 24, scale: 0.8 }}
animate={{ opacity: [0, 1, 1, 0], y: [24, -8, -26, -44], scale: [0.8, 1.08, 1, 0.92] }}
transition={{ duration: 1.45, ease: 'easeOut' }}
className="pointer-events-none absolute -top-14 left-1/2 z-[12] flex -translate-x-1/2 items-center gap-1 rounded-full border px-2.5 py-1 shadow-[0_10px_24px_rgba(0,0,0,0.35)] backdrop-blur-[2px]"
data-testid={`npc-affinity-effect-${effect.npcId}`}
aria-label={`好感度变化 ${deltaText}`}
>
{isPositive ? (
<>
<div className="absolute inset-0 rounded-full bg-[radial-gradient(circle_at_top,rgba(255,255,255,0.3),transparent_60%)]" />
<div className="absolute -inset-1 rounded-full bg-rose-400/18 blur-md" />
<div className="relative flex items-center gap-1 text-rose-50">
<Heart className="h-3.5 w-3.5 fill-current" />
<span className="text-xs font-semibold tracking-[0.08em]">
{deltaText}
</span>
</div>
</>
) : (
<>
<div className="absolute inset-0 rounded-full bg-[radial-gradient(circle_at_top,rgba(255,255,255,0.18),transparent_60%)]" />
<div className="absolute -inset-1 rounded-full bg-slate-400/15 blur-md" />
<div className="relative text-xs font-semibold tracking-[0.08em] text-slate-100">
{deltaText}
</div>
</>
)}
<div
className={`absolute inset-0 rounded-full border ${
isPositive
? 'border-rose-200/45 bg-rose-500/18'
: 'border-slate-200/35 bg-slate-700/30'
}`}
/>
</motion.div>
);
}

View File

@@ -56,7 +56,7 @@ const DESKTOP_PAGE_STAGE_CLASS =
function SectionHeader({ title, detail }: { title: string; detail: string }) { function SectionHeader({ title, detail }: { title: string; detail: string }) {
return ( return (
<div className="mb-3"> <div className="mb-3">
<div className="text-[10px] font-semibold tracking-[0.26em] text-zinc-500"> <div className="text-[10px] font-semibold tracking-[0.26em] text-[var(--platform-text-soft)]">
{detail} {detail}
</div> </div>
<div className="mt-1 text-base font-semibold text-[var(--platform-text-strong)]"> <div className="mt-1 text-base font-semibold text-[var(--platform-text-strong)]">
@@ -69,7 +69,7 @@ function SectionHeader({ title, detail }: { title: string; detail: string }) {
function EmptyShelf({ text }: { text: string }) { function EmptyShelf({ text }: { text: string }) {
return ( return (
<div <div
className={`${PANEL_SURFACE_CLASS} rounded-[1.35rem] px-4 py-3 text-sm leading-6 text-zinc-300`} className={`${PANEL_SURFACE_CLASS} rounded-[1.35rem] px-4 py-3 text-sm leading-6 text-[var(--platform-text-base)]`}
> >
{text} {text}
</div> </div>
@@ -101,7 +101,7 @@ function SaveArchivePreview({
)} )}
<div className="absolute inset-0 bg-[var(--platform-card-overlay-soft)]" /> <div className="absolute inset-0 bg-[var(--platform-card-overlay-soft)]" />
<div className="absolute inset-x-0 bottom-0 px-2.5 py-2"> <div className="absolute inset-x-0 bottom-0 px-2.5 py-2">
<span className="inline-flex max-w-full items-center rounded-full border border-white/15 bg-black/24 px-2.5 py-1 text-[9px] font-semibold tracking-[0.14em] text-white/88"> <span className="inline-flex max-w-full items-center rounded-full border border-white/15 bg-black/24 px-2.5 py-1 text-[9px] font-semibold tracking-[0.14em] text-[var(--platform-text-strong)]">
{label} {label}
</span> </span>
</div> </div>
@@ -162,15 +162,15 @@ function WorldCard({
</span> </span>
</div> </div>
<div className="mt-auto"> <div className="mt-auto">
<div className="line-clamp-1 text-xl font-black text-white"> <div className="line-clamp-1 text-xl font-black text-[var(--platform-text-strong)]">
{entry.worldName} {entry.worldName}
</div> </div>
{entry.subtitle ? ( {entry.subtitle ? (
<div className="mt-1 line-clamp-1 text-[11px] tracking-[0.16em] text-zinc-300/85"> <div className="mt-1 line-clamp-1 text-[11px] tracking-[0.16em] text-[color:color-mix(in_srgb,var(--platform-text-base)_85%,transparent)]">
{entry.subtitle} {entry.subtitle}
</div> </div>
) : null} ) : null}
<div className="mt-2 line-clamp-2 text-xs leading-5 text-zinc-200/90"> <div className="mt-2 line-clamp-2 text-xs leading-5 text-[color:color-mix(in_srgb,var(--platform-text-base)_90%,transparent)]">
{entry.summaryText || '等待补充世界摘要。'} {entry.summaryText || '等待补充世界摘要。'}
</div> </div>
<div className="mt-3 flex flex-wrap gap-2"> <div className="mt-3 flex flex-wrap gap-2">
@@ -249,28 +249,28 @@ function CreationLibraryCard({
> >
{statusLabel} {statusLabel}
</span> </span>
<span className="inline-flex min-w-0 max-w-full items-center rounded-full border border-white/12 bg-black/18 px-2 py-1 text-[10px] font-medium text-zinc-300"> <span className="inline-flex min-w-0 max-w-full items-center rounded-full border border-white/12 bg-black/18 px-2 py-1 text-[10px] font-medium text-[var(--platform-text-base)]">
<span className="truncate">{metaLabel}</span> <span className="truncate">{metaLabel}</span>
</span> </span>
</div> </div>
<div className="mt-auto min-w-0"> <div className="mt-auto min-w-0">
<div className="line-clamp-2 break-words text-base font-black leading-[1.15] text-white sm:text-[1.12rem]"> <div className="line-clamp-2 break-words text-base font-black leading-[1.15] text-[var(--platform-text-strong)] sm:text-[1.12rem]">
{entry.worldName} {entry.worldName}
</div> </div>
{entry.subtitle ? ( {entry.subtitle ? (
<div className="mt-1 line-clamp-1 break-words text-[11px] tracking-[0.08em] text-zinc-300/84"> <div className="mt-1 line-clamp-1 break-words text-[11px] tracking-[0.08em] text-[color:color-mix(in_srgb,var(--platform-text-base)_84%,transparent)]">
{entry.subtitle} {entry.subtitle}
</div> </div>
) : null} ) : null}
<div className="mt-2 line-clamp-3 break-words text-[11px] leading-5 text-zinc-200/88 sm:text-xs"> <div className="mt-2 line-clamp-3 break-words text-[11px] leading-5 text-[color:color-mix(in_srgb,var(--platform-text-base)_88%,transparent)] sm:text-xs">
{summaryText} {summaryText}
</div> </div>
<div className="mt-3 flex flex-wrap items-center gap-1.5"> <div className="mt-3 flex flex-wrap items-center gap-1.5">
<span className="inline-flex min-w-0 max-w-full items-center rounded-full border border-white/12 bg-black/18 px-2 py-1 text-[10px] font-semibold tracking-[0.1em] text-zinc-100/90"> <span className="inline-flex min-w-0 max-w-full items-center rounded-full border border-white/12 bg-black/18 px-2 py-1 text-[10px] font-semibold tracking-[0.1em] text-[color:color-mix(in_srgb,var(--platform-text-strong)_90%,transparent)]">
<span className="truncate">{primaryTag}</span> <span className="truncate">{primaryTag}</span>
</span> </span>
<span className="inline-flex items-center gap-1 text-[11px] font-semibold text-zinc-200"> <span className="inline-flex items-center gap-1 text-[11px] font-semibold text-[var(--platform-text-base)]">
<span> <span>
{entry.visibility === 'published' ? '进入世界' : '继续创作'} {entry.visibility === 'published' ? '进入世界' : '继续创作'}
</span> </span>
@@ -306,21 +306,21 @@ function SaveArchiveCard({
<div className="relative z-10 flex h-full w-full flex-col gap-3"> <div className="relative z-10 flex h-full w-full flex-col gap-3">
<div className="flex flex-wrap items-center justify-between gap-2"> <div className="flex flex-wrap items-center justify-between gap-2">
<span className="platform-pill platform-pill--cool">ARCHIVE</span> <span className="platform-pill platform-pill--cool">ARCHIVE</span>
<span className="rounded-full border border-white/10 bg-black/18 px-2.5 py-1 text-[11px] font-medium text-zinc-300"> <span className="rounded-full border border-white/10 bg-black/18 px-2.5 py-1 text-[11px] font-medium text-[var(--platform-text-base)]">
{loading ? '恢复中' : formatSnapshotTime(entry.lastPlayedAt)} {loading ? '恢复中' : formatSnapshotTime(entry.lastPlayedAt)}
</span> </span>
</div> </div>
<div className="flex min-w-0 flex-1 items-stretch gap-3 sm:gap-4"> <div className="flex min-w-0 flex-1 items-stretch gap-3 sm:gap-4">
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<div className="line-clamp-2 break-words text-[1.15rem] font-black leading-tight text-white sm:text-xl"> <div className="line-clamp-2 break-words text-[1.15rem] font-black leading-tight text-[var(--platform-text-strong)] sm:text-xl">
{entry.worldName} {entry.worldName}
</div> </div>
{entry.subtitle ? ( {entry.subtitle ? (
<div className="mt-1 line-clamp-1 break-words text-sm text-zinc-300"> <div className="mt-1 line-clamp-1 break-words text-sm text-[var(--platform-text-base)]">
{entry.subtitle} {entry.subtitle}
</div> </div>
) : null} ) : null}
<div className="mt-2 line-clamp-3 break-words text-xs leading-5 text-zinc-400 sm:text-sm"> <div className="mt-2 line-clamp-3 break-words text-xs leading-5 text-[var(--platform-text-soft)] sm:text-sm">
{summaryText} {summaryText}
</div> </div>
<div className="mt-4 inline-flex items-center gap-1.5 text-xs font-semibold text-zinc-200"> <div className="mt-4 inline-flex items-center gap-1.5 text-xs font-semibold text-zinc-200">
@@ -356,13 +356,11 @@ function PlatformTabButton({
onClick={onClick} onClick={onClick}
className={`platform-bottom-nav__button ${active ? 'platform-bottom-nav__button--active' : ''}`} className={`platform-bottom-nav__button ${active ? 'platform-bottom-nav__button--active' : ''}`}
> >
<span className="flex flex-col items-center justify-center gap-1"> <span className="platform-bottom-nav__button-content">
<span className="platform-bottom-nav__icon-shell"> <span className="platform-bottom-nav__icon-shell">
<Icon className="platform-bottom-nav__icon h-[1.05rem] w-[1.05rem]" /> <Icon className="platform-bottom-nav__icon" />
</span>
<span className="platform-bottom-nav__label text-[11px] font-semibold tracking-[0.18em]">
{label}
</span> </span>
<span className="platform-bottom-nav__label">{label}</span>
</span> </span>
</button> </button>
); );
@@ -425,14 +423,14 @@ function DesktopTrendingItem({
</div> </div>
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<div className="flex items-center gap-2 text-[10px] tracking-[0.18em] text-zinc-500"> <div className="flex items-center gap-2 text-[10px] tracking-[0.18em] text-[var(--platform-text-soft)]">
<span>{`${rank}`.padStart(2, '0')}</span> <span>{`${rank}`.padStart(2, '0')}</span>
<span>{formatPlatformWorldTime(entry.publishedAt)}</span> <span>{formatPlatformWorldTime(entry.publishedAt)}</span>
</div> </div>
<div className="mt-2 line-clamp-1 text-lg font-semibold text-[var(--platform-text-strong)]"> <div className="mt-2 line-clamp-1 text-lg font-semibold text-[var(--platform-text-strong)]">
{entry.worldName} {entry.worldName}
</div> </div>
<div className="mt-1 line-clamp-2 text-sm leading-6 text-zinc-300/86"> <div className="mt-1 line-clamp-2 text-sm leading-6 text-[color:color-mix(in_srgb,var(--platform-text-base)_86%,transparent)]">
{entry.summaryText || entry.subtitle || '等待补充世界摘要。'} {entry.summaryText || entry.subtitle || '等待补充世界摘要。'}
</div> </div>
<div className="mt-3 flex flex-wrap gap-2"> <div className="mt-3 flex flex-wrap gap-2">
@@ -453,7 +451,7 @@ function DesktopTrendingItem({
</div> </div>
</div> </div>
<ChevronRight className="h-4 w-4 shrink-0 text-zinc-500" /> <ChevronRight className="h-4 w-4 shrink-0 text-[var(--platform-text-soft)]" />
</button> </button>
); );
} }
@@ -584,7 +582,7 @@ function ProfileStatCard({
onClick={onClick ? () => onClick(cardKey) : undefined} onClick={onClick ? () => onClick(cardKey) : undefined}
className="platform-subpanel rounded-[1.35rem] px-4 py-3 text-left transition hover:border-[var(--platform-surface-hover-border)] hover:bg-[var(--platform-button-secondary-fill)]" className="platform-subpanel rounded-[1.35rem] px-4 py-3 text-left transition hover:border-[var(--platform-surface-hover-border)] hover:bg-[var(--platform-button-secondary-fill)]"
> >
<div className="flex items-center gap-2 text-zinc-400"> <div className="flex items-center gap-2 text-[var(--platform-text-soft)]">
<Icon className="h-4 w-4" /> <Icon className="h-4 w-4" />
<span className="text-[11px] tracking-[0.16em]">{label}</span> <span className="text-[11px] tracking-[0.16em]">{label}</span>
</div> </div>
@@ -598,8 +596,8 @@ function ProfileStatCard({
function ProfileStatCardSkeleton() { function ProfileStatCardSkeleton() {
return ( return (
<div className="platform-subpanel rounded-[1.35rem] px-4 py-3"> <div className="platform-subpanel rounded-[1.35rem] px-4 py-3">
<div className="h-4 w-20 animate-pulse rounded-full bg-white/10" /> <div className="h-4 w-20 animate-pulse rounded-full bg-[var(--platform-subpanel-border)]" />
<div className="mt-3 h-7 w-16 animate-pulse rounded-full bg-white/12" /> <div className="mt-3 h-7 w-16 animate-pulse rounded-full bg-[var(--platform-line-soft)]" />
</div> </div>
); );
} }
@@ -748,7 +746,7 @@ export function PlatformHomeView({
}} }}
className={`${HERO_SURFACE_CLASS} relative block w-full overflow-hidden px-[18px] py-4 text-left`} className={`${HERO_SURFACE_CLASS} relative block w-full overflow-hidden px-[18px] py-4 text-left`}
> >
<div className="absolute inset-0 bg-[radial-gradient(circle_at_top_left,rgba(255,255,255,0.18),transparent_30%),radial-gradient(circle_at_right,rgba(255,205,178,0.18),transparent_28%),linear-gradient(135deg,rgba(255,47,112,0.92),rgba(255,136,104,0.9))]" /> <div className="absolute inset-0 bg-[var(--platform-hero-overlay-strong)]" />
<div className="relative z-10 flex min-h-[10rem] flex-col justify-between"> <div className="relative z-10 flex min-h-[10rem] flex-col justify-between">
<div className="flex items-start justify-between gap-4"> <div className="flex items-start justify-between gap-4">
<span className="platform-pill platform-pill--warm"> <span className="platform-pill platform-pill--warm">
@@ -776,7 +774,7 @@ export function PlatformHomeView({
</button> </button>
{platformError ? ( {platformError ? (
<div className="rounded-2xl border border-rose-400/20 bg-rose-500/10 px-4 py-3 text-sm leading-6 text-rose-100"> <div className="rounded-2xl border border-rose-400/20 bg-rose-500/10 px-4 py-3 text-sm leading-6 text-rose-700">
{platformError} {platformError}
</div> </div>
) : null} ) : null}
@@ -833,7 +831,7 @@ export function PlatformHomeView({
onClick={onOpenCreateTypePicker} onClick={onOpenCreateTypePicker}
className={`${HERO_SURFACE_CLASS} relative block w-full overflow-hidden px-[18px] py-4 text-left`} className={`${HERO_SURFACE_CLASS} relative block w-full overflow-hidden px-[18px] py-4 text-left`}
> >
<div className="absolute inset-0 bg-[radial-gradient(circle_at_top_left,rgba(255,255,255,0.16),transparent_38%),radial-gradient(circle_at_right,rgba(255,201,172,0.18),transparent_30%),linear-gradient(180deg,rgba(255,90,141,0.88),rgba(255,144,105,0.88))]" /> <div className="absolute inset-0 bg-[var(--platform-hero-overlay-strong)]" />
<div className="relative z-10 flex min-h-[10rem] flex-col justify-between"> <div className="relative z-10 flex min-h-[10rem] flex-col justify-between">
<span className="platform-pill platform-pill--cool w-fit"> <span className="platform-pill platform-pill--cool w-fit">
CREATE CREATE
@@ -887,7 +885,7 @@ export function PlatformHomeView({
{authUi?.user ? ( {authUi?.user ? (
<> <>
{saveError ? ( {saveError ? (
<div className="rounded-2xl border border-rose-400/20 bg-rose-500/10 px-4 py-3 text-sm leading-6 text-rose-100"> <div className="rounded-2xl border border-rose-400/20 bg-rose-500/10 px-4 py-3 text-sm leading-6 text-rose-700">
{saveError} {saveError}
</div> </div>
) : null} ) : null}
@@ -1060,7 +1058,7 @@ export function PlatformHomeView({
</> </>
)} )}
</div> </div>
<div className="mt-3 text-[11px] text-zinc-500"> <div className="mt-3 text-[11px] text-[var(--platform-text-soft)]">
{dashboardError {dashboardError
? dashboardError ? dashboardError
: `更新于 ${formatDashboardUpdatedAt(profileDashboard?.updatedAt)}`} : `更新于 ${formatDashboardUpdatedAt(profileDashboard?.updatedAt)}`}
@@ -1090,10 +1088,12 @@ export function PlatformHomeView({
<div className="text-base font-semibold text-[var(--platform-text-strong)]"> <div className="text-base font-semibold text-[var(--platform-text-strong)]">
</div> </div>
<div className="text-xs text-zinc-400"></div> <div className="text-xs text-[var(--platform-text-soft)]">
</div>
</div> </div>
</div> </div>
<ChevronRight className="h-4 w-4 text-zinc-500" /> <ChevronRight className="h-4 w-4 text-[var(--platform-text-soft)]" />
</button> </button>
</section> </section>
</> </>
@@ -1121,7 +1121,7 @@ export function PlatformHomeView({
activeTab === 'home' ? ( activeTab === 'home' ? (
<div className={DESKTOP_PAGE_STAGE_CLASS}> <div className={DESKTOP_PAGE_STAGE_CLASS}>
{platformError ? ( {platformError ? (
<div className="rounded-[1.5rem] border border-rose-400/20 bg-rose-500/10 px-4 py-3 text-sm leading-6 text-rose-100"> <div className="rounded-[1.5rem] border border-rose-400/20 bg-rose-500/10 px-4 py-3 text-sm leading-6 text-rose-700">
{platformError} {platformError}
</div> </div>
) : null} ) : null}
@@ -1147,8 +1147,7 @@ export function PlatformHomeView({
className="absolute inset-0 h-full w-full object-cover opacity-34" className="absolute inset-0 h-full w-full object-cover opacity-34"
/> />
) : null} ) : null}
<div className="absolute inset-0 bg-[linear-gradient(115deg,rgba(255,31,111,0.94),rgba(255,109,104,0.8)_52%,rgba(255,164,124,0.9))]" /> <div className="absolute inset-0 bg-[var(--platform-hero-overlay-strong)]" />
<div className="absolute inset-0 bg-[radial-gradient(circle_at_top_left,rgba(255,255,255,0.18),transparent_28%),radial-gradient(circle_at_78%_24%,rgba(255,208,178,0.18),transparent_20%)]" />
<div className="relative z-10 flex min-h-[24rem] flex-col justify-between"> <div className="relative z-10 flex min-h-[24rem] flex-col justify-between">
<div className="flex items-start justify-between gap-4"> <div className="flex items-start justify-between gap-4">
<span className="platform-pill platform-pill--warm"> <span className="platform-pill platform-pill--warm">
@@ -1198,8 +1197,8 @@ export function PlatformHomeView({
) : null} ) : null}
<div className="absolute inset-0 bg-[linear-gradient(180deg,rgba(255,255,255,0.04),rgba(91,24,46,0.34))]" /> <div className="absolute inset-0 bg-[linear-gradient(180deg,rgba(255,255,255,0.04),rgba(91,24,46,0.34))]" />
</div> </div>
<div className="flex items-center gap-2 px-3 py-2 text-[11px] text-zinc-300/82"> <div className="flex items-center gap-2 px-3 py-2 text-[11px] text-[color:color-mix(in_srgb,var(--platform-text-base)_82%,transparent)]">
<span className="text-zinc-500"> <span className="text-[var(--platform-text-soft)]">
{`${index + 1}`.padStart(2, '0')} {`${index + 1}`.padStart(2, '0')}
</span> </span>
<span className="line-clamp-1"> <span className="line-clamp-1">
@@ -1286,17 +1285,17 @@ export function PlatformHomeView({
<span className="platform-pill platform-pill--cool"> <span className="platform-pill platform-pill--cool">
{hasSavedGame ? 'SAVE POINT' : 'START HERE'} {hasSavedGame ? 'SAVE POINT' : 'START HERE'}
</span> </span>
<ArrowRight className="h-4 w-4 text-zinc-400" /> <ArrowRight className="h-4 w-4 text-[var(--platform-text-soft)]" />
</div> </div>
<div className="mt-4 text-2xl font-semibold text-white"> <div className="mt-4 text-2xl font-semibold text-[var(--platform-text-strong)]">
{hasSavedGame ? snapshotWorldName : '从这里开启新的创作'} {hasSavedGame ? snapshotWorldName : '从这里开启新的创作'}
</div> </div>
<div className="mt-2 text-sm leading-7 text-zinc-300/84"> <div className="mt-2 text-sm leading-7 text-[color:color-mix(in_srgb,var(--platform-text-base)_84%,transparent)]">
{hasSavedGame {hasSavedGame
? `当前角色:${snapshotCharacterName}` ? `当前角色:${snapshotCharacterName}`
: '快速进入自定义世界创作,继续补齐设定、角色与核心冲突。'} : '快速进入自定义世界创作,继续补齐设定、角色与核心冲突。'}
</div> </div>
<div className="mt-3 line-clamp-3 text-sm leading-6 text-zinc-400"> <div className="mt-3 line-clamp-3 text-sm leading-6 text-[var(--platform-text-soft)]">
{hasSavedGame {hasSavedGame
? snapshotDigest ? snapshotDigest
: '先生成一版可玩的世界底稿,再继续编辑并发布。'} : '先生成一版可玩的世界底稿,再继续编辑并发布。'}
@@ -1305,7 +1304,7 @@ export function PlatformHomeView({
</button> </button>
<div className="mt-5"> <div className="mt-5">
<div className="text-[10px] font-semibold tracking-[0.24em] text-zinc-500"> <div className="text-[10px] font-semibold tracking-[0.24em] text-[var(--platform-text-soft)]">
{desktopLibraryPreview.length > 0 {desktopLibraryPreview.length > 0
? '最近作品' ? '最近作品'
: historyEntries.length > 0 : historyEntries.length > 0
@@ -1325,10 +1324,10 @@ export function PlatformHomeView({
className="platform-desktop-trending-item flex w-full items-center justify-between gap-3 px-4 py-4 text-left" className="platform-desktop-trending-item flex w-full items-center justify-between gap-3 px-4 py-4 text-left"
> >
<div className="min-w-0"> <div className="min-w-0">
<div className="line-clamp-1 text-base font-semibold text-white"> <div className="line-clamp-1 text-base font-semibold text-[var(--platform-text-strong)]">
{entry.worldName} {entry.worldName}
</div> </div>
<div className="mt-1 text-sm text-zinc-400"> <div className="mt-1 text-sm text-[var(--platform-text-soft)]">
{entry.visibility === 'published' {entry.visibility === 'published'
? `发布于 ${formatPlatformWorldTime(entry.publishedAt)}` ? `发布于 ${formatPlatformWorldTime(entry.publishedAt)}`
: '草稿待完善'} : '草稿待完善'}
@@ -1366,10 +1365,10 @@ export function PlatformHomeView({
className="platform-desktop-trending-item flex w-full items-center justify-between gap-3 px-4 py-4 text-left" className="platform-desktop-trending-item flex w-full items-center justify-between gap-3 px-4 py-4 text-left"
> >
<div className="min-w-0"> <div className="min-w-0">
<div className="line-clamp-1 text-base font-semibold text-white"> <div className="line-clamp-1 text-base font-semibold text-[var(--platform-text-strong)]">
{entry.worldName} {entry.worldName}
</div> </div>
<div className="mt-1 text-sm text-zinc-400"> <div className="mt-1 text-sm text-[var(--platform-text-soft)]">
{entry.authorDisplayName} {entry.authorDisplayName}
</div> </div>
</div> </div>
@@ -1434,7 +1433,7 @@ export function PlatformHomeView({
paddingBottom: 'calc(env(safe-area-inset-bottom) + 0.2rem)', paddingBottom: 'calc(env(safe-area-inset-bottom) + 0.2rem)',
}} }}
> >
<div className="platform-bottom-nav grid h-14 grid-cols-4 gap-1 rounded-[1.2rem] px-1 py-1"> <div className="platform-bottom-nav grid grid-cols-4">
<PlatformTabButton <PlatformTabButton
active={activeTab === 'home'} active={activeTab === 'home'}
label="首页" label="首页"
@@ -1468,7 +1467,7 @@ export function PlatformHomeView({
<div className="platform-desktop-topbar flex items-center gap-4 px-5 py-4"> <div className="platform-desktop-topbar flex items-center gap-4 px-5 py-4">
<div className="flex min-w-0 flex-1 items-center gap-5"> <div className="flex min-w-0 flex-1 items-center gap-5">
<PlatformBrandLogo className="shrink-0" decorative /> <PlatformBrandLogo className="shrink-0" decorative />
<div className="platform-desktop-search flex min-w-0 max-w-[34rem] flex-1 items-center gap-3 px-4 py-3 text-zinc-400"> <div className="platform-desktop-search flex min-w-0 max-w-[34rem] flex-1 items-center gap-3 px-4 py-3 text-[var(--platform-text-soft)]">
<Search className="h-4 w-4 shrink-0" /> <Search className="h-4 w-4 shrink-0" />
<span className="truncate text-sm"> <span className="truncate text-sm">
@@ -1503,7 +1502,7 @@ export function PlatformHomeView({
<span className="block truncate text-sm font-semibold text-[var(--platform-text-strong)]"> <span className="block truncate text-sm font-semibold text-[var(--platform-text-strong)]">
{authUi?.user?.displayName || '进入账户'} {authUi?.user?.displayName || '进入账户'}
</span> </span>
<span className="block truncate text-xs text-zinc-400"> <span className="block truncate text-xs text-[var(--platform-text-soft)]">
{authUi?.user ? publicUserCode : '登录后同步创作与进度'} {authUi?.user ? publicUserCode : '登录后同步创作与进度'}
</span> </span>
</span> </span>

View File

@@ -110,7 +110,7 @@ export function PlatformWorldDetailView({
className="absolute bottom-0 right-2 h-32 w-32 object-contain opacity-25" className="absolute bottom-0 right-2 h-32 w-32 object-contain opacity-25"
/> />
) : null} ) : null}
<div className="absolute inset-0 bg-[linear-gradient(125deg,rgba(255,31,111,0.78),rgba(255,138,115,0.52)_48%,rgba(255,255,255,0.08)_100%)]" /> <div className="absolute inset-0 bg-[var(--platform-hero-overlay-strong)]" />
<div className="relative z-10"> <div className="relative z-10">
<div className="flex flex-wrap items-center gap-2"> <div className="flex flex-wrap items-center gap-2">
<span className="platform-pill platform-pill--warm"> <span className="platform-pill platform-pill--warm">
@@ -151,12 +151,12 @@ export function PlatformWorldDetailView({
<div className="grid gap-4 lg:grid-cols-[1.2fr_0.8fr]"> <div className="grid gap-4 lg:grid-cols-[1.2fr_0.8fr]">
<div className="platform-surface platform-surface--soft px-4 py-3.5"> <div className="platform-surface platform-surface--soft px-4 py-3.5">
<div className="text-[10px] tracking-[0.22em] text-zinc-500"> <div className="text-[10px] tracking-[0.22em] text-[var(--platform-text-soft)]">
</div> </div>
<div className="mt-3 grid grid-cols-2 gap-3 text-sm text-zinc-100 sm:grid-cols-4"> <div className="mt-3 grid grid-cols-2 gap-3 text-sm text-[var(--platform-text-strong)] sm:grid-cols-4">
<div className="platform-subpanel rounded-xl px-3 py-3"> <div className="platform-subpanel rounded-xl px-3 py-3">
<div className="text-[10px] tracking-[0.18em] text-zinc-500"> <div className="text-[10px] tracking-[0.18em] text-[var(--platform-text-soft)]">
</div> </div>
<div className="mt-2 text-lg font-bold"> <div className="mt-2 text-lg font-bold">
@@ -164,7 +164,7 @@ export function PlatformWorldDetailView({
</div> </div>
</div> </div>
<div className="platform-subpanel rounded-xl px-3 py-3"> <div className="platform-subpanel rounded-xl px-3 py-3">
<div className="text-[10px] tracking-[0.18em] text-zinc-500"> <div className="text-[10px] tracking-[0.18em] text-[var(--platform-text-soft)]">
</div> </div>
<div className="mt-2 text-lg font-bold"> <div className="mt-2 text-lg font-bold">
@@ -172,7 +172,7 @@ export function PlatformWorldDetailView({
</div> </div>
</div> </div>
<div className="platform-subpanel rounded-xl px-3 py-3"> <div className="platform-subpanel rounded-xl px-3 py-3">
<div className="text-[10px] tracking-[0.18em] text-zinc-500"> <div className="text-[10px] tracking-[0.18em] text-[var(--platform-text-soft)]">
</div> </div>
<div className="mt-2 text-lg font-bold"> <div className="mt-2 text-lg font-bold">
@@ -180,7 +180,7 @@ export function PlatformWorldDetailView({
</div> </div>
</div> </div>
<div className="platform-subpanel rounded-xl px-3 py-3"> <div className="platform-subpanel rounded-xl px-3 py-3">
<div className="text-[10px] tracking-[0.18em] text-zinc-500"> <div className="text-[10px] tracking-[0.18em] text-[var(--platform-text-soft)]">
</div> </div>
<div className="mt-2 text-lg font-bold"> <div className="mt-2 text-lg font-bold">
@@ -190,19 +190,19 @@ export function PlatformWorldDetailView({
</div> </div>
<div className="mt-5"> <div className="mt-5">
<div className="text-[10px] tracking-[0.22em] text-zinc-500"> <div className="text-[10px] tracking-[0.22em] text-[var(--platform-text-soft)]">
</div> </div>
<div className="mt-3 grid gap-3 sm:grid-cols-3"> <div className="mt-3 grid gap-3 sm:grid-cols-3">
{previewCharacters.map((character, index) => ( {previewCharacters.map((character, index) => (
<div <div
key={character.id || `preview-character-${index}`} key={character.id || `preview-character-${index}`}
className="platform-subpanel rounded-2xl px-3 py-3" className="platform-subpanel rounded-2xl px-3 py-3"
> >
<div className="line-clamp-1 text-sm font-bold text-white"> <div className="line-clamp-1 text-sm font-bold text-[var(--platform-text-strong)]">
{character.title} {character.title}
</div> </div>
<div className="mt-1 line-clamp-2 text-xs leading-5 text-zinc-300"> <div className="mt-1 line-clamp-2 text-xs leading-5 text-[var(--platform-text-base)]">
{character.description} {character.description}
</div> </div>
</div> </div>
@@ -211,19 +211,19 @@ export function PlatformWorldDetailView({
</div> </div>
<div className="mt-5"> <div className="mt-5">
<div className="text-[10px] tracking-[0.22em] text-zinc-500"> <div className="text-[10px] tracking-[0.22em] text-[var(--platform-text-soft)]">
</div> </div>
<div className="mt-3 grid gap-3 sm:grid-cols-3"> <div className="mt-3 grid gap-3 sm:grid-cols-3">
{previewLandmarks.map((landmark, index) => ( {previewLandmarks.map((landmark, index) => (
<div <div
key={landmark.id || `preview-landmark-${index}`} key={landmark.id || `preview-landmark-${index}`}
className="platform-subpanel rounded-2xl px-3 py-3" className="platform-subpanel rounded-2xl px-3 py-3"
> >
<div className="line-clamp-1 text-sm font-bold text-white"> <div className="line-clamp-1 text-sm font-bold text-[var(--platform-text-strong)]">
{landmark.name} {landmark.name}
</div> </div>
<div className="mt-1 line-clamp-2 text-xs leading-5 text-zinc-300"> <div className="mt-1 line-clamp-2 text-xs leading-5 text-[var(--platform-text-base)]">
{landmark.description} {landmark.description}
</div> </div>
</div> </div>
@@ -233,7 +233,7 @@ export function PlatformWorldDetailView({
</div> </div>
<div className="platform-surface platform-surface--soft px-4 py-3.5"> <div className="platform-surface platform-surface--soft px-4 py-3.5">
<div className="text-[10px] tracking-[0.22em] text-zinc-500"> <div className="text-[10px] tracking-[0.22em] text-[var(--platform-text-soft)]">
</div> </div>
<div className="mt-4 flex flex-col gap-3"> <div className="mt-4 flex flex-col gap-3">
@@ -275,7 +275,7 @@ export function PlatformWorldDetailView({
) : null} ) : null}
</div> </div>
{error ? ( {error ? (
<div className="mt-4 rounded-2xl border border-rose-400/20 bg-rose-500/10 px-4 py-3 text-sm leading-6 text-rose-100"> <div className="mt-4 rounded-2xl border border-rose-400/20 bg-rose-500/10 px-4 py-3 text-sm leading-6 text-rose-700">
{error} {error}
</div> </div>
) : null} ) : null}

View File

@@ -483,7 +483,8 @@ test('starting draft generation leaves the agent workspace and shows the generat
expect(await screen.findByText('世界草稿生成进度')).toBeTruthy(); expect(await screen.findByText('世界草稿生成进度')).toBeTruthy();
expect(screen.queryByText(/Agent/u)).toBeNull(); expect(screen.queryByText(/Agent/u)).toBeNull();
expect(screen.getAllByText('生成世界底稿').length).toBeGreaterThan(0); expect(screen.getAllByText('生成世界底稿').length).toBeGreaterThan(0);
expect(screen.getByText('当前锚点信息')).toBeTruthy(); expect(screen.getByText('当前世界信息')).toBeTruthy();
expect(screen.queryByText('回到工作区')).toBeNull();
expect(screen.getByText('世界承诺')).toBeTruthy(); expect(screen.getByText('世界承诺')).toBeTruthy();
expect(screen.getByText(/穿/u)).toBeTruthy(); expect(screen.getByText(/穿/u)).toBeTruthy();
expect(screen.queryByText('先告诉我你想做一个怎样的 RPG 世界。')).toBeNull(); expect(screen.queryByText('先告诉我你想做一个怎样的 RPG 世界。')).toBeNull();

View File

@@ -43,9 +43,7 @@ import {
readCustomWorldAgentUiState, readCustomWorldAgentUiState,
writeCustomWorldAgentUiState, writeCustomWorldAgentUiState,
} from '../../services/customWorldAgentUiState'; } from '../../services/customWorldAgentUiState';
import { import { buildCustomWorldCreatorIntentFoundationText } from '../../services/customWorldCreatorIntent';
buildCustomWorldCreatorIntentFoundationText,
} from '../../services/customWorldCreatorIntent';
import { import {
hasPendingPlatformBrowseHistoryMigration, hasPendingPlatformBrowseHistoryMigration,
markPlatformBrowseHistoryMigrated, markPlatformBrowseHistoryMigrated,
@@ -169,7 +167,7 @@ function normalizeAgentBackedProfile(profile: CustomWorldProfile) {
function LazyPanelFallback({ label }: { label: string }) { function LazyPanelFallback({ label }: { label: string }) {
return ( return (
<div className="flex h-full min-h-0 items-center justify-center"> <div className="flex h-full min-h-0 items-center justify-center">
<div className="platform-subpanel rounded-2xl px-5 py-4 text-sm text-zinc-300"> <div className="platform-subpanel rounded-2xl px-5 py-4 text-sm text-[var(--platform-text-base)]">
{label} {label}
</div> </div>
</div> </div>
@@ -405,9 +403,8 @@ export function PreGameSelectionFlow({
hasPendingPlatformBrowseHistoryMigration(authUi?.user) && hasPendingPlatformBrowseHistoryMigration(authUi?.user) &&
localHistoryEntries.length > 0 localHistoryEntries.length > 0
) { ) {
nextEntries = await syncProfileBrowseHistory( nextEntries =
localHistoryEntries, await syncProfileBrowseHistory(localHistoryEntries);
);
markPlatformBrowseHistoryMigrated(authUi?.user); markPlatformBrowseHistoryMigrated(authUi?.user);
} }
@@ -472,12 +469,17 @@ export function PreGameSelectionFlow({
} else if (isAuthenticated) { } else if (isAuthenticated) {
setSaveEntries([]); setSaveEntries([]);
setSaveError( setSaveError(
resolveErrorMessage(saveArchivesResult.reason, '读取存档列表失败。'), resolveErrorMessage(
saveArchivesResult.reason,
'读取存档列表失败。',
),
); );
} }
const nextPlatformBootstrapUserId = authUi?.user?.id ?? null; const nextPlatformBootstrapUserId = authUi?.user?.id ?? null;
if (platformTabBootstrapUserIdRef.current !== nextPlatformBootstrapUserId) { if (
platformTabBootstrapUserIdRef.current !== nextPlatformBootstrapUserId
) {
platformTabBootstrapUserIdRef.current = nextPlatformBootstrapUserId; platformTabBootstrapUserIdRef.current = nextPlatformBootstrapUserId;
if (!initialAgentUiStateRef.current.activeSessionId) { if (!initialAgentUiStateRef.current.activeSessionId) {
setPlatformTab( setPlatformTab(
@@ -1333,7 +1335,7 @@ export function PreGameSelectionFlow({
> >
{isDetailLoading || !selectedDetailEntry ? ( {isDetailLoading || !selectedDetailEntry ? (
<div className="flex h-full items-center justify-center"> <div className="flex h-full items-center justify-center">
<div className="platform-subpanel rounded-2xl px-5 py-4 text-sm text-zinc-300"> <div className="platform-subpanel rounded-2xl px-5 py-4 text-sm text-[var(--platform-text-base)]">
{detailError || '正在读取作品详情...'} {detailError || '正在读取作品详情...'}
</div> </div>
</div> </div>
@@ -1419,7 +1421,7 @@ export function PreGameSelectionFlow({
/> />
) : ( ) : (
<div className="flex h-full items-center justify-center"> <div className="flex h-full items-center justify-center">
<div className="platform-subpanel rounded-2xl px-5 py-4 text-sm text-zinc-300"> <div className="platform-subpanel rounded-2xl px-5 py-4 text-sm text-[var(--platform-text-base)]">
{isLoadingAgentSession {isLoadingAgentSession
? '正在准备 Agent 共创工作区...' ? '正在准备 Agent 共创工作区...'
: creationTypeError || '正在恢复创作工作区...'} : creationTypeError || '正在恢复创作工作区...'}
@@ -1452,14 +1454,10 @@ export function PreGameSelectionFlow({
onRetry={retryAgentDraftGeneration} onRetry={retryAgentDraftGeneration}
onInterrupt={undefined} onInterrupt={undefined}
backLabel="返回工作区" backLabel="返回工作区"
settingActionLabel="回到工作区" settingActionLabel={null}
retryLabel="重新生成草稿" retryLabel="重新生成草稿"
settingTitle="当前锚点信息" settingTitle="当前世界信息"
settingDescription={ settingDescription={null}
isAgentDraftGenerationView
? '将按当前八锚点结构编译第一版世界底稿与草稿卡。'
: undefined
}
progressTitle={ progressTitle={
isAgentDraftGenerationView ? '世界草稿生成进度' : undefined isAgentDraftGenerationView ? '世界草稿生成进度' : undefined
} }

View File

@@ -140,6 +140,15 @@ function toText(value: unknown, fallback = '') {
return typeof value === 'string' ? value.trim() : fallback; return typeof value === 'string' ? value.trim() : fallback;
} }
function toRecordArray(value: unknown) {
return Array.isArray(value)
? value.filter(
(entry): entry is Record<string, unknown> =>
Boolean(entry) && typeof entry === 'object',
)
: [];
}
function toStringArray(value: unknown) { function toStringArray(value: unknown) {
return Array.isArray(value) return Array.isArray(value)
? value ? value
@@ -858,10 +867,22 @@ function normalizeCampScene(
} }
return { return {
id: toText(value.id, fallback.id),
name: toText(value.name, fallback.name), name: toText(value.name, fallback.name),
description: toText(value.description, fallback.description), description: toText(value.description, fallback.description),
visualDescription: toText(value.visualDescription) || undefined,
dangerLevel: toText(value.dangerLevel, fallback.dangerLevel), dangerLevel: toText(value.dangerLevel, fallback.dangerLevel),
imageSrc: toText(value.imageSrc) || undefined, imageSrc: toText(value.imageSrc) || undefined,
sceneNpcIds: toStringArray(value.sceneNpcIds),
connections: toRecordArray(value.connections)
.map((connection) => ({
targetLandmarkId: toText(connection.targetLandmarkId),
relativePosition:
toText(connection.relativePosition) || toText(connection.position) || 'forward',
summary: toText(connection.summary) || toText(connection.description),
}))
.filter((connection) => connection.targetLandmarkId),
narrativeResidues: null,
}; };
} }
@@ -950,6 +971,7 @@ function normalizeSceneActBlueprint(
? ['opening'] ? ['opening']
: ['climax', 'aftermath'], : ['climax', 'aftermath'],
backgroundImageSrc: toText(value.backgroundImageSrc) || undefined, backgroundImageSrc: toText(value.backgroundImageSrc) || undefined,
backgroundAssetId: toText(value.backgroundAssetId) || undefined,
encounterNpcIds, encounterNpcIds,
primaryNpcId: toText(value.primaryNpcId, encounterNpcIds[0] ?? ''), primaryNpcId: toText(value.primaryNpcId, encounterNpcIds[0] ?? ''),
linkedThreadIds: toStringArray(value.linkedThreadIds), linkedThreadIds: toStringArray(value.linkedThreadIds),

View File

@@ -18,7 +18,6 @@ export type EditorJsonResourceId =
(typeof EDITOR_JSON_RESOURCE_IDS)[keyof typeof EDITOR_JSON_RESOURCE_IDS]; (typeof EDITOR_JSON_RESOURCE_IDS)[keyof typeof EDITOR_JSON_RESOURCE_IDS];
export const ASSET_API_PATHS = { export const ASSET_API_PATHS = {
characterPromptBundleGenerate: `${ASSETS_API_BASE_PATH}/character-prompts/generate`,
characterWorkflowCache: `${ASSETS_API_BASE_PATH}/character-workflow-cache`, characterWorkflowCache: `${ASSETS_API_BASE_PATH}/character-workflow-cache`,
characterVisualGenerate: `${ASSETS_API_BASE_PATH}/character-visual/generate`, characterVisualGenerate: `${ASSETS_API_BASE_PATH}/character-visual/generate`,
characterVisualPublish: `${ASSETS_API_BASE_PATH}/character-visual/publish`, characterVisualPublish: `${ASSETS_API_BASE_PATH}/character-visual/publish`,

View File

@@ -221,6 +221,7 @@ describe('createStoryChoiceActions', () => {
(inputState: GameState) => inputState.sceneHostileNpcs, (inputState: GameState) => inputState.sceneHostileNpcs,
), ),
buildNpcStory: vi.fn(() => createFallbackStory()), buildNpcStory: vi.fn(() => createFallbackStory()),
handleNpcBattleConversationContinuation: vi.fn(() => false),
updateQuestLog: vi.fn((inputState: GameState) => inputState), updateQuestLog: vi.fn((inputState: GameState) => inputState),
incrementRuntimeStats: vi.fn((inputState: GameState) => inputState), incrementRuntimeStats: vi.fn((inputState: GameState) => inputState),
getCampCompanionTravelScene: vi.fn(() => null), getCampCompanionTravelScene: vi.fn(() => null),
@@ -296,6 +297,7 @@ describe('createStoryChoiceActions', () => {
getStoryGenerationHostileNpcs: vi.fn(() => []), getStoryGenerationHostileNpcs: vi.fn(() => []),
getResolvedSceneHostileNpcs: vi.fn((inputState: GameState) => inputState.sceneHostileNpcs), getResolvedSceneHostileNpcs: vi.fn((inputState: GameState) => inputState.sceneHostileNpcs),
buildNpcStory: vi.fn(() => createFallbackStory()), buildNpcStory: vi.fn(() => createFallbackStory()),
handleNpcBattleConversationContinuation: vi.fn(() => false),
updateQuestLog: vi.fn((inputState: GameState) => inputState), updateQuestLog: vi.fn((inputState: GameState) => inputState),
incrementRuntimeStats: vi.fn((inputState: GameState) => inputState), incrementRuntimeStats: vi.fn((inputState: GameState) => inputState),
getCampCompanionTravelScene: vi.fn(() => null), getCampCompanionTravelScene: vi.fn(() => null),
@@ -385,6 +387,7 @@ describe('createStoryChoiceActions', () => {
getStoryGenerationHostileNpcs: vi.fn(() => []), getStoryGenerationHostileNpcs: vi.fn(() => []),
getResolvedSceneHostileNpcs: vi.fn((inputState: GameState) => inputState.sceneHostileNpcs), getResolvedSceneHostileNpcs: vi.fn((inputState: GameState) => inputState.sceneHostileNpcs),
buildNpcStory: vi.fn(() => createFallbackStory()), buildNpcStory: vi.fn(() => createFallbackStory()),
handleNpcBattleConversationContinuation: vi.fn(() => false),
updateQuestLog: vi.fn((inputState: GameState) => inputState), updateQuestLog: vi.fn((inputState: GameState) => inputState),
incrementRuntimeStats: vi.fn((inputState: GameState) => inputState), incrementRuntimeStats: vi.fn((inputState: GameState) => inputState),
getCampCompanionTravelScene: vi.fn(() => null), getCampCompanionTravelScene: vi.fn(() => null),
@@ -410,8 +413,20 @@ describe('createStoryChoiceActions', () => {
expect(resolveServerRuntimeChoiceMock).not.toHaveBeenCalled(); expect(resolveServerRuntimeChoiceMock).not.toHaveBeenCalled();
}); });
it('keeps the finishing action in history before npc victory follow-up generation', async () => { it('reopens npc chat instead of running generic follow-up after local npc victory', async () => {
const state = createBaseState(); const encounter: Encounter = {
id: 'npc-opponent',
kind: 'npc',
npcName: '山道客',
npcDescription: '拦路旧敌',
npcAvatar: '/npc.png',
context: '山道旧案',
};
const state = {
...createBaseState(),
currentEncounter: encounter,
npcInteractionActive: true,
};
const option = createBattleOption(); const option = createBattleOption();
const afterSequence = { const afterSequence = {
...state, ...state,
@@ -422,6 +437,7 @@ describe('createStoryChoiceActions', () => {
const generateStoryForState = vi.fn().mockResolvedValue(createFallbackStory('战后续写')); const generateStoryForState = vi.fn().mockResolvedValue(createFallbackStory('战后续写'));
const setCurrentStory = vi.fn(); const setCurrentStory = vi.fn();
const setGameState = vi.fn(); const setGameState = vi.fn();
const handleNpcBattleConversationContinuation = vi.fn(() => true);
const { handleChoice } = createStoryChoiceActions({ const { handleChoice } = createStoryChoiceActions({
gameState: state, gameState: state,
@@ -456,6 +472,7 @@ describe('createStoryChoiceActions', () => {
getStoryGenerationHostileNpcs: vi.fn(() => []), getStoryGenerationHostileNpcs: vi.fn(() => []),
getResolvedSceneHostileNpcs: vi.fn((inputState: GameState) => inputState.sceneHostileNpcs), getResolvedSceneHostileNpcs: vi.fn((inputState: GameState) => inputState.sceneHostileNpcs),
buildNpcStory: vi.fn(() => createFallbackStory()), buildNpcStory: vi.fn(() => createFallbackStory()),
handleNpcBattleConversationContinuation,
updateQuestLog: vi.fn((inputState: GameState) => inputState), updateQuestLog: vi.fn((inputState: GameState) => inputState),
incrementRuntimeStats: vi.fn((inputState: GameState) => inputState), incrementRuntimeStats: vi.fn((inputState: GameState) => inputState),
getCampCompanionTravelScene: vi.fn(() => null), getCampCompanionTravelScene: vi.fn(() => null),
@@ -484,15 +501,23 @@ describe('createStoryChoiceActions', () => {
await handleChoice(option); await handleChoice(option);
expect(generateStoryForState).toHaveBeenCalledTimes(1); expect(handleNpcBattleConversationContinuation).toHaveBeenCalledWith(
const [{ history }] = generateStoryForState.mock.calls[0] as [ expect.objectContaining({
{ history: StoryMoment[] }, nextState: expect.objectContaining({
]; currentBattleNpcId: null,
expect(history.map((entry) => `${entry.historyRole}:${entry.text}`)).toEqual([ currentNpcBattleMode: null,
'action:挥刀抢攻', currentNpcBattleOutcome: null,
'result:山道客已经败下阵来。胜利奖励:无战利品。', }),
]); encounter,
expect(setCurrentStory).toHaveBeenCalledWith(createFallbackStory('战后续写')); actionText: '挥刀抢攻',
resultText: '山道客已经败下阵来。胜利奖励:无战利品。',
battleMode: 'fight',
}),
);
expect(generateStoryForState).not.toHaveBeenCalled();
expect(setCurrentStory).not.toHaveBeenCalledWith(
createFallbackStory('战后续写'),
);
}); });
it('injects an escape resolution into the immediate story context before ai continuation', async () => { it('injects an escape resolution into the immediate story context before ai continuation', async () => {
@@ -568,6 +593,7 @@ describe('createStoryChoiceActions', () => {
getStoryGenerationHostileNpcs: vi.fn(() => []), getStoryGenerationHostileNpcs: vi.fn(() => []),
getResolvedSceneHostileNpcs: vi.fn((inputState: GameState) => inputState.sceneHostileNpcs), getResolvedSceneHostileNpcs: vi.fn((inputState: GameState) => inputState.sceneHostileNpcs),
buildNpcStory: vi.fn(() => createFallbackStory()), buildNpcStory: vi.fn(() => createFallbackStory()),
handleNpcBattleConversationContinuation: vi.fn(() => false),
updateQuestLog: vi.fn((inputState: GameState) => inputState), updateQuestLog: vi.fn((inputState: GameState) => inputState),
incrementRuntimeStats, incrementRuntimeStats,
getCampCompanionTravelScene: vi.fn(() => null), getCampCompanionTravelScene: vi.fn(() => null),

View File

@@ -49,6 +49,15 @@ type BuildNpcStory = (
overrideText?: string, overrideText?: string,
) => StoryMoment; ) => StoryMoment;
type HandleNpcBattleConversationContinuation = (params: {
nextState: GameState;
encounter: Encounter;
character: Character;
actionText: string;
resultText: string;
battleMode: NonNullable<GameState['currentNpcBattleMode']>;
}) => boolean;
type BuildStoryContextFromState = ( type BuildStoryContextFromState = (
state: GameState, state: GameState,
extras?: { extras?: {
@@ -87,6 +96,7 @@ export function createStoryChoiceActions({
getStoryGenerationHostileNpcs, getStoryGenerationHostileNpcs,
getResolvedSceneHostileNpcs, getResolvedSceneHostileNpcs,
buildNpcStory, buildNpcStory,
handleNpcBattleConversationContinuation,
updateQuestLog, updateQuestLog,
incrementRuntimeStats, incrementRuntimeStats,
getCampCompanionTravelScene, getCampCompanionTravelScene,
@@ -127,6 +137,7 @@ export function createStoryChoiceActions({
getStoryGenerationHostileNpcs: (state: GameState) => GameState['sceneHostileNpcs']; getStoryGenerationHostileNpcs: (state: GameState) => GameState['sceneHostileNpcs'];
getResolvedSceneHostileNpcs: (state: GameState) => GameState['sceneHostileNpcs']; getResolvedSceneHostileNpcs: (state: GameState) => GameState['sceneHostileNpcs'];
buildNpcStory: BuildNpcStory; buildNpcStory: BuildNpcStory;
handleNpcBattleConversationContinuation: HandleNpcBattleConversationContinuation;
updateQuestLog: UpdateQuestLog; updateQuestLog: UpdateQuestLog;
incrementRuntimeStats: IncrementRuntimeStats; incrementRuntimeStats: IncrementRuntimeStats;
getCampCompanionTravelScene: (state: GameState, character: Character) => GameState['currentScenePreset'] | null; getCampCompanionTravelScene: (state: GameState, character: Character) => GameState['currentScenePreset'] | null;
@@ -247,6 +258,7 @@ export function createStoryChoiceActions({
getStoryGenerationHostileNpcs, getStoryGenerationHostileNpcs,
getResolvedSceneHostileNpcs, getResolvedSceneHostileNpcs,
buildNpcStory, buildNpcStory,
handleNpcBattleConversationContinuation,
updateQuestLog, updateQuestLog,
incrementRuntimeStats, incrementRuntimeStats,
finalizeNpcBattleResult, finalizeNpcBattleResult,

View File

@@ -390,6 +390,13 @@ function createAcceptedPendingQuestStory(
}; };
} }
function createFallbackStory(text = 'fallback'): StoryMoment {
return {
text,
options: [],
};
}
type GenerateStoryForStateTestDouble = (params: { type GenerateStoryForStateTestDouble = (params: {
state: GameState; state: GameState;
character: Character; character: Character;
@@ -407,6 +414,12 @@ function createNpcEncounterActions(overrides: {
state: GameState, state: GameState,
character: Character, character: Character,
) => StoryOption[] | null; ) => StoryOption[] | null;
buildNpcStory?: (
state: GameState,
character: Character,
encounter: Encounter,
overrideText?: string,
) => StoryMoment;
}) { }) {
const gameState = overrides.gameState ?? createState(); const gameState = overrides.gameState ?? createState();
const currentStory = overrides.currentStory ?? createCurrentChatStory(); const currentStory = overrides.currentStory ?? createCurrentChatStory();
@@ -437,6 +450,33 @@ function createNpcEncounterActions(overrides: {
historyRole: 'result' as const, historyRole: 'result' as const,
}, },
]), ]),
buildNpcStory:
overrides.buildNpcStory ??
vi.fn(
(
_state: GameState,
_character: Character,
encounter: Encounter,
overrideText?: string,
) => ({
text:
overrideText ??
`${encounter.npcName}还在盯着你,像是在等你继续把话说下去。`,
options: [
createOption('npc_chat', '先把刚才那一刀说清楚', {
kind: 'npc',
npcId: encounter.id ?? encounter.npcName,
action: 'chat',
}),
createOption('npc_chat', '你刚才为什么会收手', {
kind: 'npc',
npcId: encounter.id ?? encounter.npcName,
action: 'chat',
}),
],
displayMode: 'dialogue',
}),
),
buildOpeningCampChatContext: vi.fn(() => ({})), buildOpeningCampChatContext: vi.fn(() => ({})),
buildStoryContextFromState: vi.fn(() => ({ buildStoryContextFromState: vi.fn(() => ({
playerHp: gameState.playerHp, playerHp: gameState.playerHp,
@@ -707,16 +747,17 @@ describe('npcEncounterActions', () => {
text: '先别急着拔话头。桥上的风向刚变,我得先确认你是来问旧账,还是来救人。', text: '先别急着拔话头。桥上的风向刚变,我得先确认你是来问旧账,还是来救人。',
}, },
]); ]);
expect(lastStory.npcChatState).toMatchObject({ expect(lastStory.npcChatState).toMatchObject({
npcId: 'npc-rival', npcId: 'npc-rival',
openingSource: 'npc_initiated', openingSource: 'npc_initiated',
turnCount: 0, turnCount: 0,
}); });
expect(lastStory.options.map((option) => option.actionText)).toEqual([ expect(lastStory.npcAffinityEffect).toBeNull();
'我先听你说桥上出了什么事', expect(lastStory.options.map((option) => option.actionText)).toEqual([
'你先说你在防谁', '我先听你说桥上出了什么事',
'我不是来翻旧账的', '你先说你在防谁',
]); '我不是来翻旧账的',
]);
}); });
it('removes any prefilled local opening line before the first model-driven npc reply', async () => { it('removes any prefilled local opening line before the first model-driven npc reply', async () => {
@@ -777,6 +818,11 @@ describe('npcEncounterActions', () => {
turn.text.includes('先和你打个招呼。前面的风不太对。'), turn.text.includes('先和你打个招呼。前面的风不太对。'),
), ),
).toBe(false); ).toBe(false);
expect(lastStory.npcAffinityEffect).toEqual({
eventId: expect.stringContaining('npc-chat-affinity-npc-rival-'),
npcId: 'npc-rival',
delta: 1,
});
}); });
it('passes the quest id through to the server runtime resolver for quest turn-in', async () => { it('passes the quest id through to the server runtime resolver for quest turn-in', async () => {
@@ -920,6 +966,94 @@ describe('npcEncounterActions', () => {
expect(actions.setIsLoading).toHaveBeenLastCalledWith(false); expect(actions.setIsLoading).toHaveBeenLastCalledWith(false);
}); });
it('prefers the current story non-chat options when rebuilding options after exiting npc chat', async () => {
const gameState = createState({
storyHistory: [
{
text: '你先试探了对方的态度。',
options: [],
historyRole: 'action',
},
],
});
const generateStoryForState = vi.fn().mockResolvedValue({
text: '你重新收束心神,开始判断断桥口接下来会怎么变。',
options: [createOption('idle_observe_signs', '观察周围动静')],
});
const actions = createNpcEncounterActions({
gameState,
currentStory: {
text: '断桥客把话收住,像是在等你决定下一步。',
displayMode: 'dialogue',
dialogue: [
{ speaker: 'npc', speakerName: '断桥客', text: '你还想继续聊下去吗。' },
],
options: [
createOption('npc_chat', '先问问你为什么堵在这里', {
kind: 'npc',
npcId: 'npc-rival',
action: 'chat',
}),
createOption('npc_help', '借你的人脉把线索铺开', {
kind: 'npc',
npcId: 'npc-rival',
action: 'help',
}),
createOption('npc_fight', '现在就把这笔旧账打清', {
kind: 'npc',
npcId: 'npc-rival',
action: 'fight',
}),
],
npcChatState: {
npcId: 'npc-rival',
npcName: '断桥客',
turnCount: 2,
customInputPlaceholder: '输入你想对 TA 说的话',
},
},
generateStoryForState,
getAvailableOptionsForState: vi.fn(() => [
createOption('npc_chat', '先问问你为什么堵在这里', {
kind: 'npc',
npcId: 'npc-rival',
action: 'chat',
}),
createOption('npc_help', '请求援手', {
kind: 'npc',
npcId: 'npc-rival',
action: 'help',
}),
createOption('npc_fight', '直接动手', {
kind: 'npc',
npcId: 'npc-rival',
action: 'fight',
}),
]),
});
expect(actions.exitNpcChat()).toBe(true);
await flushAsyncWork();
const [{ optionCatalog }] = generateStoryForState.mock.calls[0] as [
{ optionCatalog: StoryOption[] },
];
expect(optionCatalog).toEqual([
expect.objectContaining({
functionId: 'npc_chat',
actionText: '先问问你为什么堵在这里',
}),
expect.objectContaining({
functionId: 'npc_help',
actionText: '借你的人脉把线索铺开',
}),
expect.objectContaining({
functionId: 'npc_fight',
actionText: '现在就把这笔旧账打清',
}),
]);
});
it('opens hostile npc encounters as a declaration dialogue with only escape and fight options', () => { it('opens hostile npc encounters as a declaration dialogue with only escape and fight options', () => {
const encounter = createEncounter(); const encounter = createEncounter();
const actions = createNpcEncounterActions({ const actions = createNpcEncounterActions({
@@ -1095,6 +1229,105 @@ describe('npcEncounterActions', () => {
); );
}); });
it('reopens npc chat after battle victory with combat context and preserved negative affinity limit', () => {
const actions = createNpcEncounterActions({
gameState: createState({
customWorldProfile: createSceneActProfile(),
currentEncounter: createEncounter(),
npcInteractionActive: true,
npcStates: {
'npc-rival': {
affinity: -12,
helpUsed: false,
chattedCount: 2,
giftsGiven: 0,
inventory: [],
recruited: false,
firstMeaningfulContactResolved: true,
},
},
storyHistory: [
{
historyRole: 'action',
text: '你挥刀抢攻,逼住了断桥客的退路。',
options: [],
},
{
historyRole: 'result',
text: '断桥客被逼到桥栏边,刀势已经散了。',
options: [],
},
],
}),
currentStory: createFallbackStory(),
});
const reopened = actions.reopenNpcChatAfterBattle({
nextState: createState({
customWorldProfile: createSceneActProfile(),
currentEncounter: createEncounter(),
npcInteractionActive: true,
npcStates: {
'npc-rival': {
affinity: -12,
helpUsed: false,
chattedCount: 2,
giftsGiven: 0,
inventory: [],
recruited: false,
firstMeaningfulContactResolved: true,
},
},
storyHistory: [
{
historyRole: 'action',
text: '你挥刀抢攻,逼住了断桥客的退路。',
options: [],
},
{
historyRole: 'result',
text: '断桥客被逼到桥栏边,刀势已经散了。',
options: [],
},
],
}),
encounter: createEncounter(),
actionText: '挥刀抢攻',
resultText: '断桥客已经败下阵来。胜利奖励:无战利品。',
battleMode: 'fight',
});
expect(reopened).toBe(true);
const lastStory = actions.setCurrentStory.mock.calls.at(-1)?.[0] as StoryMoment;
expect(lastStory.npcChatState).toMatchObject({
npcId: 'npc-rival',
sceneActId: 'scene-bridge-act-1',
turnLimit: 5,
remainingTurns: 5,
limitReason: 'negative_affinity',
combatContext: {
battleOutcome: 'victory',
},
});
expect(lastStory.dialogue?.[0]).toEqual(
expect.objectContaining({
speaker: 'system',
text: '断桥客已经败下阵来。胜利奖励:无战利品。',
}),
);
expect(lastStory.npcChatState?.combatContext?.summary).toContain(
'你刚赢下这场交锋',
);
expect(lastStory.npcChatState?.combatContext?.logLines).toEqual(
expect.arrayContaining([
'你挥刀抢攻,逼住了断桥客的退路。',
'断桥客被逼到桥栏边,刀势已经散了。',
'挥刀抢攻',
'断桥客已经败下阵来。胜利奖励:无战利品。',
]),
);
});
it('offers a pending quest after enough warmup chat turns with a positive-affinity npc', async () => { it('offers a pending quest after enough warmup chat turns with a positive-affinity npc', async () => {
const pendingQuest = createQuest('quest-bridge-offer', '断桥口的密信'); const pendingQuest = createQuest('quest-bridge-offer', '断桥口的密信');
streamNpcChatTurnMock.mockResolvedValueOnce({ streamNpcChatTurnMock.mockResolvedValueOnce({
@@ -1142,6 +1375,11 @@ describe('npcEncounterActions', () => {
const lastStory = actions.setCurrentStory.mock.calls.at(-1)?.[0] as StoryMoment; const lastStory = actions.setCurrentStory.mock.calls.at(-1)?.[0] as StoryMoment;
expect(lastStory.npcChatState?.pendingQuestOffer?.quest).toEqual(pendingQuest); expect(lastStory.npcChatState?.pendingQuestOffer?.quest).toEqual(pendingQuest);
expect(lastStory.npcAffinityEffect).toEqual({
eventId: expect.stringContaining('npc-chat-affinity-npc-rival-'),
npcId: 'npc-rival',
delta: 2,
});
expect(lastStory.options.map((option) => option.actionText)).toEqual([ expect(lastStory.options.map((option) => option.actionText)).toEqual([
'查看任务', '查看任务',
'更换任务', '更换任务',

View File

@@ -85,6 +85,10 @@ type NpcChatDirective = {
forceExitAfterTurn?: boolean; forceExitAfterTurn?: boolean;
} | null; } | null;
type NpcChatCombatContext = NonNullable<
NonNullable<StoryMoment['npcChatState']>['combatContext']
>;
function isNpcEncounter( function isNpcEncounter(
encounter: GameState['currentEncounter'], encounter: GameState['currentEncounter'],
): encounter is Encounter { ): encounter is Encounter {
@@ -108,6 +112,7 @@ export function createStoryNpcEncounterActions({
setAiError, setAiError,
setIsLoading, setIsLoading,
appendHistory, appendHistory,
buildNpcStory,
buildOpeningCampChatContext, buildOpeningCampChatContext,
buildStoryContextFromState, buildStoryContextFromState,
buildFallbackStoryForState, buildFallbackStoryForState,
@@ -135,6 +140,12 @@ export function createStoryNpcEncounterActions({
actionText: string, actionText: string,
resultText: string, resultText: string,
) => GameState['storyHistory']; ) => GameState['storyHistory'];
buildNpcStory: (
state: GameState,
character: Character,
encounter: Encounter,
overrideText?: string,
) => StoryMoment;
buildOpeningCampChatContext: ( buildOpeningCampChatContext: (
state: GameState, state: GameState,
character: Character, character: Character,
@@ -308,6 +319,98 @@ export function createStoryNpcEncounterActions({
'先把这附近真正危险的地方说清楚', '先把这附近真正危险的地方说清楚',
].map((actionText) => buildNpcChatOption(encounter, actionText)); ].map((actionText) => buildNpcChatOption(encounter, actionText));
const extractRecentCombatLogLines = (history: GameState['storyHistory']) =>
history
.slice(-6)
.map((moment) => moment.text.trim())
.filter(Boolean)
.slice(-4);
const buildNpcBattleChatCombatContext = (params: {
battleMode: NpcBattleMode;
resultText: string;
actionText: string;
historyBase: GameState['storyHistory'];
}): NpcChatCombatContext => {
const logLines = [
...extractRecentCombatLogLines(params.historyBase),
params.actionText,
params.resultText,
].filter((line, index, lines) => lines.indexOf(line) === index);
return {
summary:
params.battleMode === 'spar'
? `你们刚结束一场切磋,${params.resultText}`
: `你刚赢下这场交锋,${params.resultText}`,
logLines,
battleOutcome:
params.battleMode === 'spar' ? 'spar_complete' : 'victory',
};
};
const reopenNpcChatAfterBattle = (params: {
nextState: GameState;
encounter: Encounter;
actionText: string;
resultText: string;
battleMode: NpcBattleMode;
}) => {
const playerCharacter = params.nextState.playerCharacter;
if (!playerCharacter) {
return false;
}
const reopenedNpcState = getResolvedNpcState(params.nextState, params.encounter);
const chatDirective = resolveLimitedPrimaryNpcChatState({
state: params.nextState,
npcId: params.encounter.id ?? params.encounter.npcName,
affinity: reopenedNpcState.affinity,
nextTurnCount: 0,
});
const baseStory = buildNpcStory(
params.nextState,
playerCharacter,
params.encounter,
params.resultText,
);
const baseChatOptions = (baseStory.options ?? []).filter((option) =>
isNpcChatOptionForEncounter(option, params.encounter),
);
const fallbackChatOption =
baseChatOptions[0] ??
buildNpcChatOption(params.encounter, `继续和${params.encounter.npcName}对话`);
const combatContext = buildNpcBattleChatCombatContext({
battleMode: params.battleMode,
resultText: params.resultText,
actionText: params.actionText,
historyBase: params.nextState.storyHistory,
});
setCurrentStory(
buildNpcChatStoryMoment({
encounter: params.encounter,
dialogue: [
{
speaker: 'system',
text: params.resultText,
},
],
options: buildNpcChatEntryOptions(
params.encounter,
fallbackChatOption,
baseChatOptions.slice(1),
),
streaming: false,
turnCount: 0,
chatDirective,
openingSource: 'player_reply',
combatContext,
}),
);
return true;
};
const finalizeNpcBattleResult = ( const finalizeNpcBattleResult = (
state: GameState, state: GameState,
character: Character, character: Character,
@@ -380,6 +483,18 @@ export function createStoryNpcEncounterActions({
const defeatedHostileNpcIds = activeBattleHostiles.map( const defeatedHostileNpcIds = activeBattleHostiles.map(
(hostileNpc) => hostileNpc.id, (hostileNpc) => hostileNpc.id,
); );
const restoredEncounter =
(state.currentEncounter?.kind === 'npc' ? state.currentEncounter : null) ??
activeBattleHostiles[0]?.encounter ??
({
id: battleNpcId,
kind: 'npc',
npcName: activeBattleHostiles[0]?.name ?? battleNpcId,
npcDescription: '',
npcAvatar: '',
context: '',
hostile: false,
} satisfies Encounter);
const progressedQuests = applyQuestProgressFromHostileNpcDefeat( const progressedQuests = applyQuestProgressFromHostileNpcDefeat(
state.quests, state.quests,
state.currentScenePreset?.id ?? null, state.currentScenePreset?.id ?? null,
@@ -398,8 +513,8 @@ export function createStoryNpcEncounterActions({
currentBattleNpcId: null, currentBattleNpcId: null,
currentNpcBattleMode: null, currentNpcBattleMode: null,
currentNpcBattleOutcome: null, currentNpcBattleOutcome: null,
currentEncounter: null, currentEncounter: restoredEncounter,
npcInteractionActive: false, npcInteractionActive: true,
sceneHostileNpcs: [], sceneHostileNpcs: [],
playerInventory: addInventoryItems(state.playerInventory, lootItems), playerInventory: addInventoryItems(state.playerInventory, lootItems),
quests: progressedQuests, quests: progressedQuests,
@@ -407,8 +522,8 @@ export function createStoryNpcEncounterActions({
...state.npcStates, ...state.npcStates,
[battleNpcId]: { [battleNpcId]: {
...markNpcFirstMeaningfulContactResolved(npcState), ...markNpcFirstMeaningfulContactResolved(npcState),
affinity: 0, affinity: npcState.affinity,
relationState: buildRelationState(0), relationState: buildRelationState(npcState.affinity),
recruited: false, recruited: false,
inventory: nextNpcInventory, inventory: nextNpcInventory,
}, },
@@ -604,12 +719,15 @@ export function createStoryNpcEncounterActions({
quest: QuestLogEntry; quest: QuestLogEntry;
} | null; } | null;
openingSource?: 'npc_initiated' | 'player_reply'; openingSource?: 'npc_initiated' | 'player_reply';
combatContext?: NpcChatCombatContext | null;
latestAffinityEffect?: StoryMoment['npcAffinityEffect'];
}): StoryMoment => ({ }): StoryMoment => ({
text: params.dialogue.map((turn) => turn.text).join('\n'), text: params.dialogue.map((turn) => turn.text).join('\n'),
options: params.options, options: params.options,
displayMode: 'dialogue', displayMode: 'dialogue',
dialogue: params.dialogue, dialogue: params.dialogue,
streaming: params.streaming, streaming: params.streaming,
npcAffinityEffect: params.latestAffinityEffect ?? null,
npcChatState: { npcChatState: {
npcId: params.encounter.id ?? params.encounter.npcName, npcId: params.encounter.id ?? params.encounter.npcName,
npcName: params.encounter.npcName, npcName: params.encounter.npcName,
@@ -622,6 +740,7 @@ export function createStoryNpcEncounterActions({
limitReason: params.chatDirective?.limitReason ?? null, limitReason: params.chatDirective?.limitReason ?? null,
forceExitAfterTurn: params.chatDirective?.forceExitAfterTurn ?? false, forceExitAfterTurn: params.chatDirective?.forceExitAfterTurn ?? false,
pendingQuestOffer: params.pendingQuestOffer ?? null, pendingQuestOffer: params.pendingQuestOffer ?? null,
combatContext: params.combatContext ?? null,
}, },
}); });
@@ -642,6 +761,50 @@ export function createStoryNpcEncounterActions({
}); });
}; };
const buildPostNpcChatOptionCatalog = (
encounter: Encounter,
playerCharacter: Character,
) => {
const resolvedStateOptions =
collapseNpcChatOptions(
getAvailableOptionsForState(gameState, playerCharacter) ?? [],
);
const currentStoryOptions = currentStory?.options ?? [];
const currentNpcKey = encounter.id ?? encounter.npcName;
const currentChatOptions = currentStoryOptions.filter((option) =>
isNpcChatOptionForEncounter(option, encounter),
);
const nonChatCurrentOptions = currentStoryOptions.filter(
(option) => !currentChatOptions.includes(option),
);
const nonChatResolvedOptions = resolvedStateOptions.filter(
(option) => !isNpcChatOptionForEncounter(option, encounter),
);
const mergedOptions: StoryOption[] = [];
const seenOptionIdentity = new Set<string>();
const pushUniqueOption = (option: StoryOption) => {
const optionIdentity = [
option.functionId,
option.interaction?.kind ?? '',
option.interaction?.kind === 'npc' ? option.interaction.npcId : '',
option.interaction?.kind === 'npc' ? option.interaction.action : '',
].join('::');
if (seenOptionIdentity.has(optionIdentity)) {
return;
}
seenOptionIdentity.add(optionIdentity);
mergedOptions.push(option);
};
currentChatOptions.slice(0, 1).forEach(pushUniqueOption);
nonChatCurrentOptions.forEach(pushUniqueOption);
nonChatResolvedOptions.forEach(pushUniqueOption);
return mergedOptions;
};
const buildLegacyNpcChatOpeningPlaceholder = (encounter: Encounter) => const buildLegacyNpcChatOpeningPlaceholder = (encounter: Encounter) =>
`${encounter.npcName}看着你,像是在等你把话接下去。`; `${encounter.npcName}看着你,像是在等你把话接下去。`;
@@ -967,6 +1130,7 @@ export function createStoryNpcEncounterActions({
currentStory?.npcChatState?.npcId === (encounter.id ?? encounter.npcName) currentStory?.npcChatState?.npcId === (encounter.id ?? encounter.npcName)
? currentStory.npcChatState ? currentStory.npcChatState
: null; : null;
const currentCombatContext = currentNpcChatState?.combatContext ?? null;
const existingDialogue = const existingDialogue =
currentStory?.dialogue && currentNpcChatState currentStory?.dialogue && currentNpcChatState
? sanitizeNpcChatDialogueHistory( ? sanitizeNpcChatDialogueHistory(
@@ -1006,6 +1170,7 @@ export function createStoryNpcEncounterActions({
streaming: true, streaming: true,
turnCount: nextTurnCount, turnCount: nextTurnCount,
chatDirective: limitedChatDirective, chatDirective: limitedChatDirective,
combatContext: currentCombatContext,
}), }),
); );
@@ -1045,6 +1210,7 @@ export function createStoryNpcEncounterActions({
streaming: true, streaming: true,
turnCount: nextTurnCount, turnCount: nextTurnCount,
chatDirective: limitedChatDirective, chatDirective: limitedChatDirective,
combatContext: currentCombatContext,
}), }),
); );
}, },
@@ -1055,6 +1221,7 @@ export function createStoryNpcEncounterActions({
turnCount: nextTurnCount, turnCount: nextTurnCount,
}, },
chatDirective: limitedChatDirective, chatDirective: limitedChatDirective,
combatContext: currentCombatContext,
}, },
); );
@@ -1092,18 +1259,15 @@ export function createStoryNpcEncounterActions({
}; };
setGameState(finalState); setGameState(finalState);
const affinityTurn = // 好感变化只保留为一次性表现事件,不再插入聊天消息流。
const latestAffinityEffect =
chatTurn.affinityDelta !== 0 chatTurn.affinityDelta !== 0
? [ ? {
{ eventId: `npc-chat-affinity-${encounter.id ?? encounter.npcName}-${Date.now()}`,
speaker: 'system' as const, npcId: encounter.id ?? encounter.npcName,
text: `${chatTurn.affinityText} \u597d\u611f ${ delta: chatTurn.affinityDelta,
chatTurn.affinityDelta > 0 ? '+' : '-' }
}${Math.abs(chatTurn.affinityDelta)}`, : null;
affinityDelta: chatTurn.affinityDelta,
},
]
: [];
const nextDialogue = [ const nextDialogue = [
...dialogueWithPlayer, ...dialogueWithPlayer,
@@ -1112,7 +1276,6 @@ export function createStoryNpcEncounterActions({
speakerName: encounter.npcName, speakerName: encounter.npcName,
text: chatTurn.npcReply, text: chatTurn.npcReply,
}, },
...affinityTurn,
]; ];
const pendingQuest = const pendingQuest =
(chatTurn.pendingQuestOffer?.quest as QuestLogEntry | undefined) ?? (chatTurn.pendingQuestOffer?.quest as QuestLogEntry | undefined) ??
@@ -1153,6 +1316,7 @@ export function createStoryNpcEncounterActions({
displayMode: 'dialogue', displayMode: 'dialogue',
dialogue: closingDialogue, dialogue: closingDialogue,
streaming: false, streaming: false,
npcAffinityEffect: latestAffinityEffect,
}); });
return true; return true;
} }
@@ -1177,6 +1341,8 @@ export function createStoryNpcEncounterActions({
pendingQuestOffer: { pendingQuestOffer: {
quest: pendingQuest, quest: pendingQuest,
}, },
combatContext: currentCombatContext,
latestAffinityEffect,
}), }),
); );
return true; return true;
@@ -1195,6 +1361,8 @@ export function createStoryNpcEncounterActions({
streaming: false, streaming: false,
turnCount: nextTurnCount, turnCount: nextTurnCount,
chatDirective: resolvedChatDirective, chatDirective: resolvedChatDirective,
combatContext: currentCombatContext,
latestAffinityEffect,
}), }),
); );
return true; return true;
@@ -1212,6 +1380,7 @@ export function createStoryNpcEncounterActions({
streaming: false, streaming: false,
turnCount: nextTurnCount, turnCount: nextTurnCount,
chatDirective: limitedChatDirective, chatDirective: limitedChatDirective,
combatContext: currentCombatContext,
}), }),
); );
return false; return false;
@@ -1234,8 +1403,9 @@ export function createStoryNpcEncounterActions({
const choiceText = `结束与${encounter.npcName}的这轮交谈,重新观察当前局势`; const choiceText = `结束与${encounter.npcName}的这轮交谈,重新观察当前局势`;
try { try {
const postChatOptionCatalog = collapseNpcChatOptions( const postChatOptionCatalog = buildPostNpcChatOptionCatalog(
getAvailableOptionsForState(gameState, playerCharacter) ?? [], encounter,
playerCharacter,
); );
const nextStory = await generateStoryForState({ const nextStory = await generateStoryForState({
state: gameState, state: gameState,
@@ -1691,6 +1861,7 @@ export function createStoryNpcEncounterActions({
enterNpcInteraction, enterNpcInteraction,
handleNpcInteraction, handleNpcInteraction,
finalizeNpcBattleResult, finalizeNpcBattleResult,
reopenNpcChatAfterBattle,
handleNpcChatTurn, handleNpcChatTurn,
exitNpcChat, exitNpcChat,
replacePendingNpcQuestOffer, replacePendingNpcQuestOffer,

View File

@@ -49,6 +49,15 @@ type BuildNpcStory = (
overrideText?: string, overrideText?: string,
) => StoryMoment; ) => StoryMoment;
type HandleNpcBattleConversationContinuation = (params: {
nextState: GameState;
encounter: Encounter;
character: Character;
actionText: string;
resultText: string;
battleMode: NonNullable<GameState['currentNpcBattleMode']>;
}) => boolean;
type BuildStoryContextFromState = ( type BuildStoryContextFromState = (
state: GameState, state: GameState,
extras?: { extras?: {
@@ -112,6 +121,7 @@ export async function runLocalStoryChoiceContinuation(params: {
state: GameState, state: GameState,
) => GameState['sceneHostileNpcs']; ) => GameState['sceneHostileNpcs'];
buildNpcStory: BuildNpcStory; buildNpcStory: BuildNpcStory;
handleNpcBattleConversationContinuation: HandleNpcBattleConversationContinuation;
updateQuestLog: UpdateQuestLog; updateQuestLog: UpdateQuestLog;
incrementRuntimeStats: IncrementRuntimeStats; incrementRuntimeStats: IncrementRuntimeStats;
finalizeNpcBattleResult: ( finalizeNpcBattleResult: (
@@ -289,6 +299,19 @@ export async function runLocalStoryChoiceContinuation(params: {
: null; : null;
fallbackState = nextState; fallbackState = nextState;
params.setGameState(nextState); params.setGameState(nextState);
if (
nextState.currentEncounter &&
params.handleNpcBattleConversationContinuation({
nextState,
encounter: nextState.currentEncounter,
character: params.character,
actionText: params.option.actionText,
resultText: victory.resultText,
battleMode: baseChoiceState.currentNpcBattleMode!,
})
) {
return;
}
try { try {
const nextStory = await params.generateStoryForState({ const nextStory = await params.generateStoryForState({
state: nextState, state: nextState,

View File

@@ -65,7 +65,10 @@ export type ChoiceRuntimeController = {
export type ChoiceRuntimeSupport = Pick< export type ChoiceRuntimeSupport = Pick<
StoryRuntimeSupport, StoryRuntimeSupport,
'buildNpcStory' | 'updateQuestLog' | 'updateRuntimeStats' | 'buildNpcStory'
| 'handleNpcBattleConversationContinuation'
| 'updateQuestLog'
| 'updateRuntimeStats'
>; >;
export type StoryChoiceCoordinatorParams = { export type StoryChoiceCoordinatorParams = {
@@ -148,6 +151,8 @@ export function createStoryChoiceCoordinatorConfig(
getStoryGenerationHostileNpcs: params.getStoryGenerationHostileNpcs, getStoryGenerationHostileNpcs: params.getStoryGenerationHostileNpcs,
getResolvedSceneHostileNpcs: params.getResolvedSceneHostileNpcs, getResolvedSceneHostileNpcs: params.getResolvedSceneHostileNpcs,
buildNpcStory: params.runtimeSupport.buildNpcStory, buildNpcStory: params.runtimeSupport.buildNpcStory,
handleNpcBattleConversationContinuation:
params.runtimeSupport.handleNpcBattleConversationContinuation,
updateQuestLog: params.runtimeSupport.updateQuestLog, updateQuestLog: params.runtimeSupport.updateQuestLog,
incrementRuntimeStats: params.runtimeSupport.updateRuntimeStats, incrementRuntimeStats: params.runtimeSupport.updateRuntimeStats,
getCampCompanionTravelScene: getCampCompanionTravelScene:

View File

@@ -61,6 +61,7 @@ describe('storyInteractionCoordinator', () => {
const resolveNpcInteractionDecision = vi.fn(() => ({ kind: 'chat' })); const resolveNpcInteractionDecision = vi.fn(() => ({ kind: 'chat' }));
const runtimeSupport = { const runtimeSupport = {
buildNpcStory: vi.fn(), buildNpcStory: vi.fn(),
handleNpcBattleConversationContinuation: vi.fn(() => false),
cloneInventoryItemForOwner: vi.fn(), cloneInventoryItemForOwner: vi.fn(),
getNpcEncounterKey: vi.fn(), getNpcEncounterKey: vi.fn(),
getResolvedNpcState: vi.fn(), getResolvedNpcState: vi.fn(),

View File

@@ -10,6 +10,7 @@ import type {
Encounter, Encounter,
GameState, GameState,
InventoryItem, InventoryItem,
NpcBattleMode,
} from '../../types'; } from '../../types';
import { getNpcEncounterKey } from './storyGenerationState'; import { getNpcEncounterKey } from './storyGenerationState';
@@ -119,6 +120,14 @@ export const storyRuntimeSupport = {
getNpcEncounterKey, getNpcEncounterKey,
getResolvedNpcState, getResolvedNpcState,
buildNpcStory, buildNpcStory,
handleNpcBattleConversationContinuation: (_params: {
nextState: GameState;
encounter: Encounter;
character: Character;
actionText: string;
resultText: string;
battleMode: NpcBattleMode;
}) => false,
updateNpcState, updateNpcState,
updateQuestLog, updateQuestLog,
updateRuntimeStats, updateRuntimeStats,

View File

@@ -16,6 +16,7 @@ import { useStoryNpcInteractionFlow } from './npcInteraction';
import type { StoryInteractionCoordinatorConfig } from './storyInteractionCoordinator'; import type { StoryInteractionCoordinatorConfig } from './storyInteractionCoordinator';
import type { StoryRuntimeSupport } from './storyRuntimeSupport'; import type { StoryRuntimeSupport } from './storyRuntimeSupport';
import type { import type {
ChoiceRuntimeSupport,
ChoiceRuntimeController, ChoiceRuntimeController,
StoryChoiceCoordinatorParams, StoryChoiceCoordinatorParams,
} from './storyChoiceCoordinator'; } from './storyChoiceCoordinator';
@@ -97,6 +98,7 @@ export function useStoryInteractionCoordinator({
enterNpcInteraction, enterNpcInteraction,
handleNpcInteraction, handleNpcInteraction,
finalizeNpcBattleResult, finalizeNpcBattleResult,
reopenNpcChatAfterBattle,
handleNpcChatTurn, handleNpcChatTurn,
exitNpcChat, exitNpcChat,
replacePendingNpcQuestOffer, replacePendingNpcQuestOffer,
@@ -104,6 +106,7 @@ export function useStoryInteractionCoordinator({
acceptPendingNpcQuestOffer, acceptPendingNpcQuestOffer,
} = createStoryNpcEncounterActions({ } = createStoryNpcEncounterActions({
...interactionConfig.npcEncounterActions, ...interactionConfig.npcEncounterActions,
buildNpcStory: runtimeSupport.buildNpcStory,
npcInteractionFlow, npcInteractionFlow,
}); });
@@ -173,6 +176,23 @@ export function useStoryInteractionCoordinator({
); );
}, },
}; };
const choiceRuntimeSupport: ChoiceRuntimeSupport = {
...runtimeSupport,
handleNpcBattleConversationContinuation: ({
nextState,
encounter,
actionText,
resultText,
battleMode,
}) =>
reopenNpcChatAfterBattle({
nextState,
encounter,
actionText,
resultText,
battleMode,
}),
};
const { handleChoice, battleRewardUi, clearStoryChoiceUi } = const { handleChoice, battleRewardUi, clearStoryChoiceUi } =
useStoryChoiceCoordinator({ useStoryChoiceCoordinator({
gameState, gameState,
@@ -187,7 +207,7 @@ export function useStoryInteractionCoordinator({
interactionConfig.npcEncounterActions.getStoryGenerationHostileNpcs, interactionConfig.npcEncounterActions.getStoryGenerationHostileNpcs,
getResolvedSceneHostileNpcs, getResolvedSceneHostileNpcs,
runtimeController: choiceRuntimeController, runtimeController: choiceRuntimeController,
runtimeSupport, runtimeSupport: choiceRuntimeSupport,
enterNpcInteraction, enterNpcInteraction,
handleNpcInteraction, handleNpcInteraction,
handleTreasureInteraction, handleTreasureInteraction,

View File

@@ -20,6 +20,18 @@
:root { :root {
--ui-scale: clamp(0.78, 0.72 + 0.45vw, 1.06); --ui-scale: clamp(0.78, 0.72 + 0.45vw, 1.06);
--platform-bottom-nav-height: 3.5rem;
--platform-bottom-nav-padding: 0.25rem;
--platform-bottom-nav-gap: 0.25rem;
--platform-bottom-nav-radius: 1.2rem;
--platform-bottom-nav-button-radius: 1rem;
--platform-bottom-nav-icon-size: 1.05rem;
--platform-bottom-nav-icon-shell-size: 1.55rem;
--platform-bottom-nav-label-size: 11px;
--platform-bottom-nav-label-tracking: 0.18em;
--platform-bottom-nav-content-gap: 0.22rem;
--platform-bottom-nav-active-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.08),
0 8px 18px rgba(255, 91, 132, 0.1);
} }
body { body {
@@ -138,20 +150,20 @@ body {
color-scheme: light; color-scheme: light;
--platform-body-fill: radial-gradient( --platform-body-fill: radial-gradient(
circle at top left, circle at top left,
rgba(255, 108, 155, 0.16), rgba(255, 196, 214, 0.14),
transparent 20% transparent 24%
), ),
radial-gradient( radial-gradient(
circle at 88% 4%, circle at 88% 4%,
rgba(255, 195, 150, 0.14), rgba(255, 222, 196, 0.12),
transparent 18% transparent 20%
), ),
radial-gradient( radial-gradient(
circle at bottom, circle at bottom,
rgba(255, 118, 162, 0.08), rgba(255, 214, 225, 0.08),
transparent 22% transparent 26%
), ),
linear-gradient(180deg, #fffafc 0%, #fffefe 48%, #fff5f8 100%); linear-gradient(180deg, #fffdfd 0%, #fffefe 50%, #fff8fa 100%);
--platform-panel-shadow: 0 22px 60px rgba(215, 87, 134, 0.12), --platform-panel-shadow: 0 22px 60px rgba(215, 87, 134, 0.12),
0 8px 20px rgba(255, 255, 255, 0.82); 0 8px 20px rgba(255, 255, 255, 0.82);
--platform-panel-fill: linear-gradient( --platform-panel-fill: linear-gradient(
@@ -166,18 +178,28 @@ body {
); );
--platform-hero-fill: linear-gradient( --platform-hero-fill: linear-gradient(
135deg, 135deg,
rgba(255, 31, 111, 0.96), rgba(255, 139, 162, 0.9),
rgba(255, 135, 103, 0.92) rgba(255, 184, 153, 0.88)
);
--platform-hero-glow-a: rgba(255, 255, 255, 0.22);
--platform-hero-glow-b: rgba(255, 228, 211, 0.2);
--platform-hero-overlay-strong: linear-gradient(
135deg,
rgba(255, 146, 170, 0.78),
rgba(255, 201, 171, 0.72)
);
--platform-hero-overlay-soft: linear-gradient(
180deg,
rgba(255, 255, 255, 0.1),
rgba(255, 246, 249, 0.26)
); );
--platform-hero-glow-a: rgba(255, 255, 255, 0.18);
--platform-hero-glow-b: rgba(255, 197, 219, 0.18);
--platform-surface-border: rgba(239, 221, 228, 0.9); --platform-surface-border: rgba(239, 221, 228, 0.9);
--platform-surface-hover-border: rgba(255, 154, 188, 0.58); --platform-surface-hover-border: rgba(255, 154, 188, 0.58);
--platform-shell-glow-1: rgba(255, 255, 255, 0.22); --platform-shell-glow-1: rgba(255, 255, 255, 0.2);
--platform-shell-glow-2: rgba(255, 186, 205, 0.22); --platform-shell-glow-2: rgba(255, 220, 229, 0.18);
--platform-shell-glow-3: rgba(255, 197, 158, 0.16); --platform-shell-glow-3: rgba(255, 221, 194, 0.14);
--platform-surface-glow-a: rgba(255, 165, 195, 0.16); --platform-surface-glow-a: rgba(255, 213, 225, 0.14);
--platform-surface-glow-b: rgba(255, 196, 160, 0.14); --platform-surface-glow-b: rgba(255, 224, 201, 0.12);
--platform-text-strong: #28151d; --platform-text-strong: #28151d;
--platform-text-base: #5c4650; --platform-text-base: #5c4650;
--platform-text-soft: #886f79; --platform-text-soft: #886f79;
@@ -609,6 +631,20 @@ body {
background: var(--platform-hero-fill); background: var(--platform-hero-fill);
} }
.platform-surface--hero::before {
background: radial-gradient(
circle at top left,
var(--platform-hero-glow-a),
transparent 34%
),
radial-gradient(
circle at bottom right,
var(--platform-hero-glow-b),
transparent 32%
),
var(--platform-hero-overlay-soft);
}
.platform-surface--light { .platform-surface--light {
border-color: var(--platform-line-soft); border-color: var(--platform-line-soft);
background: var(--platform-subpanel-fill); background: var(--platform-subpanel-fill);
@@ -800,8 +836,13 @@ body {
} }
.platform-bottom-nav { .platform-bottom-nav {
box-sizing: border-box;
min-height: var(--platform-bottom-nav-height);
gap: var(--platform-bottom-nav-gap);
border: 1px solid var(--platform-desktop-panel-border); border: 1px solid var(--platform-desktop-panel-border);
border-radius: var(--platform-bottom-nav-radius);
background: var(--platform-nav-fill); background: var(--platform-nav-fill);
padding: var(--platform-bottom-nav-padding);
box-shadow: box-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.03), inset 0 1px 0 rgba(255, 255, 255, 0.03),
0 16px 40px rgba(0, 0, 0, 0.1); 0 16px 40px rgba(0, 0, 0, 0.1);
@@ -812,13 +853,15 @@ body {
display: flex; display: flex;
box-sizing: border-box; box-sizing: border-box;
width: 100%; width: 100%;
height: 100%; min-height: calc(
var(--platform-bottom-nav-height) - var(--platform-bottom-nav-padding) * 2
);
align-items: center; align-items: center;
justify-content: center; justify-content: center;
border: 1px solid transparent; border: 1px solid transparent;
border-radius: 1rem; border-radius: var(--platform-bottom-nav-button-radius);
background: transparent; background: transparent;
padding: 0.35rem 0.5rem; padding: 0;
color: var(--platform-nav-item-text); color: var(--platform-nav-item-text);
transition: transition:
background-color 180ms ease, background-color 180ms ease,
@@ -827,6 +870,16 @@ body {
transform 180ms ease; transform 180ms ease;
} }
.platform-bottom-nav__button-content {
display: flex;
min-height: 100%;
width: 100%;
flex-direction: column;
align-items: center;
justify-content: center;
gap: var(--platform-bottom-nav-content-gap);
}
.platform-bottom-nav__button:hover { .platform-bottom-nav__button:hover {
color: var(--platform-text-strong); color: var(--platform-text-strong);
background: var(--platform-nav-item-hover-fill); background: var(--platform-nav-item-hover-fill);
@@ -836,9 +889,7 @@ body {
border: 1px solid var(--platform-nav-active-border); border: 1px solid var(--platform-nav-active-border);
background: var(--platform-nav-active-fill); background: var(--platform-nav-active-fill);
color: var(--platform-text-strong); color: var(--platform-text-strong);
box-shadow: box-shadow: var(--platform-bottom-nav-active-shadow);
inset 0 1px 0 rgba(255, 255, 255, 0.08),
var(--platform-nav-active-shadow);
} }
.platform-bottom-nav__icon-shell, .platform-bottom-nav__icon-shell,
@@ -857,8 +908,8 @@ body {
} }
.platform-bottom-nav__icon-shell { .platform-bottom-nav__icon-shell {
width: 1.5rem; width: var(--platform-bottom-nav-icon-shell-size);
height: 1.5rem; height: var(--platform-bottom-nav-icon-shell-size);
} }
.platform-desktop-rail__icon-shell { .platform-desktop-rail__icon-shell {
@@ -869,12 +920,18 @@ body {
.platform-bottom-nav__icon, .platform-bottom-nav__icon,
.platform-desktop-rail__icon { .platform-desktop-rail__icon {
color: var(--platform-nav-item-icon-text); color: var(--platform-nav-item-icon-text);
width: var(--platform-bottom-nav-icon-size);
height: var(--platform-bottom-nav-icon-size);
transition: color 180ms ease; transition: color 180ms ease;
} }
.platform-bottom-nav__label, .platform-bottom-nav__label,
.platform-desktop-rail__label { .platform-desktop-rail__label {
color: var(--platform-nav-item-text); color: var(--platform-nav-item-text);
font-size: var(--platform-bottom-nav-label-size);
font-weight: 600;
letter-spacing: var(--platform-bottom-nav-label-tracking);
line-height: 1;
transition: color 180ms ease; transition: color 180ms ease;
} }

View File

@@ -13,8 +13,7 @@
* 当前真实调用状态: * 当前真实调用状态:
* - CustomWorldRoleAssetStudioModal 的初始默认值主链,来自本文件 * - CustomWorldRoleAssetStudioModal 的初始默认值主链,来自本文件
* - 也就是说,资产工坊页面打开时看到的“形象描述 / 动作描述” * - 也就是说,资产工坊页面打开时看到的“形象描述 / 动作描述”
* 当前优先取这里的本地字段映射,而不是后端 * 当前直接取这里的本地字段映射
* /api/assets/character-prompts/generate 接口
*/ */
export type PromptDefaultRole = { export type PromptDefaultRole = {
name: string; name: string;

View File

@@ -978,6 +978,11 @@ export async function streamNpcChatTurn(
state: GameState; state: GameState;
turnCount: number; turnCount: number;
} | null; } | null;
combatContext?: {
summary: string;
logLines: string[];
battleOutcome: 'victory' | 'spar_complete';
} | null;
chatDirective?: NpcChatTurnDirective | null; chatDirective?: NpcChatTurnDirective | null;
npcInitiatesConversation?: boolean; npcInitiatesConversation?: boolean;
} = {}, } = {},
@@ -1002,6 +1007,7 @@ export async function streamNpcChatTurn(
turnCount: options.questOfferContext.turnCount, turnCount: options.questOfferContext.turnCount,
} }
: null, : null,
combatContext: options.combatContext ?? null,
chatDirective: options.chatDirective ?? null, chatDirective: options.chatDirective ?? null,
} satisfies NpcChatTurnRequest; } satisfies NpcChatTurnRequest;

View File

@@ -8,6 +8,7 @@ import {
CUSTOM_WORLD_SCENE_RELATIVE_POSITION_OPTIONS, CUSTOM_WORLD_SCENE_RELATIVE_POSITION_OPTIONS,
type CustomWorldLandmarkDraft, type CustomWorldLandmarkDraft,
getCustomWorldSceneRelativePositionLabel, getCustomWorldSceneRelativePositionLabel,
normalizeCustomWorldSceneRelativePosition,
normalizeCustomWorldLandmarks, normalizeCustomWorldLandmarks,
} from '../data/customWorldSceneGraph'; } from '../data/customWorldSceneGraph';
import { import {
@@ -120,9 +121,15 @@ export interface CustomWorldGenerationLandmarkOutline {
} }
export interface CustomWorldGenerationCampOutline { export interface CustomWorldGenerationCampOutline {
id?: string;
name: string; name: string;
description: string; description: string;
visualDescription?: string;
dangerLevel: string; dangerLevel: string;
imageSrc?: string;
sceneNpcIds?: string[];
sceneNpcNames?: string[];
connections?: CustomWorldGenerationLandmarkConnectionOutline[];
} }
export interface CustomWorldGenerationFramework { export interface CustomWorldGenerationFramework {
@@ -1061,9 +1068,33 @@ function normalizeCampOutline(
: {}; : {};
return { return {
id: toText(item.id) || fallback.id,
name: toText(item.name) || fallback.name, name: toText(item.name) || fallback.name,
description: toText(item.description) || fallback.description, description: toText(item.description) || fallback.description,
visualDescription: toText(item.visualDescription) || undefined,
dangerLevel: toText(item.dangerLevel) || fallback.dangerLevel, dangerLevel: toText(item.dangerLevel) || fallback.dangerLevel,
imageSrc: toText(item.imageSrc) || undefined,
sceneNpcIds: toStringArray(item.sceneNpcIds),
sceneNpcNames: [
...toStringArray(item.sceneNpcNames),
...toStringArray(item.npcs, 'name'),
...toStringArray(item.sceneNpcs, 'name'),
...toStringArray(item.npcNames),
],
connections: toRecordArray(item.connections)
.map((connection) => ({
targetLandmarkName:
toText(connection.targetLandmarkName) ||
toText(connection.target) ||
toText(connection.sceneName),
relativePosition:
toText(connection.relativePosition) ||
toText(connection.position) ||
'forward',
summary:
toText(connection.summary) || toText(connection.description),
}))
.filter((connection) => connection.targetLandmarkName),
}; };
} }
@@ -1181,10 +1212,23 @@ function normalizeCampScene(
: {}; : {};
return { return {
id: toText(item.id) || fallback.id,
name: toText(item.name) || fallback.name, name: toText(item.name) || fallback.name,
description: toText(item.description) || fallback.description, description: toText(item.description) || fallback.description,
visualDescription: toText(item.visualDescription) || undefined,
dangerLevel: toText(item.dangerLevel) || fallback.dangerLevel, dangerLevel: toText(item.dangerLevel) || fallback.dangerLevel,
imageSrc: toText(item.imageSrc) || undefined, imageSrc: toText(item.imageSrc) || undefined,
sceneNpcIds: toStringArray(item.sceneNpcIds),
connections: toRecordArray(item.connections)
.map((connection) => ({
targetLandmarkId: toText(connection.targetLandmarkId),
relativePosition: normalizeCustomWorldSceneRelativePosition(
toText(connection.relativePosition) || toText(connection.position) || 'forward',
),
summary: toText(connection.summary) || toText(connection.description),
}))
.filter((connection) => connection.targetLandmarkId),
narrativeResidues: null,
}; };
} }

View File

@@ -582,6 +582,72 @@ test('adapts agent draft profile into legacy custom world result profile', () =>
expect(profile?.landmarks[0]?.name).toBe('回潮旧灯塔'); expect(profile?.landmarks[0]?.name).toBe('回潮旧灯塔');
}); });
test('agent draft result keeps generated role portraits and scene act backgrounds', () => {
const profile = buildCustomWorldProfileFromAgentDraft({
...session,
draftProfile: {
...session.draftProfile,
playableNpcs: [
{
...session.draftProfile.playableNpcs[0],
imageSrc: '/generated-characters/playable-1/visual/asset-1/master.png',
generatedVisualAssetId: 'asset-1',
},
],
storyNpcs: [
{
...session.draftProfile.storyNpcs[0],
imageSrc: '/generated-characters/story-1/visual/asset-2/master.png',
generatedVisualAssetId: 'asset-2',
},
],
sceneChapters: [
{
id: 'scene-chapter-1',
sceneId: 'landmark-1',
sceneName: '回潮旧灯塔',
title: '灯塔初章',
summary: '围绕灯塔推进的首个场景章节。',
linkedThreadIds: ['thread-1'],
linkedLandmarkIds: ['landmark-1'],
acts: [
{
id: 'scene-act-1',
title: '第一幕',
summary: '先接住回潮灯塔的入口压力。',
stageCoverage: ['opening'],
backgroundImageSrc:
'/generated-custom-world-scenes/landmark-1/scene-act-1/scene.png',
backgroundAssetId: 'scene-asset-1',
encounterNpcIds: ['story-1'],
primaryNpcId: 'story-1',
linkedThreadIds: ['thread-1'],
actGoal: '接住首幕入口',
transitionHook: '向第二幕推进。',
advanceRule: 'after_primary_contact',
},
],
},
],
},
});
expect(profile?.playableNpcs[0]?.imageSrc).toBe(
'/generated-characters/playable-1/visual/asset-1/master.png',
);
expect(profile?.playableNpcs[0]?.generatedVisualAssetId).toBe('asset-1');
expect(profile?.storyNpcs[0]?.imageSrc).toBe(
'/generated-characters/story-1/visual/asset-2/master.png',
);
expect(profile?.storyNpcs[0]?.generatedVisualAssetId).toBe('asset-2');
expect(profile?.sceneChapterBlueprints?.[0]?.acts[0]?.backgroundImageSrc).toBe(
'/generated-custom-world-scenes/landmark-1/scene-act-1/scene.png',
);
expect(profile?.sceneChapterBlueprints?.[0]?.acts[0]?.backgroundAssetId).toBe(
'scene-asset-1',
);
});
test('prefers embedded legacy result profile without dropping compiled runtime fields', () => { test('prefers embedded legacy result profile without dropping compiled runtime fields', () => {
const profile = buildProfileFromEmbeddedLegacyResult(); const profile = buildProfileFromEmbeddedLegacyResult();
@@ -604,6 +670,87 @@ test('prefers embedded legacy result profile without dropping compiled runtime f
expect(profile?.landmarks[0]?.narrativeResidues?.[0]?.title).toBe('潮痕'); expect(profile?.landmarks[0]?.narrativeResidues?.[0]?.title).toBe('潮痕');
}); });
test('embedded legacy result profile merges latest draft asset fields for result view', () => {
const profile = buildCustomWorldProfileFromAgentDraft({
...session,
draftProfile: {
...session.draftProfile,
legacyResultProfile: buildLegacyResultProfile(),
playableNpcs: [
{
...session.draftProfile.playableNpcs[0],
imageSrc: '/generated-characters/playable-1/visual/asset-runtime/master.png',
generatedVisualAssetId: 'asset-runtime-playable',
},
],
storyNpcs: [
{
...session.draftProfile.storyNpcs[0],
imageSrc: '/generated-characters/story-1/visual/asset-runtime/master.png',
generatedVisualAssetId: 'asset-runtime-story',
},
],
landmarks: [
{
...session.draftProfile.landmarks[0],
imageSrc: '/generated-custom-world-scenes/landmark-1/scene.png',
},
],
sceneChapters: [
{
id: 'scene-chapter-1',
sceneId: 'landmark-1',
sceneName: '回潮旧灯塔',
title: '灯塔初章',
summary: '围绕灯塔推进的首个场景章节。',
linkedThreadIds: ['thread-1'],
linkedLandmarkIds: ['landmark-1'],
acts: [
{
id: 'scene-act-1',
title: '第一幕',
summary: '先接住回潮灯塔的入口压力。',
stageCoverage: ['opening'],
backgroundImageSrc:
'/generated-custom-world-scenes/landmark-1/scene-act-1/scene.png',
backgroundAssetId: 'scene-asset-runtime',
encounterNpcIds: ['story-1'],
primaryNpcId: 'story-1',
linkedThreadIds: ['thread-1'],
actGoal: '接住首幕入口',
transitionHook: '向第二幕推进。',
advanceRule: 'after_primary_contact',
},
],
},
],
},
});
expect(profile?.name).toBe('旧版完整结果');
expect(profile?.playableNpcs[0]?.imageSrc).toBe(
'/generated-characters/playable-1/visual/asset-runtime/master.png',
);
expect(profile?.playableNpcs[0]?.generatedVisualAssetId).toBe(
'asset-runtime-playable',
);
expect(profile?.storyNpcs[0]?.imageSrc).toBe(
'/generated-characters/story-1/visual/asset-runtime/master.png',
);
expect(profile?.storyNpcs[0]?.generatedVisualAssetId).toBe(
'asset-runtime-story',
);
expect(profile?.landmarks[0]?.imageSrc).toBe(
'/generated-custom-world-scenes/landmark-1/scene.png',
);
expect(profile?.sceneChapterBlueprints?.[0]?.acts[0]?.backgroundImageSrc).toBe(
'/generated-custom-world-scenes/landmark-1/scene-act-1/scene.png',
);
expect(profile?.sceneChapterBlueprints?.[0]?.acts[0]?.backgroundAssetId).toBe(
'scene-asset-runtime',
);
});
test('embedded legacy result profile keeps result-page settings in runtime characters and scenes', () => { test('embedded legacy result profile keeps result-page settings in runtime characters and scenes', () => {
const profile = buildProfileFromEmbeddedLegacyResult(); const profile = buildProfileFromEmbeddedLegacyResult();

View File

@@ -178,6 +178,110 @@ function adaptDraftLandmarks(value: unknown, storyNpcIdSet: Set<string>) {
.filter(Boolean) as AdaptedDraftLandmark[]; .filter(Boolean) as AdaptedDraftLandmark[];
} }
function mergeDraftRoleAssetsIntoProfile(
baseProfile: CustomWorldProfile,
draftRoles: AdaptedDraftCharacter[],
roleKind: 'playable' | 'story',
) {
const draftRoleById = new Map(draftRoles.map((role) => [role.id, role]));
const currentRoles =
roleKind === 'playable' ? baseProfile.playableNpcs : baseProfile.storyNpcs;
const mergedRoles = currentRoles.map((role) => {
const draftRole = draftRoleById.get(role.id);
if (!draftRole) {
return role;
}
return {
...role,
imageSrc: draftRole.imageSrc ?? role.imageSrc,
generatedVisualAssetId:
draftRole.generatedVisualAssetId ?? role.generatedVisualAssetId,
generatedAnimationSetId:
draftRole.generatedAnimationSetId ?? role.generatedAnimationSetId,
animationMap: draftRole.animationMap ?? role.animationMap,
};
});
if (roleKind === 'playable') {
return {
...baseProfile,
playableNpcs: mergedRoles,
} satisfies CustomWorldProfile;
}
return {
...baseProfile,
storyNpcs: mergedRoles,
} satisfies CustomWorldProfile;
}
function mergeDraftSceneAssetsIntoProfile(
baseProfile: CustomWorldProfile,
draftSceneChapters: CustomWorldProfile['sceneChapterBlueprints'],
draftLandmarks: AdaptedDraftLandmark[],
) {
const normalizedDraftSceneChapters = draftSceneChapters ?? [];
const draftSceneChapterBySceneId = new Map(
normalizedDraftSceneChapters.map((chapter) => [chapter.sceneId, chapter]),
);
const draftLandmarkById = new Map(draftLandmarks.map((entry) => [entry.id, entry]));
const nextCamp = baseProfile.camp
? {
...baseProfile.camp,
imageSrc: baseProfile.camp.imageSrc,
}
: baseProfile.camp;
const nextLandmarks = baseProfile.landmarks.map((landmark) => {
const draftLandmark = draftLandmarkById.get(landmark.id);
return {
...landmark,
imageSrc: draftLandmark?.imageSrc ?? landmark.imageSrc,
};
});
const nextSceneChapterBlueprints =
normalizedDraftSceneChapters.length > 0
? baseProfile.sceneChapterBlueprints?.map((chapter) => {
const draftChapter = draftSceneChapterBySceneId.get(chapter.sceneId);
if (!draftChapter) {
return chapter;
}
const draftActById = new Map(
draftChapter.acts.map((act) => [act.id, act]),
);
return {
...chapter,
acts: chapter.acts.map((act) => {
const draftAct = draftActById.get(act.id);
if (!draftAct) {
return act;
}
return {
...act,
backgroundImageSrc:
draftAct.backgroundImageSrc ?? act.backgroundImageSrc,
backgroundAssetId:
draftAct.backgroundAssetId ?? act.backgroundAssetId,
};
}),
};
}) ?? normalizedDraftSceneChapters
: baseProfile.sceneChapterBlueprints;
return {
...baseProfile,
camp: nextCamp,
landmarks: nextLandmarks,
sceneChapterBlueprints: nextSceneChapterBlueprints,
} satisfies CustomWorldProfile;
}
function toStageCoverage(value: unknown) { function toStageCoverage(value: unknown) {
const stageCoverage = Array.isArray(value) const stageCoverage = Array.isArray(value)
? value ? value
@@ -227,6 +331,8 @@ function adaptDraftSceneChapters(
: ['climax', 'aftermath'], : ['climax', 'aftermath'],
backgroundImageSrc: backgroundImageSrc:
toText(actRecord.backgroundImageSrc) || undefined, toText(actRecord.backgroundImageSrc) || undefined,
backgroundAssetId:
toText(actRecord.backgroundAssetId) || undefined,
encounterNpcIds, encounterNpcIds,
primaryNpcId, primaryNpcId,
linkedThreadIds: toStringArray(actRecord.linkedThreadIds), linkedThreadIds: toStringArray(actRecord.linkedThreadIds),
@@ -268,13 +374,6 @@ export function buildCustomWorldProfileFromAgentDraft(
} }
const draftProfile = session.draftProfile; const draftProfile = session.draftProfile;
const legacyResultProfile = normalizeCustomWorldProfileRecord(
draftProfile.legacyResultProfile,
);
if (legacyResultProfile) {
return legacyResultProfile;
}
const settingText = buildAgentDraftFoundationSettingText(session); const settingText = buildAgentDraftFoundationSettingText(session);
const templateWorldType = inferTemplateWorldType(settingText); const templateWorldType = inferTemplateWorldType(settingText);
const playableNpcs = adaptDraftCharacters( const playableNpcs = adaptDraftCharacters(
@@ -292,6 +391,32 @@ export function buildCustomWorldProfileFromAgentDraft(
const landmarkIdSet = new Set( const landmarkIdSet = new Set(
adaptedLandmarks.map((entry) => toText(entry.id)).filter(Boolean), adaptedLandmarks.map((entry) => toText(entry.id)).filter(Boolean),
); );
const draftSceneChapterBlueprints = adaptDraftSceneChapters(
draftProfile.sceneChapters,
storyNpcIdSet,
landmarkIdSet,
);
const legacyResultProfile = normalizeCustomWorldProfileRecord(
draftProfile.legacyResultProfile,
);
if (legacyResultProfile) {
const mergedPlayableProfile = mergeDraftRoleAssetsIntoProfile(
legacyResultProfile,
playableNpcs,
'playable',
);
const mergedStoryProfile = mergeDraftRoleAssetsIntoProfile(
mergedPlayableProfile,
storyNpcs,
'story',
);
return mergeDraftSceneAssetsIntoProfile(
mergedStoryProfile,
draftSceneChapterBlueprints,
adaptedLandmarks,
);
}
const normalized = normalizeCustomWorldProfileRecord({ const normalized = normalizeCustomWorldProfileRecord({
id: `agent-draft-${session.sessionId}`, id: `agent-draft-${session.sessionId}`,
settingText, settingText,
@@ -320,11 +445,7 @@ export function buildCustomWorldProfileFromAgentDraft(
imageSrc: toText(draftProfile.camp.imageSrc) || undefined, imageSrc: toText(draftProfile.camp.imageSrc) || undefined,
} }
: undefined, : undefined,
sceneChapterBlueprints: adaptDraftSceneChapters( sceneChapterBlueprints: draftSceneChapterBlueprints,
draftProfile.sceneChapters,
storyNpcIdSet,
landmarkIdSet,
),
anchorContent: session.anchorContent, anchorContent: session.anchorContent,
creatorIntent: session.creatorIntent, creatorIntent: session.creatorIntent,
anchorPack: session.anchorPack, anchorPack: session.anchorPack,

View File

@@ -14,8 +14,8 @@ const baseOperation: CustomWorldAgentOperationRecord = {
operationId: 'operation-1', operationId: 'operation-1',
type: 'draft_foundation', type: 'draft_foundation',
status: 'running', status: 'running',
phaseLabel: '生成世界底稿', phaseLabel: '生成场景角色',
phaseDetail: '正在根据已确认锚点编译第一版世界结构。', phaseDetail: '正在生成场景角色第 1 / 1 批,当前已完成 0/4。',
progress: 38, progress: 38,
error: null, error: null,
}; };
@@ -96,7 +96,7 @@ const baseSession: CustomWorldAgentSessionSnapshot = {
updatedAt: '2026-04-14T10:00:00.000Z', updatedAt: '2026-04-14T10:00:00.000Z',
}; };
test('maps running draft_foundation operation to legacy generation progress', () => { test('maps running draft_foundation operation to refined generation progress steps', () => {
const progress = buildAgentDraftFoundationGenerationProgress( const progress = buildAgentDraftFoundationGenerationProgress(
baseOperation, baseOperation,
1_000, 1_000,
@@ -104,21 +104,51 @@ test('maps running draft_foundation operation to legacy generation progress', ()
); );
expect(progress).not.toBeNull(); expect(progress).not.toBeNull();
expect(progress?.phaseId).toBe('foundation'); expect(progress?.phaseId).toBe('story-outline');
expect(progress?.batchLabel).toBe('生成世界底稿'); expect(progress?.batchLabel).toBe('生成场景角色');
expect(progress?.overallProgress).toBe(38); expect(progress?.overallProgress).toBe(38);
expect(progress?.elapsedMs).toBe(4_000); expect(progress?.elapsedMs).toBe(4_000);
expect(progress?.estimatedRemainingMs).toBeGreaterThan(0); expect(progress?.estimatedRemainingMs).toBeGreaterThan(0);
expect(progress?.steps).toHaveLength(13);
expect(progress?.steps.map((step) => step.status)).toEqual([ expect(progress?.steps.map((step) => step.status)).toEqual([
'completed',
'completed',
'completed', 'completed',
'active', 'active',
'pending', 'pending',
'pending', 'pending',
'pending',
'pending',
'pending',
'pending',
'pending',
'pending',
'pending',
]); ]);
expect(isDraftFoundationOperationRunning(baseOperation)).toBe(true); expect(isDraftFoundationOperationRunning(baseOperation)).toBe(true);
}); });
test('marks all legacy progress steps complete when draft foundation finishes', () => { test('maps auto asset phases to refined generation progress steps', () => {
const progress = buildAgentDraftFoundationGenerationProgress(
{
...baseOperation,
phaseLabel: '生成幕背景图',
phaseDetail: '正在生成幕背景图 3/6潮汐码头 · 封锁加压。',
progress: 99,
},
1_000,
5_000,
);
expect(progress?.phaseId).toBe('act-backgrounds');
expect(progress?.batchLabel).toBe('生成幕背景图');
expect(progress?.steps.filter((step) => step.status === 'completed')).toHaveLength(
10,
);
expect(progress?.steps[10]?.status).toBe('active');
});
test('marks all refined progress steps complete when draft foundation finishes', () => {
const progress = buildAgentDraftFoundationGenerationProgress( const progress = buildAgentDraftFoundationGenerationProgress(
{ {
...baseOperation, ...baseOperation,
@@ -138,6 +168,28 @@ test('marks all legacy progress steps complete when draft foundation finishes',
); );
}); });
test('keeps failed draft foundation progress on explicit failure state instead of pretending it is still compiling cards', () => {
const progress = buildAgentDraftFoundationGenerationProgress(
{
...baseOperation,
status: 'failed',
phaseLabel: '底稿生成失败',
phaseDetail: '角色主形象补齐失败,但世界底稿尚未完成写回。',
progress: 100,
error: 'dashscope timeout',
},
1_000,
5_000,
);
expect(progress?.phaseId).toBe('failed');
expect(progress?.phaseLabel).toBe('底稿生成失败');
expect(progress?.phaseDetail).toContain('角色主形象补齐失败');
expect(progress?.steps.some((step) => step.label === '编译草稿卡')).toBe(true);
expect(progress?.steps.some((step) => step.status === 'active')).toBe(false);
expect(progress?.steps.filter((step) => step.status === 'completed').length).toBeGreaterThan(0);
});
test('builds readable draft setting text from creator intent first', () => { test('builds readable draft setting text from creator intent first', () => {
const settingText = buildAgentDraftFoundationSettingText(baseSession); const settingText = buildAgentDraftFoundationSettingText(baseSession);

View File

@@ -193,32 +193,120 @@ export function buildAgentDraftFoundationAnchorEntries(
].filter((entry) => entry.value.trim()); ].filter((entry) => entry.value.trim());
} }
type AgentDraftFoundationStepDefinition = {
id: string;
label: string;
detail: string;
matchers: string[];
minProgress: number;
};
type AgentDraftFoundationFailedStep = {
id: string;
label: string;
detail: string;
};
// 这里按真实服务端 phaseLabel 归并步骤,避免把草稿生成硬折成 4 个失真的阶段。
const AGENT_DRAFT_FOUNDATION_STEP_DEFINITIONS = [ const AGENT_DRAFT_FOUNDATION_STEP_DEFINITIONS = [
{ {
id: 'queue', id: 'queue',
label: '接收生成请求', label: '接收生成请求',
detail: '正在锁定当前已确认的世界锚点与草稿范围。', detail: '正在校验当前锚点并准备底稿编译链路。',
matchers: ['已接收请求'],
minProgress: 0,
}, },
{ {
id: 'foundation', id: 'framework',
label: '生成世界底稿', label: '整理世界骨架',
detail: '正在根据世界核心、关系种子与冲突线编排第一版世界结构。', detail: '正在生成第一版世界框架、主题与核心冲突。',
matchers: ['整理世界骨架', '生成世界底稿'],
minProgress: 12,
},
{
id: 'playable-outline',
label: '生成可扮演角色',
detail: '正在补出玩家视角角色的首轮名单与定位。',
matchers: ['生成可扮演角色'],
minProgress: 16,
},
{
id: 'story-outline',
label: '生成场景角色',
detail: '正在整理关键 NPC、势力接口人与关系入口。',
matchers: ['生成场景角色'],
minProgress: 30,
},
{
id: 'landmark-seed',
label: '生成关键场景',
detail: '正在补出第一批关键场景与地点骨架。',
matchers: ['生成关键场景'],
minProgress: 44,
},
{
id: 'landmark-network',
label: '建立场景连接',
detail: '正在串联地点关系、线程挂钩与角色分布。',
matchers: ['建立场景连接'],
minProgress: 56,
},
{
id: 'playable-detail',
label: '补全可扮演角色细节',
detail: '正在补全可扮演角色的叙事基础与档案细节。',
matchers: ['补全可扮演角色'],
minProgress: 66,
},
{
id: 'story-detail',
label: '补全场景角色细节',
detail: '正在补全场景角色的叙事基础与档案细节。',
matchers: ['补全场景角色'],
minProgress: 84,
},
{
id: 'finalize',
label: '编译世界底稿',
detail: '正在把分批生成结果汇总成第一版可浏览的世界底稿。',
matchers: ['编译世界底稿'],
minProgress: 97,
},
{
id: 'role-visuals',
label: '生成角色主形象',
detail: '正在为关键角色补主形象预览资源。',
matchers: ['生成角色主形象'],
minProgress: 97,
},
{
id: 'act-backgrounds',
label: '生成幕背景图',
detail: '正在为场景章节的每一幕补背景图预览资源。',
matchers: ['生成幕背景图'],
minProgress: 98,
}, },
{ {
id: 'cards', id: 'cards',
label: '编译草稿卡', label: '编译草稿卡',
detail: '正在整理世界卡、角色卡地点卡的摘要和详情。', detail: '正在整理世界卡、角色卡地点卡与详情结构。',
matchers: ['编译草稿卡'],
minProgress: 99,
}, },
{ {
id: 'workspace', id: 'workspace',
label: '准备精修工作区', label: '准备精修工作区',
detail: '正在写回草稿数据,并切回可继续精修的工作区。', detail: '正在写回草稿数据,并切回可继续精修的工作区。',
matchers: ['世界底稿已生成'],
minProgress: 100,
}, },
] as const satisfies ReadonlyArray<{ ] as const satisfies ReadonlyArray<AgentDraftFoundationStepDefinition>;
id: string;
label: string; const AGENT_DRAFT_FOUNDATION_FAILED_STEP = {
detail: string; id: 'failed',
}>; label: '生成失败',
detail: '这一轮世界草稿没有编译完成,可以返回工作区补充设定后重试。',
} as const satisfies AgentDraftFoundationFailedStep;
function clampProgress(progress: number | null | undefined) { function clampProgress(progress: number | null | undefined) {
if (typeof progress !== 'number' || Number.isNaN(progress)) { if (typeof progress !== 'number' || Number.isNaN(progress)) {
@@ -228,29 +316,68 @@ function clampProgress(progress: number | null | undefined) {
return Math.max(0, Math.min(100, Math.round(progress))); return Math.max(0, Math.min(100, Math.round(progress)));
} }
function resolveAgentDraftFoundationStepIndexByProgress(progress: number) {
let matchedIndex = 0;
for (
let index = 0;
index < AGENT_DRAFT_FOUNDATION_STEP_DEFINITIONS.length - 1;
index += 1
) {
if (progress >= AGENT_DRAFT_FOUNDATION_STEP_DEFINITIONS[index].minProgress) {
matchedIndex = index;
}
}
return matchedIndex;
}
function resolveAgentDraftFoundationStepIndex( function resolveAgentDraftFoundationStepIndex(
operation: CustomWorldAgentOperationRecord, operation: CustomWorldAgentOperationRecord,
) { ) {
const progress = clampProgress(operation.progress); const progress = clampProgress(operation.progress);
const phaseLabel = operation.phaseLabel.trim(); const phaseLabel = operation.phaseLabel.trim();
if ( if (operation.status === 'completed' || phaseLabel.includes('世界底稿已生成')) {
operation.status === 'completed' || return AGENT_DRAFT_FOUNDATION_STEP_DEFINITIONS.length - 1;
phaseLabel.includes('世界底稿已生成') || }
progress >= 90
for (
let index = AGENT_DRAFT_FOUNDATION_STEP_DEFINITIONS.length - 2;
index >= 0;
index -= 1
) { ) {
return 3; const step = AGENT_DRAFT_FOUNDATION_STEP_DEFINITIONS[index];
if (step.matchers.some((matcher) => phaseLabel.includes(matcher))) {
return index;
}
} }
if (phaseLabel.includes('编译草稿卡') || progress >= 60) { return resolveAgentDraftFoundationStepIndexByProgress(progress);
return 2; }
function resolveAgentDraftFoundationFailedStep(
operation: CustomWorldAgentOperationRecord,
) {
if (operation.status !== 'failed') {
return null;
} }
if (phaseLabel.includes('生成世界底稿') || progress >= 25) { const phaseLabel = operation.phaseLabel.trim();
return 1; const phaseDetail = operation.phaseDetail.trim();
} const error = operation.error?.trim() ?? '';
return 0; return {
id: AGENT_DRAFT_FOUNDATION_FAILED_STEP.id,
label:
phaseLabel ||
error ||
AGENT_DRAFT_FOUNDATION_FAILED_STEP.label,
detail:
phaseDetail ||
error ||
AGENT_DRAFT_FOUNDATION_FAILED_STEP.detail,
} satisfies AgentDraftFoundationFailedStep;
} }
function buildAgentDraftFoundationSteps( function buildAgentDraftFoundationSteps(
@@ -259,8 +386,12 @@ function buildAgentDraftFoundationSteps(
) { ) {
return AGENT_DRAFT_FOUNDATION_STEP_DEFINITIONS.map((step, index) => { return AGENT_DRAFT_FOUNDATION_STEP_DEFINITIONS.map((step, index) => {
const isCompleted = const isCompleted =
operation.status === 'completed' || index < activeStepIndex; operation.status === 'completed' ||
const isActive = !isCompleted && index === activeStepIndex; (operation.status === 'failed'
? index < activeStepIndex
: index < activeStepIndex);
const isActive =
operation.status !== 'failed' && !isCompleted && index === activeStepIndex;
return { return {
id: step.id, id: step.id,
@@ -326,7 +457,9 @@ export function buildAgentDraftFoundationGenerationProgress(
nowMs, nowMs,
operation.status, operation.status,
); );
const failedStep = resolveAgentDraftFoundationFailedStep(operation);
const activeStep = const activeStep =
failedStep ??
AGENT_DRAFT_FOUNDATION_STEP_DEFINITIONS[activeStepIndex] ?? AGENT_DRAFT_FOUNDATION_STEP_DEFINITIONS[activeStepIndex] ??
AGENT_DRAFT_FOUNDATION_STEP_DEFINITIONS[0]; AGENT_DRAFT_FOUNDATION_STEP_DEFINITIONS[0];

View File

@@ -10,7 +10,15 @@ type CampProfileSeed = Pick<
> & { > & {
camp?: Pick< camp?: Pick<
CustomWorldCampScene, CustomWorldCampScene,
'name' | 'description' | 'dangerLevel' | 'imageSrc' | 'id'
| 'name'
| 'description'
| 'visualDescription'
| 'dangerLevel'
| 'imageSrc'
| 'sceneNpcIds'
| 'connections'
| 'narrativeResidues'
> | null; > | null;
}; };
@@ -81,9 +89,13 @@ export function buildFallbackCustomWorldCampScene(
const fallbackName = buildFallbackCampName(profile); const fallbackName = buildFallbackCampName(profile);
return { return {
id: 'custom-scene-camp',
name: fallbackName, name: fallbackName,
description: buildFallbackCampDescription(profile, fallbackName), description: buildFallbackCampDescription(profile, fallbackName),
dangerLevel: 'low', dangerLevel: 'low',
sceneNpcIds: [],
connections: [],
narrativeResidues: null,
}; };
} }
@@ -94,9 +106,18 @@ export function resolveCustomWorldCampScene(
const camp = profile.camp; const camp = profile.camp;
return { return {
id: camp?.id?.trim() || fallback.id,
name: camp?.name?.trim() || fallback.name, name: camp?.name?.trim() || fallback.name,
description: camp?.description?.trim() || fallback.description, description: camp?.description?.trim() || fallback.description,
visualDescription: camp?.visualDescription?.trim() || undefined,
dangerLevel: camp?.dangerLevel?.trim() || fallback.dangerLevel, dangerLevel: camp?.dangerLevel?.trim() || fallback.dangerLevel,
imageSrc: camp?.imageSrc?.trim() || undefined, imageSrc: camp?.imageSrc?.trim() || undefined,
sceneNpcIds: Array.isArray(camp?.sceneNpcIds)
? [...new Set(camp.sceneNpcIds.map((entry) => entry.trim()).filter(Boolean))]
: fallback.sceneNpcIds,
connections: Array.isArray(camp?.connections)
? camp.connections
: fallback.connections,
narrativeResidues: camp?.narrativeResidues ?? fallback.narrativeResidues,
}; };
} }

View File

@@ -0,0 +1,144 @@
import { describe, expect, it } from 'vitest';
import { WorldType, type CustomWorldProfile } from '../types';
import { resolveCustomWorldCoverPresentation } from './customWorldCover';
function createBaseProfile(): CustomWorldProfile {
return {
id: 'custom-world-cover-test',
settingText: '潮雾群岛',
name: '潮雾群岛',
subtitle: '封面规则测试',
summary: '用于验证默认封面优先级。',
tone: '潮湿、压抑',
playerGoal: '查明旧航道真相。',
templateWorldType: WorldType.WUXIA,
compatibilityTemplateWorldType: WorldType.WUXIA,
majorFactions: [],
coreConflicts: [],
attributeSchema: {
id: 'schema:test',
worldId: 'CUSTOM',
schemaVersion: 1,
schemaName: '测试属性',
generatedFrom: {
worldType: WorldType.CUSTOM,
worldName: '潮雾群岛',
settingSummary: '封面规则测试',
tone: '潮湿、压抑',
conflictCore: '旧航道真相',
},
slots: [],
},
playableNpcs: [
{
id: 'playable-1',
name: '林潮',
title: '守潮人',
role: '可扮演角色',
description: '负责守住第一道进港口。',
backstory: '他在港口旧案里失去过同伴。',
personality: '谨慎克制。',
motivation: '想查清货船去向。',
combatStyle: '借地形换位。',
initialAffinity: 20,
relationshipHooks: ['旧案'],
tags: ['港口'],
backstoryReveal: {
publicSummary: '他对港口格外熟悉。',
chapters: [],
},
skills: [],
initialItems: [],
imageSrc: '/images/roles/linchao.webp',
},
],
storyNpcs: [],
items: [],
camp: {
id: 'camp-1',
name: '守夜营地',
description: '潮线后的临时据点。',
dangerLevel: 'medium',
imageSrc: '/images/camp/camp.webp',
sceneNpcIds: [],
connections: [],
},
landmarks: [
{
id: 'landmark-1',
name: '潮汐码头',
description: '涨潮时会吞掉半截栈桥。',
dangerLevel: 'high',
imageSrc: '/images/landmark/docks.webp',
sceneNpcIds: [],
connections: [],
},
],
sceneChapterBlueprints: [
{
id: 'scene-chapter-1',
sceneId: 'landmark-1',
title: '潮汐码头',
summary: '第一章开局场景。',
linkedThreadIds: [],
linkedLandmarkIds: ['landmark-1'],
acts: [
{
id: 'act-1',
sceneId: 'landmark-1',
title: '雾里靠岸',
summary: '玩家第一次进入港口。',
stageCoverage: ['opening'],
backgroundImageSrc: '/images/scene/act-1.webp',
backgroundAssetId: 'asset-scene-act-1',
encounterNpcIds: [],
primaryNpcId: 'playable-1',
linkedThreadIds: [],
advanceRule: 'after_primary_contact',
actGoal: '拿到第一句真话。',
transitionHook: '下一幕将进入封锁区。',
},
],
},
],
};
}
describe('resolveCustomWorldCoverPresentation', () => {
it('优先使用开局场景第一幕图片作为默认封面底图', () => {
const profile = createBaseProfile();
const result = resolveCustomWorldCoverPresentation(profile);
expect(result.imageSrc).toBe('/images/scene/act-1.webp');
expect(result.renderMode).toBe('scene_with_roles');
expect(result.characterImageSrcs).toEqual(['/images/roles/linchao.webp']);
});
it('当第一幕图片缺失时按营地图与地标图顺序回退', () => {
const profile = createBaseProfile();
profile.sceneChapterBlueprints = [
{
...profile.sceneChapterBlueprints![0],
acts: [
{
...profile.sceneChapterBlueprints![0]!.acts[0]!,
backgroundImageSrc: null,
backgroundAssetId: null,
},
],
},
];
const fallbackToCamp = resolveCustomWorldCoverPresentation(profile);
expect(fallbackToCamp.imageSrc).toBe('/images/camp/camp.webp');
profile.camp = {
...profile.camp!,
imageSrc: '',
};
const fallbackToLandmark = resolveCustomWorldCoverPresentation(profile);
expect(fallbackToLandmark.imageSrc).toBe('/images/landmark/docks.webp');
});
});

View File

@@ -14,7 +14,17 @@ export type CustomWorldCoverPresentation = {
sourceType: CustomWorldCoverProfile['sourceType']; sourceType: CustomWorldCoverProfile['sourceType'];
}; };
function resolveOpeningSceneFirstActImageSrc(profile: CustomWorldProfile) {
return profile.sceneChapterBlueprints?.[0]?.acts?.[0]?.backgroundImageSrc?.trim() || null;
}
function resolveOpeningSceneImageSrc(profile: CustomWorldProfile) { function resolveOpeningSceneImageSrc(profile: CustomWorldProfile) {
// 默认封面优先取开局场景第一幕图,避免草稿页与作品库继续沿用旧的营地兜底策略。
const firstActImageSrc = resolveOpeningSceneFirstActImageSrc(profile);
if (firstActImageSrc) {
return firstActImageSrc;
}
const campImageSrc = profile.camp?.imageSrc?.trim() || ''; const campImageSrc = profile.camp?.imageSrc?.trim() || '';
if (campImageSrc) { if (campImageSrc) {
return campImageSrc; return campImageSrc;

View File

@@ -1,5 +1,5 @@
import { requestJson } from './apiClient'; import { requestJson } from './apiClient';
import type { CustomWorldProfile } from '../types'; import type { CustomWorldCoverCropRect, CustomWorldProfile } from '../types';
const CUSTOM_WORLD_COVER_API_BASE = '/api/runtime/custom-world'; const CUSTOM_WORLD_COVER_API_BASE = '/api/runtime/custom-world';
@@ -26,6 +26,7 @@ export interface UploadCustomWorldCoverImageRequest {
profileId: string; profileId: string;
worldName: string; worldName: string;
imageDataUrl: string; imageDataUrl: string;
cropRect: CustomWorldCoverCropRect;
} }
export async function generateCustomWorldCoverImage( export async function generateCustomWorldCoverImage(

Some files were not shown because too many files have changed in this diff Show More