This commit is contained in:
2026-04-21 18:27:46 +08:00
parent 04bff9617d
commit 4372ab5be1
358 changed files with 30788 additions and 14737 deletions

View File

@@ -19,7 +19,7 @@
## 1. 结论先行
结合当前代码与已有边界文档,前端里仍有 6 类逻辑应该继续后移:
结合当前代码与已有边界文档,前端里仍有 7 类逻辑应该继续后移:
1. **运行时快照前置写入与本地镜像解释**
2. **鉴权 token 的浏览器本地真相**
@@ -27,6 +27,7 @@
4. **NPC 待接委托“换单”仍由前端直接触发正式生成**
5. **quest/runtime item 的双环境混合编排**
6. **浏览器侧大型 AI orchestration 与 prompt/repair/fallback 主链**
7. **NPC 招募对白之后的正式结算链路**
一句话判断:
@@ -59,6 +60,20 @@
## 3. 当前高置信度应后移逻辑
## 3.0 本轮已完成后移
以下链路已在本轮或上一轮连续落地中完成后移,不再属于“仍残留在前端”的正式主链:
1. access token 浏览器本地真相
2. browse history 本地真相
3. runtime story 前置 `PUT /runtime/save/snapshot`
4. NPC 待接委托 `replace / abandon / accept`
5. custom world profile 正式浏览器入口
6. `questDirector` / `runtimeItemAiDirector` 浏览器正式编排
7. NPC 招募正式结算
其中 NPC 招募已从“前端本地改 companions / roster / npcStates / storyHistory”收回到后端 runtime action。
## 3.1 运行时快照前置写入仍在前端
### 代码证据
@@ -427,6 +442,41 @@
---
## 3.8 NPC 招募对白之后的正式结算链路已完成后移
### 本轮前状态
迁移前,`src/hooks/story/npcInteraction.ts` 中的 `buildRecruitmentOutcome / executeRecruitment / startRecruitmentSequence` 仍在前端本地正式结算:
1.`npcStates`
2.`companions`
3.`roster`
4.`currentEncounter / inBattle / sceneHostileNpcs`
5. 直接写 `storyHistory`
6. 再触发后续剧情推进
这与“前端只做表现,所有正式逻辑、数据都放到 Express 后端”直接冲突。
### 本轮后状态
本轮已完成:
1. `server-node/src/modules/story/runtimeSession.ts`
- 正式承接完整 `companions`
- 正式承接 `roster`
2. `server-node/src/modules/npc/npcInteractionService.ts`
- `npc_recruit` 已支持正常入队
- `npc_recruit` 已支持满员换队招募
3. `src/hooks/story/npcInteraction.ts`
- 前端只保留招募对白流式展示
- 正式招募结算改为调用后端 runtime action
### 当前判断
这一项已不再属于前端残留正式逻辑。
---
## 4. 可以暂时保留在前端的部分
下面这些内容即使和上述模块同文件出现,也不属于必须后移的对象:

View File

@@ -708,7 +708,7 @@ store 当前混合了:
3. 已新增 `rpgCreationAgentClient``rpgCreationWorkClient``rpgCreationLibraryClient``rpgCreationAssetClient``rpgCreationPreviewAdapter`
4. 已新增后端 `rpgCreationAgentRoutes``rpgWorldWorksRoutes``rpgWorldLibraryRoutes``rpgWorldGalleryRoutes` 命名骨架。
5. 已新增 `RpgAgentOrchestrator``RpgAgentSessionStore``RpgWorldPreviewCompiler``RpgWorldWorkSummaryService` façade。
6. 已新增 `rpgAgent*``rpgCreation*` 共享契约骨架。
6. 已新增 `rpgAgent*``rpgCreation*` 共享契约骨架,并补齐此前遗漏的 `rpgAgentDraft.ts` 与 shared 根导出
本轮刻意未做:
@@ -738,6 +738,25 @@ store 当前混合了:
最好在工作包 A 的目录骨架准备好后开始。
### 当前进展(`2026-04-21`
工作包 B 已完成以下落地:
1. 已把 `PreGameSelectionFlow.tsx` 降级为兼容入口,旧路径继续导出 `PreGameSelectionFlow``PreGameSelectionFlowProps``SelectionStage`
2. 已把 RPG 创作平台壳层的真实实现迁入 `src/components/game-shell/rpg-creation-flow/RpgCreationShellImpl.tsx`并把该文件收口成“hooks 组合 + stage 视图装配 + 视觉级 loading/error”的壳层。
3. `RpgCreationShell.tsx` 已直接桥接 `RpgCreationShellImpl.tsx`,新目录开始承接真实入口。
4. 已新增 `rpgCreationFlowTypes.ts``rpgCreationFlowShared.ts`,把壳层类型与共享 helper 从旧入口文件中收出独立落点。
5. 已接入 `useRpgCreationPlatformBootstrap.ts``useRpgCreationSessionController.ts``useRpgCreationAgentOperationPolling.ts``useRpgCreationDetailNavigation.ts``useRpgCreationResultAutosave.ts``useRpgCreationEnterWorld.ts`
6. 平台侧 works / library / gallery / history / save / dashboard 拉取、session 恢复、message streaming、action 执行、operation 轮询、detail navigation、结果页自动保存、enter-world 同步已不再直接堆在壳层组件中。
7. 已完成 `PreGameSelectionFlow.agent.interaction.test.tsx` 的 14 个交互场景回归,以及壳层相关定向 eslint、编码检查。
本轮刻意未做:
1. 还没有物理删除 `PreGameSelectionFlow.tsx` 与其他旧兼容 façade当前仍保留桥接层以避免影响并行工作包。
2. 还没有让所有调用方统一显式改走 `RpgCreationShell` 新入口,当前允许旧路径兼容收口到新实现。
3. 还没有把结果页 preview 数据源从前端兼容 adapter 切到服务端正式 preview contract这部分仍属于后续工作包 G / H 与 Phase 3 范围。
4. 还没有清理所有 legacy 兼容导出,本轮优先完成平台壳层编排拆分与主链稳定验证。
## 9.3 工作包 C前端结果页与编辑器拆分
### 目标
@@ -761,6 +780,24 @@ store 当前混合了:
依赖工作包 A 的命名规范与目录落点,和工作包 B 并行。
### 当前进展(`2026-04-21`
工作包 C 已完成以下拆分落地:
1. 已把 `CustomWorldResultView.tsx``CustomWorldEntityEditorModal.tsx``CustomWorldRoleAssetStudioModal.tsx` 的真实实现迁入 `src/components/rpg-creation-result/``src/components/rpg-creation-editor/``src/components/rpg-creation-asset-studio/`
2. 已把旧文件降级为兼容入口,现有调用仍可继续从旧路径导入,不影响当前主链行为。
3. 结果页已拆出 `RpgCreationResultHeader``RpgCreationResultActionBar``RpgCreationAssetDebugPanel``useRpgCreationResultActions`,结果页主组件开始退化为组合壳层。
4. 编辑器已补 `rpgCreationResultFormMapper.ts`,并把 `RpgCreationEntityEditorModalImpl.tsx` 收口成目标分发壳层;`world / cover / camp / playable / story / landmark` 已有稳定 section 入口。
5. 编辑器当前保留 `RpgCreationEntityEditorShared.tsx` 作为阶段性 shared 实现承载体,避免在同一轮里高风险物理拆散 180KB 级表单细节;后续可在不改壳层接口的前提下继续向各 section 文件迁移。
6. 角色资产工坊已补 `roleAssetStudioModel.ts``roleAssetStudioPublishClient.ts``useRoleVisualCandidateWorkflow.ts``useRoleAnimationWorkflow.ts`,并进一步拆出 `RpgCreationRoleVisualSection.tsx``RpgCreationRoleAnimationSection.tsx``RpgCreationRoleAssetStudioFooter.tsx`,当前主模态已退化为组合壳层。
7.`CustomWorldResultView.tsx``CustomWorldEntityEditorModal.tsx``CustomWorldRoleAssetStudioModal.tsx` 兼容入口已统一桥接到 RPG 创作域 façade而不是继续直连内部 `Impl` 文件。
本轮刻意未做:
1. 还没有把 `RpgCreationEntityEditorShared.tsx` 内部的全部表单实现物理拆成独立文件,当前先以“壳层 + section 入口 + shared 实现”完成工作包 C 收口。
2. 还没有改平台壳层 `PreGameSelectionFlow.tsx` 的任何主状态编排,仍严格遵守工作包 C 的写入边界。
3. 还没有把结果页从 legacy preview 兼容数据源切到服务端正式 preview contract这部分属于后续工作包 D / G / H 的协作范围。
## 9.4 工作包 D前端 custom world client 收口
### 目标
@@ -784,6 +821,24 @@ store 当前混合了:
依赖工作包 A 的命名和目录约束;可与 B、C 并行。
### 当前进展(`2026-04-21`
工作包 D 第一轮已完成以下落地:
1. 已新增 `src/services/rpg-creation/rpgCreationRuntimeClient.ts``src/services/rpg-creation/rpgCreationRequestHelpers.ts`,把 RPG 创作域的 runtime 请求重试策略、POST JSON 与 SSE 请求辅助能力收口到新目录。
2. `rpgCreationAgentClient.ts``rpgCreationWorkClient.ts``rpgCreationLibraryClient.ts``rpgCreationAssetClient.ts``rpgCreationGenerationClient.ts` 已从 façade 透传升级为真实请求实现,不再继续把主链请求代码堆在 `aiService.ts``storageService.ts` 中。
3. `generateCustomWorldProfile()` 已正式迁入 `rpgCreationGenerationClient.ts`,世界生成入口也已纳入 RPG 创作域 client 边界。
4. `aiService.ts` 中已迁出的 Agent / works / 世界生成 / 结果页实体生成接口已退化为兼容导出;`storageService.ts` 中 works / library / gallery / publish 链路也已退化为兼容导出。
5. `PreGameSelectionFlow.tsx` 已开始直接从 `src/services/rpg-creation/` 消费 Agent / works / library / gallery / publish 请求,不再从旧 service 入口拿主链接口。
6. `RpgCreationEntityEditorShared.tsx` 已把场景图生成请求切到 `rpgCreationAssetClient`,结果页与编辑器相关测试也已改为优先 mock 新的 RPG 创作域 client。
7. 已完成 `rpgCreationGenerationClient.test.ts``storageService.test.ts``CustomWorldEntityEditorModal.test.tsx``CustomWorldResultView.test.tsx``PreGameSelectionFlow.agent.interaction.test.tsx` 的定向回归,以及编码检查。
本轮刻意未做:
1. 还没有物理删除 `aiService.ts``storageService.ts` 中的旧命名兼容导出,本轮优先保证调用迁移可平稳过渡。
2. 还没有改平台壳层的内部流程编排与 hook 结构,这部分仍属于工作包 B。
3. 还没有把结果页从 legacy preview 兼容数据源切到服务端正式 preview contract这部分仍属于后续工作包 G / H 的协作范围。
## 9.5 工作包 E后端 Agent 编排拆分
### 目标
@@ -809,6 +864,58 @@ store 当前混合了:
建议在工作包 A 后开始;可与 B、C、D 并行。
### 当前进展(`2026-04-21`
工作包 E 当前已完成 3 轮落地,真实状态如下:
1. `customWorldAgentOrchestrator.ts` 已退化为后端应用服务 façade只保留 session/message/action 主入口、operation 创建和下游服务委托消息轮转、action 分发与派生状态重建已从热点文件中拆出。
2. `CustomWorldAgentActionRegistry` 已正式接管 action 可用性校验、payload normalize、operation type 映射与 `supportedActions` 主链接线;前端不再需要按 action 字面量猜测按钮是否可点。
3. `customWorldAgentActionExecutors/` 已补齐并接管以下真实执行链:
- `draft_foundation`
- `update_draft_card`
- `sync_result_profile`
- `generate_characters`
- `generate_landmarks`
- `generate_role_assets`
- `sync_role_assets`
- `generate_scene_assets`
- `sync_scene_assets`
- `expand_long_tail`
- `publish_world`
- `revert_checkpoint`
4. `CustomWorldAgentMessageTurnService``CustomWorldAgentSnapshotBuilder``CustomWorldAgentResultSyncService``CustomWorldAgentQualityGateService``CustomWorldAgentSuggestedActionService` 已形成稳定协作边界:
- message turn 负责会话轮转
- snapshot builder 负责派生状态重建
- result sync service 负责结果页回写
- quality gate service 负责 `qualityFindings`
- suggested action service 负责建议动作
5. 发布链已经统一切到 `CustomWorldAgentPublishingService`
- orchestrator、executor map、publish executor、server 注入口径已经一致
- 发布 readiness 与正式写库走同一服务
- 作者展示名优先走 `resolveAuthorDisplayName`,同时保留 `userRepository` 兼容兜底
- 发布产物 `profileId` 固定优先沿用 legacy 结果页 ID否则回退为 `agent-draft-${sessionId}`
6. `sync_scene_assets` 已形成完整闭环:
- 营地/地点正式场景图会写回 draft profile
- 对应 `sceneChapters[].acts``backgroundImageSrc / backgroundAssetId` 会同步刷新
- `rebuildRoleAssetCoverage()` 已补 camp/landmark 正式场景资产 fallback 汇总,确保 snapshot 重建、works 读模型与 checkpoint 回放都能保住场景资产覆盖状态
7. checkpoint 已收口为“可恢复真快照”:
- `buildCheckpointSnapshot()` 已接入关键 executor
- `revert_checkpoint` 现在依赖真实 checkpoint snapshot 与 `restoreCheckpoint()` 主链完成回滚,不再是只开放入口的空动作
8. `CustomWorldAgentActionRegistry` 已重新收口阶段策略:
- `sync_result_profile``generate_scene_assets``sync_scene_assets` 等精修动作仅允许 `object_refining / visual_refining`
- `expand_long_tail``publish_world``revert_checkpoint` 单独允许 `long_tail_review / ready_to_publish`
9. 已完成以下验证:
- `npm --prefix server-node run build`
- `npm --prefix server-node run test -- customWorldAgentPhase3.test.ts customWorldAgentActionRegistry.test.ts customWorldAgentPhase5.test.ts`
当前 `server-node` 定向回归共 `208` 项通过,已覆盖工作包 E 第三轮的发布链、场景资产、长尾扩展与 checkpoint 回滚主链。
本轮刻意未做:
1. 还没有进入 Phase 4 的“进入世界统一走发布态 gate”收口当前只完成 `publish_world` 本身的后端闭环。
2. 还没有改 `customWorldAgentSessionStore.ts` 与 repository 边界,这部分仍属于工作包 F。
3. 还没有让前端结果页正式消费服务端 `resultPreview` 主链字段,这部分仍需要与工作包 G / H 协作。
4.`customWorldAgentPublishGateService.ts``customWorldAgentPublishService.ts` 仍作为历史兼容文件保留,尚未进入物理删除阶段。
## 9.6 工作包 F后端 session/store/repository 拆分
### 目标
@@ -832,6 +939,24 @@ store 当前混合了:
与工作包 E 有接口协作关系,但可以并行推进,最终通过 façade 汇合。
### 当前进展(`2026-04-21`
工作包 F 已完成以下拆分落地:
1. 已新增 `server-node/src/services/rpg-agent-session-store/`,把 session record、compatibility、factory、repository adapter 从 `customWorldAgentSessionStore.ts` 中物理拆出。
2. `customWorldAgentSessionStore.ts` 已退化为兼容 façade保留原类名、原方法签名并正式改为依赖 `RpgAgentSessionRepositoryPort`
3. 已新增 `server-node/src/repositories/RpgAgentSessionRepository.ts``server-node/src/repositories/RpgWorldProfileRepository.ts``server-node/src/repositories/rpgWorldRepositoryShared.ts`
4. `runtimeRepository.ts` 中的 custom world session/profile/gallery 读写已改成委托新仓储runtime 大仓储开始向“通用 runtime façade”收口。
5. 已新增 `server-node/src/services/RpgWorldWorkCoverResolver.ts``server-node/src/services/RpgWorldWorkSummaryAssembler.ts``server-node/src/services/RpgWorldWorkSummaryService.ts`,把 works 读模型的封面解析、条目组装与服务入口从 `customWorldWorkSummaryService.ts` 中拆出。
6. `context.ts``server.ts``runtimeRoutes.ts``syncCustomWorldSavedProfileAssets.ts` 已切到直接注入和使用 `RpgAgentSessionRepository``RpgWorldProfileRepository``RpgWorldWorkSummaryService`
7. `customWorldAgentPhase2~5``customWorldWorkSummaryService.integration.test.ts` 已切到新的 session/profile 内存仓储端口,定向回归 21 项全部通过。
本轮刻意未做:
1. `RuntimeRepositoryPort` 仍保留兼容 façade 与 custom world 相关旧方法,现阶段先稳住 story/runtime 其他调用方。
2. `RuntimeRepository` 中的 runtime 快照同步编排还没有继续下沉,当前先完成 custom world 持久化与 works 读模型边界拆分。
3. `customWorldAgentSessionStore.ts``customWorldWorkSummaryService.ts` 等旧命名 façade 仍保留,等待后续统一命名和兼容层清理阶段再删除。
## 9.7 工作包 G后端 preview compiler 与 runtime profile 目录化
### 目标
@@ -855,6 +980,33 @@ store 当前混合了:
与工作包 E、F 并行,但在主链接入前需要先和 E 对齐 preview contract。
### 当前进展(`2026-04-21`
工作包 G 已完成以下落地:
1. 已新增 `server-node/src/modules/custom-world/runtime-profile/` 目录入口,并把原 `runtimeProfile.ts` 退化为兼容 façade。
2. 已把 runtime profile 进一步物理拆分到:
- `normalizeShared.ts`
- `normalizeRole.ts`
- `normalizeLandmark.ts`
- `normalizeSceneChapter.ts`
- `normalizeCamp.ts`
- `buildCompiledProfile.ts`
- `buildAttributeSchema.ts`
- `creatorIntentBridge.ts`
3. `runtimeProfileCompiler.ts` 已退化为兼容 façade不再承载主实现。
4. `RpgWorldPreviewCompiler.ts` 已从简单别名升级为服务端 preview compiler 入口,新增 preview envelope 输出能力。
5. `packages/shared/src/contracts/rpgCreationPreview.ts` 已补 `RpgCreationPreviewSource`,并在 Phase 5 后把 session 结果页正式 source 收口为 `session_preview`
6. `customWorldAgentFoundationDraftService.ts` 的 LLM foundation draft 主生成链已改成“直接组装 draft 主字段 + 单独保留 `legacyResultProfile` 兼容快照”,不再通过 preview compiler 反解草稿主字段。
7. 已新增 `server-node/src/services/RpgWorldPreviewCompiler.test.ts``server-node/src/services/customWorldAgentFoundationDraftService.test.ts`,并完成编码检查与工作包 G 定向回归验证。
本轮刻意未做:
1. 还没有把 preview contract 从当前 runtime-profile 兼容载体升级成独立 view model。
2. 还没有让 orchestrator、route、前端结果页正式消费 preview envelope。
3. `legacyResultProfile` 仍作为结果页兼容快照保留,相关消费链还没有完全脱离 legacy profile 富字段。
4. 兼容 façade `runtimeProfile.ts` / `runtimeProfileCompiler.ts` 仍保留,等待后续阶段统一清理。
## 9.8 工作包 H共享契约与测试基建
### 目标
@@ -878,6 +1030,34 @@ store 当前混合了:
可从工作包 A 开始先建骨架,随后跟随 B 到 G 持续补齐。
### 当前进展(`2026-04-21`
工作包 H 已完成以下落地:
1. 已把 `rpgAgentAnchors.ts``rpgAgentDraft.ts``rpgAgentActions.ts``rpgAgentSession.ts``rpgCreationPreview.ts``rpgCreationWorkSummary.ts` 从类型别名骨架推进为真实共享契约定义。
2. 已把旧 `packages/shared/src/contracts/customWorldAgent.ts` 降级为兼容聚合出口,并补齐:
- `customWorldAgentAnchors.ts`
- `customWorldAgentDraft.ts`
- `customWorldAgentActions.ts`
- `customWorldAgentSession.ts`
- `customWorldResultPreview.ts`
- `customWorldWorkSummary.ts`
让旧命名导入可以按分域文件渐进迁移,而不是继续依赖单一大文件。
3. 已新增 `packages/shared/src/contracts/rpgCreationFixtures.ts`补齐八锚点、foundation draft、session、preview、published profile、library、works 等共享样本,并把 fixture 接入 `packages/shared/src/index.ts` 统一导出。
4. 已把 shared contract tests 接入 `vitest.config.ts`,并补齐 `packages/shared/src/contracts/rpgContracts.test.ts`,覆盖 session snapshot、preview envelope、published profile、works summary以及旧命名兼容分文件的类型消费。
5. 已新增 `server-node/src/services/RpgWorldPreviewCompiler.fixture.test.ts``server-node/src/services/RpgWorldWorkSummaryAssembler.fixture.test.ts``server-node/src/services/customWorldWorkSummaryService.integration.test.ts`,把 preview compiler、works assembler、works service 对共享 fixture 的消费纳入 unit / integration / regression 回归。
6. 已补 `server-node/src/services/RpgWorldWorkSummaryService.ts` 兼容实现,确保 works 兼容入口与当前 `rpgWorldProfiles + customWorldAgentSessions` 读模型服务口径一致。
7. `customWorldAgentOrchestrator.ts` 已新增统一 session snapshot 装配入口,当前普通拉取与 SSE message stream 返回的 session 字段口径开始收口。
8. 服务端 `RpgWorldPreviewCompiler` 输出已正式接入 session snapshot 的 `resultPreview` 字段,并复用当前 `qualityFindings` 生成 preview `qualityFindings / blockers` 兼容输出。
9. `rpg-agent-session-store/rpgAgentSessionCompatibility.test.ts` 已覆盖“compatibility 脱离 store 直接单测”的主链能力Phase 2 的 session 兼容层开始具备独立回归保障。
10. `src/app.test.ts` 已补“custom world agent stream message returns enriched session payload over sse”回归session snapshot / resultPreview / supportedActions 的 HTTP 与 SSE 响应口径开始统一验证。
本轮刻意未做:
1. 还没有批量迁移仓库里所有旧 `customWorldAgent.ts` 导入到 `rpgAgent* / rpgCreation*`
2. 还没有批量把前端结果页与自动保存链统一切到服务端 `resultPreview`
3. 还没有把服务端 preview contract 从 legacy profile 兼容载体升级成独立 view model。
## 9.9 并行推进关系
推荐并行顺序如下:
@@ -986,6 +1166,23 @@ store 当前混合了:
2. 自动保存与 session 同步都基于服务端确认后的 preview
3. 结果页字段回退问题不再依赖前端兼容修补
### 当前进展(`2026-04-21`
Phase 3 本轮已完成以下主链接线:
1. 前端 `rpgCreationPreviewAdapter.ts` 已正式改成“优先读取 `session.resultPreview`,本地 `draftProfile -> legacy result profile` 只做 fallback”的薄适配层。
2. `useRpgCreationSessionController.ts``useRpgCreationResultAutosave.ts``useRpgEntryLibraryDetail.ts` 所在的 Agent 结果页打开链、自动保存链、继续创作恢复链,已统一通过 `buildPreviewFromSession()` 消费服务端 preview。
3. `RpgEntryFlowShellImpl.tsx` 当前传给结果页自动保存与创作入口恢复的 `buildDraftResultProfile` 已切到服务端 preview 主链,不再把前端本地编译结果当成正式真相源。
4. 前端 fallback 编译实现已迁入 `src/services/rpg-creation/rpgCreationPreviewDraftFallback.ts`,旧 `src/services/customWorldAgentDraftResult.ts` 已退化为兼容 re-export不再继续承载主实现。
5. 已新增 `src/services/rpg-creation/rpgCreationPreviewAdapter.test.ts`,补齐“服务端 `resultPreview` 优先于本地 draft fallback”的回归断言。
6. `PreGameSelectionFlow.agent.interaction.test.tsx` 已补充“没有 `legacyResultProfile` 也能凭服务端 preview 打开 Agent 结果页”的交互回归,验证结果页主链已切到后端 preview。
本轮刻意未做:
1. `src/services/customWorldAgentDraftResult.ts` 仍保留,但当前已退化为兼容 re-export真实 fallback 编译实现已迁到 `src/services/rpg-creation/rpgCreationPreviewDraftFallback.ts`,尚未物理删除该兼容入口。
2. `legacyResultProfile` 仍保留在 session draft 中参与兼容输出,本轮没有越界清理后端兼容字段。
3. 结果页 UI 还没有显式消费 `qualityFindings / blockers / preview source` 做额外展示,当前先完成主数据源迁移,不扩大 UI 变更面。
## Phase 4发布链、自动保存链、进入世界链统一
### 目标
@@ -1004,6 +1201,50 @@ store 当前混合了:
2. works、library、enter world 三处状态语义一致
3. 发布失败可以给出明确 blocker 与恢复入口
### 当前进展(`2026-04-21`
Phase 4 本轮已完成以下主链接线:
1. 服务端 `customWorldAgentPublishingService.ts` 已补结构化 `evaluatePublishReadiness()`,把 publish blocker 从“只在发布时报错”提升为可供 session preview、结果页和 works 读模型统一消费的后端真相。
2. `customWorldAgentOrchestrator.ts` 当前输出的 `session.resultPreview` 已补:
- `publishReady`
- `canEnterWorld`
- 基于发布门槛而不是仅 `qualityFindings` 生成的 `blockers`
让结果页可以直接消费正式 gate 语义。
3. `RpgWorldWorkSummaryAssembler.ts` 已把 works 读模型进一步收口:
- 已进入 `published` 阶段的 Agent session 不再继续以草稿项出现在 works 创作中心
- draft works 新增 `blockerCount / publishReady`
- published works 明确输出 `canEnterWorld=true`
4. 前端 Agent 结果页已开始消费服务端 Phase4 状态:
- 结果页在 Agent 草稿未发布时把主 CTA 改成“发布并进入世界”
- 结果页会展示服务端 preview source、publish blockers、warning 数量
- 有 blocker 时会禁用“发布并进入世界”按钮,不再让前端继续假装可以直接进入世界
5. `useRpgCreationEnterWorld.ts``RpgEntryFlowShellImpl.tsx` 已把 Agent 结果页进入世界主链改成:
-`sync_result_profile`
- 再执行后端 `publish_world`
- 发布成功后才进入世界
不再允许 Agent 草稿结果页绕开 publish gate 直接起游戏。
6. `RpgEntryWorldDetailView.tsx` 已把作品详情页草稿态的主按钮改成“请先发布作品”,避免 detail 页继续暗示未发布作品可以直接开始游戏。
7. 已补回归测试覆盖:
- 服务端 `customWorldAgentPhase4.test.ts`
- 服务端 `customWorldAgentPhase5.test.ts`
- 服务端 `RpgWorldWorkSummaryAssembler.fixture.test.ts`
- 前端 `CustomWorldResultView.test.tsx`
- 前端 `PreGameSelectionFlow.agent.interaction.test.tsx`
8. 作品库 detail 页的“发布到广场”入口已统一复用 Agent Phase4 publish gate
- `/api/runtime/custom-world-library/:profileId/publish` 在命中 `agent-draft-${sessionId}` 且 session 真实存在时,不再直接绕过 gate 调 `publishOwnProfile()`
- 现在会先复用 `CustomWorldAgentPublishingService` 的 blocker 判断
- publish 成功后同步把对应 session 推进到 `published`
- detail 页、works、gallery 三处发布态语义已对齐到同一条后端主链
9. 已补 HTTP 级回归测试覆盖 detail publish 主链:
- 服务端 `app.test.ts` 已新增“agent-backed detail publish 在 blocker 存在时返回明确错误”
- 服务端 `app.test.ts` 已新增“agent-backed detail publish 成功后同步发布 profile 与 session”
本轮刻意未做:
1. 旧兼容作品草稿的 detail publish 还没有强行套入 Agent publish gate当前只在 `agent-draft-${sessionId}` 且 session 真实存在时切换到统一发布链,避免在未补齐兼容映射前误伤历史作品。
2. 运行态真正的“进入世界解析”仍然是前端把 profile 交给 runtime session bootstrap本轮先完成 Agent 创作主链的 publish gate 收口与 UI 阻断,不扩大到 runtime 启动协议改造。
## Phase 5兼容层清理
### 目标
@@ -1022,6 +1263,27 @@ store 当前混合了:
2. 不再存在“前端本地编译 profile 才能自动保存”的依赖
3. 文档、契约、测试口径一致
### 当前进展(`2026-04-21`
Phase 5 本轮已完成以下主链清理:
1. 服务端已新增 `server-node/src/services/rpgCreationPreviewProfileBuilder.ts`把“foundation draft + legacyResultProfile 富字段 + 最新草稿资产”的合并规则正式收回后端preview 与 publish 开始复用同一套兼容编译口径。
2. `customWorldAgentOrchestrator.ts` 当前产出的 `session.resultPreview` 已不再依赖前端本地 fallback
- 预览 profile 改为基于服务端 `rpgCreationPreviewProfileBuilder` 构建
- preview source 已从兼容期的 `legacy_custom_world_profile` 收口为正式主链值 `session_preview`
3. 前端 `rpgCreationPreviewAdapter.ts` 已改成只消费服务端 `session.resultPreview`,结果页、继续创作、自动保存、发布后进入世界所复用的 `buildPreviewFromSession()` 不再承担本地 `draftProfile -> result profile` 编译职责。
4. 结果页与编辑器目录内部的旧 façade 依赖已继续收口,当前 RPG 创作目录内部不再通过已删除旧文件反向跳转结果页/编辑器/资产工坊主链。
5. 前后端测试口径已同步切到 Phase 5
- 前端 `rpgCreationPreviewAdapter.test.ts``PreGameSelectionFlow.agent.interaction.test.tsx` 已统一改为消费 `session_preview`
- 服务端 `RpgWorldPreviewCompiler.test.ts` 已新增“preview builder 保留 legacy 富字段并合并最新草稿资产”的回归
- 服务端 `customWorldAgentPhase3.test.ts``customWorldAgentPhase4.test.ts``app.test.ts` 已把 preview source 断言更新为 `session_preview`
本轮刻意未做:
1. 后端 `legacyResultProfile` 兼容字段仍保留在 foundation draft / result sync / publishing service 中,当前只是把“如何消费它”统一后移到服务端 preview / publish compiler而不是继续让前端主链本地重编译。
2. 旧命名 façade 如 `customWorldAgentSessionStore.ts``customWorldWorkSummaryService.ts``runtimeProfile.ts` 仍保留,因它们还在后端兼容与模块边界层承担真实职责,不属于本轮必须删除项。
3. shared contracts 中旧 `customWorld*` 分域兼容导出仍保留,当前只收口真实定义与 preview source 语义,不越界做全仓库导入迁移。
---
## 11. 本次执行约束
@@ -1047,3 +1309,193 @@ store 当前混合了:
**session 真相源 -> 服务端 preview 编译 -> published profile 发布态**
只有这样,当前链路的可读性、可扩展性和后续功能落地稳定性,才会一起提升。
---
## 13. 2026-04-21 执行核查与老流程清理记录
本节用于记录本次按执行方案做的真实完成度核查、测试结果与老流程删除情况,避免“文档宣称已完成”和“代码真实状态”继续漂移。
### 13.1 本轮核查口径
本轮围绕以下 3 件事执行:
1. 对照工作包 A / B / D / E / F / G / H 进度文档,核对真实代码入口与引用关系。
2. 运行创作链相关与全量测试,确认当前主链真实可用范围。
3. 只删除已经确认不再承载业务逻辑的旧流程桥接入口,不提前删除仍承担兼容编译责任的模块。
### 13.2 核查结论
当前可以确认:
1. 工作包 B、D、E、F、G、H 的首轮主体拆分已经真实落地且对应的新目录、hooks、client、service、repository、compiler 文件已存在。
2. 工作包 C 的结果页、编辑器、资产工坊拆分也已基本落地到 `rpg-creation-result/``rpg-creation-editor/``rpg-creation-asset-studio/` 新目录。
3. Phase 3、Phase 4、Phase 5 的主链接线与兼容层清理现已完成;当前剩余的是后端兼容字段与旧命名 façade 的保留问题,不能再把它们等同于“前端主链仍依赖老流程”。
### 13.3 本轮已物理删除的老流程入口
本轮已确认以下旧入口仅剩桥接职责,且完成引用迁移后可以安全物理删除:
1. `src/components/game-shell/PreGameSelectionFlow.tsx`
2. `src/components/CustomWorldResultView.tsx`
3. `src/components/CustomWorldEntityEditorModal.tsx`
同步完成的调用迁移包括:
1. `GameShellMainContent.tsx` 已改为直接 lazy import `rpg-creation-flow` 新入口。
2. `useGameShellViewModel.ts` 已改为直接从 `rpg-creation-flow``SelectionStage`
3. 结果页、编辑器与对应测试已切到 `rpg-creation-result/``rpg-creation-editor/` 新入口。
4. `RpgCreationShellImpl.tsx` 已改为直接 lazy import `RpgCreationResultView` 新入口,不再回退到已删除旧结果页文件。
### 13.4 本轮明确不能删除的兼容层
以下模块本轮核查后确认仍在主链中承担真实兼容职责,暂时不能物理删除:
1. `src/services/rpg-creation/rpgCreationPreviewAdapter.ts`
2. `server-node/src/services/customWorldAgentSessionStore.ts`
3. `server-node/src/services/customWorldWorkSummaryService.ts`
4. `server-node/src/services/customWorldAgentOrchestrator.ts`
5. `server-node/src/modules/custom-world/runtimeProfile.ts`
原因分别是:
1. `rpgCreationPreviewAdapter.ts` 仍是前端消费服务端 preview 的统一 façade只是已经不再承担本地 fallback 编译。
2. 后端仍通过 `legacyResultProfile` 参与阶段性结果回写与兼容输出。
3. 多个旧命名 façade 仍被 server、context、tests 或 UI 入口直接引用。
### 13.5 本轮测试结果
已执行并确认结果如下:
1. `npm run check:encoding`
结果:通过。
2. `npm --prefix server-node run test`
结果:通过,`192` 项测试全部通过。
3. `npm --prefix server-node run build`
结果:通过。
4. `npm --prefix server-node run test -- --test-name-pattern="action registry|phase5 publish_world|phase5 generate_scene_assets|phase5 publish_world blocks incomplete|phase5 revert_checkpoint|phase5 expand_long_tail"`
结果:通过,`208` 项测试全部通过,已覆盖工作包 E 第三轮发布链、场景资产、长尾扩展与 checkpoint 回滚主链。
5. `npm run test -- src/components/game-shell/PreGameSelectionFlow.agent.interaction.test.tsx src/components/CustomWorldResultView.test.tsx src/components/CustomWorldEntityEditorModal.test.tsx`
结果:通过,`34` 项测试全部通过。
6. `npm run test`
结果:失败,但失败点与本轮删除旧入口无直接关系;创作链相关定向回归已通过。
7. `npm run build`
结果Vite 构建成功,但 build gate 因 chunk warning 失败,属于既有构建门禁问题。
8. `npm run typecheck`
结果:失败,存在 shared contracts、story contracts、runtime data、旧测试断言等既有类型问题当前不适合作为本轮创作链清理通过口径。
9. `npm --prefix server-node test -- src/services/customWorldAgentPhase3.test.ts src/services/customWorldAgentPhase4.test.ts src/services/customWorldAgentActionRegistry.test.ts src/services/RpgWorldPreviewCompiler.test.ts`
结果:本轮新增的 `resultPreview` / `supportedActions` 主链断言已通过,但定向命令仍被一个既有 `customWorldAgentFoundationDraftService.test.ts` 断言失败带停,失败点与本轮 session snapshot 装配改动无直接耦合。
10. `npm --prefix server-node run build`
结果:通过。
11. `npm run test -- src/services/rpg-creation/rpgCreationPreviewAdapter.test.ts src/components/game-shell/PreGameSelectionFlow.agent.interaction.test.tsx`
结果:通过,`17` 项测试全部通过。
12. `node --test --import tsx src/services/customWorldAgentActionRegistry.test.ts`
结果:通过,`5` 项测试全部通过。
13. `node --test --import tsx src/services/customWorldAgentPhase5.test.ts`
结果:通过,`7` 项测试全部通过,已覆盖 `publish_world``generate_scene_assets``sync_scene_assets``expand_long_tail``revert_checkpoint` 的 Phase 5 主链回归。
14. `node --test --import tsx src/services/rpg-agent-session-store/rpgAgentSessionCompatibility.test.ts`
结果:通过,`2` 项测试全部通过。
15. `node --test --import tsx src/app.test.ts`
结果:通过,`55` 项测试全部通过,包含 SSE enriched session 回归。
15. `node --test --import tsx src/services/customWorldAgentPhase3.test.ts src/services/customWorldAgentPhase4.test.ts`
结果:通过,`11` 项测试全部通过。
16. `npm --prefix server-node run build`
结果:通过。
17. `npm run check:encoding`
结果:通过,`1877` 个文件编码检查通过。
18. `npm run test -- src/services/rpg-creation/rpgCreationPreviewAdapter.test.ts src/components/game-shell/PreGameSelectionFlow.agent.interaction.test.tsx`
结果:通过,`20` 项测试全部通过;已验证前端结果页主链不再依赖本地 preview fallback。
19. `node --test --test-concurrency=1 --import tsx src/services/customWorldAgentActionRegistry.test.ts src/services/customWorldAgentPhase3.test.ts src/services/customWorldAgentPhase5.test.ts`
结果:通过,`16` 项测试全部通过;已验证 action registry 契约清理、Phase3 preview source 口径与 Phase5 发布链回归均正常。
### 13.6 当前全量阻塞项
截至 `2026-04-21` 本轮核查结束时,仓库仍存在以下全量阻塞:
1. `server-node/src/app.test.ts` 存在未解决合并冲突。
2. `src/hooks/story/npcEncounterActions.test.ts` 存在未解决合并冲突。
3. 前端全量 Vitest 仍有 3 个失败用例:
- `src/components/game-shell/useGameShellRuntimeViewModel.test.ts`
- `src/data/functionCatalog/functionCatalog.test.ts`
- `src/hooks/story/npcEncounterActions.test.ts`
4. 前端全量 TypeScript 检查仍有多处既有错误。
5. 前端 build gate 仍被大 chunk warning 阻断。
### 13.7 完成度判断
按执行方案分阶段判断,当前更准确的状态是:
1. Phase 1主体完成并已开始物理清理前端旧入口。
2. Phase 2后端拆分主体完成`snapshot / supportedActions / resultPreview / SSE enriched session / session compatibility` 主链都已有定向回归覆盖;但旧命名 façade 兼容层仍保留,且发布链统一语义尚未进入 Phase 4 收口态。
3. Phase 3主链接线已完成前端结果页、自动保存与创作恢复入口已切到服务端 `resultPreview`;但本地 preview fallback 与 `legacyResultProfile` 兼容层仍保留,尚未进入 Phase 5 清理态。
4. Phase 4部分完成`publish_world` 已有真实 executor 与 gate 接线,但 publish gate / enter world / works 状态语义还没有完全统一到后端正式发布态。
4. Phase 4主链完成。Agent 结果页、works 聚合、detail publish 与进入世界阻断已统一到后端正式发布态;当前剩余仅是 runtime 启动协议与旧兼容草稿映射,不再属于本阶段必须项。
5. Phase 5主链完成。前端本地 preview 编译桥、结果页旧入口影子引用、执行型废弃 action 契约已清理完成;当前剩余仅是后端 `legacyResultProfile` 兼容字段与旧命名 façade 保留,不再阻塞本阶段验收。
### 13.8 后续删除顺序建议
后续继续删除老流程代码时,应严格按下面顺序推进:
1. 先完成 `qualityFindings / blockers / preview source` 的结果页与 gate 消费,把 Phase 4 所需阻断语义真正接到 UI 与进入世界链。
2. 再按后端兼容迁移节奏收缩 `legacyResultProfile` 写回范围,而不是恢复前端本地 preview 编译桥。
3. 再删除 `customWorldAgentSessionStore.ts``customWorldWorkSummaryService.ts``runtimeProfile.ts` 等旧命名 façade。
4. 最后清理 `customWorld*` 旧契约聚合入口与剩余测试旧导入。
### 13.9 Phase 4 本轮追加落地(`2026-04-21`
本轮围绕 Phase 4 继续补齐了“发布链、自动保存链、进入世界链统一”的剩余断点:
1. 服务端 `CustomWorldAgentPublishingService` 已新增统一的 publish gate 摘要出口,`resultPreview` 与 works 聚合现在复用同一套 `blockers / publishReady / canEnterWorld` 判断,不再各自重复拼门禁语义。
2. `RpgWorldWorkSummaryAssembler` 已改为跳过 `stage === published` 的 session 草稿项,避免作品中心在正式发布后同时出现“已发布 profile + 已发布 session 草稿”双份状态。
3. works 草稿项的 `publishReady / blockerCount` 已从“只看 qualityFindings”切到真实 publish gate 结果,作品中心、结果页与发布执行器开始共享同一套阻断口径。
4. 前端 Agent 结果页继续沿用服务端 `resultPreview`,并在“发布并进入世界”成功后优先消费发布后的 preview/profile而不是直接把 preview 原始对象强转成运行时 profile。
5. 已补 `RpgWorldWorkSummaryAssembler.fixture.test.ts``customWorldWorkSummaryService.integration.test.ts``PreGameSelectionFlow.agent.interaction.test.tsx` 回归,覆盖 works 去重、publish gate 口径一致,以及“先发布再进入世界”主链。
6. 共享 fixture 已补齐 `generatedSceneAssetId / publishReady / blockerCount / canEnterWorld` 等 Phase 4 口径字段,默认基线样本现在能够真实通过服务端 publish gate避免 works / preview / 测试断言继续使用“前端自定义假 ready”状态。
7. 前端“发布并进入世界”交互回归已改为状态驱动 mock结果页打开前保持 `ready_to_publish`,仅在 `publish_world` 完成后切换为 `published`,从而覆盖 Phase 4 真实的“草稿结果页 -> 发布 -> 进入世界”顺序,而不是直接伪造已发布初始态。
本轮仍未完成:
1. Agent 工作区内还没有独立的“发布世界”快捷入口,当前主入口仍在结果页。
2. 旧兼容作品草稿的 detail publish 仍保留旧作品库接口,不属于本次 Agent Phase 4 主链统一范围。
### 13.10 老脚本依赖删除追加记录(`2026-04-21`
本轮按“不要再与老脚本有依赖”的口径继续执行物理清理,完成以下事项:
1. 前端 RPG 创作主链已切到 `Rpg*` client 命名:
- `src/components/rpg-entry/useRpgCreationSessionController.ts` 直接调用 `createRpgCreationSession / getRpgCreationSession / streamRpgCreationMessage / executeRpgCreationAction`
- `src/components/rpg-entry/useRpgCreationResultAutosave.ts` 直接调用 `executeRpgCreationAction / getRpgCreationOperation / upsertRpgWorldProfile`
2. `src/services/rpg-creation/` 已删除旧命名导出:
- 不再导出 `createCustomWorldAgentSession / executeCustomWorldAgentAction / getCustomWorldAgentSession`
- 不再导出 `listCustomWorldWorks / upsertCustomWorldProfile / listCustomWorldLibrary`
- 不再导出结果页实体生成的 `generateCustomWorldPlayableNpc / generateCustomWorldSceneImage` 等兼容别名
3. 旧 service 聚合入口已断开:
- `src/services/aiService.ts` 不再 re-export RPG 创作链能力
- `src/services/storageService.ts` 已删除,运行时存档、设置、作品入口能力已迁入 `rpg-entry / rpg-runtime` 域 client
4. 旧组件入口继续物理删除:
- `src/components/CustomWorldRoleAssetStudioModal.tsx`
- `src/components/CustomWorldResultView.tsx`
- `src/components/CustomWorldEntityEditorModal.tsx`
- `src/components/game-shell/PreGameSelectionFlow.tsx`
5. 新组件入口已删除旧命名导出:
- `RpgCreationResultView.tsx` 只导出 `RpgCreationResultView`
- `RpgCreationEntityEditorModal.tsx` 只导出 `RpgCreationEntityEditorModal / RpgCreationEditorTarget`
- `RpgCreationRoleAssetStudioModal.tsx` 只导出 `RpgCreationRoleAssetStudioModal`
6. 已使用源码级扫描确认 `src / packages / server-node` 中不再存在以下旧主链符号引用:
- `createCustomWorldAgentSession`
- `executeCustomWorldAgentAction`
- `getCustomWorldAgentSession`
- `streamCustomWorldAgentMessage`
- `listCustomWorldWorks`
- `upsertCustomWorldProfile`
- `CustomWorldRoleAssetStudioModal`
- `CustomWorldResultView`
- `CustomWorldEntityEditorModal`
7. 本轮未删除 `CustomWorldProfile` 等历史数据结构类型名,也未删除 `server-node` 侧仍承担真实兼容职责的旧命名 façade这些属于后端兼容字段与契约命名迁移不再是前端老脚本依赖。
8. 本轮验证结果:
- `npm run test -- src/services/rpg-creation/rpgCreationPreviewAdapter.test.ts src/components/CustomWorldResultView.test.tsx src/components/CustomWorldEntityEditorModal.test.tsx src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx`
结果:通过,`42` 项测试全部通过。
- `npm run test -- src/services/rpg-creation/rpgCreationGenerationClient.test.ts`
结果:通过,`2` 项测试全部通过。
- `npm run check:encoding`
结果:通过,`1929` 个文件编码检查通过。

View File

@@ -53,15 +53,18 @@
已新增以下共享契约入口:
1. `packages/shared/src/contracts/rpgAgentAnchors.ts`
2. `packages/shared/src/contracts/rpgAgentSession.ts`
3. `packages/shared/src/contracts/rpgAgentActions.ts`
4. `packages/shared/src/contracts/rpgCreationPreview.ts`
5. `packages/shared/src/contracts/rpgCreationWorkSummary.ts`
2. `packages/shared/src/contracts/rpgAgentDraft.ts`
3. `packages/shared/src/contracts/rpgAgentSession.ts`
4. `packages/shared/src/contracts/rpgAgentActions.ts`
5. `packages/shared/src/contracts/rpgCreationPreview.ts`
6. `packages/shared/src/contracts/rpgCreationWorkSummary.ts`
当前策略:
1. 会话、动作、作品摘要先从旧 `customWorldAgent.ts` 做类型级兼容导出。
2. `rpgCreationPreview.ts` 明确标记当前 preview 仍是 legacy profile 兼容载体,避免误认为 preview contract 已经完成
2. `rpgAgentDraft.ts` 先把 foundation draft、draft card 等草稿相关类型收口成独立入口,给工作包 H 后续物理拆分预留稳定导入点
3. `packages/shared/src/index.ts` 已补上对 RPG 草稿契约骨架的根导出,避免后续工作包继续回退到旧 `customWorldAgent.ts` 取类型。
4. `rpgCreationPreview.ts` 明确标记当前 preview 仍是 legacy profile 兼容载体,避免误认为 preview contract 已经完成。
## 3. 本次没有做的事

View File

@@ -0,0 +1,106 @@
# 创作流程链路重构工作包 B 完成记录
更新时间:`2026-04-21`
## 1. 本轮目标
工作包 B 聚焦前端平台壳层与流程编排拆分,本轮目标是把平台壳层从“大编排文件”收口成“页面壳层 + 独立 hooks / coordinator”
1. `PreGameSelectionFlow.tsx` 退化为兼容入口。
2. `RpgCreationShellImpl.tsx` 只保留 stage 切换、组件装配、视觉级 loading / error。
3. 平台 bootstrap、session controller、operation polling、detail navigation、result autosave、enter-world 逻辑全部迁入 `src/components/game-shell/rpg-creation-flow/` 新目录。
4. 保证现有交互测试继续通过,不引入主链行为回退。
---
## 2. 已完成内容
### 2.1 旧入口已退化为兼容层
`src/components/game-shell/PreGameSelectionFlow.tsx` 现在只保留:
1. 旧类型导出兼容:`PreGameSelectionFlowProps``SelectionStage`
2. 旧组件名兼容:`PreGameSelectionFlow`
3. 对新实现 `RpgCreationShellImpl` 的桥接
这样现有调用方和测试仍可继续走旧路径,不会因为命名迁移立即破坏主链。
### 2.2 新目录已承接真实实现与流程 hooks
已新增或更新以下文件:
1. `src/components/game-shell/rpg-creation-flow/RpgCreationShellImpl.tsx`
2. `src/components/game-shell/rpg-creation-flow/RpgCreationShell.tsx`
3. `src/components/game-shell/rpg-creation-flow/index.ts`
4. `src/components/game-shell/rpg-creation-flow/rpgCreationFlowTypes.ts`
5. `src/components/game-shell/rpg-creation-flow/rpgCreationFlowShared.ts`
6. `src/components/game-shell/rpg-creation-flow/useRpgCreationPlatformBootstrap.ts`
7. `src/components/game-shell/rpg-creation-flow/useRpgCreationSessionController.ts`
8. `src/components/game-shell/rpg-creation-flow/useRpgCreationAgentOperationPolling.ts`
9. `src/components/game-shell/rpg-creation-flow/useRpgCreationDetailNavigation.ts`
10. `src/components/game-shell/rpg-creation-flow/useRpgCreationResultAutosave.ts`
11. `src/components/game-shell/rpg-creation-flow/useRpgCreationEnterWorld.ts`
其中:
1. `RpgCreationShell.tsx` 已不再桥接旧 `PreGameSelectionFlow`,而是直接桥接 `RpgCreationShellImpl.tsx`
2. `index.ts` 已开始从新目录导出 `SelectionStage`,为后续调用迁移准备统一出口。
### 2.3 平台编排已全部拆入独立 coordinator
本轮已经把原 `PreGameSelectionFlow` / `RpgCreationShellImpl` 中的主链编排拆到以下 hook
1. `useRpgCreationPlatformBootstrap.ts`
- 平台首页 works / library / gallery / history / save / dashboard 拉取
- 浏览历史写入与存档恢复
2. `useRpgCreationSessionController.ts`
- Agent session 创建 / 恢复
- 消息流、action 执行、草稿生成态与结果页自动打开
3. `useRpgCreationAgentOperationPolling.ts`
- Agent operation 轮询
- 完成态 session 刷新与失败兜底
4. `useRpgCreationDetailNavigation.ts`
- 作品详情、创作作品恢复、草稿结果页打开
- 详情页发布 / 下架 / 删除
5. `useRpgCreationResultAutosave.ts`
- 结果页自动保存
- `sync_result_profile` 协调
- 保存签名去重与延时保存
6. `useRpgCreationEnterWorld.ts`
- 进入世界前的最终草稿同步
当前 `RpgCreationShellImpl.tsx` 只保留:
1. hooks 组合
2. stage 级视图切换
3. 组件 props 装配
4. 视觉级 loading / error 展示
---
## 3. 当前状态判断
工作包 B 已达到执行方案中的验收口径:
1. `PreGameSelectionFlow.tsx` 只剩兼容导出与新壳层桥接。
2. `RpgCreationShellImpl.tsx` 不再直接持有平台请求编排、operation 轮询、自动保存或进入世界同步细节。
3. 平台侧主链已经切成壳层 + hooks / coordinator。
4. 现有 `PreGameSelectionFlow.agent.interaction.test.tsx` 的 14 个场景全部通过。
---
## 4. 本轮刻意未做
1. 还没有物理删除 `PreGameSelectionFlow.tsx`,当前继续保留旧入口兼容层,避免影响并行工作包的调用路径。
2. 还没有让所有调用方统一显式改走 `RpgCreationShell` 新入口,当前仍允许旧入口桥接到新壳层。
3. 还没有把结果页 preview 数据源从前端兼容 adapter 切到服务端正式 preview contract当前仍使用 `rpgCreationPreviewAdapter` 作为阶段性兼容层,这属于后续工作包 G / H 与 Phase 3 范围。
4. 还没有清理所有 legacy 兼容导出与 façade当前优先稳定主链与测试口径。
---
## 5. 验证结果
1. `npx eslint "src/components/game-shell/PreGameSelectionFlow.tsx" "src/components/game-shell/rpg-creation-flow/*.ts" "src/components/game-shell/rpg-creation-flow/*.tsx"`
2. `npx vitest run src/components/game-shell/PreGameSelectionFlow.agent.interaction.test.tsx`
3. `npm run check:encoding`
以上检查在本轮修改后均已通过。

View File

@@ -0,0 +1,106 @@
# 创作链路重构工作包 D 落地记录
更新时间:`2026-04-21`
## 1. 本次目标
本次只落实 `CREATION_FLOW_CHAIN_REFACTOR_EXECUTION_PLAN_2026-04-21.md` 中的 **工作包 D前端 custom world client 收口**,约束如下:
1. 把创作链主路径依赖的 custom world 请求从 `aiService.ts``storageService.ts` 中迁入 `src/services/rpg-creation/`
2. 首轮允许旧 service 兼容导出,追加清理轮必须删除已无调用方的旧命名导出。
3. 不改后端接口语义,不扩写结果页 UI 逻辑,不借机重构工作包 B / C 的内部状态编排。
## 2. 本次已落地内容
## 2.1 RPG 创作域请求基座已独立
已新增以下请求基座文件:
1. `src/services/rpg-creation/rpgCreationRuntimeClient.ts`
2. `src/services/rpg-creation/rpgCreationRequestHelpers.ts`
当前策略:
1. runtime 读写重试策略不再散落在 `storageService.ts` 内部,而是作为 RPG 创作域专属 runtime client 复用。
2. Agent SSE、POST JSON 请求辅助能力收口到 `rpgCreationRequestHelpers.ts`,避免再把流式解析细节写回通用 service。
## 2.2 五类 rpgCreation client 已持有真实请求实现
以下 client 已不再桥接旧 service而是直接持有真实网络实现
1. `src/services/rpg-creation/rpgCreationAgentClient.ts`
2. `src/services/rpg-creation/rpgCreationWorkClient.ts`
3. `src/services/rpg-creation/rpgCreationLibraryClient.ts`
4. `src/services/rpg-creation/rpgCreationAssetClient.ts`
5. `src/services/rpg-creation/rpgCreationGenerationClient.ts`
本轮已完成的具体收口:
1. Agent session 创建、读取、消息发送、消息流、action 执行、operation 查询、card detail 查询已经正式迁入 `rpgCreationAgentClient.ts`
2. works 列表查询已经正式迁入 `rpgCreationWorkClient.ts`
3. library / publish / unpublish / gallery / gallery detail 已经正式迁入 `rpgCreationLibraryClient.ts`
4. 结果页与编辑器依赖的场景图、场景 NPC、可扮演角色、场景角色、场景生成请求已经正式迁入 `rpgCreationAssetClient.ts`
5. `generateCustomWorldProfile()` 已正式迁入 `rpgCreationGenerationClient.ts`,世界生成入口也已进入 RPG 创作域 client。
6. `src/services/rpg-creation/index.ts` 已收口为 RPG 命名导出,创作主链不再从 barrel 暴露 `createCustomWorldAgentSession / listCustomWorldWorks / upsertCustomWorldProfile` 等旧命名入口。
## 2.3 旧 service 兼容导出已删除
追加清理轮已完成以下删除:
1. `src/services/aiService.ts` 不再 re-export RPG 创作 Agent / works / 结果页生成接口,继续只服务 story/chat 等通用 AI 运行时能力。
2. `src/services/storageService.ts` 已物理删除,运行时存档、设置、资料、浏览历史能力已迁入 `src/services/rpg-entry/``src/services/rpg-runtime/`
3. `rpgCreationAgentClient.ts``rpgCreationWorkClient.ts``rpgCreationLibraryClient.ts``rpgCreationAssetClient.ts` 已删除 `CustomWorld*` 兼容具名导出,只保留 `Rpg*` 主命名。
4. 源码扫描已确认不再存在 `createCustomWorldAgentSession / executeCustomWorldAgentAction / listCustomWorldWorks / upsertCustomWorldProfile` 等旧主链函数引用。
## 2.4 主链调用已开始直接使用 RPG 创作域 client
本轮已把以下主链入口切到 `src/services/rpg-creation/`
1. `src/components/rpg-entry/useRpgCreationSessionController.ts`
2. `src/components/rpg-entry/useRpgCreationResultAutosave.ts`
3. `src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx`
4. `src/components/rpg-creation-editor/RpgCreationEntityEditorShared.tsx`
5. 新增世界生成入口 `generateRpgWorldProfile()` 通过 `src/services/rpg-creation/` barrel 暴露,后续新代码不必再从旧 `aiService.ts` 进入。
配套收口:
1. 结果页与编辑器相关测试 mock 已改到 `rpgCreationAssetClient`,不再盯住 `aiService.ts` 的兼容层。
2. `CustomWorldResultView.test.tsx``CustomWorldEntityEditorModal.test.tsx` 已改为直接消费 `RpgCreationResultView / RpgCreationEntityEditorModal` 新入口,不再通过旧组件 façade。
## 2.5 本轮验证结果
已完成以下针对性验证:
1. `npm run test -- src/services/rpg-creation/rpgCreationGenerationClient.test.ts src/services/storageService.test.ts src/components/CustomWorldEntityEditorModal.test.tsx src/components/CustomWorldResultView.test.tsx src/components/game-shell/PreGameSelectionFlow.agent.interaction.test.tsx`
2. `npm run check:encoding`
验证结果:
1. 上述 5 组定向测试全部通过。
2. 编码检查通过,未写坏中文文件。
追加清理轮已完成以下验证:
1. `npm run test -- src/services/rpg-creation/rpgCreationPreviewAdapter.test.ts src/components/CustomWorldResultView.test.tsx src/components/CustomWorldEntityEditorModal.test.tsx src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx`
结果:通过,`42` 项测试全部通过。
2. `npm run test -- src/services/rpg-creation/rpgCreationGenerationClient.test.ts`
结果:通过,`2` 项测试全部通过。
3. `npm run check:encoding`
结果:通过,`1929` 个文件编码检查通过。
4. 源码扫描确认 `src / packages / server-node` 中不再存在本轮删除的旧主链函数与旧组件入口符号引用。
## 3. 本次刻意未做的事
以下内容明确留给后续工作包,不在本轮越界处理:
1. 没有改后端 works/library/gallery/agent route 的语义与 contract。
2. 没有拆 `PreGameSelectionFlow.tsx` 内部编排;这部分仍属于工作包 B。
3. 没有继续物理拆散 `RpgCreationEntityEditorShared.tsx`;这部分仍属于工作包 C 后续细拆。
4. 没有强行重命名历史数据结构类型,例如 `CustomWorldProfile` 与 runtime contract response 名称;这些仍是现有契约类型,不等同于旧脚本依赖。
5. 没有删除旧 `src/services/ai.ts` 中的 legacy 世界生成实现;它已不在当前 RPG 创作主链 client 上,后续应按独立 dead code 批次评估。
## 4. 对后续工作包的直接收益
1. 工作包 B 后续拆平台壳层时,可以直接从 `src/services/rpg-creation/` 消费 Agent / works / library / gallery 请求,不必继续回到旧 service 文件找接口。
2. 工作包 C 后续继续拆结果页和编辑器时,资产生成请求已经有稳定的 RPG 创作域入口。
3. 后续清理 `aiService.ts``storageService.ts` 时,创作链主路径已经完成真实迁出,不会再被“通用 service 同时承载创作域请求”拖住。

View File

@@ -0,0 +1,150 @@
# 创作链路重构工作包 E 落地记录
更新时间:`2026-04-21`
## 1. 本次目标
本次落实 `CREATION_FLOW_CHAIN_REFACTOR_EXECUTION_PLAN_2026-04-21.md` 中的 **工作包 E后端 Agent 编排拆分**,并严格遵守这一轮的写入边界:
1. 只改后端应用服务层,不动前端壳层。
2. 先把 `customWorldAgentOrchestrator.ts` 从“大分支调度 + 派生状态重建 + 结果回写细节”里拆薄。
3. 补齐 action executor 真实落点与 `supportedActions` 主链字段,但不在这一轮顺手重构 session store 和 runtime compiler。
## 2. 本次已落地内容
## 2.1 orchestrator 已退化为应用服务 façade
本轮后,`server-node/src/services/customWorldAgentOrchestrator.ts` 的职责开始收口为:
1. session 级入口方法保留。
2. 创建 operation 记录。
3. 调用 action registry 拿到执行计划。
4. 把消息轮转、foundation 生成、实体生成、角色资产同步等主链事务串起来。
这轮明确移出的内容:
1. `action -> executor` 的分支校验和分发。
2. `sync_result_profile` 的字段回写细节。
3. 多个 action 共用的 draftCards / assetCoverage / suggestedActions / qualityFindings 派生重建逻辑。
## 2.2 已新增 action registry 与 executor 目录,并完成真实执行迁移
已新增:
1. `server-node/src/services/customWorldAgentActionRegistry.ts`
2. `server-node/src/services/customWorldAgentActionExecutors/index.ts`
3. `server-node/src/services/customWorldAgentActionExecutors/types.ts`
本轮收口结果:
1. registry 统一处理 `draft_foundation``update_draft_card``sync_result_profile``generate_characters``generate_landmarks``generate_role_assets``sync_role_assets` 的可用性校验。
2. `publish_world``generate_scene_assets``sync_scene_assets``expand_long_tail``revert_checkpoint` 已完成真实 executor 装配,不再只是 registry 层面的“已声明但未开放”动作。
3. `lock_cards``unlock_cards``regenerate_scope` 仍统一通过 registry 返回禁用原因,不再继续堆在 orchestrator 分支里。
4. `customWorldAgentActionExecutors/` 已补 `draftFoundationExecutor.ts``updateDraftCardExecutor.ts``syncResultProfileExecutor.ts``generateCharactersExecutor.ts``generateLandmarksExecutor.ts``generateRoleAssetsExecutor.ts``syncRoleAssetsExecutor.ts``generateSceneAssetsExecutor.ts``syncSceneAssetsExecutor.ts``expandLongTailExecutor.ts``publishWorldExecutor.ts``revertCheckpointExecutor.ts`,真实 action 执行已从 orchestrator 物理迁入目录。
5. `customWorldAgentActionExecutors/helpers.ts``executorShared.ts` 已收口 action_result / summary message 构造、operation 更新和 session 读取共用逻辑,避免 executor 间重复堆样板代码。
## 2.3 已新增 message turn / suggested action / snapshot / quality gate / result sync service
已新增:
1. `server-node/src/services/customWorldAgentMessageTurnService.ts`
1. `server-node/src/services/customWorldAgentSuggestedActionService.ts`
2. `server-node/src/services/customWorldAgentSnapshotBuilder.ts`
3. `server-node/src/services/customWorldAgentQualityGateService.ts`
4. `server-node/src/services/customWorldAgentResultSyncService.ts`
本轮收口结果:
1. `CustomWorldAgentMessageTurnService` 已接管 session 初始派生状态与 message turn 的真实执行,`customWorldAgentOrchestrator.ts` 只保留 façade 委托。
1. `CustomWorldAgentSuggestedActionService` 统一维护 `foundation_review``object_refining``visual_refining` 的建议动作生成,不再散落在 orchestrator 和 session compatibility。
2. `CustomWorldAgentSnapshotBuilder` 统一承接 message turn、foundation draft、结果页回写、角色/地点追加、角色资产同步后的派生字段重建。
3. `CustomWorldAgentQualityGateService` 已形成独立 finding 入口,当前先输出角色缺失、地点缺失、玩家目标缺失、角色资产待补齐、场景资产待补齐等基础 gate finding。
4. `CustomWorldAgentResultSyncService` 接管了 `sync_result_profile` 的字段回写细节,明确这一轮只允许“摘要 + 资产确认结果 + legacyResultProfile 快照”回写进 draft profile。
## 2.4 `supportedActions` 已接入 session snapshot 主链
这一轮已把 registry 产出的能力矩阵正式装配到 `CustomWorldAgentSessionSnapshot.supportedActions`
1. `createSession``getSessionSnapshot`、stream message 完成态、各 action 完成后的 session 拉取都会返回真实 `supportedActions`
2. `supportedActions` 的启用状态按 session 当前阶段与草稿可用性计算,不再由前端根据 action 字面量自行猜测。
3. 具体 payload 校验仍保留在 action 执行阶段,能力矩阵只表达“当前阶段是否允许发起这类动作”。
## 2.5 action 主链行为保持不变,但派生状态已开始统一
这一轮没有改变现有 action contract也没有新增前端依赖字段但已经把以下重复派生逻辑统一改走 snapshot builder
1. `draft_foundation`
2. `update_draft_card`
3. `sync_result_profile`
4. `generate_characters`
5. `generate_landmarks`
6. `generate_role_assets`
7. `sync_role_assets`
8. message turn 结束后的 stage / suggested actions / quality findings / asset coverage 重建
这意味着:
1. 后续新增 action 时,不必再复制一整段 `draftCards + assetCoverage + suggestedActions + recommendedReplies` patch 拼装代码。
2. `qualityFindings` 已开始成为真实后端派生字段,而不只是 session store 中的空占位。
3. `sync_result_profile` 的边界已经能单独测试和继续收缩。
## 2.6 工作包 E 第三轮已补齐的真实闭环
本轮把工作包 E 前两轮遗留的 5 个动作补成了真实后端闭环:
1. `generate_scene_assets`
已通过 `CustomWorldAgentAssetBridgeService.buildSceneAssetStudioContext()` 打通场景图工坊上下文准备,支持营地与地点单场景进入。
2. `sync_scene_assets`
已通过 `applySceneAssetPublishResult()` 写回营地/地点正式场景图,并同步刷新对应 `sceneChapters[].acts` 的背景图与背景资产 ID。
3. `expand_long_tail`
已接入实体生成服务与 snapshot builder能真实追加长尾角色、地点并把阶段推进到 `long_tail_review`
4. `publish_world`
已改为走 `CustomWorldAgentPublishingService + RpgWorldProfileRepository` 主链,正式把 draft session 编译、写入并发布到作品库。
5. `revert_checkpoint`
已依赖 checkpoint snapshot 元数据与 `restoreCheckpoint()` 主链完成真实回滚,不再只是开放 action 名称。
这一轮同时补齐了 4 个关键收口:
1. 发布链已经统一改走 `CustomWorldAgentPublishingService``customWorldAgentOrchestrator.ts``customWorldAgentActionExecutors/index.ts``publishWorldExecutor.ts``server.ts` 的注入口径已经对齐;作者展示名优先走 `resolveAuthorDisplayName`,同时保留 `userRepository` 兼容兜底。
2. `publish_world` 的 readiness 与正式发布已经收口到同一个服务,`profileId` 固定优先沿用 legacy 结果页 ID否则回退为 `agent-draft-${sessionId}`,避免发布产物继续使用临时时间戳。
3. `buildCheckpointSnapshot()` 已接入 `draft_foundation``update_draft_card``sync_result_profile``generate_characters``generate_landmarks``sync_role_assets``sync_scene_assets``expand_long_tail``publish_world` 等关键 executorcheckpoint 现在保存的是真正可恢复的派生快照,而不是只记一段残缺 patch。
4. `rebuildRoleAssetCoverage()` 已补营地 / 地点正式场景资产 fallback 汇总,并收口为“只有真实正式场景图已存在时才补 standalone summary”这样 `sync_scene_assets` 写回后的 camp/landmark asset coverage 在 snapshot 重建、works 读模型与 checkpoint 回放里都不会丢失,也不会误伤 phase3 自动资产回归。
## 2.7 本轮验证结果
已完成以下验证:
1. `npm --prefix server-node run build`
2. `npm --prefix server-node run test -- customWorldAgentPhase3.test.ts customWorldAgentActionRegistry.test.ts customWorldAgentPhase5.test.ts`
本轮重点关注的回归范围:
1. `customWorldAgentActionRegistry.test.ts`
2. `customWorldAgentPhase3.test.ts`
3. `customWorldAgentPhase5.test.ts`
4. `publish_world`
5. `generate_scene_assets / sync_scene_assets`
6. `expand_long_tail`
7. `revert_checkpoint`
验证结果:
1. `server-node` 构建通过。
2. 定向回归通过,共 `208` 项测试全部通过。
3. Phase 3 与 Phase 5 已同时确认通过,说明这轮对 `sceneAssets` fallback summary 的收口没有打坏前序自动资产链。
## 3. 本次刻意未做的事
以下内容明确留给后续工作包或下一轮工作包 E不在本轮越界处理
1. 还没有进入 Phase 4 的“进入世界统一走发布态 gate”收口当前这轮只完成了发布动作本身的后端闭环。
2. 还没有改 `customWorldAgentSessionStore.ts` 内部 compatibility / snapshot 输出结构,这部分仍属于工作包 F。
3. 还没有把 result preview 正式接到 `resultPreview` 主链字段,这部分仍需要和工作包 G / H 协作。
4.`customWorldAgentPublishGateService.ts``customWorldAgentPublishService.ts` 仍作为历史兼容文件保留,但工作包 E 主链已经不再走它们;这一轮没有继续做物理删除与引用清扫,避免越界碰到 Phase 4/Phase 5 之外的兼容入口。
## 4. 对后续工作包的直接收益
1. 工作包 F 可以在不碰 orchestrator 大分支的前提下,继续拆 session/store/repository。
2. 工作包 G 可以直接围绕 `CustomWorldAgentResultSyncService``CustomWorldAgentQualityGateService` 对接服务端 preview compiler 与 publish gate。
3. 工作包 H 可以基于已落地的 `supportedActions`、action registry 和 quality gate 继续推进 preview contract 与 contract tests。
4. 后续继续拆 action executor 时,已经有 `customWorldAgentActionExecutors/` 目录和注册表,不需要再回到 orchestrator 里重新铺路。

View File

@@ -0,0 +1,92 @@
# 创作链路重构工作包 F 落地记录
更新时间:`2026-04-21`
## 1. 本次目标
本次落实 `CREATION_FLOW_CHAIN_REFACTOR_EXECUTION_PLAN_2026-04-21.md` 中的 **工作包 F后端 session/store/repository 拆分**,约束如下:
1. 不改动现有主链接口与行为语义。
2. 保留 `customWorldAgentSessionStore.ts``runtimeRepository.ts``customWorldWorkSummaryService.ts` 作为兼容 façade。
3. 把 session 兼容补齐、session 持久化、profile 持久化、works 读模型组装从大文件中物理拆出。
## 2. 本次已落地内容
## 2.1 session store 内部分层
已新增以下 RPG Agent session 拆分文件:
1. `server-node/src/services/rpg-agent-session-store/rpgAgentSessionRecord.ts`
2. `server-node/src/services/rpg-agent-session-store/rpgAgentSessionCompatibility.ts`
3. `server-node/src/services/rpg-agent-session-store/rpgAgentSessionFactory.ts`
4. `server-node/src/services/rpg-agent-session-store/rpgAgentSessionRepositoryAdapter.ts`
当前策略:
1. `customWorldAgentSessionStore.ts` 继续保留旧类名和旧方法签名。
2. sessionId 前缀、snapshot 输出结构、operation/checkpoint 写入语义保持兼容。
3. 旧 session 的兼容补齐逻辑集中收口到 `rpgAgentSessionCompatibility.ts`,不再继续堆在 store 主文件里。
4. `customWorldAgentSessionStore.ts` 已正式改为依赖 `RpgAgentSessionRepositoryPort`phase2~5 与 works 集成测试也已切到新的 session 仓储端口。
## 2.2 custom world 仓储从 runtime 大仓储中拆出
已新增以下 RPG 世界仓储文件:
1. `server-node/src/repositories/RpgAgentSessionRepository.ts`
2. `server-node/src/repositories/RpgWorldProfileRepository.ts`
3. `server-node/src/repositories/rpgWorldRepositoryShared.ts`
当前策略:
1. `RuntimeRepositoryPort` 继续保留兼容 façade`context.ts``server.ts``runtimeRoutes.ts`、同步脚本已开始直接注入并使用 `RpgAgentSessionRepository``RpgWorldProfileRepository`
2. `runtimeRepository.ts` 内的 custom world session/profile/gallery SQL 已改成委托新仓储。
3. `runtimeRepository.ts` 继续只保留 runtime 快照、设置、浏览历史、档案等通用能力,以及少量尚未迁走的快照同步编排。
## 2.3 works 读模型拆分
已新增以下 works 读模型相关文件:
1. `server-node/src/services/RpgWorldWorkCoverResolver.ts`
2. `server-node/src/services/RpgWorldWorkSummaryAssembler.ts`
3. `server-node/src/services/RpgWorldWorkSummaryService.ts`
并将:
1. `server-node/src/services/customWorldWorkSummaryService.ts`
退化为兼容入口,仅负责桥接新 `RpgWorldWorkSummaryService`
当前策略:
1. works service 只保留服务入口,不再内嵌标题、摘要、封面、资产覆盖率等全部组装细节。
2. 草稿封面与发布态封面解析统一走 resolver避免后续重复理解封面规则。
3. 草稿态与发布态 work summary 的字段语义保持不变,继续支持“继续创作”和“进入世界”入口判定。
4. `runtimeRoutes.ts` 中的 works/library/gallery 路由已切到 `rpgWorldWorkSummaryService``rpgWorldProfileRepository` 直接注入,不再经由 `runtimeRepository` 中转 custom world 读模型。
## 3. 验证结果
本次已完成以下定向回归:
1. 运行 `node --test --test-concurrency=1 --import tsx src/services/customWorldAgentPhase2.test.ts src/services/customWorldAgentPhase3.test.ts src/services/customWorldAgentPhase4.test.ts src/services/customWorldAgentPhase5.test.ts src/services/customWorldWorkSummaryService.integration.test.ts`
2. 以上 21 个 custom world / agent / works 相关测试全部通过。
同时确认:
1. 全量 `npx tsc -p server-node/tsconfig.json --noEmit` 当前仍被仓库里既有的跨模块类型问题阻塞。
2. 这些全量类型错误大多与本工作包无关,因此本轮仍以 custom world 定向测试通过作为主验证口径。
3. 工作包 F 本轮新增的 `RpgWorldWorkSummaryService.ts`、新仓储注入链和测试 helper未在定向回归中引入新的行为回归。
## 4. 当前兼容保留项
以下内容属于阶段性兼容保留,不再视为工作包 F 未完成项:
1. `RuntimeRepositoryPort` 仍保留 custom world 相关兼容方法,避免一次性冲击 story/runtime 其他调用方。
2. `customWorldAgentSessionStore.ts``customWorldWorkSummaryService.ts` 仍保留旧文件名 façade后续统一命名治理时再清理。
3. runtime 快照同步与 custom world profile 自动回写的进一步解耦,仍留待后续围绕 `runtimeRepository.ts` 继续收口。
## 5. 对后续工作包的直接收益
1. 工作包 E 可以在不继续挤压 `customWorldAgentSessionStore.ts` 的情况下,把 orchestrator 的 result sync / snapshot builder 接到更清晰的 session 持久化边界。
2. 工作包 G 后续若需要让 preview compiler / publish gate 落库,不必再继续往 `runtimeRepository.ts` 堆 custom world SQL。
3. 工作包 H 已能直接围绕 `rpg-agent-session-store/``RpgWorldWorkSummaryAssembler.ts``RpgWorldWorkSummaryService.ts` 与新仓储端口补充更细粒度回归,而不必穿透大文件。
4. 后续若继续拆 route 命名或清理旧 façade已有 `context -> server -> runtimeRoutes -> script -> tests` 的新仓储注入链可直接复用。

View File

@@ -0,0 +1,92 @@
# 创作链路重构工作包 G 落地记录
更新时间:`2026-04-21`
## 1. 本次目标
本次落实 `CREATION_FLOW_CHAIN_REFACTOR_EXECUTION_PLAN_2026-04-21.md` 中的 **工作包 G后端 preview compiler 与 runtime profile 目录化**,并把目录化拆分推进到文档目标结构:
1. 先把 `runtimeProfile.ts` 退化成兼容 façade。
2.`runtime-profile/` 真正拆成 `normalize/build/schema/creatorIntentBridge` 等独立模块。
3. 把服务端 result preview compiler 从 foundation draft 流程中抽出独立入口。
4. 不直接改路由层,不直接接前端结果页。
## 2. 本次已落地内容
## 2.1 runtime profile 已完成目录化完整拆分
已完成以下结构调整:
1. 新增 `server-node/src/modules/custom-world/runtime-profile/index.ts` 作为目录入口。
2.`server-node/src/modules/custom-world/runtimeProfile.ts` 已退化为兼容 façade只负责 re-export。
3. `server-node/src/modules/custom-world/runtime-profile/runtimeProfileCompiler.ts` 已退化为兼容 façade不再承载主实现。
4. 已新增并落地以下目标模块:
1. `normalizeShared.ts`
2. `normalizeRole.ts`
3. `normalizeLandmark.ts`
4. `normalizeSceneChapter.ts`
5. `normalizeCamp.ts`
6. `buildCompiledProfile.ts`
7. `buildAttributeSchema.ts`
8. `creatorIntentBridge.ts`
当前策略:
1. 先保证旧导入路径不失效,避免放大工作包 G 首轮改动范围。
2. 新代码优先改走 `runtime-profile/` 目录入口。
3. `runtimeProfile.ts``runtimeProfileCompiler.ts` 后续只允许继续收缩,不再接受新增主逻辑。
## 2.2 服务端 preview compiler 已从 foundation draft 流程中抽出
已完成以下收口:
1. `server-node/src/services/RpgWorldPreviewCompiler.ts` 不再只是别名导出,已提供:
1. `buildRpgWorldPreviewProfile()`
2. `normalizeRpgWorldPreviewProfile()`
3. `buildRpgWorldPreviewEnvelope()`
4. `normalizeRpgWorldPreviewEnvelope()`
2. `packages/shared/src/contracts/rpgCreationPreview.ts` 已补 `RpgCreationPreviewSource`,把 preview 来源语义显式化。
3. `customWorldAgentFoundationDraftService.ts` 已把 LLM foundation draft 主生成链改成“直接组装 foundation draft + 单独保留 `legacyResultProfile` 兼容快照”,不再通过 preview compiler 反解草稿主字段。
这轮的边界变化是:
1. foundation draft 主字段已经不再依赖“先编 legacy runtime profile再转回 draft”的双重编译。
2. `legacyResultProfile` 仍保留,但只作为结果页兼容快照,不再主导 foundation draft 生成。
3. “服务端 preview 编译入口”继续独立存在,并在 Phase 5 后补上 `rpgCreationPreviewProfileBuilder.ts`,统一承接 preview 与 publish 的兼容合并规则。
4. preview source 已在 Phase 5 后正式收口为 `session_preview`,不再继续沿用兼容期的 `legacy_custom_world_profile` 标记。
## 2.3 已补最小测试与目录化回归验证
本次新增:
1. `server-node/src/services/RpgWorldPreviewCompiler.test.ts`
2. `server-node/src/services/customWorldAgentFoundationDraftService.test.ts`
当前覆盖重点:
1. 验证 preview compiler 可以输出服务端兼容预览 envelope。
2. 验证 envelope 的 `source` 保持为 `session_preview`
3. 验证 preview profile 仍保留 runtime 编译生成的关键字段,例如 `scenarioPackId``campaignPackId`
4. 验证 Phase 5 新增的 preview builder 可以在服务端保留 `legacyResultProfile` 富字段并合并最新草稿资产。
5. 验证 foundation draft service 的 LLM 路径已经直接生成 draft 主字段,不再依赖 preview compiler 反解。
6. 验证 `runtimeProfile.ts` façade 在目录化拆分后仍保持旧调用兼容。
本轮额外验证已通过:
1. `npm run check:encoding`
2. `node --test --test-concurrency=1 --import tsx server-node/src/services/customWorldAgentFoundationDraftService.test.ts server-node/src/modules/custom-world/runtimeProfile.test.ts server-node/src/services/RpgWorldPreviewCompiler.test.ts`
## 3. 本次刻意没有做的事
以下内容仍留给后续阶段:
1. 还没有让 `RpgWorldPreviewCompiler` 输出真正独立于 legacy profile 的 preview view model。
2. 还没有把 `RpgWorldPreviewCompiler` 的 preview 载体从当前 runtime-profile 兼容对象升级成真正独立的 preview view model。
3. `legacyResultProfile` 仍保留为兼容快照,结果页与自动保存链还没有完全脱离 legacy profile 富字段。
4. 还没有删除 `runtimeProfile.ts``runtimeProfileCompiler.ts` 这两个兼容 façade。
## 4. 对后续工作包的直接收益
1. 工作包 E 可以围绕 `RpgWorldPreviewCompiler` 继续补 result sync / snapshot builder 的 preview 接口。
2. 工作包 H 可以基于 `RpgCreationPreviewEnvelope` 继续细化正式 preview contract 和 contract tests。
3. Phase 3 把结果页切到服务端 preview 时,已经有稳定的后端编译入口和目录化 normalize/build 模块,不需要再回头拆 `runtimeProfile.ts` 大文件。

View File

@@ -0,0 +1,137 @@
# 创作链路重构工作包 H 落地记录
更新时间:`2026-04-21`
## 1. 本次目标
本次落实 `CREATION_FLOW_CHAIN_REFACTOR_EXECUTION_PLAN_2026-04-21.md` 中的 **工作包 H共享契约与测试基建**,约束如下:
1. 把 RPG 创作域共享契约从“类型别名骨架”推进到“真实定义 + 兼容出口”。
2. 补齐可复用的 fixture避免前后端测试继续各自复制一套假数据。
3. 补齐 unit / contract / integration / regression 最小闭环,不越界重构 UI、路由和仓储主逻辑。
## 2. 本次已落地内容
## 2.1 共享契约已完成物理拆分与兼容收口
本轮已把以下文件从工作包 A 的骨架态推进为真实定义:
1. `packages/shared/src/contracts/rpgAgentAnchors.ts`
2. `packages/shared/src/contracts/rpgAgentDraft.ts`
3. `packages/shared/src/contracts/rpgAgentActions.ts`
4. `packages/shared/src/contracts/rpgAgentSession.ts`
5. `packages/shared/src/contracts/rpgCreationPreview.ts`
6. `packages/shared/src/contracts/rpgCreationWorkSummary.ts`
本轮收口重点:
1. `rpgAgent*``rpgCreation*` 文件不再只是从旧 `customWorldAgent.ts` 做类型别名转发,而是承载真实契约定义。
2. `rpgAgentSession.ts` 已显式加入 `supportedActions?``resultPreview?` 可选字段,为工作包 E/G 后续正式接入 registry 与服务端 preview compiler 预留稳定契约入口。
3. `rpgCreationPreview.ts` 已补 `source / generatedAt / qualityFindings / blockers`,把“预览载体”和“预览来源/质量门槛”拆开。
4. `rpgCreationWorkSummary.ts` 已收口 works 列表稳定字段,明确 `canResume / canEnterWorld` 的读模型语义。
## 2.2 旧 `customWorld*` 契约已补齐兼容分文件
本轮没有直接删除旧入口,而是把旧命名收口成“聚合出口 + 分文件兼容层”:
1. 当前旧 `customWorldAgent.ts` 不再承载主定义,而是统一聚合:
- `customWorldAgentAnchors.ts`
- `customWorldAgentDraft.ts`
- `customWorldAgentActions.ts`
- `customWorldAgentSession.ts`
- `customWorldResultPreview.ts`
- `customWorldWorkSummary.ts`
2. 现有前后端直接导入 `customWorldAgent.ts` 的代码不需要在本轮一起大改,避免把工作包 H 扩成全仓导入迁移。
3. 后续工作包可以逐步把新代码改到 `rpgAgent* / rpgCreation*` 路径;如果暂时仍需旧命名,也可以先切到更细的兼容分文件,而不是继续依赖单一大聚合文件。
## 2.3 已补共享 fixture总线样本开始统一
本轮新增:
1. `packages/shared/src/contracts/rpgCreationFixtures.ts`
当前已提供并复用的样本包括:
1. 八锚点 fixture
2. foundation draft fixture
3. session snapshot fixture
4. preview envelope fixture
5. published profile fixture
6. library entry fixture
7. works response fixture
这些样本的作用是:
1. 前端 contract test、后端 integration test、后续 preview/compiler 回归可以共用同一批样本。
2. 避免继续在各测试文件里手写不一致的 session/profile/works 假数据。
3. 把工作包 H 文档中要求的“最小 eight-anchor / preview / published profile / works 样本”先落成统一入口。
## 2.4 已补 unit / contract / integration / regression 最小闭环
本轮新增测试:
1. `packages/shared/src/contracts/rpgContracts.test.ts`
2. `server-node/src/services/customWorldWorkSummaryService.integration.test.ts`
3. `server-node/src/services/RpgWorldPreviewCompiler.fixture.test.ts`
4. `server-node/src/services/RpgWorldWorkSummaryAssembler.fixture.test.ts`
5. `server-node/src/services/customWorldAgentActionRegistry.test.ts`
6. `server-node/src/services/customWorldAgentResultSyncService.test.ts`
同时补充:
1. `vitest.config.ts` 已把 `packages/shared/src/**/*.test.ts` 纳入前端 Vitest 测试入口。
2. shared contract test 当前覆盖:
- session fixture、preview fixture、published profile fixture、works/library fixture 对齐关系
- `supportedActions` 能力矩阵样本
- 旧命名兼容分文件的类型消费
- 角色动作资产、分幕背景、works 门槛字段不会在 fixture 演进时悄悄回退
3. server unit / regression test 当前覆盖:
- preview compiler 可以直接消费 shared fixture
- works assembler 输出与 shared works fixture 保持一致
- 角色主图、动作集、分幕背景资产字段在 normalize / assemble 后仍能保留
- action registry 的 capability enable/disable 与 payload validate/normalize
- result sync service 只回写摘要与匹配资产,不让 runtime-only 结构反向污染 foundation draft
4. server integration test 当前验证共享 fixture 可以被 `customWorldWorkSummaryService` 正常消费,并输出和共享 works 响应样本一致的草稿/发布条目。
## 2.5 根导出已补齐
本轮已把:
1. `packages/shared/src/contracts/rpgCreationFixtures.ts`
2. `packages/shared/src/contracts/customWorldAgent.ts`
接入:
1. `packages/shared/src/index.ts`
这样后续前端和后端若要消费共享 fixture 或新契约,不需要再回退到旧单文件入口。
## 3. 本次验证结果
已完成以下定向验证:
1. `npm run test -- packages/shared/src/contracts/rpgContracts.test.ts`
2. `node --test --test-concurrency=1 --import tsx src/services/customWorldAgentActionRegistry.test.ts src/services/customWorldAgentResultSyncService.test.ts src/services/customWorldWorkSummaryService.integration.test.ts src/services/RpgWorldPreviewCompiler.fixture.test.ts src/services/RpgWorldWorkSummaryAssembler.fixture.test.ts`
3. `npm run check:encoding`
验证重点:
1. shared 契约样本可直接通过 Vitest 执行。
2. preview compiler、works assembler、works service 三层都可以直接消费 shared fixture不需要额外复制一套测试数据。
3. 中文文档与代码文件经过编码检查,没有把文本写坏。
## 4. 本次刻意未做的事
以下内容明确留给后续工作包或下一轮继续推进:
1. 还没有把仓库里所有 `customWorldAgent.ts` 旧导入物理迁成 `rpgAgent* / rpgCreation*` 新导入。
2. 还没有让后端 session snapshot 真正填充 `supportedActions`
3. 还没有让服务端 preview compiler 真正把 `resultPreview` 写入主链 snapshot。
4. 没有改 UI、路由、数据库仓储或 orchestrator 主逻辑,严格控制在 shared contracts 与测试基建写入边界内。
## 5. 对后续工作包的直接收益
1. 工作包 E 可以直接复用 `supportedActions` 契约入口,把 action registry 的真实能力矩阵接进 session snapshot。
2. 工作包 G 可以直接复用 `resultPreview``RpgCreationPreviewEnvelope`,继续把服务端 preview compiler 接回主链。
3. 后续前后端测试都可以从 shared fixture 取样本,不需要继续维护多套彼此漂移的 session/profile 假数据。
4. 旧命名导入可以先切到兼容分文件,再逐步替换到 `rpg*` 新契约,迁移路径更平滑。

View File

@@ -139,6 +139,25 @@
4. `questDirector` / `runtimeItemAiDirector` 收缩为前端 SDK。
5. runtime story contract 开始补“随请求提交快照上下文”的后端承接能力,并把 NPC 待接委托 replace / abandon 接到后端。
### 5.1 已完成
1. 鉴权 access/refresh session 已全部转为后端 Cookie 会话。
2. `refreshSessionCookie` 已修复双 `Set-Cookie` 覆盖问题,登录/刷新/微信回调不再丢失 access cookie。
3. 浏览历史已收敛为后端唯一真相,前端不再维护正式本地 browse history 链。
4. runtime story 已支持随请求提交 snapshot由后端内部解释与持久化。
5. NPC 待接委托 `replace / abandon / accept` 已以后端 runtime action 为准。
6. custom world profile 浏览器正式入口已改走后端 route。
7. `questDirector` / `runtimeItemAiDirector` 已收缩为前端 SDK不再承担正式浏览器编排。
8. NPC 招募正式结算已迁到后端:
- 前端只负责招募对白展示与 release 目标选择
- 后端负责 `npcStates / companions / roster / currentEncounter / storyHistory` 正式结算
- 满员换队招募已由后端承接
### 5.2 剩余未完成
1. `src/services/ai.ts` 仍保留 legacy fallback / test 能力,尚未彻底压缩出正式浏览器主链。
2. 仍需继续审视是否存在其他 NPC / 运行时分支,把正式状态裁决留在前端。
---
## 6. 验收标准

View File

@@ -16,7 +16,26 @@
- [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_FLOW_CHAIN_REFACTOR_WORK_PACKAGE_A_PROGRESS_2026-04-21.md](./CREATION_FLOW_CHAIN_REFACTOR_WORK_PACKAGE_A_PROGRESS_2026-04-21.md):记录创作链路重构工作包 A 已落地的 RPG 创作域目录骨架、兼容 façade 和共享契约入口
- [RPG_ENTRY_RUNTIME_CHAIN_REFACTOR_EXECUTION_PLAN_2026-04-21.md](./RPG_ENTRY_RUNTIME_CHAIN_REFACTOR_EXECUTION_PLAN_2026-04-21.md):梳理当前 RPG 从平台入口、继续游戏、角色选择到营地开场、冒险运行态与 runtime story 后端结算的全链脚本地图,并给出 RPG 专属命名规范、目标分层和可并行执行的工作包
- [RPG_ENTRY_RUNTIME_CHAIN_REFACTOR_WORK_PACKAGE_A_PROGRESS_2026-04-21.md](./RPG_ENTRY_RUNTIME_CHAIN_REFACTOR_WORK_PACKAGE_A_PROGRESS_2026-04-21.md):记录 RPG 进入游戏与运行时链路工作包 A 已完成的新目录骨架、前后端 façade、按域路由 path 常量与兼容仓储入口。
- [RPG_ENTRY_RUNTIME_CHAIN_REFACTOR_WORK_PACKAGE_B_PROGRESS_2026-04-21.md](./RPG_ENTRY_RUNTIME_CHAIN_REFACTOR_WORK_PACKAGE_B_PROGRESS_2026-04-21.md):记录工作包 B 已完成的前端 RPG 入口壳层真实迁移、`rpg-entry` 新入口 hooks 收口,以及旧 `game-shell` / `rpg-creation-flow` 路径降级为兼容层的状态。
- [RPG_ENTRY_RUNTIME_CHAIN_REFACTOR_WORK_PACKAGE_C_PROGRESS_2026-04-21.md](./RPG_ENTRY_RUNTIME_CHAIN_REFACTOR_WORK_PACKAGE_C_PROGRESS_2026-04-21.md):记录工作包 C 已完成的 `rpg-session` 主链迁移、snapshot / save archive client 收口、旧 `useGame*` 降级为兼容 façade以及定向回归结果。
- [RPG_ENTRY_RUNTIME_CHAIN_REFACTOR_WORK_PACKAGE_D_PROGRESS_2026-04-21.md](./RPG_ENTRY_RUNTIME_CHAIN_REFACTOR_WORK_PACKAGE_D_PROGRESS_2026-04-21.md):记录工作包 D 已完成的前端运行态 shell / stage router / panel router 真实迁移、AdventurePanel section 拆分,以及旧 `GameShell*` 热点降级为兼容桥的现状。
- [RPG_ENTRY_RUNTIME_CHAIN_REFACTOR_WORK_PACKAGE_F_PROGRESS_2026-04-21.md](./RPG_ENTRY_RUNTIME_CHAIN_REFACTOR_WORK_PACKAGE_F_PROGRESS_2026-04-21.md):记录 RPG 进入游戏与运行时链路工作包 F 已完成的后端 route 真正拆边界、`app.ts` 新域挂载、旧 `runtimeRoutes` / `storyActionRoutes` 兼容降级,以及定向路由回归验证结果。
- [RPG_ENTRY_RUNTIME_CHAIN_REFACTOR_WORK_PACKAGE_E_PROGRESS_2026-04-21.md](./RPG_ENTRY_RUNTIME_CHAIN_REFACTOR_WORK_PACKAGE_E_PROGRESS_2026-04-21.md):记录工作包 E 已完成的前端 runtime story 主链真实迁移、NPC 交互与 gateway/client 收口、旧入口兼容降级,以及定向回归验证结果。
- [RPG_ENTRY_RUNTIME_CHAIN_REFACTOR_WORK_PACKAGE_G_PROGRESS_2026-04-21.md](./RPG_ENTRY_RUNTIME_CHAIN_REFACTOR_WORK_PACKAGE_G_PROGRESS_2026-04-21.md):记录工作包 G 已完成的后端 runtime session / action service 物理迁移、新域原语导出、旧热点兼容降级,以及定向 runtime story 回归验证结果。
- [RPG_ENTRY_RUNTIME_CHAIN_REFACTOR_WORK_PACKAGE_H_PROGRESS_2026-04-21.md](./RPG_ENTRY_RUNTIME_CHAIN_REFACTOR_WORK_PACKAGE_H_PROGRESS_2026-04-21.md):记录工作包 H 已完成的 RPG 运行时仓储拆分、shared runtime contract 分文件、旧 `story.ts` façade 兼容与定向回归结果。
- [RPG_ENTRY_RUNTIME_CHAIN_REFACTOR_PARALLEL_BATCH_AUDIT_2026-04-21.md](./RPG_ENTRY_RUNTIME_CHAIN_REFACTOR_PARALLEL_BATCH_AUDIT_2026-04-21.md):对照执行计划逐项复核第一批与第二批并行工作的真实落地状态,记录本轮确认到的测试合流收口遗漏与文档索引补齐结果。
- [RPG_ENTRY_RUNTIME_CHAIN_REFACTOR_PHASE3_CLOSURE_2026-04-21.md](./RPG_ENTRY_RUNTIME_CHAIN_REFACTOR_PHASE3_CLOSURE_2026-04-21.md):记录 RPG 执行计划第三批收口已完成的前端新域主链接回、后端新仓储接线、shared contract 直连收紧、旧兼容脚本物理删除,以及明确未扩到 UI 和无关历史文档的边界。
- [RPG_ENTRY_RUNTIME_CHAIN_REFACTOR_OLD_SCRIPT_REMOVAL_2026-04-21.md](./RPG_ENTRY_RUNTIME_CHAIN_REFACTOR_OLD_SCRIPT_REMOVAL_2026-04-21.md):记录 RPG 主链旧 `GameShell``useGame*``hooks/story``runtimeRoutes``modules/story/*``contracts/story.ts` 脚本的物理删除范围、残留依赖扫描和定向验证结果。
- [CREATION_FLOW_CHAIN_REFACTOR_WORK_PACKAGE_A_PROGRESS_2026-04-21.md](./CREATION_FLOW_CHAIN_REFACTOR_WORK_PACKAGE_A_PROGRESS_2026-04-21.md):记录创作链路重构工作包 A 已落地的 RPG 创作域目录骨架、兼容 façade以及补齐后的共享契约骨架入口。
- [CREATION_FLOW_CHAIN_REFACTOR_WORK_PACKAGE_E_PROGRESS_2026-04-21.md](./CREATION_FLOW_CHAIN_REFACTOR_WORK_PACKAGE_E_PROGRESS_2026-04-21.md):记录工作包 E 已完成的后端 Agent 编排拆分、executor 物理迁移、发布链切到 `CustomWorldAgentPublishingService`、checkpoint 真快照、场景资产 coverage 收口,以及 Phase3/Phase5 定向回归结果。
- [CREATION_FLOW_CHAIN_REFACTOR_WORK_PACKAGE_H_PROGRESS_2026-04-21.md](./CREATION_FLOW_CHAIN_REFACTOR_WORK_PACKAGE_H_PROGRESS_2026-04-21.md):记录工作包 H 已完成的共享契约物理拆分、旧命名兼容分文件、统一 fixture以及 shared contract test / preview compiler / works assembler / works service 回归基建。
- [CREATION_FLOW_CHAIN_REFACTOR_WORK_PACKAGE_B_PROGRESS_2026-04-21.md](./CREATION_FLOW_CHAIN_REFACTOR_WORK_PACKAGE_B_PROGRESS_2026-04-21.md):记录工作包 B 已完成的前端平台壳层编排拆分、平台 hooks / coordinator 接入、旧入口兼容保留,以及交互回归验证结果。
- [CREATION_FLOW_CHAIN_REFACTOR_WORK_PACKAGE_F_PROGRESS_2026-04-21.md](./CREATION_FLOW_CHAIN_REFACTOR_WORK_PACKAGE_F_PROGRESS_2026-04-21.md):记录工作包 F 已完成的后端 session/store/repository 拆分、works 读模型 service 收口、route/context 直接注入新仓储,以及定向 custom world 回归验证结果。
- [CREATION_FLOW_CHAIN_REFACTOR_WORK_PACKAGE_D_PROGRESS_2026-04-21.md](./CREATION_FLOW_CHAIN_REFACTOR_WORK_PACKAGE_D_PROGRESS_2026-04-21.md):记录工作包 D 已完成的前端 custom world client 真正迁出、旧 service 兼容降级,以及平台壳层/结果页测试切换到 `rpgCreation` 域入口的现状。
- [CREATION_FLOW_CHAIN_REFACTOR_WORK_PACKAGE_G_PROGRESS_2026-04-21.md](./CREATION_FLOW_CHAIN_REFACTOR_WORK_PACKAGE_G_PROGRESS_2026-04-21.md):记录工作包 G 已完成的 runtime profile 目录化、服务端 preview compiler 收口,以及 foundation draft 主生成链与 preview 编译边界的直接拆开。
- [CREATION_FLOW_CHAIN_REFACTOR_EXECUTION_PLAN_2026-04-21.md#93-工作包-c前端结果页与编辑器拆分](./CREATION_FLOW_CHAIN_REFACTOR_EXECUTION_PLAN_2026-04-21.md#93-%E5%B7%A5%E4%BD%9C%E5%8C%85-c%E5%89%8D%E7%AB%AF%E7%BB%93%E6%9E%9C%E9%A1%B5%E4%B8%8E%E7%BC%96%E8%BE%91%E5%99%A8%E6%8B%86%E5%88%86):记录工作包 C 已完成的结果页壳层拆分、编辑器目标分发与 mapper 收口、角色资产工坊 section/workflow 拆分,以及仍保留的阶段性 shared 实现边界。
- [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

@@ -0,0 +1,999 @@
# RPG 进入游戏与运行时链路重构执行方案
更新时间:`2026-04-21`
## 0. 文档目标
本文只处理一件事:
**把当前 RPG 玩法从“平台入口/继续游戏/世界详情开始游戏”到“角色选择/营地开场/冒险运行态/runtime story 后端动作结算”的整条前后端脚本链路,整理成一份可以直接指导后续并行重构的执行方案。**
本轮不直接修改业务玩法,不新增需求,只明确:
1. 当前链路上的真实脚本地图
2. 当前命名、目录、边界和可读性问题
3. 面向 RPG 类型游戏的专属命名规范
4. 目标分层与文件级拆分建议
5. 可同时并行推进的工作包与阶段验收标准
同时补充一条必须冻结的执行约束:
**本次以及后续按本文推进的 RPG 链路重构,只允许调整脚本结构、命名、职责边界、数据流和兼容 façade不允许修改任何前端交互界面设计。**
---
## 1. 范围与依据
### 1.1 本文覆盖的 RPG 进入游戏链路
```text
平台首页 / 作品详情 / 继续游戏
-> 选择世界或恢复存档
-> 角色选择
-> 初始化 GameState / Snapshot / Session
-> 营地开场
-> 冒险运行态 shell
-> 冒险面板 / 角色面板 / 背包面板
-> runtime story 选项解析与动作结算
-> 服务端快照持久化 / 状态回写 / 继续游戏恢复
```
### 1.2 本文主要依据
1. `docs/prd/AI_NATIVE_SCENE_CHAPTER_GAMEPLAY_PRD_AND_EXECUTION_PLAN_2026-04-08.md`
2. `docs/design/SCENE_CHAPTER_LOOP_AND_FIRST_ENTRY_CHAPTER_QUEST_DESIGN_2026-04-08.md`
3. `docs/experience/CURRENT_GAME_FULL_FLOW_PLAYTEST_REPORT_2026-04-07.md`
4. `docs/technical/RUNTIME_STORY_BACKEND_BOUNDARY_MIGRATION_2026-04-19.md`
5. `docs/technical/FRONTEND_TO_BACKEND_MIGRATION_EXECUTION_PLAN_2026-04-21.md`
6. `docs/technical/NODE_SERVER_KNOWLEDGE_GRAPH_2026-04-08.md`
### 1.3 本文刻意不覆盖的链路
本文不处理以下内容:
1. RPG 创作流程链
2. Agent 八锚点共创流程
3. 自定义世界结果页编辑器内部资产工坊链
4. 非 RPG 平台公共功能的全面改造
这些内容已有独立文档,本文只关注**进入 RPG 运行态之后的主玩法链**。
### 1.4 前端界面冻结约束
本次重构对前端界面的约束必须写死:
1. 不修改任何前端交互界面设计。
2. 不修改现有页面的视觉层级、主布局结构、按钮位置、tab 组织、弹窗/独立面板的出现方式。
3. 不以“顺手优化体验”为理由调整入口页、选角页、冒险页、角色页、背包页的交互路径。
4. 重构允许做的事情只包括脚本重命名、目录迁移、hook/service 拆分、view model 收口、后端路由与服务拆分、兼容 façade 搭建。
5. 如果个别脚本拆分必须调整 props 传递或组件装配方式,最终渲染结果与交互结果必须和当前版本保持一致。
6. 任何会影响 UI 结构、交互节奏、面板开合形式的改动,都不属于本文工作范围,必须另开设计文档与实现任务。
---
## 2. 当前链路真实脚本地图
## 2.1 前端入口与进入世界链
| 文件 | 当前职责 | 当前问题 |
| --- | --- | --- |
| `src/App.tsx` | 应用入口,直接挂载 `useGameShellRuntime()``GameShellRuntime` | 入口极薄,但把“平台入口 + RPG 运行态”全部抽象成 `GameShell`,命名过泛 |
| `src/hooks/useGameShellRuntime.ts` | 串起 `useGameFlow``useGamePersistence``useStoryGeneration`、同伴与音乐逻辑,组装整套运行时 props | 已经是事实上的 RPG 主流程装配器,但命名仍像通用 shell |
| `src/hooks/useGameFlow.ts` | 世界选择、角色确认、`GameState` 初始化、营地遭遇创建 | 负责“进入游戏”的核心初始化,但文件名过泛,且把世界选择、选角、开局初始化混在一起 |
| `src/hooks/useGamePersistence.ts` | 远端快照加载、自动存档、保存退出、继续游戏恢复 | 快照加载、存档写入、恢复后 runtime story 刷新耦合在同一 hook 中 |
| `src/components/game-shell/PreGameSelectionFlow.tsx` | 平台首页、详情页、存档继续、创作入口、进入世界前流程壳层 | 仍承载过多平台级编排,不只负责 RPG 进入游戏链 |
| `src/components/game-shell/PlatformHomeView.tsx` | 平台首页、继续游戏、公开广场、存档/个人 tab 表现层 | 视觉层文件过大且“平台首页”与“RPG 进入游戏入口”没有显式命名边界 |
| `src/components/game-shell/PlatformWorldDetailView.tsx` | 世界详情与“开始游戏/继续创作/发布”等操作 | `开始游戏` 是 RPG 入口动作,但当前文件名和职责仍偏平台通用详情页 |
| `src/components/game-shell/CharacterSelectionFlow.tsx` | 角色选择、角色自定义草稿、确认进入营地 | 已是纯 RPG 选角页面,但命名仍是泛化 `SelectionFlow` |
## 2.2 前端运行态壳层与面板链
| 文件 | 当前职责 | 当前问题 |
| --- | --- | --- |
| `src/components/game-shell/GameShellRuntime.tsx` | 运行态最外层 shell装配画布、主内容、各种 overlay | 同时承担平台主题外壳和 RPG 运行态外壳,命名与职责都偏泛 |
| `src/components/game-shell/GameShellMainContent.tsx` | 根据 `worldType / playerCharacter / selectionStage` 在平台、选角、冒险面板三种主阶段间切换 | 实际上是 RPG 主阶段路由器,但文件名没有表达“入口阶段切换” |
| `src/components/game-shell/GameShellStoryPanels.tsx` | 冒险/角色/背包三个主标签切换,挂载 `AdventurePanel``CharacterPanel``InventoryPanel` | 运行态主面板路由器与 tab 容器混在一起 |
| `src/components/AdventurePanel.tsx` | 冒险主面板、对话流、选项区、任务/设置/统计 overlay、NPC 聊天输入、奖励面板 | 单文件过大,是当前前端 RPG 运行时最大热点之一 |
| `src/components/game-shell/useGameShellRuntimeViewModel.ts` | 运行态视图模型、可见状态、过场、统计、对话指示器 | 负责运行态展示编排,但仍以 `GameShell` 泛名承载 |
| `src/components/game-shell/useGameShellViewModel.ts` | overlay/modal/选中实体/selectionStage 的壳层状态 | 进入游戏前与进入游戏后 UI 状态混在一起,命名不够领域化 |
## 2.3 前端剧情运行时协调链
| 文件 | 当前职责 | 当前问题 |
| --- | --- | --- |
| `src/hooks/useStoryGeneration.ts` | 运行态故事主入口,拼装 runtime controller、goal session、interaction coordinator | 事实上的 RPG 叙事运行时入口,但命名过于抽象 |
| `src/hooks/story/useStoryRuntimeController.ts` | 当前故事、AI 错误、故事请求入口、fallback story 和 commit 动作 | 浏览器 AI 请求与服务端 runtime story 共同挂在同一 controller 上 |
| `src/hooks/story/useStoryFlowCoordinator.ts` | 汇总 goal option、interaction、session 行为,输出完整故事流程能力 | 多层 coordinator 套娃,可读性差 |
| `src/hooks/story/useStoryGoalSessionCoordinator.ts` | 任务领奖、重置 story、恢复 story、地图移动 | 任务会话动作与 story 生命周期控制混在一起 |
| `src/hooks/story/useStoryInteractionCoordinator.ts` | 选择分发、NPC 交互、宝藏交互、背包动作、战斗奖励、聊天输入 | 交互中心职责太重,是第二个热点文件 |
| `src/hooks/story/npcEncounterActions.ts` | NPC 聊天、切磋、委托接受/替换/放弃、服务端 runtime action、聊天 UI 细节 | 依然是巨型多职责文件,且混合 UI 组装、状态更新、服务端请求 |
| `src/hooks/story/runtimeStoryCoordinator.ts` | 继续游戏后恢复 runtime story、状态解析、server action 调用 | 是前端对接服务端 runtime story 的真正 gateway但命名像临时协调器 |
| `src/hooks/story/storyRequestCoordinator.ts` | AI 故事请求参数编排、server option catalog 决策 | 与 runtime story 主链存在交叉语义,边界不直观 |
| `src/hooks/story/sessionActions.ts` | 地图移动、任务奖励、story reset/hydrate | “session action” 命名过泛,而且同时处理 quest/chapter/story 三类状态 |
## 2.4 前端 service / client 链
| 文件 | 当前职责 | 当前问题 |
| --- | --- | --- |
| `src/services/storageService.ts` | 存档、设置、个人看板、浏览历史、作品库、继续游戏、世界详情 | RPG 快照/存档接口与平台资料/作品库接口混在一个通用 client 中 |
| `src/services/runtimeStoryService.ts` | `/api/runtime/story` 的状态读取、动作提交、story moment 转换 | 已接近 RPG 专属 client但文件名仍偏通用 |
| `src/services/aiService.ts` | 初始剧情/续写、角色聊天、NPC 聊天、runtime item、quest、custom world 共创接口 | RPG 运行时 AI、角色聊天、创作 Agent 接口混在一起,领域过宽 |
## 2.5 后端路由与运行时主链
| 文件 | 当前职责 | 当前问题 |
| --- | --- | --- |
| `server-node/src/server.ts` | 组装 `AppContext`,注入 runtimeRepository、customWorldAgentOrchestrator 等依赖 | 依赖对象过于集中RPG 运行态缺少显式模块边界 |
| `server-node/src/app.ts` | 注册 `/api/auth``/api/runtime/story``/api` 等总路由 | RPG 进入世界链、平台路由、编辑器路由全部在总 app 中汇合,语义不够清晰 |
| `server-node/src/routes/runtimeRoutes.ts` | 资料、存档、浏览历史、作品库、runtime AI、世界生成等大杂糅路由 | 当前后端最大热点之一,平台资料与 RPG runtime 接口强耦合 |
| `server-node/src/modules/story/storyActionRoutes.ts` | runtime story 状态读取、动作结算 | 路由层本身还算薄,但命名仍然过于通用 |
| `server-node/src/modules/story/storyActionService.ts` | runtime story 状态/动作主应用服务,拼接 combat、npc、quest、treasure、LLM story | 是当前后端 RPG 运行时主热点,承担过多动作路由和 story 组装细节 |
| `server-node/src/modules/story/runtimeSession.ts` | runtime snapshot 归一化、option 构建、viewModel 编译、legacy currentStory 构建、rawGameState 同步 | 运行态编译中心过重,加载器、编译器、同步器、兼容层全部混在一起 |
| `server-node/src/modules/npc/npcInteractionService.ts` | NPC help/chat/fight/spar/recruit 等动作结算 | 与 runtime story 仍存在大量双向耦合 |
| `server-node/src/modules/quest/questStoryActionService.ts` | 委托接受、交付、待接委托读取与结算 | 已承担正式 quest 语义,但入口仍埋在 storyActionService 下游 |
| `server-node/src/repositories/runtimeRepository.ts` | 快照、存档列表、看板、浏览历史、作品库、会话等持久化读写 | 仓储过大,按技术分组而不是按 RPG 领域分组 |
| `server-node/src/modules/runtime/runtimeSnapshotHydration.ts` | 存档/gameState/currentStory 归一化、迁移补丁、默认值填充 | 既承担快照迁移,又承担业务字段补齐,是基础设施与领域逻辑混合点 |
| `packages/shared/src/contracts/story.ts` | 前后端 runtime story / npc chat / quest / runtime item 等共享契约 | RPG 运行时契约体量过大story/action/state/chat 混放,难以独立演进 |
---
## 3. 当前结构性问题
## 3.1 命名没有体现“这是 RPG 进入游戏主链”
当前主链上充满以下泛化命名:
1. `GameShell`
2. `MainContent`
3. `SelectionFlow`
4. `runtimeRoutes`
5. `storyActionService`
6. `sessionActions`
这些命名的问题不是“不好看”,而是:
1. 无法一眼区分平台入口、RPG 进入游戏、RPG 运行态
2. 无法一眼看出文件属于前端壳层、状态协调器、还是后端应用服务
3. 后续非 RPG 流程接入时,很容易继续误复用这些泛化热点文件
## 3.2 平台入口与 RPG 进入游戏链混在一起
`PreGameSelectionFlow.tsx` 当前同时承担:
1. 平台首页
2. 详情页
3. 存档恢复
4. 创作入口
5. 进入世界
这会导致:
1. 任何平台改动都可能碰到 RPG 进入游戏主链
2. 任何 RPG 进入游戏改动都要穿过平台杂项状态
3. 文件变成事实上的超大编排中心
## 3.3 `GameState` 初始化、快照恢复、运行态切入没有明确分层
`useGameFlow.ts``useGamePersistence.ts``useGameShellRuntime.ts` 当前共同承担:
1. 新开局初始化
2. 世界选择
3. 角色选择
4. 自动存档
5. 继续游戏恢复
6. runtime story 恢复刷新
结果是:
1. “进入游戏前的 session bootstrap”与“进入游戏后的自动持久化”没有明确边界
2. 继续游戏逻辑很难单独替换或扩展
3. 任何存档策略变更都容易影响开局链
## 3.4 前端剧情运行时协调层过多,职责分散却仍然耦合
当前前端 story 主链至少经过:
1. `useStoryGeneration`
2. `useStoryRuntimeController`
3. `useStoryFlowCoordinator`
4. `useStoryGoalSessionCoordinator`
5. `useStoryInteractionCoordinator`
6. `npcEncounterActions`
7. `runtimeStoryCoordinator`
问题在于:
1. 层数多,但不是稳定分层,而是热点文件之间互相穿透
2. 有些层是 view model有些层是 action dispatcher有些层是 server gateway命名看不出来
3. 浏览器 AI 续写链与服务端 runtime story 链还没有完全收口为两个明确通道
## 3.5 后端路由层过于“大 runtime 大入口”
`runtimeRoutes.ts` 当前同时覆盖:
1. profile dashboard
2. browse history
3. save archives
4. custom world library/gallery
5. custom world profile generation
6. runtime story 外围 AI 接口
7. runtime item / quest 生成接口
这会导致:
1. RPG 进入游戏链难以抽出独立模块
2. 平台资料接口和 RPG runtime 接口一起变更时风险高
3. route 文件越来越像“后端总控清单”
## 3.6 `runtimeSession.ts` 是当前后端最大可读性瓶颈之一
这个文件当前同时做了:
1. snapshot 载入
2. rawGameState 归一化
3. option interaction 构建
4. battle option 编译
5. NPC option 编译
6. viewModel 编译
7. legacy currentStory 兼容输出
8. rawGameState 回写同步
这会直接造成:
1. 新增动作时很难判断应该改哪里
2. 任何小调整都容易触碰多个职责
3. 单测难以按职责拆开
## 3.7 持久化仓储按技术堆叠,没有按 RPG 域拆开
`runtimeRepository.ts` 把:
1. snapshot
2. save archives
3. settings
4. dashboard
5. browse history
6. custom world library
7. custom world sessions
全部堆在一起。
对 RPG 进入游戏链来说,至少应该显式分开:
1. 运行时快照
2. 存档归档
3. 平台资料
4. 世界库/详情
否则“继续游戏链”与“平台资料链”永远无法清晰拆边界。
---
## 4. 目标分层架构
## 4.1 目标原则
后续重构必须统一遵守 7 条原则:
1. **平台入口只负责进入 RPG会话真相不留在页面壳层。**
2. **世界选择、角色选择、新开局、继续游戏恢复属于 RPG session 入口域。**
3. **进入世界后的运行态壳层、冒险面板、story runtime 网关必须显式分层。**
4. **前端只保留展示状态、输入状态、UI 过场状态;正式快照、正式动作、正式 story option 以后端为准。**
5. **后端 route / application service / compiler / repository 必须按 RPG 域拆开,不再扩大“大 runtime 单文件”。**
6. **所有新命名都要显式表达“这是 RPG 类型游戏专属流程”,不能继续依赖 `GameShell / runtime / flow` 这类泛化词。**
7. **重构期间严格冻结前端交互界面设计,脚本重组不能改变任何页面结构与交互表现。**
## 4.2 目标链路
```text
RPG 平台入口壳层
-> RPG session bootstrap
-> RPG 角色选择
-> RPG 运行态 shell
-> RPG 运行态面板路由
-> RPG runtime story gateway
-> RPG runtime story routes
-> RPG runtime action/state services
-> RPG runtime session loader/compiler
-> RPG snapshot repository / save archive repository
```
## 4.3 推荐目录骨架
### 前端
```text
src/
├─ components/
│ ├─ rpg-entry/
│ ├─ rpg-runtime-shell/
│ ├─ rpg-runtime-panels/
│ └─ rpg-runtime-overlays/
├─ hooks/
│ ├─ rpg-session/
│ └─ rpg-runtime-story/
└─ services/
├─ rpg-entry/
└─ rpg-runtime/
```
### 后端
```text
server-node/src/
├─ routes/
│ ├─ rpg-entry/
│ ├─ rpg-profile/
│ └─ rpg-runtime/
├─ modules/
│ └─ rpg-runtime-story/
├─ services/
│ ├─ rpg-entry/
│ └─ rpg-runtime/
└─ repositories/
├─ rpg-entry/
└─ rpg-runtime/
```
---
## 5. RPG 专属命名规范
## 5.1 命名根
后续进入游戏与运行态链统一使用以下命名根:
1. `rpgEntry`
- 平台首页、详情页、世界进入、角色选择、继续游戏入口
2. `rpgSession`
- 新开局、继续游戏恢复、快照 persistence、开局 bootstrap
3. `rpgRuntime`
- 游戏内 shell、tab、面板、overlay、view model
4. `rpgRuntimeStory`
- story state/action client、gateway、route、service、compiler
5. `rpgProfile`
- dashboard、browse history、save archive 等玩家资料域
## 5.2 命名规则
1. React 组件文件统一使用 `Rpg...` 前缀。
2. hooks 统一使用 `useRpg...` 前缀。
3. 前端 client/gateway/adapter 统一使用 `rpg...` 小驼峰前缀。
4. 后端 route 使用 `rpg...Routes.ts`
5. 后端应用服务使用 `Rpg...Service.ts`
6. 后端编译器/装配器使用 `Rpg...Compiler.ts` / `Rpg...Assembler.ts`
7. 后端仓储使用 `Rpg...Repository.ts`
8. 共享契约文件优先拆成 `rpgEntry...``rpgRuntimeStory...``rpgProfile...`
## 5.3 命名禁忌
后续重构中禁止继续新增以下主命名:
1. `GameShell*`
2. `PreGame*`
3. `SelectionFlow*`
4. `runtimeRoutes.ts` 这种单文件总入口命名
5. `storyActionService.ts` 这种过宽的单域名
6. `sessionActions.ts``flowCoordinator.ts``manager.ts``helper.ts` 作为主业务模块名
## 5.4 关键文件重命名建议
| 当前文件 | 目标命名 | 说明 |
| --- | --- | --- |
| `src/components/game-shell/PreGameSelectionFlow.tsx` | `src/components/rpg-entry/RpgEntryFlowShell.tsx` | 平台进入世界与选角前阶段壳层 |
| `src/components/game-shell/PlatformHomeView.tsx` | `src/components/rpg-entry/RpgEntryHomeView.tsx` | RPG 平台首页 |
| `src/components/game-shell/PlatformWorldDetailView.tsx` | `src/components/rpg-entry/RpgEntryWorldDetailView.tsx` | 世界详情与开始游戏入口 |
| `src/components/game-shell/CharacterSelectionFlow.tsx` | `src/components/rpg-entry/RpgEntryCharacterSelectView.tsx` | RPG 选角页 |
| `src/hooks/useGameFlow.ts` | `src/hooks/rpg-session/useRpgSessionBootstrap.ts` | 新开局/世界选择/角色确认 |
| `src/hooks/useGamePersistence.ts` | `src/hooks/rpg-session/useRpgSessionPersistence.ts` | 自动存档/继续游戏恢复 |
| `src/hooks/useGameShellRuntime.ts` | `src/hooks/rpg-session/useRpgRuntimeSession.ts` | RPG 主运行态装配器 |
| `src/components/game-shell/GameShellRuntime.tsx` | `src/components/rpg-runtime-shell/RpgRuntimeShell.tsx` | 运行态总外壳 |
| `src/components/game-shell/GameShellMainContent.tsx` | `src/components/rpg-runtime-shell/RpgRuntimeStageRouter.tsx` | 平台/选角/冒险阶段切换 |
| `src/components/game-shell/GameShellStoryPanels.tsx` | `src/components/rpg-runtime-panels/RpgRuntimePanelRouter.tsx` | 冒险/角色/背包主标签路由 |
| `src/components/AdventurePanel.tsx` | `src/components/rpg-runtime-panels/RpgAdventurePanel.tsx` | 冒险主面板 |
| `src/hooks/useStoryGeneration.ts` | `src/hooks/rpg-runtime-story/useRpgRuntimeStory.ts` | 前端 story 运行态主入口 |
| `src/hooks/story/useStoryRuntimeController.ts` | `src/hooks/rpg-runtime-story/useRpgRuntimeStoryController.ts` | 当前 story 与请求控制 |
| `src/hooks/story/useStoryFlowCoordinator.ts` | `src/hooks/rpg-runtime-story/useRpgRuntimeStoryFlow.ts` | story 主编排 |
| `src/hooks/story/useStoryInteractionCoordinator.ts` | `src/hooks/rpg-runtime-story/useRpgRuntimeInteractionFlow.ts` | runtime 交互分发 |
| `src/hooks/story/npcEncounterActions.ts` | `src/hooks/rpg-runtime-story/useRpgRuntimeNpcInteraction.ts` | NPC 交互与聊天动作 |
| `src/hooks/story/runtimeStoryCoordinator.ts` | `src/hooks/rpg-runtime-story/rpgRuntimeStoryGateway.ts` | 前端到后端 runtime story 网关 |
| `src/services/runtimeStoryService.ts` | `src/services/rpg-runtime/rpgRuntimeStoryClient.ts` | `/api/runtime/story` client |
| `src/services/storageService.ts` | 拆成 `rpgProfileClient.ts` / `rpgEntryLibraryClient.ts` / `rpgSnapshotClient.ts` | 按领域拆 client |
| `server-node/src/routes/runtimeRoutes.ts` | 拆成 `rpgProfileRoutes.ts` / `rpgEntryRoutes.ts` / `rpgRuntimeAiRoutes.ts` / `rpgWorldLibraryRoutes.ts` | 拒绝单文件总路由 |
| `server-node/src/modules/story/storyActionRoutes.ts` | `server-node/src/routes/rpg-runtime/rpgRuntimeStoryRoutes.ts` | runtime story 专属路由 |
| `server-node/src/modules/story/storyActionService.ts` | `RpgRuntimeStoryActionService.ts` + `RpgRuntimeStoryStateService.ts` | 动作结算与状态读取拆开 |
| `server-node/src/modules/story/runtimeSession.ts` | `RpgRuntimeSessionLoader.ts` + `RpgRuntimeOptionCompiler.ts` + `RpgRuntimeSnapshotSync.ts` | 按职责拆分 |
| `server-node/src/repositories/runtimeRepository.ts` | `RpgRuntimeSnapshotRepository.ts` 等多个仓储 | 按领域拆仓储 |
| `packages/shared/src/contracts/story.ts` | 拆成 `rpgRuntimeStory.ts` / `rpgRuntimeChat.ts` / `rpgRuntimeAction.ts` | 契约拆分 |
---
## 6. 前端重构拆分方案
## 6.1 RPG 入口壳层拆分
### 当前问题
`PreGameSelectionFlow.tsx` 当前同时承担平台首页、详情页、进入世界、存档恢复与部分创作入口逻辑。
### 目标拆分
保留一个极薄的 `RpgEntryFlowShell.tsx`,只负责:
1. 入口阶段切换
2. 装配子页面
3. loading / error 壳层
从当前文件拆出:
1. `useRpgEntryBootstrap.ts`
2. `useRpgEntryNavigation.ts`
3. `useRpgEntrySaveResume.ts`
4. `useRpgEntryLibraryDetail.ts`
5. `RpgEntryHomeView.tsx`
6. `RpgEntryWorldDetailView.tsx`
7. `RpgEntryCharacterSelectView.tsx`
### 关键要求
1. 入口壳层不再直接操作作品库、浏览历史、看板的多路加载细节。
2. 入口壳层不再直接处理“继续游戏后刷新 runtime story”的逻辑。
3. 创作入口与 RPG 进入世界入口要显式分段,不再共享大文件。
4. 页面视觉结构、按钮布局、tab 形式和独立面板交互方式保持不变。
## 6.2 RPG session bootstrap / persistence 拆分
### 当前问题
`useGameFlow.ts``useGamePersistence.ts` 共同承担了开局初始化、世界进入、存档自动保存和恢复。
### 目标拆分
建议拆出:
1. `useRpgSessionBootstrap.ts`
2. `useRpgCharacterEntry.ts`
3. `useRpgSnapshotPersistence.ts`
4. `useRpgContinueGame.ts`
5. `rpgSnapshotClient.ts`
### 关键要求
1. 新开局初始化与继续游戏恢复是两条显式流程。
2. `GameState` 初始化逻辑不再和自动存档逻辑放在同一 hook 里。
3. 存档自动保存不再直接夹带 UI 层状态决策。
4. 脚本拆分后不改变用户看到的进入游戏流程和交互顺序。
## 6.3 RPG 运行态 shell 与面板拆分
### 当前问题
`GameShellRuntime.tsx``GameShellMainContent.tsx``GameShellStoryPanels.tsx``AdventurePanel.tsx` 共同组成了一个过于耦合的壳层群。
### 目标拆分
建议形成:
1. `RpgRuntimeShell.tsx`
2. `RpgRuntimeStageRouter.tsx`
3. `RpgRuntimePanelRouter.tsx`
4. `RpgAdventurePanel.tsx`
5. `RpgRuntimeOverlayHost.tsx`
6. `useRpgRuntimeShellViewModel.ts`
### 关键要求
1. 运行态最外层壳层只管布局、背景、过场和 overlay host。
2. 主阶段路由器只管“平台/选角/冒险”的分流。
3. 面板路由器只管“冒险/角色/背包”的主 tab 分流。
4. `AdventurePanel` 内部继续按“story 区 / option 区 / overlay 区”拆 section。
5. 不重做任何面板样式、信息层次和交互布局,拆分只发生在脚本内部。
## 6.4 前端 runtime story 主链拆分
### 当前问题
前端 runtime story 主链层数太多,但边界并不稳定。
### 目标拆分
建议收成四层:
1. `useRpgRuntimeStory.ts`
- 作为唯一对上输出入口
2. `useRpgRuntimeStoryState.ts`
- 管当前 story、loading、error、hydration
3. `useRpgRuntimeInteractionFlow.ts`
- 分发 NPC / 战斗 / 宝藏 / 任务 / 地图动作
4. `rpgRuntimeStoryGateway.ts`
- 只负责和后端 runtime story client 交互
### 关键要求
1. 浏览器侧 AI 续写逻辑与 server runtime story 逻辑必须显式分离。
2. `npcEncounterActions.ts` 中的 UI 细节和正式动作结算必须拆开。
3. 任何 `resolveServerRuntimeChoice(...)` 都应只通过统一 gateway 入口调用。
4. 交互按钮、对话区、输入区和奖励展示的前端交互形式保持现状不变。
## 6.5 前端 service/client 收口方案
### 当前问题
`storageService.ts``aiService.ts` 的领域过宽。
### 目标拆分
建议新增:
1. `src/services/rpg-entry/rpgEntryLibraryClient.ts`
2. `src/services/rpg-entry/rpgProfileClient.ts`
3. `src/services/rpg-runtime/rpgSnapshotClient.ts`
4. `src/services/rpg-runtime/rpgRuntimeStoryClient.ts`
5. `src/services/rpg-runtime/rpgRuntimeChatClient.ts`
### 关键要求
1. `storageService.ts` 逐步降级为兼容 façade。
2. `aiService.ts` 逐步只保留通用 AI 与创作域 client不继续承接 RPG runtime story 主链。
3. 进入游戏链不能再依赖过宽的通用 client 文件。
---
## 7. 后端重构拆分方案
## 7.1 route 层拆分
### 当前问题
`runtimeRoutes.ts` 过重,平台资料、作品库和 RPG runtime 都在一起。
### 目标拆分
建议拆成:
1. `server-node/src/routes/rpg-profile/rpgProfileRoutes.ts`
2. `server-node/src/routes/rpg-entry/rpgEntrySaveRoutes.ts`
3. `server-node/src/routes/rpg-entry/rpgWorldLibraryRoutes.ts`
4. `server-node/src/routes/rpg-runtime/rpgRuntimeStoryRoutes.ts`
5. `server-node/src/routes/rpg-runtime/rpgRuntimeAiAssistRoutes.ts`
### 关键要求
1. RPG 进入游戏链相关接口必须从“大 runtime 总路由”中抽离。
2. `app.ts` 中要能一眼看出平台资料、世界库、runtime story 的路由边界。
3. route 层继续保持薄,不直接承载 story 业务决策。
## 7.2 runtime story service 拆分
### 当前问题
`storyActionService.ts` 当前同时承担状态读取、动作结算、LLM story 包装和 snapshot 持久化。
### 目标拆分
建议拆为:
1. `RpgRuntimeStoryStateService.ts`
2. `RpgRuntimeStoryActionService.ts`
3. `RpgRuntimeCombatActionService.ts`
4. `RpgRuntimeNpcActionService.ts`
5. `RpgRuntimeStoryPresentationCompiler.ts`
6. `RpgRuntimeStorySnapshotCommitService.ts`
### 关键要求
1. 状态读取与动作结算分开。
2. 各子域动作要能各自单测,不再全部挂在一个 service 里。
3. LLM 二次包装 story 文本不能继续散在动作服务主文件中。
## 7.3 `runtimeSession.ts` 目录化拆分
### 当前问题
`runtimeSession.ts` 已经集中了太多运行时编译职责。
### 目标拆分
建议新增目录:
```text
server-node/src/modules/rpg-runtime-story/session/
├─ RpgRuntimeSessionLoader.ts
├─ RpgRuntimeEncounterNormalizer.ts
├─ RpgRuntimeOptionCompiler.ts
├─ RpgRuntimeBattleOptionCompiler.ts
├─ RpgRuntimeNpcOptionCompiler.ts
├─ RpgRuntimeViewModelCompiler.ts
├─ RpgRuntimeLegacyStoryAdapter.ts
└─ RpgRuntimeSnapshotSync.ts
```
### 关键要求
1. loader、compiler、legacy adapter、snapshot sync 必须物理拆开。
2. “是否生成 legacy currentStory”要成为单独兼容层而不是散落在核心编排里。
3. interaction 语义编译要可单测、可扩展。
## 7.4 repository 拆分
### 当前问题
`runtimeRepository.ts` 不是 RPG 进入游戏链友好的结构。
### 目标拆分
建议拆成:
1. `RpgRuntimeSnapshotRepository.ts`
2. `RpgSaveArchiveRepository.ts`
3. `RpgProfileDashboardRepository.ts`
4. `RpgBrowseHistoryRepository.ts`
5. `RpgWorldLibraryRepository.ts`
### 关键要求
1. snapshot、save archive 与资料型仓储分离。
2. 世界库详情读取与运行时快照读写不再耦合在同一仓储。
3. 后续“继续游戏”链可以只依赖 snapshot/save archive 仓储。
## 7.5 shared contract 拆分
### 当前问题
`packages/shared/src/contracts/story.ts` 已经过大。
### 目标拆分
建议拆为:
1. `rpgRuntimeStoryAction.ts`
2. `rpgRuntimeStoryState.ts`
3. `rpgRuntimeChat.ts`
4. `rpgRuntimeQuestAssist.ts`
### 关键要求
1. runtime story 主链契约要能独立演进,不被 NPC chat / quest / item 附属能力拖累。
2. 前后端只在必要范围共享契约,减少大而全文件。
---
## 8. 可并行重构工作包
本次执行计划必须拆成多个可以同时推进的工作部分。
总原则如下:
1. 每个工作包只负责一组明确文件。
2. 每个工作包先建新目录和 façade再迁真实调用再清旧层。
3. 同一阶段允许并行,但禁止多人同时大改同一热点主文件。
## 8.1 工作包 ARPG 命名规范与目录骨架
### 目标
先建立 RPG 进入游戏链的新命名与目录落点。
### 负责范围
1. `src/components/rpg-entry/`
2. `src/components/rpg-runtime-shell/`
3. `src/components/rpg-runtime-panels/`
4. `src/hooks/rpg-session/`
5. `src/hooks/rpg-runtime-story/`
6. `src/services/rpg-entry/`
7. `src/services/rpg-runtime/`
8. `server-node/src/routes/rpg-*/`
9. `server-node/src/modules/rpg-runtime-story/`
10. `server-node/src/repositories/rpg-*/`
### 写入边界
1. 只建目录、入口 façade、导出索引和基础命名规范。
2. 不负责大规模迁移老逻辑。
### 前置依赖
无,可立即开始。
## 8.2 工作包 B前端 RPG 入口壳层拆分
### 目标
把平台首页/详情页/继续游戏/进入世界从 `PreGameSelectionFlow.tsx` 拆出来。
### 负责范围
1. `PreGameSelectionFlow.tsx`
2. `PlatformHomeView.tsx`
3. `PlatformWorldDetailView.tsx`
4. `CharacterSelectionFlow.tsx`
5. 入口阶段相关新 hooks
### 写入边界
1. 只改前端入口链。
2. 不改后端接口语义。
3. 不拆运行态冒险面板。
4. 不修改任何前端交互界面设计。
### 前置依赖
依赖工作包 A 的目录骨架。
## 8.3 工作包 C前端 session/bootstrap/persistence 拆分
### 目标
`useGameFlow.ts``useGamePersistence.ts``useGameShellRuntime.ts` 收成 RPG session 域。
### 负责范围
1. `useGameFlow.ts`
2. `useGamePersistence.ts`
3. `useGameShellRuntime.ts`
4. `storageService.ts` 中 snapshot/save archive 相关调用入口
### 写入边界
1. 主要改 session bootstrap、自动存档、继续游戏恢复。
2. 不改 AdventurePanel UI。
3. 不改后端 storyActionService 语义。
4. 不修改任何前端交互界面设计。
### 前置依赖
依赖工作包 A可与 B、D、F、G、H 并行。
## 8.4 工作包 D前端运行态 shell 与面板拆分
### 目标
`GameShellRuntime.tsx``GameShellMainContent.tsx``GameShellStoryPanels.tsx``AdventurePanel.tsx` 拆成 RPG runtime shell 体系。
### 负责范围
1. 运行态 shell
2. 主阶段路由器
3. 面板路由器
4. 冒险主面板 section
### 写入边界
1. 主改组件与 view model。
2. 不改 runtime story 后端协议。
3. 不改平台入口链。
4. 不修改任何前端交互界面设计。
### 前置依赖
依赖工作包 A可与 B、C、E、F、G、H 并行。
## 8.5 工作包 E前端 runtime story 与 NPC 交互链拆分
### 目标
把多层 story coordinator 收成稳定的 RPG runtime story 结构。
### 负责范围
1. `useStoryGeneration.ts`
2. `useStoryRuntimeController.ts`
3. `useStoryFlowCoordinator.ts`
4. `useStoryGoalSessionCoordinator.ts`
5. `useStoryInteractionCoordinator.ts`
6. `npcEncounterActions.ts`
7. `runtimeStoryCoordinator.ts`
8. `runtimeStoryService.ts`
### 写入边界
1. 只改前端 runtime story 主链。
2. 不拆平台首页和世界详情壳层。
3. 不直接修改后端动作语义。
4. 不修改任何前端交互界面设计。
### 前置依赖
依赖工作包 A可与 B、C、D、F、G、H 并行。
## 8.6 工作包 F后端 route 边界拆分
### 目标
`runtimeRoutes.ts``storyActionRoutes.ts` 按 RPG 域拆边界。
### 负责范围
1. `app.ts`
2. `runtimeRoutes.ts`
3. `storyActionRoutes.ts`
4.`rpgProfileRoutes.ts`
5.`rpgEntry...Routes.ts`
6.`rpgRuntimeStoryRoutes.ts`
### 写入边界
1. 只调整路由组织和 façade。
2. 不重写下游全部 service 逻辑。
### 前置依赖
依赖工作包 A可与 B、C、D、E、G、H 并行。
## 8.7 工作包 G后端 runtime session / action service 拆分
### 目标
`storyActionService.ts``runtimeSession.ts` 目录化拆开。
### 负责范围
1. `storyActionService.ts`
2. `runtimeSession.ts`
3. `npcInteractionService.ts`
4. `questStoryActionService.ts`
5. 新 action service / compiler / adapter 文件
### 写入边界
1. 主改后端运行时 story 主链。
2. 不改前端入口与 UI。
3. 不负责仓储拆分。
### 前置依赖
依赖工作包 A可与 B、C、D、E、F、H 并行。
## 8.8 工作包 H仓储、契约与测试基建
### 目标
把 snapshot/profile/library 仓储与 shared contract 收成独立层,并补齐测试。
### 负责范围
1. `runtimeRepository.ts`
2. `runtimeSnapshotHydration.ts`
3. `packages/shared/src/contracts/story.ts`
4. runtime story / snapshot / continue game 相关测试
### 写入边界
1. 只负责仓储、契约、fixture、测试。
2. 不直接改前端 UI。
3. 不重写 route 层。
### 前置依赖
依赖工作包 A可与 B、C、D、E、F、G 并行。
## 8.9 推荐并行顺序
```text
第一批并行:
工作包 A
第二批并行:
工作包 B + 工作包 C + 工作包 D + 工作包 E + 工作包 F + 工作包 G + 工作包 H
第三批收口:
把 B~H 的 façade 接回主链
-> 联调继续游戏 / 开始游戏 / 角色选择 / 冒险 runtime / 存档恢复
-> 清理旧命名与兼容导出
```
## 8.10 并行协作约束
1. 工作包 B 独占 `PreGameSelectionFlow.tsx``PlatformHomeView.tsx``PlatformWorldDetailView.tsx``CharacterSelectionFlow.tsx`
2. 工作包 C 独占 `useGameFlow.ts``useGamePersistence.ts``useGameShellRuntime.ts`
3. 工作包 D 独占 `GameShellRuntime.tsx``GameShellMainContent.tsx``GameShellStoryPanels.tsx``AdventurePanel.tsx`
4. 工作包 E 独占 story runtime hooks 与 `runtimeStoryService.ts`
5. 工作包 F 独占 `runtimeRoutes.ts``storyActionRoutes.ts``app.ts`
6. 工作包 G 独占 `storyActionService.ts``runtimeSession.ts` 与相关 runtime story modules。
7. 工作包 H 独占 `runtimeRepository.ts``runtimeSnapshotHydration.ts` 与 shared runtime story contracts。
---
## 9. 分阶段落地计划
## Phase 0冻结命名与边界口径
### 目标
先冻结“RPG 入口链 / RPG session / RPG runtime / RPG runtime story / RPG profile”五类命名边界。
### 验收标准
1. 后续新增文件不再继续进入 `GameShell*``runtimeRoutes.ts``storyActionService.ts` 这类旧热点。
2. 团队对文件落点和命名根达成一致。
3. 团队对“脚本重构不改前端交互界面设计”的冻结边界达成一致。
## Phase 1目录骨架与前端入口拆分
### 目标
先建立目录骨架,并把进入游戏前的前端壳层从大文件中拆开。
### 验收标准
1. 平台首页/详情页/选角能落到 `rpgEntry` 目录。
2. `PreGameSelectionFlow.tsx` 退化为兼容壳层或 façade。
3. 页面视觉结构与交互方式和当前线上版本保持一致。
## Phase 2session/bootstrap/persistence 收口
### 目标
把“新开局/继续游戏/自动存档/恢复 runtime story”从组件与面板层抽离。
### 验收标准
1. `useGameFlow.ts``useGamePersistence.ts` 不再是直接主入口命名。
2. 继续游戏与自动存档都能走独立 session hook。
3. 用户看到的开始游戏/继续游戏交互顺序不变。
## Phase 3运行态 shell 与 runtime story 主链拆分
### 目标
把冒险运行态 UI 壳层与 runtime story 协调层分别重构。
### 验收标准
1. `AdventurePanel.tsx` 不再是大一统主热点。
2. 前端 runtime story 主链收成 3~4 个稳定层级,而不是多层 coordinator 套娃。
3. 冒险态界面布局、tab 交互与 overlay 呈现方式不变。
## Phase 4后端 route / service / compiler / repository 目录化
### 目标
把后端“大 runtime route + 大 storyActionService + 大 runtimeSession + 大 runtimeRepository”按 RPG 域拆开。
### 验收标准
1. `runtimeRoutes.ts` 退化为 façade 或被拆空。
2. `storyActionService.ts` 只保留兼容导出。
3. `runtimeSession.ts` 只保留 façade 或兼容导出。
4. `runtimeRepository.ts` 不再承载全部 RPG 资料与快照职责。
5. 前端交互界面设计在整个后端重构阶段保持零变更。
## Phase 5兼容层清理
### 目标
在主链稳定后清理旧命名与旧 façade。
### 验收标准
1. 进入游戏主链只剩 RPG 专属命名文件。
2. `GameShell*``runtimeRoutes.ts` 不再是主链真实入口。
3. 文档、契约、测试口径一致。
---
## 10. 验收标准
本次重构方案最终要达成以下结果:
1. 从“平台首页/详情页开始游戏”到“进入 RPG 运行态”的脚本链能一眼看出前端入口、session、runtime、story 的层级。
2. 进入游戏主链不再依赖过于泛化的 `GameShell / runtime / flow` 命名。
3. 前端自动存档、继续游戏恢复、runtime story server gateway 具备独立文件边界。
4. 后端 route、runtime story service、session compiler、snapshot repository 分层清晰。
5. 所有新主文件都以 RPG 域命名,不再继续扩大旧热点文件。
6. 整个重构过程不改变任何前端交互界面设计,用户可见交互保持一致。
---
## 11. 结论
当前 RPG 进入游戏后的主玩法链,真正的问题不是单点 bug而是
**平台入口、会话初始化、运行态壳层、前端 runtime story 协调链、后端 route 总入口、后端 runtime session 编译器和仓储层同时过重,而且命名没有体现“这是 RPG 专属主链”。**
后续正确的重构方向不是继续在 `GameShell``runtimeRoutes.ts``storyActionService.ts` 这些热点文件上打补丁,而是把主链收成:
**RPG 入口域 -> RPG session 域 -> RPG runtime shell 域 -> RPG runtime story 域 -> RPG snapshot / profile 域**
只有这样,这条“开始游戏 -> 进入运行态 -> 推进剧情 -> 自动存档 -> 继续游戏”的链路,才会真正具备后续可读、可扩、可并行维护的工程形态。

View File

@@ -0,0 +1,107 @@
# RPG 进入游戏与运行时链路旧脚本删除收口记录
更新时间:`2026-04-21`
## 1. 本次目标
本轮继续按 `RPG_ENTRY_RUNTIME_CHAIN_REFACTOR_EXECUTION_PLAN_2026-04-21.md` 收口,目标只有一个:
**在新 `rpg-entry`、`rpg-session`、`rpg-runtime-shell`、`rpg-runtime-panels`、`rpg-runtime-story` 主链已经接回后,物理删除旧 RPG 入口与运行态脚本,并确认源码和当前后端构建产物不再依赖旧路径。**
本轮不改 UI 布局、不改按钮位置、不改 tab 组织、不改弹层交互方式,也不把历史审计文档里的旧文件名当成运行时代码依赖处理。
## 2. 已删除旧脚本范围
前端旧入口与旧兼容层已从源码中移除:
1. `src/components/game-shell/`
2. `src/components/game-shell/rpg-creation-flow/`
3. `src/components/AdventurePanel.tsx`
4. `src/hooks/story/`
5. `src/hooks/useGameFlow.ts`
6. `src/hooks/useGamePersistence.ts`
7. `src/hooks/useGameShellRuntime.ts`
8. `src/hooks/useStoryGeneration.ts`
9. `src/services/runtimeStoryService.ts`
10. `src/services/storageService.ts`
后端与共享契约旧入口已从源码中移除:
1. `server-node/src/routes/runtimeRoutes.ts`
2. `server-node/src/modules/story/runtimeSession.ts`
3. `server-node/src/modules/story/storyActionRoutes.ts`
4. `server-node/src/modules/story/storyActionService.ts`
5. `packages/shared/src/contracts/story.ts`
## 3. 新主链落点
删除旧脚本后RPG 主链只允许继续落在以下新域:
1. 前端入口:`src/components/rpg-entry/`
2. 前端 session`src/hooks/rpg-session/`
3. 前端运行态 shell`src/components/rpg-runtime-shell/`
4. 前端运行态面板:`src/components/rpg-runtime-panels/`
5. 前端 runtime story`src/hooks/rpg-runtime-story/`
6. 前端 client`src/services/rpg-entry/``src/services/rpg-runtime/`
7. 后端 route`server-node/src/routes/rpg-entry/``server-node/src/routes/rpg-profile/``server-node/src/routes/rpg-runtime/`
8. 后端 runtime story`server-node/src/modules/rpg-runtime-story/`
9. 后端仓储:`server-node/src/repositories/rpg-entry/``server-node/src/repositories/rpg-profile/``server-node/src/repositories/rpg-runtime/`
10. 共享契约:`packages/shared/src/contracts/rpgRuntimeStoryAction.ts``packages/shared/src/contracts/rpgRuntimeStoryState.ts``packages/shared/src/contracts/rpgRuntimeChat.ts``packages/shared/src/contracts/rpgRuntimeQuestAssist.ts`
## 4. 残留依赖检查
本轮使用旧路径和旧入口名扫描源码与当前后端构建产物,确认以下路径不存在且没有运行时代码引用:
1. `src/components/game-shell`
2. `src/hooks/story`
3. `src/hooks/useGameFlow.ts`
4. `src/hooks/useGamePersistence.ts`
5. `src/hooks/useGameShellRuntime.ts`
6. `src/hooks/useStoryGeneration.ts`
7. `src/services/runtimeStoryService.ts`
8. `src/services/storageService.ts`
9. `server-node/src/routes/runtimeRoutes.ts`
10. `server-node/src/modules/story/runtimeSession.ts`
11. `server-node/src/modules/story/storyActionRoutes.ts`
12. `server-node/src/modules/story/storyActionService.ts`
13. `packages/shared/src/contracts/story.ts`
补充处理:
1. `npm --prefix server-node run build` 已重新生成当前 `server-node/dist/server.cjs`
2. 旧的忽略产物 `server-node/dist/server.js``server-node/dist/server.js.map``2026-04-18` 遗留 bundle仍包含旧路径 sourcemap本轮已定点删除避免本地误跑旧构建。
3. 当前 `server-node/dist/` 只保留 `server.cjs``server.cjs.map`,旧主入口路径扫描无命中。
## 5. 本轮补丁
本轮额外修正了迁移后测试 prop 类型不一致的问题:
1. `src/components/rpg-runtime-panels/RpgAdventurePanel.npcChat.test.tsx`
2. `src/components/rpg-runtime-panels/RpgAdventurePanel.test.tsx`
`npcChatQuestOfferUi.replacePendingOffer` 在新面板类型中是同步布尔返回,测试 mock 已从 `async () => false` 改为 `() => false`,避免旧异步 mock 继续伪装成兼容层行为。
## 6. 验证结果
已通过:
1. `npm run check:encoding`
2. `npm --prefix server-node run build`
3. `npx vitest run src/components/rpg-runtime-panels/RpgAdventurePanel.npcChat.test.tsx src/components/rpg-runtime-panels/RpgAdventurePanel.test.tsx src/services/rpg-runtime/rpgRuntimeStoryClient.test.ts src/components/rpg-entry/RpgEntryCharacterSelectView.test.tsx src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx src/components/rpg-runtime-shell/useRpgRuntimeShellViewModel.test.ts src/hooks/rpg-runtime-story/useRpgRuntimeStoryState.test.ts src/hooks/rpg-runtime-story/useRpgRuntimeInteractionFlow.test.ts`
定向前端回归结果:`8` 个测试文件、`37` 个测试通过。
未完全通过但不属于本轮 RPG 旧脚本删除阻塞:
1. `npm run typecheck`
- 剩余错误集中在 `packages/shared/src/contracts/rpgCreationFixtures.ts``src/components/auth/AccountModal.test.tsx``src/data/customWorldLibrary.ts``src/services/customWorldCover.test.ts`
- 本轮已清掉 `RpgAdventurePanel*.test.tsx` 中与迁移相关的 `Promise<boolean>` 类型错误。
2. `npm --prefix server-node run test`
- RPG runtime story、RPG entry save、RPG world library、RPG profile route 等相关测试通过。
- 剩余失败为 `custom world agent` HTTP 相关 `12` 个用例返回 `500 !== 200`,属于创作链/Agent 链路问题,不属于本轮 RPG 旧脚本删除范围。
## 7. 结论
当前 RPG 进入游戏与运行时主链已经不再依赖旧 `GameShell``useGame*``useStoryGeneration``hooks/story``runtimeStoryService``runtimeRoutes``modules/story/*``contracts/story.ts` 脚本。
后续如果继续开发 RPG 入口、运行态、runtime story 或存档恢复,只应扩展新 `rpg-*` 目录与分文件契约,不应重新创建旧路径 façade。

View File

@@ -0,0 +1,132 @@
# RPG 进入游戏与运行时链路重构第一批第二批并行工作复核记录
更新时间:`2026-04-21`
## 1. 复核目标
本次复核只做一件事:
**对照 `RPG_ENTRY_RUNTIME_CHAIN_REFACTOR_EXECUTION_PLAN_2026-04-21.md`,检查第一批并行与第二批并行工作是否存在遗漏、未完成收口或仍停留在“看起来已完成但工程状态未闭合”的问题。**
执行边界保持如下:
1. 只检查工作包 A 到 H 是否达到各自文档声明的落地状态。
2. 只补齐确认属于本次重构范围的遗漏项。
3. 不顺手扩展 UI、玩法、协议或无关模块。
## 2. 复核范围
本次逐项核对了以下内容:
1. `docs/technical/RPG_ENTRY_RUNTIME_CHAIN_REFACTOR_EXECUTION_PLAN_2026-04-21.md`
2. `docs/technical/RPG_ENTRY_RUNTIME_CHAIN_REFACTOR_WORK_PACKAGE_A_PROGRESS_2026-04-21.md`
3. `docs/technical/RPG_ENTRY_RUNTIME_CHAIN_REFACTOR_WORK_PACKAGE_B_PROGRESS_2026-04-21.md`
4. `docs/technical/RPG_ENTRY_RUNTIME_CHAIN_REFACTOR_WORK_PACKAGE_C_PROGRESS_2026-04-21.md`
5. `docs/technical/RPG_ENTRY_RUNTIME_CHAIN_REFACTOR_WORK_PACKAGE_D_PROGRESS_2026-04-21.md`
6. `docs/technical/RPG_ENTRY_RUNTIME_CHAIN_REFACTOR_WORK_PACKAGE_E_PROGRESS_2026-04-21.md`
7. `docs/technical/RPG_ENTRY_RUNTIME_CHAIN_REFACTOR_WORK_PACKAGE_F_PROGRESS_2026-04-21.md`
8. `docs/technical/RPG_ENTRY_RUNTIME_CHAIN_REFACTOR_WORK_PACKAGE_G_PROGRESS_2026-04-21.md`
9. `docs/technical/RPG_ENTRY_RUNTIME_CHAIN_REFACTOR_WORK_PACKAGE_H_PROGRESS_2026-04-21.md`
10. 前端 `rpg-entry``rpg-session``rpg-runtime-shell``rpg-runtime-panels``rpg-runtime-story` 新域真实实现
11. 后端 `routes/rpg-*``modules/rpg-runtime-story``repositories/rpg-*` 新域真实实现
12. shared contract 分文件与兼容 façade
13. 与工作包 E / F / G / H 直接相关的定向测试与工作树状态
## 3. 复核结论
## 3.1 第一批并行结论
第一批并行只包含工作包 A。
复核结果:
1. 工作包 A 要求的目录骨架、façade、barrel 与按域命名落点已经建立。
2. 工作包 A 没有发现需要继续补做的编码遗漏。
3. 当前 A 的剩余内容本来就属于后续工作包 B 到 H 的真实迁移,不属于遗漏。
结论:
**第一批并行无新增遗漏项。**
## 3.2 第二批并行总体结论
对照工作包 B 到 H 的进度文档与实际主链代码,当前状态如下:
1. 工作包 B`rpg-entry` 已承接真实入口实现,旧 `game-shell` / `rpg-creation-flow` 路径已降级为兼容层。
2. 工作包 C`rpg-session` 已承接真实 session / persistence 主链,`storageService.ts` 已退化为兼容转发层。
3. 工作包 D`rpg-runtime-shell``rpg-runtime-panels` 已承接真实实现,旧 `GameShell*` / `AdventurePanel` 已降级为兼容入口。
4. 工作包 E前端 runtime story 主链已迁入 `rpg-runtime-story`,但测试工作树存在未收口的合流状态。
5. 工作包 F`app.ts` 已按 `rpgProfile / rpgEntry / rpgRuntimeStory / rpgRuntimeAiAssist` 挂载新域路由,旧 `runtimeRoutes.ts` 已降级。
6. 工作包 G后端 runtime story action / state / session 真实实现已迁入 `modules/rpg-runtime-story`,旧热点已退化为兼容导出。
7. 工作包 H仓储、shared contract 与定向测试基建已落到新域命名与分文件结构。
结论:
**第二批并行的主链改造基本已经完成,本轮确认到的真实遗漏主要集中在“工程收口状态”而不是“功能未落地”。**
## 4. 本轮确认的遗漏项
## 4.1 工作包 E 相关测试文件仍处于未解决合流状态
复核时发现:
1. `src/hooks/story/npcEncounterActions.test.ts` 的文件内容已经切到 `../rpg-runtime-story/rpgRuntimeStoryGateway` 新路径。
2. 但 Git 索引仍把该文件标记为 `UU`,说明这份并行工作没有真正完成收口。
这会导致:
1. 工作包 E 虽然逻辑上已完成迁移,但工程状态仍不能算完全闭合。
2. 后续继续判断第二批并行是否完成时,会被未解决合流状态误判为仍未完成。
## 4.2 后端总测试文件仍保留同一轮并行改动的未解决合流状态
复核时发现:
1. `server-node/src/app.test.ts` 仍处于 `UU` 状态。
2. 该文件中的两侧改动并不冲突:
- 一侧是把微信登录测试的 cookie 解析统一改成 `readCookieValue(...)`
- 另一侧是补充 custom world agent SSE enriched session 回归测试
3. 当前文件内容已经同时包含两侧结果,但索引没有完成正式收口。
4. 合并后还残留了一个未被使用的 `accessCookie` 中间变量,属于典型的合流尾巴。
这同样属于:
**不是功能缺失,而是并行工作未彻底收口。**
## 4.3 技术文档索引未完整登记本轮工作包文档
复核时发现:
1. `docs/technical/README.md` 已登记 A、D、E、F、G、H 的进度文档。
2. 但缺少工作包 B 与工作包 C 的进度文档入口。
3. 这会让后续按文档索引回溯第二批并行工作时出现“文档已存在但目录索引缺失”的信息断层。
这属于文档收口遗漏,应当在本轮一并补齐。
## 5. 本轮补齐动作
本轮只补以下缺口:
1. 清理 `server-node/src/app.test.ts` 中合流后残留的未使用变量,保留两侧都应保留的测试结果。
2.`src/hooks/story/npcEncounterActions.test.ts` 作为工作包 E 已确认的正确内容进行正式收口。
3.`server-node/src/app.test.ts` 作为同轮并行改动的正确合并结果进行正式收口。
4. 更新 `docs/technical/README.md`,补齐工作包 B、工作包 C 与本复核文档入口。
## 6. 本轮明确没有做的事
为了避免过度开发,本轮没有继续做以下事情:
1. 没有继续拆 UI 组件。
2. 没有继续清理旧兼容 façade。
3. 没有额外重构任何 route / service / repository。
4. 没有调整任何前端交互界面设计。
5. 没有顺手处理与本次 RPG 并行重构无直接关系的其他噪音文件。
## 7. 复核后状态结论
在本轮补齐之后,可以对第一批与第二批并行工作给出如下结论:
1. 第一批并行没有发现新增遗漏。
2. 第二批并行的主链功能改造已经基本齐备。
3. 本轮确认并补齐的遗漏主要是测试合流收口与文档索引缺口。
4. 当前不需要继续扩大改造范围,后续可以按执行计划进入第三批统一收口。

View File

@@ -0,0 +1,191 @@
# RPG 进入游戏与运行时链路重构第三批收口记录
更新时间:`2026-04-21`
## 1. 本次目标
本次只落实 `RPG_ENTRY_RUNTIME_CHAIN_REFACTOR_EXECUTION_PLAN_2026-04-21.md` 中的 **第三批收口**,严格遵守以下边界:
1. 把第二批工作包 B 到 H 已完成的新域 façade 真正接回主链。
2. 对照执行计划逐项检查“开始游戏 / 继续游戏 / 角色选择 / 冒险 runtime / 存档恢复”是否仍残留旧命名真实依赖。
3. 只清理确认属于 RPG 主链的新域反向依赖与未接线仓储不扩到创作链、UI 设计或玩法语义。
## 2. 本次完成的第三批收口
## 2.1 前端运行态主链不再反向依赖旧 `GameShell` 热点
本轮把以下真实实现从旧 `game-shell` 命名下摘回 `rpg-runtime-shell`
1. `src/components/rpg-runtime-shell/RpgRuntimeCanvasStage.tsx`
2. `src/components/rpg-runtime-shell/rpgRuntimeLoaders.tsx`
3. `src/components/rpg-runtime-shell/useRpgSceneTransitionModel.ts`
同时完成以下主链接线:
1. `src/components/rpg-runtime-shell/RpgRuntimeShell.tsx` 直接使用 `RpgRuntimeCanvasStage`
2. `src/components/rpg-runtime-shell/RpgRuntimeOverlayHost.tsx` 直接使用 `rpgRuntimeLoaders`
3. `src/components/rpg-runtime-shell/useRpgRuntimeShellViewModel.ts` 直接使用 `useRpgSceneTransitionModel`
4. `src/components/rpg-runtime-panels/RpgRuntimePanelRouter.tsx` 直接从 `rpg-runtime-shell` 读取 `PanelLoadingFallback`
当前结果:
1. 运行态主链真实实现已经不再依赖旧 `GameShellCanvasStage``GameShellLoaders``useSceneTransitionModel`
2.`game-shell` 文件只保留兼容 re-export不再作为 RPG 运行态真实落点。
## 2.2 前端入口域主链不再反向依赖旧平台展示 helper
本轮把以下入口域通用展示能力收回 `rpg-entry`
1. `src/components/rpg-entry/RpgEntryBrandLogo.tsx`
2. `src/components/rpg-entry/rpgEntryWorldPresentation.ts`
3. `src/components/rpg-entry/RpgEntryCreationTypeModal.tsx`
并完成以下主链接线:
1. `RpgEntryHomeView.tsx` 改为直接使用 `RpgEntryBrandLogo``rpgEntryWorldPresentation`
2. `RpgEntryWorldDetailView.tsx` 改为直接使用 `rpgEntryWorldPresentation`
3. `RpgEntryFlowShellImpl.tsx` 改为直接使用 `RpgEntryCreationTypeModal`
当前结果:
1. `rpg-entry` 真实实现不再依赖旧 `PlatformBrandLogo``platformWorldPresentation``PlatformCreationTypeModal`
2.`game-shell` 对应文件只保留兼容导出。
## 2.3 工作包 H 新仓储已真正接回后端 RPG 主链
本轮把工作包 H 中已建立但尚未接回主链的新仓储正式注入 `AppContext`
1. `rpgProfileDashboardRepository`
2. `rpgBrowseHistoryRepository`
3. `rpgSaveArchiveRepository`
4. `rpgRuntimeSnapshotRepository`
并完成以下主链接线:
1. `server-node/src/routes/rpg-profile/rpgProfileRoutes.ts`
- 资料看板、钱包流水、游玩统计走 `rpgProfileDashboardRepository`
- 浏览历史走 `rpgBrowseHistoryRepository`
- 设置读写走 `rpgProfileDashboardRepository`
2. `server-node/src/routes/rpg-entry/rpgEntrySaveRoutes.ts`
- snapshot 读写删除走 `rpgRuntimeSnapshotRepository`
- save archive 列表与恢复走 `rpgSaveArchiveRepository`
3. `server-node/src/routes/rpg-runtime/rpgRuntimeStoryRoutes.ts`
- runtime story 状态读取与动作结算走 `rpgRuntimeSnapshotRepository`
当前结果:
1. `rpgProfile``rpgEntrySave``rpgRuntimeStory` 主链已经不再直接把大 `runtimeRepository` 当作唯一注入边界。
2. 工作包 H 的新仓储不再停留在“命名骨架已存在但主链未接线”的状态。
## 2.4 新域 shared contract 进一步脱离旧 `story.ts` façade
本轮继续把 RPG 新主链中的 shared contract 直接切到分文件:
1. `server-node/src/routes/rpg-runtime/rpgRuntimeStoryRoutes.ts`
- 改为直接使用 `rpgRuntimeStoryState.ts`
2. `server-node/src/routes/rpg-runtime/rpgRuntimeAiAssistRoutes.ts`
- 改为直接使用 `rpgRuntimeChat.ts``rpgRuntimeQuestAssist.ts`
3. `server-node/src/modules/rpg-runtime-story/RpgRuntimeSessionDomain.ts`
- 改为直接使用 `rpgRuntimeStoryAction.ts`
- snapshot 类型改为使用 `RpgRuntimeSavedSnapshot`
4. `server-node/src/modules/rpg-runtime-story/RpgRuntimeStoryActionDomain.ts`
- runtime story request/response 类型改为直接使用 `rpgRuntimeStoryState.ts`
- snapshot 仓储端口改为使用 `RpgRuntimeSnapshotRepositoryPort`
当前结果:
1. RPG 新域主链已经显著减少对旧 `packages/shared/src/contracts/story.ts` façade 的反向依赖。
2. `story.ts` 继续保留兼容职责,但不再是第三批已收口主链的真实首选入口。
## 2.5 补齐世界库主链接线遗漏
代码级复核后,本轮额外补齐了第三批范围内一个真实遗漏:世界库主链虽然已经有 `rpg-entry` 路由和 `RpgWorldLibraryRepository`,但此前并未完整接回主链。
本次补齐包括:
1. `server-node/src/context.ts``server-node/src/server.ts`
- 正式注入 `rpgWorldLibraryRepository`
2. `server-node/src/routes/rpg-entry/rpgWorldLibraryRoutes.ts`
- 作品库/广场/详情/发布/下架/删除等读写改为直接走 `rpgWorldLibraryRepository`
- `rpgWorldProfileRepository` 继续保留给 Agent 发布链与发布服务使用
3. `src/services/rpg-entry/rpgEntryLibraryClient.ts`
- 改为直接承接 `/api/runtime/custom-world-library``/api/runtime/custom-world-gallery` 请求
- 不再反向依赖旧 `storageService.ts` 兼容层
4. `src/services/rpg-entry/rpgEntryLibraryClient.test.ts`
- 补齐入口世界库 client 的定向回归
当前结果:
1. “平台首页 / 世界详情 / 开始游戏”所依赖的世界库链路,现在已经和 save/profile 一样真正回到 `rpg-entry` + `rpg-entry repository` 主链。
2. 第三批范围内不再残留“新命名已建好,但真实主链仍穿旧兼容层/旧仓储”的世界库遗漏。
## 3. 老代码物理删除补充
根据后续收口要求,本轮在主链稳定后继续删除旧兼容层,不再让 RPG 入口与运行态链路通过旧脚本名兜底。
已删除的旧前端入口包括:
1. `src/components/AdventurePanel.tsx`
2. `src/components/game-shell/*`
3. `src/components/game-shell/rpg-creation-flow/*`
4. `src/hooks/useGameFlow.ts`
5. `src/hooks/useGamePersistence.ts`
6. `src/hooks/useGameShellRuntime.ts`
7. `src/hooks/useStoryGeneration.ts`
8. `src/services/runtimeStoryService.ts`
9. `src/services/storageService.ts`
已删除的旧后端与共享契约入口包括:
1. `server-node/src/routes/runtimeRoutes.ts`
2. `server-node/src/modules/story/runtimeSession.ts`
3. `server-node/src/modules/story/storyActionService.ts`
4. `server-node/src/modules/story/storyActionRoutes.ts`
5. `packages/shared/src/contracts/story.ts`
同步完成的主链迁移包括:
1. `RpgEntryFlowShellImpl.tsx` 直接使用 `src/components/rpg-entry/useRpgCreation*` hooks不再反向依赖 `game-shell/rpg-creation-flow`
2. runtime story client 测试迁到 `src/services/rpg-runtime/rpgRuntimeStoryClient.test.ts`
3. profile / world library 路由测试迁到 `src/services/rpg-entry/rpgEntryClients.routing.test.ts`
4. 冒险面板测试迁到 `src/components/rpg-runtime-panels/RpgAdventurePanel*.test.tsx`
5. `AdventurePanelOverlays` 子模块改名并迁到 `src/components/rpg-runtime-panels/RpgAdventurePanelOverlays.tsx`
6. 后端与前端 shared contract import 已切到 `rpgRuntimeChat.ts``rpgRuntimeQuestAssist.ts``rpgRuntimeStoryAction.ts``rpgRuntimeStoryState.ts`
本轮仍然刻意没有继续扩大到以下内容:
1. 没有重命名历史审计、旧 PRD、旧技术方案中作为历史记录出现的旧文件名。
2. 没有继续拆分 `runtimeRepository.ts` 剩余实现。
3. 没有改 UI 布局、按钮位置、tab 组织、弹层方式或任何用户可见交互设计。
4. 没有清理与本执行计划无关的创作链旧文件;只删除 RPG 入口 / 运行态链路已经不再依赖的旧脚本。
## 4. 本轮检查后确认的未扩范围
为了避免过度开发,本轮明确没有继续扩到以下内容:
1. 没有继续改 UI 布局、按钮位置、tab 组织、弹层方式或任何用户可见交互设计。
2. 没有继续拆分 `runtimeRepository.ts` 剩余实现,也没有扩大到更多仓储接口重命名。
3. 没有顺手清理历史文档里的旧文件名引用,只更新本次执行收口文档。
## 5. 第三批遗漏复核结论
对照执行计划中的第三批要求,本轮确认如下:
1. **把 B 到 H 的 façade 接回主链**:已完成
- 前端 `rpg-entry``rpg-runtime-shell``rpg-runtime-panels`
- 后端 `rpg-profile``rpg-entry-save``rpg-runtime-story`
2. **联调继续游戏 / 开始游戏 / 角色选择 / 冒险 runtime / 存档恢复**:本轮已把这些链路上的主注入边界和真实落点接回新域
3. **清理旧命名与兼容导出**:已完成主链级清理,并按本次补充要求物理删除旧兼容脚本
当前仍保留但不计为遗漏的部分:
1. 历史审计、旧 PRD、旧技术方案中仍会提到旧文件名这是历史记录不代表运行时代码依赖。
2. `src/components/rpg-runtime-panels/RpgAdventurePanel.tsx` 仍保持原交互结构,只做脚本路径与命名收口。
## 6. 结论
在“不改 UI、不改玩法、不扩到创作链执行方案”的前提下本轮已经完成 RPG 执行计划中的第三批主链收口:
1. 新域真实实现已经接回前后端主链。
2. RPG 主链对旧 `GameShell` / `runtimeRoutes` / `story.ts` façade 的真实依赖已经删除。
3. 复核中发现并补齐了世界库主链接线遗漏后,当前没有再发现属于本次第三批范围、且必须继续补做的遗漏项。

View File

@@ -0,0 +1,101 @@
# RPG 进入游戏与运行时链路重构工作包 A 落地记录
更新时间:`2026-04-21`
## 1. 本次目标
本次只落实 `RPG_ENTRY_RUNTIME_CHAIN_REFACTOR_EXECUTION_PLAN_2026-04-21.md` 中的 **工作包 ARPG 命名规范与目录骨架**,严格遵守以下边界:
1. 先建立 RPG 进入游戏链的新目录与命名落点。
2. 先补 façade、barrel、path 常量和按域仓储入口,不提前迁移主流程逻辑。
3. 不修改现有前端交互界面设计,不提前实现工作包 B 到 H 的真实拆分。
## 2. 本次已落地内容
## 2.1 前端目录骨架
已新增以下前端目录与兼容 façade
1. `src/components/rpg-entry/`
2. `src/components/rpg-runtime-shell/`
3. `src/components/rpg-runtime-panels/`
4. `src/hooks/rpg-session/`
5. `src/hooks/rpg-runtime-story/`
6. `src/services/rpg-entry/`
7. `src/services/rpg-runtime/`
当前策略:
1. `RpgEntryFlowShell` 先桥接当前真实存在的 `src/components/game-shell/rpg-creation-flow/RpgCreationShell.tsx`,不再依赖已删除的旧 `PreGameSelectionFlow.tsx`
2. `RpgEntryHomeView``RpgEntryWorldDetailView``RpgEntryCharacterSelectView` 继续桥接旧平台首页、世界详情和选角视图。
3. `RpgRuntimeShell``RpgRuntimeStageRouter``RpgRuntimePanelRouter``RpgAdventurePanel``RpgRuntimeOverlayHost` 继续桥接旧 `GameShell*``AdventurePanel` 组件。
4. `useRpgSessionBootstrap``useRpgSessionPersistence``useRpgRuntimeSession` 继续桥接 `useGameFlow``useGamePersistence``useGameShellRuntime`
5. `useRpgRuntimeStory``useRpgRuntimeStoryController``useRpgRuntimeStoryFlow``useRpgRuntimeInteractionFlow``useRpgRuntimeNpcInteraction``rpgRuntimeStoryGateway` 继续桥接旧 story runtime hooks 与 gateway。
## 2.2 前端 service/client 骨架
已新增以下 service/client 落点:
1. `src/services/rpg-entry/rpgEntryLibraryClient.ts`
2. `src/services/rpg-entry/rpgProfileClient.ts`
3. `src/services/rpg-runtime/rpgSnapshotClient.ts`
4. `src/services/rpg-runtime/rpgRuntimeStoryClient.ts`
5. `src/services/rpg-runtime/rpgRuntimeChatClient.ts`
当前策略:
1. `rpgEntryLibraryClient` 继续桥接 `storageService.ts` 中的世界库、世界广场与发布相关接口。
2. `rpgProfileClient` 继续桥接资料看板、浏览历史、设置与继续游戏归档相关接口。
3. `rpgSnapshotClient` 继续桥接快照读写接口。
4. `rpgRuntimeStoryClient` 继续桥接 `/api/runtime/story` 的旧 client。
5. `rpgRuntimeChatClient` 继续桥接 `aiService.ts` 中的角色聊天、NPC 对话与招募对话接口。
## 2.3 后端目录骨架
已新增以下后端目录与 façade
1. `server-node/src/routes/rpg-entry/`
2. `server-node/src/routes/rpg-profile/`
3. `server-node/src/routes/rpg-runtime/`
4. `server-node/src/modules/rpg-runtime-story/`
5. `server-node/src/repositories/rpg-entry/`
6. `server-node/src/repositories/rpg-profile/`
7. `server-node/src/repositories/rpg-runtime/`
当前策略:
1. `createRpgProfileRoutes()``createRpgEntrySaveRoutes()``createRpgWorldLibraryRoutes()``createRpgRuntimeAiAssistRoutes()` 当前只提供空路由骨架与稳定 path 常量。
2. `createRpgRuntimeStoryRoutes()` 继续桥接旧 `createStoryActionRoutes()`,确保 runtime story 路由已经有新命名落点。
3. `RpgRuntimeStoryActionService``RpgRuntimeSessionLoader``RpgRuntimeOptionCompiler``RpgRuntimeSnapshotSync` 继续桥接旧 `storyActionService.ts``runtimeSession.ts`
4. `RpgRuntimeSnapshotRepository``RpgSaveArchiveRepository``RpgProfileDashboardRepository``RpgWorldLibraryRepository` 先以委托 runtimeRepository 的方式建立按域命名仓储入口。
## 3. 本次没有做的事
以下内容仍保持原状,留给后续工作包:
1. 没有改 `GameShellMainContent.tsx``GameShellRuntime.tsx``AdventurePanel.tsx` 的内部实现。
2. 没有拆 `useGameFlow.ts``useGamePersistence.ts``useGameShellRuntime.ts` 的真实逻辑。
3. 没有拆 `useStoryGeneration.ts``npcEncounterActions.ts``runtimeStoryCoordinator.ts` 的内部职责。
4. 没有改 `server-node/src/app.ts``server-node/src/routes/runtimeRoutes.ts``server-node/src/modules/story/storyActionService.ts``server-node/src/modules/story/runtimeSession.ts` 的真实挂载与内部逻辑。
5. 没有补 shared contract 新文件,本轮执行方案的工作包 A 范围未把共享契约骨架列为必做项,因此保持到工作包 H 统一收口。
6. 没有修改任何前端交互界面设计。
## 4. 验证与现状说明
本轮已执行:
1. `npm run check:encoding`
验证结果:
1. 编码检查通过。
2. 全量 `npm run typecheck` 当前未通过,但失败项主要来自工作树中已存在的并行修改与历史类型问题。
3. 本轮已修正新 façade 中对已删除旧入口 `PreGameSelectionFlow.tsx` 的失效导入,当前 `RpgEntryFlowShell` 已改为桥接真实存在的 `rpg-creation-flow` 入口。
## 5. 对后续工作包的直接收益
1. 工作包 B 可以直接把平台入口与选角真实实现落到 `src/components/rpg-entry/`,而不必再次讨论命名与目录。
2. 工作包 C 可以直接把 session/bootstrap/persistence 的调用方迁到 `src/hooks/rpg-session/``src/services/rpg-runtime/`
3. 工作包 D 可以直接让运行态壳层与面板消费 `rpg-runtime-shell``rpg-runtime-panels` 新入口。
4. 工作包 E 可以直接把 story runtime 与 NPC 交互迁到 `src/hooks/rpg-runtime-story/``src/services/rpg-runtime/`
5. 工作包 F、G、H 可以直接基于 `server-node/src/routes/rpg-*``server-node/src/modules/rpg-runtime-story/``server-node/src/repositories/rpg-*/` 继续做真实迁移,而不用重新搭第一层命名骨架。

View File

@@ -0,0 +1,108 @@
# RPG 进入游戏与运行时链路重构工作包 B 落地记录
更新时间:`2026-04-21`
## 1. 本次目标
本次只落实 `RPG_ENTRY_RUNTIME_CHAIN_REFACTOR_EXECUTION_PLAN_2026-04-21.md` 中的 **工作包 B前端 RPG 入口壳层拆分**,严格遵守以下边界:
1. 只改前端入口链,不改后端接口语义。
2. 只处理平台首页、世界详情、继续游戏、进入世界、选角相关入口壳层与入口 hooks。
3. 不拆运行态冒险面板,不修改任何前端交互界面设计。
## 2. 本次已完成内容
## 2.1 `rpg-entry` 已成为真实入口目录
以下文件现在承载真实实现,不再只是 façade
1. `src/components/rpg-entry/RpgEntryFlowShell.tsx`
2. `src/components/rpg-entry/RpgEntryFlowShellImpl.tsx`
3. `src/components/rpg-entry/RpgEntryHomeView.tsx`
4. `src/components/rpg-entry/RpgEntryWorldDetailView.tsx`
5. `src/components/rpg-entry/RpgEntryCharacterSelectView.tsx`
6. `src/components/rpg-entry/rpgEntryTypes.ts`
7. `src/components/rpg-entry/rpgEntryShared.ts`
这意味着当前 RPG 平台入口、详情页和选角页的真实物理落点已经从旧 `game-shell` 命名转入 `rpg-entry` 域,满足执行方案里“平台首页/详情页/选角能落到 `rpgEntry` 目录”的验收要求。
## 2.2 入口相关 hooks 已补齐到 `rpg-entry`
本轮新增或迁入以下入口域 hooks
1. `src/components/rpg-entry/useRpgEntryBootstrap.ts`
2. `src/components/rpg-entry/useRpgEntryLibraryDetail.ts`
3. `src/components/rpg-entry/useRpgEntryNavigation.ts`
4. `src/components/rpg-entry/useRpgEntrySaveResume.ts`
5. `src/components/rpg-entry/useRpgEntryCharacterSelect.ts`
其中:
1. `useRpgEntryBootstrap` 负责平台 works / library / gallery / history / save / dashboard 拉取与继续游戏恢复入口。
2. `useRpgEntryLibraryDetail` 负责详情页打开、作品详情读取、继续创作入口、发布/下架/删除动作。
3. `useRpgEntryNavigation` 收口入口阶段跳转,避免壳层里继续散落匿名 `setSelectionStage(...)`
4. `useRpgEntrySaveResume` 明确“继续游戏”动作入口。
5. `useRpgEntryCharacterSelect` 补齐选角页回退与确认动作的入口域命名。
## 2.3 旧 `game-shell` / `rpg-creation-flow` 路径已降级为兼容层
以下旧文件已改为兼容 re-export不再持有真实实现
1. `src/components/game-shell/PlatformHomeView.tsx`
2. `src/components/game-shell/PlatformWorldDetailView.tsx`
3. `src/components/game-shell/CharacterSelectionFlow.tsx`
4. `src/components/game-shell/rpg-creation-flow/RpgCreationShell.tsx`
5. `src/components/game-shell/rpg-creation-flow/RpgCreationShellImpl.tsx`
6. `src/components/game-shell/rpg-creation-flow/rpgCreationFlowTypes.ts`
7. `src/components/game-shell/rpg-creation-flow/rpgCreationFlowShared.ts`
8. `src/components/game-shell/rpg-creation-flow/useRpgCreationPlatformBootstrap.ts`
9. `src/components/game-shell/rpg-creation-flow/useRpgCreationDetailNavigation.ts`
兼容策略如下:
1. 旧命名继续导出,避免并行工作包与现有测试断开。
2. 真实实现统一回落到 `rpg-entry`
3. 本轮不清理 legacy façade只让它们退化成稳定桥接层。
## 2.4 主链调用方已接回 `rpg-entry`
本轮已把主入口相关调用改为直接消费 `rpg-entry`
1. `src/components/game-shell/GameShellMainContent.tsx`
2. `src/components/game-shell/useGameShellViewModel.ts`
3. `src/components/rpg-entry/index.ts`
当前结果:
1. 主阶段路由器 lazy import 已直接走 `RpgEntryFlowShell``RpgEntryCharacterSelectView`
2. `SelectionStage` 类型已从 `rpg-entry` 暴露,不再依赖旧 `rpg-creation-flow` 作为主命名根。
3. 旧路径仍可用,但已经不再是主链真实入口。
## 3. 对照执行方案的完成判断
工作包 B 本轮已完成以下计划项:
1. 已把平台首页/详情页/继续游戏/进入世界从旧 `PreGameSelectionFlow` 所属旧命名体系中收进 `rpg-entry` 域。
2. 已让 `rpg-entry` 成为真实实现目录,而不是只保留 façade。
3. 已补齐入口阶段相关新 hooks。
4. 已让旧路径退化为兼容桥接层。
5. 没有修改任何前端交互界面设计。
当前仍刻意保留的边界:
1. 没有拆运行态冒险面板,这属于工作包 D。
2. 没有改 session/bootstrap/persistence 主逻辑,这属于工作包 C。
3. 没有改后端接口语义与 route/service 边界,这属于工作包 F/G/H。
4. 没有提前清理所有 legacy re-export本轮以稳定主链为先。
## 4. 验证与遗漏核查
本轮需要重点核查的遗漏项已经逐项确认:
1. `RpgEntryFlowShell` 不再桥接旧实现,已直接使用 `rpg-entry` 目录下的真实壳层。
2. `PlatformHomeView``PlatformWorldDetailView``CharacterSelectionFlow` 的旧路径已不再持有真实实现。
3. `GameShellMainContent` 已直接 lazy import `rpg-entry` 新入口。
4. `SelectionStage` 已从 `rpg-entry` 作为主出口暴露。
5. 复核时补齐了一个第二批收口遗漏:`RpgEntryFlowShellImpl``useRpgEntryBootstrap``useRpgEntryLibraryDetail` 这些 `rpg-entry` 真实实现已改为直接消费 `rpg-entry` 新域 client不再反向依赖旧 `storageService.ts` 作为主链入口。
本轮未执行全量 `typecheck`,原因是工作树存在并行改动与未解决冲突文件;但已把本工作包范围内最容易遗漏的主链接线点和兼容层路径全部补齐。

View File

@@ -0,0 +1,115 @@
# RPG 进入游戏与运行时链路重构工作包 C 落地记录
更新时间:`2026-04-21`
## 1. 本次目标
本次只落实 `RPG_ENTRY_RUNTIME_CHAIN_REFACTOR_EXECUTION_PLAN_2026-04-21.md` 中的 **工作包 C前端 session / bootstrap / persistence 拆分**,严格遵守以下边界:
1.`useGameFlow.ts``useGamePersistence.ts``useGameShellRuntime.ts` 的真实实现收进 `rpg-session` 域。
2. 把 snapshot / save archive 相关 client 从 `storageService.ts` 抽到 `src/services/rpg-runtime/``src/services/rpg-entry/`
3. 旧文件只保留兼容导出,不再继续承载主实现。
4. 不修改 AdventurePanel UI不修改后端 story 动作语义,不修改任何前端交互界面设计。
## 2. 本次已落地内容
## 2.1 `rpg-session` 主实现已承接 session 链
本轮已把以下真实实现迁入新域目录:
1. `src/hooks/rpg-session/useRpgSessionBootstrap.ts`
2. `src/hooks/rpg-session/useRpgSessionPersistence.ts`
3. `src/hooks/rpg-session/useRpgRuntimeSession.ts`
4. `src/hooks/rpg-session/rpgSessionTypes.ts`
落地结果:
1. `useRpgSessionBootstrap` 现在直接承载世界选择、角色确认、新开局 `GameState` 初始化逻辑。
2. `useRpgSessionPersistence` 现在直接承载自动存档、继续游戏恢复、远端快照拉取与 runtime story 恢复刷新。
3. `useRpgRuntimeSession` 现在直接组合 bootstrap / persistence / story / combat / companion 等链路,成为主运行态装配入口。
## 2.2 旧 `useGame*` 文件已退化为兼容 façade
以下旧文件已不再承载主实现,只保留兼容导出:
1. `src/hooks/useGameFlow.ts`
2. `src/hooks/useGamePersistence.ts`
3. `src/hooks/useGameShellRuntime.ts`
当前策略:
1. 旧调用方仍可继续工作,避免影响并行工作包。
2. 正式主入口已经切到 `useRpgRuntimeSession`
3. `BottomTab` 类型已从 `rpg-session` 域提供,避免继续绑在旧 hook 文件上。
## 2.3 snapshot / save archive client 已迁到新域
本轮已把快照与继续游戏归档的真实请求实现迁入:
1. `src/services/rpg-runtime/rpgRuntimeRequest.ts`
2. `src/services/rpg-runtime/rpgSnapshotClient.ts`
3. `src/services/rpg-entry/rpgProfileClient.ts`
落地结果:
1. `rpgSnapshotClient` 现在直接承载 `/api/runtime/save/snapshot` 的读取、写入、删除。
2. `rpgProfileClient` 现在直接承载设置、个人资料、浏览历史、继续游戏归档相关请求。
3. `storageService.ts` 已退化为兼容转发层,不再继续作为 snapshot / save archive 主实现落点。
## 2.4 主入口与直接类型依赖已切换
本轮已更新以下直接调用点:
1. `src/App.tsx` 改为直接使用 `useRpgRuntimeSession`
2. `src/components/game-shell/types.ts`
3. `src/components/game-shell/GameShellMainContent.tsx`
4. `src/components/game-shell/GameShellStoryPanels.tsx`
当前状态:
1. 运行态主入口已经不再直接依赖 `useGameShellRuntime`
2. `GameShell` 相关组件仍保持 UI 与结构不变,只调整了 session 域导入路径。
## 2.5 测试补齐
本轮补齐或切换了以下定向测试:
1. `src/hooks/runtimeAuthGuards.test.tsx`
2. `src/hooks/useGameFlow.customWorld.test.tsx`
3. `src/services/rpg-entry/rpgProfileClient.test.ts`
4. `src/services/rpg-runtime/rpgSnapshotClient.test.ts`
目的:
1. 覆盖 `rpg-session` 新 hook 的远端鉴权守卫行为。
2. 覆盖自定义世界进入世界后的 bootstrap 初始化结果。
3. 覆盖迁入新域后的 browse history / save archive / snapshot 路由请求。
## 3. 本次没有做的事
以下内容仍保持原状,留给后续工作包:
1. 没有拆 `AdventurePanel.tsx``GameShellRuntime.tsx``GameShellMainContent.tsx` 的内部 UI 组织。
2. 没有拆 `useStoryGeneration.ts``npcEncounterActions.ts``runtimeStoryCoordinator.ts` 的 runtime story 内部职责。
3. 没有修改后端 `storyActionService.ts``runtimeSession.ts` 或任何路由组织。
4. 没有修改平台入口/世界详情/选角页面的视觉结构、按钮位置、tab 组织和独立面板交互方式。
## 4. 验证与检查
本轮应执行并记录:
1. `npm run check:encoding`
2. `npx vitest run src/hooks/runtimeAuthGuards.test.tsx src/hooks/useGameFlow.customWorld.test.tsx src/services/rpg-entry/rpgProfileClient.test.ts src/services/rpg-runtime/rpgSnapshotClient.test.ts src/services/storageService.test.ts`
重点核查点:
1. 主入口是否已经切到 `useRpgRuntimeSession`
2. `useGameFlow.ts``useGamePersistence.ts``useGameShellRuntime.ts` 是否只剩兼容导出。
3. `storageService.ts` 是否已不再承载 snapshot / save archive 的真实实现。
4. 是否没有改动任何前端界面结构与交互表现。
## 5. 对后续工作包的直接收益
1. 工作包 B / D 可以继续消费 `rpg-session` 新域入口,而不必再从旧 `useGame*` 文件接主逻辑。
2. 工作包 E 可以只关注 runtime story 链,不必再同时承担 session / persistence 主实现迁移。
3. 后续清理旧命名时,可以直接删除 `useGame*` 兼容层,而不会再触发大规模逻辑回迁。

View File

@@ -0,0 +1,130 @@
# RPG 进入游戏与运行时链路重构工作包 D 落地记录
更新时间:`2026-04-21`
## 1. 本次目标
本次只落实 `RPG_ENTRY_RUNTIME_CHAIN_REFACTOR_EXECUTION_PLAN_2026-04-21.md` 中的 **工作包 D前端运行态 shell 与面板拆分**,严格遵守以下边界:
1.`GameShellRuntime.tsx``GameShellMainContent.tsx``GameShellStoryPanels.tsx``AdventurePanel.tsx` 的真实实现迁入 `rpg-runtime-shell``rpg-runtime-panels`
2.`GameShell*``AdventurePanel` 保留兼容桥接,不在这一轮硬删旧路径。
3. 不改平台入口链、不改 runtime story 后端协议、不改任何前端交互界面设计。
4. 冒险主面板只做最小必要的 section 拆分,不额外扩散成更多与工作包 D 无关的重构。
## 2. 本次已落地内容
## 2.1 RPG 运行态 shell 已承接真实实现
以下文件已从 façade 升级为真实实现:
1. `src/components/rpg-runtime-shell/RpgRuntimeShell.tsx`
2. `src/components/rpg-runtime-shell/RpgRuntimeStageRouter.tsx`
3. `src/components/rpg-runtime-shell/RpgRuntimeOverlayHost.tsx`
4. `src/components/rpg-runtime-shell/useRpgRuntimeShellViewModel.ts`
5. `src/components/rpg-runtime-shell/useRpgRuntimeOverlayState.ts`
6. `src/components/rpg-runtime-shell/types.ts`
本轮完成的真实迁移包括:
1. `App.tsx` 主入口已经改为直接挂载 `RpgRuntimeShell`,不再把旧 `GameShellRuntime` 当作真实主入口。
2. `RpgRuntimeShell` 已承接运行态总外壳、画布舞台、主阶段路由、overlay host 装配。
3. `RpgRuntimeStageRouter` 已承接平台入口 / 角色选择 / 冒险运行态三阶段切换。
4. `RpgRuntimeOverlayHost` 已承接角色面板浮层、背包浮层、冒险实体详情、营地、地图、角色聊天与 NPC 交互弹层。
5. `useRpgRuntimeShellViewModel` 已承接运行态 overlay 状态、过场可见态、统计数据和 scene transition choice 包装。
6. `useRpgRuntimeOverlayState` 已把旧 `useGameShellViewModel` 的壳层状态迁入 RPG 域命名。
## 2.2 RPG 主面板路由与冒险主面板已承接真实实现
以下文件已从 façade 升级为真实实现:
1. `src/components/rpg-runtime-panels/RpgRuntimePanelRouter.tsx`
2. `src/components/rpg-runtime-panels/RpgAdventurePanel.tsx`
本轮完成的真实迁移包括:
1. `RpgRuntimePanelRouter` 已承接冒险 / 角色 / 背包三主标签切换。
2. `RpgAdventurePanel` 已成为真实冒险主面板实现,不再只是桥接旧 `AdventurePanel`
3. 冒险主面板按执行计划要求显式拆成了三个主 section
- story section剧情展示区与对话流
- choice section按钮区、快捷入口、NPC 输入框
- overlay section任务/设置/统计/奖励等独立面板挂载
4. 拆分后保持了原有 UI 结构、按钮位置、浮层方式和交互顺序不变。
5. 同时修正了冒险面板测试里要求隐藏的说明文本,避免迁移后把 `detailText` 重新暴露到 UI 中。
## 2.3 旧热点文件已降级为兼容桥接层
以下旧热点文件已不再承载真实实现,只保留兼容入口:
1. `src/components/game-shell/GameShellRuntime.tsx`
2. `src/components/game-shell/GameShellMainContent.tsx`
3. `src/components/game-shell/GameShellStoryPanels.tsx`
4. `src/components/game-shell/GameShellOverlays.tsx`
5. `src/components/AdventurePanel.tsx`
6. `src/components/game-shell/useGameShellRuntimeViewModel.ts`
7. `src/components/game-shell/useGameShellViewModel.ts`
8. `src/components/game-shell/types.ts`
当前策略:
1.`GameShell*` 路径继续可被现有调用与测试引用。
2. 旧 hook / type 文件继续对外提供兼容别名,避免并行工作流马上失效。
3. 真实运行态主链已经切到 `rpg-runtime-shell``rpg-runtime-panels`,旧热点不再继续扩大职责。
## 2.4 为兼容迁移补齐的新类型出口
为了避免本轮迁移造成旧桥接和 barrel 断裂,本轮额外补齐了以下类型出口:
1. `RpgRuntimeShellProps`
2. `RpgRuntimeShellViewModelResult`
3. `RpgEntryHomeViewProps`
4. `RpgEntryWorldDetailViewProps`
这些改动只用于保证工作包 D 新目录可以作为真实调用入口和兼容桥的稳定目标,不属于额外功能开发。
## 3. 本次刻意未做的事
以下内容明确保持到其他工作包,不在本轮越界处理:
1. 没有改平台入口链编排;`rpg-entry` 的真实拆分仍属于工作包 B。
2. 没有改 session/bootstrap/persistence 语义;这部分仍属于工作包 C。
3. 没有改 runtime story hooks、NPC 交互主链与后端协议;这部分仍属于工作包 E 及后续后端工作包。
4. 没有重做 AdventurePanel 内部更细粒度的卡片/子面板组件树,只做执行计划明确要求的三段 section 拆分。
5. 没有删除旧 `GameShell*` 文件,只把它们降级为兼容桥,避免影响并行工作流。
6. 没有调整任何用户可见的布局、按钮位置、tab 组织或弹窗/独立面板出现方式。
## 4. 验证结果
本轮已完成以下验证:
1. `npm run check:encoding`
2. `npx vitest run src/components/AdventurePanel.test.tsx src/components/AdventurePanel.npcChat.test.tsx src/components/game-shell/useGameShellRuntimeViewModel.test.ts src/components/CustomWorldEntityEditorModal.test.tsx`
3. 针对本工作包改动路径执行 `tsc` 定向筛查
验证结果:
1. 编码检查通过。
2. 上述 4 组定向测试全部通过。
3. 针对本轮改动路径的类型筛查无新增报错。
4. 全量 `tsc` 仍然存在其他工作流文件的并行类型噪音,但未命中本轮工作包 D 改动文件。
## 5. 对工作包 D 完成度的复核
对照执行计划中的工作包 D 目标,本轮已完成:
1. 运行态 shell 的真实迁移。
2. 主阶段路由器的真实迁移。
3. 主面板路由器的真实迁移。
4. 冒险主面板的真实迁移。
5. 冒险主面板按 section 显式分段。
6. 旧热点文件降级为兼容桥。
仍刻意保留、但不属于遗漏的部分:
1. 更细粒度的 AdventurePanel 子组件继续保留在单文件内部。
2. 旧文件兼容导出尚未清理。
3. 运行态 story hooks / gateway 仍在工作包 E 路径中继续演进。
4. 本轮复核已补齐一个收口遗漏:`rpg-runtime-shell` / `rpg-runtime-panels` 真实实现所依赖的 story UI 类型,现已直接从 `hooks/rpg-runtime-story` 导入,不再反向依赖旧 `useStoryGeneration.ts` 兼容入口。
结论:
**工作包 D 在“不改 UI、不改后端协议、禁止过度开发”的前提下已经完整落地当前没有遗漏的必做项。**

View File

@@ -0,0 +1,112 @@
# RPG 进入游戏与运行时链路重构工作包 E 落地记录
更新时间:`2026-04-21`
## 1. 本次目标
本次只落实 `RPG_ENTRY_RUNTIME_CHAIN_REFACTOR_EXECUTION_PLAN_2026-04-21.md` 中的 **工作包 E前端 runtime story 与 NPC 交互链拆分**,严格遵守以下边界:
1. 把前端 runtime story 主链真实迁到 `src/hooks/rpg-runtime-story/``src/services/rpg-runtime/`
2.`useStoryGeneration.ts``useStoryRuntimeController.ts``useStoryFlowCoordinator.ts``useStoryGoalSessionCoordinator.ts``useStoryInteractionCoordinator.ts``npcEncounterActions.ts``runtimeStoryCoordinator.ts``runtimeStoryService.ts` 的正式实现从旧命名入口迁出。
3. 保留旧 `story/*``runtimeStoryService.ts` 兼容导出,避免误伤其他并行工作包。
4. 不改任何前端交互界面设计,不改后端动作语义,不新增玩法。
## 2. 本次已落地内容
## 2.1 RPG runtime story 主链真实迁移
已把以下真实实现迁入 RPG 域目录:
1. `src/hooks/rpg-runtime-story/useRpgRuntimeStory.ts`
2. `src/hooks/rpg-runtime-story/useRpgRuntimeStoryController.ts`
3. `src/hooks/rpg-runtime-story/useRpgRuntimeStoryFlow.ts`
4. `src/hooks/rpg-runtime-story/useRpgRuntimeStoryState.ts`
5. `src/hooks/rpg-runtime-story/useRpgRuntimeInteractionFlow.ts`
6. `src/hooks/rpg-runtime-story/useRpgRuntimeNpcInteraction.ts`
7. `src/hooks/rpg-runtime-story/rpgRuntimeStoryGateway.ts`
本轮完成后:
1. `useRpgRuntimeStory` 已成为前端 runtime story 顶层装配入口,不再只是 façade。
2. `useRpgRuntimeStoryController` 负责 story 状态、AI 请求与提交动作。
3. `useRpgRuntimeStoryFlow` 负责 option 展示、交互流和 story/session 状态流收口。
4. `useRpgRuntimeStoryState` 负责 reset、hydrate、地图跳转与 quest UI 收口。
5. `useRpgRuntimeInteractionFlow` 负责宝箱、背包、NPC、story choice 正式分发。
6. `useRpgRuntimeNpcInteraction` 负责 NPC 对话、待接委托、战斗后续对话重开与服务端 NPC 动作派发。
7. `rpgRuntimeStoryGateway` 负责 option catalog 拉取、继续游戏恢复与服务端 runtime choice 结算。
## 2.2 runtime story client 真实迁移
已把 `/api/runtime/story` 的真实 client 实现迁入:
1. `src/services/rpg-runtime/rpgRuntimeStoryClient.ts`
本轮完成后:
1. `rpgRuntimeStoryClient` 不再桥接旧 `runtimeStoryService.ts`,而是成为真实请求实现。
2. `runtimeStoryService.ts` 降级为兼容导出层。
3. `getRpgRuntimeStoryState``resolveRpgRuntimeStoryAction``resolveRpgRuntimeStoryMoment``isRpgRuntimeServerFunctionId``shouldUseRpgRuntimeServerOptions` 已成为新域主能力。
## 2.3 主调用链已接回 RPG 域
本轮已把以下主链与 helper 入口切到 RPG 域实现:
1. `src/hooks/rpg-session/useRpgRuntimeSession.ts` 改为直接使用 `useRpgRuntimeStory`
2. `src/hooks/story/storyRequestCoordinator.ts` 改为消费 `loadRpgRuntimeOptionCatalog``shouldUseRpgRuntimeServerOptions`
3. `src/hooks/story/choiceActions.ts` 改为消费 `isRpgRuntimeServerFunctionId`
4. `src/hooks/story/storyChoiceRuntime.ts` 改为消费 `resolveRpgRuntimeChoice`
5. `src/hooks/story/inventoryActions.ts` 改为消费 `resolveRpgRuntimeChoice`
6. `src/hooks/story/npcInteraction.ts` 改为消费 `resolveRpgRuntimeChoice`
7. `src/hooks/useTreasureFlow.ts` 改为消费 `resolveRpgRuntimeChoice`
这意味着工作包 E 范围内的前端 runtime story 正式结算主链,已经不再依赖旧 `runtimeStoryService.ts` 与旧 `runtimeStoryCoordinator.ts` 的真实实现。
## 2.4 兼容层处理
以下旧文件现已降级为兼容导出,不再承载正式实现:
1. `src/hooks/useStoryGeneration.ts`
2. `src/hooks/story/useStoryRuntimeController.ts`
3. `src/hooks/story/useStoryFlowCoordinator.ts`
4. `src/hooks/story/useStoryGoalSessionCoordinator.ts`
5. `src/hooks/story/useStoryInteractionCoordinator.ts`
6. `src/hooks/story/npcEncounterActions.ts`
7. `src/hooks/story/runtimeStoryCoordinator.ts`
8. `src/services/runtimeStoryService.ts`
保留这些兼容层的原因:
1. 避免一次性改动所有 UI 与测试引用,降低并行工作冲突。
2. 让工作包 D、C 仍能通过旧导入继续编译,再逐步切到新域命名。
3. 明确“主实现已迁移、旧入口只兼容”的工程状态,避免后续继续扩大旧热点文件。
4. 复核时已补齐一批主链收口点:`RpgRuntimeStageRouter``RpgRuntimeOverlayHost``RpgRuntimePanelRouter``RpgAdventurePanel` 及其直连组件的 story UI 类型导入,现已直接消费 `hooks/rpg-runtime-story` 新域出口,旧 `useStoryGeneration.ts` 仅保留兼容角色。
## 3. 本次没有做的事
以下内容仍保持原状,留给其他工作包:
1. 没有修改 `AdventurePanel.tsx``GameShellMainContent.tsx``GameShellStoryPanels.tsx` 的 UI 结构。
2. 没有重做任何对话区、奖励区、输入区、overlay 的交互形式。
3. 没有修改后端 runtime story route / service / compiler 语义。
4. 没有清理所有旧 UI 组件对 `useStoryGeneration.ts` 的类型导入,本轮只保证旧入口已退化为兼容层。
5. 没有删除旧兼容文件,避免误伤其他并行中的工作包。
## 4. 验证结果
本轮已执行:
1. `npm run check:encoding`
2. `npx vitest run src/services/runtimeStoryService.test.ts src/hooks/story/runtimeStoryCoordinator.test.ts src/hooks/story/storyRequestCoordinator.test.ts src/hooks/story/choiceActions.test.ts src/hooks/story/storyChoiceRuntime.test.ts src/hooks/story/npcEncounterActions.test.ts`
验证结果:
1. 编码检查通过。
2. 工作包 E 直接相关的 6 个测试文件、44 条测试全部通过。
3. 运行时 option catalog、runtime choice、NPC 交互、待接委托、背包动作与本地/服务端分流逻辑均完成回归。
4. 复核补跑 `src/services/rpg-entry/rpgProfileClient.test.ts``src/services/rpg-runtime/rpgSnapshotClient.test.ts``src/services/runtimeStoryService.test.ts``src/components/game-shell/PreGameSelectionFlow.agent.interaction.test.tsx` 共 33 条定向测试,全部通过。
## 5. 对后续工作包的直接收益
1. 工作包 D 后续如果继续拆 runtime panel 与 adventure panel可以直接消费 `useRpgRuntimeStory``rpg-runtime-story` 域类型,不必再穿旧 `story` 目录。
2. 工作包 C 与 session/persistence 链路已经可以直接对接 `rpgRuntimeStoryGateway` 的继续游戏恢复能力。
3. 后续如果要清理旧 `useStoryGeneration.ts``runtimeStoryService.ts` 等旧命名入口,已经具备“新主链真实可用”的前提,不再是 façade 空壳状态。

View File

@@ -0,0 +1,121 @@
# RPG 进入游戏与运行时链路重构工作包 F 落地记录
更新时间:`2026-04-21`
## 1. 本次目标
本次落实 `RPG_ENTRY_RUNTIME_CHAIN_REFACTOR_EXECUTION_PLAN_2026-04-21.md` 中的 **工作包 F后端 route 边界拆分**,严格遵守以下边界:
1. 只调整后端路由组织、挂载边界与兼容 façade。
2. 不重写下游 `service` / `repository` 业务语义。
3. 不修改任何前端交互界面设计,不额外推进工作包 G/H。
## 2. 本次已落地内容
## 2.1 `app.ts` 显式挂载 RPG 域路由
已把 `server-node/src/app.ts` 中的 RPG 相关入口改成显式按域挂载:
1. `rpgProfile`:资料看板、浏览历史、设置
2. `rpgEntrySave`:快照读写、继续游戏归档列表与恢复
3. `rpgWorldLibrary`作品库、作品广场、works 列表
4. `rpgRuntimeStory``/api/runtime/story` 状态读取与动作结算
5. `rpgRuntimeAiAssist`runtime story 之外的 AI 辅助接口
当前策略:
1. 使用 `scopeToPrefixes(...)` 只匹配各自域前缀,避免新路由误拦截无关 `/api` 请求。
2. 所有线上接口路径保持不变,仍兼容现有前端与测试调用。
3. `routeVersion` 对新域挂载统一标记为 `2026-04-21`,与本轮路由重构窗口对齐。
## 2.2 新 `rpg-*` 路由从骨架升级为真实实现
已把以下骨架路由补成真实入口:
1. `server-node/src/routes/rpg-profile/rpgProfileRoutes.ts`
2. `server-node/src/routes/rpg-entry/rpgEntrySaveRoutes.ts`
3. `server-node/src/routes/rpg-entry/rpgWorldLibraryRoutes.ts`
4. `server-node/src/routes/rpg-runtime/rpgRuntimeStoryRoutes.ts`
5. `server-node/src/routes/rpg-runtime/rpgRuntimeAiAssistRoutes.ts`
本轮真实迁入的新边界包括:
1. `rpgProfileRoutes.ts`
- `/api/runtime/profile/dashboard`
- `/api/runtime/profile/wallet-ledger`
- `/api/runtime/profile/play-stats`
- `/api/runtime/profile/browse-history`
- 兼容路径 `/api/profile/...`
- `/api/runtime/settings`
2. `rpgEntrySaveRoutes.ts`
- `/api/runtime/save/snapshot`
- `/api/runtime/profile/save-archives`
- 兼容路径 `/api/profile/save-archives`
3. `rpgWorldLibraryRoutes.ts`
- `/api/runtime/custom-world-gallery`
- `/api/runtime/custom-world/works`
- `/api/runtime/custom-world-library`
- publish / unpublish / soft delete 等库操作
4. `rpgRuntimeStoryRoutes.ts`
- `/api/runtime/story/actions/resolve`
- `/api/runtime/story/state/:sessionId`
- `/api/runtime/story/state/resolve`
5. `rpgRuntimeAiAssistRoutes.ts`
- runtime story 之外的 LLM proxy、cover/scene 资产、custom world profile 生成、角色/NPC chat、runtime item、quest 生成、`/api/ws/health`
## 2.3 旧大路由退化为兼容 façade
已把旧入口降级为兼容层:
1. `server-node/src/modules/story/storyActionRoutes.ts`
- 不再自己承载 schema 与 handler
- 直接桥接到 `createRpgRuntimeStoryRoutes(context)`
2. `server-node/src/routes/runtimeRoutes.ts`
- 不再继续承载 profile / save / world library / runtime ai assist / runtime story
- 当前只保留旧 `customWorldAgent` 挂载与兼容 `ws/health`
这样处理后:
1. `runtimeRoutes.ts` 不再是 RPG 主链真实入口。
2. `storyActionRoutes.ts` 不再是 runtime story 主链真实实现。
3. 后续工作包 G/H 可以基于新 `rpg-*` 路由继续细化,而不用再穿透旧大文件。
## 3. 本次没有做的事
以下内容仍保持原状,不属于工作包 F 本轮范围:
1. 没有拆 `storyActionService.ts``runtimeSession.ts` 的内部职责。
2. 没有继续改 `npcInteractionService.ts``questStoryActionService.ts` 的业务实现。
3. 没有推进仓储、共享契约的进一步物理拆分。
4. 没有改变任何前端页面结构、交互路径、面板形式或 UI 文案。
## 4. 验证结果
本次已执行:
1. `npm run check:encoding`
2. `node --test --test-concurrency=1 --import tsx "src/routes/rpgRouteBoundaries.test.ts"`
验证结果:
1. 编码检查通过。
2. 新增定向测试 `server-node/src/routes/rpgRouteBoundaries.test.ts` 4 项全部通过。
3. 已覆盖以下工作包 F 验收重点:
- `rpgProfile` 新路径与 legacy 路径兼容
- `rpgEntrySave` 快照/归档路由可用
- `rpgWorldLibrary` / `gallery` / `works` 新边界可用
- `rpgRuntimeStory` 新边界与旧 façade 兼容可用
同时确认:
1. 全量 `npx tsc -p server-node/tsconfig.json --noEmit` 仍被仓库中既有跨模块类型问题阻塞。
2. 这些错误覆盖 auth、inventory、custom world、scene image 等多个既有热点,与本次工作包 F 新增路由边界并非同一问题面。
## 5. 对执行计划的对齐结论
对照执行计划中的工作包 F
1. `app.ts` 已能一眼看出 `rpgProfile``rpgEntry``rpgRuntimeStory``rpgRuntimeAiAssist` 的挂载边界。
2. `runtimeRoutes.ts` 已退化为兼容入口,不再承接 RPG 主链的大杂糅职责。
3. `storyActionRoutes.ts` 已退化为兼容 façade真实 runtime story 路由落点转入 `server-node/src/routes/rpg-runtime/rpgRuntimeStoryRoutes.ts`
4. 本轮没有越过工作包 F 去重写下游 service 语义,满足“禁止过度开发”的约束。

View File

@@ -0,0 +1,96 @@
# RPG 进入游戏与运行时链路重构工作包 G 落地记录
更新时间:`2026-04-21`
## 1. 本次目标
本次只落实 `RPG_ENTRY_RUNTIME_CHAIN_REFACTOR_EXECUTION_PLAN_2026-04-21.md` 中的 **工作包 G后端 runtime session / action service 拆分**,严格遵守以下边界:
1.`storyActionService.ts``runtimeSession.ts` 的真实实现迁入 `server-node/src/modules/rpg-runtime-story/` 新域目录。
2. 把 runtime action 主链依赖的 session 原语、option 编译、snapshot sync、story state 读取按职责落到新文件。
3.`server-node/src/modules/story/` 热点文件只保留兼容导出,不再承载真实实现。
4. 不改前端入口与 UI不改路由协议语义不做仓储拆分。
## 2. 本次已落地内容
## 2.1 `runtimeSession.ts` 真实实现已迁入 `rpg-runtime-story`
本轮已把旧 `server-node/src/modules/story/runtimeSession.ts` 的真实实现迁入:
1. `server-node/src/modules/rpg-runtime-story/RpgRuntimeSessionDomain.ts`
2. `server-node/src/modules/rpg-runtime-story/RpgRuntimeSessionLoader.ts`
3. `server-node/src/modules/rpg-runtime-story/RpgRuntimeSessionPrimitives.ts`
4. `server-node/src/modules/rpg-runtime-story/RpgRuntimeOptionCompiler.ts`
5. `server-node/src/modules/rpg-runtime-story/RpgRuntimeSnapshotSync.ts`
6. `server-node/src/modules/rpg-runtime-story/RpgRuntimeStoryPresentationCompiler.ts`
落地结果:
1. `loadRuntimeSession(...)``buildAvailableOptions(...)``buildRuntimeViewModel(...)``syncRawGameState(...)``replaceRuntimeSessionRawGameState(...)` 已有新域真实落点。
2. `appendStoryHistory(...)``getEncounterNpcState(...)``setEncounterNpcState(...)``MAX_TASK5_COMPANIONS``TASK6_DEFERRED_FUNCTION_IDS` 等运行时原语已通过 `RpgRuntimeSessionPrimitives.ts` 对外输出。
3.`server-node/src/modules/story/runtimeSession.ts` 已退化为兼容层,不再承载主实现。
## 2.2 `storyActionService.ts` 真实实现已迁入 `rpg-runtime-story`
本轮已把旧 `server-node/src/modules/story/storyActionService.ts` 的真实实现迁入:
1. `server-node/src/modules/rpg-runtime-story/RpgRuntimeStoryActionDomain.ts`
2. `server-node/src/modules/rpg-runtime-story/RpgRuntimeStoryActionService.ts`
3. `server-node/src/modules/rpg-runtime-story/RpgRuntimeStoryStateService.ts`
落地结果:
1. `resolveRuntimeStoryAction(...)` 已从新域动作服务入口导出。
2. `getRuntimeStoryState(...)` 已从新域状态服务入口导出。
3.`server-node/src/modules/story/storyActionService.ts` 已退化为兼容转发层。
## 2.3 runtime action 主链下游依赖已切到新域
本轮已把以下直接依赖 `runtimeSession.ts` 的后端模块切到 `rpg-runtime-story` 新域入口:
1. `server-node/src/modules/combat/combatResolutionService.ts`
2. `server-node/src/modules/npc/npcInteractionService.ts`
3. `server-node/src/modules/inventory/inventoryStoryActionService.ts`
4. `server-node/src/modules/inventory/npcInventoryStoryActionService.ts`
5. `server-node/src/modules/quest/questRuntimeSignalService.ts`
6. `server-node/src/modules/quest/questStoryActionService.ts`
7. `server-node/src/modules/runtime-item/treasureStoryActionService.ts`
8. `server-node/src/modules/story/storyActionRoutes.ts`
9. `server-node/src/modules/story/runtimeSession.test.ts`
当前状态:
1. runtime action 执行链已经直接消费 `rpg-runtime-story` 域入口,不再把旧 `modules/story/` 视为真实主实现。
2. `storyActionRoutes.ts` 已直接从新域读取动作服务与状态服务。
3. `runtimeSession.test.ts` 已直接验证新域编译链与 legacy currentStory 展示投影。
## 3. 本次没有做的事
以下内容仍保持原状,留给后续工作包或后续阶段:
1. 没有修改任何前端入口、前端运行态 UI、前端交互设计。
2. 没有拆 `server-node/src/routes/runtimeRoutes.ts``server-node/src/app.ts` 的路由挂载组织,这属于工作包 F。
3. 没有拆 `runtimeRepository.ts``runtimeSnapshotHydration.ts` 与 shared contract这属于工作包 H。
4. 没有把 `RpgRuntimeStoryActionDomain.ts` 再继续切成更细颗粒度的真实实现文件;本轮只做到工作包 G 要求的“目录化拆开 + 旧热点降级”。
5. 没有改动任何动作语义、返回协议或 LLM 编排行为。
## 4. 验证与检查
本轮已执行:
1. `npm run check:encoding`
2. `npx tsx --test server-node/src/modules/story/runtimeSession.test.ts server-node/src/modules/story/storyActionRoutes.test.ts`
3. `npm --prefix server-node run build`
验证结果:
1. 编码检查通过。
2. 与 runtime session / runtime story action 直接相关的后端定向测试 `20` 项全部通过。
3. `server-node` 构建通过。
4.`server-node/src/modules/story/runtimeSession.ts``server-node/src/modules/story/storyActionService.ts` 已只剩兼容导出,没有残留真实主实现。
## 5. 对后续工作的直接收益
1. 工作包 F 后续继续做 route 边界拆分时,可以直接把 runtime story 路由稳定挂到 `rpg-runtime-story` 新域入口。
2. 工作包 H 后续拆仓储、shared contract 和测试基建时,可以围绕 `rpg-runtime-story` 新目录继续收口,而不必再穿透旧热点文件。
3. 后续如果继续细拆 runtime story 主链,可以在新域内部继续物理拆分,而不会重新把真实实现塞回 `modules/story/`

View File

@@ -0,0 +1,90 @@
# RPG 进入游戏与运行时链路重构工作包 H 落地记录
更新时间:`2026-04-21`
## 1. 本次目标
本次只落实 `RPG_ENTRY_RUNTIME_CHAIN_REFACTOR_EXECUTION_PLAN_2026-04-21.md` 中的 **工作包 H仓储、契约与测试基建**,严格遵守以下边界:
1. 只补仓储、shared contract、fixture 与测试基建。
2. 不修改前端 UI不改页面交互设计。
3. 不重写 route 层与 runtime story 主流程逻辑。
## 2. 本次已落地内容
## 2.1 仓储按 RPG 域补齐独立入口
本次补齐并收口了工作包 H 目标中的按域仓储入口:
1. `server-node/src/repositories/rpg-runtime/RpgRuntimeSnapshotRepository.ts`
2. `server-node/src/repositories/rpg-entry/RpgSaveArchiveRepository.ts`
3. `server-node/src/repositories/rpg-entry/RpgWorldLibraryRepository.ts`
4. `server-node/src/repositories/rpg-profile/RpgProfileDashboardRepository.ts`
5. `server-node/src/repositories/rpg-profile/RpgBrowseHistoryRepository.ts`
本轮策略仍保持最小侵入:
1. 新仓储继续委托 `runtimeRepository.ts` 提供真实读写。
2. `RpgProfileDashboardRepository` 只保留资料看板、设置、钱包、游玩统计职责。
3. 浏览历史读写从资料仓储中抽离到 `RpgBrowseHistoryRepository`,与执行方案的目标拆分保持一致。
## 2.2 shared runtime contract 按领域拆分并保留兼容 façade
已把原 `packages/shared/src/contracts/story.ts` 中的 RPG runtime shared contract 拆分为:
1. `packages/shared/src/contracts/rpgRuntimeStoryAction.ts`
2. `packages/shared/src/contracts/rpgRuntimeStoryState.ts`
3. `packages/shared/src/contracts/rpgRuntimeChat.ts`
4. `packages/shared/src/contracts/rpgRuntimeQuestAssist.ts`
兼容策略:
1. `story.ts` 退化为 façade只做分文件 re-export。
2. 现有前后端调用方仍可继续从 `contracts/story` 取用类型与常量,不要求本轮同步迁移所有 import。
3. runtime story 主链契约与 chat / quest assist / runtime item 辅助契约已经具备独立演进落点。
## 2.3 测试基建补齐
本次补充并核对了工作包 H 范围内的测试:
1. `server-node/src/modules/runtime/runtimeSnapshotHydration.test.ts`
用于覆盖 snapshot 归一化、恢复继续游戏时的默认值修复与旧存档兼容。
2. `server-node/src/repositories/rpg-profile/RpgProfileRepositories.test.ts`
用于确认资料看板仓储与浏览历史仓储的职责边界已经分离。
3. `server-node/src/repositories/rpg-entry/RpgEntryRepositories.test.ts`
用于确认 continue game 归档仓储与作品库仓储已经独立命名并保持职责边界。
4. `server-node/src/repositories/rpg-runtime/RpgRuntimeSnapshotRepository.test.ts`
用于确认 snapshot 读写职责已经有独立仓储入口。
5. `packages/shared/src/contracts/rpgRuntimeContracts.test.ts`
用于确认 `story.ts` façade 在拆分后仍保持旧入口兼容。
## 3. 本次没有做的事
以下内容仍保持原状,留给其他工作包或第三批统一收口:
1. 没有把前后端所有 `contracts/story` import 全量改写到新分文件,避免与并行工作包产生无谓冲突。
2. 没有改 `server-node/src/routes/runtimeRoutes.ts``server-node/src/modules/story/storyActionService.ts``server-node/src/modules/story/runtimeSession.ts` 的真实逻辑。
3. 没有改前端 continue game、角色选择、冒险运行态的界面与交互。
## 4. 验证结果
本轮执行并通过:
1. `npm run check:encoding`
2. `npx vitest run packages/shared/src/contracts/rpgRuntimeContracts.test.ts`
3. `node --test --test-concurrency=1 --import tsx src/modules/runtime/runtimeSnapshotHydration.test.ts src/repositories/rpg-profile/RpgProfileRepositories.test.ts src/repositories/rpg-entry/RpgEntryRepositories.test.ts src/repositories/rpg-runtime/RpgRuntimeSnapshotRepository.test.ts`
说明:
1. 工作树当前存在其他并行修改与冲突文件,因此没有把与工作包 H 无关的全量问题一并处理。
2. 本轮验证只覆盖工作包 H 自身改动与其直接依赖,避免过度开发。
## 5. 与执行方案的对照结论
对照 `RPG_ENTRY_RUNTIME_CHAIN_REFACTOR_EXECUTION_PLAN_2026-04-21.md` 中工作包 H 的目标,本轮已完成:
1. snapshot / profile / library / browse history 仓储独立命名入口补齐。
2. `story.ts` shared contract 已拆为 runtime story action、runtime story state、runtime chat、runtime quest assist 四个独立文件。
3. runtime story / snapshot / continue game 相关测试已补齐到可直接回归的最小闭环。
当前未额外扩张到主流程迁移、route 改写、UI 调整,符合“禁止过度开发”的执行要求。

View File

@@ -1,538 +1,12 @@
export type CustomWorldWorkStatus = 'draft' | 'published';
export type CustomWorldWorkSource = 'agent_session' | 'published_profile';
/**
* 兼容出口:
* 当前仓库仍有大量旧 customWorld 命名导入,这个文件继续作为过渡层保留。
* 工作包 H 完成后,真实类型定义已经迁移到 rpg* 契约文件中;这里仅聚合旧命名分文件。
*/
export interface WorldPromiseValue {
hook: string;
differentiator: string;
desiredExperience: string;
}
export interface PlayerFantasyValue {
playerRole: string;
corePursuit: string;
fearOfLoss: string;
}
export interface ThemeBoundaryValue {
toneKeywords: string[];
aestheticDirectives: string[];
forbiddenDirectives: string[];
}
export interface PlayerEntryPointValue {
openingIdentity: string;
openingProblem: string;
entryMotivation: string;
}
export interface CoreConflictValue {
surfaceConflicts: string[];
hiddenCrisis: string;
firstTouchedConflict: string;
}
export interface KeyRelationshipValue {
pairs: string;
relationshipType: string;
secretOrCost: string;
}
export interface HiddenLineValue {
hiddenTruths: string[];
misdirectionHints: string[];
revealPacing: string;
}
export interface IconicElementValue {
iconicMotifs: string[];
institutionsOrArtifacts: string[];
hardRules: string[];
}
export interface EightAnchorContent {
worldPromise: WorldPromiseValue | null;
playerFantasy: PlayerFantasyValue | null;
themeBoundary: ThemeBoundaryValue | null;
playerEntryPoint: PlayerEntryPointValue | null;
coreConflict: CoreConflictValue | null;
keyRelationships: KeyRelationshipValue[];
hiddenLines: HiddenLineValue | null;
iconicElements: IconicElementValue | null;
}
export interface CustomWorldWorkSummary {
workId: string;
sourceType: CustomWorldWorkSource;
status: CustomWorldWorkStatus;
title: string;
subtitle: string;
summary: string;
coverImageSrc?: string | null;
coverRenderMode?: 'image' | 'scene_with_roles';
coverCharacterImageSrcs?: string[];
updatedAt: string;
publishedAt?: string | null;
stage?: string | null;
stageLabel?: string | null;
playableNpcCount: number;
landmarkCount: number;
roleVisualReadyCount?: number;
roleAnimationReadyCount?: number;
roleAssetSummaryLabel?: string | null;
sessionId?: string | null;
profileId?: string | null;
canResume: boolean;
canEnterWorld: boolean;
}
export interface CreatorIntentReadiness {
isReady: boolean;
completedKeys: string[];
missingKeys: string[];
}
export interface CustomWorldPendingClarification {
id: string;
label: string;
question: string;
targetKey:
| 'world_hook'
| 'player_premise'
| 'theme_and_tone'
| 'core_conflict'
| 'relationship_seed'
| 'iconic_element';
priority: number;
answer?: string;
}
export type CustomWorldAgentStage =
| 'collecting_intent'
| 'clarifying'
| 'foundation_review'
| 'object_refining'
| 'visual_refining'
| 'long_tail_review'
| 'ready_to_publish'
| 'published'
| 'error';
export type CustomWorldAgentMessageRole = 'user' | 'assistant' | 'system';
export type CustomWorldAgentMessageKind =
| 'chat'
| 'clarification'
| 'summary'
| 'checkpoint'
| 'warning'
| 'action_result';
export interface CustomWorldAgentMessage {
id: string;
role: CustomWorldAgentMessageRole;
kind: CustomWorldAgentMessageKind;
text: string;
createdAt: string;
relatedOperationId?: string | null;
}
export type CustomWorldDraftCardKind =
| 'world'
| 'camp'
| 'faction'
| 'character'
| 'landmark'
| 'thread'
| 'chapter'
| 'scene_chapter'
| 'carrier'
| 'sidequest_seed';
export type CustomWorldDraftCardStatus =
| 'suggested'
| 'confirmed'
| 'locked'
| 'warning';
export interface CustomWorldDraftCardSummary {
id: string;
kind: CustomWorldDraftCardKind;
title: string;
subtitle: string;
summary: string;
status: CustomWorldDraftCardStatus;
linkedIds: string[];
warningCount: number;
assetStatus?: CustomWorldRoleAssetStatus | null;
assetStatusLabel?: string | null;
}
export interface CustomWorldDraftCardDetailSection {
id: string;
label: string;
value: string;
}
export interface CustomWorldFoundationDraftFaction {
id: string;
name: string;
title?: string;
subtitle?: string;
publicGoal: string;
relatedConflict: string;
tension?: string;
playerRelation: string;
summary: string;
}
export interface CustomWorldFoundationDraftCharacter {
id: string;
name: string;
title: string;
role: string;
publicIdentity: string;
publicMask?: string;
currentPressure: string;
hiddenHook?: string;
relationToPlayer: string;
threadIds: string[];
summary: string;
skills?: Array<{
id: string;
name: string;
actionPreviewConfig?: Record<string, unknown> | null;
}>;
imageSrc?: string | null;
generatedVisualAssetId?: string | null;
generatedAnimationSetId?: string | null;
animationMap?: Record<string, unknown> | null;
}
export interface CustomWorldFoundationDraftLandmark {
id: string;
name: string;
description?: string;
purpose: string;
mood: string;
importance: string;
secret?: string;
dangerLevel?: string;
imageSrc?: string | null;
characterIds: string[];
threadIds: string[];
summary: string;
}
export interface CustomWorldFoundationDraftThread {
id: string;
title: string;
type: 'main' | 'hidden';
conflictType?: string;
conflict: string;
stakes?: string;
characterIds: string[];
landmarkIds: string[];
summary: string;
}
export interface CustomWorldFoundationDraftChapter {
id: string;
title: string;
openingEvent: string;
playerGoal: string;
characterIds: string[];
landmarkIds: string[];
understandingShift: string;
summary: string;
}
export interface CustomWorldFoundationDraftCamp {
id: string;
name: string;
description: string;
mood: string;
dangerLevel?: string;
imageSrc?: string | null;
summary: string;
}
export type CustomWorldSceneActStage =
| 'opening'
| 'expansion'
| 'turning_point'
| 'climax'
| 'aftermath';
export type CustomWorldSceneActAdvanceRule =
| 'after_primary_contact'
| 'after_active_step_complete'
| 'after_chapter_resolution';
export interface CustomWorldFoundationDraftSceneAct {
id: string;
title: string;
summary: string;
stageCoverage: CustomWorldSceneActStage[];
backgroundImageSrc?: string | null;
backgroundAssetId?: string | null;
encounterNpcIds: string[];
primaryNpcId: string;
linkedThreadIds: string[];
actGoal: string;
transitionHook: string;
advanceRule: CustomWorldSceneActAdvanceRule;
}
export interface CustomWorldFoundationDraftSceneChapter {
id: string;
sceneId: string;
sceneName: string;
title: string;
summary: string;
linkedThreadIds: string[];
linkedLandmarkIds: string[];
acts: CustomWorldFoundationDraftSceneAct[];
}
export interface CustomWorldFoundationDraftProfile {
name: string;
subtitle: string;
summary: string;
tone: string;
playerGoal: string;
majorFactions: string[];
coreConflicts: string[];
playableNpcs: CustomWorldFoundationDraftCharacter[];
storyNpcs: CustomWorldFoundationDraftCharacter[];
landmarks: CustomWorldFoundationDraftLandmark[];
camp?: CustomWorldFoundationDraftCamp | null;
themePack?: Record<string, unknown> | null;
storyGraph?: Record<string, unknown> | null;
factions: CustomWorldFoundationDraftFaction[];
threads: CustomWorldFoundationDraftThread[];
chapters: CustomWorldFoundationDraftChapter[];
sceneChapters: CustomWorldFoundationDraftSceneChapter[];
worldHook: string;
playerPremise: string;
openingSituation: string;
iconicElements: string[];
sourceAnchorSummary: string;
}
export interface CustomWorldFoundationDraftResult {
draftProfile: CustomWorldFoundationDraftProfile;
draftCards: CustomWorldDraftCardSummary[];
}
export interface CustomWorldDraftCardDetail {
id: string;
kind: CustomWorldDraftCardKind;
title: string;
sections: CustomWorldDraftCardDetailSection[];
linkedIds: string[];
locked: false;
editable: boolean;
editableSectionIds: string[];
warningMessages: string[];
assetStatus?: CustomWorldRoleAssetStatus | null;
assetStatusLabel?: string | null;
}
export interface CustomWorldSuggestedAction {
id: string;
type:
| 'request_summary'
| 'draft_foundation'
| 'refine_focus_target'
| 'lock_current_target'
| 'generate_role_assets'
| 'generate_scene_assets'
| 'expand_long_tail'
| 'publish_world';
label: string;
targetId?: string | null;
}
export type CustomWorldAssetPriorityTier = 'hero' | 'featured' | 'supporting';
export type CustomWorldRoleAssetStatus =
| 'missing'
| 'visual_ready'
| 'animations_ready'
| 'complete';
export interface CustomWorldRoleAssetSummary {
roleId: string;
roleName: string;
roleKind: 'playable' | 'story';
priorityTier: CustomWorldAssetPriorityTier;
portraitPath?: string | null;
generatedVisualAssetId?: string | null;
generatedAnimationSetId?: string | null;
status: CustomWorldRoleAssetStatus;
missingAnimations: string[];
nextPointCost: number;
}
export interface CustomWorldSceneAssetSummary {
sceneId: string;
sceneName: string;
actId?: string | null;
actTitle?: string | null;
imageSrc?: string | null;
assetId?: string | null;
status: 'missing' | 'ready';
nextPointCost: number;
}
export interface CustomWorldAssetCoverageSummary {
roleAssets: CustomWorldRoleAssetSummary[];
sceneAssets: CustomWorldSceneAssetSummary[];
allRoleAssetsReady: boolean;
allSceneAssetsReady: boolean;
}
export interface CustomWorldAgentSessionSnapshot {
sessionId: string;
currentTurn: number;
anchorContent: EightAnchorContent;
progressPercent: number;
lastAssistantReply: string | null;
stage: CustomWorldAgentStage;
focusCardId: string | null;
creatorIntent: Record<string, unknown> | null;
creatorIntentReadiness: CreatorIntentReadiness;
anchorPack: Record<string, unknown> | null;
lockState: Record<string, unknown> | null;
draftProfile: Record<string, unknown> | null;
messages: CustomWorldAgentMessage[];
draftCards: CustomWorldDraftCardSummary[];
pendingClarifications: CustomWorldPendingClarification[];
suggestedActions: CustomWorldSuggestedAction[];
recommendedReplies: string[];
qualityFindings: {
id: string;
severity: 'info' | 'warning' | 'blocker';
code: string;
targetId?: string | null;
message: string;
}[];
assetCoverage: CustomWorldAssetCoverageSummary;
updatedAt: string;
}
export type CustomWorldAgentOperationType =
| 'process_message'
| 'lock_cards'
| 'unlock_cards'
| 'regenerate_scope'
| 'draft_foundation'
| 'update_draft_card'
| 'sync_result_profile'
| 'generate_characters'
| 'generate_landmarks'
| 'generate_role_assets'
| 'sync_role_assets'
| 'generate_scene_assets'
| 'sync_scene_assets'
| 'expand_long_tail'
| 'publish_world'
| 'revert_checkpoint';
export type CustomWorldAgentOperationStatus =
| 'queued'
| 'running'
| 'completed'
| 'failed';
export interface CustomWorldAgentOperationRecord {
operationId: string;
type: CustomWorldAgentOperationType;
status: CustomWorldAgentOperationStatus;
phaseLabel: string;
phaseDetail: string;
progress: number;
error?: string | null;
}
export interface CreateCustomWorldAgentSessionRequest {
seedText?: string;
}
export interface CreateCustomWorldAgentSessionResponse {
session: CustomWorldAgentSessionSnapshot;
}
export interface SendCustomWorldAgentMessageRequest {
clientMessageId: string;
text: string;
quickFillRequested?: boolean;
focusCardId?: string | null;
selectedCardIds?: string[];
}
export interface SendCustomWorldAgentMessageResponse {
operation: CustomWorldAgentOperationRecord;
}
export type CustomWorldAgentActionRequest =
| { action: 'lock_cards'; cardIds: string[] }
| { action: 'unlock_cards'; cardIds: string[] }
| {
action: 'regenerate_scope';
scope:
| 'focus_card'
| 'long_tail_npcs'
| 'long_tail_landmarks'
| 'sidequest_seeds'
| 'role_assets'
| 'scene_assets';
targetCardId?: string | null;
}
| { action: 'draft_foundation' }
| {
action: 'update_draft_card';
cardId: string;
sections: Array<{
sectionId: string;
value: string;
}>;
}
| {
action: 'sync_result_profile';
profile: Record<string, unknown>;
}
| {
action: 'generate_characters';
count: number;
promptText?: string | null;
anchorCardIds?: string[];
}
| {
action: 'generate_landmarks';
count: number;
promptText?: string | null;
anchorCardIds?: string[];
}
| { action: 'generate_role_assets'; roleIds: string[] }
| {
action: 'sync_role_assets';
roleId: string;
portraitPath: string;
generatedVisualAssetId: string;
generatedAnimationSetId?: string | null;
animationMap?: Record<string, unknown> | null;
}
| { action: 'publish_world' };
export interface CustomWorldAgentActionResponse {
operation: CustomWorldAgentOperationRecord;
}
export interface GetCustomWorldAgentCardDetailResponse {
card: CustomWorldDraftCardDetail;
}
export interface ListCustomWorldWorksResponse {
items: CustomWorldWorkSummary[];
}
export type * from './customWorldAgentAnchors';
export type * from './customWorldAgentDraft';
export type * from './customWorldAgentActions';
export type * from './customWorldAgentSession';
export type * from './customWorldResultPreview';
export type * from './customWorldWorkSummary';

View File

@@ -0,0 +1,14 @@
/**
* 旧 custom world 动作契约兼容出口。
* 后续若逐步迁移旧代码,建议直接改用 rpgAgentActions.ts。
*/
export type {
RpgAgentActionRequest as CustomWorldAgentActionRequest,
RpgAgentActionResponse as CustomWorldAgentActionResponse,
RpgAgentOperationRecord as CustomWorldAgentOperationRecord,
RpgAgentOperationStatus as CustomWorldAgentOperationStatus,
RpgAgentOperationType as CustomWorldAgentOperationType,
RpgAgentSupportedAction as CustomWorldSupportedAction,
RpgAgentSuggestedAction as CustomWorldSuggestedAction,
} from './rpgAgentActions';

View File

@@ -0,0 +1,16 @@
/**
* 旧 custom world 八锚点兼容出口。
* 这里只保留旧命名到 RPG 创作域新契约的映射,便于旧导入渐进迁移。
*/
export type {
RpgCreationAnchorContent as EightAnchorContent,
RpgCreationCoreConflictValue as CoreConflictValue,
RpgCreationHiddenLineValue as HiddenLineValue,
RpgCreationIconicElementValue as IconicElementValue,
RpgCreationKeyRelationshipValue as KeyRelationshipValue,
RpgCreationPlayerEntryPointValue as PlayerEntryPointValue,
RpgCreationPlayerFantasyValue as PlayerFantasyValue,
RpgCreationThemeBoundaryValue as ThemeBoundaryValue,
RpgCreationWorldPromiseValue as WorldPromiseValue,
} from './rpgAgentAnchors';

View File

@@ -0,0 +1,29 @@
/**
* 旧 custom world 草稿契约兼容出口。
* 工作包 H 完成后,真实定义已经迁到 rpgAgentDraft.ts这里只负责旧命名映射。
*/
export type {
RpgAgentAssetCoverageSummary as CustomWorldAssetCoverageSummary,
RpgAgentAssetPriorityTier as CustomWorldAssetPriorityTier,
RpgAgentDraftCardDetail as CustomWorldDraftCardDetail,
RpgAgentDraftCardDetailSection as CustomWorldDraftCardDetailSection,
RpgAgentDraftCardKind as CustomWorldDraftCardKind,
RpgAgentDraftCardStatus as CustomWorldDraftCardStatus,
RpgAgentDraftCardSummary as CustomWorldDraftCardSummary,
RpgAgentFoundationDraftCamp as CustomWorldFoundationDraftCamp,
RpgAgentFoundationDraftChapter as CustomWorldFoundationDraftChapter,
RpgAgentFoundationDraftCharacter as CustomWorldFoundationDraftCharacter,
RpgAgentFoundationDraftFaction as CustomWorldFoundationDraftFaction,
RpgAgentFoundationDraftLandmark as CustomWorldFoundationDraftLandmark,
RpgAgentFoundationDraftProfile as CustomWorldFoundationDraftProfile,
RpgAgentFoundationDraftResult as CustomWorldFoundationDraftResult,
RpgAgentFoundationDraftSceneAct as CustomWorldFoundationDraftSceneAct,
RpgAgentFoundationDraftSceneChapter as CustomWorldFoundationDraftSceneChapter,
RpgAgentFoundationDraftThread as CustomWorldFoundationDraftThread,
RpgAgentRoleAssetStatus as CustomWorldRoleAssetStatus,
RpgAgentRoleAssetSummary as CustomWorldRoleAssetSummary,
RpgAgentSceneActAdvanceRule as CustomWorldSceneActAdvanceRule,
RpgAgentSceneActStage as CustomWorldSceneActStage,
RpgAgentSceneAssetSummary as CustomWorldSceneAssetSummary,
} from './rpgAgentDraft';

View File

@@ -0,0 +1,20 @@
/**
* 旧 custom world 会话契约兼容出口。
* 这一层只做命名映射,不再承担 session 真相源结构定义。
*/
export type {
CreateRpgAgentSessionRequest as CreateCustomWorldAgentSessionRequest,
CreateRpgAgentSessionResponse as CreateCustomWorldAgentSessionResponse,
GetRpgAgentCardDetailResponse as GetCustomWorldAgentCardDetailResponse,
RpgAgentMessage as CustomWorldAgentMessage,
RpgAgentMessageKind as CustomWorldAgentMessageKind,
RpgAgentMessageRole as CustomWorldAgentMessageRole,
RpgAgentPendingClarification as CustomWorldPendingClarification,
RpgAgentQualityFinding as CustomWorldAgentQualityFinding,
RpgAgentSessionSnapshot as CustomWorldAgentSessionSnapshot,
RpgAgentStage as CustomWorldAgentStage,
RpgCreationIntentReadiness as CreatorIntentReadiness,
SendRpgAgentMessageRequest as SendCustomWorldAgentMessageRequest,
SendRpgAgentMessageResponse as SendCustomWorldAgentMessageResponse,
} from './rpgAgentSession';

View File

@@ -0,0 +1,12 @@
/**
* 旧 custom world 结果页预览兼容出口。
* 额外单独拆一个 preview 兼容文件,避免预览别名继续堆回 customWorldAgent.ts 聚合层。
*/
export type {
RpgCreationPreview as CustomWorldResultPreview,
RpgCreationPreviewBlocker as CustomWorldResultPreviewBlocker,
RpgCreationPreviewEnvelope as CustomWorldResultPreviewEnvelope,
RpgCreationPreviewFinding as CustomWorldResultPreviewFinding,
RpgCreationPreviewSource as CustomWorldResultPreviewSource,
} from './rpgCreationPreview';

View File

@@ -0,0 +1,11 @@
/**
* 旧 custom world works 读模型兼容出口。
* 用于把旧作品列表命名平滑映射到新的 RPG 创作域 works 契约。
*/
export type {
ListRpgCreationWorksResponse as ListCustomWorldWorksResponse,
RpgCreationWorkSource as CustomWorldWorkSource,
RpgCreationWorkStatus as CustomWorldWorkStatus,
RpgCreationWorkSummary as CustomWorldWorkSummary,
} from './rpgCreationWorkSummary';

View File

@@ -1,7 +1,120 @@
export type {
CustomWorldAgentActionRequest as RpgAgentActionRequest,
CustomWorldAgentActionResponse as RpgAgentActionResponse,
CustomWorldAgentOperationStatus as RpgAgentOperationStatus,
CustomWorldAgentOperationType as RpgAgentOperationType,
CustomWorldSuggestedAction as RpgAgentSuggestedAction,
} from './customWorldAgent';
/**
* RPG Agent 动作与异步操作契约。
* 这里显式区分“建议动作”和“真实可执行动作”,为后续后端 registry 收口预留接口。
*/
export type RpgAgentSuggestedActionType =
| 'request_summary'
| 'draft_foundation'
| 'refine_focus_target'
| 'lock_current_target'
| 'generate_role_assets'
| 'generate_scene_assets'
| 'expand_long_tail'
| 'publish_world';
export interface RpgAgentSuggestedAction {
id: string;
type: RpgAgentSuggestedActionType;
label: string;
targetId?: string | null;
}
export type RpgAgentActionType =
| 'draft_foundation'
| 'update_draft_card'
| 'sync_result_profile'
| 'generate_characters'
| 'generate_landmarks'
| 'generate_role_assets'
| 'sync_role_assets'
| 'generate_scene_assets'
| 'sync_scene_assets'
| 'expand_long_tail'
| 'publish_world'
| 'revert_checkpoint';
export type RpgAgentActionCapabilityKey =
| RpgAgentSuggestedActionType
| RpgAgentActionType;
/**
* 当前先把能力矩阵定义为可选契约。
* 等工作包 E 的 registry 落地后,后端可以把真实 supportedActions 填充到 session snapshot。
*/
export interface RpgAgentSupportedAction {
action: RpgAgentActionCapabilityKey;
enabled: boolean;
reason?: string | null;
}
export type RpgAgentOperationType = RpgAgentActionType | 'process_message';
export type RpgAgentOperationStatus =
| 'queued'
| 'running'
| 'completed'
| 'failed';
export interface RpgAgentOperationRecord {
operationId: string;
type: RpgAgentOperationType;
status: RpgAgentOperationStatus;
phaseLabel: string;
phaseDetail: string;
progress: number;
error?: string | null;
}
export type RpgAgentActionRequest =
| { action: 'draft_foundation' }
| {
action: 'update_draft_card';
cardId: string;
sections: Array<{
sectionId: string;
value: string;
}>;
}
| {
action: 'sync_result_profile';
profile: Record<string, unknown>;
}
| {
action: 'generate_characters';
count: number;
promptText?: string | null;
anchorCardIds?: string[];
}
| {
action: 'generate_landmarks';
count: number;
promptText?: string | null;
anchorCardIds?: string[];
}
| { action: 'generate_role_assets'; roleIds: string[] }
| {
action: 'sync_role_assets';
roleId: string;
portraitPath: string;
generatedVisualAssetId: string;
generatedAnimationSetId?: string | null;
animationMap?: Record<string, unknown> | null;
}
| { action: 'generate_scene_assets'; sceneIds: string[] }
| {
action: 'sync_scene_assets';
sceneId: string;
sceneKind: 'camp' | 'landmark';
imageSrc: string;
generatedSceneAssetId: string;
generatedScenePrompt?: string | null;
generatedSceneModel?: string | null;
}
| { action: 'expand_long_tail' }
| { action: 'publish_world' }
| { action: 'revert_checkpoint'; checkpointId: string };
export interface RpgAgentActionResponse {
operation: RpgAgentOperationRecord;
}

View File

@@ -1,11 +1,63 @@
export type {
EightAnchorContent as RpgCreationAnchorContent,
CoreConflictValue as RpgCreationCoreConflictValue,
HiddenLineValue as RpgCreationHiddenLineValue,
IconicElementValue as RpgCreationIconicElementValue,
KeyRelationshipValue as RpgCreationKeyRelationshipValue,
PlayerEntryPointValue as RpgCreationPlayerEntryPointValue,
PlayerFantasyValue as RpgCreationPlayerFantasyValue,
ThemeBoundaryValue as RpgCreationThemeBoundaryValue,
WorldPromiseValue as RpgCreationWorldPromiseValue,
} from './customWorldAgent';
/**
* RPG 创作八锚点契约。
* 这一层只描述“创作意图采集态”的结构,不混入 session 或结果页字段。
*/
export interface RpgCreationWorldPromiseValue {
hook: string;
differentiator: string;
desiredExperience: string;
}
export interface RpgCreationPlayerFantasyValue {
playerRole: string;
corePursuit: string;
fearOfLoss: string;
}
export interface RpgCreationThemeBoundaryValue {
toneKeywords: string[];
aestheticDirectives: string[];
forbiddenDirectives: string[];
}
export interface RpgCreationPlayerEntryPointValue {
openingIdentity: string;
openingProblem: string;
entryMotivation: string;
}
export interface RpgCreationCoreConflictValue {
surfaceConflicts: string[];
hiddenCrisis: string;
firstTouchedConflict: string;
}
export interface RpgCreationKeyRelationshipValue {
pairs: string;
relationshipType: string;
secretOrCost: string;
}
export interface RpgCreationHiddenLineValue {
hiddenTruths: string[];
misdirectionHints: string[];
revealPacing: string;
}
export interface RpgCreationIconicElementValue {
iconicMotifs: string[];
institutionsOrArtifacts: string[];
hardRules: string[];
}
export interface RpgCreationAnchorContent {
worldPromise: RpgCreationWorldPromiseValue | null;
playerFantasy: RpgCreationPlayerFantasyValue | null;
themeBoundary: RpgCreationThemeBoundaryValue | null;
playerEntryPoint: RpgCreationPlayerEntryPointValue | null;
coreConflict: RpgCreationCoreConflictValue | null;
keyRelationships: RpgCreationKeyRelationshipValue[];
hiddenLines: RpgCreationHiddenLineValue | null;
iconicElements: RpgCreationIconicElementValue | null;
}

View File

@@ -0,0 +1,251 @@
/**
* RPG Agent 草稿与资产覆盖率契约。
* 这一层只描述 foundation draft、草稿卡片与资产状态不包含会话编排语义。
*/
export type RpgAgentDraftCardKind =
| 'world'
| 'camp'
| 'faction'
| 'character'
| 'landmark'
| 'thread'
| 'chapter'
| 'scene_chapter'
| 'carrier'
| 'sidequest_seed';
export type RpgAgentDraftCardStatus =
| 'suggested'
| 'confirmed'
| 'locked'
| 'warning';
export interface RpgAgentDraftCardSummary {
id: string;
kind: RpgAgentDraftCardKind;
title: string;
subtitle: string;
summary: string;
status: RpgAgentDraftCardStatus;
linkedIds: string[];
warningCount: number;
assetStatus?: RpgAgentRoleAssetStatus | null;
assetStatusLabel?: string | null;
}
export interface RpgAgentDraftCardDetailSection {
id: string;
label: string;
value: string;
}
export interface RpgAgentFoundationDraftFaction {
id: string;
name: string;
title?: string;
subtitle?: string;
publicGoal: string;
relatedConflict: string;
tension?: string;
playerRelation: string;
summary: string;
}
export interface RpgAgentFoundationDraftCharacter {
id: string;
name: string;
title: string;
role: string;
publicIdentity: string;
publicMask?: string;
currentPressure: string;
hiddenHook?: string;
relationToPlayer: string;
threadIds: string[];
summary: string;
skills?: Array<{
id: string;
name: string;
actionPreviewConfig?: Record<string, unknown> | null;
}>;
imageSrc?: string | null;
generatedVisualAssetId?: string | null;
generatedAnimationSetId?: string | null;
animationMap?: Record<string, unknown> | null;
}
export interface RpgAgentFoundationDraftLandmark {
id: string;
name: string;
description?: string;
purpose: string;
mood: string;
importance: string;
secret?: string;
dangerLevel?: string;
imageSrc?: string | null;
generatedSceneAssetId?: string | null;
generatedScenePrompt?: string | null;
generatedSceneModel?: string | null;
characterIds: string[];
threadIds: string[];
summary: string;
}
export interface RpgAgentFoundationDraftThread {
id: string;
title: string;
type: 'main' | 'hidden';
conflictType?: string;
conflict: string;
stakes?: string;
characterIds: string[];
landmarkIds: string[];
summary: string;
}
export interface RpgAgentFoundationDraftChapter {
id: string;
title: string;
openingEvent: string;
playerGoal: string;
characterIds: string[];
landmarkIds: string[];
understandingShift: string;
summary: string;
}
export interface RpgAgentFoundationDraftCamp {
id: string;
name: string;
description: string;
mood: string;
dangerLevel?: string;
imageSrc?: string | null;
generatedSceneAssetId?: string | null;
generatedScenePrompt?: string | null;
generatedSceneModel?: string | null;
summary: string;
}
export type RpgAgentSceneActStage =
| 'opening'
| 'expansion'
| 'turning_point'
| 'climax'
| 'aftermath';
export type RpgAgentSceneActAdvanceRule =
| 'after_primary_contact'
| 'after_active_step_complete'
| 'after_chapter_resolution';
export interface RpgAgentFoundationDraftSceneAct {
id: string;
title: string;
summary: string;
stageCoverage: RpgAgentSceneActStage[];
backgroundImageSrc?: string | null;
backgroundAssetId?: string | null;
encounterNpcIds: string[];
primaryNpcId: string;
linkedThreadIds: string[];
actGoal: string;
transitionHook: string;
advanceRule: RpgAgentSceneActAdvanceRule;
}
export interface RpgAgentFoundationDraftSceneChapter {
id: string;
sceneId: string;
sceneName: string;
title: string;
summary: string;
linkedThreadIds: string[];
linkedLandmarkIds: string[];
acts: RpgAgentFoundationDraftSceneAct[];
}
export interface RpgAgentFoundationDraftProfile {
name: string;
subtitle: string;
summary: string;
tone: string;
playerGoal: string;
majorFactions: string[];
coreConflicts: string[];
playableNpcs: RpgAgentFoundationDraftCharacter[];
storyNpcs: RpgAgentFoundationDraftCharacter[];
landmarks: RpgAgentFoundationDraftLandmark[];
camp?: RpgAgentFoundationDraftCamp | null;
themePack?: Record<string, unknown> | null;
storyGraph?: Record<string, unknown> | null;
factions: RpgAgentFoundationDraftFaction[];
threads: RpgAgentFoundationDraftThread[];
chapters: RpgAgentFoundationDraftChapter[];
sceneChapters: RpgAgentFoundationDraftSceneChapter[];
worldHook: string;
playerPremise: string;
openingSituation: string;
iconicElements: string[];
sourceAnchorSummary: string;
}
export interface RpgAgentFoundationDraftResult {
draftProfile: RpgAgentFoundationDraftProfile;
draftCards: RpgAgentDraftCardSummary[];
}
export interface RpgAgentDraftCardDetail {
id: string;
kind: RpgAgentDraftCardKind;
title: string;
sections: RpgAgentDraftCardDetailSection[];
linkedIds: string[];
locked: false;
editable: boolean;
editableSectionIds: string[];
warningMessages: string[];
assetStatus?: RpgAgentRoleAssetStatus | null;
assetStatusLabel?: string | null;
}
export type RpgAgentAssetPriorityTier = 'hero' | 'featured' | 'supporting';
export type RpgAgentRoleAssetStatus =
| 'missing'
| 'visual_ready'
| 'animations_ready'
| 'complete';
export interface RpgAgentRoleAssetSummary {
roleId: string;
roleName: string;
roleKind: 'playable' | 'story';
priorityTier: RpgAgentAssetPriorityTier;
portraitPath?: string | null;
generatedVisualAssetId?: string | null;
generatedAnimationSetId?: string | null;
status: RpgAgentRoleAssetStatus;
missingAnimations: string[];
nextPointCost: number;
}
export interface RpgAgentSceneAssetSummary {
sceneId: string;
sceneName: string;
actId?: string | null;
actTitle?: string | null;
imageSrc?: string | null;
assetId?: string | null;
status: 'missing' | 'ready';
nextPointCost: number;
}
export interface RpgAgentAssetCoverageSummary {
roleAssets: RpgAgentRoleAssetSummary[];
sceneAssets: RpgAgentSceneAssetSummary[];
allRoleAssetsReady: boolean;
allSceneAssetsReady: boolean;
}

View File

@@ -1,15 +1,134 @@
export type {
CreateCustomWorldAgentSessionRequest as CreateRpgAgentSessionRequest,
CreateCustomWorldAgentSessionResponse as CreateRpgAgentSessionResponse,
GetCustomWorldAgentCardDetailResponse as GetRpgAgentCardDetailResponse,
CustomWorldAgentMessage as RpgAgentMessage,
CustomWorldAgentMessageKind as RpgAgentMessageKind,
CustomWorldAgentMessageRole as RpgAgentMessageRole,
CustomWorldAgentOperationRecord as RpgAgentOperationRecord,
CustomWorldPendingClarification as RpgAgentPendingClarification,
CustomWorldAgentSessionSnapshot as RpgAgentSessionSnapshot,
CustomWorldAgentStage as RpgAgentStage,
CreatorIntentReadiness as RpgCreationIntentReadiness,
SendCustomWorldAgentMessageRequest as SendRpgAgentMessageRequest,
SendCustomWorldAgentMessageResponse as SendRpgAgentMessageResponse,
} from './customWorldAgent';
import type { RpgAgentActionResponse, RpgAgentOperationRecord, RpgAgentSupportedAction, RpgAgentSuggestedAction } from './rpgAgentActions';
import type { RpgCreationAnchorContent } from './rpgAgentAnchors';
import type { RpgAgentAssetCoverageSummary, RpgAgentDraftCardDetail, RpgAgentDraftCardSummary } from './rpgAgentDraft';
import type { RpgCreationPreviewEnvelope } from './rpgCreationPreview';
/**
* RPG Agent 会话层契约。
* 这里承载 session 真相源与会话编排元数据,同时预留 resultPreview 与 supportedActions 两个后续主链字段。
*/
export interface RpgCreationIntentReadiness {
isReady: boolean;
completedKeys: string[];
missingKeys: string[];
}
export interface RpgAgentPendingClarification {
id: string;
label: string;
question: string;
targetKey:
| 'world_hook'
| 'player_premise'
| 'theme_and_tone'
| 'core_conflict'
| 'relationship_seed'
| 'iconic_element';
priority: number;
answer?: string;
}
export type RpgAgentStage =
| 'collecting_intent'
| 'clarifying'
| 'foundation_review'
| 'object_refining'
| 'visual_refining'
| 'long_tail_review'
| 'ready_to_publish'
| 'published'
| 'error';
export type RpgAgentMessageRole = 'user' | 'assistant' | 'system';
export type RpgAgentMessageKind =
| 'chat'
| 'clarification'
| 'summary'
| 'checkpoint'
| 'warning'
| 'action_result';
export interface RpgAgentMessage {
id: string;
role: RpgAgentMessageRole;
kind: RpgAgentMessageKind;
text: string;
createdAt: string;
relatedOperationId?: string | null;
}
export interface RpgAgentQualityFinding {
id: string;
severity: 'info' | 'warning' | 'blocker';
code: string;
targetId?: string | null;
message: string;
}
export interface RpgAgentSessionSnapshot {
sessionId: string;
currentTurn: number;
anchorContent: RpgCreationAnchorContent;
progressPercent: number;
lastAssistantReply: string | null;
stage: RpgAgentStage;
focusCardId: string | null;
creatorIntent: Record<string, unknown> | null;
creatorIntentReadiness: RpgCreationIntentReadiness;
anchorPack: Record<string, unknown> | null;
lockState: Record<string, unknown> | null;
draftProfile: Record<string, unknown> | null;
messages: RpgAgentMessage[];
draftCards: RpgAgentDraftCardSummary[];
pendingClarifications: RpgAgentPendingClarification[];
suggestedActions: RpgAgentSuggestedAction[];
recommendedReplies: string[];
qualityFindings: RpgAgentQualityFinding[];
assetCoverage: RpgAgentAssetCoverageSummary;
/**
* checkpoint 元数据需要进入 session snapshot 主链,
* 这样前端后续才能拿到真实可回滚目标,而不是只能盲发 checkpointId。
*/
checkpoints?: Array<{
checkpointId: string;
createdAt: string;
label: string;
}>;
/**
* 后续由工作包 E 的 action registry 真实填充。
* 当前保持可选,确保主链迁移期间不影响旧 session snapshot。
*/
supportedActions?: RpgAgentSupportedAction[];
/**
* 后续由服务端 preview compiler 输出。
* 当前保持可选,允许前端兼容层继续走 legacy profile。
*/
resultPreview?: RpgCreationPreviewEnvelope | null;
updatedAt: string;
}
export interface CreateRpgAgentSessionRequest {
seedText?: string;
}
export interface CreateRpgAgentSessionResponse {
session: RpgAgentSessionSnapshot;
}
export interface SendRpgAgentMessageRequest {
clientMessageId: string;
text: string;
quickFillRequested?: boolean;
focusCardId?: string | null;
selectedCardIds?: string[];
}
export interface SendRpgAgentMessageResponse extends RpgAgentActionResponse {
operation: RpgAgentOperationRecord;
}
export interface GetRpgAgentCardDetailResponse {
card: RpgAgentDraftCardDetail;
}

View File

@@ -0,0 +1,146 @@
import { describe, expect, test } from 'vitest';
import type { CustomWorldAgentSessionSnapshot } from './customWorldAgentSession';
import type { CustomWorldResultPreviewEnvelope } from './customWorldResultPreview';
import type { CustomWorldWorkSummary } from './customWorldWorkSummary';
import {
createRpgAgentFoundationDraftProfileFixture,
createRpgAgentSupportedActionsFixture,
createRpgAgentSessionFixture,
createRpgCreationAnchorContentFixture,
createRpgCreationPreviewEnvelopeFixture,
createRpgCreationPublishedProfileFixture,
createRpgCreationWorksResponseFixture,
createRpgWorldLibraryEntryFixture,
} from './rpgCreationFixtures';
describe('RPG 创作共享契约 fixture', () => {
test('旧命名兼容分文件可以直接承接新 fixture 的类型消费', () => {
const legacySession: CustomWorldAgentSessionSnapshot =
createRpgAgentSessionFixture();
const legacyPreview: CustomWorldResultPreviewEnvelope =
createRpgCreationPreviewEnvelopeFixture();
const legacyWork: CustomWorldWorkSummary =
createRpgCreationWorksResponseFixture().items[0]!;
expect(legacySession.stage).toBe('ready_to_publish');
expect(legacySession.resultPreview?.source).toBe(legacyPreview.source);
expect(legacyWork.status).toBe('draft');
});
test('anchor fixture 与 foundation draft fixture 保持最小创作真相源对应关系', () => {
const anchors = createRpgCreationAnchorContentFixture();
const draftProfile = createRpgAgentFoundationDraftProfileFixture();
expect(anchors.worldPromise?.hook).toContain('旧航路群岛');
expect(draftProfile.worldHook).toContain('旧航路群岛');
expect(draftProfile.playableNpcs).toHaveLength(1);
expect(draftProfile.storyNpcs).toHaveLength(1);
expect(draftProfile.sceneChapters[0]?.acts[0]?.backgroundImageSrc).toContain(
'/generated-custom-world-scenes/landmark-1/scene-act-1/scene.png',
);
});
test('session fixture 同时暴露 supportedActions 与 resultPreview', () => {
const session = createRpgAgentSessionFixture();
expect(session.sessionId).toBe('rpg-session-fixture');
expect(session.stage).toBe('ready_to_publish');
expect(session.checkpoints?.[0]?.checkpointId).toBe(
'checkpoint-foundation-v1',
);
expect(session.supportedActions?.map((entry) => entry.action)).toEqual(
expect.arrayContaining(['draft_foundation', 'generate_role_assets', 'publish_world']),
);
expect(session.resultPreview?.source).toBe('session_preview');
expect(session.resultPreview?.blockers).toEqual([]);
});
test('preview fixture 保持预览来源、质量结论与 profile 载体三层边界', () => {
const preview = createRpgCreationPreviewEnvelopeFixture();
expect(preview.source).toBe('session_preview');
expect(preview.preview.previewId).toBe('preview-fixture-1');
expect(preview.preview.sessionId).toBe('rpg-session-fixture');
expect(preview.qualityFindings?.[0]).toMatchObject({
severity: 'info',
code: 'scene_asset_ready',
});
});
test('supported actions fixture 明确区分可执行能力矩阵,而不是让前端自行猜测按钮状态', () => {
const supportedActions = createRpgAgentSupportedActionsFixture();
expect(supportedActions).toEqual([
{ action: 'draft_foundation', enabled: true },
{ action: 'generate_role_assets', enabled: true },
{ action: 'publish_world', enabled: true },
]);
});
test('published profile fixture 能稳定承载作品库与结果页所需的封面、场景幕与角色资产字段', () => {
const profile = createRpgCreationPublishedProfileFixture();
expect(profile.id).toBe('rpg-profile-fixture');
expect(profile.playableNpcs).toHaveLength(1);
expect(profile.landmarks).toHaveLength(1);
expect(profile.sceneChapterBlueprints).toHaveLength(1);
expect(
(profile.sceneChapterBlueprints as Array<{ acts?: Array<{ backgroundImageSrc?: string }> }>)[0]
?.acts?.[0]?.backgroundImageSrc,
).toContain('/generated-custom-world-scenes/landmark-1/scene-act-1/scene.png');
});
test('regression: session preview 与 published profile 需要共同保留角色动作资产和分幕背景字段', () => {
const session = createRpgAgentSessionFixture();
const publishedProfile = createRpgCreationPublishedProfileFixture();
const preview = createRpgCreationPreviewEnvelopeFixture();
expect(
((session.draftProfile as { playableNpcs?: Array<{ animationMap?: { run?: { basePath?: string } } }> })
.playableNpcs?.[0]?.animationMap?.run?.basePath ?? ''),
).toContain('/generated-characters/playable-1/animations/run');
expect(
((preview.preview.playableNpcs as Array<{ generatedAnimationSetId?: string }>)[0]
?.generatedAnimationSetId ?? ''),
).toBe('animation-set-playable-1');
expect(
((publishedProfile.sceneChapterBlueprints as Array<{
acts?: Array<{ backgroundAssetId?: string }>;
}>)[0]?.acts?.[0]?.backgroundAssetId ?? ''),
).toBe('scene-asset-runtime');
});
test('works fixture 与 library fixture 对齐同一 published profile', () => {
const works = createRpgCreationWorksResponseFixture();
const libraryEntry = createRpgWorldLibraryEntryFixture();
const publishedWork = works.items.find((entry) => entry.status === 'published');
expect(publishedWork?.profileId).toBe(libraryEntry.profileId);
expect(publishedWork?.title).toBe(libraryEntry.worldName);
expect(publishedWork?.canEnterWorld).toBe(true);
expect(libraryEntry.profile.id).toBe(libraryEntry.profileId);
});
test('regression: works fixture 需要稳定保留草稿与发布态的作品门槛字段', () => {
const works = createRpgCreationWorksResponseFixture();
const draftWork = works.items.find((entry) => entry.status === 'draft');
const publishedWork = works.items.find((entry) => entry.status === 'published');
expect(draftWork).toMatchObject({
stage: 'ready_to_publish',
stageLabel: '准备发布',
canResume: true,
canEnterWorld: false,
roleVisualReadyCount: 2,
roleAnimationReadyCount: 2,
});
expect(publishedWork).toMatchObject({
stage: 'published',
stageLabel: '已发布',
canResume: false,
canEnterWorld: true,
});
});
});

View File

@@ -0,0 +1,714 @@
import type {
CustomWorldLibraryEntry,
CustomWorldProfileRecord,
} from './runtime';
import type { RpgAgentSupportedAction } from './rpgAgentActions';
import type { RpgCreationAnchorContent } from './rpgAgentAnchors';
import type {
RpgAgentAssetCoverageSummary,
RpgAgentDraftCardSummary,
RpgAgentFoundationDraftProfile,
} from './rpgAgentDraft';
import type { RpgAgentSessionSnapshot } from './rpgAgentSession';
import type { RpgCreationPreviewEnvelope } from './rpgCreationPreview';
import type {
ListRpgCreationWorksResponse,
RpgCreationWorkSummary,
} from './rpgCreationWorkSummary';
const RPG_CREATION_FIXTURE_SESSION_ID = 'rpg-session-fixture';
const RPG_CREATION_FIXTURE_PROFILE_ID = 'rpg-profile-fixture';
const RPG_CREATION_FIXTURE_USER_ID = 'fixture-user';
const RPG_CREATION_FIXTURE_UPDATED_AT = '2026-04-21T09:30:00.000Z';
const RPG_CREATION_FIXTURE_PUBLISHED_AT = '2026-04-21T10:00:00.000Z';
function cloneFixture<T>(value: T): T {
return JSON.parse(JSON.stringify(value)) as T;
}
/**
* 共享八锚点 fixture。
* 用于 contract test、session fixture 和 works 集成测试复用同一份创作意图样本。
*/
export function createRpgCreationAnchorContentFixture(): RpgCreationAnchorContent {
return cloneFixture({
worldPromise: {
hook: '被海雾吞没的旧航路群岛',
differentiator: '灯塔与禁航令共同决定谁能活着穿过去。',
desiredExperience: '压抑、悬疑、潮湿',
},
playerFantasy: {
playerRole: '玩家回到群岛调查沉船真相。',
corePursuit: '找出失控航路背后的真相。',
fearOfLoss: '失去最后一个还能对上旧案的人。',
},
themeBoundary: {
toneKeywords: ['压抑', '潮湿', '悬疑'],
aestheticDirectives: ['旧灯塔', '潮雾', '断裂航路'],
forbiddenDirectives: ['不要出现现代枪械'],
},
playerEntryPoint: {
openingIdentity: '被迫返乡的失职守灯人',
openingProblem: '首夜就有陌生船只闯入禁航区。',
entryMotivation: '查清沉船夜里被谁改动了灯册。',
},
coreConflict: {
surfaceConflicts: ['守灯会与航运公会争夺旧航路控制权'],
hiddenCrisis: '沉船夜的航灯与灯册被人动过手脚。',
firstTouchedConflict: '玩家开局就会撞上新的封航命令。',
},
keyRelationships: [
{
pairs: '玩家 / 沈砺',
relationshipType: '旧友兼潜在背叛者',
secretOrCost: '沈砺暗地里在替沉船商盟引路。',
},
],
hiddenLines: {
hiddenTruths: ['沉船夜的真实失误并不是单纯天灾。'],
misdirectionHints: ['所有人都会先把问题推给潮雾本身。'],
revealPacing: '第一章露出痕迹,第二章才让玩家摸到灯册线。',
},
iconicElements: {
iconicMotifs: ['会移动的海雾'],
institutionsOrArtifacts: ['回潮旧灯塔', '封灯令', '旧潮图'],
hardRules: ['禁航信号一旦点亮,任何船都必须退航。'],
},
} satisfies RpgCreationAnchorContent);
}
/**
* 共享 foundation draft fixture。
* 这份样本同时服务 session 草稿、preview 适配回归测试和 works 聚合测试。
*/
export function createRpgAgentFoundationDraftProfileFixture(): RpgAgentFoundationDraftProfile {
return cloneFixture({
name: '潮雾列岛',
subtitle: '旧灯塔与失控航路',
summary: '第一版世界底稿已经整理完成,玩家将从回潮旧灯塔切入沉船旧案。',
tone: '压抑、潮湿、悬疑',
playerGoal: '查清沉船与禁航区异动的真相。',
majorFactions: ['守灯会', '航运公会'],
coreConflicts: ['守灯会与航运公会争夺旧航路控制权'],
playableNpcs: [
{
id: 'playable-1',
name: '沈砺',
title: '旧航路引路人',
role: '关键同行者',
publicIdentity: '最熟悉旧航路的人。',
publicMask: '看上去像可靠旧友。',
currentPressure: '他必须在两股势力间站队。',
hiddenHook: '暗中替沉船商盟引路。',
relationToPlayer: '旧友兼潜在背叛者',
threadIds: ['thread-1'],
summary: '他像旧友,但也像一把始终没收回鞘的刀。',
skills: [
{
id: 'skill-playable-1',
name: '潮行引路',
actionPreviewConfig: {
basePath:
'/generated-characters/playable-1/animations/skills/skill-playable-1',
},
},
],
imageSrc:
'/generated-characters/playable-1/visual/asset-runtime/master.png',
generatedVisualAssetId: 'asset-runtime-playable',
generatedAnimationSetId: 'animation-set-playable-1',
animationMap: {
idle: {
basePath: '/generated-characters/playable-1/animations/idle',
},
run: {
basePath: '/generated-characters/playable-1/animations/run',
},
attack: {
basePath: '/generated-characters/playable-1/animations/attack',
},
},
},
],
storyNpcs: [
{
id: 'story-1',
name: '顾潮音',
title: '守灯会值夜人',
role: '场景关键角色',
publicIdentity: '负责夜间巡灯与封锁。',
publicMask: '对外一直冷静克制。',
currentPressure: '她知道更多禁航区真相。',
hiddenHook: '曾亲眼见过失控海雾吞船。',
relationToPlayer: '最早愿意交换线索的人',
threadIds: ['thread-1'],
summary: '她总像比所有人更早知道海雾会往哪一侧压下来。',
skills: [
{
id: 'skill-story-1',
name: '夜潮灯语',
actionPreviewConfig: {
basePath:
'/generated-characters/story-1/animations/skills/skill-story-1',
},
},
],
imageSrc:
'/generated-characters/story-1/visual/asset-runtime/master.png',
generatedVisualAssetId: 'asset-runtime-story',
generatedAnimationSetId: 'animation-set-story-1',
animationMap: {
run: {
basePath: '/generated-characters/story-1/animations/run',
},
attack: {
basePath: '/generated-characters/story-1/animations/attack',
},
},
},
],
landmarks: [
{
id: 'landmark-1',
name: '回潮旧灯塔',
description: '被海雾啃旧的石塔仍在夜里维持着微弱灯火。',
purpose: '观察雾潮与往来船只',
mood: '潮湿、压抑、风声不止',
importance: '开局核心场景',
secret: '高处潮痕说明海面异常抬升过。',
dangerLevel: 'high',
imageSrc: '/generated-custom-world-scenes/landmark-1/latest-scene.png',
generatedSceneAssetId: 'scene-asset-landmark-1',
characterIds: ['story-1'],
threadIds: ['thread-1'],
summary: '旧灯塔是整片群岛最先看见异动的地方。',
},
],
camp: {
id: 'camp-1',
name: '回潮暂栖所',
description: '能暂时收拢队伍、整理灯册与潮路线索的落脚点。',
mood: '克制、紧绷,但还能暂时收拢局势',
dangerLevel: 'low',
imageSrc: '/custom/camp/huichao.png',
generatedSceneAssetId: 'scene-asset-camp-1',
summary: '玩家能在这里整理情报、回看旧灯册和沉船名单。',
},
themePack: {
id: 'theme-pack:tide',
displayName: '潮雾悬疑',
},
storyGraph: {
visibleThreads: [
{
id: 'thread-visible-1',
title: '封航争夺',
},
],
},
factions: [
{
id: 'faction-1',
name: '守灯会',
title: '守灯会',
subtitle: '把控禁航灯令的人',
publicGoal: '维持封航秩序并压住灯册流出。',
relatedConflict: '想把旧案继续压在禁航记录之下。',
tension: '他们越强调规矩,越像在遮掩灯册。',
playerRelation: '玩家迟早要与他们正面冲突。',
summary: '掌握灯塔与封航令的势力,也是最怕旧案被翻出来的一方。',
},
],
threads: [
{
id: 'thread-1',
title: '沉船旧案',
type: 'main',
conflictType: '真相遮蔽',
conflict: '沉船夜的航灯与灯册被人动过手脚。',
stakes: '真相一旦坐实,群岛秩序会先崩。',
characterIds: ['playable-1', 'story-1'],
landmarkIds: ['landmark-1'],
summary: '玩家会从灯塔高处潮痕一路追到沉船夜的真相。',
},
],
chapters: [
{
id: 'chapter-1',
title: '灯塔回潮',
openingEvent: '禁航区闯入了一艘不该出现的陌生船。',
playerGoal: '先稳住局势,再拿到第一份灯册线索。',
characterIds: ['playable-1', 'story-1'],
landmarkIds: ['landmark-1'],
understandingShift: '玩家会意识到沉船旧案至今仍在操控群岛秩序。',
summary: '第一章聚焦灯塔与封航令,给玩家一条可追的旧案线索。',
},
],
sceneChapters: [
{
id: 'scene-chapter-1',
sceneId: 'landmark-1',
sceneName: '回潮旧灯塔',
title: '灯塔初章',
summary: '围绕灯塔推进的首个场景章节。',
linkedThreadIds: ['thread-1'],
linkedLandmarkIds: ['landmark-1'],
acts: [
{
id: 'scene-act-1',
title: '第一幕',
summary: '先接住回潮灯塔的入口压力。',
stageCoverage: ['opening'],
backgroundImageSrc:
'/generated-custom-world-scenes/landmark-1/scene-act-1/scene.png',
backgroundAssetId: 'scene-asset-runtime',
encounterNpcIds: ['story-1'],
primaryNpcId: 'story-1',
linkedThreadIds: ['thread-1'],
actGoal: '接住首幕入口',
transitionHook: '向第二幕推进。',
advanceRule: 'after_primary_contact',
},
],
},
],
worldHook: '被海雾吞没的旧航路群岛',
playerPremise: '玩家回到群岛调查沉船真相。',
openingSituation: '首夜就有陌生船只闯入禁航区。',
iconicElements: ['会移动的海雾', '回潮旧灯塔'],
sourceAnchorSummary: '海雾、旧灯塔、失控航路。',
} satisfies RpgAgentFoundationDraftProfile);
}
function createRpgAgentDraftCardsFixture(): RpgAgentDraftCardSummary[] {
return cloneFixture([
{
id: 'world-foundation',
kind: 'world',
title: '潮雾列岛',
subtitle: '旧灯塔与失控航路',
summary: '第一版世界底稿已经整理完成。',
status: 'suggested',
linkedIds: ['playable-1', 'story-1', 'landmark-1'],
warningCount: 0,
},
{
id: 'playable-1',
kind: 'character',
title: '沈砺',
subtitle: '旧航路引路人 / 动作已齐',
summary: '最熟悉旧航路的人,也可能是最危险的旧友。',
status: 'suggested',
linkedIds: ['thread-1', 'landmark-1'],
warningCount: 0,
assetStatus: 'complete',
assetStatusLabel: '动作已齐',
},
{
id: 'landmark-1',
kind: 'landmark',
title: '回潮旧灯塔',
subtitle: '观察雾潮与往来船只',
summary: '旧灯塔是整片群岛最先看见异动的地方。',
status: 'suggested',
linkedIds: ['story-1', 'thread-1'],
warningCount: 0,
},
] satisfies RpgAgentDraftCardSummary[]);
}
function createRpgAgentAssetCoverageFixture(): RpgAgentAssetCoverageSummary {
return cloneFixture({
roleAssets: [
{
roleId: 'playable-1',
roleName: '沈砺',
roleKind: 'playable',
priorityTier: 'hero',
portraitPath:
'/generated-characters/playable-1/visual/asset-runtime/master.png',
generatedVisualAssetId: 'asset-runtime-playable',
generatedAnimationSetId: 'animation-set-playable-1',
status: 'complete',
missingAnimations: [],
nextPointCost: 0,
},
{
roleId: 'story-1',
roleName: '顾潮音',
roleKind: 'story',
priorityTier: 'featured',
portraitPath:
'/generated-characters/story-1/visual/asset-runtime/master.png',
generatedVisualAssetId: 'asset-runtime-story',
generatedAnimationSetId: 'animation-set-story-1',
status: 'complete',
missingAnimations: [],
nextPointCost: 0,
},
],
sceneAssets: [
{
sceneId: 'landmark-1',
sceneName: '回潮旧灯塔',
actId: 'scene-act-1',
actTitle: '第一幕',
imageSrc:
'/generated-custom-world-scenes/landmark-1/scene-act-1/scene.png',
assetId: 'scene-asset-runtime',
status: 'ready',
nextPointCost: 0,
},
],
allRoleAssetsReady: true,
allSceneAssetsReady: true,
} satisfies RpgAgentAssetCoverageSummary);
}
/**
* 已发布 profile fixture。
* 用于 preview compiler、works 聚合和 library 元数据解析测试。
*/
export function createRpgCreationPublishedProfileFixture(): CustomWorldProfileRecord {
const draft = createRpgAgentFoundationDraftProfileFixture();
return cloneFixture({
id: RPG_CREATION_FIXTURE_PROFILE_ID,
settingText: draft.worldHook,
name: draft.name,
subtitle: draft.subtitle,
summary: draft.summary,
tone: draft.tone,
playerGoal: draft.playerGoal,
templateWorldType: 'WUXIA',
compatibilityTemplateWorldType: 'WUXIA',
majorFactions: draft.majorFactions,
coreConflicts: draft.coreConflicts,
playableNpcs: draft.playableNpcs.map((role) => ({
id: role.id,
name: role.name,
title: role.title,
role: role.role,
description: role.publicIdentity,
backstory: role.hiddenHook || role.summary,
personality: role.publicMask || role.summary,
motivation: role.currentPressure,
combatStyle: '借地形和潮路换位,先拉扯再压近。',
initialAffinity: 18,
relationshipHooks: [role.relationToPlayer],
tags: ['潮路', '旧案'],
imageSrc: role.imageSrc,
generatedVisualAssetId: role.generatedVisualAssetId,
generatedAnimationSetId: role.generatedAnimationSetId,
animationMap: role.animationMap,
skills: [
{
id: 'skill-playable-1',
name: '潮行引路',
summary: '踩着旧潮阶切线前压,替队伍打开角度。',
style: '机动周旋',
},
],
templateCharacterId: 'archer-hero',
})),
storyNpcs: draft.storyNpcs.map((role) => ({
id: role.id,
name: role.name,
title: role.title,
role: role.role,
description: role.publicIdentity,
backstory: role.hiddenHook || role.summary,
personality: role.publicMask || role.summary,
motivation: role.currentPressure,
combatStyle: '借塔顶视角和风向压制,再用灯火错位扰乱。',
initialAffinity: 8,
relationshipHooks: [role.relationToPlayer],
tags: ['守灯会', '灯塔'],
imageSrc: role.imageSrc,
generatedVisualAssetId: role.generatedVisualAssetId,
skills: [
{
id: 'skill-story-1',
name: '夜潮灯语',
summary: '借灯语与潮声干扰对方判断。',
style: '起手压制',
},
],
})),
camp: {
name: draft.camp?.name,
description: draft.camp?.description,
dangerLevel: draft.camp?.dangerLevel,
imageSrc: draft.camp?.imageSrc,
},
landmarks: draft.landmarks.map((landmark) => ({
id: landmark.id,
name: landmark.name,
description: landmark.description,
dangerLevel: landmark.dangerLevel,
imageSrc: landmark.imageSrc,
sceneNpcIds: landmark.characterIds,
connections: [
{
targetLandmarkId: 'landmark-1',
relativePosition: 'forward',
summary: '沿着旧潮阶继续前压到雾栈尽头。',
},
],
})),
cover: {
sourceType: 'default',
characterRoleIds: ['playable-1'],
},
sceneChapterBlueprints: draft.sceneChapters.map((chapter) => ({
id: chapter.id,
sceneId: chapter.sceneId,
sceneName: chapter.sceneName,
title: chapter.title,
summary: chapter.summary,
acts: chapter.acts.map((act) => ({
id: act.id,
title: act.title,
summary: act.summary,
backgroundImageSrc: act.backgroundImageSrc,
backgroundAssetId: act.backgroundAssetId,
encounterNpcIds: act.encounterNpcIds,
primaryNpcId: act.primaryNpcId,
actGoal: act.actGoal,
transitionHook: act.transitionHook,
})),
})),
themePack: draft.themePack,
storyGraph: draft.storyGraph,
scenarioPackId: 'scenario-pack:tide',
campaignPackId: 'campaign-pack:tide',
generationMode: 'fast',
generationStatus: 'key_only',
updatedAt: RPG_CREATION_FIXTURE_UPDATED_AT,
publishedAt: RPG_CREATION_FIXTURE_PUBLISHED_AT,
} satisfies CustomWorldProfileRecord);
}
export function createRpgCreationPreviewEnvelopeFixture(): RpgCreationPreviewEnvelope {
return cloneFixture({
preview: {
...createRpgCreationPublishedProfileFixture(),
previewId: 'preview-fixture-1',
sessionId: RPG_CREATION_FIXTURE_SESSION_ID,
profileId: RPG_CREATION_FIXTURE_PROFILE_ID,
},
source: 'session_preview',
generatedAt: RPG_CREATION_FIXTURE_UPDATED_AT,
qualityFindings: [
{
id: 'finding-scene-asset-ready',
severity: 'info',
code: 'scene_asset_ready',
targetId: 'scene-act-1',
message: '首幕背景图已经就绪,可直接用于结果页预览。',
},
],
blockers: [],
publishReady: true,
canEnterWorld: false,
} satisfies RpgCreationPreviewEnvelope);
}
export function createRpgAgentSupportedActionsFixture(): RpgAgentSupportedAction[] {
return cloneFixture([
{
action: 'draft_foundation',
enabled: true,
},
{
action: 'generate_role_assets',
enabled: true,
},
{
action: 'publish_world',
enabled: true,
},
] satisfies RpgAgentSupportedAction[]);
}
/**
* 共享 session snapshot fixture。
* 默认模拟“底稿、预览、资产都已准备好”的 ready_to_publish 状态。
*/
export function createRpgAgentSessionFixture(): RpgAgentSessionSnapshot {
const draftProfile = createRpgAgentFoundationDraftProfileFixture();
return cloneFixture({
sessionId: RPG_CREATION_FIXTURE_SESSION_ID,
currentTurn: 6,
anchorContent: createRpgCreationAnchorContentFixture(),
progressPercent: 100,
lastAssistantReply: '八锚点与底稿都已经齐备,可以进入结果页收口。',
stage: 'ready_to_publish',
focusCardId: null,
creatorIntent: {
sourceMode: 'card',
rawSettingText: draftProfile.worldHook,
worldHook: draftProfile.worldHook,
playerPremise: draftProfile.playerPremise,
themeKeywords: ['海雾', '旧航路'],
toneDirectives: ['压抑', '悬疑'],
openingSituation: draftProfile.openingSituation,
coreConflicts: draftProfile.coreConflicts,
keyFactions: ['守灯会'],
keyCharacters: ['沈砺', '顾潮音'],
keyLandmarks: ['回潮旧灯塔'],
iconicElements: draftProfile.iconicElements,
forbiddenDirectives: ['不要出现现代枪械'],
},
creatorIntentReadiness: {
isReady: true,
completedKeys: [
'world_hook',
'player_premise',
'theme_and_tone',
'core_conflict',
'relationship_seed',
'iconic_element',
],
missingKeys: [],
},
anchorPack: {
summary: draftProfile.sourceAnchorSummary,
},
lockState: {
lockedCardIds: ['world-foundation'],
},
draftProfile,
messages: [
{
id: 'message-1',
role: 'assistant',
kind: 'summary',
text: '世界底稿已整理完成,建议进入结果页确认资产与发布门槛。',
createdAt: RPG_CREATION_FIXTURE_UPDATED_AT,
relatedOperationId: null,
},
],
draftCards: createRpgAgentDraftCardsFixture(),
pendingClarifications: [],
suggestedActions: [
{
id: 'action-publish',
type: 'publish_world',
label: '发布世界',
},
],
recommendedReplies: ['先看结果页', '继续精修角色关系'],
qualityFindings: [
{
id: 'finding-scene-asset-ready',
severity: 'info',
code: 'scene_asset_ready',
targetId: 'scene-act-1',
message: '首幕背景图已经就绪,可直接用于结果页预览。',
},
],
assetCoverage: createRpgAgentAssetCoverageFixture(),
checkpoints: [
{
checkpointId: 'checkpoint-foundation-v1',
createdAt: RPG_CREATION_FIXTURE_UPDATED_AT,
label: '世界底稿 V1',
},
],
supportedActions: createRpgAgentSupportedActionsFixture(),
resultPreview: createRpgCreationPreviewEnvelopeFixture(),
updatedAt: RPG_CREATION_FIXTURE_UPDATED_AT,
} satisfies RpgAgentSessionSnapshot);
}
export function createRpgWorldLibraryEntryFixture(): CustomWorldLibraryEntry<CustomWorldProfileRecord> {
const profile = createRpgCreationPublishedProfileFixture();
return cloneFixture({
ownerUserId: RPG_CREATION_FIXTURE_USER_ID,
profileId: RPG_CREATION_FIXTURE_PROFILE_ID,
profile,
visibility: 'published',
publishedAt: RPG_CREATION_FIXTURE_PUBLISHED_AT,
updatedAt: RPG_CREATION_FIXTURE_UPDATED_AT,
authorDisplayName: '测试玩家',
worldName: String(profile.name ?? '潮雾列岛'),
subtitle: String(profile.subtitle ?? '旧灯塔与失控航路'),
summaryText: String(profile.summary ?? '第一版世界底稿已经整理完成。'),
coverImageSrc:
'/generated-custom-world-scenes/landmark-1/scene-act-1/scene.png',
themeMode: 'tide',
playableNpcCount: Array.isArray(profile.playableNpcs)
? profile.playableNpcs.length
: 0,
landmarkCount: Array.isArray(profile.landmarks)
? profile.landmarks.length
: 0,
} satisfies CustomWorldLibraryEntry<CustomWorldProfileRecord>);
}
export function createRpgCreationWorksResponseFixture(): ListRpgCreationWorksResponse {
return cloneFixture({
items: [
{
workId: `draft:${RPG_CREATION_FIXTURE_SESSION_ID}`,
sourceType: 'agent_session',
status: 'draft',
title: '潮雾列岛',
subtitle: '旧灯塔与失控航路',
summary: '第一版世界底稿已经整理完成,玩家将从回潮旧灯塔切入沉船旧案。',
coverImageSrc: '/custom/camp/huichao.png',
coverRenderMode: 'scene_with_roles',
coverCharacterImageSrcs: [
'/generated-characters/playable-1/visual/asset-runtime/master.png',
],
updatedAt: RPG_CREATION_FIXTURE_UPDATED_AT,
publishedAt: null,
stage: 'ready_to_publish',
stageLabel: '准备发布',
playableNpcCount: 2,
landmarkCount: 1,
roleVisualReadyCount: 2,
roleAnimationReadyCount: 2,
roleAssetSummaryLabel: '沈砺 · 动作已就绪',
sessionId: RPG_CREATION_FIXTURE_SESSION_ID,
profileId: null,
canResume: true,
canEnterWorld: false,
blockerCount: 0,
publishReady: true,
},
{
workId: `published:${RPG_CREATION_FIXTURE_PROFILE_ID}`,
sourceType: 'published_profile',
status: 'published',
title: '潮雾列岛',
subtitle: '旧灯塔与失控航路',
summary: '第一版世界底稿已经整理完成,玩家将从回潮旧灯塔切入沉船旧案。',
coverImageSrc:
'/generated-custom-world-scenes/landmark-1/scene-act-1/scene.png',
coverRenderMode: 'scene_with_roles',
coverCharacterImageSrcs: [
'/generated-characters/playable-1/visual/asset-runtime/master.png',
],
updatedAt: RPG_CREATION_FIXTURE_UPDATED_AT,
publishedAt: RPG_CREATION_FIXTURE_PUBLISHED_AT,
stage: 'published',
stageLabel: '已发布',
playableNpcCount: 1,
landmarkCount: 1,
roleVisualReadyCount: 1,
roleAnimationReadyCount: 1,
roleAssetSummaryLabel: '动作已就绪 1',
sessionId: null,
profileId: RPG_CREATION_FIXTURE_PROFILE_ID,
canResume: false,
canEnterWorld: true,
blockerCount: 0,
publishReady: true,
},
] satisfies RpgCreationWorkSummary[],
} satisfies ListRpgCreationWorksResponse);
}

View File

@@ -1,12 +1,40 @@
import type { CustomWorldProfileRecord } from './runtime';
/**
* 工作包 A 先建立 RPG 创作结果预览契约骨架
* 在服务端 preview compiler 正式落地前,这里继续把旧的世界 profile 视为兼容预览载体
* 结果预览契约。
* 当前 preview 仍以兼容 profile 作为承载体,但已经把来源、阻断项和质量结论从 session 草稿里显式剥离出来
*/
export type RpgCreationPreview = CustomWorldProfileRecord;
export type RpgCreationPreviewEnvelope = {
preview: RpgCreationPreview;
source: 'legacy_custom_world_profile';
export type RpgCreationPreviewSource =
| 'session_preview'
| 'published_profile';
export interface RpgCreationPreviewFinding {
id: string;
severity: 'info' | 'warning' | 'blocker';
code: string;
targetId?: string | null;
message: string;
}
export interface RpgCreationPreviewBlocker {
id: string;
code: string;
message: string;
}
export type RpgCreationPreview = CustomWorldProfileRecord & {
previewId?: string;
sessionId?: string | null;
profileId?: string | null;
};
export interface RpgCreationPreviewEnvelope {
preview: RpgCreationPreview;
source: RpgCreationPreviewSource;
generatedAt?: string;
qualityFindings?: RpgCreationPreviewFinding[];
blockers?: RpgCreationPreviewBlocker[];
publishReady?: boolean;
canEnterWorld?: boolean;
}

View File

@@ -1,6 +1,38 @@
export type {
ListCustomWorldWorksResponse as ListRpgCreationWorksResponse,
CustomWorldWorkSource as RpgCreationWorkSource,
CustomWorldWorkStatus as RpgCreationWorkStatus,
CustomWorldWorkSummary as RpgCreationWorkSummary,
} from './customWorldAgent';
/**
* RPG 创作作品卡读模型契约。
* works 列表只暴露继续创作与进入世界判断所需的稳定字段。
*/
export type RpgCreationWorkStatus = 'draft' | 'published';
export type RpgCreationWorkSource = 'agent_session' | 'published_profile';
export interface RpgCreationWorkSummary {
workId: string;
sourceType: RpgCreationWorkSource;
status: RpgCreationWorkStatus;
title: string;
subtitle: string;
summary: string;
coverImageSrc?: string | null;
coverRenderMode?: 'image' | 'scene_with_roles';
coverCharacterImageSrcs?: string[];
updatedAt: string;
publishedAt?: string | null;
stage?: string | null;
stageLabel?: string | null;
playableNpcCount: number;
landmarkCount: number;
roleVisualReadyCount?: number;
roleAnimationReadyCount?: number;
roleAssetSummaryLabel?: string | null;
sessionId?: string | null;
profileId?: string | null;
canResume: boolean;
canEnterWorld: boolean;
blockerCount?: number;
publishReady?: boolean;
}
export interface ListRpgCreationWorksResponse {
items: RpgCreationWorkSummary[];
}

View File

@@ -0,0 +1,184 @@
/**
* RPG 运行时聊天相关共享契约。
* 将角色聊天、NPC 对话与轻量 story 请求载荷从旧 story.ts 中独立出来。
*/
import type { JsonObject } from './common';
export type NpcChatTurnLimitReason = 'negative_affinity';
export type NpcChatTurnClosingMode = 'free' | 'foreshadow_close';
export type NpcChatTurnDirective = {
sceneActId?: string | null;
turnLimit?: number | null;
remainingTurns?: number | null;
limitReason?: NpcChatTurnLimitReason | null;
closingMode?: NpcChatTurnClosingMode | null;
forceExitAfterTurn?: boolean;
};
export type NpcChatTurnCompletionDirective = {
turnLimit?: number | null;
remainingTurns?: number | null;
forceExit?: boolean;
closingMode?: NpcChatTurnClosingMode;
};
export type CharacterChatReplyRequest<
TCharacter = unknown,
TStoryMoment = unknown,
TContext = unknown,
TConversationTurn = unknown,
TTargetStatus = unknown,
> = {
worldType: string;
playerCharacter: TCharacter;
targetCharacter: TCharacter;
storyHistory: TStoryMoment[];
context: TContext;
conversationHistory: TConversationTurn[];
conversationSummary: string;
playerMessage: string;
targetStatus: TTargetStatus;
};
export type CharacterChatSuggestionsRequest<
TCharacter = unknown,
TStoryMoment = unknown,
TContext = unknown,
TConversationTurn = unknown,
TTargetStatus = unknown,
> = {
worldType: string;
playerCharacter: TCharacter;
targetCharacter: TCharacter;
storyHistory: TStoryMoment[];
context: TContext;
conversationHistory: TConversationTurn[];
conversationSummary: string;
targetStatus: TTargetStatus;
};
export type CharacterChatSummaryRequest<
TCharacter = unknown,
TStoryMoment = unknown,
TContext = unknown,
TConversationTurn = unknown,
TTargetStatus = unknown,
> = {
worldType: string;
playerCharacter: TCharacter;
targetCharacter: TCharacter;
storyHistory: TStoryMoment[];
context: TContext;
conversationHistory: TConversationTurn[];
previousSummary: string;
targetStatus: TTargetStatus;
};
export type NpcChatDialogueRequest<
TCharacter = unknown,
TEncounter = unknown,
TMonster = unknown,
TStoryMoment = unknown,
TContext = unknown,
> = {
worldType: string;
character: TCharacter;
encounter: TEncounter;
monsters: TMonster[];
history: TStoryMoment[];
context: TContext;
topic: string;
resultSummary: string;
npcInitiatesConversation?: boolean;
};
export type NpcChatTurnRequest<
TCharacter = unknown,
TEncounter = unknown,
TMonster = unknown,
TStoryMoment = unknown,
TContext = unknown,
TConversationTurn = unknown,
TCombatContext = unknown,
TNpcState = unknown,
TQuestOfferState = unknown,
TQuestOfferEncounter = unknown,
TChatDirective = NpcChatTurnDirective,
> = {
worldType: string;
character?: TCharacter;
player?: TCharacter;
encounter: TEncounter;
monsters: TMonster[];
history: TStoryMoment[];
context: TContext;
conversationHistory?: TConversationTurn[];
dialogue?: TConversationTurn[];
combatContext?: TCombatContext | null;
playerMessage: string;
npcState: TNpcState;
npcInitiatesConversation?: boolean;
questOfferContext?: {
state: TQuestOfferState;
encounter: TQuestOfferEncounter;
turnCount: number;
} | null;
chatDirective?: TChatDirective | null;
};
export type NpcChatPendingQuestOffer<TQuest = unknown> = {
quest: TQuest;
introText?: string;
};
export type NpcChatTurnResult<TQuest = unknown> = {
npcReply: string;
affinityDelta: number;
affinityText: string;
suggestions: string[];
pendingQuestOffer?: NpcChatPendingQuestOffer<TQuest> | null;
chatDirective?: NpcChatTurnCompletionDirective | null;
};
export type NpcRecruitDialogueRequest<
TCharacter = unknown,
TEncounter = unknown,
TMonster = unknown,
TStoryMoment = unknown,
TContext = unknown,
> = {
worldType: string;
character: TCharacter;
encounter: TEncounter;
monsters: TMonster[];
history: TStoryMoment[];
context: TContext;
invitationText: string;
recruitSummary: string;
};
export type StoryRequestOptionsPayload = {
availableOptions?: JsonObject[];
optionCatalog?: JsonObject[];
};
export type StoryRequestPayload<TWorldType extends string = string> = {
worldType: TWorldType;
character: JsonObject;
monsters?: JsonObject[];
history?: JsonObject[];
choice?: string;
context: JsonObject;
requestOptions?: StoryRequestOptionsPayload;
};
export type PlainTextPromptRequest = {
systemPrompt: string;
userPrompt: string;
};
export type PlainTextResponse = {
text: string;
};

View File

@@ -0,0 +1,51 @@
import { describe, expect, test } from 'vitest';
import {
CharacterChatReplyRequest,
QUEST_NARRATIVE_TYPES,
RuntimeStoryActionRequest,
SERVER_RUNTIME_FUNCTION_IDS,
TASK6_RUNTIME_FUNCTION_IDS,
} from './story';
import { TASK5_RUNTIME_OPTION_SCOPES } from './rpgRuntimeStoryAction';
import type { RuntimeStoryStateRequest } from './rpgRuntimeStoryState';
describe('RPG runtime shared contract façades', () => {
test('旧 story façade 继续导出 runtime story action 常量与类型', () => {
expect(SERVER_RUNTIME_FUNCTION_IDS).toContain('npc_chat');
expect(TASK6_RUNTIME_FUNCTION_IDS).toContain('npc_trade');
expect(TASK5_RUNTIME_OPTION_SCOPES).toEqual(['story', 'combat', 'npc']);
const request: RuntimeStoryActionRequest = {
sessionId: 'runtime-session-1',
action: {
type: 'story_choice',
functionId: 'npc_chat',
},
};
expect(request.action.functionId).toBe('npc_chat');
});
test('旧 story façade 继续导出 chat 与 quest assist 契约', () => {
const payload: CharacterChatReplyRequest = {
worldType: 'WUXIA',
playerCharacter: {},
targetCharacter: {},
storyHistory: [],
context: {},
conversationHistory: [],
conversationSummary: '测试摘要',
playerMessage: '近况如何?',
targetStatus: {},
};
const stateRequest: RuntimeStoryStateRequest = {
sessionId: 'runtime-session-2',
};
expect(payload.playerMessage).toBe('近况如何?');
expect(stateRequest.sessionId).toBe('runtime-session-2');
expect(QUEST_NARRATIVE_TYPES).toContain('relationship');
});
});

View File

@@ -0,0 +1,83 @@
/**
* RPG 运行时任务辅助与道具意图共享契约。
* 该文件只承载 quest / runtime item 辅助类型,不混入 runtime story 主状态。
*/
import type { JsonObject } from './common';
export const QUEST_NARRATIVE_TYPES = [
'bounty',
'escort',
'investigation',
'retrieval',
'relationship',
'trial',
] as const;
export type SharedQuestNarrativeType = (typeof QUEST_NARRATIVE_TYPES)[number];
export const QUEST_OBJECTIVE_KINDS = [
'defeat_hostile_npc',
'inspect_treasure',
'spar_with_npc',
'talk_to_npc',
'reach_scene',
'deliver_item',
] as const;
export type SharedQuestObjectiveKind = (typeof QUEST_OBJECTIVE_KINDS)[number];
export const QUEST_URGENCY_LEVELS = ['low', 'medium', 'high'] as const;
export type SharedQuestUrgency = (typeof QUEST_URGENCY_LEVELS)[number];
export const QUEST_INTIMACY_LEVELS = [
'transactional',
'cooperative',
'trust_based',
] as const;
export type SharedQuestIntimacy = (typeof QUEST_INTIMACY_LEVELS)[number];
export const QUEST_REWARD_THEMES = [
'currency',
'resource',
'relationship',
'intel',
'rare_item',
] as const;
export type SharedQuestRewardTheme = (typeof QUEST_REWARD_THEMES)[number];
export const RUNTIME_ITEM_FUNCTIONAL_BIAS_VALUES = [
'heal',
'mana',
'cooldown',
'guard',
'damage',
] as const;
export type SharedRuntimeItemFunctionalBias =
(typeof RUNTIME_ITEM_FUNCTIONAL_BIAS_VALUES)[number];
export const RUNTIME_ITEM_TONE_VALUES = [
'grim',
'mysterious',
'martial',
'ritual',
'survival',
] as const;
export type SharedRuntimeItemTone = (typeof RUNTIME_ITEM_TONE_VALUES)[number];
export type RuntimeItemIntentRequest<
TContext = JsonObject,
TPlan = JsonObject,
> = {
context: TContext;
plans: TPlan[];
};
export type RuntimeItemIntentResponse<TIntent = JsonObject> = {
intents: TIntent[];
};
export type QuestGenerationRequest<
TState = JsonObject,
TEncounter = JsonObject,
> = {
state: TState;
encounter: TEncounter;
};

View File

@@ -0,0 +1,136 @@
/**
* RPG runtime story 动作层共享契约。
* 将 function id、动作请求与交互元数据从旧 story.ts 中单独收口。
*/
import type { JsonObject } from './common';
export type RuntimeAction<
TType extends string = string,
TPayload = JsonObject,
> = {
type: TType;
functionId?: string;
targetId?: string;
payload?: TPayload;
};
export type RuntimeActionRequest<
TAction extends RuntimeAction = RuntimeAction,
> = {
sessionId: string;
clientVersion?: number;
action: TAction;
};
export type RuntimeActionResponse<
TViewModel = JsonObject,
TPresentation = JsonObject,
TPatch = JsonObject,
> = {
sessionId: string;
serverVersion: number;
viewModel: TViewModel;
presentation: TPresentation;
patches: TPatch[];
};
export const TASK5_RUNTIME_FUNCTION_IDS = [
'story_continue_adventure',
'story_opening_camp_dialogue',
'camp_travel_home_scene',
'idle_call_out',
'idle_explore_forward',
'idle_observe_signs',
'idle_rest_focus',
'idle_travel_next_scene',
'battle_attack_basic',
'battle_use_skill',
'battle_all_in_crush',
'battle_escape_breakout',
'battle_feint_step',
'battle_finisher_window',
'battle_guard_break',
'battle_probe_pressure',
'battle_recover_breath',
'npc_chat',
'npc_fight',
'npc_help',
'npc_leave',
'npc_preview_talk',
'npc_recruit',
'npc_spar',
] as const;
export type Task5RuntimeFunctionId =
(typeof TASK5_RUNTIME_FUNCTION_IDS)[number];
export const TASK6_RUNTIME_FUNCTION_IDS = [
'equipment_equip',
'equipment_unequip',
'forge_craft',
'forge_dismantle',
'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',
'treasure_inspect',
'treasure_leave',
'treasure_secure',
] as const;
export type Task6RuntimeFunctionId =
(typeof TASK6_RUNTIME_FUNCTION_IDS)[number];
export const SERVER_RUNTIME_FUNCTION_IDS = [
...TASK5_RUNTIME_FUNCTION_IDS,
...TASK6_RUNTIME_FUNCTION_IDS,
] as const;
export type ServerRuntimeFunctionId =
(typeof SERVER_RUNTIME_FUNCTION_IDS)[number];
export const TASK5_RUNTIME_OPTION_SCOPES = ['story', 'combat', 'npc'] as const;
export type Task5RuntimeOptionScope =
(typeof TASK5_RUNTIME_OPTION_SCOPES)[number];
export type RuntimeStoryChoicePayload = JsonObject & {
optionText?: string;
note?: string;
releaseNpcId?: string;
preludeText?: string;
};
export type RuntimeStoryOptionInteraction =
| {
kind: 'npc';
npcId: string;
action:
| 'chat'
| 'help'
| 'fight'
| 'leave'
| 'quest_offer_abandon'
| 'quest_offer_replace'
| 'quest_offer_view'
| 'recruit'
| 'spar'
| 'trade'
| 'gift'
| 'quest_accept'
| 'quest_turn_in';
questId?: string;
}
| {
kind: 'treasure';
action: 'inspect' | 'leave' | 'secure';
};
export type RuntimeStoryChoiceAction = RuntimeAction<
'story_choice',
RuntimeStoryChoicePayload
> & {
functionId: string;
targetId?: string;
};

View File

@@ -0,0 +1,146 @@
/**
* RPG runtime story 状态与响应共享契约。
* 该文件只负责 view model、presentation、patch 与 snapshot 回包结构。
*/
import type { JsonObject } from './common';
import type { SavedGameSnapshot, SavedGameSnapshotInput } from './runtime';
import type {
RuntimeActionRequest,
RuntimeActionResponse,
RuntimeStoryChoiceAction,
RuntimeStoryChoicePayload,
RuntimeStoryOptionInteraction,
Task5RuntimeOptionScope,
} from './rpgRuntimeStoryAction';
export type RuntimeStoryOptionView = {
functionId: string;
actionText: string;
detailText?: string;
scope: Task5RuntimeOptionScope;
interaction?: RuntimeStoryOptionInteraction;
payload?: RuntimeStoryChoicePayload;
disabled?: boolean;
reason?: string;
};
export type RuntimeStoryPlayerViewModel = {
hp: number;
maxHp: number;
mana: number;
maxMana: number;
};
export type RuntimeStoryCompanionViewModel = {
npcId: string;
characterId?: string;
joinedAtAffinity: number;
};
export type RuntimeStoryEncounterViewModel = {
id: string;
kind: 'npc' | 'treasure';
npcName: string;
hostile: boolean;
affinity?: number;
recruited?: boolean;
interactionActive: boolean;
battleMode?: 'fight' | 'spar' | null;
};
export type RuntimeStoryStatusViewModel = {
inBattle: boolean;
npcInteractionActive: boolean;
currentNpcBattleMode: 'fight' | 'spar' | null;
currentNpcBattleOutcome: 'fight_victory' | 'spar_complete' | null;
};
export type RuntimeBattlePresentation = {
targetId?: string;
targetName?: string;
damageDealt?: number;
damageTaken?: number;
outcome?: 'ongoing' | 'victory' | 'spar_complete' | 'escaped';
};
export type RuntimeStoryViewModel = {
player: RuntimeStoryPlayerViewModel;
encounter: RuntimeStoryEncounterViewModel | null;
companions: RuntimeStoryCompanionViewModel[];
availableOptions: RuntimeStoryOptionView[];
status: RuntimeStoryStatusViewModel;
};
export type RuntimeStoryPresentation = {
actionText: string;
resultText: string;
storyText: string;
options: RuntimeStoryOptionView[];
toast?: string | null;
battle?: RuntimeBattlePresentation | null;
};
export type RuntimeStoryPatch =
| {
type: 'story_history_append';
actionText: string;
resultText: string;
}
| {
type: 'npc_affinity_changed';
npcId: string;
previousAffinity: number;
nextAffinity: number;
}
| {
type: 'battle_resolved';
functionId: string;
targetId?: string;
damageDealt?: number;
damageTaken?: number;
outcome: 'ongoing' | 'victory' | 'spar_complete' | 'escaped';
}
| {
type: 'status_changed';
inBattle: boolean;
npcInteractionActive: boolean;
currentNpcBattleMode: 'fight' | 'spar' | null;
currentNpcBattleOutcome: 'fight_victory' | 'spar_complete' | null;
}
| {
type: 'encounter_changed';
encounterId: string | null;
};
export type RuntimeStoryActionRequest =
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,
TSnapshotCurrentStory = JsonObject,
> = RuntimeActionResponse<
RuntimeStoryViewModel,
RuntimeStoryPresentation,
RuntimeStoryPatch
> & {
snapshot: SavedGameSnapshot<
TSnapshotGameState,
string,
TSnapshotCurrentStory
>;
};

View File

@@ -1,520 +0,0 @@
import type { JsonObject } from './common';
import type { SavedGameSnapshot } from './runtime';
export const QUEST_NARRATIVE_TYPES = [
'bounty',
'escort',
'investigation',
'retrieval',
'relationship',
'trial',
] as const;
export type SharedQuestNarrativeType = (typeof QUEST_NARRATIVE_TYPES)[number];
export const QUEST_OBJECTIVE_KINDS = [
'defeat_hostile_npc',
'inspect_treasure',
'spar_with_npc',
'talk_to_npc',
'reach_scene',
'deliver_item',
] as const;
export type SharedQuestObjectiveKind = (typeof QUEST_OBJECTIVE_KINDS)[number];
export const QUEST_URGENCY_LEVELS = ['low', 'medium', 'high'] as const;
export type SharedQuestUrgency = (typeof QUEST_URGENCY_LEVELS)[number];
export const QUEST_INTIMACY_LEVELS = [
'transactional',
'cooperative',
'trust_based',
] as const;
export type SharedQuestIntimacy = (typeof QUEST_INTIMACY_LEVELS)[number];
export const QUEST_REWARD_THEMES = [
'currency',
'resource',
'relationship',
'intel',
'rare_item',
] as const;
export type SharedQuestRewardTheme = (typeof QUEST_REWARD_THEMES)[number];
export const RUNTIME_ITEM_FUNCTIONAL_BIAS_VALUES = [
'heal',
'mana',
'cooldown',
'guard',
'damage',
] as const;
export type SharedRuntimeItemFunctionalBias =
(typeof RUNTIME_ITEM_FUNCTIONAL_BIAS_VALUES)[number];
export const RUNTIME_ITEM_TONE_VALUES = [
'grim',
'mysterious',
'martial',
'ritual',
'survival',
] as const;
export type SharedRuntimeItemTone = (typeof RUNTIME_ITEM_TONE_VALUES)[number];
export type StoryRequestOptionsPayload = {
availableOptions?: JsonObject[];
optionCatalog?: JsonObject[];
};
export type StoryRequestPayload<TWorldType extends string = string> = {
worldType: TWorldType;
character: JsonObject;
monsters?: JsonObject[];
history?: JsonObject[];
choice?: string;
context: JsonObject;
requestOptions?: StoryRequestOptionsPayload;
};
export type PlainTextPromptRequest = {
systemPrompt: string;
userPrompt: string;
};
export type PlainTextResponse = {
text: string;
};
export type NpcChatTurnLimitReason = 'negative_affinity';
export type NpcChatTurnClosingMode = 'free' | 'foreshadow_close';
export type NpcChatTurnDirective = {
sceneActId?: string | null;
turnLimit?: number | null;
remainingTurns?: number | null;
limitReason?: NpcChatTurnLimitReason | null;
closingMode?: NpcChatTurnClosingMode | null;
forceExitAfterTurn?: boolean;
};
export type NpcChatTurnCompletionDirective = {
turnLimit?: number | null;
remainingTurns?: number | null;
forceExit?: boolean;
closingMode?: NpcChatTurnClosingMode;
};
export type CharacterChatReplyRequest<
TCharacter = unknown,
TStoryMoment = unknown,
TContext = unknown,
TConversationTurn = unknown,
TTargetStatus = unknown,
> = {
worldType: string;
playerCharacter: TCharacter;
targetCharacter: TCharacter;
storyHistory: TStoryMoment[];
context: TContext;
conversationHistory: TConversationTurn[];
conversationSummary: string;
playerMessage: string;
targetStatus: TTargetStatus;
};
export type CharacterChatSuggestionsRequest<
TCharacter = unknown,
TStoryMoment = unknown,
TContext = unknown,
TConversationTurn = unknown,
TTargetStatus = unknown,
> = {
worldType: string;
playerCharacter: TCharacter;
targetCharacter: TCharacter;
storyHistory: TStoryMoment[];
context: TContext;
conversationHistory: TConversationTurn[];
conversationSummary: string;
targetStatus: TTargetStatus;
};
export type CharacterChatSummaryRequest<
TCharacter = unknown,
TStoryMoment = unknown,
TContext = unknown,
TConversationTurn = unknown,
TTargetStatus = unknown,
> = {
worldType: string;
playerCharacter: TCharacter;
targetCharacter: TCharacter;
storyHistory: TStoryMoment[];
context: TContext;
conversationHistory: TConversationTurn[];
previousSummary: string;
targetStatus: TTargetStatus;
};
export type NpcChatDialogueRequest<
TCharacter = unknown,
TEncounter = unknown,
TMonster = unknown,
TStoryMoment = unknown,
TContext = unknown,
> = {
worldType: string;
character: TCharacter;
encounter: TEncounter;
monsters: TMonster[];
history: TStoryMoment[];
context: TContext;
topic: string;
resultSummary: string;
npcInitiatesConversation?: boolean;
};
export type NpcChatTurnRequest<
TCharacter = unknown,
TEncounter = unknown,
TMonster = unknown,
TStoryMoment = unknown,
TContext = unknown,
TConversationTurn = unknown,
TCombatContext = unknown,
TNpcState = unknown,
TQuestOfferState = unknown,
TQuestOfferEncounter = unknown,
TChatDirective = NpcChatTurnDirective,
> = {
worldType: string;
character?: TCharacter;
player?: TCharacter;
encounter: TEncounter;
monsters: TMonster[];
history: TStoryMoment[];
context: TContext;
conversationHistory?: TConversationTurn[];
dialogue?: TConversationTurn[];
combatContext?: TCombatContext | null;
playerMessage: string;
npcState: TNpcState;
npcInitiatesConversation?: boolean;
questOfferContext?: {
state: TQuestOfferState;
encounter: TQuestOfferEncounter;
turnCount: number;
} | null;
chatDirective?: TChatDirective | null;
};
export type NpcChatPendingQuestOffer<TQuest = unknown> = {
quest: TQuest;
introText?: string;
};
export type NpcChatTurnResult<TQuest = unknown> = {
npcReply: string;
affinityDelta: number;
affinityText: string;
suggestions: string[];
pendingQuestOffer?: NpcChatPendingQuestOffer<TQuest> | null;
chatDirective?: NpcChatTurnCompletionDirective | null;
};
export type NpcRecruitDialogueRequest<
TCharacter = unknown,
TEncounter = unknown,
TMonster = unknown,
TStoryMoment = unknown,
TContext = unknown,
> = {
worldType: string;
character: TCharacter;
encounter: TEncounter;
monsters: TMonster[];
history: TStoryMoment[];
context: TContext;
invitationText: string;
recruitSummary: string;
};
export type RuntimeItemIntentRequest<
TContext = JsonObject,
TPlan = JsonObject,
> = {
context: TContext;
plans: TPlan[];
};
export type RuntimeItemIntentResponse<TIntent = JsonObject> = {
intents: TIntent[];
};
export type QuestGenerationRequest<
TState = JsonObject,
TEncounter = JsonObject,
> = {
state: TState;
encounter: TEncounter;
};
export type RuntimeAction<
TType extends string = string,
TPayload = JsonObject,
> = {
type: TType;
functionId?: string;
targetId?: string;
payload?: TPayload;
};
export type RuntimeActionRequest<
TAction extends RuntimeAction = RuntimeAction,
> = {
sessionId: string;
clientVersion?: number;
action: TAction;
};
export type RuntimeActionResponse<
TViewModel = JsonObject,
TPresentation = JsonObject,
TPatch = JsonObject,
> = {
sessionId: string;
serverVersion: number;
viewModel: TViewModel;
presentation: TPresentation;
patches: TPatch[];
};
export const TASK5_RUNTIME_FUNCTION_IDS = [
'story_continue_adventure',
'story_opening_camp_dialogue',
'camp_travel_home_scene',
'idle_call_out',
'idle_explore_forward',
'idle_observe_signs',
'idle_rest_focus',
'idle_travel_next_scene',
'battle_attack_basic',
'battle_use_skill',
'battle_all_in_crush',
'battle_escape_breakout',
'battle_feint_step',
'battle_finisher_window',
'battle_guard_break',
'battle_probe_pressure',
'battle_recover_breath',
'npc_chat',
'npc_fight',
'npc_help',
'npc_leave',
'npc_preview_talk',
'npc_recruit',
'npc_spar',
] as const;
export type Task5RuntimeFunctionId =
(typeof TASK5_RUNTIME_FUNCTION_IDS)[number];
export const TASK6_RUNTIME_FUNCTION_IDS = [
'equipment_equip',
'equipment_unequip',
'forge_craft',
'forge_dismantle',
'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',
'treasure_inspect',
'treasure_leave',
'treasure_secure',
] as const;
export type Task6RuntimeFunctionId =
(typeof TASK6_RUNTIME_FUNCTION_IDS)[number];
export const SERVER_RUNTIME_FUNCTION_IDS = [
...TASK5_RUNTIME_FUNCTION_IDS,
...TASK6_RUNTIME_FUNCTION_IDS,
] as const;
export type ServerRuntimeFunctionId =
(typeof SERVER_RUNTIME_FUNCTION_IDS)[number];
export const TASK5_RUNTIME_OPTION_SCOPES = ['story', 'combat', 'npc'] as const;
export type Task5RuntimeOptionScope =
(typeof TASK5_RUNTIME_OPTION_SCOPES)[number];
export type RuntimeStoryChoicePayload = JsonObject & {
optionText?: string;
note?: string;
};
export type RuntimeStoryOptionInteraction =
| {
kind: 'npc';
npcId: string;
action:
| 'chat'
| 'help'
| 'fight'
| 'leave'
| 'quest_offer_abandon'
| 'quest_offer_replace'
| 'quest_offer_view'
| 'recruit'
| 'spar'
| 'trade'
| 'gift'
| 'quest_accept'
| 'quest_turn_in';
questId?: string;
}
| {
kind: 'treasure';
action: 'inspect' | 'leave' | 'secure';
};
export type RuntimeStoryChoiceAction = RuntimeAction<
'story_choice',
RuntimeStoryChoicePayload
> & {
functionId: string;
targetId?: string;
};
export type RuntimeStoryOptionView = {
functionId: string;
actionText: string;
detailText?: string;
scope: Task5RuntimeOptionScope;
interaction?: RuntimeStoryOptionInteraction;
payload?: RuntimeStoryChoicePayload;
disabled?: boolean;
reason?: string;
};
export type RuntimeStoryPlayerViewModel = {
hp: number;
maxHp: number;
mana: number;
maxMana: number;
};
export type RuntimeStoryCompanionViewModel = {
npcId: string;
characterId?: string;
joinedAtAffinity: number;
};
export type RuntimeStoryEncounterViewModel = {
id: string;
kind: 'npc' | 'treasure';
npcName: string;
hostile: boolean;
affinity?: number;
recruited?: boolean;
interactionActive: boolean;
battleMode?: 'fight' | 'spar' | null;
};
export type RuntimeStoryStatusViewModel = {
inBattle: boolean;
npcInteractionActive: boolean;
currentNpcBattleMode: 'fight' | 'spar' | null;
currentNpcBattleOutcome: 'fight_victory' | 'spar_complete' | null;
};
export type RuntimeBattlePresentation = {
targetId?: string;
targetName?: string;
damageDealt?: number;
damageTaken?: number;
outcome?: 'ongoing' | 'victory' | 'spar_complete' | 'escaped';
};
export type RuntimeStoryViewModel = {
player: RuntimeStoryPlayerViewModel;
encounter: RuntimeStoryEncounterViewModel | null;
companions: RuntimeStoryCompanionViewModel[];
availableOptions: RuntimeStoryOptionView[];
status: RuntimeStoryStatusViewModel;
};
export type RuntimeStoryPresentation = {
actionText: string;
resultText: string;
storyText: string;
options: RuntimeStoryOptionView[];
toast?: string | null;
battle?: RuntimeBattlePresentation | null;
};
export type RuntimeStoryPatch =
| {
type: 'story_history_append';
actionText: string;
resultText: string;
}
| {
type: 'npc_affinity_changed';
npcId: string;
previousAffinity: number;
nextAffinity: number;
}
| {
type: 'battle_resolved';
functionId: string;
targetId?: string;
damageDealt?: number;
damageTaken?: number;
outcome: 'ongoing' | 'victory' | 'spar_complete' | 'escaped';
}
| {
type: 'status_changed';
inBattle: boolean;
npcInteractionActive: boolean;
currentNpcBattleMode: 'fight' | 'spar' | null;
currentNpcBattleOutcome: 'fight_victory' | 'spar_complete' | null;
}
| {
type: 'encounter_changed';
encounterId: string | null;
};
export type RuntimeStoryActionRequest =
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,
TSnapshotCurrentStory = JsonObject,
> = RuntimeActionResponse<
RuntimeStoryViewModel,
RuntimeStoryPresentation,
RuntimeStoryPatch
> & {
snapshot: SavedGameSnapshot<
TSnapshotGameState,
string,
TSnapshotCurrentStory
>;
};

View File

@@ -1,13 +1,19 @@
export * from './assets/qwenSprite';
export * from './contracts/auth';
export * from './contracts/common';
export type * from './contracts/customWorldAgent';
export * from './contracts/rpgAgentActions';
export * from './contracts/rpgAgentAnchors';
export * from './contracts/rpgAgentDraft';
export * from './contracts/rpgAgentSession';
export * from './contracts/rpgCreationFixtures';
export * from './contracts/rpgCreationPreview';
export * from './contracts/rpgCreationWorkSummary';
export * from './contracts/rpgRuntimeChat';
export * from './contracts/rpgRuntimeQuestAssist';
export * from './contracts/rpgRuntimeStoryAction';
export * from './contracts/rpgRuntimeStoryState';
export * from './contracts/runtime';
export * from './contracts/story';
export * from './http';
export * from './llm/narrativeLanguage';
export * from './llm/parsers';

View File

@@ -208,7 +208,10 @@ async function authEntry(baseUrl: string, username: string, password: string) {
username: string;
};
};
const refreshCookie = response.headers.get('set-cookie');
const refreshCookie = buildCookieHeader(
response.headers.get('set-cookie'),
'genarrative_refresh_session',
);
assert.equal(response.status, 200);
assert.ok(payload.token);
@@ -263,7 +266,10 @@ async function phoneLogin(baseUrl: string, phone: string, code = '123456') {
wechatBound: boolean;
};
};
const refreshCookie = response.headers.get('set-cookie');
const refreshCookie = buildCookieHeader(
response.headers.get('set-cookie'),
'genarrative_refresh_session',
);
assert.equal(response.status, 200);
assert.ok(payload.token);
@@ -449,6 +455,191 @@ async function createObjectRefiningCustomWorldAgentSession(params: {
return session;
}
async function markAgentSessionPublishReady(params: {
context: TestAppContext;
userId: string;
sessionId: string;
}) {
const snapshot = await params.context.customWorldAgentOrchestrator.getSessionSnapshot(
params.userId,
params.sessionId,
);
const draftProfile = snapshot?.draftProfile as Record<string, unknown> | null;
const playableNpcs = Array.isArray(draftProfile?.playableNpcs)
? (draftProfile?.playableNpcs as Array<Record<string, unknown>>)
: [];
const storyNpcs = Array.isArray(draftProfile?.storyNpcs)
? (draftProfile?.storyNpcs as Array<Record<string, unknown>>)
: [];
const landmarks = Array.isArray(draftProfile?.landmarks)
? (draftProfile?.landmarks as Array<Record<string, unknown>>)
: [];
const sceneChapters = Array.isArray(draftProfile?.sceneChapters)
? (draftProfile?.sceneChapters as Array<Record<string, unknown>>)
: [];
const camp =
draftProfile?.camp && typeof draftProfile.camp === 'object'
? (draftProfile.camp as Record<string, unknown>)
: null;
const firstPlayableRoleId =
typeof playableNpcs[0]?.id === 'string' && playableNpcs[0]?.id.trim()
? playableNpcs[0].id.trim()
: null;
const firstStoryRoleId =
typeof storyNpcs[0]?.id === 'string' && storyNpcs[0]?.id.trim()
? storyNpcs[0].id.trim()
: firstPlayableRoleId;
assert.ok(snapshot);
assert.ok(draftProfile);
assert.ok(playableNpcs.length > 0);
assert.ok(storyNpcs.length > 0);
assert.ok(landmarks.length > 0);
assert.ok(sceneChapters.length > 0);
assert.ok(firstStoryRoleId);
await params.context.customWorldAgentSessions.replaceDerivedState(
params.userId,
params.sessionId,
{
stage: 'ready_to_publish',
qualityFindings: [],
draftProfile: {
...draftProfile,
chapters:
Array.isArray(draftProfile.chapters) && draftProfile.chapters.length > 0
? draftProfile.chapters
: [{ id: 'chapter-main-1', title: '主线第一章' }],
camp: {
...(camp ?? {}),
id:
typeof camp?.id === 'string' && camp.id.trim()
? camp.id.trim()
: 'camp-home',
name:
typeof camp?.name === 'string' && camp.name.trim()
? camp.name.trim()
: '归潮营地',
description:
typeof camp?.description === 'string' && camp.description.trim()
? camp.description.trim()
: '可供玩家整理线索的临时据点。',
imageSrc:
typeof camp?.imageSrc === 'string' && camp.imageSrc.trim()
? camp.imageSrc.trim()
: '/generated/camp/publish-ready.png',
generatedSceneAssetId:
typeof camp?.generatedSceneAssetId === 'string' &&
camp.generatedSceneAssetId.trim()
? camp.generatedSceneAssetId.trim()
: 'scene-camp-publish-ready',
generatedScenePrompt:
typeof camp?.generatedScenePrompt === 'string' &&
camp.generatedScenePrompt.trim()
? camp.generatedScenePrompt.trim()
: '潮雾营地发布正式图',
generatedSceneModel:
typeof camp?.generatedSceneModel === 'string' &&
camp.generatedSceneModel.trim()
? camp.generatedSceneModel.trim()
: 'test-scene-model',
},
playableNpcs: playableNpcs.map((entry, index) => ({
...entry,
imageSrc:
typeof entry.imageSrc === 'string' && entry.imageSrc.trim()
? entry.imageSrc.trim()
: `/generated/playable/publish-ready-${index + 1}.png`,
generatedVisualAssetId:
typeof entry.generatedVisualAssetId === 'string' &&
entry.generatedVisualAssetId.trim()
? entry.generatedVisualAssetId.trim()
: `visual-playable-publish-${index + 1}`,
generatedAnimationSetId:
typeof entry.generatedAnimationSetId === 'string' &&
entry.generatedAnimationSetId.trim()
? entry.generatedAnimationSetId.trim()
: `anim-playable-publish-${index + 1}`,
})),
storyNpcs: storyNpcs.map((entry, index) => ({
...entry,
imageSrc:
typeof entry.imageSrc === 'string' && entry.imageSrc.trim()
? entry.imageSrc.trim()
: `/generated/story/publish-ready-${index + 1}.png`,
generatedVisualAssetId:
typeof entry.generatedVisualAssetId === 'string' &&
entry.generatedVisualAssetId.trim()
? entry.generatedVisualAssetId.trim()
: `visual-story-publish-${index + 1}`,
generatedAnimationSetId:
typeof entry.generatedAnimationSetId === 'string' &&
entry.generatedAnimationSetId.trim()
? entry.generatedAnimationSetId.trim()
: `anim-story-publish-${index + 1}`,
})),
landmarks: landmarks.map((entry, index) => ({
...entry,
imageSrc:
typeof entry.imageSrc === 'string' && entry.imageSrc.trim()
? entry.imageSrc.trim()
: `/generated/landmark/publish-ready-${index + 1}.png`,
generatedSceneAssetId:
typeof entry.generatedSceneAssetId === 'string' &&
entry.generatedSceneAssetId.trim()
? entry.generatedSceneAssetId.trim()
: `scene-landmark-publish-${index + 1}`,
generatedScenePrompt:
typeof entry.generatedScenePrompt === 'string' &&
entry.generatedScenePrompt.trim()
? entry.generatedScenePrompt.trim()
: `地点 ${typeof entry.name === 'string' ? entry.name : index + 1} 的正式场景图`,
generatedSceneModel:
typeof entry.generatedSceneModel === 'string' &&
entry.generatedSceneModel.trim()
? entry.generatedSceneModel.trim()
: 'test-scene-model',
})),
sceneChapters: sceneChapters.map((chapter, chapterIndex) => {
const acts = Array.isArray(chapter.acts)
? (chapter.acts as Array<Record<string, unknown>>)
: [];
return {
...chapter,
linkedThreadIds:
Array.isArray(chapter.linkedThreadIds) &&
chapter.linkedThreadIds.length > 0
? chapter.linkedThreadIds
: ['thread-publish-ready'],
acts: acts.map((act, actIndex) => ({
...act,
encounterNpcIds:
Array.isArray(act.encounterNpcIds) && act.encounterNpcIds.length > 0
? act.encounterNpcIds
: [firstStoryRoleId],
primaryNpcId:
typeof act.primaryNpcId === 'string' && act.primaryNpcId.trim()
? act.primaryNpcId.trim()
: firstStoryRoleId,
backgroundImageSrc:
typeof act.backgroundImageSrc === 'string' &&
act.backgroundImageSrc.trim()
? act.backgroundImageSrc.trim()
: `/generated/scene/publish-ready-${chapterIndex + 1}-${actIndex + 1}.png`,
backgroundAssetId:
typeof act.backgroundAssetId === 'string' &&
act.backgroundAssetId.trim()
? act.backgroundAssetId.trim()
: `scene-act-publish-${chapterIndex + 1}-${actIndex + 1}`,
})),
};
}),
},
},
);
}
function parseRedirectHash(location: string) {
const url = new URL(location, 'http://127.0.0.1');
return new URLSearchParams(
@@ -456,6 +647,18 @@ function parseRedirectHash(location: string) {
);
}
function readCookieValue(cookieHeader: string, cookieName: string) {
const match = cookieHeader.match(
new RegExp(`${cookieName}=([^;,\r\n]+)`, 'u'),
);
return match?.[1] ? decodeURIComponent(match[1]) : '';
}
function buildCookieHeader(cookieHeader: string | null | undefined, cookieName: string) {
const value = readCookieValue(cookieHeader || '', cookieName);
return value ? `${cookieName}=${encodeURIComponent(value)}` : '';
}
async function startWechatMockFlow(baseUrl: string, redirectPath = '/') {
const startResponse = await httpRequest(
`${baseUrl}/api/auth/wechat/start?redirectPath=${encodeURIComponent(redirectPath)}`,
@@ -473,18 +676,10 @@ async function startWechatMockFlow(baseUrl: string, redirectPath = '/') {
assert.ok(location);
const hash = parseRedirectHash(location);
const setCookieHeader = callbackResponse.headers.get('set-cookie') || '';
const accessCookie = setCookieHeader
.split(',')
.map((entry) => entry.trim())
.find((entry) => entry.startsWith('genarrative_access_session='));
const token =
accessCookie
?.split(';')[0]
?.split('=')
.slice(1)
.join('=')
.trim() || '';
const token = readCookieValue(
setCookieHeader,
'genarrative_access_session',
);
assert.ok(token);
return {
@@ -1552,7 +1747,10 @@ test('logout-all revokes all refresh sessions and invalidates existing access to
assert.equal(refreshResponse.status, 200);
const entryB = {
token: refreshPayload.token,
refreshCookie: refreshResponse.headers.get('set-cookie') || '',
refreshCookie: buildCookieHeader(
refreshResponse.headers.get('set-cookie'),
'genarrative_refresh_session',
),
};
const logoutAllResponse = await httpRequest(
@@ -1623,7 +1821,7 @@ test('error responses share one structure and preserve request ids', async () =>
assert.equal(response.status, 401);
assert.equal(payload.error.code, 'UNAUTHORIZED');
assert.equal(payload.error.message, '缺少 Authorization Bearer Token');
assert.equal(payload.error.message, '缺少登录凭证');
assert.equal(payload.meta.requestId, requestId);
assert.equal(payload.meta.apiVersion, '2026-04-08');
assert.equal(payload.meta.routeVersion, '2026-04-08');
@@ -2895,6 +3093,98 @@ test('custom world agent draft_foundation action generates draft cards and card
);
});
test('custom world agent stream message returns enriched session payload over sse', async () => {
await withTestServer(
'custom-world-agent-stream-session',
async ({ baseUrl, context }) => {
installTestCustomWorldAgentSingleTurnLlm(context);
const entry = await authEntry(baseUrl, 'cw_agent_stream', 'secret123');
const readySession = await createReadyCustomWorldAgentSession({
baseUrl,
token: entry.token,
});
const foundationResponse = await httpRequest(
`${baseUrl}/api/runtime/custom-world/agent/sessions/${encodeURIComponent(readySession.sessionId)}/actions`,
withBearer(entry.token, {
method: 'POST',
body: JSON.stringify({
action: 'draft_foundation',
}),
}),
);
const foundationPayload = (await foundationResponse.json()) as {
operation: {
operationId: string;
};
};
assert.equal(foundationResponse.status, 200);
await waitForCustomWorldAgentOperation({
baseUrl,
token: entry.token,
sessionId: readySession.sessionId,
operationId: foundationPayload.operation.operationId,
expectedStatus: 'completed',
});
const streamResponse = await httpRequest(
`${baseUrl}/api/runtime/custom-world/agent/sessions/${encodeURIComponent(readySession.sessionId)}/messages/stream`,
withBearer(entry.token, {
method: 'POST',
headers: {
Accept: 'text/event-stream',
},
body: JSON.stringify({
clientMessageId: 'stream-client-1',
text: '把守灯会的压力再收紧一些,玩家与沈砺的旧案关系也再明显一点。',
focusCardId: null,
selectedCardIds: [],
}),
}),
);
const streamText = await streamResponse.text();
const sessionEventMatch = streamText.match(/event: session\s+data: (\{.*\})/u);
assert.equal(streamResponse.status, 200);
assert.match(
streamResponse.headers.get('content-type') ?? '',
/text\/event-stream/u,
);
assert.match(streamText, /event: reply_delta/u);
assert.match(streamText, /event: session/u);
assert.match(streamText, /event: done/u);
assert.ok(sessionEventMatch?.[1]);
const sessionEvent = JSON.parse(sessionEventMatch![1]) as {
session: {
stage: string;
supportedActions?: Array<{ action: string; enabled: boolean }>;
resultPreview?: {
source: string;
preview: { name?: string };
} | null;
};
};
assert.equal(sessionEvent.session.stage, 'object_refining');
assert.equal(
sessionEvent.session.supportedActions?.some(
(entry) =>
entry.action === 'update_draft_card' && entry.enabled === true,
),
true,
);
assert.equal(
sessionEvent.session.resultPreview?.source,
'session_preview',
);
assert.ok(sessionEvent.session.resultPreview?.preview?.name);
},
);
});
test('custom world agent draft_foundation action rejects not-ready sessions over http', async () => {
await withTestServer(
'custom-world-agent-phase3-http-not-ready',
@@ -3197,6 +3487,129 @@ test('custom world agent sync_result_profile action writes result snapshot back
);
});
test('library publish for agent-backed draft returns explicit blocker message when Phase4 gate is not ready', async () => {
await withTestServer(
'custom-world-library-agent-publish-blocked',
async ({ baseUrl, context }) => {
installTestCustomWorldAgentSingleTurnLlm(context);
const entry = await authEntry(
baseUrl,
'cw_library_agent_blocked',
'secret123',
);
const session = await createObjectRefiningCustomWorldAgentSession({
baseUrl,
token: entry.token,
});
const profileId = `agent-draft-${session.sessionId}`;
const publishResponse = await httpRequest(
`${baseUrl}/api/runtime/custom-world-library/${encodeURIComponent(profileId)}/publish`,
withBearer(entry.token, {
method: 'POST',
}),
);
const publishPayload = (await publishResponse.json()) as {
error: {
code: string;
message: string;
};
};
const sessionAfterPublishAttempt =
await context.customWorldAgentOrchestrator.getSessionSnapshot(
entry.user.id,
session.sessionId,
);
assert.equal(publishResponse.status, 409);
assert.equal(publishPayload.error.code, 'CONFLICT');
assert.match(
publishPayload.error.message,
/ \d+ blocker/u,
);
assert.match(
publishPayload.error.message,
/||线/u,
);
assert.notEqual(sessionAfterPublishAttempt?.stage, 'published');
},
);
});
test('library publish for agent-backed draft reuses Phase4 gate and syncs session into published stage', async () => {
await withTestServer(
'custom-world-library-agent-publish-success',
async ({ baseUrl, context }) => {
installTestCustomWorldAgentSingleTurnLlm(context);
const entry = await authEntry(
baseUrl,
'cw_library_agent_success',
'secret123',
);
const session = await createObjectRefiningCustomWorldAgentSession({
baseUrl,
token: entry.token,
});
const profileId = `agent-draft-${session.sessionId}`;
await markAgentSessionPublishReady({
context,
userId: entry.user.id,
sessionId: session.sessionId,
});
const publishResponse = await httpRequest(
`${baseUrl}/api/runtime/custom-world-library/${encodeURIComponent(profileId)}/publish`,
withBearer(entry.token, {
method: 'POST',
}),
);
const publishPayload = (await publishResponse.json()) as {
entry: {
profileId: string;
visibility: 'draft' | 'published';
};
};
const libraryResponse = await httpRequest(
`${baseUrl}/api/runtime/custom-world-library`,
withBearer(entry.token),
);
const libraryPayload = (await libraryResponse.json()) as {
entries: Array<{
profileId: string;
visibility: 'draft' | 'published';
}>;
};
const sessionAfterPublish =
await context.customWorldAgentOrchestrator.getSessionSnapshot(
entry.user.id,
session.sessionId,
);
assert.equal(publishResponse.status, 200);
assert.equal(publishPayload.entry.profileId, profileId);
assert.equal(publishPayload.entry.visibility, 'published');
assert.equal(libraryResponse.status, 200);
assert.equal(
libraryPayload.entries.find((item) => item.profileId === profileId)
?.visibility,
'published',
);
assert.equal(sessionAfterPublish?.stage, 'published');
assert.equal(sessionAfterPublish?.resultPreview?.publishReady, true);
assert.equal(sessionAfterPublish?.resultPreview?.canEnterWorld, true);
assert.deepEqual(sessionAfterPublish?.resultPreview?.blockers ?? [], []);
assert.ok(
sessionAfterPublish?.messages.some(
(message) =>
message.kind === 'action_result' &&
message.text.includes('已正式发布'),
),
);
},
);
});
test('custom world agent generate_characters action appends character cards over http', async () => {
await withTestServer(
'custom-world-agent-phase4-generate-characters-http',

View File

@@ -9,9 +9,13 @@ import { requestIdMiddleware } from './middleware/requestId.js';
import { responseEnvelopeMiddleware } from './middleware/responseEnvelope.js';
import { createCharacterAssetRoutes } from './modules/assets/characterAssetRoutes.js';
import { createEditorRoutes } from './modules/editor/editorRoutes.js';
import { createStoryActionRoutes } from './modules/story/storyActionRoutes.js';
import { createAuthRoutes } from './routes/authRoutes.js';
import { createRuntimeRoutes } from './routes/runtimeRoutes.js';
import { createRpgEntrySaveRoutes } from './routes/rpg-entry/rpgEntrySaveRoutes.js';
import { createRpgWorldLibraryRoutes } from './routes/rpg-entry/rpgWorldLibraryRoutes.js';
import { createRpgProfileRoutes } from './routes/rpg-profile/rpgProfileRoutes.js';
import { createRpgRuntimeAiAssistRoutes } from './routes/rpg-runtime/rpgRuntimeAiAssistRoutes.js';
import { createRpgRuntimeStoryRoutes } from './routes/rpg-runtime/rpgRuntimeStoryRoutes.js';
import { createCustomWorldAgentRoutes } from './routes/customWorldAgent.js';
function matchesRoutePrefix(
request: express.Request,
@@ -118,6 +122,33 @@ export function createApp(context: AppContext) {
createCharacterAssetRoutes(context.config, context.llmClient),
),
);
app.use(
'/api',
scopeToPrefixes(
['/runtime/profile', '/profile', '/runtime/settings'],
withRouteMeta({ routeVersion: '2026-04-21', operation: 'rpg.profile.api' }),
),
createRpgProfileRoutes(context),
);
app.use(
'/api',
scopeToPrefixes(
['/runtime/save', '/runtime/profile/save-archives', '/profile/save-archives'],
withRouteMeta({ routeVersion: '2026-04-21', operation: 'rpg.entry.save.api' }),
),
createRpgEntrySaveRoutes(context),
);
app.use(
'/api',
scopeToPrefixes(
['/runtime/custom-world-gallery', '/runtime/custom-world/works', '/runtime/custom-world-library'],
withRouteMeta({
routeVersion: '2026-04-21',
operation: 'rpg.entry.worldLibrary.api',
}),
),
createRpgWorldLibraryRoutes(context),
);
app.use(
'/api/auth',
withRouteMeta({ routeVersion: '2026-04-08' }),
@@ -125,13 +156,61 @@ export function createApp(context: AppContext) {
);
app.use(
'/api/runtime/story',
withRouteMeta({ routeVersion: '2026-04-08' }),
createStoryActionRoutes(context),
withRouteMeta({ routeVersion: '2026-04-21' }),
createRpgRuntimeStoryRoutes(context),
);
app.use(
scopeToPrefixes(
[
'/llm/chat/completions',
'/custom-world/cover-image',
'/custom-world/cover-upload',
'/custom-world/scene-image',
'/custom-world/entity',
'/custom-world/scene-npc',
'/runtime/custom-world/entity',
'/runtime/custom-world/scene-npc',
'/runtime/custom-world/profile',
'/runtime/story/initial',
'/runtime/story/continue',
'/runtime/chat',
'/runtime/items',
'/runtime/quests',
'/ws/health',
],
withRouteMeta({
routeVersion: '2026-04-21',
operation: 'rpg.runtime.aiAssist.api',
}),
),
);
app.use(
'/api',
withRouteMeta({ routeVersion: '2026-04-08' }),
createRuntimeRoutes(context),
scopeToPrefixes(
[
'/llm/chat/completions',
'/custom-world/cover-image',
'/custom-world/cover-upload',
'/custom-world/scene-image',
'/custom-world/entity',
'/custom-world/scene-npc',
'/runtime/custom-world/entity',
'/runtime/custom-world/scene-npc',
'/runtime/custom-world/profile',
'/runtime/story/initial',
'/runtime/story/continue',
'/runtime/chat',
'/runtime/items',
'/runtime/quests',
'/ws/health',
],
createRpgRuntimeAiAssistRoutes(context),
),
);
app.use(
'/api/runtime/custom-world/agent',
withRouteMeta({ routeVersion: '2026-04-21', operation: 'rpg.creation.agent.api' }),
createCustomWorldAgentRoutes(context),
);
app.use(
express.static(context.config.publicDir, {

View File

@@ -32,6 +32,21 @@ function buildCookieParts(
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 hashRefreshSessionToken(token: string) {
return crypto.createHash('sha256').update(token).digest('hex');
}
@@ -46,8 +61,8 @@ export function setRefreshSessionCookie(
token: string,
maxAgeSeconds: number,
) {
response.setHeader(
'Set-Cookie',
appendSetCookieHeader(
response,
buildCookieParts(config, token, {
maxAgeSeconds,
}),
@@ -55,8 +70,8 @@ export function setRefreshSessionCookie(
}
export function clearRefreshSessionCookie(response: Response, config: AppConfig) {
response.setHeader(
'Set-Cookie',
appendSetCookieHeader(
response,
buildCookieParts(config, '', {
maxAgeSeconds: 0,
}),

View File

@@ -5,6 +5,13 @@ import type { AppDatabase } from './db.js';
import { AuthAuditLogRepository } from './repositories/authAuditLogRepository.js';
import { AuthIdentityRepository } from './repositories/authIdentityRepository.js';
import { AuthRiskBlockRepository } from './repositories/authRiskBlockRepository.js';
import type { RpgAgentSessionRepository } from './repositories/RpgAgentSessionRepository.js';
import type { RpgSaveArchiveRepository } from './repositories/rpg-entry/RpgSaveArchiveRepository.js';
import type { RpgWorldLibraryRepository } from './repositories/rpg-entry/RpgWorldLibraryRepository.js';
import type { RpgBrowseHistoryRepository } from './repositories/rpg-profile/RpgBrowseHistoryRepository.js';
import type { RpgProfileDashboardRepository } from './repositories/rpg-profile/RpgProfileDashboardRepository.js';
import type { RpgRuntimeSnapshotRepository } from './repositories/rpg-runtime/RpgRuntimeSnapshotRepository.js';
import type { RpgWorldProfileRepository } from './repositories/RpgWorldProfileRepository.js';
import { RuntimeRepository } from './repositories/runtimeRepository.js';
import { SmsAuthEventRepository } from './repositories/smsAuthEventRepository.js';
import { UserRepository } from './repositories/userRepository.js';
@@ -13,6 +20,7 @@ import { CaptchaChallengeStore } from './services/captchaChallengeStore.js';
import { CustomWorldAgentOrchestrator } from './services/customWorldAgentOrchestrator.js';
import { CustomWorldAgentSessionStore } from './services/customWorldAgentSessionStore.js';
import { UpstreamLlmClient } from './services/llmClient.js';
import type { RpgWorldWorkSummaryService } from './services/RpgWorldWorkSummaryService.js';
import type { SmsVerificationService } from './services/smsVerificationService.js';
import type { WechatAuthService } from './services/wechatAuthService.js';
import { WechatAuthStateStore } from './services/wechatAuthStateStore.js';
@@ -27,10 +35,18 @@ export type AppContext = {
authRiskBlockRepository: AuthRiskBlockRepository;
smsAuthEventRepository: SmsAuthEventRepository;
userSessionRepository: UserSessionRepository;
rpgAgentSessionRepository: RpgAgentSessionRepository;
rpgWorldProfileRepository: RpgWorldProfileRepository;
rpgProfileDashboardRepository: RpgProfileDashboardRepository;
rpgBrowseHistoryRepository: RpgBrowseHistoryRepository;
rpgSaveArchiveRepository: RpgSaveArchiveRepository;
rpgWorldLibraryRepository: RpgWorldLibraryRepository;
rpgRuntimeSnapshotRepository: RpgRuntimeSnapshotRepository;
runtimeRepository: RuntimeRepository;
llmClient: UpstreamLlmClient;
customWorldAgentSessions: CustomWorldAgentSessionStore;
customWorldAgentOrchestrator: CustomWorldAgentOrchestrator;
rpgWorldWorkSummaryService: RpgWorldWorkSummaryService;
smsVerificationService: SmsVerificationService;
wechatAuthService: WechatAuthService;
wechatAuthStates: WechatAuthStateStore;

View File

@@ -9,7 +9,7 @@ import type {
type NpcChatTurnCompletionDirective,
NpcChatTurnRequest,
NpcRecruitDialogueRequest,
} from '../../../../packages/shared/src/contracts/story.js';
} from '../../../../packages/shared/src/contracts/rpgRuntimeChat.js';
import { parseLineListContent } from '../../../../packages/shared/src/llm/parsers.js';
import { prepareEventStreamResponse } from '../../http.js';
import type { UpstreamLlmClient } from '../../services/llmClient.js';

View File

@@ -4,7 +4,7 @@ import test from 'node:test';
import type {
CharacterChatSuggestionsRequest,
NpcChatTurnRequest,
} from '../../../../packages/shared/src/contracts/story.js';
} from '../../../../packages/shared/src/contracts/rpgRuntimeChat.js';
import { createTestPlayerCharacter } from '../../testFixtures/runtimeCharacter.js';
import {
generateCharacterChatSuggestionsFromOrchestrator,

View File

@@ -1,8 +1,10 @@
import type {
RuntimeBattlePresentation,
RuntimeStoryChoicePayload,
RuntimeStoryPatch,
} from '../../../../packages/shared/src/contracts/story.js';
} from '../../../../packages/shared/src/contracts/rpgRuntimeStoryState.js';
import type {
RuntimeStoryChoicePayload,
} from '../../../../packages/shared/src/contracts/rpgRuntimeStoryAction.js';
import {
buildInventoryUseResultText,
incrementGameRuntimeStats,
@@ -26,7 +28,7 @@ import {
getPlayerSkillCooldowns,
setEncounterNpcState,
type RuntimeSession,
} from '../story/runtimeSession.js';
} from '../rpg-runtime-story/RpgRuntimeSessionPrimitives.js';
type CombatActionConfig = {
actionText: string;

View File

@@ -0,0 +1,365 @@
import type {
AttributeVector,
CustomWorldNpc,
CustomWorldPlayableNpc,
RoleAttributeProfile,
WorldAttributeSchema,
WorldAttributeSlot,
WorldType,
} from '../runtimeTypes.js';
import { inferWorldTypeFromSetting } from './creatorIntentBridge.js';
import { slugify } from './normalizeShared.js';
/**
* 工作包 G
* 把 attribute schema 构建和角色属性画像编译从主 runtime compiler 中抽离,
* 让结果预览编译、世界基础 profile 归一和角色属性推导有清晰边界。
*/
const WORLD_ATTRIBUTE_SLOT_IDS = [
'axis_a',
'axis_b',
'axis_c',
'axis_d',
'axis_e',
'axis_f',
] as const;
const AXIS_KEYWORD_RULES: Array<{
slotId: string;
patterns: RegExp[];
weight: number;
}> = [
{ slotId: 'axis_a', patterns: [/||||||||||/u], weight: 16 },
{ slotId: 'axis_b', patterns: [/|||||||||/u], weight: 16 },
{ slotId: 'axis_c', patterns: [/|||||||||/u], weight: 16 },
{ slotId: 'axis_d', patterns: [/|||||||||/u], weight: 16 },
{ slotId: 'axis_e', patterns: [/|||||||||/u], weight: 16 },
{ slotId: 'axis_f', patterns: [/|||||||||/u], weight: 16 },
];
export function buildTemplateWorldAttributeSchema(
worldType: Exclude<WorldType, 'CUSTOM'>,
) {
const common = {
schemaVersion: 1,
generatedFrom:
worldType === 'XIANXIA'
? {
worldType: 'XIANXIA' as const,
worldName: '仙侠',
settingSummary: '灵潮、宗门、禁制、秘境与道途交织。',
tone: '空灵、危险、带着灾变与大道压迫。',
conflictCore: '在裂变与因果之间稳住自我与道途。',
}
: {
worldType: 'WUXIA' as const,
worldName: '武侠',
settingSummary: '江湖、门派、旧案与人情纠葛并存。',
tone: '克制、紧张、讲究局势与心气。',
conflictCore: '在人情、威压与旧案之间立住自身。',
},
};
if (worldType === 'XIANXIA') {
return {
id: 'schema:xianxia:v1',
worldId: 'XIANXIA',
schemaName: '灵界六轴',
...common,
slots: [
{
slotId: 'axis_a',
name: '道骨',
definition: '承载道压与高强度冲击的底子。',
positiveSignals: ['承压', '根基稳', '扛得住'],
negativeSignals: ['根基浅', '易溃', '承载不足'],
combatUseText: '扛住灵压、正面承受高强度对撞。',
socialUseText: '让人感到根基扎实,值得托付重事。',
explorationUseText: '承受秘境、禁制与裂隙带来的压迫。',
},
{
slotId: 'axis_b',
name: '灵行',
definition: '位移、御空、转场、抢占天时地利的能力。',
positiveSignals: ['位移', '御空', '机动'],
negativeSignals: ['迟滞', '失位', '转场慢'],
combatUseText: '抢位、御空、快速重整战场位置。',
socialUseText: '反应轻快,擅长顺势接住局面的变化。',
explorationUseText: '穿越高危地形、裂隙、云海与复杂禁区。',
},
{
slotId: 'axis_c',
name: '识海',
definition: '解析禁制、洞察因果、识破虚实的能力。',
positiveSignals: ['洞察', '解构', '看破'],
negativeSignals: ['迷失', '误判', '看不清'],
combatUseText: '识破术理、找出因果节点与破绽。',
socialUseText: '更容易辨认真话、虚言与隐藏动机。',
explorationUseText: '解读阵纹、禁制、旧史与环境异象。',
},
{
slotId: 'axis_d',
name: '劫纹',
definition: '在高危变化中强行推进、改写局势的能力。',
positiveSignals: ['强推', '决断', '逆转'],
negativeSignals: ['畏缩', '迟疑', '不敢碰变局'],
combatUseText: '在高压窗口里压上去,逼出变化与突破。',
socialUseText: '在关键谈判中拍板,推动他人表态。',
explorationUseText: '面对异变与风险时敢于推进关键节点。',
},
{
slotId: 'axis_e',
name: '心契',
definition: '与他者、器物、灵兽、誓约建立共鸣的能力。',
positiveSignals: ['共鸣', '结契', '安抚'],
negativeSignals: ['隔阂', '生硬', '难以共振'],
combatUseText: '与器物、灵兽、同伴形成协同与共鸣。',
socialUseText: '建立信任、誓约与更深层的关系连结。',
explorationUseText: '借由共鸣打开封印、回应遗物或安抚异兽。',
},
{
slotId: 'axis_f',
name: '玄息',
definition: '循环灵息、稳住心神、让自身持续在线的能力。',
positiveSignals: ['稳态', '回转', '续航'],
negativeSignals: ['紊乱', '枯竭', '失衡'],
combatUseText: '维持灵息循环、拖住长线压力与消耗。',
socialUseText: '气息沉稳,不轻易乱阵脚或露出破绽。',
explorationUseText: '在漫长探索与灵潮侵蚀中维持可行动状态。',
},
] satisfies WorldAttributeSlot[],
} satisfies WorldAttributeSchema;
}
return {
id: 'schema:wuxia:v1',
worldId: 'WUXIA',
schemaVersion: 1,
schemaName: '江湖六脉',
generatedFrom: common.generatedFrom,
slots: [
{
slotId: 'axis_a',
name: '骨势',
definition: '扛压、顶冲、硬吃风险也不退的势头。',
positiveSignals: ['扛压', '硬桥硬马', '稳住正面'],
negativeSignals: ['虚浮', '怯退', '一碰就散'],
combatUseText: '顶住正面压力、换伤不退、撑住阵线。',
socialUseText: '在强压场面里不露怯,给人可靠或强硬之感。',
explorationUseText: '穿越险路、硬顶机关、承受高压环境。',
},
{
slotId: 'axis_b',
name: '身法',
definition: '腾挪、抢位、换线、把握出手节奏的能力。',
positiveSignals: ['快', '轻灵', '抢位'],
negativeSignals: ['迟缓', '失位', '笨重'],
combatUseText: '切线换位、闪转腾挪、争夺先手。',
socialUseText: '应变快,擅长观察气口并顺势接话。',
explorationUseText: '攀越、潜入、追踪与复杂地形穿行。',
},
{
slotId: 'axis_c',
name: '眼脉',
definition: '看破破绽、拆招、识局、看穿人心的能力。',
positiveSignals: ['识局', '洞察', '拆招'],
negativeSignals: ['迟钝', '误判', '看不透'],
combatUseText: '抓破绽、拆套路、找出最该切入的位置。',
socialUseText: '判断弦外之音、试探真假、识别来意。',
explorationUseText: '识破机关、辨认痕迹、看懂异状。',
},
{
slotId: 'axis_d',
name: '心焰',
definition: '决断、压迫、胆气、在局面中立住自身意志的能力。',
positiveSignals: ['胆气', '决断', '压迫'],
negativeSignals: ['犹疑', '软弱', '易被动摇'],
combatUseText: '逼迫对手、强行推进、在关键时刻拍板。',
socialUseText: '立威、定调、在谈判里压住场子。',
explorationUseText: '在未知风险前保持决断,不被局势拖死。',
},
{
slotId: 'axis_e',
name: '尘缘',
definition: '与人事、情面、承诺、牵引关系打交道的能力。',
positiveSignals: ['通人情', '会安抚', '懂交换'],
negativeSignals: ['生硬', '失礼', '不近人情'],
combatUseText: '借势协同、读懂同伴与对手的关系脉络。',
socialUseText: '安抚、求助、结盟、维系承诺与信任。',
explorationUseText: '从传闻、人脉和地方关系里打开线索。',
},
{
slotId: 'axis_f',
name: '玄息',
definition: '调息、稳态、久战、把自身维持在可用状态的能力。',
positiveSignals: ['稳', '续战', '调息'],
negativeSignals: ['紊乱', '易崩', '续不上'],
combatUseText: '续战、回气、稳住节奏与状态。',
socialUseText: '遇事不乱,语气和姿态都更沉稳可信。',
explorationUseText: '长线跋涉、在恶劣环境下维持专注与状态。',
},
] satisfies WorldAttributeSlot[],
} satisfies WorldAttributeSchema;
}
export function generateWorldAttributeSchema(input: {
worldName: string;
settingText: string;
summary: string;
tone: string;
playerGoal: string;
}) {
const inferredWorldType = inferWorldTypeFromSetting(input.settingText);
const template = buildTemplateWorldAttributeSchema(
inferredWorldType === 'XIANXIA' ? 'XIANXIA' : 'WUXIA',
);
return {
...template,
id: `schema:custom:${slugify(input.worldName)}`,
worldId: `custom:${input.worldName}`,
generatedFrom: {
worldType: 'CUSTOM',
worldName: input.worldName,
settingSummary: input.summary,
tone: input.tone,
conflictCore: input.playerGoal,
},
} satisfies WorldAttributeSchema;
}
function normalizeAttributeValues(
values: AttributeVector,
slotIds: readonly string[],
targetTotal = 360,
) {
const positiveValues = slotIds.map((slotId) => Math.max(0, values[slotId] ?? 0));
const rawTotal = positiveValues.reduce((sum, value) => sum + value, 0);
const normalized =
rawTotal > 0
? positiveValues.map((value) => (value / rawTotal) * targetTotal)
: slotIds.map(() => targetTotal / Math.max(slotIds.length, 1));
const rounded = normalized.map((value) => Math.max(0, Math.min(100, Math.round(value))));
return Object.fromEntries(
slotIds.map((slotId, index) => [slotId, rounded[index] ?? 0]),
) as AttributeVector;
}
function ensureRoleAttributeProfile(
profile: Partial<RoleAttributeProfile> | null | undefined,
schema: WorldAttributeSchema,
fallbackValues: AttributeVector,
): RoleAttributeProfile {
const slotIds = schema.slots.map((slot) => slot.slotId);
const values = normalizeAttributeValues(
{
...fallbackValues,
...(profile?.values ?? {}),
},
slotIds,
);
const sortedSlots = [...schema.slots]
.map((slot) => ({
slot,
value: values[slot.slotId] ?? 0,
}))
.sort((left, right) => right.value - left.value);
return {
schemaId: profile?.schemaId ?? schema.id,
values,
topTraits: sortedSlots.slice(0, 2).map((entry) => entry.slot.name),
hiddenTraits: profile?.hiddenTraits ? [...profile.hiddenTraits] : undefined,
evidence:
profile?.evidence?.length
? [...profile.evidence]
: sortedSlots.slice(0, 3).map((entry) => ({
slotId: entry.slot.slotId,
reason: `${entry.slot.name}在当前画像中最突出。`,
})),
};
}
function buildDefaultAxisVector(
overrides: Partial<Record<(typeof WORLD_ATTRIBUTE_SLOT_IDS)[number], number>>,
) {
return WORLD_ATTRIBUTE_SLOT_IDS.reduce<AttributeVector>((result, slotId) => {
result[slotId] = overrides[slotId] ?? 0;
return result;
}, {});
}
function buildRoleAttributeProfileFromTexts(params: {
schema: WorldAttributeSchema;
textBlocks: Array<string | null | undefined>;
}) {
const sourceText = params.textBlocks.filter(Boolean).join(' ');
const seed = buildDefaultAxisVector({
axis_a: 58,
axis_b: 58,
axis_c: 58,
axis_d: 58,
axis_e: 58,
axis_f: 58,
});
AXIS_KEYWORD_RULES.forEach((rule) => {
const matches = rule.patterns.reduce(
(count, pattern) => count + (pattern.test(sourceText) ? 1 : 0),
0,
);
if (matches <= 0) {
return;
}
seed[rule.slotId] = (seed[rule.slotId] ?? 0) + rule.weight * matches;
});
return ensureRoleAttributeProfile(
{
schemaId: params.schema.id,
},
params.schema,
seed,
);
}
export function buildCustomWorldPlayableNpcAttributeProfile(
npc: CustomWorldPlayableNpc,
schema: WorldAttributeSchema,
) {
return buildRoleAttributeProfileFromTexts({
schema,
textBlocks: [
npc.title,
npc.role,
npc.description,
npc.backstory,
npc.personality,
npc.motivation,
npc.combatStyle,
...(npc.relationshipHooks ?? []),
...(npc.tags ?? []),
],
});
}
export function buildCustomWorldStoryNpcAttributeProfile(
npc: CustomWorldNpc,
schema: WorldAttributeSchema,
) {
return buildRoleAttributeProfileFromTexts({
schema,
textBlocks: [
npc.title,
npc.role,
npc.description,
npc.backstory,
npc.personality,
npc.motivation,
npc.combatStyle,
...(npc.relationshipHooks ?? []),
...(npc.tags ?? []),
],
});
}

View File

@@ -0,0 +1,410 @@
import type {
CustomWorldGenerationFramework,
CustomWorldProfile,
} from '../runtimeTypes.js';
import {
buildCustomWorldPlayableNpcAttributeProfile,
buildCustomWorldStoryNpcAttributeProfile,
generateWorldAttributeSchema,
} from './buildAttributeSchema.js';
import {
buildWorldName,
inferWorldTypeFromSetting,
normalizeWorldType,
normalizeCustomWorldCreatorIntent,
normalizeCustomWorldLockState,
resolveCustomWorldRuntimeIntentBridge,
} from './creatorIntentBridge.js';
import {
buildFallbackCustomWorldCampScene,
normalizeCampOutline,
normalizeCampScene,
} from './normalizeCamp.js';
import {
buildCustomWorldRawProfileLandmarksFromFramework,
normalizeLandmarkOutlineList,
normalizeLandmarks,
} from './normalizeLandmark.js';
import {
buildCustomWorldRawProfileRolesFromFramework,
normalizeCustomWorldGenerationFrameworkRoles,
normalizePlayableNpcList,
normalizeStoryNpcList,
} from './normalizeRole.js';
import {
buildDefaultCustomWorldCover,
MIN_CUSTOM_WORLD_LANDMARK_COUNT,
MIN_CUSTOM_WORLD_PLAYABLE_NPC_COUNT,
normalizeCustomWorldCover,
normalizeItemList,
normalizeTags,
PLAYABLE_TEMPLATE_CHARACTER_IDS,
slugify,
toRecordArray,
toText,
} from './normalizeShared.js';
import { normalizeSceneChapterBlueprints } from './normalizeSceneChapter.js';
/**
* 工作包 G
* 让 runtime profile 真正由“主编译入口 + 目录化 normalize/build 子模块”组成。
*/
function buildBaseCustomWorldProfile(settingText: string): CustomWorldProfile {
const templateWorldType = inferWorldTypeFromSetting(settingText);
const name = buildWorldName(settingText, templateWorldType);
const subtitle = '前路未明';
const summary = settingText.trim()
? `这个世界围绕“${settingText.trim().slice(0, 28)}”展开。`
: '一个仍待展开的独立世界正在成形。';
const tone = '未知、紧绷、仍在展开';
const playerGoal = '查清眼前局势的关键矛盾,并守住仍值得相信的人与事';
const camp = buildFallbackCustomWorldCampScene({
name,
summary,
tone,
playerGoal,
settingText: settingText.trim(),
});
return {
id: `custom-world-${Date.now().toString(36)}-${slugify(name)}`,
settingText: settingText.trim(),
name,
subtitle,
summary,
tone,
playerGoal,
cover: buildDefaultCustomWorldCover([]),
templateWorldType,
compatibilityTemplateWorldType: templateWorldType,
majorFactions: [],
coreConflicts: [summary],
attributeSchema: generateWorldAttributeSchema({
worldName: name,
settingText: settingText.trim(),
summary,
tone,
playerGoal,
}),
playableNpcs: [],
storyNpcs: [],
items: [],
camp,
landmarks: [],
themePack: null,
storyGraph: null,
creatorIntent: null,
anchorPack: null,
lockState: normalizeCustomWorldLockState(null),
generationMode: 'full',
generationStatus: 'complete',
ownedSettingLayers: null,
scenarioPackId: null,
campaignPackId: null,
};
}
export function normalizeCustomWorldGenerationFramework(
raw: unknown,
settingText: string,
): CustomWorldGenerationFramework {
const fallback = buildBaseCustomWorldProfile(settingText);
if (!raw || typeof raw !== 'object') {
return {
settingText: fallback.settingText,
name: fallback.name,
subtitle: fallback.subtitle,
summary: fallback.summary,
tone: fallback.tone,
playerGoal: fallback.playerGoal,
templateWorldType: fallback.templateWorldType,
compatibilityTemplateWorldType:
fallback.compatibilityTemplateWorldType ?? fallback.templateWorldType,
majorFactions: [],
coreConflicts: [fallback.summary],
camp: {
name: fallback.camp?.name ?? '归舍',
description: fallback.camp?.description ?? '',
dangerLevel: fallback.camp?.dangerLevel ?? 'low',
},
playableNpcs: [],
storyNpcs: [],
landmarks: [],
};
}
const item = raw as Record<string, unknown>;
const roleState = normalizeCustomWorldGenerationFrameworkRoles({
raw: item,
fallback,
settingText,
});
return {
settingText: settingText.trim(),
name: roleState.name,
subtitle: toText(item.subtitle) || fallback.subtitle,
summary: toText(item.summary) || fallback.summary,
tone: toText(item.tone) || fallback.tone,
playerGoal: toText(item.playerGoal) || fallback.playerGoal,
templateWorldType: roleState.templateWorldType,
compatibilityTemplateWorldType: roleState.templateWorldType,
majorFactions: normalizeTags(item.majorFactions, []),
coreConflicts: normalizeTags(item.coreConflicts, [fallback.summary]),
camp: {
name: normalizeCampOutline(item.camp, roleState.campFallbackProfile).name,
description: normalizeCampOutline(item.camp, roleState.campFallbackProfile)
.description,
dangerLevel: normalizeCampOutline(item.camp, roleState.campFallbackProfile)
.dangerLevel,
},
playableNpcs: roleState.playableNpcs,
storyNpcs: roleState.storyNpcs,
landmarks: normalizeLandmarkOutlineList(item.landmarks),
};
}
export function buildCustomWorldRawProfileFromFramework(
framework: CustomWorldGenerationFramework,
) {
return {
name: framework.name,
subtitle: framework.subtitle,
summary: framework.summary,
tone: framework.tone,
playerGoal: framework.playerGoal,
templateWorldType: framework.templateWorldType,
compatibilityTemplateWorldType: framework.compatibilityTemplateWorldType,
majorFactions: framework.majorFactions,
coreConflicts: framework.coreConflicts,
camp: {
name: framework.camp.name,
description: framework.camp.description,
dangerLevel: framework.camp.dangerLevel,
},
...buildCustomWorldRawProfileRolesFromFramework(framework),
landmarks: buildCustomWorldRawProfileLandmarksFromFramework(framework),
};
}
function pickCyclic<T>(items: readonly T[], index: number, label: string): T {
const item = items[index % items.length];
if (item === undefined) {
throw new Error(`Missing ${label}`);
}
return item;
}
export function normalizeCustomWorldProfile(
raw: unknown,
settingText: string,
): CustomWorldProfile {
const fallback = buildBaseCustomWorldProfile(settingText);
if (!raw || typeof raw !== 'object') {
return fallback;
}
const item = raw as Record<string, unknown>;
const worldSignalText = [
settingText,
toText(item.subtitle),
toText(item.summary),
toText(item.tone),
toText(item.playerGoal),
].join(' ');
const templateWorldType = normalizeWorldType(
item.templateWorldType,
worldSignalText,
);
const name =
toText(item.name) || buildWorldName(settingText, templateWorldType);
const summary = toText(item.summary) || fallback.summary;
const tone = toText(item.tone) || fallback.tone;
const playerGoal = toText(item.playerGoal) || fallback.playerGoal;
const generatedAttributeSchema = generateWorldAttributeSchema({
worldName: name,
settingText: settingText.trim(),
summary,
tone,
playerGoal,
});
const playableNpcs = normalizePlayableNpcList(item.playableNpcs);
const storyNpcs = normalizeStoryNpcList(item.storyNpcs);
const landmarkDrafts = toRecordArray(item.landmarks);
const camp = normalizeCampScene(item.camp, {
name,
summary,
tone,
playerGoal,
settingText: settingText.trim(),
});
const runtimeBridge = resolveCustomWorldRuntimeIntentBridge(item);
return {
id:
toText(item.id) || `custom-world-${Date.now().toString(36)}-${slugify(name)}`,
settingText: settingText.trim(),
name,
subtitle: toText(item.subtitle) || fallback.subtitle,
summary,
tone,
playerGoal,
cover: normalizeCustomWorldCover(item.cover, playableNpcs),
templateWorldType,
compatibilityTemplateWorldType: templateWorldType,
majorFactions: normalizeTags(item.majorFactions, []),
coreConflicts: normalizeTags(item.coreConflicts, [summary]),
attributeSchema:
item.attributeSchema && typeof item.attributeSchema === 'object'
? generatedAttributeSchema
: generatedAttributeSchema,
playableNpcs,
storyNpcs,
items: normalizeItemList(item.items),
camp,
landmarks: normalizeLandmarks({
landmarks: landmarkDrafts,
storyNpcs,
}),
themePack:
item.themePack && typeof item.themePack === 'object'
? (item.themePack as CustomWorldProfile['themePack'])
: null,
storyGraph:
item.storyGraph && typeof item.storyGraph === 'object'
? (item.storyGraph as CustomWorldProfile['storyGraph'])
: null,
anchorContent:
item.anchorContent && typeof item.anchorContent === 'object'
? (item.anchorContent as Record<string, unknown>)
: null,
creatorIntent: runtimeBridge.creatorIntent,
anchorPack: runtimeBridge.anchorPack,
lockState: runtimeBridge.lockState,
generationMode:
item.generationMode === 'fast' || item.generationMode === 'full'
? item.generationMode
: fallback.generationMode,
generationStatus:
item.generationStatus === 'key_only' || item.generationStatus === 'complete'
? item.generationStatus
: fallback.generationStatus,
ownedSettingLayers:
item.ownedSettingLayers && typeof item.ownedSettingLayers === 'object'
? (item.ownedSettingLayers as Record<string, unknown>)
: null,
knowledgeFacts:
Array.isArray(item.knowledgeFacts)
? (item.knowledgeFacts as Array<Record<string, unknown>>)
: null,
threadContracts:
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,
};
}
export function buildCompiledCustomWorldProfile(
raw: unknown,
settingText: string,
): CustomWorldProfile {
const profile = normalizeCustomWorldProfile(raw, settingText);
const playableNpcs = profile.playableNpcs.map((npc, index) => {
const templateCharacterId =
npc.templateCharacterId ??
pickCyclic(
PLAYABLE_TEMPLATE_CHARACTER_IDS,
index,
'playable template character id',
);
return {
...npc,
templateCharacterId,
attributeProfile:
npc.attributeProfile ??
buildCustomWorldPlayableNpcAttributeProfile(
{
...npc,
templateCharacterId,
},
profile.attributeSchema,
),
};
});
const storyNpcs = profile.storyNpcs.map((npc) => ({
...npc,
attributeProfile:
npc.attributeProfile ??
buildCustomWorldStoryNpcAttributeProfile(npc, profile.attributeSchema),
}));
return {
...profile,
playableNpcs,
storyNpcs,
scenarioPackId:
profile.scenarioPackId ?? `scenario-pack:${slugify(profile.name)}`,
campaignPackId:
profile.campaignPackId ?? `campaign-pack:${slugify(profile.name)}`,
};
}
function countUniqueNames(items: Array<{ name: string }>) {
return new Set(items.map((item) => item.name.trim()).filter(Boolean)).size;
}
export function validateGeneratedCustomWorldProfile(
profile: CustomWorldProfile,
) {
const playableCount = countUniqueNames(profile.playableNpcs);
const landmarkCount = countUniqueNames(profile.landmarks);
if (playableCount < MIN_CUSTOM_WORLD_PLAYABLE_NPC_COUNT) {
throw new Error(
`自定义世界生成要求模型至少产出 ${MIN_CUSTOM_WORLD_PLAYABLE_NPC_COUNT} 名可扮演角色。`,
);
}
if (landmarkCount < MIN_CUSTOM_WORLD_LANDMARK_COUNT) {
throw new Error(
`自定义世界生成要求至少产出 ${MIN_CUSTOM_WORLD_LANDMARK_COUNT} 个场景,当前仅返回 ${landmarkCount} 个。`,
);
}
const validStoryNpcIds = new Set(profile.storyNpcs.map((npc) => npc.id));
const validLandmarkIds = new Set(
profile.landmarks.map((landmark) => landmark.id),
);
profile.landmarks.forEach((landmark) => {
const uniqueSceneNpcIds = [...new Set(landmark.sceneNpcIds)];
if (uniqueSceneNpcIds.length < 3) {
throw new Error(
`场景「${landmark.name}」至少需要关联 3 个场景角色,当前仅有 ${uniqueSceneNpcIds.length} 个。`,
);
}
if (uniqueSceneNpcIds.some((npcId) => !validStoryNpcIds.has(npcId))) {
throw new Error(`场景「${landmark.name}」引用了不存在的场景角色。`);
}
if (landmark.connections.length === 0) {
throw new Error(`场景「${landmark.name}」缺少可用的场景连接关系。`);
}
if (
landmark.connections.some(
(connection) =>
connection.targetLandmarkId === landmark.id ||
!validLandmarkIds.has(connection.targetLandmarkId),
)
) {
throw new Error(`场景「${landmark.name}」存在无效的目标场景连接。`);
}
});
}

View File

@@ -0,0 +1,82 @@
import {
buildCustomWorldAnchorPackFromIntent,
deriveCustomWorldLockStateFromIntent,
normalizeCustomWorldCreatorIntent,
normalizeCustomWorldLockState,
} from '../creatorIntentRuntime.js';
import type {
CustomWorldCreatorIntent,
CustomWorldProfile,
WorldType,
} from '../runtimeTypes.js';
import { toText } from './normalizeShared.js';
/**
* 工作包 G
* 统一 runtime profile 对 creator intent、anchor pack 和 lock state 的桥接入口,
* 避免主编译器继续直接拼装这些兼容字段。
*/
export function inferWorldTypeFromSetting(settingText: string): WorldType {
return /[]/u.test(settingText)
? 'XIANXIA'
: 'WUXIA';
}
export function normalizeWorldType(value: unknown, sourceText: string): WorldType {
const worldType = toText(value).toUpperCase();
if (worldType === 'WUXIA' || worldType === 'XIANXIA') {
return worldType;
}
return inferWorldTypeFromSetting(sourceText);
}
export function buildSeedPhrase(settingText: string, fallback: string) {
const compact = settingText.replace(/\s+/g, '').trim();
return compact ? compact.slice(0, 10) : fallback;
}
export function buildWorldName(settingText: string, worldType: WorldType) {
const seed = buildSeedPhrase(settingText, '新旅');
const suffix = worldType === 'XIANXIA' ? '境' : '域';
return `${seed}${suffix}`;
}
export {
normalizeCustomWorldCreatorIntent,
normalizeCustomWorldLockState,
};
export function buildEmptyCustomWorldRuntimeBridge() {
return {
creatorIntent: null,
anchorPack: null,
lockState: normalizeCustomWorldLockState(null),
} satisfies {
creatorIntent: CustomWorldCreatorIntent | null;
anchorPack: CustomWorldProfile['anchorPack'];
lockState: CustomWorldProfile['lockState'];
};
}
export function resolveCustomWorldRuntimeIntentBridge(
raw: Record<string, unknown>,
) {
const creatorIntent = normalizeCustomWorldCreatorIntent(raw.creatorIntent);
return {
creatorIntent,
anchorPack:
raw.anchorPack && typeof raw.anchorPack === 'object'
? (raw.anchorPack as CustomWorldProfile['anchorPack'])
: buildCustomWorldAnchorPackFromIntent(creatorIntent),
lockState:
raw.lockState && typeof raw.lockState === 'object'
? normalizeCustomWorldLockState(raw.lockState)
: deriveCustomWorldLockStateFromIntent(creatorIntent),
} satisfies {
creatorIntent: CustomWorldCreatorIntent | null;
anchorPack: CustomWorldProfile['anchorPack'];
lockState: CustomWorldProfile['lockState'];
};
}

View File

@@ -0,0 +1,13 @@
/**
* 工作包 G
* custom world runtime profile 的主入口统一收口到目录化模块。
* 主编译逻辑和各类 normalize/build 子模块已经物理拆分,旧 compiler 文件只保留兼容转发。
*/
export * from './buildAttributeSchema.js';
export * from './buildCompiledProfile.js';
export * from './creatorIntentBridge.js';
export * from './normalizeCamp.js';
export * from './normalizeLandmark.js';
export * from './normalizeRole.js';
export * from './normalizeSceneChapter.js';
export * from './normalizeShared.js';

View File

@@ -0,0 +1,178 @@
import type {
CustomWorldCampScene,
CustomWorldGenerationCampOutline,
} from '../runtimeTypes.js';
import {
clampText,
toRecordArray,
toStringArray,
toText,
} from './normalizeShared.js';
/**
* 工作包 G
* 营地 fallback、outline 归一和 runtime 场景归一单独收口,
* 避免主编译器继续混合 UI 展示语义和营地领域默认值。
*/
export type CustomWorldCampFallbackProfile = {
name: string;
summary: string;
tone: string;
playerGoal: string;
settingText: string;
};
function detectCustomWorldThemeMode(profile: {
settingText: string;
summary: string;
tone: string;
playerGoal: string;
}) {
const source = [
profile.settingText,
profile.summary,
profile.tone,
profile.playerGoal,
].join(' ');
if (/[齿]/u.test(source)) return 'machina';
if (/[]/u.test(source)) return 'tide';
if (/[线]/u.test(source)) return 'rift';
if (/[]/u.test(source)) return 'arcane';
if (/[]/u.test(source)) return 'martial';
return 'mythic';
}
function sanitizeCampSeed(name: string) {
const normalized = name.trim().replace(/\s+/g, '');
if (!normalized) {
return '';
}
const stripped = normalized.replace(
/(|||||||||)$/u,
'',
);
const seed = stripped || normalized;
return seed.slice(0, Math.min(seed.length, 4));
}
function buildFallbackCampName(profile: CustomWorldCampFallbackProfile) {
const seed = sanitizeCampSeed(profile.name) || '归途';
const themeMode = detectCustomWorldThemeMode(profile);
const suffixByMode = {
mythic: '归舍',
martial: '归舍',
arcane: '栖居',
machina: '整备居',
tide: '潮居',
rift: '界隙居所',
} as const;
return `${seed}${suffixByMode[themeMode]}`;
}
export function buildFallbackCustomWorldCampScene(
profile: CustomWorldCampFallbackProfile,
): CustomWorldCampScene {
const fallbackName = buildFallbackCampName(profile);
const summaryLead = clampText(profile.summary, 24) || '前路尚未明朗';
const goalLead = clampText(profile.playerGoal, 24) || '继续整理线索';
const themeMode = detectCustomWorldThemeMode(profile);
const descriptionByMode = {
mythic: `${fallbackName}是你在${profile.name}暂时安顿的归处,能整备行装、交换判断,并围绕“${goalLead}”继续启程。`,
martial: `${fallbackName}是你在${profile.name}暂时落脚的归处,能整备行装、收拢同伴,并朝“${goalLead}”继续动身。`,
arcane: `${fallbackName}是你在${profile.name}暂时栖身的居所,适合调息、整理法器,并围绕“${summaryLead}”交换判断。`,
machina: `${fallbackName}是你在${profile.name}的临时居所,能检修装备、补齐物资,并为下一段行动重新校准节奏。`,
tide: `${fallbackName}是你在${profile.name}靠潮歇息的住处,能安顿队伍、整理补给,并顺着“${goalLead}”继续前探。`,
rift: `${fallbackName}是你在${profile.name}勉强守住的一处居所,既能短暂停步,也能围绕“${summaryLead}”商量下一步去向。`,
} as const;
return {
id: 'custom-scene-camp',
name: fallbackName,
description: descriptionByMode[themeMode],
dangerLevel: 'low',
sceneNpcIds: [],
connections: [],
narrativeResidues: null,
};
}
export function normalizeCampOutline(
value: unknown,
fallbackProfile: CustomWorldCampFallbackProfile,
) {
const fallback = buildFallbackCustomWorldCampScene(fallbackProfile);
const item =
value && typeof value === 'object'
? (value as Record<string, unknown>)
: {};
return {
id: toText(item.id) || fallback.id,
name: toText(item.name) || fallback.name,
description: toText(item.description) || fallback.description,
visualDescription: toText(item.visualDescription) || undefined,
dangerLevel: toText(item.dangerLevel) || fallback.dangerLevel,
imageSrc: toText(item.imageSrc) || undefined,
sceneNpcIds: toStringArray(item.sceneNpcIds),
connections: toRecordArray(item.connections)
.map((connection) => ({
targetLandmarkName:
toText(connection.targetLandmarkName) ||
toText(connection.target) ||
toText(connection.sceneName),
relativePosition:
toText(connection.relativePosition) ||
toText(connection.position) ||
'forward',
summary: toText(connection.summary) || toText(connection.description),
}))
.filter((connection) => connection.targetLandmarkName),
} satisfies CustomWorldGenerationCampOutline & {
id: string;
visualDescription?: string;
imageSrc?: string;
sceneNpcIds: string[];
connections: Array<{
targetLandmarkName: string;
relativePosition: string;
summary: string;
}>;
};
}
export function normalizeCampScene(
value: unknown,
fallbackProfile: CustomWorldCampFallbackProfile,
): CustomWorldCampScene {
const fallback = buildFallbackCustomWorldCampScene(fallbackProfile);
const item =
value && typeof value === 'object'
? (value as Record<string, unknown>)
: {};
return {
id: toText(item.id) || fallback.id,
name: toText(item.name) || fallback.name,
description: toText(item.description) || fallback.description,
visualDescription: toText(item.visualDescription) || undefined,
dangerLevel: toText(item.dangerLevel) || fallback.dangerLevel,
imageSrc: toText(item.imageSrc) || undefined,
sceneNpcIds: toStringArray(item.sceneNpcIds),
connections: toRecordArray(item.connections)
.map((connection) => ({
targetLandmarkId: toText(connection.targetLandmarkId),
relativePosition:
toText(connection.relativePosition) || toText(connection.position) || 'forward',
summary: toText(connection.summary) || toText(connection.description),
}))
.filter((connection) => connection.targetLandmarkId),
narrativeResidues: null,
};
}

View File

@@ -0,0 +1,151 @@
import type {
CustomWorldGenerationFramework,
CustomWorldGenerationLandmarkOutline,
CustomWorldNpc,
} from '../runtimeTypes.js';
import {
clampText,
createEntryId,
MIN_CUSTOM_WORLD_LANDMARK_COUNT,
toRecordArray,
toStringArray,
toText,
} from './normalizeShared.js';
/**
* 工作包 G
* 世界地点 outline/runtime 归一独立收口,避免地点网络解析继续和角色、场景章节逻辑混在一个文件里。
*/
export function normalizeLandmarkOutlineList(value: unknown) {
return toRecordArray(value)
.map((item) => {
const name = toText(item.name);
return {
name,
description:
toText(item.description) ||
clampText(`${name}暗藏新的局势变化。`, 40),
visualDescription: toText(item.visualDescription) || undefined,
dangerLevel: toText(item.dangerLevel) || 'medium',
sceneNpcNames: [
...toStringArray(item.sceneNpcNames),
...toStringArray(item.npcs, 'name'),
...toStringArray(item.sceneNpcs, 'name'),
...toStringArray(item.npcNames),
],
connections: toRecordArray(item.connections)
.map((connection) => ({
targetLandmarkName:
toText(connection.targetLandmarkName) ||
toText(connection.target) ||
toText(connection.sceneName),
relativePosition:
toText(connection.relativePosition) ||
toText(connection.position) ||
'forward',
summary:
toText(connection.summary) || toText(connection.description),
}))
.filter((connection) => connection.targetLandmarkName),
} satisfies CustomWorldGenerationLandmarkOutline;
})
.filter((entry) => entry.name)
.slice(0, MIN_CUSTOM_WORLD_LANDMARK_COUNT);
}
export function normalizeCustomWorldGenerationLandmarkOutlineBatch(raw: unknown) {
const item =
raw && typeof raw === 'object' ? (raw as Record<string, unknown>) : {};
return normalizeLandmarkOutlineList(item.landmarks);
}
export function buildCustomWorldRawProfileLandmarksFromFramework(
framework: CustomWorldGenerationFramework,
) {
return framework.landmarks.map((landmark) => ({
name: landmark.name,
description: landmark.description,
visualDescription: landmark.visualDescription,
dangerLevel: landmark.dangerLevel,
sceneNpcNames: [...landmark.sceneNpcNames],
connections: landmark.connections.map((connection) => ({
targetLandmarkName: connection.targetLandmarkName,
relativePosition: connection.relativePosition,
summary: connection.summary,
})),
}));
}
export function normalizeLandmarks(params: {
landmarks: Array<Record<string, unknown>>;
storyNpcs: CustomWorldNpc[];
}) {
const storyNpcIdByName = new Map(
params.storyNpcs.map((npc) => [npc.name.trim(), npc.id] as const),
);
const landmarkEntries = params.landmarks
.map((item, index) => ({
id: toText(item.id) || createEntryId('landmark', toText(item.name), index),
name: toText(item.name),
description: toText(item.description),
visualDescription: toText(item.visualDescription) || undefined,
dangerLevel: toText(item.dangerLevel) || 'medium',
imageSrc: toText(item.imageSrc) || undefined,
sceneNpcIds: toStringArray(item.sceneNpcIds),
sceneNpcNames: [
...toStringArray(item.sceneNpcNames),
...toStringArray(item.npcs, 'name'),
...toStringArray(item.sceneNpcs, 'name'),
...toStringArray(item.npcNames),
],
connections: toRecordArray(item.connections).map((connection) => ({
targetLandmarkId: toText(connection.targetLandmarkId),
targetLandmarkName:
toText(connection.targetLandmarkName) ||
toText(connection.target) ||
toText(connection.sceneName),
relativePosition:
toText(connection.relativePosition) || toText(connection.position),
summary: toText(connection.summary) || toText(connection.description),
})),
}))
.filter((entry) => entry.name);
const landmarkIdByName = new Map(
landmarkEntries.map((landmark) => [landmark.name.trim(), landmark.id] as const),
);
return landmarkEntries.map((landmark) => {
const resolvedSceneNpcIds = [
...new Set(
[
...landmark.sceneNpcIds,
...landmark.sceneNpcNames
.map((name) => storyNpcIdByName.get(name.trim()) ?? '')
.filter(Boolean),
].filter(Boolean),
),
];
return {
id: landmark.id,
name: landmark.name,
description: landmark.description,
visualDescription: landmark.visualDescription,
dangerLevel: landmark.dangerLevel,
imageSrc: landmark.imageSrc,
sceneNpcIds: resolvedSceneNpcIds,
connections: landmark.connections
.map((connection) => ({
targetLandmarkId:
connection.targetLandmarkId ||
landmarkIdByName.get(connection.targetLandmarkName.trim()) ||
'',
relativePosition: connection.relativePosition || 'forward',
summary: connection.summary,
}))
.filter((connection) => connection.targetLandmarkId),
};
});
}

View File

@@ -0,0 +1,541 @@
import type {
CharacterBackstoryChapter,
CharacterBackstoryRevealConfig,
CustomWorldGenerationFramework,
CustomWorldGenerationRoleBatchType,
CustomWorldGenerationRoleOutline,
CustomWorldNpc,
CustomWorldPlayableNpc,
CustomWorldProfile,
CustomWorldRoleInitialItem,
CustomWorldRoleProfile,
CustomWorldRoleSkill,
} from '../runtimeTypes.js';
import {
buildWorldName,
normalizeWorldType,
} from './creatorIntentBridge.js';
import {
clampCustomWorldAffinity,
clampText,
createEntryId,
MIN_CUSTOM_WORLD_PLAYABLE_NPC_COUNT,
MIN_CUSTOM_WORLD_STORY_NPC_COUNT,
normalizeInitialAffinity,
normalizeRarity,
normalizeRoleItemCategory,
normalizeTags,
toRecordArray,
toText,
} from './normalizeShared.js';
/**
* 工作包 G
* 把角色相关的 outline/runtime 归一、背景揭示、初始技能与物品 fallback 统一收口,
* 让主编译器只负责装配,不继续内嵌角色画像细节。
*/
const AFFINITY_BACKSTORY_CHAPTER_THRESHOLDS = [15, 30, 60, 90] as const;
const DEFAULT_PRIVATE_CHAT_UNLOCK_AFFINITY = 60;
const DEFAULT_PLAYABLE_INITIAL_AFFINITY = 18;
const DEFAULT_STORY_NPC_INITIAL_AFFINITY = 6;
const DEFAULT_CUSTOM_WORLD_ROLE_SKILL_COUNT = 3;
const DEFAULT_CUSTOM_WORLD_ROLE_INITIAL_ITEM_COUNT = 3;
const CUSTOM_WORLD_BACKSTORY_CHAPTER_TITLES = [
'表层来意',
'旧事裂痕',
'隐藏执念',
'最终底牌',
] as const;
type CustomWorldRoleFallbackSource = Pick<
CustomWorldRoleProfile,
| 'name'
| 'title'
| 'role'
| 'description'
| 'backstory'
| 'personality'
| 'motivation'
| 'combatStyle'
| 'relationshipHooks'
| 'tags'
>;
function splitNarrativeSentences(text: string) {
const normalized = text.replace(/\s+/g, ' ').trim();
if (!normalized) {
return [];
}
const matches = normalized.match(/[^!?]+[!?]?/gu);
return (matches ?? [normalized]).map((item) => item.trim()).filter(Boolean);
}
function buildFallbackBackstoryReveal(
source: CustomWorldRoleFallbackSource,
): CharacterBackstoryRevealConfig {
const normalizedBackstory =
source.backstory.trim() || `${source.name}对自己的过去始终有所保留。`;
const backstorySentences = splitNarrativeSentences(normalizedBackstory);
const backstoryLead = backstorySentences[0] ?? normalizedBackstory;
const backstoryDetail =
backstorySentences.slice(0, 2).join('') || normalizedBackstory;
const publicSummary =
source.description.trim() || clampText(normalizedBackstory, 42);
const fallbackContents = [
source.description.trim() || backstoryLead,
backstoryDetail,
source.motivation.trim()
? `${source.name}真正挂念的,是:${source.motivation.trim()}`
: `${source.name}的选择与“${clampText(backstoryLead, 24)}”直接相关。`,
source.personality.trim()
? `${source.name}不会轻易说出的底色,是:${source.personality.trim()}`
: `${source.name}仍把最深的筹码藏在过去之中。`,
];
return {
publicSummary,
privateChatUnlockAffinity: DEFAULT_PRIVATE_CHAT_UNLOCK_AFFINITY,
chapters: AFFINITY_BACKSTORY_CHAPTER_THRESHOLDS.map(
(affinityRequired, index) =>
({
id: createEntryId(
'backstory-chapter',
`${source.name}-${CUSTOM_WORLD_BACKSTORY_CHAPTER_TITLES[index] ?? index + 1}`,
index,
),
title:
CUSTOM_WORLD_BACKSTORY_CHAPTER_TITLES[index] ??
`背景片段${index + 1}`,
affinityRequired,
teaser: clampText(
fallbackContents[index] ?? normalizedBackstory,
22,
),
content: clampText(
fallbackContents[index] ?? normalizedBackstory,
72,
),
contextSnippet: clampText(
`${source.name}的背景正在向“${fallbackContents[index] ?? normalizedBackstory}”这条线索展开。`,
48,
),
}) satisfies CharacterBackstoryChapter,
),
};
}
function normalizeBackstoryReveal(
value: unknown,
fallbackSource: CustomWorldRoleFallbackSource,
) {
const fallback = buildFallbackBackstoryReveal(fallbackSource);
if (!value || typeof value !== 'object') {
return fallback;
}
const item = value as Record<string, unknown>;
const rawChapters = toRecordArray(item.chapters);
return {
publicSummary: toText(item.publicSummary) || fallback.publicSummary,
privateChatUnlockAffinity:
typeof item.privateChatUnlockAffinity === 'number' &&
Number.isFinite(item.privateChatUnlockAffinity)
? clampCustomWorldAffinity(item.privateChatUnlockAffinity)
: fallback.privateChatUnlockAffinity,
chapters: AFFINITY_BACKSTORY_CHAPTER_THRESHOLDS.map(
(defaultAffinity, index) => {
const fallbackChapter = fallback.chapters[index];
const rawChapter = rawChapters[index];
return {
id:
(rawChapter && toText(rawChapter.id)) ||
fallbackChapter?.id ||
`backstory-chapter-${index + 1}`,
title:
(rawChapter && toText(rawChapter.title)) ||
fallbackChapter?.title ||
`背景片段${index + 1}`,
affinityRequired:
fallbackChapter?.affinityRequired ?? defaultAffinity,
teaser:
(rawChapter && toText(rawChapter.teaser)) ||
fallbackChapter?.teaser ||
'',
content:
(rawChapter && toText(rawChapter.content)) ||
fallbackChapter?.content ||
'',
contextSnippet:
(rawChapter && toText(rawChapter.contextSnippet)) ||
fallbackChapter?.contextSnippet ||
'',
} satisfies CharacterBackstoryChapter;
},
),
} satisfies CharacterBackstoryRevealConfig;
}
function buildFallbackRoleSkills(source: CustomWorldRoleFallbackSource) {
const skillNameSeed = source.title || source.role || source.name || '角色';
const skillSummarySeed =
source.combatStyle || source.description || `${source.name}善于把握局势。`;
const motivationSeed =
source.motivation || source.personality || source.backstory;
return [
{
id: createEntryId('role-skill', `${skillNameSeed}-起手`, 0),
name: `${skillNameSeed}起手`,
summary: clampText(skillSummarySeed, 36),
style: '起手压制',
},
{
id: createEntryId('role-skill', `${skillNameSeed}-机动`, 1),
name: `${skillNameSeed}变招`,
summary: clampText(
source.personality || `${source.name}习惯在试探中寻找破绽。`,
36,
),
style: '机动周旋',
},
{
id: createEntryId('role-skill', `${skillNameSeed}-底牌`, 2),
name: `${skillNameSeed}底牌`,
summary: clampText(
motivationSeed || `${source.name}会在关键时刻亮出压箱手段。`,
36,
),
style: '爆发终结',
},
] satisfies CustomWorldRoleSkill[];
}
function normalizeRoleSkillList(
value: unknown,
fallbackSource: CustomWorldRoleFallbackSource,
) {
const normalized = toRecordArray(value)
.map((item, index) => {
const name = toText(item.name);
const summary = toText(item.summary) || toText(item.description);
const style = toText(item.style) || toText(item.category) || '常用';
return {
id: createEntryId('role-skill', name || style, index),
name,
summary,
style,
} satisfies CustomWorldRoleSkill;
})
.filter((entry) => entry.name)
.slice(0, DEFAULT_CUSTOM_WORLD_ROLE_SKILL_COUNT);
return normalized.length > 0
? normalized
: buildFallbackRoleSkills(fallbackSource);
}
function buildFallbackRoleInitialItems(source: CustomWorldRoleFallbackSource) {
const itemNameSeed = source.title || source.role || source.name || '角色';
return [
{
id: createEntryId('role-item', `${itemNameSeed}-1`, 0),
name: `${itemNameSeed}常备武具`,
category: '武器',
quantity: 1,
rarity: 'rare',
description: clampText(
source.combatStyle || `${source.name}随身携带的主要作战物件。`,
36,
),
tags: normalizeTags(source.tags, ['战斗', '随身']),
},
{
id: createEntryId('role-item', `${itemNameSeed}-2`, 1),
name: `${itemNameSeed}补给包`,
category: '消耗品',
quantity: 2,
rarity: 'uncommon',
description: clampText(
source.personality || `${source.name}为了长期行动准备的基础补给。`,
36,
),
tags: normalizeTags(source.relationshipHooks, ['补给', '行动']),
},
{
id: createEntryId('role-item', `${itemNameSeed}-3`, 2),
name: `${itemNameSeed}私人物件`,
category: '专属物品',
quantity: 1,
rarity: 'rare',
description: clampText(
source.backstory ||
source.motivation ||
`${source.name}不愿随意交出的信物。`,
36,
),
tags: normalizeTags(
[...source.tags, ...source.relationshipHooks],
['信物', '线索'],
),
},
] satisfies CustomWorldRoleInitialItem[];
}
function normalizeRoleInitialItemList(
value: unknown,
fallbackSource: CustomWorldRoleFallbackSource,
) {
const normalized = toRecordArray(value)
.map((item, index) => {
const name = toText(item.name);
return {
id: createEntryId('role-item', name, index),
name,
category: normalizeRoleItemCategory(item.category),
quantity:
typeof item.quantity === 'number' && Number.isFinite(item.quantity)
? Math.max(1, Math.min(99, Math.round(item.quantity)))
: 1,
rarity: normalizeRarity(item.rarity, 'rare'),
description: toText(item.description),
tags: normalizeTags(item.tags),
} satisfies CustomWorldRoleInitialItem;
})
.filter((entry) => entry.name)
.slice(0, DEFAULT_CUSTOM_WORLD_ROLE_INITIAL_ITEM_COUNT);
return normalized.length > 0
? normalized
: buildFallbackRoleInitialItems(fallbackSource);
}
function normalizeRoleOutlineList(
value: unknown,
options: {
titleFallback: string;
defaultAffinity: number;
maxCount?: number;
},
) {
const normalized = toRecordArray(value)
.map((item) => {
const name = toText(item.name);
const title =
toText(item.title) || toText(item.role) || options.titleFallback;
const role = toText(item.role) || title;
const relationshipHooks = normalizeTags(
item.relationshipHooks,
normalizeTags(item.tags),
);
return {
name,
title,
role,
description:
toText(item.description) ||
clampText(`${name || title}在世界中以${role}身份活动。`, 36),
visualDescription: toText(item.visualDescription) || undefined,
actionDescription: toText(item.actionDescription) || undefined,
sceneVisualDescription: toText(item.sceneVisualDescription) || undefined,
initialAffinity: normalizeInitialAffinity(
item.initialAffinity,
options.defaultAffinity,
),
relationshipHooks,
tags: normalizeTags(item.tags, relationshipHooks),
} satisfies CustomWorldGenerationRoleOutline;
})
.filter((entry) => entry.name);
return typeof options.maxCount === 'number'
? normalized.slice(0, options.maxCount)
: normalized;
}
export function normalizeCustomWorldGenerationRoleOutlineBatch(
raw: unknown,
roleType: CustomWorldGenerationRoleBatchType,
) {
const item =
raw && typeof raw === 'object' ? (raw as Record<string, unknown>) : {};
const key = roleType === 'playable' ? 'playableNpcs' : 'storyNpcs';
return normalizeRoleOutlineList(item[key], {
titleFallback: '未定称号',
defaultAffinity:
roleType === 'playable'
? DEFAULT_PLAYABLE_INITIAL_AFFINITY
: DEFAULT_STORY_NPC_INITIAL_AFFINITY,
});
}
export function normalizeCustomWorldGenerationFrameworkRoles(params: {
raw: Record<string, unknown>;
fallback: CustomWorldProfile;
settingText: string;
}) {
const worldSignalText = [
params.settingText,
toText(params.raw.subtitle),
toText(params.raw.summary),
toText(params.raw.tone),
toText(params.raw.playerGoal),
].join(' ');
const templateWorldType = normalizeWorldType(
params.raw.templateWorldType,
worldSignalText,
);
const name =
toText(params.raw.name) || buildWorldName(params.settingText, templateWorldType);
return {
name,
templateWorldType,
playableNpcs: normalizeRoleOutlineList(params.raw.playableNpcs, {
titleFallback: '未定称号',
defaultAffinity: DEFAULT_PLAYABLE_INITIAL_AFFINITY,
maxCount: MIN_CUSTOM_WORLD_PLAYABLE_NPC_COUNT,
}),
storyNpcs: normalizeRoleOutlineList(params.raw.storyNpcs, {
titleFallback: '未定称号',
defaultAffinity: DEFAULT_STORY_NPC_INITIAL_AFFINITY,
maxCount: MIN_CUSTOM_WORLD_STORY_NPC_COUNT,
}),
campFallbackProfile: {
name,
summary: toText(params.raw.summary) || params.fallback.summary,
tone: toText(params.raw.tone) || params.fallback.tone,
playerGoal: toText(params.raw.playerGoal) || params.fallback.playerGoal,
settingText: params.settingText.trim(),
},
};
}
export function buildCustomWorldRawProfileRolesFromFramework(
framework: CustomWorldGenerationFramework,
) {
return {
playableNpcs: framework.playableNpcs.map((npc) => ({
name: npc.name,
title: npc.title,
role: npc.role,
description: npc.description,
visualDescription: npc.visualDescription,
actionDescription: npc.actionDescription,
sceneVisualDescription: npc.sceneVisualDescription,
initialAffinity: npc.initialAffinity,
relationshipHooks: [...npc.relationshipHooks],
tags: [...npc.tags],
})),
storyNpcs: framework.storyNpcs.map((npc) => ({
name: npc.name,
title: npc.title,
role: npc.role,
description: npc.description,
visualDescription: npc.visualDescription,
actionDescription: npc.actionDescription,
sceneVisualDescription: npc.sceneVisualDescription,
initialAffinity: npc.initialAffinity,
relationshipHooks: [...npc.relationshipHooks],
tags: [...npc.tags],
})),
};
}
function normalizeRoleProfile(
item: Record<string, unknown>,
index: number,
options: {
idPrefix: 'playable-npc' | 'story-npc';
titleFallback: string;
defaultAffinity: number;
},
) {
const name = toText(item.name);
const title =
toText(item.title) || toText(item.role) || options.titleFallback;
const role = toText(item.role) || title;
const relationshipHooks = normalizeTags(
item.relationshipHooks,
normalizeTags(item.tags),
);
const normalizedRole = {
id: toText(item.id) || createEntryId(options.idPrefix, name, index),
name,
title,
role,
description: toText(item.description),
visualDescription: toText(item.visualDescription) || undefined,
actionDescription: toText(item.actionDescription) || undefined,
sceneVisualDescription: toText(item.sceneVisualDescription) || undefined,
backstory: toText(item.backstory),
personality: toText(item.personality),
motivation: toText(item.motivation) || toText(item.description),
combatStyle: toText(item.combatStyle),
initialAffinity: normalizeInitialAffinity(
item.initialAffinity,
options.defaultAffinity,
),
relationshipHooks,
tags: normalizeTags(item.tags, relationshipHooks),
};
return {
...normalizedRole,
backstoryReveal: normalizeBackstoryReveal(
item.backstoryReveal,
normalizedRole,
),
skills: normalizeRoleSkillList(item.skills, normalizedRole),
initialItems: normalizeRoleInitialItemList(item.initialItems, normalizedRole),
imageSrc: toText(item.imageSrc) || undefined,
generatedVisualAssetId: toText(item.generatedVisualAssetId) || undefined,
generatedAnimationSetId:
toText(item.generatedAnimationSetId) || undefined,
animationMap:
item.animationMap && typeof item.animationMap === 'object'
? (item.animationMap as Record<string, unknown>)
: undefined,
narrativeProfile:
item.narrativeProfile && typeof item.narrativeProfile === 'object'
? (item.narrativeProfile as CustomWorldRoleProfile['narrativeProfile'])
: null,
};
}
export function normalizePlayableNpcList(value: unknown) {
return toRecordArray(value)
.map((item, index) => ({
...normalizeRoleProfile(item, index, {
idPrefix: 'playable-npc',
titleFallback: '未定称号',
defaultAffinity: DEFAULT_PLAYABLE_INITIAL_AFFINITY,
}),
templateCharacterId: toText(item.templateCharacterId) || undefined,
}))
.filter((entry) => entry.name)
.slice(0, MIN_CUSTOM_WORLD_PLAYABLE_NPC_COUNT);
}
export function normalizeStoryNpcList(value: unknown) {
return toRecordArray(value)
.map(
(item, index) =>
({
...normalizeRoleProfile(item, index, {
idPrefix: 'story-npc',
titleFallback: '未定称号',
defaultAffinity: DEFAULT_STORY_NPC_INITIAL_AFFINITY,
}),
visual:
item.visual && typeof item.visual === 'object'
? (item.visual as Record<string, unknown>)
: undefined,
}) satisfies CustomWorldNpc,
)
.filter((entry) => entry.name);
}

View File

@@ -0,0 +1,123 @@
import type { SceneActBlueprint, SceneChapterBlueprint } from '../runtimeTypes.js';
import { createEntryId, toRecordArray, toStringArray, toText } from './normalizeShared.js';
/**
* 工作包 G
* 分幕与场景章节 blueprint 归一独立收口,让结果预览编译层只消费稳定的章节结构。
*/
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',
]);
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) ||
createEntryId(`saved-scene-act-${sceneId}`, title || sceneId, index),
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),
};
}
export 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) ||
createEntryId('saved-scene-chapter', sceneId, index),
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;
}

View File

@@ -0,0 +1,248 @@
import type {
CustomWorldCoverProfile,
CustomWorldCoverSourceType,
CustomWorldItem,
CustomWorldPlayableNpc,
} from '../runtimeTypes.js';
/**
* 工作包 G
* 把 runtime profile 编译流程里的通用常量、基础文本归一和通用小型归一逻辑收口到共享模块,
* 让角色、地点、场景章节和主编译入口都不再重复维护这些基础能力。
*/
const MIN_CUSTOM_WORLD_AFFINITY = -40;
const MAX_CUSTOM_WORLD_AFFINITY = 90;
const CUSTOM_WORLD_RARITIES = [
'common',
'uncommon',
'rare',
'epic',
'legendary',
] as const;
const CUSTOM_WORLD_ROLE_ITEM_CATEGORIES = [
'武器',
'护甲',
'饰品',
'消耗品',
'材料',
'稀有品',
'专属物品',
'专属物',
] as const;
export const MIN_CUSTOM_WORLD_PLAYABLE_NPC_COUNT = 5;
export const MIN_CUSTOM_WORLD_TOTAL_NPC_COUNT = 30;
export const MIN_CUSTOM_WORLD_LANDMARK_COUNT = 10;
export const MIN_CUSTOM_WORLD_STORY_NPC_COUNT = Math.max(
0,
MIN_CUSTOM_WORLD_TOTAL_NPC_COUNT - MIN_CUSTOM_WORLD_PLAYABLE_NPC_COUNT,
);
export const PLAYABLE_TEMPLATE_CHARACTER_IDS = [
'sword-princess',
'archer-hero',
'girl-hero',
'punch-hero',
'fighter-4',
] as const;
export function toText(value: unknown) {
return typeof value === 'string' ? value.trim() : '';
}
export function toFiniteInteger(value: unknown) {
return typeof value === 'number' && Number.isFinite(value)
? Math.round(value)
: undefined;
}
export function toRecord(value: unknown) {
return value && typeof value === 'object' && !Array.isArray(value)
? (value as Record<string, unknown>)
: null;
}
export function toRecordArray(value: unknown) {
return Array.isArray(value)
? (value.filter((item) => item && typeof item === 'object') as Array<
Record<string, unknown>
>)
: [];
}
export function toStringArray(value: unknown, nestedKey?: string) {
if (!Array.isArray(value)) {
return [];
}
return value
.map((item) => {
if (typeof item === 'string') {
return item.trim();
}
if (nestedKey && item && typeof item === 'object') {
return toText((item as Record<string, unknown>)[nestedKey]);
}
return '';
})
.filter(Boolean);
}
export function normalizeTags(value: unknown, fallbackTags: string[] = []) {
const tags = Array.isArray(value)
? value.map((item) => toText(item)).filter(Boolean)
: [];
return [
...new Set((tags.length > 0 ? tags : fallbackTags).filter(Boolean)),
].slice(0, 5);
}
export function clampText(value: string, maxLength: number) {
const normalized = value.trim().replace(/\s+/g, ' ');
if (!normalized) {
return '';
}
if (normalized.length <= maxLength) {
return normalized;
}
return `${normalized.slice(0, Math.max(0, maxLength - 1)).trim()}`;
}
export function slugify(value: string) {
const ascii = value
.toLowerCase()
.replace(/[^a-z0-9\u4e00-\u9fa5]+/g, '-')
.replace(/^-+|-+$/g, '');
return ascii ? ascii.slice(0, 24) : 'entry';
}
export function createEntryId(prefix: string, label: string, index: number) {
return `${prefix}-${slugify(label || `${prefix}-${index + 1}`)}-${index + 1}`;
}
export function clampCustomWorldAffinity(value: number) {
return Math.max(
MIN_CUSTOM_WORLD_AFFINITY,
Math.min(MAX_CUSTOM_WORLD_AFFINITY, Math.round(value)),
);
}
export function normalizeInitialAffinity(value: unknown, fallback: number) {
return typeof value === 'number' && Number.isFinite(value)
? clampCustomWorldAffinity(value)
: fallback;
}
export function normalizeRarity(
value: unknown,
fallback: (typeof CUSTOM_WORLD_RARITIES)[number] = 'rare',
) {
const rarity = toText(value).toLowerCase();
return CUSTOM_WORLD_RARITIES.includes(
rarity as (typeof CUSTOM_WORLD_RARITIES)[number],
)
? (rarity as (typeof CUSTOM_WORLD_RARITIES)[number])
: fallback;
}
export function normalizeRoleItemCategory(value: unknown, fallback = '材料') {
const category = toText(value);
if (
(CUSTOM_WORLD_ROLE_ITEM_CATEGORIES as readonly string[]).includes(category)
) {
return category === '专属物' ? '专属物品' : category;
}
if (/||||||/u.test(category)) return '';
if (/||||/u.test(category)) return '';
if (/|||||/u.test(category)) return '';
if (/||||/u.test(category)) return '';
if (/|||||/u.test(category)) return '';
if (/|||||/u.test(category)) return '';
if (/||||/u.test(category)) return '';
return fallback;
}
export function normalizeCustomWorldCoverCharacterRoleIds(
value: unknown,
playableNpcs: Array<Pick<CustomWorldPlayableNpc, 'id'>>,
) {
const availableIds = new Set(
playableNpcs.map((entry) => entry.id.trim()).filter(Boolean),
);
const selectedIds = Array.isArray(value)
? [
...new Set(
value
.map((entry) => toText(entry))
.filter((entry) => entry && availableIds.has(entry)),
),
].slice(0, 3)
: [];
if (selectedIds.length > 0) {
return selectedIds;
}
return playableNpcs
.map((entry) => entry.id.trim())
.filter(Boolean)
.slice(0, 3);
}
export function buildDefaultCustomWorldCover(
playableNpcs: Array<Pick<CustomWorldPlayableNpc, 'id'>>,
): CustomWorldCoverProfile {
return {
sourceType: 'default' as const,
imageSrc: null,
characterRoleIds: normalizeCustomWorldCoverCharacterRoleIds(
undefined,
playableNpcs,
),
};
}
export function normalizeCustomWorldCover(
value: unknown,
playableNpcs: Array<Pick<CustomWorldPlayableNpc, 'id'>>,
): CustomWorldCoverProfile {
if (!value || typeof value !== 'object' || Array.isArray(value)) {
return buildDefaultCustomWorldCover(playableNpcs);
}
const item = value as Record<string, unknown>;
const sourceType: CustomWorldCoverSourceType =
item.sourceType === 'uploaded' || item.sourceType === 'generated'
? item.sourceType
: 'default';
const imageSrc = toText(item.imageSrc) || null;
if (sourceType !== 'default' && imageSrc) {
return {
sourceType,
imageSrc,
characterRoleIds: [],
};
}
return buildDefaultCustomWorldCover(playableNpcs);
}
export function normalizeItemList(value: unknown) {
return toRecordArray(value)
.map((item, index) => {
const name = toText(item.name);
const category = toText(item.category);
return {
id: toText(item.id) || createEntryId('item', name, index),
name,
category,
rarity: normalizeRarity(item.rarity, 'rare'),
description: toText(item.description),
tags: normalizeTags(item.tags),
} satisfies CustomWorldItem;
})
.filter((entry) => entry.name && entry.category);
}

View File

@@ -0,0 +1,13 @@
/**
* 兼容期 façade
* 旧的 runtimeProfileCompiler 文件名暂时保留,避免工作包 G 完整拆分后影响仍未迁移的局部导入。
* 新实现已经拆到目录模块中,后续新增逻辑禁止继续回写到这个文件。
*/
export * from './buildAttributeSchema.js';
export * from './buildCompiledProfile.js';
export * from './creatorIntentBridge.js';
export * from './normalizeCamp.js';
export * from './normalizeLandmark.js';
export * from './normalizeRole.js';
export * from './normalizeSceneChapter.js';
export * from './normalizeShared.js';

File diff suppressed because it is too large Load Diff

View File

@@ -1,7 +1,7 @@
import type {
RuntimeStoryActionRequest,
RuntimeStoryPatch,
} from '../../../../packages/shared/src/contracts/story.js';
} from '../../../../packages/shared/src/contracts/rpgRuntimeStoryState.js';
import { conflict, invalidRequest } from '../../errors.js';
import {
getPlayerBuildDamageBreakdown,
@@ -19,8 +19,8 @@ import {
} from './inventoryMutationService.js';
import {
replaceRuntimeSessionRawGameState,
type RuntimeSession,
} from '../story/runtimeSession.js';
} from '../rpg-runtime-story/RpgRuntimeSnapshotSync.js';
import type { RuntimeSession } from '../rpg-runtime-story/RpgRuntimeSessionLoader.js';
const SUPPORTED_INVENTORY_STORY_FUNCTION_IDS = new Set<string>([
'equipment_equip',

View File

@@ -1,7 +1,7 @@
import type {
RuntimeStoryActionRequest,
RuntimeStoryPatch,
} from '../../../../packages/shared/src/contracts/story.js';
} from '../../../../packages/shared/src/contracts/rpgRuntimeStoryState.js';
import { conflict, invalidRequest } from '../../errors.js';
import {
addInventoryItems,
@@ -23,8 +23,8 @@ import {
} from '../../bridges/legacyNpcTask6Bridge.js';
import {
replaceRuntimeSessionRawGameState,
type RuntimeSession,
} from '../story/runtimeSession.js';
} from '../rpg-runtime-story/RpgRuntimeSnapshotSync.js';
import type { RuntimeSession } from '../rpg-runtime-story/RpgRuntimeSessionLoader.js';
const SUPPORTED_NPC_INVENTORY_STORY_FUNCTION_IDS = new Set<string>([
'npc_gift',

View File

@@ -1,6 +1,10 @@
import type { RuntimeStoryPatch } from '../../../../packages/shared/src/contracts/story.js';
import type { RuntimeStoryPatch } from '../../../../packages/shared/src/contracts/rpgRuntimeStoryState.js';
import { conflict } from '../../errors.js';
import { resolveHostileBattleProfile } from '../progression/hostileProgressionService.js';
import {
applyStoryChoiceToStanceProfile,
} from './npcTask6Primitives.js';
import { markNpcFirstMeaningfulContactResolved } from '../runtime/runtimeNpcStatePrimitives.js';
import {
MAX_TASK5_COMPANIONS,
getEncounterNpcState,
@@ -8,7 +12,7 @@ import {
type RuntimeEncounter,
type RuntimeNpcState,
type RuntimeSession,
} from '../story/runtimeSession.js';
} from '../rpg-runtime-story/RpgRuntimeSessionPrimitives.js';
type JsonRecord = Record<string, unknown>;
@@ -57,6 +61,158 @@ function isRecord(value: unknown): value is JsonRecord {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}
function readString(value: unknown) {
return typeof value === 'string' && value.trim() ? value.trim() : '';
}
function buildRecruitedCompanion(
session: RuntimeSession,
encounter: RuntimeEncounter,
npcState: RuntimeNpcState,
) {
const rawCompanionSource = isRecord(session.rawGameState.currentEncounter)
? session.rawGameState.currentEncounter
: {};
const maxHp = Math.max(
1,
Math.round(
typeof rawCompanionSource.maxHp === 'number' &&
Number.isFinite(rawCompanionSource.maxHp)
? rawCompanionSource.maxHp
: 180,
),
);
const maxMana = Math.max(
1,
Math.round(
typeof rawCompanionSource.maxMana === 'number' &&
Number.isFinite(rawCompanionSource.maxMana)
? rawCompanionSource.maxMana
: 999,
),
);
const skillCooldowns = Object.fromEntries(
Object.entries(
isRecord(rawCompanionSource.skillCooldowns)
? rawCompanionSource.skillCooldowns
: {},
).map(([skillId, turns]) => [
skillId,
typeof turns === 'number' && Number.isFinite(turns)
? Math.max(0, Math.round(turns))
: 0,
]),
);
return {
npcId: encounter.id,
characterId: encounter.characterId ?? '',
joinedAtAffinity: npcState.affinity,
hp: maxHp,
maxHp,
mana: maxMana,
maxMana,
skillCooldowns,
animationState: readString(rawCompanionSource.animationState) || 'idle',
actionMode: readString(rawCompanionSource.actionMode) || 'idle',
offsetX:
typeof rawCompanionSource.offsetX === 'number' &&
Number.isFinite(rawCompanionSource.offsetX)
? rawCompanionSource.offsetX
: 0,
offsetY:
typeof rawCompanionSource.offsetY === 'number' &&
Number.isFinite(rawCompanionSource.offsetY)
? rawCompanionSource.offsetY
: 0,
transitionMs:
typeof rawCompanionSource.transitionMs === 'number' &&
Number.isFinite(rawCompanionSource.transitionMs)
? Math.max(0, Math.round(rawCompanionSource.transitionMs))
: 0,
};
}
function upsertCompanion(
list: RuntimeSession['companions'],
companion: RuntimeSession['companions'][number],
) {
const next = [...list];
const existingIndex = next.findIndex((item) => item.npcId === companion.npcId);
if (existingIndex >= 0) {
next[existingIndex] = companion;
return next;
}
next.push(companion);
return next;
}
function removeCompanion(
list: RuntimeSession['companions'],
npcId: string,
) {
return list.filter((item) => item.npcId !== npcId);
}
function normalizeRoster(
roster: RuntimeSession['roster'],
activeCompanions: RuntimeSession['companions'],
) {
const activeIds = new Set(activeCompanions.map((companion) => companion.npcId));
return roster.filter((companion) => !activeIds.has(companion.npcId));
}
function recruitCompanionToParty(params: {
session: RuntimeSession;
companion: RuntimeSession['companions'][number];
releaseNpcId?: string | null;
}) {
const nextRosterWithoutRecruit = removeCompanion(
params.session.roster,
params.companion.npcId,
);
if (
!params.releaseNpcId &&
params.session.companions.length < MAX_TASK5_COMPANIONS
) {
return {
companions: [...params.session.companions, params.companion],
roster: nextRosterWithoutRecruit,
releasedCompanion: null,
};
}
if (!params.releaseNpcId) {
throw conflict('队伍已满时必须明确指定一名离队同伴');
}
const replaceIndex = params.session.companions.findIndex(
(item) => item.npcId === params.releaseNpcId,
);
if (replaceIndex < 0) {
throw conflict('指定的离队同伴不存在,无法完成换队招募');
}
const releasedCompanion = params.session.companions[replaceIndex];
if (!releasedCompanion) {
throw conflict('指定的离队同伴不存在,无法完成换队招募');
}
const nextCompanions = [...params.session.companions];
nextCompanions[replaceIndex] = params.companion;
return {
companions: nextCompanions,
roster: normalizeRoster(
upsertCompanion(nextRosterWithoutRecruit, releasedCompanion),
nextCompanions,
),
releasedCompanion,
};
}
function buildBattleTarget(
encounter: RuntimeEncounter,
rawGameState: JsonRecord,
@@ -92,6 +248,7 @@ function buildBattleTarget(
export function resolveNpcInteraction(
session: RuntimeSession,
functionId: string,
payload?: JsonRecord,
): NpcInteractionResolution {
const encounter = requireNpcEncounter(session);
const npcState = requireNpcState(session, encounter);
@@ -179,20 +336,29 @@ export function resolveNpcInteraction(
if (npcState.affinity < 60) {
throw conflict('当前关系还没达到招募阈值,暂时不能邀请入队');
}
if (session.companions.length >= MAX_TASK5_COMPANIONS) {
throw conflict('队伍已满任务5首轮后端接口暂不处理换队逻辑');
}
setEncounterNpcState(session, {
...npcState,
const releaseNpcId = readString(payload?.releaseNpcId) || null;
const recruitedCompanion = buildRecruitedCompanion(
session,
encounter,
npcState,
);
const recruitmentResult = recruitCompanionToParty({
session,
companion: recruitedCompanion,
releaseNpcId,
});
const nextNpcState = {
...markNpcFirstMeaningfulContactResolved(npcState),
recruited: true,
firstMeaningfulContactResolved: true,
});
session.companions.push({
npcId: encounter.id,
characterId: encounter.characterId ?? '',
joinedAtAffinity: npcState.affinity,
});
stanceProfile: applyStoryChoiceToStanceProfile(
npcState.stanceProfile,
'npc_recruit',
{ recruited: true },
),
};
setEncounterNpcState(session, nextNpcState);
session.companions = recruitmentResult.companions;
session.roster = recruitmentResult.roster;
session.currentEncounter = null;
session.npcInteractionActive = false;
session.currentNpcBattleMode = null;
@@ -202,7 +368,9 @@ export function resolveNpcInteraction(
return {
actionText: `邀请${encounter.npcName}加入队伍`,
resultText: `${encounter.npcName}接受了你的邀请,正式进入了同行队伍。`,
resultText: recruitmentResult.releasedCompanion
? `${encounter.npcName}接受了你的邀请,你先让一名当前同行暂时离队,把位置腾给了新的同行者。`
: `${encounter.npcName}接受了你的邀请,正式进入了同行队伍。`,
patches: [
{
type: 'status_changed',

View File

@@ -1,12 +1,12 @@
import type { RuntimeBattlePresentation } from '../../../../packages/shared/src/contracts/story.js';
import type { RuntimeBattlePresentation } from '../../../../packages/shared/src/contracts/rpgRuntimeStoryState.js';
import {
applyQuestSignal,
normalizeQuestEntries,
} from './questProgressionService.js';
import {
replaceRuntimeSessionRawGameState,
type RuntimeSession,
} from '../story/runtimeSession.js';
} from '../rpg-runtime-story/RpgRuntimeSnapshotSync.js';
import type { RuntimeSession } from '../rpg-runtime-story/RpgRuntimeSessionLoader.js';
type JsonRecord = Record<string, unknown>;
type RuntimeGameState = {

View File

@@ -2,7 +2,7 @@ import type {
RuntimeStoryOptionView,
RuntimeStoryActionRequest,
RuntimeStoryPatch,
} from '../../../../packages/shared/src/contracts/story.js';
} from '../../../../packages/shared/src/contracts/rpgRuntimeStoryState.js';
import {
buildExperienceGrantResultText,
grantPlayerExperience,
@@ -26,8 +26,8 @@ import {
} from './questTask6Bridge.js';
import {
replaceRuntimeSessionRawGameState,
type RuntimeSession,
} from '../story/runtimeSession.js';
} from '../rpg-runtime-story/RpgRuntimeSnapshotSync.js';
import type { RuntimeSession } from '../rpg-runtime-story/RpgRuntimeSessionLoader.js';
const SUPPORTED_QUEST_STORY_FUNCTION_IDS = new Set<string>([
'npc_chat_quest_offer_abandon',

View File

@@ -4,7 +4,7 @@ import {
QUEST_OBJECTIVE_KINDS,
QUEST_REWARD_THEMES,
QUEST_URGENCY_LEVELS,
} from '../../../../packages/shared/src/contracts/story.js';
} from '../../../../packages/shared/src/contracts/rpgRuntimeQuestAssist.js';
import {
buildQuestIntentPrompt,
QUEST_INTENT_SYSTEM_PROMPT,

View File

@@ -0,0 +1,14 @@
import {
buildAvailableOptions,
buildLegacyCurrentStory,
buildRuntimeViewModel,
} from './RpgRuntimeSessionDomain.js';
/**
* RPG runtime option / view model 编译入口。
* 工作包 G 后所有可见 option 与 view model 都从新域目录输出。
*/
export { buildAvailableOptions, buildRuntimeViewModel };
export const buildRpgRuntimeAvailableOptions = buildAvailableOptions;
export const buildRpgRuntimeViewModel = buildRuntimeViewModel;
export const buildRpgRuntimeLegacyCurrentStory = buildLegacyCurrentStory;

View File

@@ -3,9 +3,13 @@ import test from 'node:test';
import {
buildAvailableOptions,
} from './RpgRuntimeOptionCompiler.js';
import {
buildLegacyCurrentStory,
} from './RpgRuntimeStoryPresentationCompiler.js';
import {
loadRuntimeSession,
} from './runtimeSession.ts';
} from './RpgRuntimeSessionLoader.js';
function createNpcSnapshot() {
return {

View File

@@ -1,3 +1,7 @@
/**
* RPG runtime session
* G `runtimeSession.ts`
*/
import type {
RuntimeStoryChoicePayload,
RuntimeStoryEncounterViewModel,
@@ -5,9 +9,9 @@ import type {
RuntimeStoryOptionView,
RuntimeStoryViewModel,
Task5RuntimeOptionScope,
} from '../../../../packages/shared/src/contracts/story.js';
import { TASK6_RUNTIME_FUNCTION_IDS } from '../../../../packages/shared/src/contracts/story.js';
import type { SavedSnapshot } from '../../repositories/runtimeRepository.js';
} from '../../../../packages/shared/src/contracts/rpgRuntimeStoryState.js';
import { TASK6_RUNTIME_FUNCTION_IDS } from '../../../../packages/shared/src/contracts/rpgRuntimeStoryAction.js';
import type { RpgRuntimeSavedSnapshot } from '../../repositories/rpg-runtime/RpgRuntimeSnapshotRepository.js';
import {
normalizeRuntimeEntityLevelProfile,
type RuntimeEntityLevelProfile,
@@ -75,6 +79,16 @@ export type RuntimeCompanion = {
npcId: string;
characterId: string;
joinedAtAffinity: number;
hp: number;
maxHp: number;
mana: number;
maxMana: number;
skillCooldowns: Record<string, number>;
animationState?: string;
actionMode?: string;
offsetX?: number;
offsetY?: number;
transitionMs?: number;
};
type RuntimePlayerAttributes = {
@@ -146,6 +160,7 @@ export type RuntimeSession = {
playerMaxMana: number;
npcStates: Record<string, RuntimeNpcState>;
companions: RuntimeCompanion[];
roster: RuntimeCompanion[];
currentNpcBattleMode: 'fight' | 'spar' | null;
currentNpcBattleOutcome: 'fight_victory' | 'spar_complete' | null;
};
@@ -511,6 +526,53 @@ function normalizeCompanion(value: unknown): RuntimeCompanion | null {
npcId,
characterId: readString(rawCompanion.characterId),
joinedAtAffinity: Math.round(readNumber(rawCompanion.joinedAtAffinity, 0)),
hp: Math.max(
0,
Math.round(
readNumber(
rawCompanion.hp,
readNumber(rawCompanion.maxHp, 1),
),
),
),
maxHp: Math.max(1, Math.round(readNumber(rawCompanion.maxHp, 1))),
mana: Math.max(
0,
Math.round(
readNumber(
rawCompanion.mana,
readNumber(rawCompanion.maxMana, 1),
),
),
),
maxMana: Math.max(1, Math.round(readNumber(rawCompanion.maxMana, 1))),
skillCooldowns: Object.fromEntries(
Object.entries(
isObject(rawCompanion.skillCooldowns)
? rawCompanion.skillCooldowns
: {},
).map(([skillId, turns]) => [
skillId,
Math.max(0, Math.round(readNumber(turns, 0))),
]),
),
animationState: readString(rawCompanion.animationState) || undefined,
actionMode: readString(rawCompanion.actionMode) || undefined,
offsetX:
typeof rawCompanion.offsetX === 'number' &&
Number.isFinite(rawCompanion.offsetX)
? rawCompanion.offsetX
: undefined,
offsetY:
typeof rawCompanion.offsetY === 'number' &&
Number.isFinite(rawCompanion.offsetY)
? rawCompanion.offsetY
: undefined,
transitionMs:
typeof rawCompanion.transitionMs === 'number' &&
Number.isFinite(rawCompanion.transitionMs)
? Math.max(0, Math.round(rawCompanion.transitionMs))
: undefined,
};
}
@@ -531,6 +593,15 @@ function normalizeCompanions(value: unknown) {
.filter((entry): entry is RuntimeCompanion => Boolean(entry));
}
function normalizeRoster(
roster: RuntimeCompanion[],
companions: RuntimeCompanion[],
) {
const activeNpcIds = new Set(companions.map((companion) => companion.npcId));
return roster.filter((companion) => !activeNpcIds.has(companion.npcId));
}
function normalizeHostileNpcs(value: unknown) {
return readArray(value)
.map((entry) => normalizeHostileNpc(entry))
@@ -944,7 +1015,7 @@ export function getEncounterKey(encounter: RuntimeEncounter) {
}
export function loadRuntimeSession(
snapshot: SavedSnapshot,
snapshot: RpgRuntimeSavedSnapshot,
requestedSessionId: string,
): RuntimeSession {
const rawGameState = isObject(snapshot.gameState)
@@ -982,6 +1053,10 @@ export function loadRuntimeSession(
),
npcStates: normalizeNpcStates(rawGameState.npcStates),
companions: normalizeCompanions(rawGameState.companions),
roster: normalizeRoster(
normalizeCompanions(rawGameState.roster),
normalizeCompanions(rawGameState.companions),
),
currentNpcBattleMode:
rawGameState.currentNpcBattleMode === 'fight' ||
rawGameState.currentNpcBattleMode === 'spar'
@@ -1185,16 +1260,7 @@ export function buildAvailableOptions(session: RuntimeSession) {
if (npcState && !npcState.recruited && npcState.affinity >= 60) {
options.push(
buildOptionView(
session,
'npc_recruit',
session.companions.length >= MAX_TASK5_COMPANIONS
? {
disabled: true,
reason: '队伍已满任务5首轮后端接口暂不处理换队逻辑。',
}
: {},
),
buildOptionView(session, 'npc_recruit'),
);
}
@@ -1328,6 +1394,7 @@ export function syncRawGameState(session: RuntimeSession) {
session.rawGameState.playerMaxMana = session.playerMaxMana;
session.rawGameState.npcStates = cloneJson(session.npcStates);
session.rawGameState.companions = cloneJson(session.companions);
session.rawGameState.roster = cloneJson(session.roster);
session.rawGameState.currentNpcBattleMode = session.currentNpcBattleMode;
session.rawGameState.currentNpcBattleOutcome =
session.currentNpcBattleOutcome;
@@ -1367,6 +1434,7 @@ export function replaceRuntimeSessionRawGameState(
session.playerMaxMana = refreshed.playerMaxMana;
session.npcStates = refreshed.npcStates;
session.companions = refreshed.companions;
session.roster = refreshed.roster;
session.currentNpcBattleMode = refreshed.currentNpcBattleMode;
session.currentNpcBattleOutcome = refreshed.currentNpcBattleOutcome;
}

View File

@@ -0,0 +1,13 @@
import {
loadRuntimeSession,
type RuntimeSession,
} from './RpgRuntimeSessionDomain.js';
export type { RuntimeSession };
/**
* RPG runtime session loader 的主入口。
* 工作包 G 把旧 `runtimeSession.ts` 的真实实现迁入新域后,这里负责承接稳定命名。
*/
export { loadRuntimeSession };
export const loadRpgRuntimeSession = loadRuntimeSession;

View File

@@ -0,0 +1,29 @@
/**
* RPG runtime session 原子能力导出。
* 这里集中输出运行时动作链直接依赖的 session 原语,避免再次回到旧热点文件取用。
*/
export {
appendStoryHistory,
getEncounterKey,
getEncounterNpcState,
getPlayerCharacter,
getPlayerSkillCooldowns,
isCombatFunctionId,
isNpcFunctionId,
isStoryFunctionId,
isTask5FunctionId,
isTask6RuntimeFunctionId,
MAX_TASK5_COMPANIONS,
setEncounterNpcState,
syncRawGameState,
TASK6_DEFERRED_FUNCTION_IDS,
} from './RpgRuntimeSessionDomain.js';
export type {
RuntimeCompanion,
RuntimeEncounter,
RuntimeHostileNpc,
RuntimeNpcState,
RuntimeSession,
RuntimeStoryHistoryEntry,
} from './RpgRuntimeSessionDomain.js';

View File

@@ -0,0 +1,13 @@
import {
replaceRuntimeSessionRawGameState,
syncRawGameState,
} from './RpgRuntimeSessionDomain.js';
/**
* RPG runtime snapshot 同步入口。
* 工作包 G 后 rawGameState 的回写与替换都统一从新域目录输出。
*/
export { replaceRuntimeSessionRawGameState, syncRawGameState };
export const syncRpgRuntimeSnapshot = syncRawGameState;
export const replaceRpgRuntimeSessionRawGameState =
replaceRuntimeSessionRawGameState;

View File

@@ -1,3 +1,7 @@
/**
* RPG runtime story /
* G RPG runtime story
*/
import type {
RuntimeBattlePresentation,
RuntimeStoryActionRequest,
@@ -5,9 +9,9 @@ import type {
RuntimeStoryOptionView,
RuntimeStoryPatch,
RuntimeStoryStateRequest,
} from '../../../../packages/shared/src/contracts/story.js';
} from '../../../../packages/shared/src/contracts/rpgRuntimeStoryState.js';
import { conflict, invalidRequest } from '../../errors.js';
import type { RuntimeRepositoryPort } from '../../repositories/runtimeRepository.js';
import type { RpgRuntimeSnapshotRepositoryPort } from '../../repositories/rpg-runtime/RpgRuntimeSnapshotRepository.js';
import type { UpstreamLlmClient } from '../../services/llmClient.js';
import {
buildStrictNpcChatDialoguePrompt,
@@ -39,21 +43,27 @@ import {
resolveTreasureStoryAction,
} from '../runtime-item/treasureStoryActionService.js';
import {
appendStoryHistory,
buildAvailableOptions,
buildLegacyCurrentStory,
buildRuntimeViewModel,
} from './RpgRuntimeOptionCompiler.js';
import {
appendStoryHistory,
getEncounterNpcState,
isCombatFunctionId,
isNpcFunctionId,
isStoryFunctionId,
isTask5FunctionId,
loadRuntimeSession,
type RuntimeSession,
setEncounterNpcState,
syncRawGameState,
TASK6_DEFERRED_FUNCTION_IDS,
} from './runtimeSession.js';
} from './RpgRuntimeSessionPrimitives.js';
import {
buildLegacyCurrentStory,
} from './RpgRuntimeStoryPresentationCompiler.js';
import {
loadRuntimeSession,
type RuntimeSession,
} from './RpgRuntimeSessionLoader.js';
type StoryResolution = {
actionText: string;
@@ -630,18 +640,23 @@ function normalizeIncomingSnapshot(snapshot: unknown) {
}
async function resolveSnapshotForRequest(params: {
runtimeRepository: RuntimeRepositoryPort;
snapshotRepository: RpgRuntimeSnapshotRepositoryPort;
userId: string;
snapshot?: unknown;
}) {
const incomingSnapshot = normalizeIncomingSnapshot(params.snapshot);
if (incomingSnapshot) {
return hydrateSavedSnapshot(
await params.runtimeRepository.putSnapshot(params.userId, incomingSnapshot),
await params.snapshotRepository.putSnapshot(
params.userId,
incomingSnapshot,
),
)!;
}
const persistedSnapshot = await params.runtimeRepository.getSnapshot(params.userId);
const persistedSnapshot = await params.snapshotRepository.getSnapshot(
params.userId,
);
if (!persistedSnapshot) {
throw conflict('运行时快照不存在,请先初始化并保存一次游戏');
}
@@ -900,13 +915,13 @@ function resolveStoryFlowAction(
}
export async function resolveRuntimeStoryAction(params: {
runtimeRepository: RuntimeRepositoryPort;
snapshotRepository: RpgRuntimeSnapshotRepositoryPort;
llmClient?: UpstreamLlmClient;
userId: string;
request: RuntimeStoryActionRequest;
}) {
const hydratedSnapshot = await resolveSnapshotForRequest({
runtimeRepository: params.runtimeRepository,
snapshotRepository: params.snapshotRepository,
userId: params.userId,
snapshot: params.request.snapshot,
});
@@ -969,7 +984,13 @@ export async function resolveRuntimeStoryAction(params: {
: undefined,
});
} else if (isNpcFunctionId(functionId)) {
resolution = resolveNpcInteraction(session, functionId);
resolution = resolveNpcInteraction(
session,
functionId,
isObject(params.request.action.payload)
? params.request.action.payload
: undefined,
);
} else if (isSupportedInventoryStoryFunctionId(functionId)) {
resolution = resolveInventoryStoryAction(session, params.request);
} else if (isSupportedNpcInventoryStoryFunctionId(functionId)) {
@@ -1074,7 +1095,7 @@ export async function resolveRuntimeStoryAction(params: {
appendStoryHistory(session, actionText, historyResultText);
syncRawGameState(session);
const persistedSnapshot = await params.runtimeRepository.putSnapshot(
const persistedSnapshot = await params.snapshotRepository.putSnapshot(
params.userId,
normalizeSavedSnapshotPayload({
savedAt: new Date().toISOString(),
@@ -1109,14 +1130,14 @@ export async function resolveRuntimeStoryAction(params: {
}
export async function getRuntimeStoryState(params: {
runtimeRepository: RuntimeRepositoryPort;
snapshotRepository: RpgRuntimeSnapshotRepositoryPort;
userId: string;
sessionId: string;
clientVersion?: number;
snapshot?: RuntimeStoryStateRequest['snapshot'];
}) {
const hydratedSnapshot = await resolveSnapshotForRequest({
runtimeRepository: params.runtimeRepository,
snapshotRepository: params.snapshotRepository,
userId: params.userId,
snapshot: params.snapshot,
});

View File

@@ -0,0 +1,10 @@
import {
resolveRuntimeStoryAction,
} from './RpgRuntimeStoryActionDomain.js';
/**
* RPG runtime story 动作服务入口。
* 工作包 G 后 runtime action 主链直接落到新域实现,不再继续桥接旧热点文件。
*/
export { resolveRuntimeStoryAction };
export const resolveRpgRuntimeStoryAction = resolveRuntimeStoryAction;

View File

@@ -0,0 +1,8 @@
import { buildLegacyCurrentStory } from './RpgRuntimeSessionDomain.js';
/**
* RPG runtime story 展示兼容编译器。
* 当前仍复用 legacy currentStory 投影规则,但真实落点已经切到新域目录。
*/
export { buildLegacyCurrentStory };
export const buildRpgRuntimeLegacyCurrentStory = buildLegacyCurrentStory;

View File

@@ -0,0 +1,8 @@
import { getRuntimeStoryState } from './RpgRuntimeStoryActionDomain.js';
/**
* RPG runtime story 状态读取入口。
* 工作包 G 先把状态读取从旧热点文件迁到新域命名,再保持动作与状态的物理落点分离。
*/
export { getRuntimeStoryState };
export const getRpgRuntimeStoryState = getRuntimeStoryState;

View File

@@ -0,0 +1,35 @@
export {
buildRpgRuntimeAvailableOptions,
buildRpgRuntimeLegacyCurrentStory,
buildRpgRuntimeViewModel,
} from './RpgRuntimeOptionCompiler.js';
export {
appendStoryHistory,
getEncounterKey,
getEncounterNpcState,
getPlayerCharacter,
getPlayerSkillCooldowns,
isCombatFunctionId,
isNpcFunctionId,
isStoryFunctionId,
isTask5FunctionId,
isTask6RuntimeFunctionId,
MAX_TASK5_COMPANIONS,
setEncounterNpcState,
TASK6_DEFERRED_FUNCTION_IDS,
type RuntimeEncounter,
type RuntimeNpcState,
type RuntimeSession as RuntimeSessionPrimitives,
} from './RpgRuntimeSessionPrimitives.js';
export {
loadRpgRuntimeSession,
type RuntimeSession,
} from './RpgRuntimeSessionLoader.js';
export {
replaceRpgRuntimeSessionRawGameState,
syncRpgRuntimeSnapshot,
} from './RpgRuntimeSnapshotSync.js';
export {
resolveRpgRuntimeStoryAction,
} from './RpgRuntimeStoryActionService.js';
export { getRpgRuntimeStoryState } from './RpgRuntimeStoryStateService.js';

View File

@@ -1,7 +1,7 @@
import {
RUNTIME_ITEM_FUNCTIONAL_BIAS_VALUES,
RUNTIME_ITEM_TONE_VALUES,
} from '../../../../packages/shared/src/contracts/story.js';
} from '../../../../packages/shared/src/contracts/rpgRuntimeQuestAssist.js';
import {
buildRuntimeItemIntentPromptText,
RUNTIME_ITEM_INTENT_SYSTEM_PROMPT,

View File

@@ -1,7 +1,7 @@
import type {
RuntimeStoryActionRequest,
RuntimeStoryPatch,
} from '../../../../packages/shared/src/contracts/story.js';
} from '../../../../packages/shared/src/contracts/rpgRuntimeStoryState.js';
import { conflict, invalidRequest } from '../../errors.js';
import {
addInventoryItems,
@@ -14,8 +14,8 @@ import {
import { buildBuildToast } from '../inventory/inventoryStoryActionService.js';
import {
replaceRuntimeSessionRawGameState,
type RuntimeSession,
} from '../story/runtimeSession.js';
} from '../rpg-runtime-story/RpgRuntimeSnapshotSync.js';
import type { RuntimeSession } from '../rpg-runtime-story/RpgRuntimeSessionLoader.js';
const SUPPORTED_TREASURE_STORY_FUNCTION_IDS = new Set<string>([
'treasure_inspect',

View File

@@ -251,7 +251,7 @@ test('unauthorized request keeps request trace in error log and response header'
assert.equal(response.status, 401);
assert.equal(response.headers.get('x-request-id'), requestId);
assert.equal(payload.error.message, '缺少 Authorization Bearer Token');
assert.equal(payload.error.message, '缺少登录凭证');
const errorLog = await waitForRecord(
records,
@@ -262,7 +262,7 @@ test('unauthorized request keeps request trace in error log and response header'
assert.equal(errorLog.user_id, null);
assert.equal(
(errorLog.err as { message?: string } | undefined)?.message,
'缺少 Authorization Bearer Token',
'缺少登录凭证',
);
assert.equal(errorLog.api_version, '2026-04-08');
assert.equal(errorLog.route_version, '2026-04-08');

View File

@@ -5,7 +5,7 @@ import type {
NpcChatDialogueRequest,
NpcChatTurnRequest,
NpcRecruitDialogueRequest,
} from '../../../packages/shared/src/contracts/story.js';
} from '../../../packages/shared/src/contracts/rpgRuntimeChat.js';
type JsonRecord = Record<string, unknown>;

View File

@@ -0,0 +1,100 @@
import type { CustomWorldSessionRecord } from '../../../packages/shared/src/contracts/runtime.js';
import type { AppDatabase } from '../db.js';
import {
type RpgAgentSessionRow,
} from './rpgWorldRepositoryShared.js';
/**
* RPG Agent session 仓储最小读写接口。
* 工作包 F 后续所有 session store / test stub 都应优先依赖这个领域端口。
*/
export type RpgAgentSessionRepositoryPort = {
listSessions(userId: string): Promise<CustomWorldSessionRecord[]>;
getSession(
userId: string,
sessionId: string,
): Promise<CustomWorldSessionRecord | null>;
upsertSession(
userId: string,
sessionId: string,
session: CustomWorldSessionRecord,
): Promise<CustomWorldSessionRecord>;
};
/**
* RPG Agent session 仓储只负责 session 表读写,不再承担兼容补齐与快照派生。
*/
export class RpgAgentSessionRepository implements RpgAgentSessionRepositoryPort {
constructor(private readonly db: AppDatabase) {}
async listSessions(userId: string) {
const result = await this.db.query<RpgAgentSessionRow>(
`SELECT payload_json AS payload,
created_at AS "createdAt",
updated_at AS "updatedAt"
FROM custom_world_sessions
WHERE user_id = $1
ORDER BY updated_at DESC`,
[userId],
);
return result.rows.map((row) => ({
...row.payload,
createdAt: row.createdAt,
updatedAt: row.updatedAt,
}));
}
async getSession(userId: string, sessionId: string) {
const result = await this.db.query<RpgAgentSessionRow>(
`SELECT payload_json AS payload,
created_at AS "createdAt",
updated_at AS "updatedAt"
FROM custom_world_sessions
WHERE user_id = $1 AND session_id = $2`,
[userId, sessionId],
);
const row = result.rows[0];
if (!row) {
return null;
}
return {
...row.payload,
createdAt: row.createdAt,
updatedAt: row.updatedAt,
};
}
async upsertSession(
userId: string,
sessionId: string,
session: CustomWorldSessionRecord,
) {
const payload = {
...session,
sessionId,
} satisfies CustomWorldSessionRecord;
await this.db.query(
`INSERT INTO custom_world_sessions (
user_id,
session_id,
payload_json,
created_at,
updated_at
) VALUES ($1, $2, $3, $4, $5)
ON CONFLICT (user_id, session_id) DO UPDATE SET
payload_json = EXCLUDED.payload_json,
updated_at = EXCLUDED.updated_at`,
[userId, sessionId, payload, session.createdAt, session.updatedAt],
);
return {
...payload,
createdAt: session.createdAt,
updatedAt: session.updatedAt,
};
}
}

View File

@@ -0,0 +1,433 @@
import type {
CustomWorldGalleryCard,
CustomWorldLibraryEntry,
CustomWorldProfileRecord,
} from '../../../packages/shared/src/contracts/runtime.js';
import { extractCustomWorldLibraryMetadata } from './customWorldLibraryMetadata.js';
import type { AppDatabase } from '../db.js';
import {
MAX_RPG_WORLD_GALLERY_ENTRIES,
MAX_RPG_WORLD_PROFILE_ENTRIES,
normalizeStoredRpgWorldProfile,
toRpgWorldGalleryCard,
toRpgWorldLibraryEntry,
type RpgWorldGalleryRow,
type RpgWorldProfileRow,
} from './rpgWorldRepositoryShared.js';
/**
* RPG 世界 profile 领域端口。
* works、library、gallery、脚本同步等链路后续统一依赖这个接口而不是 RuntimeRepositoryPort。
*/
export type RpgWorldProfileRepositoryPort = {
listOwnProfiles(
userId: string,
): Promise<CustomWorldLibraryEntry<CustomWorldProfileRecord>[]>;
upsertOwnProfile(
userId: string,
profileId: string,
profile: Record<string, unknown>,
authorDisplayName: string,
): Promise<{
entry: CustomWorldLibraryEntry<CustomWorldProfileRecord>;
entries: CustomWorldLibraryEntry<CustomWorldProfileRecord>[];
}>;
syncProfileFromSnapshot(
userId: string,
profileId: string,
profile: Record<string, unknown>,
syncedAt: string,
): Promise<void>;
softDeleteOwnProfile(
userId: string,
profileId: string,
): Promise<CustomWorldLibraryEntry<CustomWorldProfileRecord>[]>;
publishOwnProfile(
userId: string,
profileId: string,
authorDisplayName: string,
): Promise<{
entry: CustomWorldLibraryEntry<CustomWorldProfileRecord>;
entries: CustomWorldLibraryEntry<CustomWorldProfileRecord>[];
} | null>;
unpublishOwnProfile(
userId: string,
profileId: string,
authorDisplayName: string,
): Promise<{
entry: CustomWorldLibraryEntry<CustomWorldProfileRecord>;
entries: CustomWorldLibraryEntry<CustomWorldProfileRecord>[];
} | null>;
listPublishedGallery(): Promise<CustomWorldGalleryCard[]>;
getPublishedGalleryDetail(
ownerUserId: string,
profileId: string,
): Promise<CustomWorldLibraryEntry<CustomWorldProfileRecord> | null>;
};
/**
* RPG 世界 profile 仓储统一负责作品库、发布态与画廊读写。
*/
export class RpgWorldProfileRepository implements RpgWorldProfileRepositoryPort {
constructor(private readonly db: AppDatabase) {}
private async findOwnProfileEntry(userId: string, profileId: string) {
const result = await this.db.query<RpgWorldProfileRow>(
`SELECT user_id AS "ownerUserId",
profile_id AS "profileId",
payload_json AS payload,
visibility,
published_at AS "publishedAt",
updated_at AS "updatedAt",
author_display_name AS "authorDisplayName",
world_name AS "worldName",
subtitle,
summary_text AS "summaryText",
cover_image_src AS "coverImageSrc",
theme_mode AS "themeMode",
playable_npc_count AS "playableNpcCount",
landmark_count AS "landmarkCount"
FROM custom_world_profiles
WHERE user_id = $1
AND profile_id = $2
AND deleted_at IS NULL`,
[userId, profileId],
);
const row = result.rows[0];
return row ? toRpgWorldLibraryEntry(row) : null;
}
async listOwnProfiles(userId: string) {
const result = await this.db.query<RpgWorldProfileRow>(
`SELECT user_id AS "ownerUserId",
profile_id AS "profileId",
payload_json AS payload,
visibility,
published_at AS "publishedAt",
updated_at AS "updatedAt",
author_display_name AS "authorDisplayName",
world_name AS "worldName",
subtitle,
summary_text AS "summaryText",
cover_image_src AS "coverImageSrc",
theme_mode AS "themeMode",
playable_npc_count AS "playableNpcCount",
landmark_count AS "landmarkCount"
FROM custom_world_profiles
WHERE user_id = $1
AND deleted_at IS NULL
ORDER BY updated_at DESC
LIMIT $2`,
[userId, MAX_RPG_WORLD_PROFILE_ENTRIES],
);
return result.rows.map((row) => toRpgWorldLibraryEntry(row));
}
async upsertOwnProfile(
userId: string,
profileId: string,
profile: Record<string, unknown>,
authorDisplayName: string,
) {
const payload = normalizeStoredRpgWorldProfile(profileId, profile);
const metadata = extractCustomWorldLibraryMetadata(payload);
const now = new Date().toISOString();
await this.db.query(
`INSERT INTO custom_world_profiles (
user_id,
profile_id,
payload_json,
updated_at,
author_display_name,
world_name,
subtitle,
summary_text,
cover_image_src,
theme_mode,
playable_npc_count,
landmark_count
)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)
ON CONFLICT (user_id, profile_id) DO UPDATE SET
payload_json = EXCLUDED.payload_json,
updated_at = EXCLUDED.updated_at,
deleted_at = NULL,
author_display_name = EXCLUDED.author_display_name,
world_name = EXCLUDED.world_name,
subtitle = EXCLUDED.subtitle,
summary_text = EXCLUDED.summary_text,
cover_image_src = EXCLUDED.cover_image_src,
theme_mode = EXCLUDED.theme_mode,
playable_npc_count = EXCLUDED.playable_npc_count,
landmark_count = EXCLUDED.landmark_count`,
[
userId,
profileId,
payload,
now,
authorDisplayName || '玩家',
metadata.worldName,
metadata.subtitle,
metadata.summaryText,
metadata.coverImageSrc,
metadata.themeMode,
metadata.playableNpcCount,
metadata.landmarkCount,
],
);
const entry = await this.findOwnProfileEntry(userId, profileId);
if (!entry) {
throw new Error('failed to resolve custom world after upsert');
}
return {
entry,
entries: await this.listOwnProfiles(userId),
};
}
async syncProfileFromSnapshot(
userId: string,
profileId: string,
profile: Record<string, unknown>,
syncedAt: string,
) {
const payload = normalizeStoredRpgWorldProfile(profileId, profile);
const metadata = extractCustomWorldLibraryMetadata(payload);
await this.db.query(
`INSERT INTO custom_world_profiles (
user_id,
profile_id,
payload_json,
updated_at,
author_display_name,
world_name,
subtitle,
summary_text,
cover_image_src,
theme_mode,
playable_npc_count,
landmark_count,
deleted_at
)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, NULL)
ON CONFLICT (user_id, profile_id) DO UPDATE SET
payload_json = EXCLUDED.payload_json,
updated_at = EXCLUDED.updated_at,
deleted_at = NULL,
world_name = EXCLUDED.world_name,
subtitle = EXCLUDED.subtitle,
summary_text = EXCLUDED.summary_text,
cover_image_src = EXCLUDED.cover_image_src,
theme_mode = EXCLUDED.theme_mode,
playable_npc_count = EXCLUDED.playable_npc_count,
landmark_count = EXCLUDED.landmark_count`,
[
userId,
profileId,
payload,
syncedAt,
'玩家',
metadata.worldName,
metadata.subtitle,
metadata.summaryText,
metadata.coverImageSrc,
metadata.themeMode,
metadata.playableNpcCount,
metadata.landmarkCount,
],
);
}
async softDeleteOwnProfile(userId: string, profileId: string) {
const deletedAt = new Date().toISOString();
await this.db.query(
`UPDATE custom_world_profiles
SET deleted_at = $1,
updated_at = $1,
visibility = 'draft',
published_at = NULL
WHERE user_id = $2
AND profile_id = $3
AND deleted_at IS NULL`,
[deletedAt, userId, profileId],
);
return this.listOwnProfiles(userId);
}
async publishOwnProfile(
userId: string,
profileId: string,
authorDisplayName: string,
) {
const existingEntry = await this.findOwnProfileEntry(userId, profileId);
if (!existingEntry) {
return null;
}
const payload = normalizeStoredRpgWorldProfile(
profileId,
existingEntry.profile,
);
const metadata = extractCustomWorldLibraryMetadata(payload);
const now = new Date().toISOString();
await this.db.query(
`UPDATE custom_world_profiles
SET visibility = 'published',
published_at = $1,
updated_at = $1,
author_display_name = $2,
world_name = $3,
subtitle = $4,
summary_text = $5,
cover_image_src = $6,
theme_mode = $7,
playable_npc_count = $8,
landmark_count = $9
WHERE user_id = $10
AND profile_id = $11`,
[
now,
authorDisplayName || '玩家',
metadata.worldName,
metadata.subtitle,
metadata.summaryText,
metadata.coverImageSrc,
metadata.themeMode,
metadata.playableNpcCount,
metadata.landmarkCount,
userId,
profileId,
],
);
const entry = await this.findOwnProfileEntry(userId, profileId);
if (!entry) {
throw new Error('failed to resolve custom world after publish');
}
return {
entry,
entries: await this.listOwnProfiles(userId),
};
}
async unpublishOwnProfile(
userId: string,
profileId: string,
authorDisplayName: string,
) {
const existingEntry = await this.findOwnProfileEntry(userId, profileId);
if (!existingEntry) {
return null;
}
const payload = normalizeStoredRpgWorldProfile(
profileId,
existingEntry.profile,
);
const metadata = extractCustomWorldLibraryMetadata(payload);
const now = new Date().toISOString();
await this.db.query(
`UPDATE custom_world_profiles
SET visibility = 'draft',
published_at = NULL,
updated_at = $1,
author_display_name = $2,
world_name = $3,
subtitle = $4,
summary_text = $5,
cover_image_src = $6,
theme_mode = $7,
playable_npc_count = $8,
landmark_count = $9
WHERE user_id = $10
AND profile_id = $11`,
[
now,
authorDisplayName || '玩家',
metadata.worldName,
metadata.subtitle,
metadata.summaryText,
metadata.coverImageSrc,
metadata.themeMode,
metadata.playableNpcCount,
metadata.landmarkCount,
userId,
profileId,
],
);
const entry = await this.findOwnProfileEntry(userId, profileId);
if (!entry) {
throw new Error('failed to resolve custom world after unpublish');
}
return {
entry,
entries: await this.listOwnProfiles(userId),
};
}
async listPublishedGallery() {
const result = await this.db.query<RpgWorldGalleryRow>(
`SELECT user_id AS "ownerUserId",
profile_id AS "profileId",
visibility,
published_at AS "publishedAt",
updated_at AS "updatedAt",
author_display_name AS "authorDisplayName",
world_name AS "worldName",
subtitle,
summary_text AS "summaryText",
cover_image_src AS "coverImageSrc",
theme_mode AS "themeMode",
playable_npc_count AS "playableNpcCount",
landmark_count AS "landmarkCount"
FROM custom_world_profiles
WHERE visibility = 'published'
AND deleted_at IS NULL
ORDER BY published_at DESC, updated_at DESC
LIMIT $1`,
[MAX_RPG_WORLD_GALLERY_ENTRIES],
);
return result.rows.map((row) => toRpgWorldGalleryCard(row));
}
async getPublishedGalleryDetail(ownerUserId: string, profileId: string) {
const result = await this.db.query<RpgWorldProfileRow>(
`SELECT user_id AS "ownerUserId",
profile_id AS "profileId",
payload_json AS payload,
visibility,
published_at AS "publishedAt",
updated_at AS "updatedAt",
author_display_name AS "authorDisplayName",
world_name AS "worldName",
subtitle,
summary_text AS "summaryText",
cover_image_src AS "coverImageSrc",
theme_mode AS "themeMode",
playable_npc_count AS "playableNpcCount",
landmark_count AS "landmarkCount"
FROM custom_world_profiles
WHERE user_id = $1
AND profile_id = $2
AND visibility = 'published'
AND deleted_at IS NULL`,
[ownerUserId, profileId],
);
const row = result.rows[0];
return row ? toRpgWorldLibraryEntry(row) : null;
}
}

View File

@@ -0,0 +1,241 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import type { RuntimeRepositoryPort } from '../runtimeRepository.js';
import { RpgSaveArchiveRepository } from './RpgSaveArchiveRepository.js';
import { RpgWorldLibraryRepository } from './RpgWorldLibraryRepository.js';
function createRuntimeRepositoryStub(): RuntimeRepositoryPort {
return {
async getSnapshot() {
return null;
},
async putSnapshot(_userId, payload) {
return {
version: 1,
...payload,
};
},
async getProfileDashboard() {
return {
walletBalance: 0,
totalPlayTimeMs: 0,
playedWorldCount: 0,
updatedAt: '2026-04-21T00:00:00.000Z',
};
},
async listProfileWalletLedger() {
return [];
},
async getProfilePlayStats() {
return {
totalPlayTimeMs: 0,
playedWorks: [],
updatedAt: '2026-04-21T00:00:00.000Z',
};
},
async listProfileSaveArchives() {
return [
{
worldKey: 'world-1',
ownerUserId: 'owner-1',
profileId: 'profile-1',
worldType: 'custom',
worldName: '潮影群岛',
subtitle: '港雾与旧航道',
summaryText: '最近一次继续游戏入口',
coverImageSrc: null,
lastPlayedAt: '2026-04-20T23:59:59.000Z',
},
];
},
async resumeProfileSaveArchive() {
return {
entry: {
worldKey: 'world-1',
ownerUserId: 'owner-1',
profileId: 'profile-1',
worldType: 'custom',
worldName: '潮影群岛',
subtitle: '港雾与旧航道',
summaryText: '最近一次继续游戏入口',
coverImageSrc: null,
lastPlayedAt: '2026-04-20T23:59:59.000Z',
},
snapshot: {
version: 1,
savedAt: '2026-04-20T23:59:59.000Z',
bottomTab: 'adventure',
gameState: { currentScene: '潮影港' },
currentStory: null,
},
};
},
async deleteSnapshot() {},
async getSettings() {
return {
musicVolume: 0.42,
platformTheme: 'light',
};
},
async putSettings(_userId, settings) {
return settings;
},
async listCustomWorldProfiles() {
return [
{
ownerUserId: 'owner-1',
profileId: 'profile-1',
profile: {
id: 'profile-1',
},
visibility: 'published',
publishedAt: '2026-04-20T08:00:00.000Z',
updatedAt: '2026-04-20T08:00:00.000Z',
authorDisplayName: '造物者',
worldName: '潮影群岛',
subtitle: '港雾与旧航道',
summaryText: '一座在潮汐中漂移的群岛。',
coverImageSrc: '/covers/tide.png',
themeMode: 'tide',
playableNpcCount: 2,
landmarkCount: 3,
},
];
},
async listPlatformBrowseHistory() {
return [];
},
async upsertPlatformBrowseHistoryEntries() {
return [];
},
async clearPlatformBrowseHistory() {},
async upsertCustomWorldProfile() {
return {
entry: {
ownerUserId: 'owner-1',
profileId: 'profile-1',
profile: {
id: 'profile-1',
},
visibility: 'draft',
publishedAt: null,
updatedAt: '2026-04-20T08:00:00.000Z',
authorDisplayName: '造物者',
worldName: '潮影群岛',
subtitle: '港雾与旧航道',
summaryText: '一座在潮汐中漂移的群岛。',
coverImageSrc: '/covers/tide.png',
themeMode: 'tide',
playableNpcCount: 2,
landmarkCount: 3,
},
entries: [],
};
},
async deleteCustomWorldProfile() {
return [];
},
async listCustomWorldSessions() {
return [];
},
async getCustomWorldSession() {
return null;
},
async upsertCustomWorldSession(_userId, _sessionId, session) {
return session;
},
async publishCustomWorldProfile() {
return {
entry: {
ownerUserId: 'owner-1',
profileId: 'profile-1',
profile: {
id: 'profile-1',
},
visibility: 'published',
publishedAt: '2026-04-20T08:00:00.000Z',
updatedAt: '2026-04-20T08:00:00.000Z',
authorDisplayName: '造物者',
worldName: '潮影群岛',
subtitle: '港雾与旧航道',
summaryText: '一座在潮汐中漂移的群岛。',
coverImageSrc: '/covers/tide.png',
themeMode: 'tide',
playableNpcCount: 2,
landmarkCount: 3,
},
entries: [],
};
},
async unpublishCustomWorldProfile() {
return null;
},
async listPublishedCustomWorldGallery() {
return [
{
ownerUserId: 'owner-1',
profileId: 'profile-1',
visibility: 'published',
publishedAt: '2026-04-20T08:00:00.000Z',
updatedAt: '2026-04-20T08:00:00.000Z',
authorDisplayName: '造物者',
worldName: '潮影群岛',
subtitle: '港雾与旧航道',
summaryText: '一座在潮汐中漂移的群岛。',
coverImageSrc: '/covers/tide.png',
themeMode: 'tide',
playableNpcCount: 2,
landmarkCount: 3,
},
];
},
async getPublishedCustomWorldGalleryDetail() {
return {
ownerUserId: 'owner-1',
profileId: 'profile-1',
profile: {
id: 'profile-1',
},
visibility: 'published',
publishedAt: '2026-04-20T08:00:00.000Z',
updatedAt: '2026-04-20T08:00:00.000Z',
authorDisplayName: '造物者',
worldName: '潮影群岛',
subtitle: '港雾与旧航道',
summaryText: '一座在潮汐中漂移的群岛。',
coverImageSrc: '/covers/tide.png',
themeMode: 'tide',
playableNpcCount: 2,
landmarkCount: 3,
};
},
};
}
test('RpgSaveArchiveRepository 只承接继续游戏归档读取职责', async () => {
const repository = new RpgSaveArchiveRepository(createRuntimeRepositoryStub());
const archives = await repository.listProfileSaveArchives('user-1');
const resumed = await repository.resumeProfileSaveArchive('user-1', 'world-1');
assert.equal(archives[0]?.worldName, '潮影群岛');
assert.equal(resumed?.snapshot.bottomTab, 'adventure');
assert.equal('getSnapshot' in repository, false);
});
test('RpgWorldLibraryRepository 独立承接作品库与广场读取职责', async () => {
const repository = new RpgWorldLibraryRepository(createRuntimeRepositoryStub());
const profiles = await repository.listCustomWorldProfiles('user-1');
const gallery = await repository.listPublishedCustomWorldGallery();
const detail = await repository.getPublishedCustomWorldGalleryDetail(
'owner-1',
'profile-1',
);
assert.equal(profiles[0]?.worldName, '潮影群岛');
assert.equal(gallery[0]?.themeMode, 'tide');
assert.equal(detail?.profileId, 'profile-1');
assert.equal('listProfileSaveArchives' in repository, false);
});

View File

@@ -0,0 +1,36 @@
import type {
RuntimeRepositoryPort,
SavedSnapshot,
} from '../runtimeRepository.js';
import type { ProfileSaveArchiveSummary } from '../../../../packages/shared/src/contracts/runtime.js';
/**
* RPG 继续游戏归档仓储端口。
* 当前仍由 runtimeRepository 提供真实实现,本文件只建立按领域命名的兼容入口。
*/
export type RpgSaveArchiveRepositoryPort = Pick<
RuntimeRepositoryPort,
'listProfileSaveArchives' | 'resumeProfileSaveArchive'
>;
export type RpgSaveArchiveSnapshot = SavedSnapshot;
export class RpgSaveArchiveRepository implements RpgSaveArchiveRepositoryPort {
constructor(private readonly runtimeRepository: RuntimeRepositoryPort) {}
listProfileSaveArchives(userId: string): Promise<ProfileSaveArchiveSummary[]> {
return this.runtimeRepository.listProfileSaveArchives(userId);
}
resumeProfileSaveArchive(
userId: string,
worldKey: string,
): Promise<
| {
entry: ProfileSaveArchiveSummary;
snapshot: RpgSaveArchiveSnapshot;
}
| null
> {
return this.runtimeRepository.resumeProfileSaveArchive(userId, worldKey);
}
}

View File

@@ -0,0 +1,92 @@
import type {
CustomWorldGalleryCard,
CustomWorldLibraryEntry,
CustomWorldProfileRecord,
} from '../../../../packages/shared/src/contracts/runtime.js';
import type { RuntimeRepositoryPort } from '../runtimeRepository.js';
/**
* RPG 世界库仓储端口。
* 当前先桥接旧 runtimeRepository 中的世界库与广场读写,为工作包 H 的按域拆仓储提供命名骨架。
*/
export type RpgWorldLibraryRepositoryPort = Pick<
RuntimeRepositoryPort,
| 'deleteCustomWorldProfile'
| 'getPublishedCustomWorldGalleryDetail'
| 'listCustomWorldProfiles'
| 'listPublishedCustomWorldGallery'
| 'publishCustomWorldProfile'
| 'unpublishCustomWorldProfile'
| 'upsertCustomWorldProfile'
>;
export class RpgWorldLibraryRepository
implements RpgWorldLibraryRepositoryPort
{
constructor(private readonly runtimeRepository: RuntimeRepositoryPort) {}
listCustomWorldProfiles(
userId: string,
): Promise<CustomWorldLibraryEntry<CustomWorldProfileRecord>[]> {
return this.runtimeRepository.listCustomWorldProfiles(userId);
}
upsertCustomWorldProfile(
userId: string,
profileId: string,
profile: Record<string, unknown>,
authorDisplayName: string,
) {
return this.runtimeRepository.upsertCustomWorldProfile(
userId,
profileId,
profile,
authorDisplayName,
);
}
deleteCustomWorldProfile(
userId: string,
profileId: string,
): Promise<CustomWorldLibraryEntry<CustomWorldProfileRecord>[]> {
return this.runtimeRepository.deleteCustomWorldProfile(userId, profileId);
}
publishCustomWorldProfile(
userId: string,
profileId: string,
authorDisplayName: string,
) {
return this.runtimeRepository.publishCustomWorldProfile(
userId,
profileId,
authorDisplayName,
);
}
unpublishCustomWorldProfile(
userId: string,
profileId: string,
authorDisplayName: string,
) {
return this.runtimeRepository.unpublishCustomWorldProfile(
userId,
profileId,
authorDisplayName,
);
}
listPublishedCustomWorldGallery(): Promise<CustomWorldGalleryCard[]> {
return this.runtimeRepository.listPublishedCustomWorldGallery();
}
getPublishedCustomWorldGalleryDetail(
ownerUserId: string,
profileId: string,
): Promise<CustomWorldLibraryEntry<CustomWorldProfileRecord> | null> {
return this.runtimeRepository.getPublishedCustomWorldGalleryDetail(
ownerUserId,
profileId,
);
}
}

View File

@@ -0,0 +1,8 @@
export {
RpgSaveArchiveRepository,
type RpgSaveArchiveRepositoryPort,
} from './RpgSaveArchiveRepository.js';
export {
RpgWorldLibraryRepository,
type RpgWorldLibraryRepositoryPort,
} from './RpgWorldLibraryRepository.js';

View File

@@ -0,0 +1,42 @@
import type {
PlatformBrowseHistoryEntry,
PlatformBrowseHistoryWriteEntry,
} from '../../../../packages/shared/src/contracts/runtime.js';
import type { RuntimeRepositoryPort } from '../runtimeRepository.js';
/**
* RPG 浏览历史仓储端口。
* 将继续游戏与平台浏览足迹独立命名,避免继续堆叠在资料看板仓储内。
*/
export type RpgBrowseHistoryRepositoryPort = Pick<
RuntimeRepositoryPort,
| 'clearPlatformBrowseHistory'
| 'listPlatformBrowseHistory'
| 'upsertPlatformBrowseHistoryEntries'
>;
export class RpgBrowseHistoryRepository
implements RpgBrowseHistoryRepositoryPort
{
constructor(private readonly runtimeRepository: RuntimeRepositoryPort) {}
listPlatformBrowseHistory(
userId: string,
): Promise<PlatformBrowseHistoryEntry[]> {
return this.runtimeRepository.listPlatformBrowseHistory(userId);
}
upsertPlatformBrowseHistoryEntries(
userId: string,
entries: PlatformBrowseHistoryWriteEntry[],
): Promise<PlatformBrowseHistoryEntry[]> {
return this.runtimeRepository.upsertPlatformBrowseHistoryEntries(
userId,
entries,
);
}
clearPlatformBrowseHistory(userId: string): Promise<void> {
return this.runtimeRepository.clearPlatformBrowseHistory(userId);
}
}

View File

@@ -0,0 +1,49 @@
import type {
ProfileDashboardSummary,
ProfilePlayStatsResponse,
ProfileWalletLedgerEntry,
RuntimeSettings,
} from '../../../../packages/shared/src/contracts/runtime.js';
import type { RuntimeRepositoryPort } from '../runtimeRepository.js';
/**
* RPG profile 域仓储端口。
* 当前以委托方式桥接旧 runtimeRepository给后续按域仓储拆分保留稳定依赖面。
*/
export type RpgProfileDashboardRepositoryPort = Pick<
RuntimeRepositoryPort,
| 'getProfileDashboard'
| 'getProfilePlayStats'
| 'getSettings'
| 'listProfileWalletLedger'
| 'putSettings'
>;
export class RpgProfileDashboardRepository
implements RpgProfileDashboardRepositoryPort
{
constructor(private readonly runtimeRepository: RuntimeRepositoryPort) {}
getProfileDashboard(userId: string): Promise<ProfileDashboardSummary> {
return this.runtimeRepository.getProfileDashboard(userId);
}
listProfileWalletLedger(userId: string): Promise<ProfileWalletLedgerEntry[]> {
return this.runtimeRepository.listProfileWalletLedger(userId);
}
getProfilePlayStats(userId: string): Promise<ProfilePlayStatsResponse> {
return this.runtimeRepository.getProfilePlayStats(userId);
}
getSettings(userId: string): Promise<RuntimeSettings> {
return this.runtimeRepository.getSettings(userId);
}
putSettings(
userId: string,
settings: RuntimeSettings,
): Promise<RuntimeSettings> {
return this.runtimeRepository.putSettings(userId, settings);
}
}

View File

@@ -0,0 +1,154 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import type { RuntimeRepositoryPort } from '../runtimeRepository.js';
import { RpgBrowseHistoryRepository } from './RpgBrowseHistoryRepository.js';
import { RpgProfileDashboardRepository } from './RpgProfileDashboardRepository.js';
function createRuntimeRepositoryStub(): RuntimeRepositoryPort {
return {
async getSnapshot() {
return null;
},
async putSnapshot(_userId, payload) {
return {
version: 1,
...payload,
};
},
async getProfileDashboard() {
return {
walletBalance: 0,
totalPlayTimeMs: 0,
playedWorldCount: 0,
updatedAt: '2026-04-21T00:00:00.000Z',
};
},
async listProfileWalletLedger() {
return [];
},
async getProfilePlayStats() {
return {
totalPlayTimeMs: 0,
playedWorks: [],
updatedAt: '2026-04-21T00:00:00.000Z',
};
},
async listProfileSaveArchives() {
return [];
},
async resumeProfileSaveArchive() {
return null;
},
async deleteSnapshot() {},
async getSettings() {
return {
musicVolume: 0.5,
platformTheme: 'light',
};
},
async putSettings(_userId, settings) {
return settings;
},
async listCustomWorldProfiles() {
return [];
},
async listPlatformBrowseHistory() {
return [
{
ownerUserId: 'owner-1',
profileId: 'profile-1',
worldName: '雾港',
subtitle: '沿海试炼',
summaryText: '最近访问',
coverImageSrc: null,
themeMode: 'mythic',
authorDisplayName: '测试者',
visitedAt: '2026-04-21T00:00:00.000Z',
},
];
},
async upsertPlatformBrowseHistoryEntries(_userId, entries) {
return entries.map((entry) => ({
ownerUserId: entry.ownerUserId,
profileId: entry.profileId,
worldName: entry.worldName,
subtitle: entry.subtitle ?? '',
summaryText: entry.summaryText ?? '',
coverImageSrc: entry.coverImageSrc ?? null,
themeMode: entry.themeMode ?? 'mythic',
authorDisplayName: entry.authorDisplayName ?? '玩家',
visitedAt: entry.visitedAt ?? '2026-04-21T00:00:00.000Z',
}));
},
async clearPlatformBrowseHistory() {},
async upsertCustomWorldProfile() {
return {
entry: {} as never,
entries: [],
};
},
async deleteCustomWorldProfile() {
return [];
},
async listCustomWorldSessions() {
return [];
},
async getCustomWorldSession() {
return null;
},
async upsertCustomWorldSession(_userId, _sessionId, session) {
return session;
},
async publishCustomWorldProfile() {
return null;
},
async unpublishCustomWorldProfile() {
return null;
},
async listPublishedCustomWorldGallery() {
return [];
},
async getPublishedCustomWorldGalleryDetail() {
return null;
},
};
}
test('RpgProfileDashboardRepository 只暴露资料看板域方法', async () => {
const repository = new RpgProfileDashboardRepository(
createRuntimeRepositoryStub(),
);
const dashboard = await repository.getProfileDashboard('user-1');
const playStats = await repository.getProfilePlayStats('user-1');
const settings = await repository.getSettings('user-1');
assert.equal(dashboard.playedWorldCount, 0);
assert.equal(playStats.playedWorks.length, 0);
assert.equal(settings.platformTheme, 'light');
assert.equal('listPlatformBrowseHistory' in repository, false);
});
test('RpgBrowseHistoryRepository 独立承接浏览历史读写,不再混入资料看板仓储', async () => {
const repository = new RpgBrowseHistoryRepository(createRuntimeRepositoryStub());
const history = await repository.listPlatformBrowseHistory('user-1');
const updated = await repository.upsertPlatformBrowseHistoryEntries('user-1', [
{
ownerUserId: 'owner-2',
profileId: 'profile-2',
worldName: '盐雾镇',
subtitle: '盐路补给点',
summaryText: '测试写入浏览历史',
coverImageSrc: null,
themeMode: 'mythic',
authorDisplayName: '测试者二号',
visitedAt: '2026-04-21T01:00:00.000Z',
},
]);
assert.equal(history[0]?.worldName, '雾港');
assert.equal(updated[0]?.profileId, 'profile-2');
assert.equal('getProfileDashboard' in repository, false);
});

View File

@@ -0,0 +1,8 @@
export {
RpgBrowseHistoryRepository,
type RpgBrowseHistoryRepositoryPort,
} from './RpgBrowseHistoryRepository.js';
export {
RpgProfileDashboardRepository,
type RpgProfileDashboardRepositoryPort,
} from './RpgProfileDashboardRepository.js';

View File

@@ -0,0 +1,126 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import type { RuntimeRepositoryPort } from '../runtimeRepository.js';
import { RpgRuntimeSnapshotRepository } from './RpgRuntimeSnapshotRepository.js';
function createRuntimeRepositoryStub(): RuntimeRepositoryPort {
const deletedUserIds: string[] = [];
return {
async getSnapshot(userId) {
return {
version: 2,
savedAt: '2026-04-21T00:00:00.000Z',
bottomTab: 'adventure',
gameState: {
owner: userId,
},
currentStory: null,
};
},
async putSnapshot(_userId, payload) {
return {
version: 2,
...payload,
};
},
async getProfileDashboard() {
return {
walletBalance: 0,
totalPlayTimeMs: 0,
playedWorldCount: 0,
updatedAt: '2026-04-21T00:00:00.000Z',
};
},
async listProfileWalletLedger() {
return [];
},
async getProfilePlayStats() {
return {
totalPlayTimeMs: 0,
playedWorks: [],
updatedAt: '2026-04-21T00:00:00.000Z',
};
},
async listProfileSaveArchives() {
return [];
},
async resumeProfileSaveArchive() {
return null;
},
async deleteSnapshot(userId) {
deletedUserIds.push(userId);
},
async getSettings() {
return {
musicVolume: 0.42,
platformTheme: 'light',
};
},
async putSettings(_userId, settings) {
return settings;
},
async listCustomWorldProfiles() {
return [];
},
async listPlatformBrowseHistory() {
return [];
},
async upsertPlatformBrowseHistoryEntries() {
return [];
},
async clearPlatformBrowseHistory() {},
async upsertCustomWorldProfile() {
return {
entry: {} as never,
entries: [],
};
},
async deleteCustomWorldProfile() {
return [];
},
async listCustomWorldSessions() {
return [];
},
async getCustomWorldSession() {
return null;
},
async upsertCustomWorldSession(_userId, _sessionId, session) {
return session;
},
async publishCustomWorldProfile() {
return null;
},
async unpublishCustomWorldProfile() {
return null;
},
async listPublishedCustomWorldGallery() {
return [];
},
async getPublishedCustomWorldGalleryDetail() {
return null;
},
};
}
test('RpgRuntimeSnapshotRepository 独立承接 snapshot 读写与删除职责', async () => {
const runtimeRepository = createRuntimeRepositoryStub();
const repository = new RpgRuntimeSnapshotRepository(runtimeRepository);
const snapshot = await repository.getSnapshot('user-7');
const saved = await repository.putSnapshot('user-7', {
savedAt: '2026-04-21T01:00:00.000Z',
bottomTab: 'inventory',
gameState: {
owner: 'user-7',
currentScene: '雾港',
},
currentStory: null,
});
await repository.deleteSnapshot('user-7');
assert.equal(snapshot?.gameState.owner, 'user-7');
assert.equal(saved.bottomTab, 'inventory');
assert.equal('listProfileSaveArchives' in repository, false);
});

View File

@@ -0,0 +1,35 @@
import type {
RuntimeRepositoryPort,
SavedSnapshot,
} from '../runtimeRepository.js';
/**
* RPG runtime 快照仓储端口。
* 工作包 A 先把 snapshot 领域从大仓储中抽出独立命名入口,真实读写仍委托现有 runtimeRepository。
*/
export type RpgRuntimeSnapshotRepositoryPort = Pick<
RuntimeRepositoryPort,
'deleteSnapshot' | 'getSnapshot' | 'putSnapshot'
>;
export type RpgRuntimeSavedSnapshot = SavedSnapshot;
export class RpgRuntimeSnapshotRepository
implements RpgRuntimeSnapshotRepositoryPort
{
constructor(private readonly runtimeRepository: RuntimeRepositoryPort) {}
getSnapshot(userId: string): Promise<RpgRuntimeSavedSnapshot | null> {
return this.runtimeRepository.getSnapshot(userId);
}
putSnapshot(
userId: string,
payload: Omit<RpgRuntimeSavedSnapshot, 'version'>,
): Promise<RpgRuntimeSavedSnapshot> {
return this.runtimeRepository.putSnapshot(userId, payload);
}
deleteSnapshot(userId: string): Promise<void> {
return this.runtimeRepository.deleteSnapshot(userId);
}
}

View File

@@ -0,0 +1,4 @@
export {
RpgRuntimeSnapshotRepository,
type RpgRuntimeSnapshotRepositoryPort,
} from './RpgRuntimeSnapshotRepository.js';

View File

@@ -0,0 +1,116 @@
import type { QueryResultRow } from 'pg';
import type {
CustomWorldProfileRecord,
} from '../../../packages/shared/src/contracts/runtime.js';
import {
type CustomWorldGalleryCard,
type CustomWorldLibraryEntry,
type CustomWorldPublicationStatus,
type CustomWorldSessionRecord,
} from '../../../packages/shared/src/contracts/runtime.js';
import { extractCustomWorldLibraryMetadata } from './customWorldLibraryMetadata.js';
export const MAX_RPG_WORLD_PROFILE_ENTRIES = 12;
export const MAX_RPG_WORLD_GALLERY_ENTRIES = 36;
export type RpgWorldProfileRow = QueryResultRow & {
ownerUserId: string;
profileId: string;
payload: CustomWorldProfileRecord;
visibility: CustomWorldPublicationStatus;
publishedAt: string | null;
updatedAt: string;
authorDisplayName: string;
worldName: string;
subtitle: string;
summaryText: string;
coverImageSrc: string | null;
themeMode: CustomWorldLibraryEntry['themeMode'];
playableNpcCount: number;
landmarkCount: number;
};
export type RpgAgentSessionRow = QueryResultRow & {
payload: CustomWorldSessionRecord;
createdAt: string;
updatedAt: string;
};
export type RpgWorldGalleryRow = QueryResultRow & {
ownerUserId: string;
profileId: string;
visibility: CustomWorldPublicationStatus;
publishedAt: string | null;
updatedAt: string;
authorDisplayName: string;
worldName: string;
subtitle: string;
summaryText: string;
coverImageSrc: string | null;
themeMode: CustomWorldGalleryCard['themeMode'];
playableNpcCount: number;
landmarkCount: number;
};
/**
* 落库前统一补齐 profileId避免不同入口写入时出现同一世界两个 id 口径。
*/
export function normalizeStoredRpgWorldProfile(
profileId: string,
profile: Record<string, unknown>,
): CustomWorldProfileRecord {
return {
...profile,
id: profileId,
};
}
export function toRpgWorldLibraryEntry(
row: RpgWorldProfileRow,
): CustomWorldLibraryEntry<CustomWorldProfileRecord> {
const fallbackMetadata = extractCustomWorldLibraryMetadata(row.payload);
return {
ownerUserId: row.ownerUserId,
profileId: row.profileId,
profile: row.payload,
visibility: row.visibility,
publishedAt: row.publishedAt,
updatedAt: row.updatedAt,
authorDisplayName: row.authorDisplayName || '玩家',
worldName: row.worldName || fallbackMetadata.worldName,
subtitle: row.subtitle || fallbackMetadata.subtitle,
summaryText: row.summaryText || fallbackMetadata.summaryText,
coverImageSrc: row.coverImageSrc || fallbackMetadata.coverImageSrc,
themeMode: row.themeMode || fallbackMetadata.themeMode,
playableNpcCount:
row.playableNpcCount > 0
? row.playableNpcCount
: fallbackMetadata.playableNpcCount,
landmarkCount:
row.landmarkCount > 0
? row.landmarkCount
: fallbackMetadata.landmarkCount,
};
}
export function toRpgWorldGalleryCard(
row: RpgWorldGalleryRow,
): CustomWorldGalleryCard {
return {
ownerUserId: row.ownerUserId,
profileId: row.profileId,
visibility: row.visibility,
publishedAt: row.publishedAt,
updatedAt: row.updatedAt,
authorDisplayName: row.authorDisplayName || '玩家',
worldName: row.worldName || '未命名世界',
subtitle: row.subtitle || '',
summaryText: row.summaryText || '',
coverImageSrc: row.coverImageSrc || null,
themeMode: row.themeMode || 'mythic',
playableNpcCount: row.playableNpcCount,
landmarkCount: row.landmarkCount,
};
}

View File

@@ -25,9 +25,9 @@ import {
} from '../../../packages/shared/src/contracts/runtime.js';
import type { AppDatabase } from '../db.js';
import { extractCustomWorldLibraryMetadata } from './customWorldLibraryMetadata.js';
const MAX_CUSTOM_WORLD_PROFILES = 12;
const MAX_PUBLIC_CUSTOM_WORLD_PROFILES = 36;
import { RpgAgentSessionRepository } from './RpgAgentSessionRepository.js';
import { RpgWorldProfileRepository } from './RpgWorldProfileRepository.js';
import { normalizeStoredRpgWorldProfile } from './rpgWorldRepositoryShared.js';
export type SavedSnapshot = SavedGameSnapshot<unknown, string, unknown>;
@@ -44,45 +44,6 @@ type SettingsRow = QueryResultRow & {
platformTheme: RuntimeSettings['platformTheme'];
};
type CustomWorldEntryRow = QueryResultRow & {
ownerUserId: string;
profileId: string;
payload: CustomWorldProfileRecord;
visibility: CustomWorldPublicationStatus;
publishedAt: string | null;
updatedAt: string;
authorDisplayName: string;
worldName: string;
subtitle: string;
summaryText: string;
coverImageSrc: string | null;
themeMode: CustomWorldLibraryEntry['themeMode'];
playableNpcCount: number;
landmarkCount: number;
};
type SessionRow = QueryResultRow & {
payload: CustomWorldSessionRecord;
createdAt: string;
updatedAt: string;
};
type CustomWorldCardRow = QueryResultRow & {
ownerUserId: string;
profileId: string;
visibility: CustomWorldPublicationStatus;
publishedAt: string | null;
updatedAt: string;
authorDisplayName: string;
worldName: string;
subtitle: string;
summaryText: string;
coverImageSrc: string | null;
themeMode: CustomWorldGalleryCard['themeMode'];
playableNpcCount: number;
landmarkCount: number;
};
type PlatformBrowseHistoryRow = QueryResultRow & {
ownerUserId: string;
profileId: string;
@@ -227,65 +188,6 @@ export type RuntimeRepositoryPort = {
): Promise<CustomWorldLibraryEntry<CustomWorldProfileRecord> | null>;
};
function normalizeStoredProfile(
profileId: string,
profile: Record<string, unknown>,
): CustomWorldProfileRecord {
return {
...profile,
id: profileId,
};
}
function toCustomWorldLibraryEntry(
row: CustomWorldEntryRow,
): CustomWorldLibraryEntry<CustomWorldProfileRecord> {
const fallbackMetadata = extractCustomWorldLibraryMetadata(row.payload);
return {
ownerUserId: row.ownerUserId,
profileId: row.profileId,
profile: row.payload,
visibility: row.visibility,
publishedAt: row.publishedAt,
updatedAt: row.updatedAt,
authorDisplayName: row.authorDisplayName || '玩家',
worldName: row.worldName || fallbackMetadata.worldName,
subtitle: row.subtitle || fallbackMetadata.subtitle,
summaryText: row.summaryText || fallbackMetadata.summaryText,
coverImageSrc: row.coverImageSrc || fallbackMetadata.coverImageSrc,
themeMode: row.themeMode || fallbackMetadata.themeMode,
playableNpcCount:
row.playableNpcCount > 0
? row.playableNpcCount
: fallbackMetadata.playableNpcCount,
landmarkCount:
row.landmarkCount > 0
? row.landmarkCount
: fallbackMetadata.landmarkCount,
};
}
function toCustomWorldGalleryCard(
row: CustomWorldCardRow,
): CustomWorldGalleryCard {
return {
ownerUserId: row.ownerUserId,
profileId: row.profileId,
visibility: row.visibility,
publishedAt: row.publishedAt,
updatedAt: row.updatedAt,
authorDisplayName: row.authorDisplayName || '玩家',
worldName: row.worldName || '未命名世界',
subtitle: row.subtitle || '',
summaryText: row.summaryText || '',
coverImageSrc: row.coverImageSrc || null,
themeMode: row.themeMode || 'mythic',
playableNpcCount: row.playableNpcCount,
landmarkCount: row.landmarkCount,
};
}
function toPlatformBrowseHistoryEntry(
row: PlatformBrowseHistoryRow,
): PlatformBrowseHistoryEntry {
@@ -678,7 +580,7 @@ function resolveProfileSaveArchiveMeta(
if (customWorldProfile) {
const profileId = readString(customWorldProfile.id) || 'custom-world';
const metadata = extractCustomWorldLibraryMetadata(
normalizeStoredProfile(profileId, customWorldProfile),
normalizeStoredRpgWorldProfile(profileId, customWorldProfile),
);
return {
@@ -717,33 +619,12 @@ function resolveProfileSaveArchiveMeta(
}
export class RuntimeRepository implements RuntimeRepositoryPort {
constructor(private readonly db: AppDatabase) {}
private readonly rpgAgentSessionRepository: RpgAgentSessionRepository;
private readonly rpgWorldProfileRepository: RpgWorldProfileRepository;
private async findCustomWorldProfileEntry(userId: string, profileId: string) {
const result = await this.db.query<CustomWorldEntryRow>(
`SELECT user_id AS "ownerUserId",
profile_id AS "profileId",
payload_json AS payload,
visibility,
published_at AS "publishedAt",
updated_at AS "updatedAt",
author_display_name AS "authorDisplayName",
world_name AS "worldName",
subtitle,
summary_text AS "summaryText",
cover_image_src AS "coverImageSrc",
theme_mode AS "themeMode",
playable_npc_count AS "playableNpcCount",
landmark_count AS "landmarkCount"
FROM custom_world_profiles
WHERE user_id = $1
AND profile_id = $2
AND deleted_at IS NULL`,
[userId, profileId],
);
const row = result.rows[0];
return row ? toCustomWorldLibraryEntry(row) : null;
constructor(private readonly db: AppDatabase) {
this.rpgAgentSessionRepository = new RpgAgentSessionRepository(db);
this.rpgWorldProfileRepository = new RpgWorldProfileRepository(db);
}
private async getProfileDashboardState(userId: string) {
@@ -1043,52 +924,13 @@ export class RuntimeRepository implements RuntimeRepositoryPort {
return;
}
const payload = normalizeStoredProfile(profileId, customWorldProfile);
const metadata = extractCustomWorldLibraryMetadata(payload);
const syncedAt = snapshot.savedAt || new Date().toISOString();
await this.db.query(
`INSERT INTO custom_world_profiles (
user_id,
profile_id,
payload_json,
updated_at,
author_display_name,
world_name,
subtitle,
summary_text,
cover_image_src,
theme_mode,
playable_npc_count,
landmark_count,
deleted_at
)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, NULL)
ON CONFLICT (user_id, profile_id) DO UPDATE SET
payload_json = EXCLUDED.payload_json,
updated_at = EXCLUDED.updated_at,
deleted_at = NULL,
world_name = EXCLUDED.world_name,
subtitle = EXCLUDED.subtitle,
summary_text = EXCLUDED.summary_text,
cover_image_src = EXCLUDED.cover_image_src,
theme_mode = EXCLUDED.theme_mode,
playable_npc_count = EXCLUDED.playable_npc_count,
landmark_count = EXCLUDED.landmark_count`,
[
userId,
profileId,
payload,
syncedAt,
'玩家',
metadata.worldName,
metadata.subtitle,
metadata.summaryText,
metadata.coverImageSrc,
metadata.themeMode,
metadata.playableNpcCount,
metadata.landmarkCount,
],
await this.rpgWorldProfileRepository.syncProfileFromSnapshot(
userId,
profileId,
customWorldProfile,
syncedAt,
);
}
@@ -1394,29 +1236,7 @@ export class RuntimeRepository implements RuntimeRepositoryPort {
}
async listCustomWorldProfiles(userId: string) {
const result = await this.db.query<CustomWorldEntryRow>(
`SELECT user_id AS "ownerUserId",
profile_id AS "profileId",
payload_json AS payload,
visibility,
published_at AS "publishedAt",
updated_at AS "updatedAt",
author_display_name AS "authorDisplayName",
world_name AS "worldName",
subtitle,
summary_text AS "summaryText",
cover_image_src AS "coverImageSrc",
theme_mode AS "themeMode",
playable_npc_count AS "playableNpcCount",
landmark_count AS "landmarkCount"
FROM custom_world_profiles
WHERE user_id = $1
AND deleted_at IS NULL
ORDER BY updated_at DESC
LIMIT $2`,
[userId, MAX_CUSTOM_WORLD_PROFILES],
);
return result.rows.map((row) => toCustomWorldLibraryEntry(row));
return this.rpgWorldProfileRepository.listOwnProfiles(userId);
}
async upsertCustomWorldProfile(
@@ -1425,120 +1245,27 @@ export class RuntimeRepository implements RuntimeRepositoryPort {
profile: Record<string, unknown>,
authorDisplayName: string,
) {
const payload = normalizeStoredProfile(profileId, profile);
const metadata = extractCustomWorldLibraryMetadata(payload);
const now = new Date().toISOString();
await this.db.query(
`INSERT INTO custom_world_profiles (
user_id,
profile_id,
payload_json,
updated_at,
author_display_name,
world_name,
subtitle,
summary_text,
cover_image_src,
theme_mode,
playable_npc_count,
landmark_count
)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)
ON CONFLICT (user_id, profile_id) DO UPDATE SET
payload_json = EXCLUDED.payload_json,
updated_at = EXCLUDED.updated_at,
deleted_at = NULL,
author_display_name = EXCLUDED.author_display_name,
world_name = EXCLUDED.world_name,
subtitle = EXCLUDED.subtitle,
summary_text = EXCLUDED.summary_text,
cover_image_src = EXCLUDED.cover_image_src,
theme_mode = EXCLUDED.theme_mode,
playable_npc_count = EXCLUDED.playable_npc_count,
landmark_count = EXCLUDED.landmark_count`,
[
userId,
profileId,
payload,
now,
authorDisplayName || '玩家',
metadata.worldName,
metadata.subtitle,
metadata.summaryText,
metadata.coverImageSrc,
metadata.themeMode,
metadata.playableNpcCount,
metadata.landmarkCount,
],
return this.rpgWorldProfileRepository.upsertOwnProfile(
userId,
profileId,
profile,
authorDisplayName,
);
const entry = await this.findCustomWorldProfileEntry(userId, profileId);
if (!entry) {
throw new Error('failed to resolve custom world after upsert');
}
return {
entry,
entries: await this.listCustomWorldProfiles(userId),
};
}
async deleteCustomWorldProfile(userId: string, profileId: string) {
const deletedAt = new Date().toISOString();
await this.db.query(
`UPDATE custom_world_profiles
SET deleted_at = $1,
updated_at = $1,
visibility = 'draft',
published_at = NULL
WHERE user_id = $2
AND profile_id = $3
AND deleted_at IS NULL`,
[deletedAt, userId, profileId],
return this.rpgWorldProfileRepository.softDeleteOwnProfile(
userId,
profileId,
);
return this.listCustomWorldProfiles(userId);
}
async listCustomWorldSessions(userId: string) {
const result = await this.db.query<SessionRow>(
`SELECT payload_json AS payload,
created_at AS "createdAt",
updated_at AS "updatedAt"
FROM custom_world_sessions
WHERE user_id = $1
ORDER BY updated_at DESC`,
[userId],
);
return result.rows.map((row) => ({
...row.payload,
createdAt: row.createdAt,
updatedAt: row.updatedAt,
}));
return this.rpgAgentSessionRepository.listSessions(userId);
}
async getCustomWorldSession(userId: string, sessionId: string) {
const result = await this.db.query<SessionRow>(
`SELECT payload_json AS payload,
created_at AS "createdAt",
updated_at AS "updatedAt"
FROM custom_world_sessions
WHERE user_id = $1 AND session_id = $2`,
[userId, sessionId],
);
const row = result.rows[0];
if (!row) {
return null;
}
return {
...row.payload,
createdAt: row.createdAt,
updatedAt: row.updatedAt,
};
return this.rpgAgentSessionRepository.getSession(userId, sessionId);
}
async upsertCustomWorldSession(
@@ -1546,30 +1273,11 @@ export class RuntimeRepository implements RuntimeRepositoryPort {
sessionId: string,
session: CustomWorldSessionRecord,
) {
const payload = {
...session,
return this.rpgAgentSessionRepository.upsertSession(
userId,
sessionId,
} satisfies CustomWorldSessionRecord;
await this.db.query(
`INSERT INTO custom_world_sessions (
user_id,
session_id,
payload_json,
created_at,
updated_at
) VALUES ($1, $2, $3, $4, $5)
ON CONFLICT (user_id, session_id) DO UPDATE SET
payload_json = EXCLUDED.payload_json,
updated_at = EXCLUDED.updated_at`,
[userId, sessionId, payload, session.createdAt, session.updatedAt],
session,
);
return {
...payload,
createdAt: session.createdAt,
updatedAt: session.updatedAt,
};
}
async publishCustomWorldProfile(
@@ -1577,57 +1285,11 @@ export class RuntimeRepository implements RuntimeRepositoryPort {
profileId: string,
authorDisplayName: string,
) {
const existingEntry = await this.findCustomWorldProfileEntry(
return this.rpgWorldProfileRepository.publishOwnProfile(
userId,
profileId,
authorDisplayName,
);
if (!existingEntry) {
return null;
}
const payload = normalizeStoredProfile(profileId, existingEntry.profile);
const metadata = extractCustomWorldLibraryMetadata(payload);
const now = new Date().toISOString();
await this.db.query(
`UPDATE custom_world_profiles
SET visibility = 'published',
published_at = $1,
updated_at = $1,
author_display_name = $2,
world_name = $3,
subtitle = $4,
summary_text = $5,
cover_image_src = $6,
theme_mode = $7,
playable_npc_count = $8,
landmark_count = $9
WHERE user_id = $10
AND profile_id = $11`,
[
now,
authorDisplayName || '玩家',
metadata.worldName,
metadata.subtitle,
metadata.summaryText,
metadata.coverImageSrc,
metadata.themeMode,
metadata.playableNpcCount,
metadata.landmarkCount,
userId,
profileId,
],
);
const entry = await this.findCustomWorldProfileEntry(userId, profileId);
if (!entry) {
throw new Error('failed to resolve custom world after publish');
}
return {
entry,
entries: await this.listCustomWorldProfiles(userId),
};
}
async unpublishCustomWorldProfile(
@@ -1635,113 +1297,24 @@ export class RuntimeRepository implements RuntimeRepositoryPort {
profileId: string,
authorDisplayName: string,
) {
const existingEntry = await this.findCustomWorldProfileEntry(
return this.rpgWorldProfileRepository.unpublishOwnProfile(
userId,
profileId,
authorDisplayName,
);
if (!existingEntry) {
return null;
}
const payload = normalizeStoredProfile(profileId, existingEntry.profile);
const metadata = extractCustomWorldLibraryMetadata(payload);
const now = new Date().toISOString();
await this.db.query(
`UPDATE custom_world_profiles
SET visibility = 'draft',
published_at = NULL,
updated_at = $1,
author_display_name = $2,
world_name = $3,
subtitle = $4,
summary_text = $5,
cover_image_src = $6,
theme_mode = $7,
playable_npc_count = $8,
landmark_count = $9
WHERE user_id = $10
AND profile_id = $11`,
[
now,
authorDisplayName || '玩家',
metadata.worldName,
metadata.subtitle,
metadata.summaryText,
metadata.coverImageSrc,
metadata.themeMode,
metadata.playableNpcCount,
metadata.landmarkCount,
userId,
profileId,
],
);
const entry = await this.findCustomWorldProfileEntry(userId, profileId);
if (!entry) {
throw new Error('failed to resolve custom world after unpublish');
}
return {
entry,
entries: await this.listCustomWorldProfiles(userId),
};
}
async listPublishedCustomWorldGallery() {
const result = await this.db.query<CustomWorldCardRow>(
`SELECT user_id AS "ownerUserId",
profile_id AS "profileId",
visibility,
published_at AS "publishedAt",
updated_at AS "updatedAt",
author_display_name AS "authorDisplayName",
world_name AS "worldName",
subtitle,
summary_text AS "summaryText",
cover_image_src AS "coverImageSrc",
theme_mode AS "themeMode",
playable_npc_count AS "playableNpcCount",
landmark_count AS "landmarkCount"
FROM custom_world_profiles
WHERE visibility = 'published'
AND deleted_at IS NULL
ORDER BY published_at DESC, updated_at DESC
LIMIT $1`,
[MAX_PUBLIC_CUSTOM_WORLD_PROFILES],
);
return result.rows.map((row) => toCustomWorldGalleryCard(row));
return this.rpgWorldProfileRepository.listPublishedGallery();
}
async getPublishedCustomWorldGalleryDetail(
ownerUserId: string,
profileId: string,
) {
const result = await this.db.query<CustomWorldEntryRow>(
`SELECT user_id AS "ownerUserId",
profile_id AS "profileId",
payload_json AS payload,
visibility,
published_at AS "publishedAt",
updated_at AS "updatedAt",
author_display_name AS "authorDisplayName",
world_name AS "worldName",
subtitle,
summary_text AS "summaryText",
cover_image_src AS "coverImageSrc",
theme_mode AS "themeMode",
playable_npc_count AS "playableNpcCount",
landmark_count AS "landmarkCount"
FROM custom_world_profiles
WHERE user_id = $1
AND profile_id = $2
AND visibility = 'published'
AND deleted_at IS NULL`,
[ownerUserId, profileId],
return this.rpgWorldProfileRepository.getPublishedGalleryDetail(
ownerUserId,
profileId,
);
const row = result.rows[0];
return row ? toCustomWorldLibraryEntry(row) : null;
}
}

View File

@@ -67,9 +67,29 @@ const actionSchema = z.discriminatedUnion('action', [
generatedAnimationSetId: z.string().trim().nullable().optional(),
animationMap: z.record(z.string(), z.unknown()).nullable().optional(),
}),
z.object({
action: z.literal('generate_scene_assets'),
sceneIds: z.array(z.string().trim().min(1)).min(1),
}),
z.object({
action: z.literal('sync_scene_assets'),
sceneId: z.string().trim().min(1),
sceneKind: z.enum(['camp', 'landmark']),
imageSrc: z.string().trim().min(1),
generatedSceneAssetId: z.string().trim().min(1),
generatedScenePrompt: z.string().trim().nullable().optional(),
generatedSceneModel: z.string().trim().nullable().optional(),
}),
z.object({
action: z.literal('expand_long_tail'),
}),
z.object({
action: z.literal('publish_world'),
}),
z.object({
action: z.literal('revert_checkpoint'),
checkpointId: z.string().trim().min(1),
}),
]);
function readParam(param: string | string[] | undefined) {

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