This commit is contained in:
2026-04-21 10:30:12 +08:00
parent ae28dab032
commit 13bc79306f
49 changed files with 3691 additions and 1357 deletions

View File

@@ -127,6 +127,31 @@
这属于仓库当前既有工程问题,不是本批次引入的新断裂。
## 4.1 2026-04-21 补充修正:会话探测 401 自触发循环
在这批收口完成后,前端又暴露出一条更细的鉴权恢复回路问题:
1. `AuthGate` 启动时会调用 `getCurrentAuthUser()` 探测现有会话
2. `/api/auth/me` 返回 `401` 时,`apiClient.ts` 会默认广播一次 `AUTH_STATE_EVENT`
3. `AuthGate` 自己又监听这个事件并重新 `hydrate()`
4. 最终形成 `hydrate -> /auth/me 401 -> emit -> hydrate` 的自触发循环
这条链的问题不在“是否允许 401”而在
**会话探测请求把“未登录态探测”错误地当成了“全局登录态变更”。**
因此这里补了一条更细粒度的约束:
1. `apiClient.ts` 新增 `notifyAuthStateChange` 选项,默认仍保持原有广播行为
2. `getCurrentAuthUser()` 作为会话探测请求,显式关闭这类 401 广播
3. 真实登录、登出、刷新成功后,仍保留全局鉴权变更通知
这样修完后:
1. `AuthGate` 仍会优先尝试服务端会话恢复
2. 无会话时会正常落回未登录分支
3. 不会因为探测型 401 把自己重新唤醒并刷爆控制台
---
## 5. 本批次完成后的实际收益

View File

@@ -0,0 +1,99 @@
# Agent 对话框与结果页精修职责边界修正
更新时间:`2026-04-21`
## 1. 结论
本次修正把“Agent 对话框”和“结果页精修”重新拆清楚:
1. `CustomWorldAgentWorkspace` 只负责八锚点信息收集、八锚点进度展示、八锚点完成后的“整理世界底稿”动作。
2. “精修”不是 Agent 对话框里的概念,不再通过 Agent 建议动作进入角色、地点、世界总卡的局部修整。
3. 已经生成底稿的草稿,从创作中心点击后进入结果页继续完善。
4. 尚未生成底稿的草稿,从创作中心点击后才恢复 Agent 对话框继续补齐八锚点。
5. 结果页负责成稿后的编辑、补全、进入世界前确认和自动保存,并通过 `sync_result_profile` 回写到当前 Agent session。
一句话:
**Agent 收八锚点,结果页做精修。**
---
## 2. 为什么要修正
旧实现把 `object_refining` 草稿卡片显示成“继续精修”,但点击后直接恢复 Agent 工作区。
这个行为会让用户产生两个误解:
1. 以为精修是 Agent 对话框里的下一阶段。
2. 以为 Agent 对话框不仅负责收集八锚点,还负责后续对象级编辑。
这和当前产品边界不一致。Agent 对话框应该保持轻量,只用于拿到足够稳定的八锚点输入;对象、场景、封面、世界档案的修整都应该在结果页完成。
---
## 3. 当前落地规则
### 3.1 创作中心草稿点击分流
`custom-world/works` 返回 `agent_session` 草稿后,前端按草稿是否已有底稿内容分流:
1. `playableNpcCount <= 0 && landmarkCount <= 0`
- 视为八锚点仍未整理成底稿。
- 点击进入 `agent-workspace`
2. `playableNpcCount > 0 || landmarkCount > 0`
- 视为已有可编辑底稿。
- 点击读取对应 Agent session编译为 `CustomWorldProfile`,进入 `custom-world-result`
### 3.2 Agent 对话框动作边界
Agent 会话建议动作只保留:
1. 总结当前设定 / 总结当前世界底稿。
2. 八锚点准备完成后的“整理一版世界底稿”。
不再在 Agent 会话快照里继续生成或兼容展示:
1. `refine_focus_target`
2. “精修角色”
3. “继续补地点”
4. “先看世界总卡”
旧 session 快照如果仍带有 `refine_focus_target`,服务端兼容层会过滤掉,避免旧数据把精修入口重新塞回 Agent 对话框。
### 3.3 结果页精修边界
Agent 来源结果页不再是冻结预览态。
当前允许在结果页继续进行成稿精修,包括:
1. 编辑世界信息。
2. 编辑角色、场景、封面等对象档案。
3. 删除或调整已有对象。
4. 自动保存到作品草稿。
5. 进入世界前通过 `sync_result_profile` 写回 Agent session。
为了保持主链简洁Agent 来源结果页仍不重新打开“通过 Agent 对话精修对象”的入口。
---
## 4. 对历史文档口径的覆盖
这份文档覆盖 [AGENT_RESULT_PROFILE_SYNC_PHASE3_2026-04-20.md](./AGENT_RESULT_PROFILE_SYNC_PHASE3_2026-04-20.md) 中“Agent 来源结果页冻结为预览态”的阶段性口径。
新的主口径是:
1. Agent 来源结果页可以编辑,因为精修本来就应该发生在结果页。
2. 需要收紧的是 Agent 对话框,不是结果页。
3. 结果页编辑后仍必须同步回 Agent session保持进入世界前的数据真相源一致。
---
## 5. 验收标准
本次修正完成后应满足:
1. 创作中心已有底稿草稿按钮文案为“继续完善”,点击进入结果页。
2. 创作中心未成稿草稿按钮仍为“继续创作”,点击进入 Agent 对话框。
3. Agent 对话框不出现“精修角色 / 补地点 / 看世界总卡”类对象精修入口。
4. Agent 来源结果页可以打开编辑弹窗进行精修。
5. 返回创作从结果页回到创作中心,不回到 Agent 对话框。

View File

@@ -0,0 +1,96 @@
# Agent 草稿结果页资产合并修复 2026-04-21
更新时间:`2026-04-21`
## 1. 问题现象
当前创作流程里,用户在“生成草稿”后反馈:
1. 角色主图没有稳定出现在结果页
2. 场景背景图有时可见,有时角色图缺失
3. 自动保存后的作品库条目里,分幕图可能已经存在,但场景角色主图仍为空
## 2. 本次真实排查结论
本轮不是单一的“没写数据库”问题,而是 `agent draft -> result profile` 桥接层存在一类更隐蔽的集合漂移问题。
排查后确认:
1. 最新 `custom_world_sessions.payload_json` 里的 `draftProfile.storyNpcs[].imageSrc` 已经存在
2. 最新 `draftProfile.sceneChapters[].acts[].backgroundImageSrc` 也已经存在
3. 对应图片文件也真实存在于仓库根 `public/`
4. 最新 `custom_world_profiles.payload_json` 里,分幕图通常已保存成功
5. 但场景角色主图可能仍为空
根因在于:
1. 结果页桥接层在 `draftProfile.legacyResultProfile` 存在时,仍把 `legacyResultProfile` 视为主列表
2. 旧逻辑只会按 `id``draftProfile` 里的图片字段回贴到 `legacyResultProfile`
3. 一旦后续草稿精修导致 `draftProfile` 的角色集合、角色 id 或角色命名发生漂移
4.`legacyResultProfile` 就会继续主导结果页和自动保存对象列表
5. 最新角色主图虽然已在 `draftProfile` 里生成完成,但会因为匹配失败而被整批吞掉
这类问题在场景角色上最明显,因为角色集合最容易在后续精修中替换。
## 3. 修复策略
本轮在:
- `src/services/customWorldAgentDraftResult.ts`
调整桥接规则:
1. `legacyResultProfile` 仍保留,继续提供运行时富字段
2. 但角色、场景、分幕等对象集合不再默认由 `legacyResultProfile` 主导
3. 最新 `draftProfile` 成为结果页对象列表的主来源
4. `legacyResultProfile` 只负责给命中的对象补运行时富字段
5. 匹配优先级为:
- 先按 `id`
- 再按名称兜底
具体规则:
1. `playableNpcs`:以最新 draft 集合为主legacy 只补富字段与旧运行时字段
2. `storyNpcs`:同上,避免旧角色列表吞掉新角色主图
3. `sceneChapterBlueprints`:以最新 draft 幕列表为主legacy 只补章节/幕已有运行时字段
4. `landmarks`:优先更新最新 draft 命中的场景对象,但保留 legacy 中未被命中的剩余运行时场景,避免丢连接与残留信息
5. `camp`:保留 legacy 基础信息,但优先取 draft 最新图片字段
## 4. 修复后的链路意义
修复后:
1. 草稿自动资产服务生成的角色主图不会再因为旧 `legacyResultProfile` 的角色集合过时而丢失
2. 分幕图继续可以稳定进入结果页与自动保存
3. 作品库自动保存时,结果页编译出的 profile 更接近“当前草稿真实快照”,而不是历史 legacy 快照
## 5. 新增验证
本轮补了前端桥接测试:
- `src/services/customWorldAgentDraftResult.test.ts`
新增验证点:
1.`draftProfile.storyNpcs``legacyResultProfile.storyNpcs` 集合漂移时
2. 结果页仍应优先展示最新 draft 角色
3. 最新角色主图与最新分幕图不能被旧 legacy 快照吞掉
## 6. 当前状态
本轮修复后,本地已验证:
1. `src/services/customWorldAgentDraftResult.test.ts`
2. `src/components/CustomWorldResultView.test.tsx`
3. `src/components/game-shell/PreGameSelectionFlow.agent.interaction.test.tsx`
4. `npm run check:encoding`
均通过。
## 7. 后续建议
这次问题再次说明:
1. `legacyResultProfile` 继续长期作为结果页主列表来源风险很高
2. 只做“按 id 回贴图片字段”在对象集合会漂移的链路里不够稳
3. 后续如果继续推进后端单一真相源,应优先把结果页对象集合完全改为服务端最新 compile/preview 结果,而不是继续让前端桥接层承担最终裁决

View File

@@ -0,0 +1,119 @@
# 创作流程草稿/图片/动作自动保存数据库检查 2026-04-21
更新时间:`2026-04-21`
## 1. 本次检查范围
本次检查只聚焦当前创作流程里下面这条链路:
`结果页前端编辑 -> 自动保存 -> Agent session 主链同步 -> 作品库落库`
重点核对三类内容:
1. 草稿文本类修改
2. 生成后的角色图片、地点图片、分幕图
3. 角色动作相关资产字段
## 2. 当前实际自动保存链路
当前前端主入口在:
- `src/components/game-shell/PreGameSelectionFlow.tsx`
实际行为如下:
1. 结果页编辑统一通过 `onProfileChange` 更新 `generatedCustomWorldProfile`
2. 当结果页停留在 `custom-world-result` 阶段时,前端会对 profile 做防抖自动保存
3. 如果当前结果页来源是 `agent-draft`,自动保存前会先执行 `sync_result_profile`
4. `sync_result_profile` 完成后,前端不直接保存旧内存 profile而是优先保存“从最新 session 重编译出的 profile”
5. 作品库保存最终走 `PUT /api/runtime/custom-world-library/:profileId`
6. Express 后端通过 `runtimeRepository.upsertCustomWorldProfile(...)` 把 profile 写入 `custom_world_profiles.payload_json`
所以数据库层本身是有正常落库能力的。
## 3. 本次检查前确认成立的部分
以下能力在本次检查前已经成立:
1. 结果页普通草稿字段编辑会触发自动保存
2. 自动保存会真正调用后端作品库接口并更新数据库
3. 返回创作、进入世界两条路径也会优先同步 Agent session
4. `legacyResultProfile` 已作为阶段一桥接快照保留在 session 中
## 4. 本次发现的真实风险
风险不在数据库写入本身,而在:
`sync_result_profile -> session 重编译结果页 profile`
此前 `sync_result_profile` 只回写:
1. 基础摘要字段
2. `legacyResultProfile`
但没有把结果页里已经确认过的资产字段同步回 foundation draft 对应节点。
这会导致一个阶段性风险:
1. 用户在结果页换了新的角色图
2. 或者结果页里刚确认了新的动作资产字段
3. 或者结果页里刚确认了新的地点图、分幕图
4. 自动保存前前端先做一次 session 同步
5. 同步完成后又从 session 重编译结果页 profile
6. 重编译过程会把 draft 层旧资产字段再次并入结果 profile
这样就可能出现:
**数据库自动保存成功了,但保存进去的是“被旧 draft 资产字段回退过的版本”,不是用户刚在结果页看到的最新图/动作。**
## 5. 本轮修复
本轮在:
- `server-node/src/services/customWorldAgentOrchestrator.ts`
补了一个收窄修复:
1. `sync_result_profile` 仍然保持阶段一边界,不做整套 runtime -> foundation draft 反解
2. 但会按相同 id把结果页里已确认的资产字段同步回 draft 层已有对象
3. 同步范围包括:
- 角色 `imageSrc`
- 角色 `generatedVisualAssetId`
- 角色 `generatedAnimationSetId`
- 角色 `animationMap`
- 地点 `imageSrc`
- 分幕 `backgroundImageSrc`
- 分幕 `backgroundAssetId`
这样后续再从 session 重编译结果页 profile 时,最新资产字段不会再被旧 draft 值回退。
## 6. 验证补充
本轮补了服务端测试:
- `server-node/src/services/customWorldAgentPhase4.test.ts`
新增验证点:
1. `sync_result_profile` 后,最新角色主图会写回 draft
2. 最新角色动作资产字段会写回 draft
3. 最新地点图会写回 draft
4. 最新分幕图会写回 draft
## 7. 结论
截至本轮修复后,当前创作流程里:
1. 草稿文本修改可以自动保存到数据库
2. 结果页中确认后的角色图、地点图、分幕图可以随自动保存稳定进入数据库
3. 角色动作相关资产字段可以随 session 同步和自动保存稳定保留
但仍需注意:
1. 当前仍是阶段一兼容链路,核心桥接字段仍然是 `legacyResultProfile`
2. 正式发布链 `publish_world` 还没有在当前阶段打通
3. 前端仍依赖 `buildCustomWorldProfileFromAgentDraft()` 作为 session -> 结果页兼容编译层
因此本轮结论是:
**当前“前端修改 -> 自动保存 -> 数据库”主链可用;本次已补上图片与动作资产在 session 重编译阶段的回退风险。**

File diff suppressed because it is too large Load Diff

View File

@@ -2,6 +2,8 @@
更新时间:`2026-04-21`
补充修正:`2026-04-21` 本文档的“草稿恢复优先回 Agent 工作区”和“Agent 来源结果页冻结为预览收口层”属于阶段性收口口径,已被 [AGENT_DIALOG_AND_RESULT_REFINEMENT_BOUNDARY_2026-04-21.md](./AGENT_DIALOG_AND_RESULT_REFINEMENT_BOUNDARY_2026-04-21.md) 覆盖。当前主口径是Agent 对话框只收集八锚点,已有底稿的草稿从创作中心进入结果页继续完善。
## 1. 结论先行
当前这条 Agent 创作流已经完成阶段一到阶段三的主要收口。

View File

@@ -0,0 +1,169 @@
# 前端逻辑后移实施方案2026-04-21
更新时间:`2026-04-21`
## 1. 目标
本方案只回答一件事:
**怎样把当前仍残留在前端的正式运行时逻辑、正式会话真相与正式生成编排,继续收回到 Express 后端。**
这份文档不是泛泛而谈的方向说明,而是直接面向本轮与后续几轮编码落地的实施基线。
---
## 2. 本轮确定的硬边界
根据仓库约束与当前审计结果,本轮继续冻结以下边界:
1. 前端只负责表现、输入采集、临时 UI 状态与服务端结果渲染。
2. 后端负责正式鉴权、正式会话、正式运行时快照、正式任务生成、正式运行时物品意图生成、正式自定义世界生成。
3. 浏览器内不再保存 access token不再把浏览历史作为本地正式真相不再保留正式 quest / runtime item / custom world 生成编排。
4. 运行时主链必须继续向“前端提交意图,后端解释快照并返回展示模型”收敛。
---
## 3. 现状拆分
当前残留问题已经收敛为三批:
### 3.1 第一批:正式真相仍在前端
1. `src/services/apiClient.ts`
- 浏览器仍保存 access token并拼接 `Authorization: Bearer ...`
2. `src/services/authService.ts`
- 登录、微信绑定等流程仍把 access token 当作前端真相
3. `src/components/game-shell/PreGameSelectionFlow.tsx`
- 浏览历史仍是本地写入 + 后端回填的双真相
4. `src/services/platformBrowseHistory.ts`
- 维护浏览历史本地存储、迁移标记与同步状态
### 3.2 第二批:运行时主链仍依赖前端预写快照
1. `src/hooks/story/runtimeStoryCoordinator.ts`
- 在请求 runtime state / runtime action 前,仍先 `PUT /runtime/save/snapshot`
2. `src/hooks/story/npcEncounterActions.ts`
- 待接委托的“更换任务”“放弃任务”仍由前端正式结算
### 3.3 第三批:正式生成编排仍残留在浏览器
1. `src/services/questDirector.ts`
2. `src/services/runtimeItemAiDirector.ts`
3. `src/services/aiService.ts` 的 custom world profile 生成入口
4. `src/services/ai.ts` 中仍保留的浏览器侧 legacy AI orchestration
---
## 4. 分批实施策略
## 4.1 第一批:先收正式真相
### 鉴权
目标状态:
1. 后端通过 HttpOnly Cookie 持有 refresh session 与 access session。
2. 前端请求层不再读写 access token。
3. 前端只监听鉴权状态事件,不解释 token 生命周期。
本批涉及:
1. `server-node/src/auth/accessSessionCookie.ts`
2. `server-node/src/routes/authRoutes.ts`
3. `server-node/src/middleware/auth.ts`
4. `src/services/apiClient.ts`
5. `src/services/authService.ts`
6. `src/components/auth/AuthGate.tsx`
### 浏览历史
目标状态:
1. 浏览历史唯一真相在 `runtimeRepository`
2. 前端不再保留本地浏览历史、迁移标记、同步标记。
3. 浏览历史只通过 `storageService` 读取和写入。
本批涉及:
1. `src/components/game-shell/PreGameSelectionFlow.tsx`
2. `src/components/game-shell/PlatformHomeView.tsx`
3. `src/services/storageService.ts`
4. `src/services/platformBrowseHistory.ts`
## 4.2 第二批:把 runtime story 快照解释权收回后端
目标状态:
1. 前端不再通过单独的 `PUT /runtime/save/snapshot` 预写快照再触发动作。
2. runtime state / runtime action 允许前端提交当前快照上下文,由后端内部决定是否写入、如何解释、何时持久化。
3. NPC 待接委托的 replace / abandon / accept 全部走后端 runtime action。
建议实施方式:
1. 扩展 `packages/shared/src/contracts/story.ts`
- `RuntimeStoryActionRequest` 增加可选 `snapshot`
- 新增 `RuntimeStoryStateRequest`
2. 新增 `POST /api/runtime/story/state/resolve`
3. `storyActionService` 内部统一处理“请求携带快照上下文时的服务端同步”
4.`npc_chat_quest_offer_replace` / `npc_chat_quest_offer_abandon` 接到后端 runtime action
## 4.3 第三批:把正式生成编排收成后端唯一出口
目标状态:
1. `questDirector` 只保留轻量 SDK。
2. `runtimeItemAiDirector` 只保留轻量 SDK。
3. custom world profile 正式生成走后端 route。
4. 浏览器侧 `src/services/ai.ts` 不再承担正式浏览器主链。
建议实施方式:
1. `server-node/src/routes/runtimeRoutes.ts`
-`custom-world/profile` 正式 route
2. `src/services/aiService.ts`
- custom world 入口改走后端
3. `src/services/questDirector.ts`
- 只请求 `/api/runtime/quests/generate`
4. `src/services/runtimeItemAiDirector.ts`
- 只请求 `/api/runtime/items/runtime-intent`
---
## 5. 本轮落地范围
本轮优先完成以下内容:
1. 鉴权 access token 从前端 localStorage 后移到后端 Cookie。
2. 浏览历史从前端本地真相后移到后端唯一真相。
3. custom world profile 正式生成入口补齐后端 route并把前端收成 SDK。
4. `questDirector` / `runtimeItemAiDirector` 收缩为前端 SDK。
5. runtime story contract 开始补“随请求提交快照上下文”的后端承接能力,并把 NPC 待接委托 replace / abandon 接到后端。
---
## 6. 验收标准
### 第一批验收
1. 浏览器中不再保存 access token。
2. `fetchWithApiAuth` 不再拼接 Bearer token。
3. 浏览历史仅通过远端接口读写。
4. `src/services/platformBrowseHistory.ts` 不再是正式链路依赖。
### 第二批验收
1. `runtimeStoryCoordinator.ts` 不再在动作前独立 `PUT /runtime/save/snapshot`
2. `NPC` 待接委托 replace / abandon / accept 都以后端返回结果为准。
### 第三批验收
1. `questDirector.ts``runtimeItemAiDirector.ts` 不再保留正式 fallback orchestration。
2. custom world profile 的浏览器正式入口不再直接 import legacy `./ai`
---
## 7. 一句话结论
这轮迁移的重点不是“把几个 helper 挪到 server-node 目录”,而是:
**把前端里仍然承担正式真相、正式运行时解释和正式生成编排的那一层职责,继续收回到 Express 后端。**

View File

@@ -4,6 +4,7 @@
## 文档列表
- [FRONTEND_TO_BACKEND_MIGRATION_EXECUTION_PLAN_2026-04-21.md](./FRONTEND_TO_BACKEND_MIGRATION_EXECUTION_PLAN_2026-04-21.md)把鉴权、浏览历史、runtime story 快照、NPC 待接委托与正式生成编排继续后移到 Express 后端的实施方案与验收口径。
- [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/` 的目录方案、兼容策略与后续新增规则。
- [CUSTOM_WORLD_DRAFT_GENERATION_FAILURE_ANALYSIS_AND_FIX_2026-04-20.md](./CUSTOM_WORLD_DRAFT_GENERATION_FAILURE_ANALYSIS_AND_FIX_2026-04-20.md):世界草稿生成失败后等待页误显示为“卡在编译草稿卡”的根因拆解、主链与增强链路边界,以及本次修复策略。
@@ -12,7 +13,9 @@
- [AGENT_RESULT_PROFILE_SYNC_PHASE1_2026-04-20.md](./AGENT_RESULT_PROFILE_SYNC_PHASE1_2026-04-20.md):阶段一保持结果页深度编辑能力不变,同时把结果页完整世界快照同步回 Agent session 主链的方案说明。
- [AGENT_RESULT_PROFILE_SYNC_PHASE2_2026-04-20.md](./AGENT_RESULT_PROFILE_SYNC_PHASE2_2026-04-20.md):阶段二把平台创作入口统一到聚合作品列表,并收紧 Agent 结果页的新增入口职责边界。
- [AGENT_RESULT_PROFILE_SYNC_PHASE3_2026-04-20.md](./AGENT_RESULT_PROFILE_SYNC_PHASE3_2026-04-20.md):阶段三继续降级旧 pipeline让创作中心只认 Agent 草稿与已发布作品,并把 Agent 结果页冻结为预览收口层。
- [AGENT_DIALOG_AND_RESULT_REFINEMENT_BOUNDARY_2026-04-21.md](./AGENT_DIALOG_AND_RESULT_REFINEMENT_BOUNDARY_2026-04-21.md):修正 Agent 对话框与结果页职责边界,明确 Agent 只收集八锚点,已有底稿的精修进入结果页完成。
- [CURRENT_AGENT_CREATION_FLOW_STAGE4_CLEANUP_CHECK_2026-04-21.md](./CURRENT_AGENT_CREATION_FLOW_STAGE4_CLEANUP_CHECK_2026-04-21.md):对照当前优化计划核查四阶段完成度,并明确这轮只允许物理删除旧 `custom-world/sessions` 世界生成链,不误伤 Agent 主链与已保存作品兼容编辑链。
- [CREATION_FLOW_CHAIN_REFACTOR_EXECUTION_PLAN_2026-04-21.md](./CREATION_FLOW_CHAIN_REFACTOR_EXECUTION_PLAN_2026-04-21.md):梳理当前创作入口到结果页自动保存再到进入世界的全链前后端脚本地图,并给出文件级重构拆分方案、目标分层与阶段验收标准。
- [CREATION_PAGE_MOBILE_UI_FIX_2026-04-21.md](./CREATION_PAGE_MOBILE_UI_FIX_2026-04-21.md):创作页移动端底部 Tab、亮色主题 token 与滚动权责修复记录。
- [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 运行时后端的技术栈、入口、鉴权、存储与接口知识图谱。

View File

@@ -78,7 +78,8 @@ export type AuthPhoneChangeResponse = {
};
export type AuthRefreshResponse = {
token: string;
ok: true;
token?: string;
};
export type AuthSessionSummary = {

View File

@@ -325,6 +325,9 @@ export const TASK6_RUNTIME_FUNCTION_IDS = [
'forge_reforge',
'inventory_use',
'npc_gift',
'npc_chat_quest_offer_abandon',
'npc_chat_quest_offer_replace',
'npc_chat_quest_offer_view',
'npc_quest_accept',
'npc_quest_turn_in',
'npc_trade',
@@ -360,6 +363,9 @@ export type RuntimeStoryOptionInteraction =
| 'help'
| 'fight'
| 'leave'
| 'quest_offer_abandon'
| 'quest_offer_replace'
| 'quest_offer_view'
| 'recruit'
| 'spar'
| 'trade'
@@ -481,7 +487,22 @@ export type RuntimeStoryPatch =
};
export type RuntimeStoryActionRequest =
RuntimeActionRequest<RuntimeStoryChoiceAction>;
RuntimeActionRequest<RuntimeStoryChoiceAction> & {
snapshot?: SavedGameSnapshotInput;
};
export type RuntimeStoryStateRequest<
TSnapshotGameState = JsonObject,
TSnapshotCurrentStory = JsonObject,
> = {
sessionId: string;
clientVersion?: number;
snapshot?: SavedGameSnapshotInput<
TSnapshotGameState,
string,
TSnapshotCurrentStory
>;
};
export type RuntimeStoryActionResponse<
TSnapshotGameState = JsonObject,

View File

@@ -0,0 +1,84 @@
import type { Request, Response } from 'express';
import type { AppConfig } from '../config.js';
function buildCookieParts(
config: AppConfig,
value: string,
options: {
maxAgeSeconds: number;
},
) {
const parts = [
`${config.authSession.accessCookieName}=${encodeURIComponent(value)}`,
`Path=${config.authSession.accessCookiePath}`,
'HttpOnly',
`SameSite=${config.authSession.accessCookieSameSite}`,
`Max-Age=${Math.max(0, Math.floor(options.maxAgeSeconds))}`,
];
if (config.authSession.accessCookieSecure) {
parts.push('Secure');
}
return parts.join('; ');
}
function appendSetCookieHeader(response: Response, cookieValue: string) {
const currentHeader = response.getHeader('Set-Cookie');
if (!currentHeader) {
response.setHeader('Set-Cookie', cookieValue);
return;
}
if (Array.isArray(currentHeader)) {
response.setHeader('Set-Cookie', [...currentHeader, cookieValue]);
return;
}
response.setHeader('Set-Cookie', [String(currentHeader), cookieValue]);
}
export function setAccessSessionCookie(
response: Response,
config: AppConfig,
token: string,
maxAgeSeconds: number,
) {
appendSetCookieHeader(
response,
buildCookieParts(config, token, {
maxAgeSeconds,
}),
);
}
export function clearAccessSessionCookie(response: Response, config: AppConfig) {
appendSetCookieHeader(
response,
buildCookieParts(config, '', {
maxAgeSeconds: 0,
}),
);
}
export function readAccessSessionToken(request: Request, config: AppConfig) {
const cookieHeader = request.header('cookie')?.trim() || '';
if (!cookieHeader) {
return '';
}
const cookieEntries = cookieHeader.split(';');
for (const entry of cookieEntries) {
const [rawName, ...valueParts] = entry.split('=');
const name = rawName?.trim();
if (name !== config.authSession.accessCookieName) {
continue;
}
const rawValue = valueParts.join('=').trim();
return rawValue ? decodeURIComponent(rawValue) : '';
}
return '';
}

View File

@@ -74,6 +74,11 @@ export type AppConfig = {
mockAvatarUrl: string;
};
authSession: {
accessCookieName: string;
accessCookieTtlSeconds: number;
accessCookieSecure: boolean;
accessCookieSameSite: 'Lax' | 'Strict' | 'None';
accessCookiePath: string;
refreshCookieName: string;
refreshSessionTtlDays: number;
refreshCookieSecure: boolean;
@@ -274,6 +279,11 @@ export function loadConfig(options: LoadConfigOptions = {}): AppConfig {
'AUTH_REFRESH_COOKIE_SAME_SITE',
'Lax',
);
const accessSameSite = readString(
env,
'AUTH_ACCESS_COOKIE_SAME_SITE',
'Lax',
);
return {
nodeEnv,
@@ -484,6 +494,30 @@ export function loadConfig(options: LoadConfigOptions = {}): AppConfig {
mockAvatarUrl: readString(env, 'WECHAT_MOCK_AVATAR_URL', ''),
},
authSession: {
accessCookieName: readString(
env,
'AUTH_ACCESS_COOKIE_NAME',
'genarrative_access_session',
),
accessCookieTtlSeconds: readPositiveInt(
env,
'AUTH_ACCESS_COOKIE_TTL_SECONDS',
7200,
),
accessCookieSecure: readBoolean(
env,
'AUTH_ACCESS_COOKIE_SECURE',
readString(env, 'NODE_ENV', 'development') === 'production',
),
accessCookieSameSite:
accessSameSite === 'None' || accessSameSite === 'Strict'
? (accessSameSite as AppConfig['authSession']['accessCookieSameSite'])
: 'Lax',
accessCookiePath: readString(
env,
'AUTH_ACCESS_COOKIE_PATH',
'/',
),
refreshCookieName: readString(
env,
'AUTH_REFRESH_COOKIE_NAME',

View File

@@ -1,5 +1,6 @@
import type { NextFunction, Request, Response } from 'express';
import { readAccessSessionToken } from '../auth/accessSessionCookie.js';
import { verifyAccessToken } from '../auth/token.js';
import type { AppConfig } from '../config.js';
import { unauthorized } from '../errors.js';
@@ -16,9 +17,10 @@ function readBearerToken(request: Request) {
export function requireJwtAuth(config: AppConfig, userRepository: UserRepository) {
return async (request: Request, _response: Response, next: NextFunction) => {
try {
const token = readBearerToken(request);
const token =
readBearerToken(request) || readAccessSessionToken(request, config);
if (!token) {
throw unauthorized('缺少 Authorization Bearer Token');
throw unauthorized('缺少登录凭证');
}
const claims = await verifyAccessToken(token, config);

View File

@@ -23,6 +23,8 @@ import type {
CustomWorldRoleProfile,
CustomWorldRoleSkill,
RoleAttributeProfile,
SceneActBlueprint,
SceneChapterBlueprint,
WorldAttributeSchema,
WorldAttributeSlot,
WorldType,
@@ -83,6 +85,18 @@ const WORLD_ATTRIBUTE_SLOT_IDS = [
'axis_e',
'axis_f',
] as const;
const SCENE_ACT_STAGES = new Set([
'opening',
'expansion',
'turning_point',
'climax',
'aftermath',
]);
const SCENE_ACT_ADVANCE_RULES = new Set([
'after_primary_contact',
'after_active_step_complete',
'after_chapter_resolution',
]);
export const MIN_CUSTOM_WORLD_PLAYABLE_NPC_COUNT = 5;
export const MIN_CUSTOM_WORLD_TOTAL_NPC_COUNT = 30;
@@ -1434,6 +1448,105 @@ function normalizeItemList(value: unknown) {
.filter((entry) => entry.name && entry.category);
}
function normalizeSceneActStageCoverage(value: unknown) {
const stageCoverage = Array.isArray(value)
? value
.filter((entry): entry is string => typeof entry === 'string')
.map((entry) => entry.trim())
.filter((entry): entry is SceneActBlueprint['stageCoverage'][number] =>
SCENE_ACT_STAGES.has(entry as never),
)
: [];
return [...new Set(stageCoverage)];
}
function normalizeSceneActBlueprint(
value: unknown,
index: number,
sceneId: string,
): SceneActBlueprint | null {
const item =
value && typeof value === 'object'
? (value as Record<string, unknown>)
: null;
if (!item) {
return null;
}
const encounterNpcIds = toStringArray(item.encounterNpcIds);
const stageCoverage = normalizeSceneActStageCoverage(item.stageCoverage);
const advanceRule = toText(item.advanceRule);
const title = toText(item.title);
const summary = toText(item.summary);
if (!title && !summary && encounterNpcIds.length === 0) {
return null;
}
return {
id: toText(item.id) || `saved-scene-act-${sceneId}-${index + 1}`,
sceneId,
title: title || `${index + 1}`,
summary: summary || title || `围绕${sceneId}继续推进`,
stageCoverage:
stageCoverage.length > 0
? stageCoverage
: index === 0
? ['opening']
: ['climax', 'aftermath'],
backgroundImageSrc: toText(item.backgroundImageSrc) || undefined,
backgroundAssetId: toText(item.backgroundAssetId) || undefined,
encounterNpcIds,
primaryNpcId: toText(item.primaryNpcId) || encounterNpcIds[0] || '',
linkedThreadIds: toStringArray(item.linkedThreadIds),
advanceRule: SCENE_ACT_ADVANCE_RULES.has(advanceRule as never)
? (advanceRule as SceneActBlueprint['advanceRule'])
: 'after_active_step_complete',
actGoal: toText(item.actGoal),
transitionHook: toText(item.transitionHook),
};
}
function normalizeSceneChapterBlueprints(value: unknown) {
if (!Array.isArray(value)) {
return null;
}
const normalized = value
.filter(
(entry): entry is Record<string, unknown> =>
Boolean(entry) && typeof entry === 'object' && !Array.isArray(entry),
)
.map((entry, index) => {
const sceneId = toText(entry.sceneId);
if (!sceneId) {
return null;
}
const acts = Array.isArray(entry.acts)
? entry.acts
.map((act, actIndex) =>
normalizeSceneActBlueprint(act, actIndex, sceneId),
)
.filter((act): act is SceneActBlueprint => Boolean(act))
: [];
return {
id: toText(entry.id) || `saved-scene-chapter-${sceneId}-${index + 1}`,
sceneId,
title: toText(entry.title) || toText(entry.sceneName) || sceneId,
summary: toText(entry.summary),
linkedThreadIds: toStringArray(entry.linkedThreadIds),
linkedLandmarkIds: toStringArray(entry.linkedLandmarkIds),
acts,
} satisfies SceneChapterBlueprint;
})
.filter((entry): entry is SceneChapterBlueprint => Boolean(entry));
return normalized.length > 0 ? normalized : null;
}
function normalizeLandmarks(params: {
landmarks: Array<Record<string, unknown>>;
storyNpcs: CustomWorldNpc[];
@@ -1655,6 +1768,9 @@ export function normalizeCustomWorldProfile(
Array.isArray(item.threadContracts)
? (item.threadContracts as Array<Record<string, unknown>>)
: null,
sceneChapterBlueprints: normalizeSceneChapterBlueprints(
item.sceneChapterBlueprints,
),
scenarioPackId: toText(item.scenarioPackId) || null,
campaignPackId: toText(item.campaignPackId) || null,
};

View File

@@ -1,4 +1,5 @@
import type {
RuntimeStoryOptionView,
RuntimeStoryActionRequest,
RuntimeStoryPatch,
} from '../../../../packages/shared/src/contracts/story.js';
@@ -29,6 +30,9 @@ import {
} from '../story/runtimeSession.js';
const SUPPORTED_QUEST_STORY_FUNCTION_IDS = new Set<string>([
'npc_chat_quest_offer_abandon',
'npc_chat_quest_offer_replace',
'npc_chat_quest_offer_view',
'npc_quest_accept',
'npc_quest_turn_in',
]);
@@ -37,6 +41,9 @@ type QuestStoryResolution = {
actionText: string;
resultText: string;
patches: RuntimeStoryPatch[];
storyText?: string;
presentationOptions?: RuntimeStoryOptionView[];
savedCurrentStory?: JsonRecord;
};
type JsonRecord = Record<string, unknown>;
@@ -140,6 +147,144 @@ function readPendingQuestOffer(
return quest as RuntimeQuestLogEntry;
}
function readPendingQuestOfferContext(
currentStory: unknown,
npcKey: string,
) {
if (!isObject(currentStory)) {
return null;
}
const npcChatState = isObject(currentStory.npcChatState)
? currentStory.npcChatState
: null;
const pendingQuestOffer = isObject(npcChatState?.pendingQuestOffer)
? npcChatState.pendingQuestOffer
: null;
const quest = readPendingQuestOffer(currentStory, npcKey);
if (!quest) {
return null;
}
const dialogue = Array.isArray(currentStory.dialogue)
? currentStory.dialogue
.filter((entry) => isObject(entry))
.map((entry) => ({ ...entry }))
: [];
const turnCount =
typeof npcChatState?.turnCount === 'number' &&
Number.isFinite(npcChatState.turnCount)
? Math.max(0, Math.round(npcChatState.turnCount))
: 0;
const customInputPlaceholder =
readString(npcChatState?.customInputPlaceholder) || '输入你想对 TA 说的话';
return {
dialogue,
turnCount,
customInputPlaceholder,
quest,
introText: readString(pendingQuestOffer?.introText),
};
}
function buildNpcChatOption(
encounter: RuntimeEncounter,
actionText: string,
) {
return {
functionId: 'npc_chat',
actionText,
text: actionText,
detailText: '',
interaction: {
kind: 'npc',
npcId: encounter.id ?? encounter.npcName,
action: 'chat',
},
visuals: {
playerAnimation: 'idle',
playerMoveMeters: 0,
playerOffsetY: 0,
playerFacing: 'right',
scrollWorld: false,
monsterChanges: [],
},
} satisfies JsonRecord;
}
function buildPendingQuestOfferOptions(encounter: RuntimeEncounter) {
const npcId = encounter.id ?? encounter.npcName;
const buildOption = (
functionId:
| 'npc_chat_quest_offer_view'
| 'npc_chat_quest_offer_replace'
| 'npc_chat_quest_offer_abandon',
actionText: string,
action: 'quest_offer_view' | 'quest_offer_replace' | 'quest_offer_abandon',
) =>
({
functionId,
actionText,
text: actionText,
detailText: '',
interaction: {
kind: 'npc',
npcId,
action,
},
visuals: {
playerAnimation: 'idle',
playerMoveMeters: 0,
playerOffsetY: 0,
playerFacing: 'right',
scrollWorld: false,
monsterChanges: [],
},
runtimePayload:
functionId === 'npc_chat_quest_offer_view'
? { npcChatQuestOfferAction: 'view' }
: functionId === 'npc_chat_quest_offer_replace'
? { npcChatQuestOfferAction: 'replace' }
: { npcChatQuestOfferAction: 'abandon' },
}) satisfies JsonRecord;
return [
buildOption('npc_chat_quest_offer_view', '查看任务', 'quest_offer_view'),
buildOption(
'npc_chat_quest_offer_replace',
'更换任务',
'quest_offer_replace',
),
buildOption(
'npc_chat_quest_offer_abandon',
'放弃任务',
'quest_offer_abandon',
),
];
}
function buildPostQuestOfferChatOptions(encounter: RuntimeEncounter) {
return [
'那先继续聊聊你刚才没说完的部分',
'除了委托,你对眼前局势还有什么判断',
'先把这附近真正危险的地方说清楚',
].map((actionText) => buildNpcChatOption(encounter, actionText));
}
function buildQuestOfferDialogueText(
encounter: RuntimeEncounter,
quest: RuntimeQuestLogEntry,
) {
const summaryText = readString(quest.summary) || readString(quest.description);
return `${encounter.npcName}沉吟了片刻,像是终于把真正想托付的事说了出来。${
summaryText
? `如果你愿意,我想把这件事正式交给你:${summaryText}`
: '如果你愿意,我想把眼前这件事正式交给你。'
}`;
}
function ensureEncounterQuestContext(session: RuntimeSession) {
const state = session.rawGameState as unknown as RuntimeGameState;
const encounter = getNpcEncounter(session, state);
@@ -225,6 +370,171 @@ function resolveQuestAcceptAction(
};
}
function resolveQuestOfferViewAction(
session: RuntimeSession,
currentStory?: unknown,
): QuestStoryResolution {
const { encounter, npcKey } = ensureEncounterQuestContext(session);
const pendingOffer = readPendingQuestOfferContext(currentStory, npcKey);
if (!pendingOffer) {
throw conflict('当前没有待处理的委托可查看。');
}
return {
actionText: `查看${encounter.npcName}提出的委托`,
resultText: readString(pendingOffer.introText) || buildQuestOfferDialogueText(encounter, pendingOffer.quest),
patches: [],
};
}
function resolveQuestOfferReplaceAction(
session: RuntimeSession,
currentStory?: unknown,
): QuestStoryResolution {
const { state, encounter, npcKey } = ensureEncounterQuestContext(session);
const pendingOffer = readPendingQuestOfferContext(currentStory, npcKey);
if (!pendingOffer) {
throw conflict('当前没有待处理的委托可更换。');
}
const nextQuest = buildQuestForEncounter({
issuerNpcId: npcKey,
issuerNpcName: encounter.npcName,
roleText: encounter.context,
scene: state.currentScenePreset,
worldType: state.worldType,
context: {
worldType: state.worldType,
recentStoryMoments: Array.isArray(state.storyHistory)
? state.storyHistory.slice(-6)
: [],
playerCharacter: state.playerCharacter ?? null,
playerProgression: state.playerProgression ?? null,
},
currentQuests: (Array.isArray(state.quests) ? state.quests : []).map((item) => ({
id: item.id,
issuerNpcId: item.issuerNpcId,
status: item.status,
})),
});
if (!nextQuest) {
throw conflict('当前没有更合适的委托可供更换。');
}
const dialogue = [
...pendingOffer.dialogue,
{
speaker: 'player',
text: '能不能换一份更适合眼下局势的委托?',
},
{
speaker: 'npc',
speakerName: encounter.npcName,
text: buildQuestOfferDialogueText(encounter, nextQuest),
},
];
return {
actionText: `${encounter.npcName}更换委托`,
resultText: buildQuestOfferDialogueText(encounter, nextQuest),
storyText: buildQuestOfferDialogueText(encounter, nextQuest),
savedCurrentStory: {
text: dialogue
.map((entry) => readString(entry.text))
.filter(Boolean)
.join('\n'),
options: buildPendingQuestOfferOptions(encounter),
displayMode: 'dialogue',
dialogue,
streaming: false,
npcChatState: {
npcId: npcKey,
npcName: encounter.npcName,
turnCount: pendingOffer.turnCount,
customInputPlaceholder: pendingOffer.customInputPlaceholder,
pendingQuestOffer: {
quest: nextQuest,
},
},
},
presentationOptions: buildPendingQuestOfferOptions(encounter).map((option) => ({
functionId: readString(option.functionId),
actionText: readString(option.actionText),
detailText: '',
scope: 'npc',
interaction: isObject(option.interaction)
? (option.interaction as RuntimeStoryOptionView['interaction'])
: undefined,
payload: isObject(option.runtimePayload)
? (option.runtimePayload as Record<string, unknown>)
: undefined,
})),
patches: [],
};
}
function resolveQuestOfferAbandonAction(
session: RuntimeSession,
currentStory?: unknown,
): QuestStoryResolution {
const { encounter, npcKey } = ensureEncounterQuestContext(session);
const pendingOffer = readPendingQuestOfferContext(currentStory, npcKey);
if (!pendingOffer) {
throw conflict('当前没有待处理的委托可放弃。');
}
const npcReply = `${encounter.npcName}点了点头,没有继续强求,只把这份委托暂时收了回去。`;
const dialogue = [
...pendingOffer.dialogue,
{
speaker: 'player',
text: '这件事我先不接,咱们还是先聊别的。',
},
{
speaker: 'npc',
speakerName: encounter.npcName,
text: npcReply,
},
];
return {
actionText: `暂不接受${encounter.npcName}的委托`,
resultText: npcReply,
storyText: npcReply,
savedCurrentStory: {
text: dialogue
.map((entry) => readString(entry.text))
.filter(Boolean)
.join('\n'),
options: buildPostQuestOfferChatOptions(encounter),
displayMode: 'dialogue',
dialogue,
streaming: false,
npcChatState: {
npcId: npcKey,
npcName: encounter.npcName,
turnCount: pendingOffer.turnCount,
customInputPlaceholder: pendingOffer.customInputPlaceholder,
pendingQuestOffer: null,
},
},
presentationOptions: buildPostQuestOfferChatOptions(encounter).map((option) => ({
functionId: readString(option.functionId),
actionText: readString(option.actionText),
detailText: '',
scope: 'npc',
interaction: isObject(option.interaction)
? (option.interaction as RuntimeStoryOptionView['interaction'])
: undefined,
payload: isObject(option.runtimePayload)
? (option.runtimePayload as Record<string, unknown>)
: undefined,
})),
patches: [],
};
}
function resolveQuestTurnInAction(
session: RuntimeSession,
request: RuntimeStoryActionRequest,
@@ -311,6 +621,12 @@ export function resolveQuestStoryAction(
} = {},
): QuestStoryResolution {
switch (request.action.functionId) {
case 'npc_chat_quest_offer_view':
return resolveQuestOfferViewAction(session, options.currentStory);
case 'npc_chat_quest_offer_replace':
return resolveQuestOfferReplaceAction(session, options.currentStory);
case 'npc_chat_quest_offer_abandon':
return resolveQuestOfferAbandonAction(session, options.currentStory);
case 'npc_quest_accept':
return resolveQuestAcceptAction(session, options.currentStory);
case 'npc_quest_turn_in':

View File

@@ -738,6 +738,21 @@ function buildOptionInteraction(
npc_spar: { kind: 'npc', npcId, action: 'spar' },
npc_trade: { kind: 'npc', npcId, action: 'trade' },
npc_gift: { kind: 'npc', npcId, action: 'gift' },
npc_chat_quest_offer_view: {
kind: 'npc',
npcId,
action: 'quest_offer_view',
},
npc_chat_quest_offer_replace: {
kind: 'npc',
npcId,
action: 'quest_offer_replace',
},
npc_chat_quest_offer_abandon: {
kind: 'npc',
npcId,
action: 'quest_offer_abandon',
},
npc_quest_accept: { kind: 'npc', npcId, action: 'quest_accept' },
npc_quest_turn_in: { kind: 'npc', npcId, action: 'quest_turn_in' },
};

View File

@@ -1,7 +1,10 @@
import { Router } from 'express';
import { z } from 'zod';
import type { RuntimeStoryActionRequest } from '../../../../packages/shared/src/contracts/story.js';
import type {
RuntimeStoryActionRequest,
RuntimeStoryStateRequest,
} from '../../../../packages/shared/src/contracts/story.js';
import type { AppContext } from '../../context.js';
import { badRequest } from '../../errors.js';
import { asyncHandler, sendApiResponse } from '../../http.js';
@@ -17,6 +20,7 @@ const actionPayloadSchema = z.record(z.string(), z.unknown());
const runtimeStoryActionSchema = z.object({
sessionId: z.string().trim().min(1),
clientVersion: z.number().int().min(0).optional(),
snapshot: z.unknown().optional(),
action: z.object({
type: z.literal('story_choice'),
functionId: z.string().trim().min(1),
@@ -25,6 +29,12 @@ const runtimeStoryActionSchema = z.object({
}),
});
const runtimeStoryStateResolveSchema = z.object({
sessionId: z.string().trim().min(1),
clientVersion: z.number().int().min(0).optional(),
snapshot: z.unknown().optional(),
});
export function createStoryActionRoutes(context: AppContext) {
const router = Router();
const requireAuth = requireJwtAuth(context.config, context.userRepository);
@@ -70,5 +80,25 @@ export function createStoryActionRoutes(context: AppContext) {
}),
);
router.post(
'/state/resolve',
routeMeta({ operation: 'runtime.story.state.resolve' }),
asyncHandler(async (request, response) => {
const payload = runtimeStoryStateResolveSchema.parse(
request.body,
) as RuntimeStoryStateRequest;
sendApiResponse(
response,
await getRuntimeStoryState({
runtimeRepository: context.runtimeRepository,
userId: request.userId!,
sessionId: payload.sessionId,
clientVersion: payload.clientVersion,
snapshot: payload.snapshot,
}),
);
}),
);
return router;
}

View File

@@ -4,6 +4,7 @@ import type {
RuntimeStoryActionResponse,
RuntimeStoryOptionView,
RuntimeStoryPatch,
RuntimeStoryStateRequest,
} from '../../../../packages/shared/src/contracts/story.js';
import { conflict, invalidRequest } from '../../errors.js';
import type { RuntimeRepositoryPort } from '../../repositories/runtimeRepository.js';
@@ -59,6 +60,8 @@ type StoryResolution = {
resultText: string;
patches: RuntimeStoryPatch[];
storyText?: string;
presentationOptions?: RuntimeStoryOptionView[];
savedCurrentStory?: JsonRecord;
battle?: RuntimeBattlePresentation | null;
toast?: string | null;
};
@@ -604,6 +607,48 @@ function readSavedStoryText(currentStory: unknown) {
return '';
}
function normalizeIncomingSnapshot(snapshot: unknown) {
if (!isObject(snapshot)) {
return null;
}
const gameState = 'gameState' in snapshot ? snapshot.gameState : null;
const bottomTab = readString(snapshot.bottomTab) || 'adventure';
const currentStory = 'currentStory' in snapshot ? snapshot.currentStory : null;
const savedAt = readString(snapshot.savedAt) || new Date().toISOString();
if (!gameState || !isObject(gameState)) {
return null;
}
return normalizeSavedSnapshotPayload({
savedAt,
bottomTab,
gameState,
currentStory: currentStory ?? null,
});
}
async function resolveSnapshotForRequest(params: {
runtimeRepository: RuntimeRepositoryPort;
userId: string;
snapshot?: unknown;
}) {
const incomingSnapshot = normalizeIncomingSnapshot(params.snapshot);
if (incomingSnapshot) {
return hydrateSavedSnapshot(
await params.runtimeRepository.putSnapshot(params.userId, incomingSnapshot),
)!;
}
const persistedSnapshot = await params.runtimeRepository.getSnapshot(params.userId);
if (!persistedSnapshot) {
throw conflict('运行时快照不存在,请先初始化并保存一次游戏');
}
return hydrateSavedSnapshot(persistedSnapshot)!;
}
function buildFallbackStoryText(session: RuntimeSession) {
if (session.inBattle && session.sceneHostileNpcs.length > 0) {
return `眼前的冲突还没结束,${session.sceneHostileNpcs[0]!.name}仍在逼你立刻做出下一步判断。`;
@@ -860,11 +905,11 @@ export async function resolveRuntimeStoryAction(params: {
userId: string;
request: RuntimeStoryActionRequest;
}) {
const snapshot = await params.runtimeRepository.getSnapshot(params.userId);
if (!snapshot) {
throw conflict('运行时快照不存在,请先初始化并保存一次游戏');
}
const hydratedSnapshot = hydrateSavedSnapshot(snapshot)!;
const hydratedSnapshot = await resolveSnapshotForRequest({
runtimeRepository: params.runtimeRepository,
userId: params.userId,
snapshot: params.request.snapshot,
});
const functionId =
typeof params.request.action.functionId === 'string'
@@ -968,6 +1013,12 @@ export async function resolveRuntimeStoryAction(params: {
storyText,
options,
);
if (resolution.presentationOptions?.length) {
options = resolution.presentationOptions;
}
if (resolution.savedCurrentStory) {
savedCurrentStory = resolution.savedCurrentStory;
}
const pendingQuestAcceptedCurrentStory =
functionId === 'npc_quest_accept'
? buildPendingQuestAcceptedCurrentStory({
@@ -1061,14 +1112,25 @@ export async function getRuntimeStoryState(params: {
runtimeRepository: RuntimeRepositoryPort;
userId: string;
sessionId: string;
clientVersion?: number;
snapshot?: RuntimeStoryStateRequest['snapshot'];
}) {
const snapshot = await params.runtimeRepository.getSnapshot(params.userId);
if (!snapshot) {
throw conflict('运行时快照不存在,请先初始化并保存一次游戏');
}
const hydratedSnapshot = hydrateSavedSnapshot(snapshot)!;
const hydratedSnapshot = await resolveSnapshotForRequest({
runtimeRepository: params.runtimeRepository,
userId: params.userId,
snapshot: params.snapshot,
});
const session = loadRuntimeSession(hydratedSnapshot, params.sessionId);
if (
typeof params.clientVersion === 'number' &&
params.clientVersion !== session.runtimeVersion
) {
throw conflict('运行时版本已变化,请先同步最新快照后再读取状态', {
clientVersion: params.clientVersion,
serverVersion: session.runtimeVersion,
});
}
ensureNpcInventorySessionState(session);
const options = buildAvailableOptions(session);
const storyText =

View File

@@ -1,4 +1,4 @@
import { type Request, Router } from 'express';
import { type Request, type Response, Router } from 'express';
import { z } from 'zod';
import type {
@@ -30,6 +30,10 @@ import {
sendPhoneLoginCode,
startWechatLogin,
} from '../auth/authService.js';
import {
clearAccessSessionCookie,
setAccessSessionCookie,
} from '../auth/accessSessionCookie.js';
import {
clearRefreshSessionCookie,
readRefreshSessionToken,
@@ -112,6 +116,23 @@ function buildRefreshCookieLifetimeSeconds(
);
}
function buildAccessCookieLifetimeSeconds(context: AppContext) {
return Math.max(0, context.config.authSession.accessCookieTtlSeconds);
}
async function writeAccessSessionCookie(
context: AppContext,
response: Response,
token: string,
) {
setAccessSessionCookie(
response,
context.config,
token,
buildAccessCookieLifetimeSeconds(context),
);
}
export function createAuthRoutes(context: AppContext) {
const router = Router();
const requireAuth = requireJwtAuth(context.config, context.userRepository);
@@ -145,6 +166,7 @@ export function createAuthRoutes(context: AppContext) {
user,
requestContext,
);
await writeAccessSessionCookie(context, response, result.token);
setRefreshSessionCookie(
response,
context.config,
@@ -223,6 +245,7 @@ export function createAuthRoutes(context: AppContext) {
user,
requestContext,
);
await writeAccessSessionCookie(context, response, result.token);
setRefreshSessionCookie(
response,
context.config,
@@ -298,6 +321,7 @@ export function createAuthRoutes(context: AppContext) {
user,
requestContext,
);
await writeAccessSessionCookie(context, response, result.token);
setRefreshSessionCookie(
response,
context.config,
@@ -309,7 +333,6 @@ export function createAuthRoutes(context: AppContext) {
302,
buildAuthResultRedirectUrl(redirectPath, {
auth_provider: 'wechat',
auth_token: result.token,
auth_binding_status: result.user.bindingStatus,
}),
);
@@ -352,6 +375,7 @@ export function createAuthRoutes(context: AppContext) {
user,
requestContext,
);
await writeAccessSessionCookie(context, response, result.token);
setRefreshSessionCookie(
response,
context.config,
@@ -369,6 +393,7 @@ export function createAuthRoutes(context: AppContext) {
const refreshToken = readRefreshSessionToken(request, context.config);
try {
const result = await refreshAuthSession(context, refreshToken);
await writeAccessSessionCookie(context, response, result.token);
setRefreshSessionCookie(
response,
context.config,
@@ -376,9 +401,11 @@ export function createAuthRoutes(context: AppContext) {
buildRefreshCookieLifetimeSeconds(context, result.refreshExpiresAt),
);
sendApiResponse(response, {
ok: true,
token: result.token,
});
} catch (error) {
clearAccessSessionCookie(response, context.config);
clearRefreshSessionCookie(response, context.config);
throw error;
}
@@ -479,6 +506,7 @@ export function createAuthRoutes(context: AppContext) {
routeMeta({ operation: 'auth.logout_all' }),
requireAuth,
asyncHandler(async (request, response) => {
clearAccessSessionCookie(response, context.config);
clearRefreshSessionCookie(response, context.config);
sendApiResponse(
response,
@@ -498,6 +526,7 @@ export function createAuthRoutes(context: AppContext) {
asyncHandler(async (request, response) => {
const refreshToken = readRefreshSessionToken(request, context.config);
await revokeRefreshSession(context, refreshToken);
clearAccessSessionCookie(response, context.config);
clearRefreshSessionCookie(response, context.config);
sendApiResponse(
response,

View File

@@ -7,6 +7,7 @@ import type {
CustomWorldGalleryResponse,
CustomWorldLibraryMutationResponse,
CustomWorldLibraryResponse,
GenerateCustomWorldProfileInput,
PlatformBrowseHistoryBatchSyncRequest,
PlatformBrowseHistoryResponse,
PlatformBrowseHistoryWriteEntry,
@@ -50,6 +51,7 @@ import {
streamNpcChatTurnFromOrchestrator,
streamNpcRecruitDialogueFromOrchestrator,
} from '../modules/ai/chatOrchestrator.js';
import { generateCustomWorldProfileFromOrchestrator } from '../modules/ai/customWorldOrchestrator.js';
import {
hydrateSavedSnapshot,
normalizeSavedSnapshotPayload,
@@ -118,6 +120,12 @@ const customWorldProfileSchema = z.object({
profile: jsonObjectSchema,
});
const customWorldProfileGenerationSchema = z.object({
settingText: z.string().trim().min(1),
creatorIntent: jsonObjectSchema.nullish(),
generationMode: z.enum(['fast', 'full']).optional(),
});
const customWorldSceneNpcSchema = z.object({
profile: jsonObjectSchema,
landmarkId: z.string().trim().min(1),
@@ -600,6 +608,23 @@ export function createRuntimeRoutes(context: AppContext) {
}),
);
router.post(
'/runtime/custom-world/profile',
routeMeta({ operation: 'runtime.customWorld.profile' }),
asyncHandler(async (request, response) => {
const payload = customWorldProfileGenerationSchema.parse(
request.body,
) as GenerateCustomWorldProfileInput;
sendApiResponse(
response,
await generateCustomWorldProfileFromOrchestrator(
context.llmClient,
payload,
),
);
}),
);
router.post(
'/runtime/custom-world-library/:profileId/publish',
routeMeta({ operation: 'runtime.customWorldLibrary.publish' }),

View File

@@ -198,6 +198,116 @@ function buildRoleAssetSyncResultText(params: {
return `已把「${params.roleName}」的角色资产写回草稿,当前状态:${params.assetStatusLabel}`;
}
function toText(value: unknown) {
return typeof value === 'string' ? value.trim() : '';
}
function isRecord(value: unknown): value is Record<string, unknown> {
return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
}
function toRecordArray(value: unknown) {
return Array.isArray(value)
? value.filter((item): item is Record<string, unknown> => isRecord(item))
: [];
}
function cloneJsonRecord<T>(value: T): T {
return JSON.parse(JSON.stringify(value)) as T;
}
function syncRoleAssetsFromResultProfile(params: {
currentRoles: unknown;
resultRoles: unknown;
}) {
const resultRoleById = new Map(
toRecordArray(params.resultRoles).map((role) => [toText(role.id), role]),
);
return toRecordArray(params.currentRoles).map((currentRole) => {
const resultRole = resultRoleById.get(toText(currentRole.id));
if (!resultRole) {
return currentRole;
}
return {
...currentRole,
imageSrc: toText(resultRole.imageSrc) || null,
generatedVisualAssetId: toText(resultRole.generatedVisualAssetId) || null,
generatedAnimationSetId:
toText(resultRole.generatedAnimationSetId) || null,
animationMap: isRecord(resultRole.animationMap)
? cloneJsonRecord(resultRole.animationMap)
: null,
} satisfies Record<string, unknown>;
});
}
function syncLandmarkAssetsFromResultProfile(params: {
currentLandmarks: unknown;
resultLandmarks: unknown;
}) {
const resultLandmarkById = new Map(
toRecordArray(params.resultLandmarks).map((landmark) => [
toText(landmark.id),
landmark,
]),
);
return toRecordArray(params.currentLandmarks).map((currentLandmark) => {
const resultLandmark = resultLandmarkById.get(toText(currentLandmark.id));
if (!resultLandmark) {
return currentLandmark;
}
return {
...currentLandmark,
imageSrc: toText(resultLandmark.imageSrc) || null,
} satisfies Record<string, unknown>;
});
}
function syncSceneChapterAssetsFromResultProfile(params: {
currentSceneChapters: unknown;
resultSceneChapters: unknown;
}) {
const resultSceneChapterBySceneId = new Map(
toRecordArray(params.resultSceneChapters).map((chapter) => [
toText(chapter.sceneId),
chapter,
]),
);
return toRecordArray(params.currentSceneChapters).map((currentChapter) => {
const resultChapter = resultSceneChapterBySceneId.get(
toText(currentChapter.sceneId),
);
if (!resultChapter) {
return currentChapter;
}
const resultActById = new Map(
toRecordArray(resultChapter.acts).map((act) => [toText(act.id), act]),
);
return {
...currentChapter,
acts: toRecordArray(currentChapter.acts).map((currentAct) => {
const resultAct = resultActById.get(toText(currentAct.id));
if (!resultAct) {
return currentAct;
}
return {
...currentAct,
backgroundImageSrc: toText(resultAct.backgroundImageSrc) || null,
backgroundAssetId: toText(resultAct.backgroundAssetId) || null,
} satisfies Record<string, unknown>;
}),
} satisfies Record<string, unknown>;
});
}
function syncResultProfileIntoDraftProfile(params: {
currentDraftProfile: Record<string, unknown> | null | undefined;
resultProfile: CustomWorldProfile;
@@ -215,6 +325,22 @@ function syncResultProfileIntoDraftProfile(params: {
playerGoal: resultProfile.playerGoal,
majorFactions: resultProfile.majorFactions,
coreConflicts: resultProfile.coreConflicts,
playableNpcs: syncRoleAssetsFromResultProfile({
currentRoles: currentDraftProfile.playableNpcs,
resultRoles: resultProfile.playableNpcs,
}),
storyNpcs: syncRoleAssetsFromResultProfile({
currentRoles: currentDraftProfile.storyNpcs,
resultRoles: resultProfile.storyNpcs,
}),
landmarks: syncLandmarkAssetsFromResultProfile({
currentLandmarks: currentDraftProfile.landmarks,
resultLandmarks: resultProfile.landmarks,
}),
sceneChapters: syncSceneChapterAssetsFromResultProfile({
currentSceneChapters: currentDraftProfile.sceneChapters,
resultSceneChapters: resultProfile.sceneChapterBlueprints,
}),
legacyResultProfile: resultProfile as unknown as Record<string, unknown>,
} satisfies Record<string, unknown>;
}

View File

@@ -421,6 +421,162 @@ test('phase4 sync_result_profile keeps existing foundation structure while updat
);
});
test('phase4 sync_result_profile also writes latest role and scene assets back into draft profile', async () => {
const runtimeRepository = createRuntimeRepositoryStub();
const sessionStore = new CustomWorldAgentSessionStore(runtimeRepository);
const orchestrator = new CustomWorldAgentOrchestrator(sessionStore, null, {
singleTurnLlmClient: createTestCustomWorldAgentSingleTurnLlmClient(),
});
const userId = 'user-phase4-sync-result-profile-assets';
const session = await createObjectRefiningSession(orchestrator, userId);
const baselineProfile = normalizeFoundationDraftProfile(session.draftProfile)!;
const playableRole = baselineProfile.playableNpcs[0]!;
const storyRole = baselineProfile.storyNpcs[0]!;
const landmark = baselineProfile.landmarks[0]!;
const response = await orchestrator.executeAction(userId, session.sessionId, {
action: 'sync_result_profile',
profile: {
id: `agent-draft-${session.sessionId}`,
settingText: '被海雾吞没的旧航路群岛',
name: '潮雾列岛·结果页精修版',
subtitle: '旧灯塔与失控航路',
summary: '结果页已经把最新图与动作一起确认。 ',
tone: '压抑、潮湿、悬疑',
playerGoal: '查清沉船夜与假航灯的真正操盘者。',
templateWorldType: 'WUXIA',
majorFactions: ['守灯会', '航运公会'],
coreConflicts: ['守灯会与航运公会争夺旧航路控制权'],
attributeSchema: {
id: 'schema:test',
worldId: 'CUSTOM',
schemaVersion: 1,
schemaName: '测试',
generatedFrom: {
worldType: 'CUSTOM',
worldName: '潮雾列岛·结果页精修版',
settingSummary: '测试',
tone: '测试',
conflictCore: '测试',
},
slots: [],
},
playableNpcs: [
{
id: playableRole.id,
name: playableRole.name,
title: '结果页角色',
role: '关键同行者',
description: '结果页确认的最新角色资产。',
backstory: '测试',
personality: '冷静',
motivation: '验证资产回写',
combatStyle: '观察',
initialAffinity: 12,
relationshipHooks: [],
tags: [],
imageSrc: '/generated/playable/latest-master.png',
generatedVisualAssetId: 'visual-playable-latest',
generatedAnimationSetId: 'anim-playable-latest',
animationMap: {
idle: {
spriteSheetPath: '/generated/playable/idle.png',
},
},
},
],
storyNpcs: [
{
id: storyRole.id,
name: storyRole.name,
title: '结果页场景角色',
role: '场景关键角色',
description: '结果页确认的最新场景角色资产。',
backstory: '测试',
personality: '克制',
motivation: '验证资产回写',
combatStyle: '观察',
initialAffinity: 6,
relationshipHooks: [],
tags: [],
imageSrc: '/generated/story/latest-master.png',
generatedVisualAssetId: 'visual-story-latest',
},
],
items: [],
landmarks: [
{
id: landmark.id,
name: landmark.name,
description: '结果页确认的最新地点图。',
dangerLevel: '中',
sceneNpcIds: [],
connections: [],
imageSrc: '/generated/landmark/latest-scene.png',
},
],
sceneChapterBlueprints: [
{
id: 'scene-chapter-1',
sceneId: landmark.id,
title: '灯塔初章',
summary: '结果页确认最新分幕图。',
linkedThreadIds: [],
linkedLandmarkIds: [landmark.id],
acts: [
{
id: `${landmark.id}-act-1`,
sceneId: landmark.id,
title: '第一幕',
summary: '第一幕',
stageCoverage: ['opening'],
backgroundImageSrc: '/generated/scene/act-1-latest.png',
backgroundAssetId: 'scene-asset-latest',
encounterNpcIds: [],
primaryNpcId: '',
linkedThreadIds: [],
advanceRule: 'after_primary_contact',
actGoal: '验证分幕图回写',
transitionHook: '进入下一幕',
},
],
},
],
generationMode: 'full',
generationStatus: 'complete',
},
});
const operation = await waitForOperation(
orchestrator,
userId,
session.sessionId,
response.operation.operationId,
);
const snapshot = await orchestrator.getSessionSnapshot(userId, session.sessionId);
const profile = normalizeFoundationDraftProfile(snapshot?.draftProfile)!;
const syncedPlayable = profile.playableNpcs.find(
(entry) => entry.id === playableRole.id,
);
const syncedStory = profile.storyNpcs.find((entry) => entry.id === storyRole.id);
const syncedLandmark = profile.landmarks.find((entry) => entry.id === landmark.id);
const syncedSceneAct = profile.sceneChapters[0]?.acts[0];
assert.equal(operation?.status, 'completed');
assert.equal(syncedPlayable?.imageSrc, '/generated/playable/latest-master.png');
assert.equal(syncedPlayable?.generatedVisualAssetId, 'visual-playable-latest');
assert.equal(syncedPlayable?.generatedAnimationSetId, 'anim-playable-latest');
assert.deepEqual(syncedPlayable?.animationMap, {
idle: {
spriteSheetPath: '/generated/playable/idle.png',
},
});
assert.equal(syncedStory?.imageSrc, '/generated/story/latest-master.png');
assert.equal(syncedStory?.generatedVisualAssetId, 'visual-story-latest');
assert.equal(syncedLandmark?.imageSrc, '/generated/landmark/latest-scene.png');
assert.equal(syncedSceneAct?.backgroundImageSrc, '/generated/scene/act-1-latest.png');
assert.equal(syncedSceneAct?.backgroundAssetId, 'scene-asset-latest');
});
test('phase4 generate_characters appends story npcs and updates work summary counts', async () => {
const runtimeRepository = createRuntimeRepositoryStub();
const sessionStore = new CustomWorldAgentSessionStore(runtimeRepository);

View File

@@ -368,10 +368,15 @@ function buildCompatibleSuggestedActions(params: {
record: CustomWorldAgentSessionRecord;
stage: CustomWorldAgentStage;
readiness: CreatorIntentReadiness;
draftProfile: Record<string, unknown>;
}) {
if (params.record.suggestedActions.length > 0) {
return params.record.suggestedActions;
// 旧快照里可能带有“精修对象”的 Agent 建议动作;当前 Agent 对话框只服务八锚点收集。
const compatibleActions = params.record.suggestedActions.filter(
(action) => action.type !== 'refine_focus_target',
);
if (compatibleActions.length > 0) {
return compatibleActions;
}
}
const actions: CustomWorldSuggestedAction[] = [
@@ -384,16 +389,6 @@ function buildCompatibleSuggestedActions(params: {
: '总结当前设定',
},
];
const playableNpcs = Array.isArray(params.draftProfile.playableNpcs)
? params.draftProfile.playableNpcs
: [];
const storyNpcs = Array.isArray(params.draftProfile.storyNpcs)
? params.draftProfile.storyNpcs
: [];
const landmarks = Array.isArray(params.draftProfile.landmarks)
? params.draftProfile.landmarks
: [];
if (params.stage === 'foundation_review' && params.readiness.isReady) {
actions.push({
id: 'draft_foundation',
@@ -403,36 +398,6 @@ function buildCompatibleSuggestedActions(params: {
return actions;
}
if (params.stage === 'object_refining' || params.stage === 'visual_refining') {
const firstCharacter = toRecord([...playableNpcs, ...storyNpcs][0]);
const firstLandmark = toRecord(landmarks[0]);
actions.push({
id: 'refine_world',
type: 'refine_focus_target',
label: '先看世界总卡',
targetId: 'world-foundation',
});
if (firstCharacter) {
actions.push({
id: `refine-character-${toText(firstCharacter.id) || 'seed'}`,
type: 'refine_focus_target',
label: `精修角色:${toText(firstCharacter.name) || '关键角色'}`,
targetId: toText(firstCharacter.id) || null,
});
}
if (firstLandmark) {
actions.push({
id: `refine-landmark-${toText(firstLandmark.id) || 'seed'}`,
type: 'refine_focus_target',
label: `继续补地点:${toText(firstLandmark.name) || '关键地点'}`,
targetId: toText(firstLandmark.id) || null,
});
}
}
return actions;
}
@@ -533,7 +498,6 @@ function applyCompatibility(record: CustomWorldAgentSessionRecord) {
record,
stage,
readiness: creatorIntentReadiness,
draftProfile,
}),
assetCoverage: buildCompatibleAssetCoverage(record, draftProfile),
recommendedReplies: normalizeRecommendedReplies(

View File

@@ -55,7 +55,7 @@ function formatDraftStageLabel(stage: CustomWorldAgentStage) {
if (stage === 'collecting_intent') return '收集世界锚点';
if (stage === 'clarifying') return '补齐关键锚点';
if (stage === 'foundation_review') return '准备整理底稿';
if (stage === 'object_refining') return '精修对象';
if (stage === 'object_refining') return '待完善草稿';
if (stage === 'visual_refining') return '视觉工坊';
if (stage === 'long_tail_review') return '扩展长尾';
if (stage === 'ready_to_publish') return '准备发布';

View File

@@ -17,7 +17,7 @@ const baseDraftItem: CustomWorldWorkSummary = {
updatedAt: new Date('2026-04-14T10:00:00.000Z').toISOString(),
publishedAt: null,
stage: 'object_refining',
stageLabel: '精修对象',
stageLabel: '待完善草稿',
playableNpcCount: 3,
landmarkCount: 4,
sessionId: 'session-1',
@@ -35,7 +35,7 @@ test('creation hub reflects updated draft title summary and counts after rerende
onBack={() => {}}
onRetry={() => {}}
onCreateNew={() => {}}
onResumeDraft={() => {}}
onOpenDraft={() => {}}
onEnterPublished={() => {}}
/>,
);
@@ -62,7 +62,7 @@ test('creation hub reflects updated draft title summary and counts after rerende
onBack={() => {}}
onRetry={() => {}}
onCreateNew={() => {}}
onResumeDraft={() => {}}
onOpenDraft={() => {}}
onEnterPublished={() => {}}
/>,
);

View File

@@ -33,7 +33,7 @@ test('creation hub draft card renders compiled work summary fields', () => {
onBack={() => {}}
onRetry={() => {}}
onCreateNew={() => {}}
onResumeDraft={() => {}}
onOpenDraft={() => {}}
onEnterPublished={() => {}}
/>,
);

View File

@@ -15,7 +15,7 @@ type CustomWorldCreationHubProps = {
onBack: () => void;
onRetry: () => void;
onCreateNew: () => void;
onResumeDraft: (sessionId: string) => void;
onOpenDraft: (item: CustomWorldWorkSummary) => void;
onEnterPublished: (profileId: string) => void;
};
@@ -36,7 +36,7 @@ export function CustomWorldCreationHub({
onBack,
onRetry,
onCreateNew,
onResumeDraft,
onOpenDraft,
onEnterPublished,
}: CustomWorldCreationHubProps) {
const [activeFilter, setActiveFilter] =
@@ -129,7 +129,7 @@ export function CustomWorldCreationHub({
item={item}
onClick={() => {
if (item.sourceType === 'agent_session' && item.sessionId) {
onResumeDraft(item.sessionId);
onOpenDraft(item);
return;
}

View File

@@ -27,6 +27,11 @@ export function CustomWorldWorkCard({
const isDraft = item.status === 'draft';
const hasFoundationDraft =
item.playableNpcCount > 0 || item.landmarkCount > 0;
const actionLabel = isDraft
? hasFoundationDraft
? '继续完善'
: '继续创作'
: '进入世界';
const roleCountLabel = isDraft ? '角色' : '可扮演角色';
return (
@@ -104,7 +109,7 @@ export function CustomWorldWorkCard({
onClick={onClick}
className="platform-button platform-button--primary min-h-0 shrink-0 rounded-full px-4 py-2 text-sm"
>
{isDraft ? (hasFoundationDraft ? '继续精修' : '继续创作') : '进入世界'}
{actionLabel}
</button>
</div>
</div>

View File

@@ -30,13 +30,13 @@ import {
import type {
CustomWorldGalleryCard,
CustomWorldLibraryEntry,
PlatformBrowseHistoryEntry,
ProfileDashboardCardKey,
ProfileDashboardSummary,
ProfileSaveArchiveSummary,
} from '../../../packages/shared/src/contracts/runtime';
import type { HydratedSavedGameSnapshot } from '../../persistence/runtimeSnapshotTypes';
import type { AuthUser } from '../../services/authService';
import type { PlatformBrowseHistoryEntry } from '../../services/platformBrowseHistory';
import type { CustomWorldProfile } from '../../types';
import { useAuthUi } from '../auth/AuthUiContext';
import { PlatformBrandLogo } from './PlatformBrandLogo';

View File

@@ -223,6 +223,97 @@ const mockAuthUser: AuthUser = {
wechatBound: false,
};
const compiledAgentDraftSession: CustomWorldAgentSessionSnapshot = {
...mockSession,
stage: 'object_refining',
creatorIntent: {
sourceMode: 'card',
worldHook: '被海雾吞没的旧航路群岛',
playerPremise: '玩家回到群岛调查沉船真相。',
themeKeywords: ['海雾', '旧航路'],
toneDirectives: ['压抑', '悬疑'],
openingSituation: '首夜就有陌生船只闯入禁航区。',
coreConflicts: ['航运公会与守灯会争夺航路控制权'],
keyFactions: [],
keyCharacters: [],
keyLandmarks: [],
iconicElements: ['会移动的海雾'],
forbiddenDirectives: [],
rawSettingText: '',
},
draftProfile: {
name: '潮雾列岛',
subtitle: '旧灯塔与失控航路',
summary: '第一版世界底稿已经整理完成。',
tone: '压抑、潮湿、悬疑',
playerGoal: '查清沉船与禁航区异动的真相。',
majorFactions: ['守灯会', '航运公会'],
coreConflicts: ['守灯会与航运公会争夺旧航路控制权'],
playableNpcs: [
{
id: 'playable-1',
name: '沈砺',
title: '旧航路引路人',
role: '关键同行者',
publicIdentity: '最熟悉旧航路的人。',
publicMask: '看上去像可靠旧友。',
currentPressure: '他必须在两股势力间站队。',
hiddenHook: '暗中替沉船商盟引路。',
relationToPlayer: '旧友兼潜在背叛者',
threadIds: ['thread-1'],
summary: '他像旧友,但也像一把始终没收回鞘的刀。',
},
],
storyNpcs: [
{
id: 'story-1',
name: '顾潮音',
title: '守灯会值夜人',
role: '场景关键角色',
publicIdentity: '负责夜间巡灯与封锁。',
publicMask: '对外一直冷静克制。',
currentPressure: '她知道更多禁航区真相。',
hiddenHook: '曾亲眼见过失控海雾吞船。',
relationToPlayer: '最早愿意交换线索的人',
threadIds: ['thread-1'],
summary: '她总像比所有人更早知道海雾会往哪一侧压下来。',
},
],
landmarks: [
{
id: 'landmark-1',
name: '回潮旧灯塔',
purpose: '观察雾潮与往来船只',
mood: '潮湿、压抑、风声不止',
importance: '开局核心场景',
characterIds: ['story-1'],
threadIds: ['thread-1'],
summary: '旧灯塔是整片群岛最先看见异动的地方。',
},
],
factions: [],
threads: [],
chapters: [],
worldHook: '被海雾吞没的旧航路群岛',
playerPremise: '玩家回到群岛调查沉船真相。',
openingSituation: '首夜就有陌生船只闯入禁航区。',
iconicElements: ['会移动的海雾'],
sourceAnchorSummary: '海雾、旧灯塔、失控航路。',
},
draftCards: [
{
id: 'world-foundation',
kind: 'world',
title: '潮雾列岛',
subtitle: '旧灯塔与失控航路',
summary: '第一版世界底稿已经整理完成。',
status: 'warning',
linkedIds: ['playable-1', 'story-1', 'landmark-1'],
warningCount: 0,
},
],
};
type TestAuthValue = {
user: AuthUser | null;
openLoginModal: (postLoginAction?: (() => void) | null) => void;
@@ -416,7 +507,7 @@ test('create tab opens game type modal, keeps AIRP and visual novel locked, and
).toBeTruthy();
});
test('create tab uses unified creation hub and can resume an agent draft', async () => {
test('create tab opens compiled agent draft in result refinement page', async () => {
const user = userEvent.setup();
vi.mocked(listCustomWorldWorks).mockResolvedValue([
@@ -425,7 +516,7 @@ test('create tab uses unified creation hub and can resume an agent draft', async
sourceType: 'agent_session',
status: 'draft',
title: '潮雾列岛',
subtitle: '精修对象',
subtitle: '待完善草稿',
summary: '玩家是失职返乡的守灯人。',
coverImageSrc: null,
coverRenderMode: 'image',
@@ -433,7 +524,7 @@ test('create tab uses unified creation hub and can resume an agent draft', async
updatedAt: '2026-04-20T10:00:00.000Z',
publishedAt: null,
stage: 'object_refining',
stageLabel: '精修对象',
stageLabel: '待完善草稿',
playableNpcCount: 3,
landmarkCount: 4,
roleVisualReadyCount: 1,
@@ -445,21 +536,70 @@ test('create tab uses unified creation hub and can resume an agent draft', async
canEnterWorld: false,
},
]);
vi.mocked(getCustomWorldAgentSession).mockResolvedValue(
compiledAgentDraftSession,
);
render(<TestWrapper withAuth />);
await openCreationHub(user);
expect(
screen.getByRole('button', { name: //u }),
).toBeTruthy();
expect(screen.getByRole('button', { name: //u })).toBeTruthy();
expect(screen.getByRole('button', { name: //u })).toBeTruthy();
await user.click(screen.getByRole('button', { name: //u }));
await user.click(screen.getByRole('button', { name: //u }));
await waitFor(
async () => {
expect(await screen.findByText('世界档案')).toBeTruthy();
expect(screen.queryByText('Agent工作区custom-world-agent-session-1')).toBeNull();
expect(screen.getByRole('button', { name: //u })).toBeTruthy();
},
{ timeout: 2500 },
);
});
test('create tab resumes agent workspace when draft has no compiled result yet', async () => {
const user = userEvent.setup();
vi.mocked(listCustomWorldWorks).mockResolvedValue([
{
workId: 'draft:custom-world-agent-session-1',
sourceType: 'agent_session',
status: 'draft',
title: '潮雾列岛',
subtitle: '补齐关键锚点',
summary: '玩家是失职返乡的守灯人。',
coverImageSrc: null,
coverRenderMode: 'image',
coverCharacterImageSrcs: [],
updatedAt: '2026-04-20T10:00:00.000Z',
publishedAt: null,
stage: 'clarifying',
stageLabel: '补齐关键锚点',
playableNpcCount: 0,
landmarkCount: 0,
roleVisualReadyCount: 0,
roleAnimationReadyCount: 0,
roleAssetSummaryLabel: null,
sessionId: 'custom-world-agent-session-1',
profileId: null,
canResume: true,
canEnterWorld: false,
},
]);
render(<TestWrapper withAuth />);
await openCreationHub(user);
expect(screen.getByRole('button', { name: //u })).toBeTruthy();
await user.click(screen.getByRole('button', { name: //u }));
expect(
await screen.findByText('Agent工作区custom-world-agent-session-1'),
).toBeTruthy();
expect(screen.queryByText('世界档案')).toBeNull();
});
test('clicking a public work while logged out routes through requireAuth', async () => {
@@ -581,7 +721,7 @@ test('starting draft generation leaves the agent workspace and shows the generat
expect(screen.queryByText('先告诉我你想做一个怎样的 RPG 世界。')).toBeNull();
});
test('existing draft sessions enter the agent preview layout without opening legacy editor', async () => {
test('existing draft sessions open result page refinement instead of agent dialog', async () => {
const user = userEvent.setup();
vi.mocked(getCustomWorldAgentOperation).mockResolvedValue({
@@ -593,96 +733,9 @@ test('existing draft sessions enter the agent preview layout without opening leg
progress: 100,
error: null,
});
vi.mocked(getCustomWorldAgentSession).mockResolvedValue({
...mockSession,
stage: 'object_refining',
creatorIntent: {
sourceMode: 'card',
worldHook: '被海雾吞没的旧航路群岛',
playerPremise: '玩家回到群岛调查沉船真相。',
themeKeywords: ['海雾', '旧航路'],
toneDirectives: ['压抑', '悬疑'],
openingSituation: '首夜就有陌生船只闯入禁航区。',
coreConflicts: ['航运公会与守灯会争夺航路控制权'],
keyFactions: [],
keyCharacters: [],
keyLandmarks: [],
iconicElements: ['会移动的海雾'],
forbiddenDirectives: [],
rawSettingText: '',
},
draftProfile: {
name: '潮雾列岛',
subtitle: '旧灯塔与失控航路',
summary: '第一版世界底稿已经整理完成。',
tone: '压抑、潮湿、悬疑',
playerGoal: '查清沉船与禁航区异动的真相。',
majorFactions: ['守灯会', '航运公会'],
coreConflicts: ['守灯会与航运公会争夺旧航路控制权'],
playableNpcs: [
{
id: 'playable-1',
name: '沈砺',
title: '旧航路引路人',
role: '关键同行者',
publicIdentity: '最熟悉旧航路的人。',
publicMask: '看上去像可靠旧友。',
currentPressure: '他必须在两股势力间站队。',
hiddenHook: '暗中替沉船商盟引路。',
relationToPlayer: '旧友兼潜在背叛者',
threadIds: ['thread-1'],
summary: '他像旧友,但也像一把始终没收回鞘的刀。',
},
],
storyNpcs: [
{
id: 'story-1',
name: '顾潮音',
title: '守灯会值夜人',
role: '场景关键角色',
publicIdentity: '负责夜间巡灯与封锁。',
publicMask: '对外一直冷静克制。',
currentPressure: '她知道更多禁航区真相。',
hiddenHook: '曾亲眼见过失控海雾吞船。',
relationToPlayer: '最早愿意交换线索的人',
threadIds: ['thread-1'],
summary: '她总像比所有人更早知道海雾会往哪一侧压下来。',
},
],
landmarks: [
{
id: 'landmark-1',
name: '回潮旧灯塔',
purpose: '观察雾潮与往来船只',
mood: '潮湿、压抑、风声不止',
importance: '开局核心场景',
characterIds: ['story-1'],
threadIds: ['thread-1'],
summary: '旧灯塔是整片群岛最先看见异动的地方。',
},
],
factions: [],
threads: [],
chapters: [],
worldHook: '被海雾吞没的旧航路群岛',
playerPremise: '玩家回到群岛调查沉船真相。',
openingSituation: '首夜就有陌生船只闯入禁航区。',
iconicElements: ['会移动的海雾'],
sourceAnchorSummary: '海雾、旧灯塔、失控航路。',
},
draftCards: [
{
id: 'world-foundation',
kind: 'world',
title: '潮雾列岛',
subtitle: '旧灯塔与失控航路',
summary: '第一版世界底稿已经整理完成。',
status: 'warning',
linkedIds: ['playable-1', 'story-1', 'landmark-1'],
warningCount: 0,
},
],
});
vi.mocked(getCustomWorldAgentSession).mockResolvedValue(
compiledAgentDraftSession,
);
render(<TestWrapper withAuth />);
@@ -704,12 +757,12 @@ test('existing draft sessions enter the agent preview layout without opening leg
await user.click(screen.getByRole('button', { name: //u }));
expect(screen.getByRole('button', { name: //u })).toBeTruthy();
expect(screen.queryByText(//u)).toBeNull();
expect(screen.queryByRole('button', { name: /AI/u })).toBeNull();
expect(screen.queryByText('技能')).toBeNull();
await user.click(screen.getByRole('button', { name: //u }));
expect(await screen.findByText(//u)).toBeTruthy();
expect(screen.getByRole('button', { name: /AI/u })).toBeTruthy();
});
test('agent draft result back button returns to workspace without redundant sync when session is already latest', async () => {
test('agent draft result back button returns to creation hub without redundant sync when session is already latest', async () => {
const user = userEvent.setup();
vi.mocked(executeCustomWorldAgentAction).mockResolvedValue({
@@ -843,7 +896,7 @@ test('agent draft result back button returns to workspace without redundant sync
} satisfies CustomWorldAgentSessionSnapshot;
vi.mocked(getCustomWorldAgentSession).mockResolvedValue(resultSession);
render(<TestWrapper />);
render(<TestWrapper withAuth />);
await openNewRpgCreation(user);
@@ -858,9 +911,7 @@ test('agent draft result back button returns to workspace without redundant sync
await user.click(screen.getByRole('button', { name: //u }));
await waitFor(() => {
expect(
screen.getByText('Agent工作区custom-world-agent-session-1'),
).toBeTruthy();
expect(screen.getByText('创作中心')).toBeTruthy();
});
expect(
@@ -968,7 +1019,7 @@ test('agent draft result auto-save persists the latest profile rebuilt from sync
} satisfies CustomWorldAgentSessionSnapshot;
vi.mocked(getCustomWorldAgentSession).mockResolvedValue(syncedSession);
render(<TestWrapper />);
render(<TestWrapper withAuth />);
await openNewRpgCreation(user);

View File

@@ -20,6 +20,8 @@ import type {
import type {
CustomWorldGalleryCard,
CustomWorldLibraryEntry,
PlatformBrowseHistoryEntry,
PlatformBrowseHistoryWriteEntry,
ProfileDashboardSummary,
ProfileSaveArchiveSummary,
} from '../../../packages/shared/src/contracts/runtime';
@@ -46,14 +48,6 @@ import {
writeCustomWorldAgentUiState,
} from '../../services/customWorldAgentUiState';
import { buildCustomWorldCreatorIntentFoundationText } from '../../services/customWorldCreatorIntent';
import {
hasPendingPlatformBrowseHistoryMigration,
markPlatformBrowseHistoryMigrated,
type PlatformBrowseHistoryEntry,
type PlatformBrowseHistoryWriteEntry,
readPlatformBrowseHistory,
writePlatformBrowseHistory,
} from '../../services/platformBrowseHistory';
import {
deleteCustomWorldProfile,
getCustomWorldGalleryDetail,
@@ -64,7 +58,6 @@ import {
listProfileSaveArchives,
publishCustomWorldProfile,
resumeProfileSaveArchive,
syncProfileBrowseHistory,
unpublishCustomWorldProfile,
upsertCustomWorldProfile,
upsertProfileBrowseHistory,
@@ -381,18 +374,11 @@ export function PreGameSelectionFlow({
const appendBrowseHistoryEntry = useCallback(
async (entry: PlatformBrowseHistoryWriteEntry) => {
const nextEntries = writePlatformBrowseHistory(authUi?.user, entry);
setHistoryEntries(nextEntries);
setHistoryError(null);
if (!authUi?.user) {
return;
}
try {
const syncedEntries = await upsertProfileBrowseHistory(entry);
setHistoryEntries(syncedEntries);
markPlatformBrowseHistoryMigrated(authUi?.user);
} catch (error) {
setHistoryError(resolveErrorMessage(error, '写入浏览历史失败。'));
}
@@ -444,8 +430,7 @@ export function PreGameSelectionFlow({
let isActive = true;
void (async () => {
const localHistoryEntries = readPlatformBrowseHistory(authUi?.user);
setHistoryEntries(localHistoryEntries);
setHistoryEntries([]);
setHistoryError(null);
setSaveError(null);
setIsLoadingPlatform(true);
@@ -472,22 +457,7 @@ export function PreGameSelectionFlow({
isAuthenticated ? listCustomWorldWorks() : Promise.resolve([]),
listCustomWorldGallery(),
isAuthenticated ? getProfileDashboard() : Promise.resolve(null),
isAuthenticated
? (async () => {
let nextEntries = await listProfileBrowseHistory();
if (
hasPendingPlatformBrowseHistoryMigration(authUi?.user) &&
localHistoryEntries.length > 0
) {
nextEntries =
await syncProfileBrowseHistory(localHistoryEntries);
markPlatformBrowseHistoryMigrated(authUi?.user);
}
return nextEntries;
})()
: Promise.resolve(localHistoryEntries),
isAuthenticated ? listProfileBrowseHistory() : Promise.resolve([]),
isAuthenticated ? listProfileSaveArchives() : Promise.resolve([]),
]);
if (!isActive) {
@@ -881,8 +851,6 @@ export function PreGameSelectionFlow({
const isAgentDraftGenerationView =
customWorldGenerationViewSource === 'agent-draft-foundation';
const isAgentDraftResultView = customWorldResultViewSource === 'agent-draft';
const isAgentDraftResultEditingFrozen =
customWorldResultViewSource === 'agent-draft';
const activeGenerationSettingText = agentDraftSettingPreview;
const activeGenerationProgress = agentDraftGenerationProgress;
const isActiveGenerationRunning =
@@ -1096,7 +1064,8 @@ export function PreGameSelectionFlow({
setCustomWorldAutoSaveState('idle');
setCustomWorldGenerationViewSource(null);
setCustomWorldResultViewSource(null);
setSelectionStage('agent-workspace');
setPlatformTab('create');
setSelectionStage('platform');
};
const retryAgentDraftGeneration = () => {
@@ -1133,17 +1102,39 @@ export function PreGameSelectionFlow({
const handleOpenCreationWork = useCallback(
async (work: CustomWorldWorkSummary) => {
if (work.status === 'draft' && work.sessionId) {
// 阶段二要求草稿优先回到 Agent 工作区,而不是再次自动顶回结果页。
isAgentDraftResultAutoOpenSuppressedRef.current = true;
persistAgentUiState(work.sessionId, null);
setGeneratedCustomWorldProfile(null);
setCustomWorldError(null);
setCustomWorldAutoSaveError(null);
setCustomWorldAutoSaveState('idle');
setCustomWorldGenerationViewSource(null);
setCustomWorldResultViewSource(null);
const shouldOpenAgentWorkspace =
work.playableNpcCount <= 0 && work.landmarkCount <= 0;
if (shouldOpenAgentWorkspace) {
// 仅八锚点未整理成底稿时才恢复 Agent 对话工作区。
isAgentDraftResultAutoOpenSuppressedRef.current = true;
setGeneratedCustomWorldProfile(null);
setCustomWorldResultViewSource(null);
setPlatformTab('create');
setSelectionStage('agent-workspace');
return;
}
isAgentDraftResultAutoOpenSuppressedRef.current = false;
const latestSession = await syncAgentSessionSnapshot(work.sessionId);
const nextProfile = buildCustomWorldProfileFromAgentDraft(latestSession);
if (!nextProfile) {
setPlatformError('当前草稿还没有可编辑的结果页数据,请先继续补齐锚点。');
setPlatformTab('create');
setSelectionStage('agent-workspace');
return;
}
setGeneratedCustomWorldProfile(normalizeAgentBackedProfile(nextProfile));
setCustomWorldResultViewSource('agent-draft');
setPlatformTab('create');
setSelectionStage('agent-workspace');
setSelectionStage('custom-world-result');
return;
}
@@ -1179,6 +1170,7 @@ export function PreGameSelectionFlow({
openLibraryDetail,
persistAgentUiState,
savedCustomWorldEntries,
syncAgentSessionSnapshot,
setSelectionStage,
],
);
@@ -1583,32 +1575,9 @@ export function PreGameSelectionFlow({
});
}}
onCreateNew={openCreationTypePicker}
onResumeDraft={(sessionId) => {
onOpenDraft={(item) => {
runProtectedAction(() => {
void handleOpenCreationWork({
workId: `draft:${sessionId}`,
sourceType: 'agent_session',
status: 'draft',
title: '',
subtitle: '',
summary: '',
coverImageSrc: null,
coverRenderMode: 'image',
coverCharacterImageSrcs: [],
updatedAt: new Date().toISOString(),
publishedAt: null,
stage: null,
stageLabel: '',
playableNpcCount: 0,
landmarkCount: 0,
roleVisualReadyCount: 0,
roleAnimationReadyCount: 0,
roleAssetSummaryLabel: null,
sessionId,
profileId: null,
canResume: true,
canEnterWorld: false,
});
void handleOpenCreationWork(item);
});
}}
onEnterPublished={(profileId) => {
@@ -1918,10 +1887,10 @@ export function PreGameSelectionFlow({
});
});
}}
readOnly={isAgentDraftResultEditingFrozen}
readOnly={false}
compactAgentResultMode={isAgentDraftResultView}
backLabel={isAgentDraftResultView ? '返回创作' : undefined}
editActionLabel="去Agent调整设定"
editActionLabel="继续调整设定"
enterWorldActionLabel="进入世界"
autoSaveState={customWorldAutoSaveState}
/>

View File

@@ -3,11 +3,9 @@ import { beforeEach, describe, expect, it, vi } from 'vitest';
const {
resolveServerRuntimeChoiceMock,
streamNpcChatTurnMock,
generateQuestForNpcEncounterMock,
} = vi.hoisted(() => ({
resolveServerRuntimeChoiceMock: vi.fn(),
streamNpcChatTurnMock: vi.fn(),
generateQuestForNpcEncounterMock: vi.fn(),
}));
vi.mock('./runtimeStoryCoordinator', () => ({
@@ -18,10 +16,6 @@ vi.mock('../../services/aiService', () => ({
streamNpcChatTurn: streamNpcChatTurnMock,
}));
vi.mock('../../services/questDirector', () => ({
generateQuestForNpcEncounter: generateQuestForNpcEncounterMock,
}));
import {
AnimationState,
type Character,
@@ -593,7 +587,6 @@ describe('npcEncounterActions', () => {
beforeEach(() => {
resolveServerRuntimeChoiceMock.mockReset();
streamNpcChatTurnMock.mockReset();
generateQuestForNpcEncounterMock.mockReset();
});
it.each([
@@ -1371,8 +1364,6 @@ describe('npcEncounterActions', () => {
}),
}),
);
expect(generateQuestForNpcEncounterMock).not.toHaveBeenCalled();
const lastStory = actions.setCurrentStory.mock.calls.at(-1)?.[0] as StoryMoment;
expect(lastStory.npcChatState?.pendingQuestOffer?.quest).toEqual(pendingQuest);
expect(lastStory.npcAffinityEffect).toEqual({
@@ -1393,18 +1384,38 @@ describe('npcEncounterActions', () => {
);
});
it('replaces a pending quest offer by reusing the existing quest generator', async () => {
it('replaces a pending quest offer through the server runtime resolver', async () => {
const currentQuest = createQuest('quest-bridge-offer', '断桥口的密信');
const nextQuest = createQuest('quest-bridge-replaced', '断桥夜巡');
generateQuestForNpcEncounterMock.mockResolvedValueOnce(nextQuest);
resolveServerRuntimeChoiceMock.mockResolvedValueOnce({
hydratedSnapshot: {
gameState: createState(),
},
nextStory: createPendingQuestOfferStory(nextQuest),
});
const actions = createNpcEncounterActions({
currentStory: createPendingQuestOfferStory(currentQuest),
});
await expect(actions.replacePendingNpcQuestOffer()).resolves.toBe(true);
expect(actions.replacePendingNpcQuestOffer()).toBe(true);
await flushAsyncWork();
expect(generateQuestForNpcEncounterMock).toHaveBeenCalledTimes(1);
expect(resolveServerRuntimeChoiceMock).toHaveBeenCalledWith(
expect.objectContaining({
gameState: actions.gameState,
currentStory: actions.currentStory,
option: expect.objectContaining({
functionId: 'npc_chat_quest_offer_replace',
actionText: '你请断桥客换一份更合适的委托。',
interaction: {
kind: 'npc',
npcId: 'npc-rival',
action: 'quest_offer_replace',
},
}),
}),
);
const lastStory = actions.setCurrentStory.mock.calls.at(-1)?.[0] as StoryMoment;
expect(lastStory.npcChatState?.pendingQuestOffer?.quest).toEqual(nextQuest);
expect(lastStory.options.map((option) => option.actionText)).toEqual([
@@ -1485,14 +1496,78 @@ describe('npcEncounterActions', () => {
);
});
it('abandons a pending quest offer and returns to free npc chat', () => {
it('abandons a pending quest offer through the server runtime resolver', async () => {
const pendingQuest = createQuest('quest-bridge-offer', '断桥口的密信');
resolveServerRuntimeChoiceMock.mockResolvedValueOnce({
hydratedSnapshot: {
gameState: createState(),
},
nextStory: {
...createPendingQuestOfferStory(pendingQuest),
options: [
createOption('npc_chat', '那先继续聊聊你刚才没说完的部分', {
kind: 'npc',
npcId: 'npc-rival',
action: 'chat',
}),
createOption('npc_chat', '除了委托,你对眼前局势还有什么判断', {
kind: 'npc',
npcId: 'npc-rival',
action: 'chat',
}),
createOption('npc_chat', '先把这附近真正危险的地方说清楚', {
kind: 'npc',
npcId: 'npc-rival',
action: 'chat',
}),
],
dialogue: [
{
speaker: 'npc',
speakerName: '断桥客',
text: '这件事我只想托给你。',
},
{
speaker: 'player',
text: '这件事我先不接,咱们还是先聊别的。',
},
{
speaker: 'npc',
speakerName: '断桥客',
text: '那就先聊别的。',
},
],
npcChatState: {
npcId: 'npc-rival',
npcName: '断桥客',
turnCount: 2,
customInputPlaceholder: '输入你想对 TA 说的话',
pendingQuestOffer: null,
},
} satisfies StoryMoment,
});
const actions = createNpcEncounterActions({
currentStory: createPendingQuestOfferStory(pendingQuest),
});
expect(actions.abandonPendingNpcQuestOffer()).toBe(true);
await flushAsyncWork();
expect(resolveServerRuntimeChoiceMock).toHaveBeenCalledWith(
expect.objectContaining({
gameState: actions.gameState,
currentStory: actions.currentStory,
option: expect.objectContaining({
functionId: 'npc_chat_quest_offer_abandon',
actionText: '你暂时没有接下断桥客提出的委托。',
interaction: {
kind: 'npc',
npcId: 'npc-rival',
action: 'quest_offer_abandon',
},
}),
}),
);
const lastStory = actions.setCurrentStory.mock.calls.at(-1)?.[0] as StoryMoment;
expect(lastStory.npcChatState?.pendingQuestOffer).toBeNull();
expect(lastStory.options.map((option) => option.actionText)).toEqual([

View File

@@ -24,7 +24,6 @@ import type { StoryGenerationContext } from '../../services/aiTypes';
import {
resolveLimitedPrimaryNpcChatState,
} from '../../services/customWorldSceneActRuntime';
import { generateQuestForNpcEncounter } from '../../services/questDirector';
import { appendStoryEngineCarrierMemory } from '../../services/storyEngine/echoMemory';
import { createHistoryMoment } from '../../services/storyHistory';
import type {
@@ -1558,81 +1557,36 @@ export function createStoryNpcEncounterActions({
}
};
const replacePendingNpcQuestOffer = async () => {
const playerCharacter = gameState.playerCharacter;
const replacePendingNpcQuestOffer = () => {
const encounter = gameState.currentEncounter;
const pendingQuestOffer = isNpcEncounter(encounter)
? getPendingQuestOffer(currentStory, encounter)
: null;
if (!playerCharacter || !encounter || !pendingQuestOffer) {
if (!encounter || !pendingQuestOffer) {
return false;
}
const encounterKey = getNpcEncounterKey(encounter);
const currentNpcChatState =
currentStory?.npcChatState?.npcId === encounterKey
? currentStory.npcChatState
: null;
const currentDialogue =
currentStory?.dialogue && currentNpcChatState
? [...currentStory.dialogue]
: [];
const turnCount = currentNpcChatState?.turnCount ?? 0;
const playerLine = '能不能换一份更适合眼下局势的委托?';
const generationState = {
...gameState,
storyHistory: appendHistory(
gameState,
`你请${encounter.npcName}换一份更合适的委托。`,
`${encounter.npcName}重新斟酌起该交给你的事。`,
),
};
setAiError(null);
setIsLoading(true);
try {
const nextQuest = await generateQuestForNpcEncounter({
state: generationState,
encounter,
});
if (!nextQuest) {
setAiError('当前没有更合适的委托可供更换。');
return false;
}
setGameState(generationState);
setCurrentStory(
buildNpcChatStoryMoment({
encounter,
dialogue: [
...currentDialogue,
{
speaker: 'player',
text: playerLine,
},
{
speaker: 'npc',
speakerName: encounter.npcName,
text: buildQuestOfferDialogueText(encounter, nextQuest),
},
],
options: buildPendingQuestOfferOptions(encounter),
streaming: false,
turnCount,
pendingQuestOffer: {
quest: nextQuest,
},
}),
);
return true;
} catch (error) {
console.error('Failed to replace pending npc quest offer:', error);
setAiError(error instanceof Error ? error.message : '更换任务失败');
return false;
} finally {
setIsLoading(false);
}
void resolveServerNpcStoryAction({
option: {
functionId: 'npc_chat_quest_offer_replace',
actionText: `你请${encounter.npcName}换一份更合适的委托。`,
text: `你请${encounter.npcName}换一份更合适的委托。`,
detailText: '',
visuals: {
playerAnimation: AnimationState.IDLE,
playerMoveMeters: 0,
playerOffsetY: 0,
playerFacing: 'right',
scrollWorld: false,
monsterChanges: [],
},
interaction: {
kind: 'npc',
npcId: encounter.id ?? encounter.npcName,
action: 'quest_offer_replace',
},
},
});
return true;
};
const abandonPendingNpcQuestOffer = () => {
@@ -1643,49 +1597,27 @@ export function createStoryNpcEncounterActions({
if (!encounter || !pendingQuestOffer) {
return false;
}
const encounterKey = getNpcEncounterKey(encounter);
const currentNpcChatState =
currentStory?.npcChatState?.npcId === encounterKey
? currentStory.npcChatState
: null;
const currentDialogue =
currentStory?.dialogue && currentNpcChatState
? [...currentStory.dialogue]
: [];
const turnCount = currentNpcChatState?.turnCount ?? 0;
const playerLine = '这件事我先不接,咱们还是先聊别的。';
const npcReply = `${encounter.npcName}点了点头,没有继续强求,只把这份委托暂时收了回去。`;
const nextState = {
...gameState,
storyHistory: appendHistory(
gameState,
`你暂时没有接下${encounter.npcName}提出的委托。`,
npcReply,
),
};
setGameState(nextState);
setCurrentStory(
buildNpcChatStoryMoment({
encounter,
dialogue: [
...currentDialogue,
{
speaker: 'player',
text: playerLine,
},
{
speaker: 'npc',
speakerName: encounter.npcName,
text: npcReply,
},
],
options: buildPostQuestOfferChatSuggestions(encounter),
streaming: false,
turnCount,
}),
);
void resolveServerNpcStoryAction({
option: {
functionId: 'npc_chat_quest_offer_abandon',
actionText: `你暂时没有接下${encounter.npcName}提出的委托。`,
text: `你暂时没有接下${encounter.npcName}提出的委托。`,
detailText: '',
visuals: {
playerAnimation: AnimationState.IDLE,
playerMoveMeters: 0,
playerOffsetY: 0,
playerFacing: 'right',
scrollWorld: false,
monsterChanges: [],
},
interaction: {
kind: 'npc',
npcId: encounter.id ?? encounter.npcName,
action: 'quest_offer_abandon',
},
},
});
return true;
};

View File

@@ -1,23 +1,17 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
const {
putSaveSnapshotMock,
getRuntimeStoryStateMock,
resolveRuntimeStoryActionMock,
getRuntimeSessionIdMock,
getRuntimeClientVersionMock,
} = vi.hoisted(() => ({
putSaveSnapshotMock: vi.fn(),
getRuntimeStoryStateMock: vi.fn(),
resolveRuntimeStoryActionMock: vi.fn(),
getRuntimeSessionIdMock: vi.fn(() => 'runtime-main'),
getRuntimeClientVersionMock: vi.fn(() => 0),
}));
vi.mock('../../services/storageService', () => ({
putSaveSnapshot: putSaveSnapshotMock,
}));
vi.mock('../../services/runtimeStoryService', async () => {
const actual =
await vi.importActual<typeof import('../../services/runtimeStoryService')>(
@@ -149,7 +143,6 @@ function createRuntimeNpcBattleSnapshot(
describe('runtimeStoryCoordinator', () => {
beforeEach(() => {
putSaveSnapshotMock.mockReset();
getRuntimeStoryStateMock.mockReset();
resolveRuntimeStoryActionMock.mockReset();
getRuntimeSessionIdMock.mockReset();
@@ -209,12 +202,15 @@ describe('runtimeStoryCoordinator', () => {
currentStory,
});
expect(putSaveSnapshotMock).toHaveBeenCalledWith({
gameState,
bottomTab: 'adventure',
currentStory,
expect(getRuntimeStoryStateMock).toHaveBeenCalledWith({
sessionId: 'runtime-main',
clientVersion: 7,
snapshot: {
gameState,
bottomTab: 'adventure',
currentStory,
},
});
expect(getRuntimeStoryStateMock).toHaveBeenCalledWith('runtime-main');
expect(options).toEqual([
expect.objectContaining({
functionId: 'npc_chat',
@@ -306,11 +302,6 @@ describe('runtimeStoryCoordinator', () => {
},
});
expect(putSaveSnapshotMock).toHaveBeenCalledWith({
gameState,
bottomTab: 'adventure',
currentStory,
});
expect(resolveRuntimeStoryActionMock).toHaveBeenCalledWith({
sessionId: 'runtime-main',
clientVersion: 7,
@@ -319,6 +310,11 @@ describe('runtimeStoryCoordinator', () => {
payload: {
note: 'server-runtime-test',
},
snapshot: {
gameState,
bottomTab: 'adventure',
currentStory,
},
});
expect(result.hydratedSnapshot).toBe(hydratedSnapshot);
expect(result.nextStory).toEqual(
@@ -414,7 +410,9 @@ describe('runtimeStoryCoordinator', () => {
const result = await resumeServerRuntimeStory(localHydratedSnapshot);
expect(getRuntimeStoryStateMock).toHaveBeenCalledWith('runtime-main');
expect(getRuntimeStoryStateMock).toHaveBeenCalledWith({
sessionId: 'runtime-main',
});
expect(result.hydratedSnapshot).toBe(serverHydratedSnapshot);
expect(result.nextStory).toEqual(
expect.objectContaining({
@@ -614,6 +612,9 @@ describe('runtimeStoryCoordinator', () => {
const result = await resumeServerRuntimeStory(localHydratedSnapshot);
expect(getRuntimeStoryStateMock).toHaveBeenCalledWith({
sessionId: 'runtime-main',
});
expect(result.hydratedSnapshot.gameState.sceneHostileNpcs[0]).toEqual(
expect.objectContaining({
id: 'npc-bandit',

View File

@@ -5,11 +5,11 @@ import {
getRuntimeSessionId,
getRuntimeStoryState,
resolveRuntimeStoryAction,
type RuntimeStorySnapshotRequest,
resolveRuntimeStoryMoment,
type RuntimeStoryChoicePayload,
type RuntimeStoryResponse,
} from '../../services/runtimeStoryService';
import { putSaveSnapshot } from '../../services/storageService';
import type { GameState, StoryMoment, StoryOption } from '../../types';
function getRuntimeResponseOptions(response: RuntimeStoryResponse) {
@@ -18,26 +18,26 @@ function getRuntimeResponseOptions(response: RuntimeStoryResponse) {
: response.presentation.options;
}
async function syncRuntimeSnapshot(
function buildRuntimeSnapshotRequest(
gameState: GameState,
currentStory: StoryMoment | null,
) {
await putSaveSnapshot({
return {
gameState,
bottomTab: 'adventure',
currentStory,
});
} satisfies RuntimeStorySnapshotRequest;
}
export async function loadServerRuntimeOptionCatalog(params: {
gameState: GameState;
currentStory: StoryMoment | null;
}) {
await syncRuntimeSnapshot(params.gameState, params.currentStory);
const response = await getRuntimeStoryState(
getRuntimeSessionId(params.gameState),
);
const response = await getRuntimeStoryState({
sessionId: getRuntimeSessionId(params.gameState),
clientVersion: getRuntimeClientVersion(params.gameState),
snapshot: buildRuntimeSnapshotRequest(params.gameState, params.currentStory),
});
const options = resolveRuntimeStoryMoment({
response,
hydratedSnapshot: response.snapshot,
@@ -64,9 +64,9 @@ export async function resumeServerRuntimeStory(
};
}
const response = await getRuntimeStoryState(
getRuntimeSessionId(hydratedSnapshot.gameState),
);
const response = await getRuntimeStoryState({
sessionId: getRuntimeSessionId(hydratedSnapshot.gameState),
});
const resumedSnapshot = rehydrateSavedSnapshot(response.snapshot);
const runtimeOptions = getRuntimeResponseOptions(response);
const nextStory =
@@ -96,8 +96,6 @@ export async function resolveServerRuntimeChoice(params: {
Partial<Pick<StoryOption, 'interaction'>>;
payload?: RuntimeStoryChoicePayload;
}) {
await syncRuntimeSnapshot(params.gameState, params.currentStory);
const response = await resolveRuntimeStoryAction({
sessionId: getRuntimeSessionId(params.gameState),
clientVersion: getRuntimeClientVersion(params.gameState),
@@ -107,6 +105,7 @@ export async function resolveServerRuntimeChoice(params: {
? params.option.interaction.npcId
: undefined,
payload: params.payload,
snapshot: buildRuntimeSnapshotRequest(params.gameState, params.currentStory),
});
const hydratedSnapshot = rehydrateSavedSnapshot(response.snapshot);

View File

@@ -82,7 +82,7 @@ export interface QuestFlowUi {
}
export interface NpcChatQuestOfferUi {
replacePendingOffer: () => Promise<boolean>;
replacePendingOffer: () => boolean;
abandonPendingOffer: () => boolean;
acceptPendingOffer: () => string | null;
}

View File

@@ -353,8 +353,37 @@ export async function generateCustomWorldProfile(
input: GenerateCustomWorldProfileInput | string,
options: GenerateCustomWorldProfileOptions = {},
): Promise<CustomWorldProfile> {
const aiClient = await loadLegacyAiModule();
return aiClient.generateCustomWorldProfile(input, options);
const normalizedInput =
typeof input === 'string'
? {
settingText: input,
}
: input;
if (typeof window === 'undefined') {
const aiClient = await loadLegacyAiModule();
return aiClient.generateCustomWorldProfile(normalizedInput, options);
}
if (options.signal?.aborted) {
throw options.signal.reason instanceof Error
? options.signal.reason
: new Error('世界生成已中断。');
}
const profile = await requestPostJson<CustomWorldProfile>(
`${RUNTIME_API_BASE}/custom-world/profile`,
normalizedInput,
'生成自定义世界失败',
);
if (options.signal?.aborted) {
throw options.signal.reason instanceof Error
? options.signal.reason
: new Error('世界生成已中断。');
}
return profile;
}
export async function generateCustomWorldSceneImage(

View File

@@ -1,33 +1,12 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import {
AUTH_STATE_EVENT,
ApiClientError,
clearStoredAccessToken,
fetchWithApiAuth,
getStoredAccessToken,
requestJson,
setStoredAccessToken,
} from './apiClient';
function createMemoryStorage() {
const values = new Map<string, string>();
return {
getItem(key: string) {
return values.has(key) ? values.get(key)! : null;
},
setItem(key: string, value: string) {
values.set(key, value);
},
removeItem(key: string) {
values.delete(key);
},
clear() {
values.clear();
},
};
}
function createResponseMock(params: {
status: number;
body?: string;
@@ -54,50 +33,18 @@ function createResponseMock(params: {
describe('apiClient', () => {
const fetchMock = vi.fn();
const dispatchEventMock = vi.fn();
beforeEach(() => {
vi.stubGlobal('fetch', fetchMock);
vi.stubGlobal('window', {
localStorage: createMemoryStorage(),
dispatchEvent: vi.fn(),
dispatchEvent: dispatchEventMock,
});
fetchMock.mockReset();
clearStoredAccessToken();
dispatchEventMock.mockReset();
});
it('attaches auth headers and clears stale tokens on unauthorized responses', async () => {
setStoredAccessToken('jwt-token');
fetchMock
.mockResolvedValueOnce(createResponseMock({ status: 401 }))
.mockResolvedValueOnce(createResponseMock({ status: 401 }));
const response = await fetchWithApiAuth('/api/protected', { method: 'GET' });
expect(response.status).toBe(401);
expect(fetchMock).toHaveBeenCalledTimes(2);
expect(fetchMock).toHaveBeenCalledWith(
'/api/protected',
expect.objectContaining({
credentials: 'same-origin',
headers: expect.objectContaining({
Authorization: 'Bearer jwt-token',
'x-genarrative-response-envelope': 'v1',
}),
}),
);
expect(fetchMock).toHaveBeenNthCalledWith(
2,
'/api/auth/refresh',
expect.objectContaining({
method: 'POST',
credentials: 'same-origin',
}),
);
expect(getStoredAccessToken()).toBe('');
});
it('refreshes the access token once and retries the original request', async () => {
setStoredAccessToken('expired-token');
it('refreshes cookie session once and retries the original request', async () => {
fetchMock
.mockResolvedValueOnce(createResponseMock({ status: 401 }))
.mockResolvedValueOnce(
@@ -106,7 +53,7 @@ describe('apiClient', () => {
body: JSON.stringify({
ok: true,
data: {
token: 'fresh-token',
ok: true,
},
error: null,
meta: {
@@ -138,41 +85,115 @@ describe('apiClient', () => {
);
expect(result).toEqual({ value: 7 });
expect(getStoredAccessToken()).toBe('fresh-token');
expect(fetchMock).toHaveBeenCalledTimes(3);
expect(fetchMock).toHaveBeenNthCalledWith(
1,
'/api/runtime/protected',
expect.objectContaining({
credentials: 'same-origin',
headers: expect.objectContaining({
'x-genarrative-response-envelope': 'v1',
}),
}),
);
expect(fetchMock).toHaveBeenNthCalledWith(
2,
'/api/auth/refresh',
expect.objectContaining({
method: 'POST',
credentials: 'same-origin',
}),
);
expect(fetchMock).toHaveBeenNthCalledWith(
3,
'/api/runtime/protected',
expect.objectContaining({
headers: expect.objectContaining({
Authorization: 'Bearer fresh-token',
}),
credentials: 'same-origin',
}),
);
expect(dispatchEventMock).toHaveBeenCalledTimes(1);
expect(dispatchEventMock).toHaveBeenCalledWith(
expect.objectContaining({
type: AUTH_STATE_EVENT,
}),
);
});
it('does not refresh or emit auth changes for 401 responses without auth context', async () => {
it('does not emit auth change events when 401 probe requests opt into silent mode', async () => {
fetchMock.mockResolvedValueOnce(createResponseMock({ status: 401 }));
const response = await fetchWithApiAuth(
'/api/auth/me',
{
method: 'GET',
},
{
notifyAuthStateChange: false,
skipRefresh: true,
},
);
expect(response.status).toBe(401);
expect(fetchMock).toHaveBeenCalledTimes(1);
expect(dispatchEventMock).not.toHaveBeenCalled();
});
it('emits auth change events when refresh fails on protected requests', async () => {
fetchMock
.mockResolvedValueOnce(createResponseMock({ status: 401 }))
.mockResolvedValueOnce(createResponseMock({ status: 401 }));
const response = await fetchWithApiAuth('/api/runtime/protected', {
method: 'GET',
});
expect(response.status).toBe(401);
expect(fetchMock).toHaveBeenCalledTimes(1);
expect(fetchMock).toHaveBeenCalledWith(
expect(fetchMock).toHaveBeenCalledTimes(2);
expect(dispatchEventMock).toHaveBeenCalledTimes(1);
});
it('accepts refresh responses that only acknowledge renewed cookie state', async () => {
fetchMock
.mockResolvedValueOnce(createResponseMock({ status: 401 }))
.mockResolvedValueOnce(
createResponseMock({
status: 200,
body: JSON.stringify({
ok: true,
data: {
ok: true,
},
error: null,
meta: {
apiVersion: '2026-04-08',
},
}),
}),
)
.mockResolvedValueOnce(
createResponseMock({
status: 200,
body: JSON.stringify({
ok: true,
data: {
value: 9,
},
error: null,
meta: {
apiVersion: '2026-04-08',
},
}),
}),
);
const result = await requestJson<{ value: number }>(
'/api/runtime/protected',
expect.objectContaining({
credentials: 'same-origin',
}),
{ method: 'GET' },
'读取受保护数据失败',
);
expect(window.dispatchEvent).not.toHaveBeenCalled();
expect(result).toEqual({ value: 9 });
expect(fetchMock).toHaveBeenCalledTimes(3);
});
it('retries transient get requests before unwrapping the response envelope', async () => {

View File

@@ -7,8 +7,8 @@ import {
parseApiErrorMessage,
unwrapApiResponse,
} from '../../packages/shared/src/http';
import type { AuthRefreshResponse } from '../../packages/shared/src/contracts/auth';
const ACCESS_TOKEN_KEY = 'genarrative.auth.access-token.v1';
export const AUTH_STATE_EVENT = 'genarrative-auth-state-changed';
const REQUEST_ID_HEADER = 'x-request-id';
const API_VERSION_HEADER = 'x-api-version';
@@ -30,6 +30,8 @@ export type ApiRequestOptions = {
skipAuth?: boolean;
omitEnvelopeHeader?: boolean;
skipRefresh?: boolean;
// 会话探测类请求需要静默处理 401避免 AuthGate 因自发广播再次触发 hydrate。
notifyAuthStateChange?: boolean;
};
type ResolvedRetryOptions = {
@@ -48,10 +50,6 @@ type ParsedApiErrorShape = {
meta: Partial<ApiMeta>;
};
type RefreshTokenResponse = {
token: string;
};
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null;
}
@@ -311,11 +309,7 @@ export class ApiClientError extends Error {
}
}
function canUseLocalStorage() {
return typeof window !== 'undefined' && typeof window.localStorage !== 'undefined';
}
function emitAuthStateChange() {
export function emitAuthStateChange() {
if (typeof window === 'undefined') {
return;
}
@@ -330,72 +324,18 @@ function emitAuthStateChange() {
}
}
export function getStoredAccessToken() {
if (!canUseLocalStorage()) {
return '';
}
return window.localStorage.getItem(ACCESS_TOKEN_KEY)?.trim() || '';
}
export function setStoredAccessToken(
token: string,
options: {
emit?: boolean;
} = {},
) {
if (!canUseLocalStorage()) {
return;
}
const nextToken = token.trim();
const previousToken = getStoredAccessToken();
if (nextToken) {
window.localStorage.setItem(ACCESS_TOKEN_KEY, nextToken);
} else {
window.localStorage.removeItem(ACCESS_TOKEN_KEY);
}
// 只有登录态令牌真的发生变化时才广播事件,避免无意义的鉴权重算。
if (options.emit !== false && previousToken !== nextToken) {
emitAuthStateChange();
}
}
export function clearStoredAccessToken(
options: {
emit?: boolean;
} = {},
) {
if (!canUseLocalStorage()) {
return;
}
const previousToken = getStoredAccessToken();
window.localStorage.removeItem(ACCESS_TOKEN_KEY);
// 未登录态下重复清空 token 不应触发状态刷新,否则会放大 401 循环。
if (options.emit !== false && previousToken) {
emitAuthStateChange();
}
}
function withAuthorizationHeaders(
function withApiHeaders(
headers?: HeadersInit,
options: Pick<ApiRequestOptions, 'omitEnvelopeHeader' | 'skipAuth'> = {},
options: Pick<ApiRequestOptions, 'omitEnvelopeHeader'> = {},
) {
const nextHeaders = normalizeHeaders(headers);
const token = getStoredAccessToken();
if (token && !options.skipAuth) {
nextHeaders.Authorization = `Bearer ${token}`;
}
if (!options.omitEnvelopeHeader) {
nextHeaders[API_RESPONSE_ENVELOPE_HEADER] = API_RESPONSE_ENVELOPE_VERSION;
}
return nextHeaders;
}
let refreshAccessTokenPromise: Promise<string> | null = null;
let refreshAccessTokenPromise: Promise<void> | null = null;
async function refreshAccessToken() {
if (refreshAccessTokenPromise) {
@@ -412,24 +352,19 @@ async function refreshAccessToken() {
});
if (!response.ok) {
clearStoredAccessToken();
throw await buildApiClientError(response, '刷新登录状态失败');
}
const responseText = await response.text();
const payload = responseText
? unwrapApiResponse<RefreshTokenResponse>(
JSON.parse(responseText) as RefreshTokenResponse,
? unwrapApiResponse<AuthRefreshResponse>(
JSON.parse(responseText) as AuthRefreshResponse,
)
: null;
if (!payload?.token?.trim()) {
clearStoredAccessToken();
if (payload?.ok !== true) {
throw new Error('刷新登录状态失败');
}
setStoredAccessToken(payload.token, { emit: false });
return payload.token;
})();
try {
@@ -446,25 +381,20 @@ export async function fetchWithApiAuth(
) {
const method = (init.method ?? 'GET').toUpperCase();
const retry = resolveRetryOptions(method, options.retry);
const shouldNotifyAuthStateChange = options.notifyAuthStateChange !== false;
let attempt = 0;
let refreshAttempted = false;
for (;;) {
try {
const requestHeaders = withAuthorizationHeaders(init.headers, options);
const hasAuthHeader = Boolean(
requestHeaders.Authorization?.trim() ||
requestHeaders.authorization?.trim(),
);
const response = await fetch(input, {
credentials: 'same-origin',
...init,
headers: requestHeaders,
headers: withApiHeaders(init.headers, options),
});
if (
response.status === 401 &&
hasAuthHeader &&
!options.skipAuth &&
!options.skipRefresh &&
!refreshAttempted
@@ -472,12 +402,19 @@ export async function fetchWithApiAuth(
try {
await refreshAccessToken();
refreshAttempted = true;
if (shouldNotifyAuthStateChange) {
emitAuthStateChange();
}
continue;
} catch {
clearStoredAccessToken();
if (shouldNotifyAuthStateChange) {
emitAuthStateChange();
}
}
} else if (response.status === 401 && !options.skipAuth) {
if (shouldNotifyAuthStateChange) {
emitAuthStateChange();
}
} else if (response.status === 401) {
clearStoredAccessToken();
}
if (!shouldRetryResponse(response.status, attempt, retry)) {

View File

@@ -1,15 +1,20 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
const { requestJsonMock } = vi.hoisted(() => ({
requestJsonMock: vi.fn(),
const apiClientMocks = vi.hoisted(() => ({
emitAuthStateChange: vi.fn(),
requestJson: vi.fn(),
}));
import {
ApiClientError,
clearStoredAccessToken,
getStoredAccessToken,
setStoredAccessToken,
} from './apiClient';
vi.mock('./apiClient', async () => {
const actual = await vi.importActual<typeof import('./apiClient')>('./apiClient');
return {
...actual,
emitAuthStateChange: apiClientMocks.emitAuthStateChange,
requestJson: apiClientMocks.requestJson,
};
});
import { ApiClientError } from './apiClient';
import {
authEntryWithStoredCredentials,
bindWechatPhone,
@@ -22,49 +27,34 @@ import {
getAuthRiskBlocks,
getAuthSessions,
getCaptchaChallengeFromError,
getCurrentAuthUser,
liftAuthRiskBlock,
loginWithPhoneCode,
logoutAllAuthSessions,
revokeAuthSession,
sendPhoneLoginCode,
startWechatLogin,
} from './authService';
function createMemoryStorage() {
const values = new Map<string, string>();
function createWindowMock(overrides: Record<string, unknown> = {}) {
return {
getItem(key: string) {
return values.has(key) ? values.get(key)! : null;
dispatchEvent: vi.fn(),
location: {
pathname: '/',
hash: '',
search: '',
assign: vi.fn(),
},
setItem(key: string, value: string) {
values.set(key, value);
},
removeItem(key: string) {
values.delete(key);
},
clear() {
values.clear();
history: {
replaceState: vi.fn(),
},
...overrides,
};
}
vi.mock('./apiClient', async () => {
const actual = await vi.importActual<typeof import('./apiClient')>('./apiClient');
return {
...actual,
requestJson: requestJsonMock,
};
});
describe('authService auto auth', () => {
describe('authService', () => {
beforeEach(() => {
vi.stubGlobal('window', {
localStorage: createMemoryStorage(),
dispatchEvent: vi.fn(),
});
requestJsonMock.mockReset();
clearStoredAccessToken();
vi.clearAllMocks();
vi.stubGlobal('window', createWindowMock());
});
it('creates credentials that match current username/password constraints', () => {
@@ -75,9 +65,8 @@ describe('authService auto auth', () => {
expect(credentials.password.length).toBeGreaterThanOrEqual(6);
});
it('stores jwt after auth entry without persisting guest credentials locally', async () => {
requestJsonMock.mockResolvedValue({
token: 'jwt-token-value',
it('auth entry trims guest credentials and emits auth state changes', async () => {
apiClientMocks.requestJson.mockResolvedValue({
user: {
id: 'user_1',
username: 'guest_abc123abc123',
@@ -95,8 +84,7 @@ describe('authService auto auth', () => {
});
expect(user.username).toBe('guest_abc123abc123');
expect(getStoredAccessToken()).toBe('jwt-token-value');
expect(requestJsonMock).toHaveBeenCalledWith(
expect(apiClientMocks.requestJson).toHaveBeenCalledWith(
'/api/auth/entry',
expect.objectContaining({
body: JSON.stringify({
@@ -106,11 +94,11 @@ describe('authService auto auth', () => {
}),
'登录失败',
);
expect(apiClientMocks.emitAuthStateChange).toHaveBeenCalledTimes(1);
});
it('creates a fresh guest credential pair for auto auth when a session is missing', async () => {
requestJsonMock.mockResolvedValue({
token: 'jwt-restored',
apiClientMocks.requestJson.mockResolvedValue({
user: {
id: 'user_saved',
username: 'guest_saveduser01',
@@ -124,7 +112,7 @@ describe('authService auto auth', () => {
const result = await ensureAutoAuthUser();
const authEntryBody = JSON.parse(
requestJsonMock.mock.calls[0]?.[1]?.body as string,
apiClientMocks.requestJson.mock.calls[0]?.[1]?.body as string,
) as {
username: string;
password: string;
@@ -136,19 +124,11 @@ describe('authService auto auth', () => {
/^auto_[a-z0-9]{24}_[a-z0-9]{8}$/u,
);
expect(authEntryBody).toEqual(result.credentials);
expect(requestJsonMock).toHaveBeenCalledWith(
'/api/auth/entry',
expect.objectContaining({
method: 'POST',
body: expect.any(String),
}),
'登录失败',
);
expect(apiClientMocks.requestJson).toHaveBeenCalledTimes(1);
});
it('deduplicates concurrent auto auth requests', async () => {
requestJsonMock.mockResolvedValue({
token: 'jwt-auto',
apiClientMocks.requestJson.mockResolvedValue({
user: {
id: 'user_auto',
username: 'guest_auto',
@@ -165,19 +145,12 @@ describe('authService auto auth', () => {
ensureAutoAuthUser(),
]);
expect(requestJsonMock).toHaveBeenCalledTimes(1);
expect(apiClientMocks.requestJson).toHaveBeenCalledTimes(1);
expect(firstResult).toEqual(secondResult);
const authEntryBody = JSON.parse(
requestJsonMock.mock.calls[0]?.[1]?.body as string,
) as {
username: string;
password: string;
};
expect(authEntryBody).toEqual(firstResult.credentials);
});
it('sends phone login code through the new auth endpoint', async () => {
requestJsonMock.mockResolvedValue({
it('sends phone login code through the auth endpoint', async () => {
apiClientMocks.requestJson.mockResolvedValue({
ok: true,
cooldownSeconds: 60,
expiresInSeconds: 300,
@@ -187,7 +160,7 @@ describe('authService auto auth', () => {
const result = await sendPhoneLoginCode(' 138 0013 8000 ');
expect(result.cooldownSeconds).toBe(60);
expect(requestJsonMock).toHaveBeenCalledWith(
expect(apiClientMocks.requestJson).toHaveBeenCalledWith(
'/api/auth/phone/send-code',
expect.objectContaining({
body: JSON.stringify({
@@ -199,28 +172,6 @@ describe('authService auto auth', () => {
);
});
it('sends phone change code with the correct scene', async () => {
requestJsonMock.mockResolvedValue({
ok: true,
cooldownSeconds: 60,
expiresInSeconds: 300,
providerRequestId: 'mock-request-id',
});
await sendPhoneLoginCode('13900139000', 'change_phone');
expect(requestJsonMock).toHaveBeenCalledWith(
'/api/auth/phone/send-code',
expect.objectContaining({
body: JSON.stringify({
phone: '13900139000',
scene: 'change_phone',
}),
}),
'发送验证码失败',
);
});
it('extracts captcha challenge details from api errors', () => {
expect(getCaptchaChallengeFromError(new Error('plain error'))).toBeNull();
@@ -246,9 +197,8 @@ describe('authService auto auth', () => {
});
});
it('stores jwt after phone login', async () => {
requestJsonMock.mockResolvedValue({
token: 'phone-jwt-token',
it('emits auth state changes after phone login', async () => {
apiClientMocks.requestJson.mockResolvedValue({
user: {
id: 'user_phone',
username: '138****8000',
@@ -263,8 +213,7 @@ describe('authService auto auth', () => {
const user = await loginWithPhoneCode('13800138000', '123456');
expect(user.username).toBe('138****8000');
expect(getStoredAccessToken()).toBe('phone-jwt-token');
expect(requestJsonMock).toHaveBeenCalledWith(
expect(apiClientMocks.requestJson).toHaveBeenCalledWith(
'/api/auth/phone/login',
expect.objectContaining({
body: JSON.stringify({
@@ -274,11 +223,11 @@ describe('authService auto auth', () => {
}),
'登录失败',
);
expect(apiClientMocks.emitAuthStateChange).toHaveBeenCalledTimes(1);
});
it('binds wechat phone and stores jwt after activation', async () => {
requestJsonMock.mockResolvedValue({
token: 'wechat-bind-token',
it('emits auth state changes after wechat bind activation', async () => {
apiClientMocks.requestJson.mockResolvedValue({
user: {
id: 'user_wechat',
username: '138****8000',
@@ -293,22 +242,11 @@ describe('authService auto auth', () => {
const user = await bindWechatPhone('13800138000', '123456');
expect(user.wechatBound).toBe(true);
expect(getStoredAccessToken()).toBe('wechat-bind-token');
expect(requestJsonMock).toHaveBeenCalledWith(
'/api/auth/wechat/bind-phone',
expect.objectContaining({
body: JSON.stringify({
phone: '13800138000',
code: '123456',
}),
}),
'绑定手机号失败',
);
expect(apiClientMocks.emitAuthStateChange).toHaveBeenCalledTimes(1);
});
it('changes phone number without replacing the stored access token', async () => {
setStoredAccessToken('active-token');
requestJsonMock.mockResolvedValue({
it('changes phone number without emitting a global auth state refresh', async () => {
apiClientMocks.requestJson.mockResolvedValue({
user: {
id: 'user_phone',
username: '139****9000',
@@ -323,41 +261,29 @@ describe('authService auto auth', () => {
const user = await changePhoneNumber('13900139000', '123456');
expect(user.phoneNumberMasked).toBe('139****9000');
expect(getStoredAccessToken()).toBe('active-token');
expect(requestJsonMock).toHaveBeenCalledWith(
'/api/auth/phone/change',
expect.objectContaining({
body: JSON.stringify({
phone: '13900139000',
code: '123456',
}),
}),
'更换手机号失败',
);
expect(apiClientMocks.emitAuthStateChange).not.toHaveBeenCalled();
});
it('starts wechat login by navigating to backend authorization url', async () => {
const assignMock = vi.fn();
vi.stubGlobal('window', {
localStorage: createMemoryStorage(),
dispatchEvent: vi.fn(),
location: {
pathname: '/',
hash: '',
search: '',
assign: assignMock,
},
history: {
replaceState: vi.fn(),
},
});
requestJsonMock.mockResolvedValue({
vi.stubGlobal(
'window',
createWindowMock({
location: {
pathname: '/',
hash: '',
search: '',
assign: assignMock,
},
}),
);
apiClientMocks.requestJson.mockResolvedValue({
authorizationUrl: '/api/auth/wechat/callback?mock_code=wx-user&state=state123',
});
await startWechatLogin();
expect(requestJsonMock).toHaveBeenCalledWith(
expect(apiClientMocks.requestJson).toHaveBeenCalledWith(
'/api/auth/wechat/start?redirectPath=%2F',
expect.objectContaining({
method: 'GET',
@@ -370,14 +296,14 @@ describe('authService auto auth', () => {
});
it('loads available login methods for the unauthenticated login screen', async () => {
requestJsonMock.mockResolvedValue({
apiClientMocks.requestJson.mockResolvedValue({
availableLoginMethods: ['phone', 'wechat'],
});
const result = await getAuthLoginOptions();
expect(result.availableLoginMethods).toEqual(['phone', 'wechat']);
expect(requestJsonMock).toHaveBeenCalledWith(
expect(apiClientMocks.requestJson).toHaveBeenCalledWith(
'/api/auth/login-options',
expect.objectContaining({
method: 'GET',
@@ -386,20 +312,22 @@ describe('authService auto auth', () => {
);
});
it('consumes auth callback hash and stores token', () => {
it('consumes auth callback hash without trying to persist tokens locally', () => {
const replaceStateMock = vi.fn();
vi.stubGlobal('window', {
localStorage: createMemoryStorage(),
dispatchEvent: vi.fn(),
location: {
pathname: '/',
search: '',
hash: '#auth_provider=wechat&auth_token=wx-token&auth_binding_status=pending_bind_phone',
},
history: {
replaceState: replaceStateMock,
},
});
vi.stubGlobal(
'window',
createWindowMock({
location: {
pathname: '/',
search: '',
hash: '#auth_provider=wechat&auth_binding_status=pending_bind_phone',
assign: vi.fn(),
},
history: {
replaceState: replaceStateMock,
},
}),
);
const result = consumeAuthCallbackResult();
@@ -408,12 +336,36 @@ describe('authService auto auth', () => {
bindingStatus: 'pending_bind_phone',
error: null,
});
expect(getStoredAccessToken()).toBe('wx-token');
expect(apiClientMocks.emitAuthStateChange).not.toHaveBeenCalled();
expect(replaceStateMock).toHaveBeenCalledWith(null, '', '/');
});
it('gets current auth user with silent auth-state notification settings', async () => {
apiClientMocks.requestJson.mockResolvedValue({
user: null,
availableLoginMethods: ['phone'],
});
const result = await getCurrentAuthUser();
expect(result).toEqual({
user: null,
availableLoginMethods: ['phone'],
});
expect(apiClientMocks.requestJson).toHaveBeenCalledWith(
'/api/auth/me',
expect.objectContaining({
method: 'GET',
}),
'读取当前用户失败',
{
notifyAuthStateChange: false,
},
);
});
it('loads auth sessions from account center endpoint', async () => {
requestJsonMock.mockResolvedValue({
apiClientMocks.requestJson.mockResolvedValue({
sessions: [
{
sessionId: 'usess_1',
@@ -432,17 +384,10 @@ describe('authService auto auth', () => {
const sessions = await getAuthSessions();
expect(sessions).toHaveLength(1);
expect(requestJsonMock).toHaveBeenCalledWith(
'/api/auth/sessions',
expect.objectContaining({
method: 'GET',
}),
'读取登录设备失败',
);
});
it('loads recent auth audit logs', async () => {
requestJsonMock.mockResolvedValue({
apiClientMocks.requestJson.mockResolvedValue({
logs: [
{
id: 'audit_1',
@@ -459,17 +404,10 @@ describe('authService auto auth', () => {
const logs = await getAuthAuditLogs();
expect(logs).toHaveLength(1);
expect(requestJsonMock).toHaveBeenCalledWith(
'/api/auth/audit-logs',
expect.objectContaining({
method: 'GET',
}),
'读取账号操作记录失败',
);
});
it('loads current risk blocks', async () => {
requestJsonMock.mockResolvedValue({
apiClientMocks.requestJson.mockResolvedValue({
blocks: [
{
scopeType: 'phone',
@@ -484,23 +422,16 @@ describe('authService auto auth', () => {
const blocks = await getAuthRiskBlocks();
expect(blocks).toHaveLength(1);
expect(requestJsonMock).toHaveBeenCalledWith(
'/api/auth/risk-blocks',
expect.objectContaining({
method: 'GET',
}),
'读取安全状态失败',
);
});
it('lifts a risk block by scope type', async () => {
requestJsonMock.mockResolvedValue({
apiClientMocks.requestJson.mockResolvedValue({
ok: true,
});
await liftAuthRiskBlock('phone');
expect(requestJsonMock).toHaveBeenCalledWith(
expect(apiClientMocks.requestJson).toHaveBeenCalledWith(
'/api/auth/risk-blocks/phone/lift',
expect.objectContaining({
method: 'POST',
@@ -509,37 +440,20 @@ describe('authService auto auth', () => {
);
});
it('revokes a remote auth session by id', async () => {
requestJsonMock.mockResolvedValue({
ok: true,
});
await revokeAuthSession('usess_123');
expect(requestJsonMock).toHaveBeenCalledWith(
'/api/auth/sessions/usess_123/revoke',
expect.objectContaining({
method: 'POST',
}),
'移除登录设备失败',
);
});
it('clears local auth state after logout all sessions', async () => {
setStoredAccessToken('stale-token');
requestJsonMock.mockResolvedValue({
it('emits auth change after logout all sessions', async () => {
apiClientMocks.requestJson.mockResolvedValue({
ok: true,
});
await logoutAllAuthSessions();
expect(requestJsonMock).toHaveBeenCalledWith(
expect(apiClientMocks.requestJson).toHaveBeenCalledWith(
'/api/auth/logout-all',
expect.objectContaining({
method: 'POST',
}),
'退出全部设备失败',
);
expect(getStoredAccessToken()).toBe('');
expect(apiClientMocks.emitAuthStateChange).toHaveBeenCalledTimes(1);
});
});

View File

@@ -23,9 +23,8 @@ import type {
} from '../../packages/shared/src/contracts/auth';
import {
ApiClientError,
clearStoredAccessToken,
emitAuthStateChange,
requestJson,
setStoredAccessToken,
} from './apiClient';
export type { AuthUser } from '../../packages/shared/src/contracts/auth';
@@ -117,7 +116,7 @@ export function createAutoAuthCredentials(): AutoAuthCredentials {
}
export function clearAuthSession() {
clearStoredAccessToken();
emitAuthStateChange();
}
export async function sendPhoneLoginCode(
@@ -160,7 +159,7 @@ export async function loginWithPhoneCode(phone: string, code: string) {
'登录失败',
);
setStoredAccessToken(response.token);
emitAuthStateChange();
return response.user;
}
@@ -178,7 +177,7 @@ export async function bindWechatPhone(phone: string, code: string) {
'绑定手机号失败',
);
setStoredAccessToken(response.token);
emitAuthStateChange();
return response.user;
}
@@ -233,7 +232,7 @@ export async function authEntry(username: string, password: string) {
'登录失败',
);
setStoredAccessToken(response.token);
emitAuthStateChange();
return response.user;
}
@@ -279,19 +278,14 @@ export function consumeAuthCallbackResult(): ConsumedAuthCallback | null {
}
const params = new URLSearchParams(hash);
const authToken = params.get('auth_token');
const authError = params.get('auth_error');
const providerValue = params.get('auth_provider');
const bindingStatus = params.get('auth_binding_status');
if (!authToken && !authError) {
if (!bindingStatus && !authError && !providerValue) {
return null;
}
if (authToken) {
setStoredAccessToken(authToken);
}
if (typeof window.history?.replaceState === 'function') {
window.history.replaceState(
null,
@@ -314,6 +308,10 @@ export async function getCurrentAuthUser(): Promise<AuthSessionSnapshot> {
method: 'GET',
},
'读取当前用户失败',
{
// 会话恢复阶段允许 401 作为“未登录”信号,不应再广播一次全局鉴权事件。
notifyAuthStateChange: false,
},
);
return {

View File

@@ -777,3 +777,85 @@ test('embedded legacy result profile keeps result-page settings in runtime chara
'守灯会值夜人,对外总像比别人更冷静一步。',
);
});
test('embedded legacy result profile uses latest draft role collection when legacy role ids drift', () => {
const profile = buildCustomWorldProfileFromAgentDraft({
...session,
draftProfile: {
...session.draftProfile,
legacyResultProfile: buildLegacyResultProfile(),
storyNpcs: [
{
id: 'story-npc-latest-1',
name: '林教授',
title: '深海学院导师',
role: '场景关键角色',
publicIdentity: '研究古代海洋遗迹的资深学者。',
publicMask: '总是先观察,再给出判断。',
currentPressure: '必须在遗迹崩塌前带出关键样本。',
hiddenHook: '他知道遗迹深处那扇门为何会苏醒。',
relationToPlayer: '最早愿意共享海图的人',
threadIds: ['thread-1'],
summary: '他像学者,也像提前看见灾变的人。',
imageSrc:
'/generated-characters/story-npc-latest-1/visual/asset-latest/master.png',
generatedVisualAssetId: 'asset-latest-story',
},
],
sceneChapters: [
{
id: 'scene-chapter-latest-1',
sceneId: 'landmark-1',
sceneName: '回潮旧灯塔',
title: '灯塔新章',
summary: '围绕林教授推进的新章节。',
linkedThreadIds: ['thread-1'],
linkedLandmarkIds: ['landmark-1'],
acts: [
{
id: 'scene-act-latest-1',
title: '第一幕',
summary: '先接林教授的入口信息。',
stageCoverage: ['opening'],
backgroundImageSrc:
'/generated-custom-world-scenes/landmark-1/scene-act-latest-1/scene.png',
backgroundAssetId: 'scene-asset-latest',
encounterNpcIds: ['story-npc-latest-1'],
primaryNpcId: 'story-npc-latest-1',
linkedThreadIds: ['thread-1'],
actGoal: '接住新的入口信息',
transitionHook: '向下一幕推进。',
advanceRule: 'after_primary_contact',
},
],
},
],
landmarks: [
{
...session.draftProfile.landmarks[0],
imageSrc: '/generated-custom-world-scenes/landmark-1/latest-scene.png',
},
],
},
});
expect(profile?.storyNpcs).toHaveLength(1);
expect(profile?.storyNpcs[0]?.id).toBe('story-npc-latest-1');
expect(profile?.storyNpcs[0]?.name).toBe('林教授');
expect(profile?.storyNpcs[0]?.imageSrc).toBe(
'/generated-characters/story-npc-latest-1/visual/asset-latest/master.png',
);
expect(profile?.storyNpcs[0]?.generatedVisualAssetId).toBe(
'asset-latest-story',
);
expect(profile?.storyNpcs[0]?.narrativeProfile).toBeFalsy();
expect(profile?.landmarks[0]?.imageSrc).toBe(
'/generated-custom-world-scenes/landmark-1/latest-scene.png',
);
expect(profile?.sceneChapterBlueprints?.[0]?.acts[0]?.primaryNpcId).toBe(
'story-npc-latest-1',
);
expect(profile?.sceneChapterBlueprints?.[0]?.acts[0]?.backgroundImageSrc).toBe(
'/generated-custom-world-scenes/landmark-1/scene-act-latest-1/scene.png',
);
});

View File

@@ -147,6 +147,13 @@ type AdaptedDraftLandmark = {
connections: never[];
};
type AdaptedDraftCamp = {
name: string;
description: string;
dangerLevel: string;
imageSrc?: string;
};
function adaptDraftLandmarks(value: unknown, storyNpcIdSet: Set<string>) {
return toRecordArray(value)
.map((record, index) => {
@@ -178,108 +185,225 @@ function adaptDraftLandmarks(value: unknown, storyNpcIdSet: Set<string>) {
.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;
}
function adaptDraftCamp(value: unknown): AdaptedDraftCamp | null {
if (!isRecord(value)) {
return null;
}
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;
const name = toText(value.name);
const description = toText(value.description);
if (!name && !description) {
return null;
}
return {
...baseProfile,
storyNpcs: mergedRoles,
} satisfies CustomWorldProfile;
name: name || '开局据点',
description: description || '开局落脚点仍待继续精修。',
dangerLevel:
toText(value.dangerLevel) || toText(value.mood) || 'medium',
imageSrc: toText(value.imageSrc) || undefined,
} satisfies AdaptedDraftCamp;
}
function mergeDraftSceneAssetsIntoProfile(
baseProfile: CustomWorldProfile,
draftSceneChapters: CustomWorldProfile['sceneChapterBlueprints'],
draftLandmarks: AdaptedDraftLandmark[],
function normalizeMatchText(value: unknown) {
return toText(value).toLocaleLowerCase();
}
function findRecordMatchIndex(
records: Record<string, unknown>[],
matcher: (record: Record<string, unknown>) => boolean,
usedIndexes: Set<number>,
) {
const normalizedDraftSceneChapters = draftSceneChapters ?? [];
const draftSceneChapterBySceneId = new Map(
normalizedDraftSceneChapters.map((chapter) => [chapter.sceneId, chapter]),
const matchedIndex = records.findIndex(
(record, index) => !usedIndexes.has(index) && matcher(record),
);
const draftLandmarkById = new Map(draftLandmarks.map((entry) => [entry.id, entry]));
if (matchedIndex >= 0) {
usedIndexes.add(matchedIndex);
}
return matchedIndex;
}
const nextCamp = baseProfile.camp
? {
...baseProfile.camp,
imageSrc: baseProfile.camp.imageSrc,
}
: baseProfile.camp;
function mergeDraftRolesIntoProfileRecord(params: {
baseRoles: unknown;
draftRoles: AdaptedDraftCharacter[];
}) {
const baseRoles = toRecordArray(params.baseRoles);
if (params.draftRoles.length <= 0) {
return baseRoles;
}
const nextLandmarks = baseProfile.landmarks.map((landmark) => {
const draftLandmark = draftLandmarkById.get(landmark.id);
const usedIndexes = new Set<number>();
// 当前 draft 才是最新角色集合legacy 只负责为同一对象补运行时富字段,
// 不能再让旧列表继续主导结果页,否则会把新角色主图和新对象列表吞掉。
return params.draftRoles.map((draftRole) => {
let matchedIndex = findRecordMatchIndex(
baseRoles,
(record) => toText(record.id) === draftRole.id,
usedIndexes,
);
if (matchedIndex < 0) {
matchedIndex = findRecordMatchIndex(
baseRoles,
(record) => normalizeMatchText(record.name) === normalizeMatchText(draftRole.name),
usedIndexes,
);
}
const baseRole = matchedIndex >= 0 ? baseRoles[matchedIndex] : null;
const baseImageSrc = toText(baseRole?.imageSrc) || undefined;
const baseGeneratedVisualAssetId =
toText(baseRole?.generatedVisualAssetId) || undefined;
const baseGeneratedAnimationSetId =
toText(baseRole?.generatedAnimationSetId) || undefined;
return {
...landmark,
imageSrc: draftLandmark?.imageSrc ?? landmark.imageSrc,
};
...(baseRole ?? {}),
...draftRole,
imageSrc: draftRole.imageSrc ?? baseImageSrc,
generatedVisualAssetId:
draftRole.generatedVisualAssetId ?? baseGeneratedVisualAssetId,
generatedAnimationSetId:
draftRole.generatedAnimationSetId ?? baseGeneratedAnimationSetId,
animationMap:
draftRole.animationMap ??
(isRecord(baseRole?.animationMap) ? baseRole?.animationMap : undefined),
} satisfies Record<string, unknown>;
});
}
function mergeDraftLandmarksIntoProfileRecord(params: {
baseLandmarks: unknown;
draftLandmarks: AdaptedDraftLandmark[];
}) {
const baseLandmarks = toRecordArray(params.baseLandmarks);
if (params.draftLandmarks.length <= 0) {
return baseLandmarks;
}
const usedIndexes = new Set<number>();
const mergedLandmarks = params.draftLandmarks.map((draftLandmark) => {
let matchedIndex = findRecordMatchIndex(
baseLandmarks,
(record) => toText(record.id) === draftLandmark.id,
usedIndexes,
);
if (matchedIndex < 0) {
matchedIndex = findRecordMatchIndex(
baseLandmarks,
(record) =>
normalizeMatchText(record.name) === normalizeMatchText(draftLandmark.name),
usedIndexes,
);
}
const baseLandmark = matchedIndex >= 0 ? baseLandmarks[matchedIndex] : null;
const baseImageSrc = toText(baseLandmark?.imageSrc) || undefined;
return {
...(baseLandmark ?? {}),
id: draftLandmark.id,
name: draftLandmark.name,
description: draftLandmark.description,
dangerLevel: draftLandmark.dangerLevel,
imageSrc: draftLandmark.imageSrc ?? baseImageSrc,
sceneNpcIds:
draftLandmark.sceneNpcIds.length > 0
? draftLandmark.sceneNpcIds
: toStringArray(baseLandmark?.sceneNpcIds),
} satisfies Record<string, unknown>;
});
const nextSceneChapterBlueprints =
normalizedDraftSceneChapters.length > 0
? baseProfile.sceneChapterBlueprints?.map((chapter) => {
const draftChapter = draftSceneChapterBySceneId.get(chapter.sceneId);
if (!draftChapter) {
return chapter;
}
const remainingLegacyLandmarks = baseLandmarks.filter(
(_entry, index) => !usedIndexes.has(index),
);
const draftActById = new Map(
draftChapter.acts.map((act) => [act.id, act]),
);
return [...mergedLandmarks, ...remainingLegacyLandmarks];
}
return {
...chapter,
acts: chapter.acts.map((act) => {
const draftAct = draftActById.get(act.id);
if (!draftAct) {
return act;
}
function mergeDraftSceneChaptersIntoProfileRecord(params: {
baseSceneChapters: unknown;
draftSceneChapters: CustomWorldProfile['sceneChapterBlueprints'];
}) {
const baseSceneChapters = toRecordArray(params.baseSceneChapters);
const draftSceneChapters = params.draftSceneChapters ?? [];
if (draftSceneChapters.length <= 0) {
return baseSceneChapters;
}
return {
...act,
backgroundImageSrc:
draftAct.backgroundImageSrc ?? act.backgroundImageSrc,
backgroundAssetId:
draftAct.backgroundAssetId ?? act.backgroundAssetId,
};
}),
};
}) ?? normalizedDraftSceneChapters
: baseProfile.sceneChapterBlueprints;
const usedChapterIndexes = new Set<number>();
return draftSceneChapters.map((draftChapter) => {
let matchedChapterIndex = findRecordMatchIndex(
baseSceneChapters,
(record) => toText(record.sceneId) === draftChapter.sceneId,
usedChapterIndexes,
);
if (matchedChapterIndex < 0) {
matchedChapterIndex = findRecordMatchIndex(
baseSceneChapters,
(record) =>
normalizeMatchText(record.title) === normalizeMatchText(draftChapter.title),
usedChapterIndexes,
);
}
const baseChapter =
matchedChapterIndex >= 0 ? baseSceneChapters[matchedChapterIndex] : null;
const baseActs = toRecordArray(baseChapter?.acts);
const usedActIndexes = new Set<number>();
const mergedActs = draftChapter.acts.map((draftAct) => {
let matchedActIndex = findRecordMatchIndex(
baseActs,
(record) => toText(record.id) === draftAct.id,
usedActIndexes,
);
if (matchedActIndex < 0) {
matchedActIndex = findRecordMatchIndex(
baseActs,
(record) =>
normalizeMatchText(record.title) === normalizeMatchText(draftAct.title),
usedActIndexes,
);
}
const baseAct = matchedActIndex >= 0 ? baseActs[matchedActIndex] : null;
const baseBackgroundImageSrc =
toText(baseAct?.backgroundImageSrc) || undefined;
const baseBackgroundAssetId =
toText(baseAct?.backgroundAssetId) || undefined;
return {
...(baseAct ?? {}),
...draftAct,
backgroundImageSrc: draftAct.backgroundImageSrc ?? baseBackgroundImageSrc,
backgroundAssetId: draftAct.backgroundAssetId ?? baseBackgroundAssetId,
} satisfies Record<string, unknown>;
});
return {
...(baseChapter ?? {}),
...draftChapter,
acts: mergedActs,
} satisfies Record<string, unknown>;
});
}
function mergeDraftCampIntoProfileRecord(params: {
baseCamp: unknown;
draftCamp: AdaptedDraftCamp | null;
}) {
if (!params.draftCamp) {
return isRecord(params.baseCamp) ? params.baseCamp : undefined;
}
const baseCamp = isRecord(params.baseCamp) ? params.baseCamp : null;
const baseImageSrc = toText(baseCamp?.imageSrc) || undefined;
return {
...baseProfile,
camp: nextCamp,
landmarks: nextLandmarks,
sceneChapterBlueprints: nextSceneChapterBlueprints,
} satisfies CustomWorldProfile;
...(baseCamp ?? {}),
...params.draftCamp,
imageSrc: params.draftCamp.imageSrc ?? baseImageSrc,
} satisfies Record<string, unknown>;
}
function toStageCoverage(value: unknown) {
@@ -396,25 +520,36 @@ export function buildCustomWorldProfileFromAgentDraft(
storyNpcIdSet,
landmarkIdSet,
);
const draftCamp = adaptDraftCamp(draftProfile.camp);
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 mergedProfile = normalizeCustomWorldProfileRecord({
...legacyResultProfile,
playableNpcs: mergeDraftRolesIntoProfileRecord({
baseRoles: legacyResultProfile.playableNpcs,
draftRoles: playableNpcs,
}),
storyNpcs: mergeDraftRolesIntoProfileRecord({
baseRoles: legacyResultProfile.storyNpcs,
draftRoles: storyNpcs,
}),
landmarks: mergeDraftLandmarksIntoProfileRecord({
baseLandmarks: legacyResultProfile.landmarks,
draftLandmarks: adaptedLandmarks,
}),
camp: mergeDraftCampIntoProfileRecord({
baseCamp: legacyResultProfile.camp,
draftCamp,
}),
sceneChapterBlueprints: mergeDraftSceneChaptersIntoProfileRecord({
baseSceneChapters: legacyResultProfile.sceneChapterBlueprints,
draftSceneChapters: draftSceneChapterBlueprints,
}),
});
return mergedProfile ?? legacyResultProfile;
}
const normalized = normalizeCustomWorldProfileRecord({
@@ -435,14 +570,12 @@ export function buildCustomWorldProfileFromAgentDraft(
playableNpcs,
storyNpcs,
landmarks: adaptedLandmarks,
camp: isRecord(draftProfile.camp)
camp: draftCamp
? {
name: toText(draftProfile.camp.name),
description: toText(draftProfile.camp.description),
dangerLevel:
toText(draftProfile.camp.dangerLevel) ||
toText(draftProfile.camp.mood),
imageSrc: toText(draftProfile.camp.imageSrc) || undefined,
name: draftCamp.name,
description: draftCamp.description,
dangerLevel: draftCamp.dangerLevel,
imageSrc: draftCamp.imageSrc,
}
: undefined,
sceneChapterBlueprints: draftSceneChapterBlueprints,

View File

@@ -295,8 +295,8 @@ const AGENT_DRAFT_FOUNDATION_STEP_DEFINITIONS = [
},
{
id: 'workspace',
label: '准备精修工作区',
detail: '正在写回草稿数据,并切回可继续精修的工作区。',
label: '准备结果页',
detail: '正在写回草稿数据,并打开可继续完善的结果页。',
matchers: ['世界底稿已生成'],
minProgress: 100,
},
@@ -324,7 +324,8 @@ function resolveAgentDraftFoundationStepIndexByProgress(progress: number) {
index < AGENT_DRAFT_FOUNDATION_STEP_DEFINITIONS.length - 1;
index += 1
) {
if (progress >= AGENT_DRAFT_FOUNDATION_STEP_DEFINITIONS[index].minProgress) {
const step = AGENT_DRAFT_FOUNDATION_STEP_DEFINITIONS[index];
if (step && progress >= step.minProgress) {
matchedIndex = index;
}
}
@@ -348,7 +349,7 @@ function resolveAgentDraftFoundationStepIndex(
index -= 1
) {
const step = AGENT_DRAFT_FOUNDATION_STEP_DEFINITIONS[index];
if (step.matchers.some((matcher) => phaseLabel.includes(matcher))) {
if (step?.matchers.some((matcher) => phaseLabel.includes(matcher))) {
return index;
}
}

View File

@@ -1,172 +0,0 @@
import type {
PlatformBrowseHistoryEntry,
PlatformBrowseHistoryWriteEntry,
} from '../../packages/shared/src/contracts/runtime';
import type { AuthUser } from './authService';
export type { PlatformBrowseHistoryEntry, PlatformBrowseHistoryWriteEntry };
const HISTORY_STORAGE_KEY_PREFIX = 'genarrative.platform.browse-history.v1';
const HISTORY_SYNC_KEY_PREFIX = 'genarrative.platform.browse-history.synced.v1';
const MAX_HISTORY_ENTRIES = 20;
function canUseLocalStorage() {
return (
typeof window !== 'undefined' && typeof window.localStorage !== 'undefined'
);
}
function buildHistoryStorageKey(user: AuthUser | null | undefined) {
const accountId = user?.id?.trim() || user?.username?.trim() || 'guest';
return `${HISTORY_STORAGE_KEY_PREFIX}:${accountId}`;
}
function buildHistorySyncKey(user: AuthUser | null | undefined) {
const accountId = user?.id?.trim() || user?.username?.trim() || 'guest';
return `${HISTORY_SYNC_KEY_PREFIX}:${accountId}`;
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}
function readString(value: unknown) {
return typeof value === 'string' ? value.trim() : '';
}
function normalizeHistoryEntry(
value: unknown,
): PlatformBrowseHistoryEntry | null {
if (!isRecord(value)) {
return null;
}
const ownerUserId = readString(value.ownerUserId);
const profileId = readString(value.profileId);
const worldName = readString(value.worldName);
const visitedAt = readString(value.visitedAt);
if (!ownerUserId || !profileId || !worldName || !visitedAt) {
return null;
}
return {
ownerUserId,
profileId,
worldName,
subtitle: readString(value.subtitle),
summaryText: readString(value.summaryText),
coverImageSrc: readString(value.coverImageSrc) || null,
themeMode:
(readString(
value.themeMode,
) as PlatformBrowseHistoryEntry['themeMode']) || 'mythic',
authorDisplayName: readString(value.authorDisplayName) || '玩家',
visitedAt,
};
}
function sortHistoryEntries(entries: PlatformBrowseHistoryEntry[]) {
return [...entries].sort((left, right) => {
return (
new Date(right.visitedAt).getTime() - new Date(left.visitedAt).getTime()
);
});
}
export function readPlatformBrowseHistory(user: AuthUser | null | undefined) {
if (!canUseLocalStorage()) {
return [] as PlatformBrowseHistoryEntry[];
}
const raw = window.localStorage.getItem(buildHistoryStorageKey(user));
if (!raw?.trim()) {
return [] as PlatformBrowseHistoryEntry[];
}
try {
const parsed = JSON.parse(raw) as unknown[];
if (!Array.isArray(parsed)) {
return [] as PlatformBrowseHistoryEntry[];
}
return sortHistoryEntries(
parsed
.map((entry) => normalizeHistoryEntry(entry))
.filter((entry): entry is PlatformBrowseHistoryEntry => Boolean(entry)),
).slice(0, MAX_HISTORY_ENTRIES);
} catch {
return [] as PlatformBrowseHistoryEntry[];
}
}
export function writePlatformBrowseHistory(
user: AuthUser | null | undefined,
entry: PlatformBrowseHistoryWriteEntry,
) {
if (!canUseLocalStorage()) {
return [] as PlatformBrowseHistoryEntry[];
}
const nextEntry: PlatformBrowseHistoryEntry = {
ownerUserId: entry.ownerUserId.trim(),
profileId: entry.profileId.trim(),
worldName: entry.worldName.trim(),
subtitle: entry.subtitle?.trim() || '',
summaryText: entry.summaryText?.trim() || '',
coverImageSrc: entry.coverImageSrc?.trim() || null,
themeMode: entry.themeMode || 'mythic',
authorDisplayName: entry.authorDisplayName?.trim() || '玩家',
visitedAt: entry.visitedAt?.trim() || new Date().toISOString(),
};
const deduped = readPlatformBrowseHistory(user).filter(
(current) =>
!(
current.ownerUserId === nextEntry.ownerUserId &&
current.profileId === nextEntry.profileId
),
);
const nextEntries = sortHistoryEntries([nextEntry, ...deduped]).slice(
0,
MAX_HISTORY_ENTRIES,
);
window.localStorage.setItem(
buildHistoryStorageKey(user),
JSON.stringify(nextEntries),
);
return nextEntries;
}
export function clearPlatformBrowseHistory(user: AuthUser | null | undefined) {
if (!canUseLocalStorage()) {
return;
}
window.localStorage.removeItem(buildHistoryStorageKey(user));
window.localStorage.removeItem(buildHistorySyncKey(user));
}
export function hasPendingPlatformBrowseHistoryMigration(
user: AuthUser | null | undefined,
) {
if (!canUseLocalStorage()) {
return false;
}
return (
readPlatformBrowseHistory(user).length > 0 &&
window.localStorage.getItem(buildHistorySyncKey(user)) !== '1'
);
}
export function markPlatformBrowseHistoryMigrated(
user: AuthUser | null | undefined,
) {
if (!canUseLocalStorage()) {
return;
}
window.localStorage.setItem(buildHistorySyncKey(user), '1');
}

View File

@@ -2,21 +2,11 @@ import {
getNpcDisclosureStage,
getNpcWarmthStage,
} from '../data/npcInteractions';
import {
buildFallbackQuestIntent,
compileQuestIntentToQuest,
evaluateQuestOpportunity,
} from '../data/questFlow';
import { evaluateQuestOpportunity } from '../data/questFlow';
import type { Encounter, GameState, QuestLogEntry } from '../types';
import type { QuestGenerationContext } from './aiTypes';
import { requestJson } from './apiClient';
import { requestChatMessageContent } from './llmClient';
import { parseJsonResponseText } from './llmParsers';
import {
buildQuestIntentPrompt,
QUEST_INTENT_SYSTEM_PROMPT,
} from './questPrompt';
import type { QuestIntent, QuestPreviewRequest } from './questTypes';
import type { QuestPreviewRequest } from './questTypes';
import {
buildFallbackActorNarrativeProfile,
normalizeActorNarrativeProfile,
@@ -24,37 +14,6 @@ import {
import { buildThemePackFromWorldProfile } from './storyEngine/themePack';
import { buildFallbackWorldStoryGraph } from './storyEngine/worldStoryGraph';
const QUEST_DIRECTOR_TIMEOUT_MS = 12000;
function coerceString(value: unknown, fallback: string) {
return typeof value === 'string' && value.trim() ? value.trim() : fallback;
}
function coerceQuestTitle(value: unknown, fallback: string) {
const title = coerceString(value, fallback)
.replace(/["']/gu, '')
.replace(/[,.!?;:].*$/u, '')
.trim();
if (title.length <= 12) {
return title;
}
return fallback.length <= 12 ? fallback : fallback.slice(0, 10);
}
function coerceStringArray(value: unknown, fallback: string[]) {
if (!Array.isArray(value)) {
return fallback;
}
const items = value
.map((item) => (typeof item === 'string' ? item.trim() : ''))
.filter(Boolean);
return items.length > 0 ? items : fallback;
}
function resolveIssuerNarrativeProfile(state: GameState, encounter: Encounter) {
if (encounter.narrativeProfile) {
return encounter.narrativeProfile;
@@ -87,73 +46,6 @@ function resolveIssuerNarrativeProfile(state: GameState, encounter: Encounter) {
);
}
function sanitizeQuestIntent(
rawIntent: unknown,
fallback: QuestIntent,
): QuestIntent {
if (!rawIntent || typeof rawIntent !== 'object') {
return fallback;
}
const intent = rawIntent as Record<string, unknown>;
return {
title: coerceQuestTitle(intent.title, fallback.title),
description: coerceString(intent.description, fallback.description),
summary: coerceString(intent.summary, fallback.summary),
narrativeType:
typeof intent.narrativeType === 'string' &&
[
'bounty',
'escort',
'investigation',
'retrieval',
'relationship',
'trial',
].includes(intent.narrativeType)
? (intent.narrativeType as QuestIntent['narrativeType'])
: fallback.narrativeType,
dramaticNeed: coerceString(intent.dramaticNeed, fallback.dramaticNeed),
issuerGoal: coerceString(intent.issuerGoal, fallback.issuerGoal),
playerHook: coerceString(intent.playerHook, fallback.playerHook),
worldReason: coerceString(intent.worldReason, fallback.worldReason),
recommendedObjectiveKinds: coerceStringArray(
intent.recommendedObjectiveKinds,
fallback.recommendedObjectiveKinds,
).filter((kind) =>
[
'defeat_hostile_npc',
'inspect_treasure',
'spar_with_npc',
'talk_to_npc',
'reach_scene',
'deliver_item',
].includes(kind),
) as QuestIntent['recommendedObjectiveKinds'],
urgency:
typeof intent.urgency === 'string' &&
['low', 'medium', 'high'].includes(intent.urgency)
? (intent.urgency as QuestIntent['urgency'])
: fallback.urgency,
intimacy:
typeof intent.intimacy === 'string' &&
['transactional', 'cooperative', 'trust_based'].includes(intent.intimacy)
? (intent.intimacy as QuestIntent['intimacy'])
: fallback.intimacy,
rewardTheme:
typeof intent.rewardTheme === 'string' &&
['currency', 'resource', 'relationship', 'intel', 'rare_item'].includes(
intent.rewardTheme,
)
? (intent.rewardTheme as QuestIntent['rewardTheme'])
: fallback.rewardTheme,
followupHooks: coerceStringArray(
intent.followupHooks,
fallback.followupHooks,
),
};
}
export function buildQuestGenerationContextFromState(params: {
state: GameState;
encounter: Encounter;
@@ -235,67 +127,13 @@ export async function generateQuestForNpcEncounter(params: {
return null;
}
const fallbackIntent = buildFallbackQuestIntent(request);
if (typeof window !== 'undefined') {
try {
return await requestJson<QuestLogEntry | null>(
'/api/runtime/quests/generate',
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(params),
},
'任务生成失败',
);
} catch (error) {
console.warn(
'[QuestDirector] backend quest generation failed, using deterministic fallback',
error,
);
return compileQuestIntentToQuest(
{
...request,
origin: 'fallback_builder',
},
fallbackIntent,
);
}
}
try {
const content = await requestChatMessageContent(
QUEST_INTENT_SYSTEM_PROMPT,
buildQuestIntentPrompt({
context: request.context!,
scene: request.scene,
opportunity,
}),
{
timeoutMs: QUEST_DIRECTOR_TIMEOUT_MS,
debugLabel: 'quest-intent',
},
);
const parsed = parseJsonResponseText(content) as { intent?: unknown };
const intent = sanitizeQuestIntent(parsed.intent, fallbackIntent);
return compileQuestIntentToQuest(
{
...request,
origin: 'ai_compiled',
},
intent,
);
} catch (error) {
console.warn(
'[QuestDirector] falling back to deterministic quest intent',
error,
);
return compileQuestIntentToQuest(
{
...request,
origin: 'fallback_builder',
},
fallbackIntent,
);
}
return requestJson<QuestLogEntry | null>(
'/api/runtime/quests/generate',
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(params),
},
'任务生成失败',
);
}

View File

@@ -1,134 +1,24 @@
import {buildRuntimeItemAiIntent} from '../data/runtimeItemNarrative';
import type {
RuntimeItemAiIntent,
RuntimeItemGenerationContext,
RuntimeItemPlan,
} from '../types';
import { requestJson } from './apiClient';
import {requestChatMessageContent} from './llmClient';
import {parseJsonResponseText} from './llmParsers';
import {
buildRuntimeItemIntentPrompt,
RUNTIME_ITEM_INTENT_SYSTEM_PROMPT,
} from './runtimeItemAiPrompt';
const RUNTIME_ITEM_INTENT_TIMEOUT_MS = 9000;
function coerceString(value: unknown, fallback: string) {
return typeof value === 'string' && value.trim() ? value.trim() : fallback;
}
function coerceStringArray(value: unknown, fallback: string[], limit: number) {
if (!Array.isArray(value)) {
return fallback;
}
const normalized = value
.map(item => (typeof item === 'string' ? item.trim() : ''))
.filter(Boolean)
.slice(0, limit);
return normalized.length > 0 ? normalized : fallback;
}
function sanitizeRuntimeItemAiIntent(
rawIntent: unknown,
fallback: RuntimeItemAiIntent,
): RuntimeItemAiIntent {
if (!rawIntent || typeof rawIntent !== 'object') {
return fallback;
}
const intent = rawIntent as Record<string, unknown>;
const desiredFunctionalBias = coerceStringArray(
intent.desiredFunctionalBias,
fallback.desiredFunctionalBias,
2,
).filter(
(
item,
): item is RuntimeItemAiIntent['desiredFunctionalBias'][number] =>
['heal', 'mana', 'cooldown', 'guard', 'damage'].includes(item),
);
const tone = coerceString(intent.tone, fallback.tone);
return {
shortNameSeed: coerceString(intent.shortNameSeed, fallback.shortNameSeed),
sourcePhrase: coerceString(intent.sourcePhrase, fallback.sourcePhrase),
reasonToAppear: coerceString(intent.reasonToAppear, fallback.reasonToAppear),
relationHooks: coerceStringArray(intent.relationHooks, fallback.relationHooks, 2),
desiredBuildTags: coerceStringArray(intent.desiredBuildTags, fallback.desiredBuildTags, 3),
desiredFunctionalBias:
desiredFunctionalBias.length > 0
? desiredFunctionalBias
: fallback.desiredFunctionalBias,
tone: ['grim', 'mysterious', 'martial', 'ritual', 'survival'].includes(tone)
? (tone as RuntimeItemAiIntent['tone'])
: fallback.tone,
visibleClue: coerceString(intent.visibleClue, fallback.visibleClue ?? ''),
witnessMark: coerceString(intent.witnessMark, fallback.witnessMark ?? ''),
unfinishedBusiness: coerceString(
intent.unfinishedBusiness,
fallback.unfinishedBusiness ?? '',
),
hiddenHook: coerceString(intent.hiddenHook, fallback.hiddenHook ?? ''),
reactionHooks: coerceStringArray(
intent.reactionHooks,
fallback.reactionHooks ?? [],
4,
),
namingPattern: coerceString(intent.namingPattern, fallback.namingPattern ?? ''),
};
}
export async function generateRuntimeItemAiIntents(params: {
context: RuntimeItemGenerationContext;
plans: RuntimeItemPlan[];
}) {
const fallbackIntents = params.plans.map(plan =>
buildRuntimeItemAiIntent(params.context, plan),
);
if (typeof window !== 'undefined') {
try {
const response = await requestJson<{
intents?: unknown[];
}>(
'/api/runtime/items/runtime-intent',
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(params),
},
'运行时物品意图生成失败',
);
const rawIntents = Array.isArray(response.intents) ? response.intents : [];
return params.plans.map((_, index) =>
sanitizeRuntimeItemAiIntent(rawIntents[index], fallbackIntents[index]!),
);
} catch (error) {
console.warn(
'[runtimeItemAiDirector] backend intent generation failed, using deterministic fallback',
error,
);
return fallbackIntents;
}
}
const content = await requestChatMessageContent(
RUNTIME_ITEM_INTENT_SYSTEM_PROMPT,
buildRuntimeItemIntentPrompt(params),
const response = await requestJson<{
intents?: RuntimeItemAiIntent[];
}>(
'/api/runtime/items/runtime-intent',
{
timeoutMs: RUNTIME_ITEM_INTENT_TIMEOUT_MS,
debugLabel: 'runtime-item-intent',
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(params),
},
'运行时物品意图生成失败',
);
const parsed = parseJsonResponseText(content) as {
intents?: unknown[];
};
const rawIntents = Array.isArray(parsed.intents) ? parsed.intents : [];
return params.plans.map((_, index) =>
sanitizeRuntimeItemAiIntent(rawIntents[index], fallbackIntents[index]!),
);
return Array.isArray(response.intents) ? response.intents : [];
}

View File

@@ -18,6 +18,7 @@ import {
buildStoryMomentFromRuntimeOptions,
getRuntimeClientVersion,
getRuntimeSessionId,
getRuntimeStoryState,
isServerRuntimeFunctionId,
isTask5RuntimeFunctionId,
resolveRuntimeStoryAction,
@@ -75,6 +76,7 @@ describe('runtimeStoryService', () => {
optionText: '继续交谈',
},
},
snapshot: undefined,
}),
}),
'执行运行时动作失败',
@@ -129,6 +131,7 @@ describe('runtimeStoryService', () => {
itemId: 'focus-tonic',
},
},
snapshot: undefined,
}),
}),
'执行运行时动作失败',
@@ -136,6 +139,80 @@ describe('runtimeStoryService', () => {
);
});
it('submits runtime state resolution with snapshot context to the server', async () => {
requestJsonMock.mockResolvedValue({
sessionId: 'runtime-main',
serverVersion: 4,
viewModel: {
player: {
hp: 100,
maxHp: 100,
mana: 20,
maxMana: 20,
},
encounter: null,
companions: [],
availableOptions: [],
status: {
inBattle: false,
npcInteractionActive: false,
currentNpcBattleMode: null,
currentNpcBattleOutcome: null,
},
},
presentation: {
actionText: '',
resultText: '',
storyText: '服务端故事',
options: [],
},
patches: [],
snapshot: {
version: 2,
savedAt: '2026-04-08T00:00:00.000Z',
bottomTab: 'adventure',
gameState: {},
currentStory: null,
},
});
await getRuntimeStoryState({
sessionId: 'runtime-main',
clientVersion: 7,
snapshot: {
gameState: { currentScene: 'Story' } as never,
bottomTab: 'adventure',
currentStory: {
text: '本地故事',
options: [],
} as never,
},
});
expect(requestJsonMock).toHaveBeenCalledWith(
'/api/runtime/story/state/resolve',
expect.objectContaining({
method: 'POST',
body: JSON.stringify({
sessionId: 'runtime-main',
clientVersion: 7,
snapshot: {
gameState: {
currentScene: 'Story',
},
bottomTab: 'adventure',
currentStory: {
text: '本地故事',
options: [],
},
},
}),
}),
'读取运行时故事状态失败',
expect.any(Object),
);
});
it('keeps disabled runtime options when rebuilding a story moment', () => {
const story = buildStoryMomentFromRuntimeOptions({
storyText: '服务端返回的新故事',

View File

@@ -1,7 +1,9 @@
import type {
RuntimeStoryActionRequest,
RuntimeStoryActionResponse,
RuntimeStoryChoicePayload,
RuntimeStoryOptionView,
RuntimeStoryStateRequest,
ServerRuntimeFunctionId,
Task5RuntimeFunctionId,
} from '../../packages/shared/src/contracts/story';
@@ -44,6 +46,10 @@ export type RuntimeStoryResponse = RuntimeStoryActionResponse<
StoryMoment
>;
export type { RuntimeStoryChoicePayload };
export type RuntimeStorySnapshotRequest = RuntimeStoryStateRequest<
HydratedGameState,
StoryMoment
>['snapshot'];
function requestRuntimeStoryJson<T>(
path: string,
@@ -170,15 +176,35 @@ export function resolveRuntimeStoryMoment(params: {
}
export async function getRuntimeStoryState(
sessionId: string,
params: {
sessionId: string;
clientVersion?: number;
snapshot?: RuntimeStorySnapshotRequest;
},
options: RuntimeStoryServiceOptions = {},
) {
const response = await requestRuntimeStoryJson<RuntimeStoryResponse>(
`/state/${encodeURIComponent(sessionId || DEFAULT_SESSION_ID)}`,
{ method: 'GET' },
'读取运行时故事状态失败',
options,
);
const normalizedSessionId = params.sessionId || DEFAULT_SESSION_ID;
const response = params.snapshot
? await requestRuntimeStoryJson<RuntimeStoryResponse>(
'/state/resolve',
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
sessionId: normalizedSessionId,
clientVersion: params.clientVersion,
snapshot: params.snapshot,
} satisfies RuntimeStoryStateRequest),
},
'读取运行时故事状态失败',
options,
)
: await requestRuntimeStoryJson<RuntimeStoryResponse>(
`/state/${encodeURIComponent(normalizedSessionId)}`,
{ method: 'GET' },
'读取运行时故事状态失败',
options,
);
return {
...response,
@@ -195,6 +221,7 @@ export async function resolveRuntimeStoryAction(
option: Pick<StoryOption, 'functionId' | 'actionText'>;
targetId?: string;
payload?: RuntimeStoryChoicePayload;
snapshot?: RuntimeStorySnapshotRequest;
},
options: RuntimeStoryServiceOptions = {},
) {
@@ -215,7 +242,8 @@ export async function resolveRuntimeStoryAction(
...(params.payload ?? {}),
},
},
}),
snapshot: params.snapshot,
} satisfies RuntimeStoryActionRequest),
},
'执行运行时动作失败',
options,