Compare commits
5 Commits
90a19aeb0d
...
aa2e9b36d7
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
aa2e9b36d7 | ||
|
|
39200ea9cc | ||
| e191619ab3 | |||
| 0f013b6eee | |||
| f0471a4f8d |
8
PLAN.md
8
PLAN.md
@@ -6,6 +6,8 @@
|
||||
|
||||
目标是把 Rust + SpacetimeDB 后端统一成清晰边界:领域规则在 `module-*`,事务和持久化在 `spacetime-module`,HTTP/BFF 在 `api-server`,外部能力在 `platform-*`,共享值处理和 DTO 分别在 `shared-kernel` / `shared-contracts`。
|
||||
|
||||
全局并行执行任务清单见 `docs/technical/SERVER_RS_DDD_PARALLEL_TASKLIST_2026-04-29.md`。runtime story 去兼容层属于该清单中的 `WP-RS` 工作包,不再单独维护专项清单。
|
||||
|
||||
## Target Architecture
|
||||
|
||||
- `module-*`:领域模型、值对象、聚合方法、领域服务、命令、领域错误、领域事件、纯应用编排结果;禁止直接依赖 Axum、reqwest、OSS、LLM、文件系统、SpacetimeDB table 操作。
|
||||
@@ -31,6 +33,12 @@
|
||||
- `api-server` 中 handler 只保留 transport 逻辑,业务分支迁移到领域或应用层。
|
||||
- `runtime_story`、`custom_world`、`puzzle`、`big_fish`、`inventory`、`quest`、`npc`、`combat`、`progression` 全部对齐同一结构。
|
||||
|
||||
- 去兼容层任务边界:
|
||||
- `module-runtime-story-compat` 不作为目标架构保留,迁移为无 `compat` 命名的 `module-runtime-story` 或拆入对应领域模块。
|
||||
- `api-server/src/runtime_story/compat*` 只允许作为待删除历史入口,不再新增兼容分支。
|
||||
- 前端 runtime story / chat client 统一改到 `POST /api/runtime/story/sessions/:sessionId/...` 新接口族。
|
||||
- 旧请求体里的 `worldType / character / monsters / history / context` 不再作为正式主链输入。
|
||||
|
||||
- 表结构硬约束:
|
||||
- 默认保持现有 SpacetimeDB 主表兼容。
|
||||
- 表结构变更采用最小必要原则。
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
- [技术方案](./technical/README.md):动画、服务端、外部产品形态拆解。
|
||||
- [规划与优先级](./planning/README.md):当前阶段的迭代排序与落地优先级。
|
||||
- [参考目录](./reference/README.md):脚本/Function 速查入口。
|
||||
重点补充:RPG 创作与运行时脚本职责地图见 [RPG_CREATION_AND_RUNTIME_SCRIPT_RESPONSIBILITY_MAP_2026-04-28.md](./reference/RPG_CREATION_AND_RUNTIME_SCRIPT_RESPONSIBILITY_MAP_2026-04-28.md)。
|
||||
- [PRD](./prd):产品需求与阶段计划;新增 RPG 开场动画方案见 [AI_NATIVE_RPG_OPENING_ANIMATION_PRD_2026-04-25.md](./prd/AI_NATIVE_RPG_OPENING_ANIMATION_PRD_2026-04-25.md)。
|
||||
|
||||
## 推荐阅读顺序
|
||||
|
||||
@@ -17,6 +17,10 @@
|
||||
- [CUSTOM_WORLD_CREATOR_TOOL_AUDIT_2026-04-08.md](./CUSTOM_WORLD_CREATOR_TOOL_AUDIT_2026-04-08.md):自定义世界创作工具当前问题、体验断层和优化优先级审计。
|
||||
- [AGENT_TO_DRAFT_TO_WORLD_PIPELINE_AUDIT_2026-04-20.md](./AGENT_TO_DRAFT_TO_WORLD_PIPELINE_AUDIT_2026-04-20.md):Agent 聊天、草稿生成、作品库存储与进入世界之间的断点、多 pipeline、冗余与未实装项审计。
|
||||
- [CHARACTER_ASSET_PROMPT_CHAIN_AUDIT_2026-04-20.md](./CHARACTER_ASSET_PROMPT_CHAIN_AUDIT_2026-04-20.md):角色资产默认描述文本、正式图像/动作 prompt、共享模板与保留接口的分层与冗余审计。
|
||||
- [RPG_RUNTIME_DIRECT_DRAFT_PROFILE_AUDIT_2026-04-25.md](./RPG_RUNTIME_DIRECT_DRAFT_PROFILE_AUDIT_2026-04-25.md):RPG 运行时进入世界时改为直读 Agent session 草稿 profile 的链路检查。
|
||||
- [RPG_WORLD_DRAFT_EDIT_AUTOSAVE_OVERRIDE_AUDIT_2026-04-28.md](./RPG_WORLD_DRAFT_EDIT_AUTOSAVE_OVERRIDE_AUDIT_2026-04-28.md):RPG 世界草稿结果页编辑后被旧设定覆盖的前端本地态、session 真相源与自动保存链路审计。
|
||||
- [engineering/RPG_FRONTEND_SCRIPT_BACKEND_MIGRATION_AUDIT_2026-04-28.md](./engineering/RPG_FRONTEND_SCRIPT_BACKEND_MIGRATION_AUDIT_2026-04-28.md):RPG 前端脚本中仍应迁到 `server-rs` / SpacetimeDB 的开局、快照、story engine、战斗、NPC/背包规则与创作残留后门审计。
|
||||
- [engineering/RPG_FRONTEND_SCRIPT_BACKEND_MIGRATION_COMPLETION_CHECK_2026-04-28.md](./engineering/RPG_FRONTEND_SCRIPT_BACKEND_MIGRATION_COMPLETION_CHECK_2026-04-28.md):RPG 前端脚本后端迁移完成度复核,标明开局、快照、story engine / prompt context、`camp_travel_home_scene`、战斗、NPC、背包/锻造、结果页保存 normalize 与角色资产 prompt 主链均已收口。
|
||||
- [engineering/ENGINEERING_CLEANUP_AND_BACKEND_BOUNDARY_AUDIT_2026-04-20.md](./engineering/ENGINEERING_CLEANUP_AND_BACKEND_BOUNDARY_AUDIT_2026-04-20.md):对 `2026-04-19` 工程清理审计的当前仓库复核,区分已完成项、仍存边界问题和新的热点迁移。
|
||||
- [engineering/ENGINEERING_CLEANUP_AND_BACKEND_BOUNDARY_AUDIT_2026-04-19.md](./engineering/ENGINEERING_CLEANUP_AND_BACKEND_BOUNDARY_AUDIT_2026-04-19.md):未引用垃圾、旧入口残留、前后端双份真相与后端迁移项的专项审计。
|
||||
|
||||
|
||||
@@ -0,0 +1,233 @@
|
||||
# RPG 世界草稿编辑后被旧设定覆盖问题审计 2026-04-28
|
||||
|
||||
## 1. 问题摘要
|
||||
|
||||
当前 RPG 世界草稿结果页存在一条明显的“本地编辑态与 Agent session 真相源分叉”问题链:
|
||||
|
||||
1. 用户在结果页编辑世界设定时,前端只更新本地 `generatedCustomWorldProfile`。
|
||||
2. Agent 草稿会话里的 `draftProfile / resultPreview.preview` 并不会随着这次编辑同步更新。
|
||||
3. 自动保存触发时,系统又会优先重新拉取 session 最新快照,并以 session 返回的 profile 作为最终保存内容。
|
||||
4. 如果 session 里仍是编辑前旧设定,那么前端刚刚改过的内容就会被旧快照覆盖回来。
|
||||
|
||||
所以,这个问题表面看起来像“自动保存覆盖了我刚保存的内容”,本质上是:
|
||||
|
||||
- 结果页编辑写到了前端内存态;
|
||||
- 自动保存落库前又改为优先相信 session 真相;
|
||||
- 但 session 真相本身没有承接这次编辑。
|
||||
|
||||
## 2. 当前主链证据
|
||||
|
||||
### 2.1 结果页编辑只改前端本地 profile
|
||||
|
||||
`src/components/platform-entry/PlatformEntryFlowShellImpl.tsx:2637-2640`
|
||||
|
||||
- `RpgCreationResultView` 的 `onProfileChange` 只调用:
|
||||
- `sessionController.setGeneratedCustomWorldProfile(normalizeAgentBackedProfile(profile))`
|
||||
- 这里没有触发任何 `update_draft_card`、`sync_result_profile` 或其它后端写回动作。
|
||||
|
||||
这意味着结果页里的“保存修改”目前只是更新前端内存中的 `generatedCustomWorldProfile`。
|
||||
|
||||
### 2.2 结果页展示数据优先来自 session 预览
|
||||
|
||||
`src/services/rpg-creation/rpgCreationPreviewAdapter.ts:27-33`
|
||||
|
||||
- `buildCustomWorldProfileFromAgentSession()` 当前优先读取:
|
||||
- `session.resultPreview.preview`
|
||||
- 若没有,再回退到:
|
||||
- `session.draftProfile.legacyResultProfile`
|
||||
|
||||
也就是说,Agent 结果页最终重新打开、重新同步或重新计算时,主数据源仍是 session 快照,而不是用户刚改过的前端本地对象。
|
||||
|
||||
### 2.3 自动保存前会先刷新 session,并优先保存 session 返回的最新结果
|
||||
|
||||
`src/components/rpg-entry/useRpgCreationResultAutosave.ts:181-236`
|
||||
|
||||
- `syncAgentDraftResultProfile()` 在 session 与前端 profile 签名不一致时,不再把前端 profile 回写 session。
|
||||
- 它只会执行:
|
||||
- `syncAgentSessionSnapshot(activeAgentSessionId)`
|
||||
- 然后把拉回来的 session profile 重新塞回:
|
||||
- `setGeneratedCustomWorldProfile(latestProfile)`
|
||||
|
||||
`src/components/rpg-entry/useRpgCreationResultAutosave.ts:326-340`
|
||||
|
||||
- 自动保存延迟触发后,如果当前是 Agent 草稿结果页:
|
||||
1. 先调用 `syncAgentDraftResultProfile(profileToSave)`
|
||||
2. 再把 `syncedResult.profile ?? profileToSave` 作为最终要入库的 profile
|
||||
|
||||
也就是:
|
||||
|
||||
- 自动保存不是直接保存用户刚改的前端 profile;
|
||||
- 它会先“向 session 对齐”;
|
||||
- 对齐后优先保存 session 返回的新 profile。
|
||||
|
||||
如果 session 里还是旧设定,那么保存和界面都会一起回滚到旧设定。
|
||||
|
||||
## 3. 当前测试口径也在强化这条行为
|
||||
|
||||
### 3.1 单测明确要求“不触发 sync_result_profile,只保存 session 最新草稿”
|
||||
|
||||
`src/components/rpg-entry/useRpgEntryAgentDraftRestore.test.tsx:195-276`
|
||||
|
||||
这条测试验证的是:
|
||||
|
||||
1. 前端当前持有的是 `oldProfile`
|
||||
2. `syncAgentSessionSnapshot()` 返回的是 `latestSession`
|
||||
3. 自动保存最终必须保存 `latestSession.draftProfile`
|
||||
4. 并且明确断言不应触发 `sync_result_profile`
|
||||
|
||||
这说明现有实现不是偶然漏掉了 session 写回,而是被当前测试和实现共同锁定为:
|
||||
|
||||
- “自动保存只刷新 session,不回写前端结果页编辑态”
|
||||
|
||||
### 3.2 交互测试也要求“结果页自动保存优先保存 session 最新快照”
|
||||
|
||||
`src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx:2867-3006`
|
||||
|
||||
这条测试进一步验证:
|
||||
|
||||
1. 结果页最终保存的对象应来自 `syncedSession.resultPreview.preview`
|
||||
2. 不应触发 `sync_result_profile`
|
||||
|
||||
所以现在的覆盖行为不是单点 bug,而是当前结果页保存架构的直接产物。
|
||||
|
||||
## 4. 系统层面的根因拆解
|
||||
|
||||
### 4.1 双真相源并存
|
||||
|
||||
当前至少同时存在两份“看起来都像真相”的数据:
|
||||
|
||||
1. 前端结果页本地 `generatedCustomWorldProfile`
|
||||
2. Agent session 内的 `draftProfile / resultPreview.preview`
|
||||
|
||||
结果页编辑写前者,自动保存与恢复又优先读后者,所以只要两边没有同事务同步,就一定会出现覆盖风险。
|
||||
|
||||
### 4.2 编辑动作没有接入正式后端写回动作
|
||||
|
||||
仓库契约和 Rust 模块实际上已经存在两条正式动作:
|
||||
|
||||
- `update_draft_card`
|
||||
- `sync_result_profile`
|
||||
|
||||
证据:
|
||||
|
||||
- `packages/shared/src/contracts/rpgAgentActions.ts`
|
||||
- `server-rs/crates/spacetime-module/src/custom_world/mod.rs`
|
||||
|
||||
其中:
|
||||
|
||||
- `update_draft_card` 适合基于卡片/section 的结构化修改写回 `draftProfile`
|
||||
- `sync_result_profile` 适合把结果页完整 profile 回写 session 并重建 preview
|
||||
|
||||
但当前结果页 `onProfileChange` 没有接入这两条正式链路,导致“编辑成功”只停留在本地内存态。
|
||||
|
||||
### 4.3 自动保存策略与编辑链路不一致
|
||||
|
||||
自动保存的当前设计目标是正确的:
|
||||
|
||||
- 不再轻易把旧前端快照反向污染 session
|
||||
- 优先保存 session 最新快照,避免把陈旧 preview 写进作品库
|
||||
|
||||
但前提应该是:
|
||||
|
||||
- 结果页编辑本身已经先进入 session 真相源
|
||||
|
||||
现在前提不成立,于是自动保存的“防旧快照污染”策略,反过来变成了“用 session 旧值覆盖用户刚编辑的新值”。
|
||||
|
||||
### 4.4 结果页定位混乱
|
||||
|
||||
当前结果页同时承担了两种角色:
|
||||
|
||||
1. 预览/发布页
|
||||
2. 可深度编辑的草稿编辑器
|
||||
|
||||
但现有数据链路更偏向第 1 种:
|
||||
|
||||
- 数据来自 session preview
|
||||
- 自动保存优先对齐 session preview
|
||||
|
||||
而 UI 行为却提供了第 2 种能力:
|
||||
|
||||
- 用户可以直接改世界、角色、场景等内容
|
||||
|
||||
两种定位没有统一,最终表现就是“能编辑,但编辑不能稳定进入主真相源”。
|
||||
|
||||
## 5. 用户视角下的实际故障表现
|
||||
|
||||
从用户体感看,当前问题会表现为以下几种:
|
||||
|
||||
1. 刚改完字段,短暂显示新内容,随后自动回弹为旧内容。
|
||||
2. 页面提示“已自动保存”,但重新进入草稿页后仍然是旧设定。
|
||||
3. 某些字段改了能停留一会儿,触发自动保存或 session 刷新后又被覆盖。
|
||||
4. 用户会误以为“保存按钮坏了”或“自动保存有缓存问题”,但真实原因是保存目标和展示真相源不一致。
|
||||
|
||||
## 6. 影响范围
|
||||
|
||||
这个问题不只影响“世界简介文本”,而是整个结果页编辑体系:
|
||||
|
||||
1. 世界基础信息编辑
|
||||
2. 角色编辑
|
||||
3. 场景编辑
|
||||
4. 封面/派生内容依赖当前 profile 的场景
|
||||
5. 结果页返回、恢复、自动打开、发布前预览一致性
|
||||
|
||||
只要编辑发生在 `generatedCustomWorldProfile`,但没有进入 session 真相源,就都有同类风险。
|
||||
|
||||
## 7. 建议修复方向
|
||||
|
||||
### 7.1 第一优先级:统一结果页编辑的唯一真相源
|
||||
|
||||
推荐二选一,不要继续混用:
|
||||
|
||||
1. 结果页只做预览,不允许直接编辑复杂设定
|
||||
2. 结果页继续允许编辑,但每次保存必须先落到 session 真相源,再更新本地显示
|
||||
|
||||
按当前产品形态,更合理的是第 2 条。
|
||||
|
||||
### 7.2 如果保留结果页编辑,建议采用的正式链路
|
||||
|
||||
1. 世界/角色/场景编辑保存时,优先调用 Rust/SpacetimeDB 的正式动作写回 session。
|
||||
2. 能用 `update_draft_card` 的地方尽量走结构化 section 更新。
|
||||
3. 对无法被 card section 覆盖的完整 profile 级编辑,再评估是否保留 `sync_result_profile`,或新增更细粒度 reducer。
|
||||
4. session 写回完成后,再重新读取 session 并刷新结果页。
|
||||
5. 自动保存只负责“把已经写进 session 的最新真相同步到作品库”,不要再承担“猜测该保存前端还是 session”的职责。
|
||||
|
||||
### 7.3 自动保存策略需要降级为“作品库存档层”,不要兼任“session 真相协调层”
|
||||
|
||||
当前自动保存同时承担:
|
||||
|
||||
1. 防止旧前端快照污染 session
|
||||
2. 选择最终保存哪份 profile
|
||||
3. 刷新结果页显示
|
||||
|
||||
职责太重,也导致覆盖问题难以定位。
|
||||
|
||||
建议改为:
|
||||
|
||||
1. 编辑保存阶段:负责写 session
|
||||
2. 自动保存阶段:只负责把 session 已确认真相落作品库
|
||||
3. 结果页渲染阶段:只读 session 最新快照
|
||||
|
||||
### 7.4 需要补的测试口径
|
||||
|
||||
当前测试主要在保护“不要再触发 `sync_result_profile` 污染 session”。
|
||||
|
||||
后续修复后,至少要补以下测试:
|
||||
|
||||
1. 用户编辑世界基础字段后,session `draftProfile / resultPreview` 会同步更新。
|
||||
2. 自动保存不会把 session 旧值覆盖到刚编辑的新值上。
|
||||
3. 刷新页面或重新进入结果页后,看到的是编辑后的新设定。
|
||||
4. 进入世界、发布世界、继续扩展时,消费的是同一份最新 session 真相。
|
||||
|
||||
## 8. 结论
|
||||
|
||||
这次问题的核心结论是:
|
||||
|
||||
1. 你的判断“像是自动保存问题”是对的,但更准确地说,是“自动保存对齐 session 时,覆盖了未写回 session 的本地编辑态”。
|
||||
2. 当前结果页编辑没有接上正式的 session 写回链路,这是第一根断点。
|
||||
3. 当前自动保存被设计成优先信任 session 最新快照,这是第二根断点。
|
||||
4. 两个断点叠加后,就形成了“编辑后又自动变回原设定”的现象。
|
||||
|
||||
如果后续要真正修掉这个问题,重点不该是单独调 debounce 时间或加一个“防抖保存中”提示,而是:
|
||||
|
||||
- 把结果页编辑动作重新接回 Rust/SpacetimeDB 的 session 真相源;
|
||||
- 让自动保存只负责作品库存档,不再替代编辑写回链路做裁决。
|
||||
@@ -10,21 +10,25 @@
|
||||
这一版是第六批落地记录,聚焦删除无入口 `questDirector`、旧观察文案 helper、一次性硬编码同步脚本,并补齐后端运行时 function catalog 契约覆盖。
|
||||
3. [ENGINEERING_DEAD_CODE_CLEANUP_BATCH_E_2026-04-21.md](./ENGINEERING_DEAD_CODE_CLEANUP_BATCH_E_2026-04-21.md)
|
||||
这一版是第五批落地记录,聚焦旧命名 re-export、空路由骨架、旧发布服务、前端 prompt 镜像与无入口编辑器壳层的物理删除。
|
||||
4. [FRONTEND_LOGIC_BACKEND_MIGRATION_AUDIT_2026-04-21.md](./FRONTEND_LOGIC_BACKEND_MIGRATION_AUDIT_2026-04-21.md)
|
||||
4. [RPG_FRONTEND_SCRIPT_BACKEND_MIGRATION_AUDIT_2026-04-28.md](./RPG_FRONTEND_SCRIPT_BACKEND_MIGRATION_AUDIT_2026-04-28.md)
|
||||
这一版专项扫描 `src/` 下 RPG 开头脚本,明确运行时开局、快照、story engine、战斗后处理、NPC/背包规则和创作链残留后门中应迁到 `server-rs` 的逻辑。
|
||||
5. [RPG_FRONTEND_SCRIPT_BACKEND_MIGRATION_COMPLETION_CHECK_2026-04-28.md](./RPG_FRONTEND_SCRIPT_BACKEND_MIGRATION_COMPLETION_CHECK_2026-04-28.md)
|
||||
这一版复核 RPG 前端脚本后端迁移完成度,确认开局、快照、存档、story engine / prompt context、`camp_travel_home_scene`、NPC、背包/锻造、战斗后处理、结果页保存前 normalize 与角色资产 prompt 主链均已收口。
|
||||
6. [FRONTEND_LOGIC_BACKEND_MIGRATION_AUDIT_2026-04-21.md](./FRONTEND_LOGIC_BACKEND_MIGRATION_AUDIT_2026-04-21.md)
|
||||
这一版是本轮前端越界逻辑专项审计,专门汇总当前仍应继续迁到 `server-rs` 的运行时、鉴权、生成编排与本地真相残留。
|
||||
5. [ENGINEERING_DEAD_CODE_CLEANUP_BATCH_D_2026-04-21.md](./ENGINEERING_DEAD_CODE_CLEANUP_BATCH_D_2026-04-21.md)
|
||||
7. [ENGINEERING_DEAD_CODE_CLEANUP_BATCH_D_2026-04-21.md](./ENGINEERING_DEAD_CODE_CLEANUP_BATCH_D_2026-04-21.md)
|
||||
这一版是第四批落地记录,聚焦未接入业务的数据生成产物、测试专用 stub 与对应配置残留出清。
|
||||
6. [ENGINEERING_DEAD_CODE_CLEANUP_BATCH_C_2026-04-21.md](./ENGINEERING_DEAD_CODE_CLEANUP_BATCH_C_2026-04-21.md)
|
||||
8. [ENGINEERING_DEAD_CODE_CLEANUP_BATCH_C_2026-04-21.md](./ENGINEERING_DEAD_CODE_CLEANUP_BATCH_C_2026-04-21.md)
|
||||
这一版是第三批落地记录,聚焦鉴权真相收口,先移除前端保存自动登录用户名/密码的本地真相,并明确运行时快照前置写入为什么当前还不能硬砍。
|
||||
7. [ENGINEERING_DEAD_CODE_CLEANUP_BATCH_B_2026-04-21.md](./ENGINEERING_DEAD_CODE_CLEANUP_BATCH_B_2026-04-21.md)
|
||||
9. [ENGINEERING_DEAD_CODE_CLEANUP_BATCH_B_2026-04-21.md](./ENGINEERING_DEAD_CODE_CLEANUP_BATCH_B_2026-04-21.md)
|
||||
这一版是第二批落地记录,聚焦旧主流程壳层、旧 bootstrap 和旧 inventory / forge / equipment flow Hook 的正式出清。
|
||||
8. [ENGINEERING_DEAD_CODE_CLEANUP_BATCH_A_2026-04-21.md](./ENGINEERING_DEAD_CODE_CLEANUP_BATCH_A_2026-04-21.md)
|
||||
10. [ENGINEERING_DEAD_CODE_CLEANUP_BATCH_A_2026-04-21.md](./ENGINEERING_DEAD_CODE_CLEANUP_BATCH_A_2026-04-21.md)
|
||||
这一版是第一批落地记录,聚焦高置信度小型孤岛、prompt 壳子、stub 和无入口 modal 的首轮清理。
|
||||
9. [CURRENT_ENGINEERING_OPTIMIZATION_OPPORTUNITIES_2026-04-20.md](./CURRENT_ENGINEERING_OPTIMIZATION_OPPORTUNITIES_2026-04-20.md)
|
||||
11. [CURRENT_ENGINEERING_OPTIMIZATION_OPPORTUNITIES_2026-04-20.md](./CURRENT_ENGINEERING_OPTIMIZATION_OPPORTUNITIES_2026-04-20.md)
|
||||
这一版是面向当前仓库状态的优化点盘点,适合直接拿来排优先级和拆执行批次。
|
||||
10. [ENGINEERING_CLEANUP_AND_BACKEND_BOUNDARY_AUDIT_2026-04-20.md](./ENGINEERING_CLEANUP_AND_BACKEND_BOUNDARY_AUDIT_2026-04-20.md)
|
||||
12. [ENGINEERING_CLEANUP_AND_BACKEND_BOUNDARY_AUDIT_2026-04-20.md](./ENGINEERING_CLEANUP_AND_BACKEND_BOUNDARY_AUDIT_2026-04-20.md)
|
||||
这一版是对 `2026-04-19` 基线的当前仓库复核,明确哪些问题已经处理、哪些表述需要纠正、热点又迁移到了哪里。
|
||||
11. [ENGINEERING_CLEANUP_AND_BACKEND_BOUNDARY_AUDIT_2026-04-19.md](./ENGINEERING_CLEANUP_AND_BACKEND_BOUNDARY_AUDIT_2026-04-19.md)
|
||||
13. [ENGINEERING_CLEANUP_AND_BACKEND_BOUNDARY_AUDIT_2026-04-19.md](./ENGINEERING_CLEANUP_AND_BACKEND_BOUNDARY_AUDIT_2026-04-19.md)
|
||||
这一版保留原始问题快照和执行回填,适合回看“为什么会有这轮清理与边界收口”。
|
||||
## 融合结论
|
||||
|
||||
|
||||
@@ -0,0 +1,650 @@
|
||||
# RPG 前端脚本后端迁移审计(2026-04-28)
|
||||
|
||||
## 0. 审计目标
|
||||
|
||||
这份文档只回答一个问题:
|
||||
|
||||
**当前 `src/` 下 RPG 开头或 RPG 目录内的前端脚本中,哪些逻辑已经越过“前端只做表现”的边界,应该继续迁移到 `server-rs` / SpacetimeDB 后端。**
|
||||
|
||||
本轮只做审计与迁移拆分,不改业务代码。
|
||||
|
||||
## 1. 审计范围
|
||||
|
||||
本轮扫描范围:
|
||||
|
||||
1. `src/RpgRuntimeApp.tsx`
|
||||
2. `src/components/rpg-*/*.ts`
|
||||
3. `src/components/rpg-*/*.tsx`
|
||||
4. `src/hooks/rpg-*/*.ts`
|
||||
5. `src/services/rpg-*/*.ts`
|
||||
6. `src/services/rpgRuntimeChatTypes.ts`
|
||||
|
||||
不把测试文件作为迁移对象,但会参考测试暴露的当前行为。
|
||||
|
||||
本轮依据:
|
||||
|
||||
1. `docs/reference/RPG_CREATION_AND_RUNTIME_SCRIPT_RESPONSIBILITY_MAP_2026-04-28.md`
|
||||
2. `docs/audits/engineering/FRONTEND_LOGIC_BACKEND_MIGRATION_AUDIT_2026-04-21.md`
|
||||
3. `docs/technical/RPG_PROMPT_FRONTEND_REMOVAL_AND_SERVER_RS_MIGRATION_2026-04-28.md`
|
||||
4. `docs/experience/PROJECT_WORK_EXPERIENCE_PLAYBOOK.md`
|
||||
5. `spacetimedb-concepts` / `spacetimedb-rust` / `spacetimedb-typescript` skill 约束
|
||||
|
||||
## 2. 判断标准
|
||||
|
||||
### 2.1 应迁后端
|
||||
|
||||
只要逻辑满足下面任一项,就不应继续以浏览器前端作为正式真相:
|
||||
|
||||
1. 生成或解释 `GameState`、任务、背包、装备、NPC 状态、战斗状态、剧情记忆。
|
||||
2. 决定开局初始状态、场景落点、遭遇、初始物品、初始装备。
|
||||
3. 决定 action 是否合法、价格、数量、奖励、掉落、复活、场景推进。
|
||||
4. 组装正式 AI prompt / story context / fallback generation。
|
||||
5. 持久化完整运行时快照,或让前端上传整份 `GameState` 作为后端写入依据。
|
||||
6. 对服务端返回的快照做业务补丁,补齐战斗阵型、场景跳转、任务推进等正式状态。
|
||||
7. 解释 Agent session / draft / result preview 哪份才是创作真相。
|
||||
|
||||
### 2.2 可留前端
|
||||
|
||||
下面这些可以继续留在前端:
|
||||
|
||||
1. 面板开关、tab、modal、loading、error、按钮禁用展示。
|
||||
2. 动画播放、镜头、过场、打字机效果、临时视觉态。
|
||||
3. API client、请求封装、SSE 文本消费。
|
||||
4. 纯展示 view model 适配,但不能改变正式业务含义。
|
||||
5. 用户正在编辑的表单草稿,但保存、校验、合并、发布门禁以后端为准。
|
||||
|
||||
## 3. 结论先行
|
||||
|
||||
当前 RPG 前端脚本中仍有 9 类逻辑应该迁移到后端:
|
||||
|
||||
1. `P0` 运行时开局 `GameState` 装配。
|
||||
2. `P0` runtime story 网关中的“客户端带快照解析”和快照补丁。
|
||||
3. `P0` 前端自动保存整份运行时快照。
|
||||
4. `P0` 前端 story engine / chapter / world mutation / prompt context 编排。
|
||||
5. `P0` 战斗胜负后处理、死亡复活、战斗后章节推进。
|
||||
6. `P1` NPC 交易、送礼、价格、数量与库存校验。
|
||||
7. `P1` 背包、装备、锻造可用性与配方视图。
|
||||
8. `P1` RPG 创作 profile 生成的非浏览器 legacy AI 回退。
|
||||
9. `P1` RPG 创作结果页自动保存、session/result preview 真相优先级与 legacy preview fallback。
|
||||
|
||||
一句话判断:
|
||||
|
||||
**前端已经大面积开始调用 server-rs,但仍保留了“后端兼容不完整时由前端补真相”的模式;这部分需要继续收口,否则 SpacetimeDB 表和 reducer 永远无法成为唯一运行时真相源。**
|
||||
|
||||
## 4. 高优先级迁移项
|
||||
|
||||
## 4.1 `P0` 运行时开局 `GameState` 装配仍在前端
|
||||
|
||||
### 代码证据
|
||||
|
||||
主要文件:
|
||||
|
||||
1. `src/hooks/rpg-session/useRpgSessionBootstrap.ts`
|
||||
|
||||
关键逻辑:
|
||||
|
||||
1. `PLAYER_BASE_MAX_HP = 180`
|
||||
2. `mergeStarterInventoryItems(...)`
|
||||
3. `normalizeExplicitStarterCategory(...)`
|
||||
4. `inferExplicitStarterSlot(...)`
|
||||
5. `buildExplicitCustomWorldRoleStarterState(...)`
|
||||
6. `createInitialGameState()`
|
||||
7. `handleCustomWorldSelect(...)`
|
||||
8. `handleCharacterSelect(...)`
|
||||
|
||||
对应代码表现:
|
||||
|
||||
1. 第 `58` 行到第 `151` 行:前端把角色初始物品编译成 `InventoryItem`,推断装备槽,并直接生成 `runtimeMetadata`。
|
||||
2. 第 `185` 行到第 `235` 行:前端创建完整初始 `GameState`。
|
||||
3. 第 `517` 行到第 `563` 行:前端选择世界时重置运行时上下文、progression、story memory、战斗态。
|
||||
4. 第 `572` 行到第 `710` 行:前端选角时决定开局场景、开局 NPC、初始 NPC state、初始装备、血蓝、货币、背包、任务、队伍、战斗字段。
|
||||
|
||||
### 为什么应迁
|
||||
|
||||
这些不是表现层逻辑,而是“新开局正式状态”的创建权。只要开局状态仍由浏览器本地装配,后端就只能被动接收一份客户端快照,无法成为 runtime session 的唯一真相。
|
||||
|
||||
### 后端落点
|
||||
|
||||
建议收口到:
|
||||
|
||||
1. `server-rs/crates/api-server/src/runtime_story.rs`
|
||||
2. `server-rs/crates/api-server/src/story_sessions.rs`
|
||||
3. `server-rs/crates/module-runtime/src/lib.rs`
|
||||
4. `server-rs/crates/module-story/src/lib.rs`
|
||||
5. `server-rs/crates/module-inventory/src/lib.rs`
|
||||
6. `server-rs/crates/module-progression/src/lib.rs`
|
||||
7. `server-rs/crates/module-runtime-story-compat/src/game_state.rs`
|
||||
|
||||
SpacetimeDB 方向:
|
||||
|
||||
1. 新增或扩展 `begin_rpg_runtime_session` reducer。
|
||||
2. 由 reducer 基于 `ctx.sender()`、作品 profile id、角色 id 创建 runtime session。
|
||||
3. 在表中写入初始场景、角色状态、背包、装备、NPC 状态、任务、progression。
|
||||
4. 前端只调用 reducer / API 并订阅或读取后端返回的 session view model。
|
||||
|
||||
注意:外部 AI / 网络调用不能放进 reducer;如果开局需要 LLM,应由 `api-server` / `platform-llm` 完成生成,再把确定结果写入 SpacetimeDB。
|
||||
|
||||
## 4.2 `P0` runtime story 网关仍要求客户端携带快照解析
|
||||
|
||||
### 代码证据
|
||||
|
||||
主要文件:
|
||||
|
||||
1. `src/hooks/rpg-runtime-story/rpgRuntimeStoryGateway.ts`
|
||||
|
||||
关键逻辑:
|
||||
|
||||
1. 第 `112` 行到第 `123` 行:`buildRuntimeSnapshotRequest(...)` 把本地 `gameState` 和 `currentStory` 一并提交给后端。
|
||||
2. 第 `125` 行到第 `213` 行:`bridgeServerSceneTravelSnapshot(...)` 在前端补齐服务端旅行快照。
|
||||
3. 第 `216` 行到第 `317` 行:`bridgeServerNpcBattleSnapshot(...)` 在前端补齐 NPC 战斗阵型。
|
||||
4. 第 `323` 行到第 `341` 行:拉取 option catalog 时仍基于客户端快照。
|
||||
5. 第 `387` 行到第 `430` 行:正式动作结算后继续由前端对服务端快照做桥接修补。
|
||||
|
||||
### 为什么应迁
|
||||
|
||||
这类桥接逻辑意味着服务端返回结果不是完整业务真相,前端还在做二次裁决:
|
||||
|
||||
1. 推断下一场景。
|
||||
2. 补齐场景 preset。
|
||||
3. 补齐遭遇预览。
|
||||
4. 补齐任务推进。
|
||||
5. 补齐 NPC 战斗阵型。
|
||||
6. 改写 `GameState` 中的战斗、场景、任务字段。
|
||||
|
||||
这些都应该由后端 runtime action resolver 在同一个事务边界内完成。
|
||||
|
||||
### 后端落点
|
||||
|
||||
建议收口到:
|
||||
|
||||
1. `server-rs/crates/api-server/src/runtime_story/compat/game_state.rs`
|
||||
2. `server-rs/crates/api-server/src/runtime_story/compat/npc_actions.rs`
|
||||
3. `server-rs/crates/api-server/src/runtime_story/compat/equipment_actions.rs`
|
||||
4. `server-rs/crates/api-server/src/runtime_story/compat/quest_actions.rs`
|
||||
5. `server-rs/crates/module-runtime-story-compat/src/options.rs`
|
||||
6. `server-rs/crates/module-runtime-story-compat/src/battle.rs`
|
||||
7. `server-rs/crates/module-runtime-story-compat/src/view_model.rs`
|
||||
|
||||
迁移后前端应该只提交:
|
||||
|
||||
1. `sessionId`
|
||||
2. `functionId`
|
||||
3. `runtimePayload`
|
||||
4. 当前 UI 所需的弱表现参数
|
||||
|
||||
后端应该基于已存 session state 解析动作,不再要求前端上传完整 `GameState`。
|
||||
|
||||
### 本轮落地口径(2026-04-28)
|
||||
|
||||
本轮先收口 `runtime story` 网关这条 P0 主链:
|
||||
|
||||
1. 前端 `rpgRuntimeStoryGateway.ts` 不再构造或上传 `snapshot.gameState / currentStory`。
|
||||
2. 前端 `rpgRuntimeStoryClient.ts` 的状态读取统一走 `GET /api/runtime/story/state/:sessionId`,动作结算统一走 `POST /api/runtime/story/actions/resolve` 且请求体只包含 `sessionId / clientVersion / action`。
|
||||
3. `idle_travel_next_scene` 的下一场景、场景 preset、遭遇清理、战斗清理与旅行计数由 `server-rs/crates/api-server/src/runtime_story/compat.rs` 在持久化快照上完成。
|
||||
4. `npc_fight / npc_spar` 的战斗阵型、`sceneHostileNpcs`、战前 encounter 归档与战斗字段由 `server-rs/crates/api-server/src/runtime_story/compat/npc_actions.rs` 在后端完成。
|
||||
5. 前端只消费后端返回的 hydrated snapshot 和 presentation,不再保留 `bridgeServer*Snapshot` 业务补丁。
|
||||
|
||||
### 本轮落地验收(2026-04-28)
|
||||
|
||||
1. `rpgRuntimeStoryGateway.ts` 已删除 `buildRuntimeSnapshotRequest`、`bridgeServerSceneTravelSnapshot`、`bridgeServerNpcBattleSnapshot` 以及 NPC 战斗/旅行快照补丁依赖;option catalog 和 action resolve 都只提交 `sessionId / clientVersion / action / payload`。
|
||||
2. `rpgRuntimeStoryClient.ts` 已移除前端 `RuntimeStorySnapshotRequest` 入参;状态读取固定为 `GET /api/runtime/story/state/:sessionId`,动作结算请求体不再带 `snapshot` 字段。
|
||||
3. `server-rs` 已覆盖 `idle_travel_next_scene`、`npc_fight`、`npc_spar` 的后端快照补齐测试,并将 route 级测试改为从服务端持久化 session 读取,不再通过旧 save 端点上传整份前端快照铺垫。
|
||||
4. 已执行验收:`cargo check -p api-server --message-format short`、`cargo test -p api-server runtime_story_ -- --nocapture`、`npm run test -- src/services/rpg-runtime/rpgRuntimeStoryClient.test.ts src/hooks/rpg-runtime-story/runtimeStoryCoordinator.test.ts`。
|
||||
5. 搜索确认:`src/hooks/rpg-runtime-story/rpgRuntimeStoryGateway.ts` 与 `src/services/rpg-runtime/rpgRuntimeStoryClient.ts` 不再包含 `/state/resolve`、`buildRuntimeSnapshotRequest`、`bridgeServer*`、`params.snapshot` 或动作请求上传 `snapshot.gameState/currentStory` 的路径。
|
||||
|
||||
## 4.3 `P0` 自动保存仍由前端上传整份运行时快照
|
||||
|
||||
### 代码证据
|
||||
|
||||
主要文件:
|
||||
|
||||
1. `src/hooks/rpg-session/useRpgSessionPersistence.ts`
|
||||
2. `src/services/rpg-runtime/rpgSnapshotClient.ts`
|
||||
|
||||
关键逻辑:
|
||||
|
||||
1. `useRpgSessionPersistence.ts` 第 `12` 行到第 `23` 行:前端判断哪些运行态可以入正式存档。
|
||||
2. 第 `90` 行到第 `150` 行:前端把 `gameState / bottomTab / currentStory` 作为 payload 写入远端。
|
||||
3. 第 `216` 行到第 `234` 行:前端监听本地 `gameState` 变化并自动保存。
|
||||
4. 第 `236` 行到第 `263` 行:手动存档也上传整份本地快照。
|
||||
5. `rpgSnapshotClient.ts` 第 `31` 行到第 `48` 行:`putRpgSaveSnapshot(...)` 直接发送 `SavedGameSnapshotInput`。
|
||||
|
||||
### 为什么应迁
|
||||
|
||||
运行时存档不是普通表单保存,它是玩家进度、任务、背包、战斗、NPC、故事状态的正式真相。当前模式仍是“前端本地状态变化 -> 上传整份快照 -> 后端持久化”,后端缺少对状态版本、动作来源、事务一致性的最终解释权。
|
||||
|
||||
### 后端落点
|
||||
|
||||
建议改成:
|
||||
|
||||
1. 后端 action resolver 每次动作结算时事务性写入 session state。
|
||||
2. 自动保存只变成后端已有 session state 的归档或 checkpoint。
|
||||
3. 前端最多提交 `sessionId`、`saveSlot`、`bottomTab` 等非业务 UI 状态。
|
||||
4. `runtime_save.rs` 负责快照归档,不再信任浏览器传入的整份 `GameState`。
|
||||
|
||||
涉及表结构时同步检查并更新 `migration.rs`。
|
||||
|
||||
## 4.4 `P0` story engine / chapter / world mutation 仍在前端编排
|
||||
|
||||
### 代码证据
|
||||
|
||||
主要文件:
|
||||
|
||||
1. `src/hooks/rpg-runtime-story/progressionActions.ts`
|
||||
2. `src/hooks/rpg-runtime-story/storyContextBuilder.ts`
|
||||
3. `src/hooks/rpg-runtime-story/useRpgRuntimeStoryController.ts`
|
||||
|
||||
关键逻辑:
|
||||
|
||||
1. `progressionActions.ts` 第 `193` 行到第 `293` 行:前端生成章节任务并写入 `quests`。
|
||||
2. 第 `295` 行到第 `395` 行:前端收集 story signals、推进 thread、同伴反应、章节、旅程 beat、阵营/世界 mutation、setpiece。
|
||||
3. 第 `644` 行到第 `782` 行:前端提交生成后状态、追加 story history、执行 AI 后再做 recovery。
|
||||
4. `storyContextBuilder.ts` 第 `1` 行到第 `51` 行:前端引入大量 story engine director / graph / memory / prompt context 组件。
|
||||
5. `useRpgRuntimeStoryController.ts` 第 `80` 行到第 `103` 行:前端把 `generateInitialStory / generateNextStep` 注入本地 story request runtime。
|
||||
|
||||
### 为什么应迁
|
||||
|
||||
这些逻辑不是 UI,而是叙事引擎状态机:
|
||||
|
||||
1. 章节任务发放。
|
||||
2. 线索和 thread 更新。
|
||||
3. 同伴关系变化。
|
||||
4. 营地事件与 setpiece 触发。
|
||||
5. 世界 mutation。
|
||||
6. 生成上下文组织。
|
||||
7. fallback / recovery。
|
||||
|
||||
这些都应作为后端 story session 的 reducer / domain service,而不是 React hook 的本地副作用。
|
||||
|
||||
### 后端落点
|
||||
|
||||
建议收口到:
|
||||
|
||||
1. `server-rs/crates/api-server/src/runtime_story/compat/ai.rs`
|
||||
2. `server-rs/crates/api-server/src/prompt/rpg/runtime_chat.rs`
|
||||
3. `server-rs/crates/module-story/src/lib.rs`
|
||||
4. `server-rs/crates/module-progression/src/lib.rs`
|
||||
5. `server-rs/crates/module-runtime-story-compat/src/core.rs`
|
||||
6. `server-rs/crates/module-runtime-story-compat/src/view_model.rs`
|
||||
|
||||
前端保留:
|
||||
|
||||
1. `currentStory` 展示。
|
||||
2. loading / error。
|
||||
3. SSE 增量文本。
|
||||
4. 视觉过场。
|
||||
|
||||
## 4.5 `P0` 战斗后处理、死亡复活、章节推进仍在前端补真相
|
||||
|
||||
### 代码证据
|
||||
|
||||
主要文件:
|
||||
|
||||
1. `src/hooks/rpg-runtime-story/storyChoiceRuntime.ts`
|
||||
2. `src/hooks/rpg-runtime-story/postBattleFlow.ts`
|
||||
|
||||
关键逻辑:
|
||||
|
||||
1. `storyChoiceRuntime.ts` 第 `1` 行到第 `6` 行仍引用掉落、背包合并等本地规则。
|
||||
2. 第 `304` 行到第 `390` 行:服务端动作返回后,前端仍判断死亡、等待复活、构造复活状态、构造战斗胜利后状态与 story。
|
||||
3. `postBattleFlow.ts` 第 `86` 行到第 `105` 行:前端构造战斗胜利后的 `GameState`。
|
||||
4. 第 `107` 行到第 `158` 行:前端推进 scene act 并构造战斗后 story / deferred options。
|
||||
5. 第 `161` 行到第 `205` 行:前端决定复活回第一场景、恢复血蓝、清战斗态、重建首幕 encounter preview。
|
||||
|
||||
### 为什么应迁
|
||||
|
||||
死亡、胜利、切磋完成、复活、掉落、战斗后章节推进都属于正式玩法结果。前端可以播死亡动画和过场,但不能决定:
|
||||
|
||||
1. 玩家是否死亡。
|
||||
2. 玩家在哪里复活。
|
||||
3. 复活后血蓝。
|
||||
4. 战斗是否结束。
|
||||
5. 任务或章节是否推进。
|
||||
6. deferred options 里有哪些正式可选动作。
|
||||
|
||||
### 后端落点
|
||||
|
||||
建议收口到:
|
||||
|
||||
1. `server-rs/crates/module-combat/src/lib.rs`
|
||||
2. `server-rs/crates/module-runtime-story-compat/src/battle.rs`
|
||||
3. `server-rs/crates/module-runtime-story-compat/src/view_model.rs`
|
||||
4. `server-rs/crates/api-server/src/story_battles.rs`
|
||||
5. `server-rs/crates/api-server/src/runtime_story/compat/presentation.rs`
|
||||
|
||||
前端保留:
|
||||
|
||||
1. 根据后端返回的 `presentation.battle` 播动画。
|
||||
2. 根据后端返回的 `nextStory` 展示文本和选项。
|
||||
3. 根据后端返回的 `GameState` 或 view model 渲染血条、角色、敌人。
|
||||
|
||||
## 5. 中优先级迁移项
|
||||
|
||||
## 5.1 `P1` NPC 交易、送礼的价格和库存校验仍在前端
|
||||
|
||||
### 代码证据
|
||||
|
||||
主要文件:
|
||||
|
||||
1. `src/hooks/rpg-runtime-story/npcInteraction.ts`
|
||||
|
||||
关键逻辑:
|
||||
|
||||
1. 第 `186` 行到第 `218` 行:前端读取 NPC / 玩家物品,计算交易单价、最大数量、数量 clamp。
|
||||
2. 第 `629` 行到第 `683` 行:前端按本地价格和库存判断买入 / 卖出是否允许,再提交后端。
|
||||
3. 第 `685` 行到第 `702` 行:前端检查礼物是否存在,并构造送礼 action。
|
||||
|
||||
### 当前判断
|
||||
|
||||
这条链已经比早期更好:正式交易和送礼会调用 `resolveRpgRuntimeChoice(...)` 走后端。但前端仍在承担“能不能买、能不能卖、价格是多少、数量是否合法”的第一层裁决。
|
||||
|
||||
### 迁移建议
|
||||
|
||||
后端应该:
|
||||
|
||||
1. 计算价格。
|
||||
2. 校验库存和货币。
|
||||
3. 校验 NPC 亲和度影响。
|
||||
4. 原子更新玩家库存、NPC 库存、货币和 NPC state。
|
||||
5. 返回可展示的交易 view model 与错误原因。
|
||||
|
||||
前端可以保留:
|
||||
|
||||
1. 当前选择的 itemId / quantity。
|
||||
2. 后端返回价格的展示。
|
||||
3. modal 开关和数量输入。
|
||||
|
||||
### 本次落地设计(2026-04-28)
|
||||
|
||||
为避免“前端只是少算了一处价格,但仍在提交前裁决库存”的半迁移,本次按下面边界收口:
|
||||
|
||||
1. `server-rs` 在 runtime story compat 状态桥中编译 `gameState.runtimeNpcInteraction` 派生 view,包含当前 NPC、玩家货币、购买列表、出售列表、赠礼列表、服务端单价、服务端库存上限、赠礼好感增益与不可选原因。
|
||||
2. `resolve_npc_trade_action(...)` 与 `resolve_npc_gift_action(...)` 仍是唯一正式结算入口,提交后重新校验 mode、itemId、quantity、NPC 库存、玩家背包数量与玩家货币;前端展示的 view 只作为 UI 提示,不作为可信输入。
|
||||
3. 前端 `npcInteraction.ts` 不再读取本地 NPC / 玩家物品计算单价、总价、最大数量,也不再因为本地库存或货币不足提前阻断提交;确认时只提交 `{ mode, itemId, quantity }`,后端返回错误即展示错误。
|
||||
4. 前端 `NpcModals.tsx` 只渲染 `runtimeNpcInteraction` 中的价格、库存和赠礼增益;按钮禁用仅保留“未选择条目 / 服务端 view 标记不可选 / 当前操作正在提交”的表现态,不再用浏览器本地价格和货币重新裁决。
|
||||
5. 验收测试必须覆盖:后端购买成功、NPC 库存不足拒绝、玩家货币不足拒绝、出售成功、玩家背包数量不足拒绝、赠礼成功、礼物不存在拒绝;前端测试覆盖确认交易/赠礼在本地库存或货币不满足时仍提交后端,由后端错误接管。
|
||||
|
||||
## 5.2 `P1` 背包、装备、锻造可用性与配方视图仍在前端
|
||||
|
||||
### 代码证据
|
||||
|
||||
主要文件:
|
||||
|
||||
1. `src/hooks/rpg-runtime-story/inventoryActions.ts`
|
||||
|
||||
关键逻辑:
|
||||
|
||||
1. 第 `44` 行到第 `52` 行:前端基于 `playerInventory / playerCurrency / worldType` 计算 forge recipe views。
|
||||
2. 第 `97` 行到第 `180` 行:前端先查找物品 / 装备 / 配方,再提交后端 reducer 风格 action。
|
||||
|
||||
### 当前判断
|
||||
|
||||
正式动作已经走后端,这是正确方向。但 `forge recipe view` 和本地物品存在性判断仍会让前端表现出“它知道业务规则”的形态。
|
||||
|
||||
### 迁移建议
|
||||
|
||||
后端应该提供:
|
||||
|
||||
1. 当前玩家背包 view。
|
||||
2. 当前装备 view。
|
||||
3. 当前可锻造配方 view。
|
||||
4. 每个动作的 disabled / reason。
|
||||
|
||||
前端只渲染后端 view,并把用户选择提交给后端。
|
||||
|
||||
## 5.3 `P1` RPG 创作 profile 生成仍保留非浏览器 legacy AI 回退
|
||||
|
||||
### 迁移回填(2026-04-28)
|
||||
|
||||
已完成本项迁移:
|
||||
|
||||
1. `src/services/rpg-creation/rpgCreationGenerationClient.ts` 删除 `LegacyAiModule`、`loadLegacyAiModule` 与 `typeof window === 'undefined'` 分支。
|
||||
2. `src/services/rpg-creation/rpgCreationGenerationClient.node.test.ts` 锁定 node 环境也只 mock `requestJson`,不再触发前端 legacy AI 模块。
|
||||
3. `src/services/rpg-creation/index.ts` 删除 `generateLegacyCustomWorldProfile` 旧命名导出。
|
||||
4. `server-rs/crates/api-server/src/app.rs` 挂载 `POST /api/runtime/custom-world/profile`。
|
||||
5. `server-rs/crates/api-server/src/custom_world.rs` 复用 `generate_custom_world_foundation_draft(...)` 生成 profile,并补齐前端结果页所需稳定字段。
|
||||
6. 迁移说明见 `docs/technical/RPG_CREATION_PROFILE_GENERATION_BACKEND_MIGRATION_2026-04-28.md`。
|
||||
|
||||
### 迁移前历史证据
|
||||
|
||||
主要文件:
|
||||
|
||||
1. `src/services/rpg-creation/rpgCreationGenerationClient.ts`
|
||||
|
||||
关键逻辑:
|
||||
|
||||
1. 第 `9` 行到第 `19` 行:前端 client 仍定义 `LegacyAiModule = typeof import('../ai')` 并动态加载旧 AI 模块。
|
||||
2. 第 `32` 行到第 `35` 行:非浏览器环境直接调用 `aiClient.generateCustomWorldProfile(...)`。
|
||||
3. 第 `43` 行到第 `51` 行:浏览器环境才请求 `/api/runtime/custom-world/profile`。
|
||||
|
||||
### 为什么应迁
|
||||
|
||||
这与 `RPG_PROMPT_FRONTEND_REMOVAL_AND_SERVER_RS_MIGRATION_2026-04-28.md` 的目标冲突。即使该分支主要服务测试或 SSR,它仍保留了“前端可持有 RPG 生成逻辑和 prompt”的技术后门。
|
||||
|
||||
### 迁移建议
|
||||
|
||||
1. 删除 `import('../ai')` 回退。
|
||||
2. 非浏览器环境也应通过 `server-rs` client / test mock 调用后端 contract。
|
||||
3. 若测试需要离线能力,应 mock `requestJson`,不要恢复前端生成链。
|
||||
|
||||
后端落点:
|
||||
|
||||
1. `server-rs/crates/api-server/src/custom_world.rs`
|
||||
2. `server-rs/crates/api-server/src/custom_world_foundation_draft.rs`
|
||||
3. `server-rs/crates/api-server/src/prompt/rpg/foundation_draft.rs`
|
||||
4. `server-rs/crates/platform-llm/`
|
||||
|
||||
## 5.4 `P1` 创作结果页保存与 Agent session 真相优先级仍在前端编排
|
||||
|
||||
### 代码证据
|
||||
|
||||
主要文件:
|
||||
|
||||
1. `src/components/rpg-entry/useRpgCreationResultAutosave.ts`
|
||||
2. `src/components/rpg-entry/useRpgEntryLibraryDetail.ts`
|
||||
3. `src/services/rpg-creation/rpgCreationPreviewAdapter.ts`
|
||||
|
||||
关键逻辑:
|
||||
|
||||
1. `useRpgCreationResultAutosave.ts` 负责 profile signature、自动保存请求去重、`upsertRpgWorldProfile(...)`、Agent session refresh。
|
||||
2. `useRpgEntryLibraryDetail.ts` 负责判断 draft work 应打开 Agent workspace、生成过程页还是结果页。
|
||||
3. `rpgCreationPreviewAdapter.ts` 在 `resultPreview` 缺失时回退读取 `draftProfile.legacyResultProfile`。
|
||||
|
||||
### 当前判断
|
||||
|
||||
这部分大多是创作 UI 编排,不能简单全迁。但下面三件事属于后端真相:
|
||||
|
||||
1. 保存前 profile normalize。
|
||||
2. Agent session / result preview / legacyResultProfile 的优先级。
|
||||
3. 发布门禁与草稿是否可进入结果页。
|
||||
|
||||
### 迁移建议
|
||||
|
||||
后端应该提供一个稳定的 `creation_result_view` 或 `work_detail_view`:
|
||||
|
||||
1. 已标准化 profile。
|
||||
2. 当前 session / work 状态。
|
||||
3. 是否可发布。
|
||||
4. 应打开的前端 stage。
|
||||
5. 缺失或失败时的恢复指令。
|
||||
|
||||
前端只根据 view model 切页面,不再自行解释 session 阶段。
|
||||
|
||||
## 5.5 `P1` 角色资产工坊默认 prompt 与缓存合并规则仍在前端
|
||||
|
||||
### 本轮落地状态(2026-04-28)
|
||||
|
||||
已完成默认 prompt 与缓存合并规则迁移:
|
||||
|
||||
1. 新增 `server-rs/crates/api-server/src/prompt/rpg/role_asset_studio.rs`,作为角色资产工坊默认 prompt、legacy prompt 过滤、逐动作 prompt 缓存合并的后端主源。
|
||||
2. 新增 `POST /api/runtime/custom-world/asset-studio/role/{character_id}/workflow`,由前端提交当前正在编辑的角色快照,后端返回合并后的 workflow view。
|
||||
3. 新增 `PUT /api/runtime/custom-world/asset-studio/role/{character_id}/workflow`,复用 OSS JSON 缓存保存,并补齐 `animationPromptTextByKey` 持久化。
|
||||
4. `RpgCreationRoleAssetStudioModalImpl.tsx` 不再调用 `buildDefaultRolePromptBundle`,也不再包含 legacy prompt 判断和缓存合并函数。
|
||||
5. 删除 `src/prompts/customWorldRolePromptDefaults.ts` 与旧兼容 re-export,避免 `src/` 继续持有角色资产默认 prompt 主源。
|
||||
|
||||
### 代码证据
|
||||
|
||||
主要文件:
|
||||
|
||||
1. `src/components/rpg-creation-asset-studio/RpgCreationRoleAssetStudioModalImpl.tsx`
|
||||
2. `src/components/rpg-creation-asset-studio/useRoleVisualCandidateWorkflow.ts`
|
||||
3. `src/components/rpg-creation-asset-studio/useRoleAnimationWorkflow.ts`
|
||||
|
||||
关键逻辑:
|
||||
|
||||
1. `RpgCreationRoleAssetStudioModalImpl.tsx` 第 `54` 行到第 `85` 行:前端合并默认动画 prompt、缓存 prompt、legacy prompt。
|
||||
2. 第 `564` 行到第 `591` 行:前端调用 `buildDefaultRolePromptBundle(baseRole)` 生成默认视觉 / 动作 prompt。
|
||||
3. 第 `807` 行到第 `830` 行:前端保存工作流缓存,包括 prompt、草稿、asset id、animation map。
|
||||
4. `useRoleVisualCandidateWorkflow.ts` 和 `useRoleAnimationWorkflow.ts` 把 prompt 文本直接作为生成 payload。
|
||||
|
||||
### 当前判断
|
||||
|
||||
用户正在编辑的 prompt 草稿可以留在前端表单里,但默认 prompt 生成、legacy prompt 判断、缓存合并、生成参数默认值不应继续散落在 UI modal 内。
|
||||
|
||||
### 迁移建议
|
||||
|
||||
后端应该提供:
|
||||
|
||||
1. `GET /api/runtime/custom-world/asset-studio/role/:id/workflow`
|
||||
2. `PUT /api/runtime/custom-world/asset-studio/role/:id/workflow`
|
||||
3. `POST /api/runtime/custom-world/assets/role-visual-candidates`
|
||||
4. `POST /api/runtime/custom-world/assets/role-animation`
|
||||
|
||||
并在 `server-rs/crates/api-server/src/prompt/rpg/` 或资产 prompt 模块中统一默认 prompt 生成。
|
||||
|
||||
前端只保留:
|
||||
|
||||
1. prompt 文本框。
|
||||
2. 参考图上传 UI。
|
||||
3. 候选图 / 动画预览。
|
||||
4. 用户点击生成、应用、保存的交互入口。
|
||||
|
||||
## 6. 可保留在前端的 RPG 脚本
|
||||
|
||||
以下脚本当前主要承担表现层或 request client 职责,可以暂时保留:
|
||||
|
||||
1. `src/components/rpg-runtime-shell/*`
|
||||
2. `src/components/rpg-runtime-panels/RpgAdventurePanel.tsx`
|
||||
3. `src/components/rpg-runtime-panels/RpgAdventurePanelOverlays.tsx`
|
||||
4. `src/components/rpg-entry/RpgEntryHomeView.tsx`
|
||||
5. `src/components/rpg-entry/RpgEntryWorldDetailView.tsx`
|
||||
6. `src/services/rpg-runtime/rpgRuntimeRequest.ts`
|
||||
7. `src/services/rpg-runtime/rpgRuntimeStoryClient.ts`
|
||||
8. `src/services/rpg-runtime/rpgSnapshotClient.ts`
|
||||
9. `src/services/rpg-creation/rpgCreationAgentClient.ts`
|
||||
10. `src/services/rpg-creation/rpgCreationAssetClient.ts`
|
||||
11. `src/services/rpg-creation/rpgCreationLibraryClient.ts`
|
||||
12. `src/services/rpg-creation/rpgCreationRuntimeClient.ts`
|
||||
|
||||
但要注意:
|
||||
|
||||
1. client 文件可以保留请求封装,但不应继续加入 fallback 生成逻辑。
|
||||
2. UI 文件可以根据后端 view model 展示 disabled / reason,但不能本地重算业务可用性。
|
||||
3. snapshot client 可以保留读取 / 删除 / 归档入口,但不应让前端上传整份 `GameState` 作为正式真相。
|
||||
|
||||
## 7. 推荐迁移顺序
|
||||
|
||||
### 第一阶段:收运行时真相
|
||||
|
||||
优先迁移:
|
||||
|
||||
1. `useRpgSessionBootstrap.ts`
|
||||
2. `rpgRuntimeStoryGateway.ts`
|
||||
3. `useRpgSessionPersistence.ts`
|
||||
|
||||
目标:
|
||||
|
||||
1. 后端创建 session。
|
||||
2. 后端保存 session state。
|
||||
3. runtime action 不再依赖客户端完整快照。
|
||||
4. 前端不再补战斗 / 旅行快照。
|
||||
|
||||
### 第二阶段:收 story engine 与战斗后处理
|
||||
|
||||
优先迁移:
|
||||
|
||||
1. `progressionActions.ts`
|
||||
2. `storyContextBuilder.ts`
|
||||
3. `storyChoiceRuntime.ts`
|
||||
4. `postBattleFlow.ts`
|
||||
|
||||
目标:
|
||||
|
||||
1. 后端统一处理 story history、thread、chapter、companion reaction、world mutation。
|
||||
2. 后端统一处理死亡、复活、胜利、切磋完成、战斗后选项。
|
||||
3. 前端只播放 presentation。
|
||||
|
||||
### 第三阶段:收 NPC / 背包 / 锻造可用性
|
||||
|
||||
优先迁移:
|
||||
|
||||
1. `npcInteraction.ts`
|
||||
2. `inventoryActions.ts`
|
||||
|
||||
目标:
|
||||
|
||||
1. 后端提供交易 / 礼物 / 背包 / 锻造 view model。
|
||||
2. 前端不再重算价格、数量、配方、动作合法性。
|
||||
|
||||
### 第四阶段:收创作链残留后门
|
||||
|
||||
优先迁移:
|
||||
|
||||
1. `rpgCreationGenerationClient.ts`
|
||||
2. `useRpgCreationResultAutosave.ts`
|
||||
3. `useRpgEntryLibraryDetail.ts`
|
||||
4. `rpgCreationPreviewAdapter.ts`
|
||||
5. `RpgCreationRoleAssetStudioModalImpl.tsx`
|
||||
|
||||
目标:
|
||||
|
||||
1. 移除 `import('../ai')`。
|
||||
2. 后端输出稳定 result/work view。
|
||||
3. 后端统一角色资产工坊默认 prompt 和缓存合并规则。
|
||||
|
||||
## 8. 后端实现注意事项
|
||||
|
||||
### 4.3 落地记录:自动保存 checkpoint 化(2026-04-28)
|
||||
|
||||
本轮已完成 `自动保存仍由前端上传整份运行时快照` 的迁移:
|
||||
|
||||
1. `src/hooks/rpg-session/useRpgSessionPersistence.ts` 自动保存与手动保存不再构造 `gameState / currentStory` 上传体,只提交 `sessionId / bottomTab` checkpoint 元数据。
|
||||
2. `src/services/rpg-runtime/rpgSnapshotClient.ts` 的 `PUT /api/runtime/save/snapshot` 请求体改为 `RuntimeSaveCheckpointInput`,前端保存链路只请求后端刷新 checkpoint。
|
||||
3. `server-rs/crates/api-server/src/runtime_save.rs` 改为读取已存在的服务端 `runtime_snapshot`,校验 `runtimeSessionId` 一致后刷新 `savedAt / bottomTab / runtimeStats.playTimeMs / lastPlayTickAt`,不再信任浏览器传入完整运行态。
|
||||
4. 旧式 `gameState / currentStory` 上传体会被 `PutRuntimeSaveCheckpointRequest` 的 `deny_unknown_fields` 拒绝;无服务端快照、session 不一致、preview/test 快照都会返回冲突。
|
||||
5. 本轮不涉及 SpacetimeDB 表结构变更,因此 `server-rs/crates/spacetime-module/src/migration.rs` 无需调整。
|
||||
|
||||
对应测试:
|
||||
|
||||
1. `src/services/rpg-runtime/rpgSnapshotClient.test.ts` 覆盖保存请求体不含 `gameState / currentStory`。
|
||||
2. `src/hooks/runtimeAuthGuards.test.tsx` 覆盖自动保存只提交 checkpoint。
|
||||
3. `server-rs/crates/api-server/src/runtime_save.rs` 覆盖旧式整快照上传拒绝、缺少服务端快照冲突、session 不一致冲突、成功 checkpoint 沿用服务端快照真相。
|
||||
|
||||
按 SpacetimeDB 约束,后续落地时要遵守:
|
||||
|
||||
1. reducer 不返回数据,前端通过订阅 / 查询读取 view model。
|
||||
2. reducer 使用 `ctx.sender()` 做鉴权,不信任前端传入身份。
|
||||
3. reducer 必须确定性,不能访问网络、文件、外部随机。
|
||||
4. LLM、OSS、图片生成等外部 I/O 放在 `api-server` / `platform-*` crate 中,再把确定结果写回 SpacetimeDB。
|
||||
5. 前端调用 reducer 使用生成绑定和对象参数,不编辑生成代码。
|
||||
6. 涉及表结构修改时同步更新 `migration.rs`。
|
||||
7. 修改后端代码后统一执行 `npm run api-server:maincloud`,并跑对应自动测试。
|
||||
|
||||
## 9. 最小验收标准
|
||||
|
||||
后续迁移完成后,前端 RPG 脚本应满足:
|
||||
|
||||
1. 搜索 `src/hooks/rpg-*` 不再出现创建完整 `GameState` 的逻辑。
|
||||
2. runtime action 请求不再携带完整 `gameState`。
|
||||
3. 前端不再出现 `bridgeServer*Snapshot` 这类业务补丁函数。
|
||||
4. 前端不再 `putSnapshot({ gameState, currentStory })`。
|
||||
5. 前端不再动态 `import('../ai')`。
|
||||
6. NPC 交易、送礼、背包、锻造的价格、合法性、结果都以后端 view model 为准。
|
||||
7. 战斗死亡、复活、胜利后状态都以后端返回为准。
|
||||
8. 创作结果页打开哪个 stage 由后端 work/session view 指示。
|
||||
|
||||
## 10. 一句话结论
|
||||
|
||||
当前 RPG 前端脚本最需要迁移的不是 UI 组件,而是仍残留在 hooks / gateway / client 里的:
|
||||
|
||||
**开局造状态、运行时带快照解析、前端补服务端快照、自动保存整份 GameState、story engine 编排、战斗后处理、NPC/背包/锻造规则裁决,以及 RPG 创作生成和结果预览的 legacy 后门。**
|
||||
@@ -0,0 +1,177 @@
|
||||
# RPG 前端脚本后端迁移完成度核验(2026-04-28)
|
||||
|
||||
## 1. 核验结论
|
||||
|
||||
本次按 `RPG_FRONTEND_SCRIPT_BACKEND_MIGRATION_AUDIT_2026-04-28.md` 中列出的应迁后端项逐项检查当前代码。
|
||||
|
||||
结论:**应迁移项已全部迁移完成。**
|
||||
|
||||
当前状态:
|
||||
|
||||
1. `已完成`:10 项。
|
||||
2. `部分完成`:0 项。
|
||||
3. `未发现完全未启动`:0 项。
|
||||
|
||||
本轮重新核查的变化:
|
||||
|
||||
1. 上次残留的 `RPG 创作结果页` 保存前 profile normalize 已完成后端化。
|
||||
2. `camp_travel_home_scene` 已完成后端收口:正式点击统一走 `/api/runtime/story/actions/resolve`,目标场景、encounter preview、`scenesTraveled` 与快照持久化由 `server-rs` 裁决。
|
||||
|
||||
## 2. 核验口径
|
||||
|
||||
### 2.1 判定为已完成
|
||||
|
||||
满足以下条件才记为已完成:
|
||||
|
||||
1. 前端不再构造正式业务状态。
|
||||
2. 前端不再上传完整 `GameState` 作为后端写入依据。
|
||||
3. 前端不再用本地规则裁决价格、库存、掉落、复活、章节推进、结果页真相优先级。
|
||||
4. 前端只保留请求封装、UI 展示、表单草稿、loading/error、动画表现和按钮禁用展示。
|
||||
|
||||
### 2.2 判定为部分完成
|
||||
|
||||
满足以下任一情况记为部分完成:
|
||||
|
||||
1. 主链已经迁到 `server-rs`,但旧前端分支仍可被正式入口调用。
|
||||
2. 后端已经提供 view/action,但前端仍保留影响业务真相的 normalize、fallback 或 AI context 编排。
|
||||
3. 后端只覆盖部分状态机,前端仍负责另一部分正式状态推进。
|
||||
|
||||
## 3. 逐项结果
|
||||
|
||||
| 原审计项 | 当前状态 | 核验结论 |
|
||||
| --- | --- | --- |
|
||||
| `P0` 运行时开局 `GameState` 装配 | 已完成 | 正式开局状态由 `server-rs/crates/api-server/src/runtime_story/compat/bootstrap.rs` 创建并持久化;前端 `useRpgSessionBootstrap.ts` 只保留选择页占位态和 `beginRpgRuntimeStorySession(...)` 调用。 |
|
||||
| `P0` runtime story 网关客户端快照解析/补丁 | 已完成 | `rpgRuntimeStoryGateway.ts` 不再有 `buildRuntimeSnapshotRequest` / `bridgeServer*Snapshot`;`rpgRuntimeStoryClient.ts` 读取 `/state/:sessionId`,动作提交 `/actions/resolve`,不再上传完整 `snapshot.gameState/currentStory`。 |
|
||||
| `P0` 自动保存整份运行时快照 | 已完成 | `useRpgSessionPersistence.ts` / `rpgSnapshotClient.ts` 保存链路只提交 `sessionId/bottomTab` checkpoint;`runtime_save.rs` 从服务端已有快照刷新 checkpoint,并测试拒绝旧式完整快照上传。 |
|
||||
| `P0` story engine / chapter / world mutation / prompt context 编排 | 已完成 | 后端已有 `project_story_engine_after_action(...)` 与 `build_runtime_story_prompt_context(...)`,`/story/initial`、`/story/continue` 带 `sessionId` 时只从服务端 snapshot 投影 world / character / history / prompt context;`camp_travel_home_scene` 正式点击也已统一进入后端 resolver,不再由前端拼装场景迁移、encounter preview 或 runtimeStats。 |
|
||||
| `P0` 战斗胜负后处理、死亡复活、战斗后章节推进 | 已完成 | `battle_* / inventory_use` 正式点击统一走 `runServerRuntimeChoiceAction(...)` 与后端 `/actions/resolve`;`storyChoiceContinuation.ts` 对战斗 / 逃脱 / 物品动作加硬保护,不再裁决掉落、任务推进、死亡复活或战后 story;旧 `postBattleFlow.ts` 正式状态构造函数已删除。 |
|
||||
| `P1` NPC 交易/送礼价格数量库存校验 | 已完成 | 前端 `npcInteraction.ts` 已改为消费 `runtimeNpcInteraction` view 并只提交 `{ mode, itemId, quantity }`;后端 `npc_actions.rs` / `npc_support.rs` 负责价格、库存、货币、赠礼好感和原子更新。 |
|
||||
| `P1` 背包/装备/锻造可用性与配方视图 | 已完成 | 前端 `inventoryActions.ts` 读取 `loadRpgRuntimeInventoryView(...)`,根据后端 action/view 提交;后端 `view_model.rs` / `forge.rs` 生成背包、装备槽、配方、`canCraft/enabled/reason`。 |
|
||||
| `P1` RPG 创作 profile 生成 legacy AI 回退 | 已完成 | `rpgCreationGenerationClient.ts` 只调用 `/api/runtime/custom-world/profile`,不再动态 `import('../ai')` 或导出 `generateLegacyCustomWorldProfile`。 |
|
||||
| `P1` 创作结果页保存与 Agent session/result preview 真相优先级 | 已完成 | 后端已提供 `GET /api/runtime/custom-world/agent/sessions/:sessionId/result-view`,统一 `targetStage/profileSource/canAutosaveLibrary/canSyncResultProfile`,前端不再直接读取 `legacyResultProfile`;保存前 canonicalize 已迁到 `server-rs`,`normalizeRpgEntryAgentBackedProfile(...)` 现在只透传兼容旧导入。 |
|
||||
| `P1` 角色资产工坊默认 prompt 与缓存合并规则 | 已完成 | 默认 prompt、legacy prompt 过滤、逐动作缓存合并已在 `server-rs/crates/api-server/src/prompt/rpg/role_asset_studio.rs`;前端 modal 只调用 workflow API、保存用户草稿和发起生成/发布。 |
|
||||
|
||||
## 4. 已完成的具体收尾点
|
||||
|
||||
### 4.1 story engine / prompt context 主链与 `camp_travel_home_scene` 已完成后端收口
|
||||
|
||||
当前后端已经处理动作结算后的确定性 story projector:
|
||||
|
||||
1. `server-rs/crates/module-runtime-story-compat/src/story_engine.rs`
|
||||
2. `server-rs/crates/api-server/src/runtime_story/compat.rs`
|
||||
|
||||
本轮已补齐 prompt context 后端 projector:
|
||||
|
||||
1. `server-rs/crates/module-runtime-story-compat/src/prompt_context.rs`
|
||||
2. `server-rs/crates/api-server/src/runtime_story/compat.rs`
|
||||
3. `server-rs/crates/api-server/src/runtime_chat.rs`
|
||||
4. `server-rs/crates/api-server/src/runtime_chat_plain.rs`
|
||||
|
||||
完成状态:
|
||||
|
||||
1. 前端不再决定 `conversationSituation`、`conversationPressure`、NPC 对话上下文、party relationship notes、scene pressure 文本等正式 prompt context。
|
||||
2. 前端 story initial / continue 在有 `runtimeSessionId` 时只提交 `sessionId / clientVersion / choice / lastFunctionId / requestOptions` 等轻量字段。
|
||||
3. 后端从已持久化 runtime snapshot 投影 `worldType / playerCharacter / sceneHostileNpcs / storyHistory / context`,旧 payload 字段只保留兼容。
|
||||
4. 角色私聊、NPC 对话、NPC 单轮聊天、NPC 招募对话已支持 `sessionId`,有 session 时上下文同样以后端 snapshot 为准。
|
||||
5. 前端奖励领取、NPC 聊天闭合与旧 `deferredRuntimeState` 兼容分支不再写入 `storyEngineMemory`,章节、scene act、thread、world mutation 等正式叙事记忆只以后端快照为准。
|
||||
|
||||
本轮验收:
|
||||
|
||||
1. `cargo test -p module-runtime-story-compat prompt_context --manifest-path server-rs\Cargo.toml` 覆盖后端 prompt context projector 对场景、NPC 披露阶段、对话压力和关系态度的投影。
|
||||
2. `cargo test -p shared-contracts runtime_story_ai_request --manifest-path server-rs\Cargo.toml` 覆盖 story AI 请求可只携带 `sessionId` 的共享契约。
|
||||
3. `cargo test -p api-server runtime_story_initial_uses_server_snapshot_prompt_context_when_session_id_present --manifest-path server-rs\Cargo.toml` 覆盖 `/story/initial` 在 session 模式下以后端快照覆盖浏览器传入的 world / character / context。
|
||||
4. `npm run test -- src/services/ai.test.ts src/hooks/rpg-runtime-story/storyRequestCoordinator.test.ts src/hooks/rpg-runtime-story/useRpgRuntimeStoryController.test.tsx` 覆盖前端 story / chat 请求在 session 模式下只发送轻量 payload。
|
||||
5. `npm run test -- src/hooks/rpg-runtime-story/sessionActions.test.ts src/hooks/rpg-runtime-story/choiceActions.test.ts src/hooks/rpg-runtime-story/npcEncounterActions.test.ts` 覆盖前端旧 UI 分支不再回写后端拥有的 `storyEngineMemory`。
|
||||
|
||||
本轮收尾:
|
||||
|
||||
1. `packages/shared/src/contracts/rpgRuntimeStoryAction.ts` 已把 `camp_travel_home_scene` 纳入 `TASK5_RUNTIME_FUNCTION_IDS` / `SERVER_RUNTIME_FUNCTION_IDS`。
|
||||
2. `server-rs/crates/api-server/src/runtime_story/compat.rs` 的 `camp_travel_home_scene` resolver 已承接前端旧分支的正式状态职责:解析目标场景、写入 `currentScenePreset`、清理战斗/遭遇残留、递增 `scenesTraveled`、生成 encounter preview,并让后续故事和持久化继续走后端 snapshot 主链。
|
||||
3. 目标场景解析以后端为准:优先接收兼容 payload 中的 `targetSceneId`,其次使用内置角色主场景映射,自定义世界按角色与 landmark 绑定解析,再回退到当前场景前向连接或首个冒险场景。
|
||||
4. `src/hooks/rpg-runtime-story/choiceActions.ts` 不再调用 `runCampTravelHomeChoice(...)`,`camp_travel_home_scene` 即使命中旧展示 helper,也会按服务端 function id 统一进入 `runServerRuntimeChoiceAction(...)`。
|
||||
5. `src/hooks/rpg-runtime-story/storyChoiceRuntime.ts` 已删除 `runCampTravelHomeChoice(...)`,前端不再保留正式场景迁移构造函数。
|
||||
|
||||
已消除风险:
|
||||
|
||||
1. `camp_travel_home_scene` 不再由浏览器决定目标场景、运行时统计或 encounter preview。
|
||||
2. 正式离营状态已经满足“前端只提交 action,后端返回 hydrated snapshot”的边界。
|
||||
|
||||
本轮验收补充:
|
||||
|
||||
1. `cargo test -p api-server runtime_story_route_boundary_camp_travel_home_scene_is_server_owned --manifest-path server-rs\Cargo.toml` 覆盖点击后 hydrated snapshot 进入角色主场景、生成 encounter preview、递增 `scenesTraveled` 并持久化。
|
||||
2. `cargo test -p api-server runtime_story --manifest-path server-rs\Cargo.toml` 覆盖 runtime story 相关后端回归。
|
||||
3. `npm run test -- src/hooks/rpg-runtime-story/choiceActions.test.ts` 覆盖 `camp_travel_home_scene` 只调用后端 resolver,不触发旧本地旅行分支。
|
||||
4. `npm run test -- src/hooks/rpg-runtime-story/storyChoiceRuntime.test.ts src/services/rpg-runtime/rpgRuntimeStoryClient.test.ts` 覆盖服务端 runtime choice presentation 与 story client 轻量 payload。
|
||||
|
||||
### 4.2 本地战斗 continuation 已收口到后端
|
||||
|
||||
当前后端已经有战斗终局收口:
|
||||
|
||||
1. `server-rs/crates/module-runtime-story-compat/src/post_battle.rs`
|
||||
2. `server-rs/crates/module-runtime-story-compat/src/battle.rs`
|
||||
3. `server-rs/crates/api-server/src/runtime_story/compat.rs`
|
||||
|
||||
本轮已完成前端旧正式分支收口:
|
||||
|
||||
1. `src/hooks/rpg-runtime-story/choiceActions.ts` 不再保留 `shouldResolveCombatChoiceLocally(...)`,所有服务端 function id 都统一进入 `runServerRuntimeChoiceAction(...)`。
|
||||
2. `src/hooks/rpg-runtime-story/storyChoiceContinuation.ts` 对 `battle_* / inventory_use` 以及被分类为 `battle / escape` 的动作加硬保护,误入时只报错回退,不会写掉落、任务、复活或战后 story。
|
||||
3. `src/hooks/rpg-runtime-story/storyChoiceRuntime.ts` 删除本地敌对 NPC 掉落 reward helper,不再调用 `rollHostileNpcLoot(...)` / `addInventoryItems(...)` 构造正式战斗奖励。
|
||||
4. `src/hooks/rpg-runtime-story/postBattleFlow.ts` 与对应测试删除,前端不再保留 `buildPostBattleVictoryState(...)`、`buildPostBattleVictoryStory(...)`、`buildRevivedFirstSceneState(...)`、`buildDeathStory(...)` 作为正式状态构造入口。
|
||||
|
||||
已消除风险:
|
||||
|
||||
1. `battle_* / inventory_use` 不再因 `inBattle`、`currentBattleNpcId`、可见 story option 等状态回落到本地结算。
|
||||
2. 本地 continuation 不再调用敌对 NPC 掉落、背包合并、敌对 NPC 任务推进。
|
||||
3. 前端不再构造死亡复活状态、胜利后 story、deferred options 和章节推进。
|
||||
|
||||
本轮验收:
|
||||
|
||||
1. `choiceActions.test.ts` 覆盖 `battle_use_skill`、`battle_attack_basic` stale option、`inventory_use` 均只调用后端 resolver,不触发 `buildResolvedChoiceState(...)` / `playResolvedChoice(...)`。
|
||||
2. `storyChoiceRuntime.test.ts` 保留服务端 battle presentation 验收,确认胜利 / 失败最终采用服务端 hydrated snapshot。
|
||||
3. 搜索确认 `src/hooks/rpg-runtime-story` 不再包含 `shouldResolveCombatChoiceLocally`、`buildPostBattleVictory*`、`buildRevivedFirstSceneState`、`buildDeathStory`、`buildHostileNpcBattleReward`。
|
||||
|
||||
### 4.3 创作结果页保存前 normalize 已完成后端化
|
||||
|
||||
后端已经负责 Agent result-view:
|
||||
|
||||
1. `server-rs/crates/api-server/src/custom_world.rs`
|
||||
2. `packages/shared/src/contracts/rpgCreationResultView.ts`
|
||||
3. `src/services/rpg-creation/rpgCreationAgentClient.ts`
|
||||
|
||||
重新核查结果:
|
||||
|
||||
1. `src/components/rpg-entry/rpgEntryShared.ts`
|
||||
2. `src/components/rpg-entry/useRpgCreationResultAutosave.ts`
|
||||
3. `server-rs/crates/api-server/src/custom_world.rs`
|
||||
|
||||
当前完成状态:
|
||||
|
||||
1. `normalizeRpgEntryAgentBackedProfile(...)` 现在直接返回原始 `profile`,注释明确保存前 canonicalize 已迁到 `server-rs`。
|
||||
2. `stringifyRpgEntryAgentBackedProfile(...)` 现在只做 `JSON.stringify(profile)`,不再触发前端 normalize。
|
||||
3. `put_custom_world_library_profile(...)` 写入作品库前调用 `canonicalize_custom_world_library_profile_payload(payload.profile)`。
|
||||
4. `serialize_sync_result_profile_action_payload(...)` 会在 Agent `sync_result_profile` action payload 中对 `profile` 执行 `canonicalize_custom_world_profile_before_save(...)`。
|
||||
5. 后端测试 `sync_result_profile_payload_is_canonicalized_on_server` 与 `custom_world_library_profile_payload_is_canonicalized_on_server` 已覆盖保存前 canonicalize。
|
||||
|
||||
本项不再计为未迁移残留。
|
||||
|
||||
## 5. 已完成项的保留边界
|
||||
|
||||
以下前端残留可以保留,不视为未迁移:
|
||||
|
||||
1. `useRpgSessionBootstrap.ts` 的 `createSelectionGameState()`:只服务选择页占位,正式开局不使用它造运行时真相。
|
||||
2. `NpcModals.tsx` 的数量 stepper、价格展示和按钮禁用:只消费后端 view,不重新裁决价格或库存。
|
||||
3. `inventoryActions.ts` 的 `submitInventoryAction(...)`:只读取后端 action 的 `enabled/reason`,不本地计算规则。
|
||||
4. 角色资产工坊 modal 中的 prompt 输入框与缓存保存:这是用户正在编辑的 UI 草稿,默认 prompt 和合并规则已由后端 workflow 输出。
|
||||
5. `playServerBattlePresentation(...)`:只播放临时动画态,最终 `GameState/currentStory` 仍以服务端 snapshot 为准。
|
||||
|
||||
## 6. 后续建议
|
||||
|
||||
本轮核验范围内的应迁项已经收口。后续建议转为质量维护:
|
||||
|
||||
1. 继续把 function catalog / 旧文档里“本地规则结算”的历史描述逐批改成当前后端归属,避免误导后续开发。
|
||||
2. 新增 runtime function 时先补后端 resolver / view / contract,再让前端接展示入口,保持“前端不造正式状态”的边界。
|
||||
3. 对仍保留的前端本地 continuation 只允许处理非服务端 function id;凡进入 `SERVER_RUNTIME_FUNCTION_IDS` 的动作都应有 route 级测试。
|
||||
|
||||
## 7. 一句话结论
|
||||
|
||||
**当前迁移已经完成开局、快照、存档、story engine / prompt context 主链、`camp_travel_home_scene` 离营迁移、NPC、背包/锻造、战斗后处理、profile 生成、创作结果页 normalize 和角色资产 prompt 主链;本核验范围内不再保留前端正式状态裁决残留。**
|
||||
33
docs/design/PLATFORM_BIG_FISH_ENTRY_HIDE_2026-04-28.md
Normal file
33
docs/design/PLATFORM_BIG_FISH_ENTRY_HIDE_2026-04-28.md
Normal file
@@ -0,0 +1,33 @@
|
||||
# 平台入口隐藏大鱼吃小鱼创作入口设计
|
||||
|
||||
日期:`2026-04-28`
|
||||
|
||||
## 1. 变更背景
|
||||
|
||||
平台当前“选择创作类型”弹层同时暴露 RPG、大鱼吃小鱼、拼图三类入口。
|
||||
|
||||
本轮需求只要求在平台里隐藏“大鱼吃小鱼”的创作入口,不要求删除已有玩法实现、运行时路由、作品数据或后台能力。
|
||||
|
||||
## 2. 落地边界
|
||||
|
||||
- 只调整平台入口层展示,不修改大鱼吃小鱼已有前后端链路。
|
||||
- 不删除 `big-fish` 相关路由、服务、作品详情、运行时与数据结构。
|
||||
- 隐藏策略应收敛到统一配置层,避免首页、弹层、后续复用入口出现显示状态漂移。
|
||||
|
||||
## 3. 实现方案
|
||||
|
||||
1. 在 `src/components/platform-entry/platformEntryCreationTypes.ts` 的创作类型元数据中增加 `hidden` 字段。
|
||||
2. 将 `big-fish` 类型标记为 `hidden: true`。
|
||||
3. 平台创作类型弹层渲染前统一过滤 `hidden` 项。
|
||||
|
||||
这样可以保证:
|
||||
|
||||
- 平台用户看不到“大鱼吃小鱼”创作入口。
|
||||
- 若后续重新开放,只需改回配置,不必再拆 UI 逻辑。
|
||||
- 不影响既有直达路由、历史作品数据和开发中的玩法链路。
|
||||
|
||||
## 4. 验收点
|
||||
|
||||
- 平台“选择创作类型”弹层不再显示“大鱼吃小鱼”卡片。
|
||||
- RPG、拼图、“敬请期待”类卡片顺序与交互保持稳定。
|
||||
- 代码层不引入对 Big Fish 运行时或结果页的额外耦合修改。
|
||||
@@ -11,6 +11,7 @@
|
||||
- [CUSTOM_WORLD_SELF_OWNED_SETTING_LAYER_OPTIMIZATION_2026-04-08.md](./CUSTOM_WORLD_SELF_OWNED_SETTING_LAYER_OPTIMIZATION_2026-04-08.md):把模板依赖逐步迁成自定义世界自有设定层,并保证不破坏当前生成流程的优化方案。
|
||||
- [MOBILE_CREATION_NEW_WORK_COMPACT_LAYOUT_2026-04-24.md](./MOBILE_CREATION_NEW_WORK_COMPACT_LAYOUT_2026-04-24.md):移动端创作页新建作品模块最多占用首屏约 1/3 高度的紧凑布局设计。
|
||||
- [PLATFORM_CATEGORY_AND_CREATE_TAB_DESIGN_2026-04-24.md](./PLATFORM_CATEGORY_AND_CREATE_TAB_DESIGN_2026-04-24.md):平台入口新增分类 Tab、登录态导航裁剪与创作 Tab 视觉强化设计。
|
||||
- [PLATFORM_BIG_FISH_ENTRY_HIDE_2026-04-28.md](./PLATFORM_BIG_FISH_ENTRY_HIDE_2026-04-28.md):平台入口暂时隐藏大鱼吃小鱼创作卡片,但保留现有玩法链路。
|
||||
- [UNIFIED_MODAL_WINDOW_DESIGN_2026-04-25.md](./UNIFIED_MODAL_WINDOW_DESIGN_2026-04-25.md):统一平台风与 RPG 像素风模态窗口外壳、交互边界和迁移顺序。
|
||||
- [AI_NATIVE_RUNTIME_ITEM_SYSTEM_REDESIGN_2026-04-02.md](./AI_NATIVE_RUNTIME_ITEM_SYSTEM_REDESIGN_2026-04-02.md):运行时物品生成系统重设计。
|
||||
- [LEVEL_PROGRESS_AND_CHAPTER_NPC_AUTO_SCALING_DESIGN_2026-04-20.md](./LEVEL_PROGRESS_AND_CHAPTER_NPC_AUTO_SCALING_DESIGN_2026-04-20.md):等级成长、章节经验节奏与 NPC 自动定级设计。
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
# 大鱼吃小鱼草稿生成链路修复 2026-04-28
|
||||
|
||||
## 背景
|
||||
|
||||
大鱼吃小鱼玩法的结果页已经具备等级卡、主图工坊、动作工坊和背景工坊,但当前 `big_fish_compile_draft` 只是把锚点交给 `module-big-fish` 的 `compile_default_draft(...)` 做静态模板拼装。
|
||||
|
||||
这会导致两个直接问题:
|
||||
|
||||
1. 草稿编译虽然能成功进入结果页,但每一级实体只会拿到非常概括的模板文本,无法真正产出“实体名称、文字描述、形象描述、待机动作描述、移动动作描述”这一组首稿。
|
||||
2. 主图和动作工坊默认提示词没有绑定到一份足够细的草稿真相源,动作面板只能看到合并后的 `motionPromptSeed`,会表现成“草稿生成一带而过,所有内容都没有正常生成”。
|
||||
|
||||
## 本次修复口径
|
||||
|
||||
### 1. 每级等级蓝图必须补齐的文本字段
|
||||
|
||||
大鱼吃小鱼每一级 `level blueprint` 在保留原有字段的同时,新增并持久化下面这些文本真相:
|
||||
|
||||
1. `textDescription`
|
||||
- 当前等级实体的正文文字描述。
|
||||
- 用于结果页等级卡和后续重生成时的人类可读设定底稿。
|
||||
2. `visualDescription`
|
||||
- 当前等级实体的形象描述。
|
||||
- 主图工坊默认输入内容直接取这份字段。
|
||||
3. `idleMotionDescription`
|
||||
- 当前等级待机动作描述。
|
||||
- `idle_float` 动作工坊默认输入内容直接取这份字段。
|
||||
4. `moveMotionDescription`
|
||||
- 当前等级移动动作描述。
|
||||
- `move_swim` 动作工坊默认输入内容直接取这份字段。
|
||||
|
||||
### 2. 默认提示词流转规则
|
||||
|
||||
草稿生成、结果页工坊和正式资产生成统一按下面口径流转:
|
||||
|
||||
1. 草稿编译阶段先产出上述结构化文本字段。
|
||||
2. 主图工坊默认文案:
|
||||
- 优先显示 `visualDescription`
|
||||
- `visualPromptSeed` 作为主图正式生图提示词的冻结快照,可由 `visualDescription` 组合生成
|
||||
3. 动作工坊默认文案:
|
||||
- `idle_float` 优先显示 `idleMotionDescription`
|
||||
- `move_swim` 优先显示 `moveMotionDescription`
|
||||
- `motionPromptSeed` 继续保留为动作方向总提示词摘要,但具体动作正式生图必须显式拼入动作位对应描述
|
||||
4. 草稿阶段生成的正式主图、动作图和后续重生成,都只能读取同一份 `draft.levels[*]` 真相,前端不得本地拼接新的设定文案。
|
||||
|
||||
### 3. 编译策略
|
||||
|
||||
`big_fish_compile_draft` 需要升级为:
|
||||
|
||||
1. `api-server` 先调用 LLM 做结构化草稿编译。
|
||||
2. 若 LLM 成功,则把完整 `draft_json` 写回 SpacetimeDB。
|
||||
3. 若 LLM 不可用、返回非法 JSON 或字段缺失,则退回 `compile_default_draft(...)` 的 deterministic fallback。
|
||||
|
||||
这样可以同时保证:
|
||||
|
||||
1. 正常环境下草稿不再只是模板壳。
|
||||
2. 模型偶发失败时不会打断结果页主链。
|
||||
3. SpacetimeDB reducer 不承担外部网络调用,仍然符合后端边界。
|
||||
|
||||
## 落地范围
|
||||
|
||||
本次修复涉及:
|
||||
|
||||
1. `server-rs/crates/module-big-fish`
|
||||
2. `server-rs/crates/spacetime-module`
|
||||
3. `server-rs/crates/spacetime-client`
|
||||
4. `server-rs/crates/shared-contracts`
|
||||
5. `server-rs/crates/api-server`
|
||||
6. `packages/shared/src/contracts/bigFish.ts`
|
||||
7. `src/components/big-fish-result/BigFishResultView.tsx`
|
||||
|
||||
## 验收口径
|
||||
|
||||
修复后需要满足下面这些观察结果:
|
||||
|
||||
1. 点击“生成草稿”后,`draft.levels[*]` 不再只有空泛模板,而是每级都带名称、文字描述、形象描述、待机动作描述、移动动作描述。
|
||||
2. 打开主图工坊时,默认文本来自当前等级的 `visualDescription`。
|
||||
3. 打开待机动作工坊时,默认文本来自当前等级的 `idleMotionDescription`。
|
||||
4. 打开移动动作工坊时,默认文本来自当前等级的 `moveMotionDescription`。
|
||||
5. 资产槽位 `promptSnapshot` 与对应动作位 / 主图位的默认提示词一致。
|
||||
6. LLM 不可用时仍然能生成一版可用 fallback 草稿,而不是直接报错或写入空草稿。
|
||||
@@ -0,0 +1,27 @@
|
||||
# 拼图本地运行态通关排行榜误请求修复记录
|
||||
|
||||
## 问题现象
|
||||
|
||||
拼图关卡完成后,右下角会弹出错误提示,内容表现为拼图 `run` 不存在。
|
||||
|
||||
## 根因
|
||||
|
||||
当前拼图玩法仍有一条前端本地兜底链路:
|
||||
|
||||
1. 进入拼图测试或公开作品体验时,前端先创建 `local-puzzle-run-*` 形式的本地运行态。
|
||||
2. 这类 `run` 只存在于前端内存,不存在后端持久化记录。
|
||||
3. 通关副作用里却统一调用了后端 `submitPuzzleLeaderboard(runId, payload)`。
|
||||
4. 后端拿到本地 `runId` 后无法找到真实记录,于是返回“run 不存在”,最终在运行时右下角暴露成错误提示。
|
||||
|
||||
## 修复口径
|
||||
|
||||
本次不改后端接口,也不把本地兜底 run 强行持久化到后端,而是先把边界收口到前端:
|
||||
|
||||
1. 显式识别 `local-puzzle-run-*` 这类本地 run。
|
||||
2. 本地 run 通关后不再请求后端排行榜接口。
|
||||
3. 直接在前端本地生成只包含当前玩家成绩的排行榜数据,保证结算弹窗仍可展示成绩。
|
||||
4. 真实后端 run 仍继续走正式排行榜提交流程,不影响后续 Rust / SpacetimeDB 版本的统一收口。
|
||||
|
||||
## 经验结论
|
||||
|
||||
只要某条玩法链路还保留“本地 run / 本地快照”兜底,就不能在通关、副作用、排行榜、下一关等后置动作里默认把它当成后端真 run 使用。必须先做运行态来源分流,再决定是否调用依赖真实 runId 的接口。
|
||||
@@ -32,3 +32,5 @@
|
||||
- [AGENT_EMPTY_SESSION_DRAFT_VISIBILITY_2026-04-26.md](./AGENT_EMPTY_SESSION_DRAFT_VISIBILITY_2026-04-26.md):记录 Agent 空会话不应进入作品草稿列表的后端判定规则。
|
||||
- [BIG_FISH_PUBLISH_FEEDBACK_FIX_2026-04-26.md](./BIG_FISH_PUBLISH_FEEDBACK_FIX_2026-04-26.md):记录大鱼吃小鱼发布成功后结果页反馈与作品列表刷新的修复口径。
|
||||
- [BIG_FISH_WORKS_JSON_COMPAT_FIX_2026-04-28.md](./BIG_FISH_WORKS_JSON_COMPAT_FIX_2026-04-28.md):记录大鱼作品列表 `items_json` 字段升级后的向后兼容修复口径,避免旧 JSON 直接打崩 works 接口。
|
||||
- [BIG_FISH_DRAFT_GENERATION_CHAIN_FIX_2026-04-28.md](./BIG_FISH_DRAFT_GENERATION_CHAIN_FIX_2026-04-28.md):记录大鱼吃小鱼草稿生成从结构化内容产出到主图/动作默认提示词回填的修复口径。
|
||||
- [PUZZLE_LOCAL_RUN_LEADERBOARD_FIX_2026-04-28.md](./PUZZLE_LOCAL_RUN_LEADERBOARD_FIX_2026-04-28.md):记录拼图本地 run 通关后误请求后端排行榜、导致“run 不存在”报错的边界修复口径。
|
||||
|
||||
@@ -74,7 +74,7 @@
|
||||
新系统必须满足:
|
||||
|
||||
1. 可解释:玩家能理解“为什么这个角色擅长这个”“为什么这个 NPC 会喜欢这种行为”“为什么这个怪物在这个世界里是这种威胁”。
|
||||
2. 可生成:自定义世界可以稳定生成新属性名称与定义。
|
||||
2. 可生成:自定义世界可以稳定生成新属性名称。
|
||||
3. 可校验:AI 输出不能直接裸写进运行时,必须经过本地验证。
|
||||
4. 可复用:同一套属性 schema 能进入角色、怪物、技能、Build、物品、对话 prompt。
|
||||
5. 可迁移:能从当前四维属性 / 标签 / 怪物 preset 平滑过渡。
|
||||
@@ -165,12 +165,6 @@ type WorldAttributeSchema = {
|
||||
slots: Array<{
|
||||
slotId: string;
|
||||
name: string;
|
||||
definition: string;
|
||||
positiveSignals: string[];
|
||||
negativeSignals: string[];
|
||||
combatUseText: string;
|
||||
socialUseText: string;
|
||||
explorationUseText: string;
|
||||
}>;
|
||||
};
|
||||
```
|
||||
@@ -178,12 +172,9 @@ type WorldAttributeSchema = {
|
||||
### 关键原则
|
||||
|
||||
1. `slotId` 是稳定技术标识,例如 `axis_a` ~ `axis_f`。
|
||||
2. `name` 是世界内真实显示名称,例如武侠里可能不是“力量”,而是“骨势”“身法”。
|
||||
3. `definition` 必须描述角色本质倾向,而不是派生战斗数值。
|
||||
4. 每个属性都必须能解释:
|
||||
- 战斗中的体现
|
||||
- 对话中的体现
|
||||
- 探索中的体现
|
||||
2. 本世界六维名称在创作、提示词输出、解析后保存的数据中只保留 `name`;其他定义、信号和用途说明字段不再进入 schema。
|
||||
3. `name` 是世界内真实显示名称,例如武侠里可能不是“力量”,而是“骨势”“身法”。
|
||||
4. 六个名称需要能支撑战斗、对话、探索的叙事理解,但这些说明由下游运行时按场景生成,不写入 schema。
|
||||
|
||||
### 禁止项
|
||||
|
||||
@@ -247,16 +238,9 @@ type AttributeSchemaGenerationInput = {
|
||||
|
||||
```ts
|
||||
type AttributeSchemaGenerationOutput = {
|
||||
schemaName: string;
|
||||
slots: Array<{
|
||||
slotId: string;
|
||||
name: string;
|
||||
definition: string;
|
||||
positiveSignals: string[];
|
||||
negativeSignals: string[];
|
||||
combatUseText: string;
|
||||
socialUseText: string;
|
||||
explorationUseText: string;
|
||||
}>;
|
||||
};
|
||||
```
|
||||
@@ -267,13 +251,9 @@ AI 输出后必须通过本地校验:
|
||||
|
||||
1. 属性数量必须等于 6。
|
||||
2. `name` 需唯一,长度建议 `2~4` 个中文字符。
|
||||
3. `definition` 不得出现“提升攻击力 / 提升防御力 / 提升生命值”这类派生描述。
|
||||
4. 每个属性都必须同时具备:
|
||||
- 一个战斗说明
|
||||
- 一个社交说明
|
||||
- 一个探索说明
|
||||
5. 任意两条属性定义关键词重叠度不能过高。
|
||||
6. 若校验失败:
|
||||
3. `name` 不得出现“生命 / 法力 / 护甲 / 攻击 / 防御 / 力量 / 敏捷 / 智力 / 精神”这类旧四维或派生资源词。
|
||||
4. 任意两个属性名称不能重复,也不能只做同义换皮。
|
||||
5. 若校验失败:
|
||||
- 预设世界回退到固化 schema
|
||||
- 自定义世界回退到模板世界 schema,并记录失败日志
|
||||
|
||||
@@ -283,25 +263,25 @@ AI 输出后必须通过本地校验:
|
||||
|
||||
### 武侠世界示例
|
||||
|
||||
| 属性名 | 定义 |
|
||||
| 槽位 | 属性名 |
|
||||
| --- | --- |
|
||||
| 骨势 | 扛压、顶冲、硬吃风险也不退的势头 |
|
||||
| 身法 | 腾挪、抢位、换线、把握出手节奏的能力 |
|
||||
| 眼脉 | 看破破绽、拆招、识局、看穿人心的能力 |
|
||||
| 心焰 | 决断、压迫、胆气、在局面中立住自身意志的能力 |
|
||||
| 尘缘 | 与人事、情面、承诺、牵引关系打交道的能力 |
|
||||
| 玄息 | 调息、稳态、久战、把自身维持在可用状态的能力 |
|
||||
| axis_a | 骨势 |
|
||||
| axis_b | 身法 |
|
||||
| axis_c | 眼脉 |
|
||||
| axis_d | 心焰 |
|
||||
| axis_e | 尘缘 |
|
||||
| axis_f | 玄息 |
|
||||
|
||||
### 仙侠世界示例
|
||||
|
||||
| 属性名 | 定义 |
|
||||
| 槽位 | 属性名 |
|
||||
| --- | --- |
|
||||
| 道骨 | 承载道压与高强度冲击的底子 |
|
||||
| 灵行 | 位移、御空、转场、抢占天时地利的能力 |
|
||||
| 识海 | 解析禁制、洞察因果、识破虚实的能力 |
|
||||
| 心契 | 与他者、器物、灵兽、誓约建立共鸣的能力 |
|
||||
| 劫纹 | 在高危变化中强行推进、改写局势的能力 |
|
||||
| 玄息 | 循环灵息、稳住心神、让自身持续在线的能力 |
|
||||
| axis_a | 道骨 |
|
||||
| axis_b | 灵行 |
|
||||
| axis_c | 识海 |
|
||||
| axis_d | 心契 |
|
||||
| axis_e | 劫纹 |
|
||||
| axis_f | 玄息 |
|
||||
|
||||
关键点:
|
||||
|
||||
@@ -692,10 +672,8 @@ AI 不可以直接生成:
|
||||
|
||||
```ts
|
||||
type PromptAttributeSummary = {
|
||||
schemaName: string;
|
||||
slots: Array<{
|
||||
name: string;
|
||||
definition: string;
|
||||
}>;
|
||||
actorTopAttributes: string[];
|
||||
targetTopAttributes?: string[];
|
||||
@@ -726,12 +704,6 @@ export type AttributeVector = Record<string, number>;
|
||||
export interface WorldAttributeSlot {
|
||||
slotId: string;
|
||||
name: string;
|
||||
definition: string;
|
||||
positiveSignals: string[];
|
||||
negativeSignals: string[];
|
||||
combatUseText: string;
|
||||
socialUseText: string;
|
||||
explorationUseText: string;
|
||||
}
|
||||
|
||||
export interface WorldAttributeSchema {
|
||||
@@ -1017,8 +989,8 @@ behaviorVectors: Array<{
|
||||
|
||||
对策:
|
||||
|
||||
1. 增加本地定义重叠校验
|
||||
2. 强制每个属性都写战斗 / 社交 / 探索三种说明
|
||||
1. 增加本地名称重复、旧词和同义换皮校验
|
||||
2. 提示词要求六个名称覆盖不同叙事气质,避免全部落在同一种行动倾向上
|
||||
3. 首版预设世界采用固化 schema,不在运行时漂移
|
||||
|
||||
### 风险 2:过度抽象,策划难以配置
|
||||
@@ -1029,8 +1001,8 @@ behaviorVectors: Array<{
|
||||
|
||||
对策:
|
||||
|
||||
1. 每个属性附带定义、正反信号、示例行为
|
||||
2. 编辑器永远显示 `name + definition`
|
||||
1. 编辑器只显示并编辑六个属性名称
|
||||
2. 解释文本由技能 / 标签 / 物品等下游按具体场景生成
|
||||
3. 所有技能 / 标签 / 物品的属性向量都显示人类可读解释
|
||||
|
||||
### 风险 3:旧系统迁移期间双轨并存混乱
|
||||
|
||||
@@ -112,7 +112,7 @@
|
||||
|
||||
- `npc_help`
|
||||
脚本:`src/data/functionCatalog/npc/npcHelp.ts`
|
||||
说明:向 NPC 寻求补给、回复或支援的 function。奖励由本地规则稳定计算,避免帮助收益被模型临场漂移。
|
||||
说明:向 NPC 寻求补给、回复或支援的 function。正式奖励、资源变化与 one-shot 状态由后端 runtime action resolver 稳定计算,避免帮助收益被模型临场漂移。
|
||||
|
||||
- `npc_chat`
|
||||
脚本:`src/data/functionCatalog/npc/npcChat.ts`
|
||||
@@ -140,7 +140,7 @@
|
||||
|
||||
- `npc_quest_accept`
|
||||
脚本:`src/data/functionCatalog/npc/npcQuestAccept.ts`
|
||||
说明:正式接下 NPC 委托的 function。它把本地生成的任务写入 quest log,并让剧情承接“玩家已经答应处理这件事”。
|
||||
说明:正式接下 NPC 委托的 function。它把后端 pending quest offer 写入 quest log,并让剧情承接“玩家已经答应处理这件事”。
|
||||
|
||||
- `npc_quest_turn_in`
|
||||
脚本:`src/data/functionCatalog/npc/npcQuestTurnIn.ts`
|
||||
@@ -172,7 +172,7 @@
|
||||
|
||||
- `camp_travel_home_scene`
|
||||
脚本:`src/data/functionCatalog/flow/campTravelHomeScene.ts`
|
||||
说明:营地开场或同伴交流结束后,正式前往角色主场景的流程项。它负责定制化场景迁移和状态清理,不属于普通 state function。
|
||||
说明:营地开场或同伴交流结束后,正式前往角色主场景的流程项。前端脚本只保留按钮与视觉元信息,目标场景、状态清理、encounter preview、`scenesTraveled` 与快照持久化由后端 runtime action resolver 负责。
|
||||
|
||||
- `story_opening_camp_dialogue`
|
||||
脚本:`src/data/functionCatalog/flow/storyOpeningCampDialogue.ts`
|
||||
@@ -182,7 +182,7 @@
|
||||
|
||||
- `inventory_use`
|
||||
脚本:`src/data/functionCatalog/panel/inventoryUse.ts`
|
||||
说明:在背包面板里使用药品、灵力物或 build buff 物品的 function。它先由本地规则结算资源变化,再把结果记入故事历史。
|
||||
说明:在背包面板里使用药品、灵力物或 build buff 物品的 function。前端只提交物品动作,资源变化、数量扣减、build buff 与故事历史由后端 resolver 写入。
|
||||
|
||||
- `equipment_equip`
|
||||
脚本:`src/data/functionCatalog/panel/equipmentEquip.ts`
|
||||
@@ -190,7 +190,7 @@
|
||||
|
||||
- `equipment_unequip`
|
||||
脚本:`src/data/functionCatalog/panel/equipmentUnequip.ts`
|
||||
说明:从装备槽位卸下物品的 function。它确保卸装结果由本地规则严格处理,不会破坏背包数量和 loadout 一致性。
|
||||
说明:从装备槽位卸下物品的 function。后端 resolver 负责卸装结果、背包数量和 loadout 一致性。
|
||||
|
||||
- `forge_craft`
|
||||
脚本:`src/data/functionCatalog/panel/forgeCraft.ts`
|
||||
@@ -198,7 +198,7 @@
|
||||
|
||||
- `forge_dismantle`
|
||||
脚本:`src/data/functionCatalog/panel/forgeDismantle.ts`
|
||||
说明:在锻造面板中拆解物品回收材料的 function。拆解产出由本地锻造规则控制,避免与物品设计脱节。
|
||||
说明:在锻造面板中拆解物品回收材料的 function。拆解产出由后端锻造 resolver 控制,避免与物品设计脱节。
|
||||
|
||||
- `forge_reforge`
|
||||
脚本:`src/data/functionCatalog/panel/forgeReforge.ts`
|
||||
|
||||
@@ -4,11 +4,13 @@
|
||||
|
||||
- [BUSINESS_PROMPT_INVENTORY_2026-04-19.md](./BUSINESS_PROMPT_INVENTORY_2026-04-19.md):业务中现存提示词的总清单,覆盖后端主链、前端遗留、自定义世界、角色形象生成、场景背景生成与工具链 prompt。
|
||||
- [FUNCTION_SCRIPT_CATALOG_2026-04-04.md](./FUNCTION_SCRIPT_CATALOG_2026-04-04.md):Function 独立脚本目录与分类速查。
|
||||
- [RPG_CREATION_AND_RUNTIME_SCRIPT_RESPONSIBILITY_MAP_2026-04-28.md](./RPG_CREATION_AND_RUNTIME_SCRIPT_RESPONSIBILITY_MAP_2026-04-28.md):RPG 创作功能脚本与 RPG 运行时脚本的职责地图,覆盖前端入口、编排、表现、client、`server-rs` 与 SpacetimeDB 侧落点。
|
||||
- [TASK_GENERATION_TRACE_2026-04-08.md](./TASK_GENERATION_TRACE_2026-04-08.md):任务描述、达成条件与奖励生成链路梳理。
|
||||
- [CUSTOM_WORLD_TEMPLATE_DEPENDENCY_INVENTORY_2026-04-08.md](./CUSTOM_WORLD_TEMPLATE_DEPENDENCY_INVENTORY_2026-04-08.md):自定义世界当前仍依赖哪些模板世界设定的清单。
|
||||
|
||||
## 使用建议
|
||||
|
||||
- 需要快速定位 Function 脚本,而不是阅读长篇方案时,优先看这里。
|
||||
- 需要快速判断“RPG 创作链和 RPG 运行时链分别该改哪些脚本”时,优先看 RPG 脚本职责地图。
|
||||
- 需要判断“武侠 / 仙侠模板层”哪些还能删、哪些不能删时,优先看自定义世界模板依赖清单。
|
||||
- 如果要评估 Function 分层是否合理,再配合 `docs/audits/FUNCTION_DESIGN_AUDIT_2026-04-03.md` 一起看。
|
||||
|
||||
@@ -0,0 +1,873 @@
|
||||
# RPG 创作功能与运行时脚本职责地图(2026-04-28)
|
||||
|
||||
## 1. 文档目的
|
||||
|
||||
这份文档只做一件事:
|
||||
|
||||
**把当前仓库里 RPG 创作功能相关脚本,以及 RPG 运行时游戏相关脚本,按真实职责整理成一份可检索的地图。**
|
||||
|
||||
这里的“脚本职责”强调的是:
|
||||
|
||||
1. 哪个脚本是入口。
|
||||
2. 哪个脚本负责状态编排。
|
||||
3. 哪个脚本只负责表现层。
|
||||
4. 哪个脚本负责请求后端。
|
||||
5. 哪个脚本在 `server-rs` 中承接正式业务真相。
|
||||
6. 哪些脚本仍处于兼容桥接层,不应继续扩张。
|
||||
|
||||
---
|
||||
|
||||
## 2. 当前口径
|
||||
|
||||
### 2.1 唯一后端口径
|
||||
|
||||
按当前工程基线,正式后端以 `server-rs` 为准:
|
||||
|
||||
1. HTTP / SSE 门面:`server-rs/crates/api-server/src/`
|
||||
2. 共享契约:`server-rs/crates/shared-contracts/src/`
|
||||
3. SpacetimeDB 模块与领域表/过程:`server-rs/crates/module-*/src/`
|
||||
4. 客户端绑定与调用封装:`server-rs/crates/spacetime-client/src/`
|
||||
|
||||
### 2.2 当前前端口径
|
||||
|
||||
前端已基本按 RPG 域拆成三条主链:
|
||||
|
||||
```text
|
||||
平台入口与创作入口
|
||||
-> RPG 创作链
|
||||
-> RPG 运行时链
|
||||
```
|
||||
|
||||
但当前仍存在一些兼容桥接脚本,例如:
|
||||
|
||||
1. `src/components/rpg-entry/RpgEntryFlowShell.tsx`
|
||||
2. `src/services/rpg-runtime/rpgRuntimeChatClient.ts`
|
||||
3. `server-rs/crates/api-server/src/runtime_story.rs`
|
||||
|
||||
这些文件的职责主要是**兼容旧入口**,不是长期承载复杂逻辑的主战场。
|
||||
|
||||
---
|
||||
|
||||
## 3. 全局总图
|
||||
|
||||
```text
|
||||
平台入口层
|
||||
-> 创作入口 / 运行时入口分流
|
||||
|
||||
RPG 创作链:
|
||||
平台入口
|
||||
-> 创作工作台 / 共创会话
|
||||
-> 结果页
|
||||
-> 实体编辑器 / 角色资产工坊
|
||||
-> 创作域 client
|
||||
-> server-rs custom_world / custom_world_ai / prompt/rpg
|
||||
-> SpacetimeDB custom-world 相关表与过程
|
||||
|
||||
RPG 运行时链:
|
||||
平台入口 / 世界详情 / 继续游戏
|
||||
-> session bootstrap / persistence
|
||||
-> runtime shell / panel router / adventure panel
|
||||
-> runtime story hooks / gateway / client
|
||||
-> functionCatalog / prompt/rpg
|
||||
-> server-rs runtime_story / runtime_chat / runtime_save / story_battles / story_sessions
|
||||
-> shared-contracts + module-runtime + module-story + module-runtime-story-compat
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. RPG 创作功能脚本职责
|
||||
|
||||
## 4.1 创作入口与平台分流
|
||||
|
||||
### `src/components/platform-entry/PlatformEntryFlowShell.tsx`
|
||||
|
||||
职责:
|
||||
|
||||
1. 平台入口通用壳层。
|
||||
2. 统一分流 RPG、Big Fish、Puzzle 等不同玩法入口。
|
||||
3. 作为多玩法并列入口的稳定门面。
|
||||
|
||||
说明:
|
||||
|
||||
1. 这是当前真正的平台入口壳层。
|
||||
2. RPG 创作和 RPG 运行时入口都先经过这里。
|
||||
|
||||
### `src/components/platform-entry/PlatformEntryFlowShellImpl.tsx`
|
||||
|
||||
职责:
|
||||
|
||||
1. 平台首页、详情页、创作类型选择、RPG 创作恢复、结果页进入等总编排。
|
||||
2. 协调 RPG 创作 Agent、Big Fish、Puzzle 的进入和返回。
|
||||
3. 连接平台级导航、作品库、公开广场、详情页和创作工作流。
|
||||
|
||||
说明:
|
||||
|
||||
1. 这是当前“平台级大编排器”。
|
||||
2. 它不是纯 RPG 文件,但当前 RPG 创作入口真实在这里收口。
|
||||
|
||||
### `src/components/custom-world-home/CustomWorldCreationHub.tsx`
|
||||
|
||||
职责:
|
||||
|
||||
1. 展示 RPG 创作工作台。
|
||||
2. 负责草稿、已发布作品、跨玩法作品卡片的列表层表现。
|
||||
3. 负责“创建新作品”“打开草稿”“进入已发布作品”“体验作品”“删除作品”的入口分发。
|
||||
|
||||
说明:
|
||||
|
||||
1. 这是 RPG 创作库的主表现层。
|
||||
2. 它不负责正式生成逻辑,只负责把用户动作抛给上层控制器。
|
||||
|
||||
---
|
||||
|
||||
## 4.2 RPG 共创会话链
|
||||
|
||||
### `src/components/custom-world-agent/CustomWorldAgentWorkspace.tsx`
|
||||
|
||||
职责:
|
||||
|
||||
1. 把 RPG 世界共创会话映射成通用 `CreationAgentWorkspace` 可消费的视图模型。
|
||||
2. 组织世界承诺、玩家幻想、主题边界、核心冲突等锚点展示。
|
||||
3. 提供“发送消息”“快捷动作”“生成底稿”等交互入口。
|
||||
|
||||
说明:
|
||||
|
||||
1. 这是 RPG 创作 Agent 会话的前端展示壳层。
|
||||
2. 它负责把领域数据翻译成工作台视图,不负责真正的网络请求。
|
||||
|
||||
### `src/services/rpg-creation/rpgCreationAgentClient.ts`
|
||||
|
||||
职责:
|
||||
|
||||
1. 创建 RPG 创作会话。
|
||||
2. 读取会话快照。
|
||||
3. 发送消息、流式发送消息。
|
||||
4. 执行创作动作。
|
||||
5. 读取操作进度和草稿卡详情。
|
||||
|
||||
说明:
|
||||
|
||||
1. 这是 RPG 创作 Agent 的主 client 入口。
|
||||
2. 前端所有“会话级创作行为”最终都应该走这里,而不是继续回流到通用 `aiService.ts`。
|
||||
|
||||
### `server-rs/crates/api-server/src/custom_world.rs`
|
||||
|
||||
职责:
|
||||
|
||||
1. 承接 RPG 世界库、世界详情、作品发布、取消发布、作品删除等 HTTP 接口。
|
||||
2. 承接创作 Agent session、message、action、operation、card detail 等接口。
|
||||
3. 协调底稿写回、资产生成、结果预览、发布门禁等后端业务。
|
||||
|
||||
说明:
|
||||
|
||||
1. 这是当前 RPG 创作后端最大入口文件之一。
|
||||
2. 前端创作会话和作品库的大部分正式请求都在这里收口。
|
||||
|
||||
### `server-rs/crates/api-server/src/custom_world_agent_turn.rs`
|
||||
|
||||
职责:
|
||||
|
||||
1. 承接 RPG 共创聊天单轮执行。
|
||||
2. 负责把用户消息、会话状态、锚点内容和输出结构组织成一轮 Agent turn。
|
||||
3. 负责回写消息、操作、阶段进度和结果。
|
||||
|
||||
### `server-rs/crates/api-server/src/custom_world_foundation_draft.rs`
|
||||
|
||||
职责:
|
||||
|
||||
1. 负责 RPG 世界底稿生成链。
|
||||
2. 组织底稿结构、草稿写回、阶段进度推进和失败恢复。
|
||||
|
||||
### `server-rs/crates/api-server/src/custom_world_ai.rs`
|
||||
|
||||
职责:
|
||||
|
||||
1. 提供 RPG 创作过程中的场景图、场景 NPC、角色、地标等 AI 衍生生成接口。
|
||||
2. 把前端“补实体”“补图”“补角色”动作收口到后端。
|
||||
|
||||
---
|
||||
|
||||
## 4.3 RPG 创作结果页与编辑链
|
||||
|
||||
### `src/components/rpg-creation-result/RpgCreationResultView.tsx`
|
||||
|
||||
职责:
|
||||
|
||||
1. RPG 创作结果页 façade。
|
||||
2. 只桥接到真实实现,不承载复杂逻辑。
|
||||
|
||||
说明:
|
||||
|
||||
1. 这是稳定入口。
|
||||
2. 真正逻辑在 `RpgCreationResultViewImpl.tsx` 及其配套 hooks 内。
|
||||
|
||||
### `src/components/rpg-creation-result/useRpgCreationResultActions.ts`
|
||||
|
||||
职责:
|
||||
|
||||
1. 管理结果页上的实体新增、删除、局部重生成、最近新增实体高亮等交互。
|
||||
2. 调用 `rpgCreationAssetClient` 生成 playable / story / landmark。
|
||||
3. 协调结果页与编辑器、资产工坊之间的动作状态。
|
||||
|
||||
说明:
|
||||
|
||||
1. 这是结果页最核心的动作编排脚本之一。
|
||||
2. “结果页做什么”主要在这里定义,不在纯展示组件里定义。
|
||||
|
||||
### `src/components/rpg-creation-editor/RpgCreationEntityEditorModal.tsx`
|
||||
|
||||
职责:
|
||||
|
||||
1. RPG 实体编辑器 modal 的稳定入口。
|
||||
2. 把复杂表单实现桥接到 `RpgCreationEntityEditorModalImpl.tsx`。
|
||||
|
||||
说明:
|
||||
|
||||
1. 这是编辑器 façade。
|
||||
2. 后续编辑器拆 section 时应继续改 impl,不要把复杂逻辑塞回 façade。
|
||||
|
||||
### `src/components/rpg-creation-asset-studio/RpgCreationRoleAssetStudioModal.tsx`
|
||||
|
||||
职责:
|
||||
|
||||
1. 角色资产工坊 modal 的稳定入口。
|
||||
2. 把角色形象、动作、候选图、动画等细节桥接到真实实现。
|
||||
|
||||
### `src/components/rpg-creation-result/RpgCreationAssetDebugPanel.tsx`
|
||||
|
||||
职责:
|
||||
|
||||
1. 用于查看创作资产链路状态、调试结果页资产问题。
|
||||
2. 服务于结果页调试与排查,而不是普通玩家主流程。
|
||||
|
||||
---
|
||||
|
||||
## 4.4 RPG 创作域 client 分层
|
||||
|
||||
### `src/services/rpg-creation/rpgCreationGenerationClient.ts`
|
||||
|
||||
职责:
|
||||
|
||||
1. 负责“生成 RPG 世界底稿 / profile”。
|
||||
2. 浏览器、SSR、Vitest node 环境统一请求 `/api/runtime/custom-world/profile`。
|
||||
3. 不再动态导入 `src/services/ai.ts`,测试离线能力只能 mock API client。
|
||||
|
||||
说明:
|
||||
|
||||
1. 这是“世界底稿生成”的入口 client。
|
||||
2. profile 生成 prompt 与 LLM 编排已经收口到 `server-rs`,前端只保留请求 contract。
|
||||
|
||||
### `src/services/rpg-creation/rpgCreationAssetClient.ts`
|
||||
|
||||
职责:
|
||||
|
||||
1. 负责场景图、角色、地标、场景 NPC、封面图等资产请求。
|
||||
2. 负责历史资产列表读取。
|
||||
3. 给结果页和资产工坊提供统一资产接口。
|
||||
|
||||
### `src/services/rpg-creation/rpgCreationLibraryClient.ts`
|
||||
|
||||
职责:
|
||||
|
||||
1. 负责世界库、作品广场、作品详情、保存、删除、发布、下架。
|
||||
2. 承接 RPG 创作结果进入库与进入广场的正式请求。
|
||||
|
||||
### `src/services/rpg-creation/rpgCreationPreviewAdapter.ts`
|
||||
|
||||
职责:
|
||||
|
||||
1. 把 Agent session / result preview 转成结果页可消费预览模型。
|
||||
2. 是“生成链结果”和“结果页展示模型”之间的适配层。
|
||||
|
||||
### `src/services/rpg-creation/rpgCreationWorkClient.ts`
|
||||
|
||||
职责:
|
||||
|
||||
1. 负责作品工作台列表和会话删除等工作流外围请求。
|
||||
|
||||
---
|
||||
|
||||
## 4.5 RPG 创作提示词与内容编排脚本
|
||||
|
||||
### 前端提示词目录 `src/prompts/`
|
||||
|
||||
与 RPG 创作直接相关的脚本:
|
||||
|
||||
1. `src/prompts/customWorldPrompts.ts`
|
||||
负责自定义世界生成相关提示词。
|
||||
2. `src/prompts/customWorldEntityActionPrompts.ts`
|
||||
负责实体生成与实体动作提示词。
|
||||
|
||||
说明:
|
||||
|
||||
1. 角色资产工坊默认 prompt 与缓存合并规则已经迁入 Rust,前端不再保留 `customWorldRolePromptDefaults.ts` 主源。
|
||||
|
||||
### Rust 提示词目录 `server-rs/crates/api-server/src/prompt/rpg/`
|
||||
|
||||
与 RPG 创作直接相关的脚本:
|
||||
|
||||
1. `foundation_draft.rs`
|
||||
负责 RPG 世界底稿生成提示词。
|
||||
2. `agent_chat.rs`
|
||||
负责 RPG 共创聊天提示词。
|
||||
3. `role_asset_studio.rs`
|
||||
负责角色资产工坊默认 prompt、legacy prompt 过滤与缓存合并 workflow view。
|
||||
|
||||
说明:
|
||||
|
||||
1. RPG 创作 prompt 已在 Rust 侧按 `rpg/` 子目录收口。
|
||||
2. 创作语义的正式后端提示词应该优先在这里改,而不是散改到路由或 service 中。
|
||||
|
||||
---
|
||||
|
||||
## 4.6 RPG 创作在 SpacetimeDB / 契约层的职责
|
||||
|
||||
### `server-rs/crates/shared-contracts/src/runtime.rs`
|
||||
|
||||
职责:
|
||||
|
||||
1. 提供 RPG 创作请求/响应需要复用的共享运行时契约。
|
||||
|
||||
### `server-rs/crates/module-custom-world/src/lib.rs`
|
||||
|
||||
职责:
|
||||
|
||||
1. 承接自定义世界作品、会话、发布、草稿等正式模块能力。
|
||||
2. 作为 RPG 创作在 SpacetimeDB 的主要领域模块之一。
|
||||
|
||||
### `server-rs/crates/spacetime-client/src/custom_world.rs`
|
||||
|
||||
职责:
|
||||
|
||||
1. 为 `api-server` 提供自定义世界相关过程调用和数据读取封装。
|
||||
2. 把 API 层和 SpacetimeDB 模块调用隔开。
|
||||
|
||||
---
|
||||
|
||||
## 5. RPG 运行时游戏脚本职责
|
||||
|
||||
## 5.1 运行时入口与 Session 初始化
|
||||
|
||||
### `src/components/rpg-entry/RpgEntryFlowShell.tsx`
|
||||
|
||||
职责:
|
||||
|
||||
1. 兼容旧 RPG 入口导入路径。
|
||||
2. 真实实现已桥接到 `PlatformEntryFlowShell`。
|
||||
|
||||
说明:
|
||||
|
||||
1. 这是兼容入口,不是当前主逻辑实现点。
|
||||
2. 如果要改多玩法入口或 RPG 入口主链,应优先看 `platform-entry/`。
|
||||
|
||||
### `src/hooks/rpg-session/useRpgSessionBootstrap.ts`
|
||||
|
||||
职责:
|
||||
|
||||
1. 负责 RPG 新开局初始化。
|
||||
2. 负责世界选择、角色确认、初始场景、初始遭遇、初始库存、初始装备、初始 progression 的装配。
|
||||
3. 负责把自定义世界 profile 编译成可运行 `GameState`。
|
||||
|
||||
说明:
|
||||
|
||||
1. 这是“开始游戏”最关键的前端 session 装配脚本。
|
||||
2. 进入运行态前的本地初始态主要在这里成型。
|
||||
|
||||
### `src/hooks/rpg-session/useRpgSessionPersistence.ts`
|
||||
|
||||
职责:
|
||||
|
||||
1. 负责远端快照读取。
|
||||
2. 负责自动存档。
|
||||
3. 负责继续游戏恢复。
|
||||
4. 负责在恢复快照后刷新 runtime story 状态。
|
||||
|
||||
说明:
|
||||
|
||||
1. 这是运行时持久化主入口。
|
||||
2. 继续游戏、保存退出、自动存档主要都在这里编排。
|
||||
|
||||
### `src/hooks/rpg-session/useRpgRuntimeSession.ts`
|
||||
|
||||
职责:
|
||||
|
||||
1. 作为 RPG 主运行态装配器。
|
||||
2. 组合 bootstrap、persistence、combat、npc interaction、runtime story、背景音乐等能力。
|
||||
3. 最终输出 `RpgRuntimeShell` 所需完整 props。
|
||||
|
||||
说明:
|
||||
|
||||
1. 这是前端运行时主装配入口。
|
||||
2. 查“为什么运行态拿到这些状态和事件回调”时,应优先看这里。
|
||||
|
||||
---
|
||||
|
||||
## 5.2 运行时 UI 壳层与面板层
|
||||
|
||||
### `src/components/rpg-runtime-shell/RpgRuntimeShell.tsx`
|
||||
|
||||
职责:
|
||||
|
||||
1. 承接 RPG 运行态总外壳。
|
||||
2. 装配画布舞台、阶段路由、overlay host、运行时级 UI chrome。
|
||||
3. 保持平台壳层和 RPG 壳层之间的显示切换。
|
||||
|
||||
### `src/components/rpg-runtime-shell/RpgRuntimeStageRouter.tsx`
|
||||
|
||||
职责:
|
||||
|
||||
1. 在平台入口态、选角态、冒险态之间路由。
|
||||
2. 把 session / story / entry 的装配结果分发给对应页面。
|
||||
|
||||
### `src/components/rpg-runtime-panels/RpgRuntimePanelRouter.tsx`
|
||||
|
||||
职责:
|
||||
|
||||
1. 在冒险、角色、背包等主面板间切换。
|
||||
2. 管运行态主 tab 的表现层分发。
|
||||
|
||||
### `src/components/rpg-runtime-panels/RpgAdventurePanel.tsx`
|
||||
|
||||
职责:
|
||||
|
||||
1. 作为 RPG 冒险主面板。
|
||||
2. 负责展示剧情文本、选项列表、任务状态、战斗状态、资源状态、存档入口等主玩法信息。
|
||||
3. 负责把运行态 story 选项真正落成可点击 UI。
|
||||
|
||||
说明:
|
||||
|
||||
1. 这是当前运行时前台最核心的展示脚本之一。
|
||||
2. 它负责表现和 UI 交互,不负责正式状态真相裁决。
|
||||
|
||||
---
|
||||
|
||||
## 5.3 运行时剧情主链
|
||||
|
||||
### `src/hooks/rpg-runtime-story/useRpgRuntimeStory.ts`
|
||||
|
||||
职责:
|
||||
|
||||
1. 作为 RPG runtime story 顶层装配入口。
|
||||
2. 组合角色聊天流、story controller、story flow。
|
||||
3. 向运行态主链输出 `currentStory`、`displayedOptions`、战斗奖励 UI、NPC UI、任务 UI 等完整剧情交互能力。
|
||||
|
||||
说明:
|
||||
|
||||
1. 前端剧情主链的总入口是它。
|
||||
2. 如果要理解运行时故事层如何被装配,先看这里。
|
||||
|
||||
### `src/hooks/rpg-runtime-story/useRpgRuntimeStoryController.ts`
|
||||
|
||||
职责:
|
||||
|
||||
1. 管理当前故事、加载态、错误态和故事请求控制。
|
||||
2. 是前端 story 状态层的核心控制器。
|
||||
|
||||
### `src/hooks/rpg-runtime-story/useRpgRuntimeStoryFlow.ts`
|
||||
|
||||
职责:
|
||||
|
||||
1. 负责真正的剧情流编排。
|
||||
2. 负责选项刷新、选项点击、NPC 交互、地图旅行、战斗奖励 UI、任务 UI、目标 UI 的组合。
|
||||
|
||||
### `src/hooks/rpg-runtime-story/useRpgRuntimeInteractionFlow.ts`
|
||||
|
||||
职责:
|
||||
|
||||
1. 负责把不同类型的运行时交互分发到对应流程。
|
||||
2. 是运行时“动作入口分发层”。
|
||||
|
||||
### `src/hooks/rpg-runtime-story/useRpgRuntimeNpcInteraction.ts`
|
||||
|
||||
职责:
|
||||
|
||||
1. 负责 NPC 聊天、送礼、招募、委托查看等 NPC 交互链。
|
||||
2. 组织 NPC 相关 UI 状态和动作入口。
|
||||
|
||||
### `src/hooks/rpg-runtime-story/rpgRuntimeStoryGateway.ts`
|
||||
|
||||
职责:
|
||||
|
||||
1. 负责前端 runtime story 与后端 runtime story 的网关衔接。
|
||||
2. 负责加载 option catalog、恢复 runtime story、提交 choice。
|
||||
3. 负责把服务端快照桥接回前端可消费状态。
|
||||
|
||||
说明:
|
||||
|
||||
1. 这是前端到后端 runtime story 主链的关键边界文件。
|
||||
2. 正式动作解析应该尽量经过这里,而不是在面板里直接组请求。
|
||||
|
||||
### `src/services/rpg-runtime/rpgRuntimeStoryClient.ts`
|
||||
|
||||
职责:
|
||||
|
||||
1. 请求 `/api/runtime/story` 的 state 和 action 接口。
|
||||
2. 把服务端 `RuntimeStoryOptionView` 适配成前端 `StoryOption`。
|
||||
3. 负责 sessionId、clientVersion、snapshot 请求结构和响应快照反序列化。
|
||||
|
||||
说明:
|
||||
|
||||
1. 这是运行时故事 HTTP client 的主入口。
|
||||
2. “服务端动作解析”和“服务端状态读取”最终都要走这里。
|
||||
|
||||
### `src/services/rpg-runtime/rpgRuntimeChatClient.ts`
|
||||
|
||||
职责:
|
||||
|
||||
1. 提供角色私聊、NPC 单轮聊天、招募对话等聊天能力。
|
||||
2. 当前仍桥接旧 `aiService`。
|
||||
|
||||
说明:
|
||||
|
||||
1. 这是运行时聊天 client 的兼容收口层。
|
||||
2. 当前属于过渡桥接脚本,后续不应继续扩大旧 `aiService` 的依赖面。
|
||||
|
||||
### `src/services/rpg-runtime/rpgSnapshotClient.ts`
|
||||
|
||||
职责:
|
||||
|
||||
1. 提供运行时快照读取、写入、删除能力。
|
||||
2. 给 session persistence 层提供正式持久化接口。
|
||||
|
||||
---
|
||||
|
||||
## 5.4 运行时选项函数与本地规则脚本
|
||||
|
||||
### `src/data/functionCatalog/`
|
||||
|
||||
职责:
|
||||
|
||||
1. 维护运行时 function 的独立定义脚本。
|
||||
2. 按 `state / npc / treasure / flow / panel` 分类收口。
|
||||
3. 统一管理 functionId、动作标题、说明、部分 helper 和运行时定义。
|
||||
|
||||
关键目录:
|
||||
|
||||
1. `src/data/functionCatalog/flow/`
|
||||
负责剧情流程控制型 function。
|
||||
2. `src/data/functionCatalog/npc/`
|
||||
负责 NPC 交互型 function。
|
||||
3. `src/data/functionCatalog/runtimeIndex.ts`
|
||||
负责运行时 function 的统一索引;该文件只依赖各分目录入口,不反向依赖 `functionCatalog/index.ts`,避免模块初始化循环。
|
||||
|
||||
### `src/data/stateFunctions.ts`
|
||||
|
||||
职责:
|
||||
|
||||
1. 聚合基础状态 function。
|
||||
2. 对运行时 option 做优先级、排序和过滤支撑。
|
||||
|
||||
### `docs/reference/FUNCTION_SCRIPT_CATALOG_2026-04-04.md`
|
||||
|
||||
职责:
|
||||
|
||||
1. 作为 function 脚本目录的专项速查文档。
|
||||
2. 如果只想查某个运行时 function 的脚本落点,优先看它。
|
||||
|
||||
---
|
||||
|
||||
## 5.5 运行时提示词脚本
|
||||
|
||||
### 前端 `src/prompts/rpg/`
|
||||
|
||||
关键脚本:
|
||||
|
||||
1. `runtimeStoryPrompts.ts`
|
||||
负责 RPG 运行时剧情和运行时叙事提示词。
|
||||
2. `characterChatPrompts.ts`
|
||||
负责角色私聊提示词。
|
||||
|
||||
### Rust `server-rs/crates/api-server/src/prompt/rpg/`
|
||||
|
||||
关键脚本:
|
||||
|
||||
1. `runtime_chat.rs`
|
||||
负责运行时剧情、NPC 对话、运行时聊天相关提示词。
|
||||
|
||||
说明:
|
||||
|
||||
1. 运行时 prompt 的正式后端组织应优先看 Rust 侧 `prompt/rpg/`。
|
||||
2. 前端 prompt 更多承担适配和兼容角色。
|
||||
|
||||
---
|
||||
|
||||
## 5.6 `server-rs` 中的 RPG 运行时后端职责
|
||||
|
||||
### `server-rs/crates/api-server/src/runtime_story.rs`
|
||||
|
||||
职责:
|
||||
|
||||
1. 当前作为 RPG runtime story 的后端门面模块。
|
||||
2. 对外导出 `compat` 中的状态读取、动作解析、初始剧情和继续剧情能力。
|
||||
|
||||
说明:
|
||||
|
||||
1. 当前文件本身很薄。
|
||||
2. 真正逻辑在 `runtime_story/compat/` 里。
|
||||
|
||||
### `server-rs/crates/api-server/src/runtime_story/compat/`
|
||||
|
||||
职责:
|
||||
|
||||
1. 承接当前 RPG runtime story 的兼容实现。
|
||||
2. 负责 AI、装备动作、任务动作、NPC 动作、表现层 view model 和测试支撑。
|
||||
|
||||
关键脚本:
|
||||
|
||||
1. `ai.rs`
|
||||
负责运行时叙事 AI 相关兼容逻辑。
|
||||
2. `npc_actions.rs`
|
||||
负责 NPC 动作解析。
|
||||
3. `quest_actions.rs`
|
||||
负责任务动作解析。
|
||||
4. `equipment_actions.rs`
|
||||
负责装备和面板动作解析。
|
||||
5. `presentation.rs`
|
||||
负责运行时表现层 view model 编译。
|
||||
6. `game_state.rs`
|
||||
负责兼容态下的状态组织。
|
||||
|
||||
### `server-rs/crates/api-server/src/runtime_chat.rs`
|
||||
|
||||
职责:
|
||||
|
||||
1. 提供运行时 NPC 单轮聊天 SSE 接口。
|
||||
2. 负责构建 NPC 对话 prompt。
|
||||
3. 负责 deterministic fallback 回复、建议选项和 function suggestion 回退。
|
||||
|
||||
### `server-rs/crates/api-server/src/runtime_save.rs`
|
||||
|
||||
职责:
|
||||
|
||||
1. 提供当前快照读取、写入、删除接口。
|
||||
2. 提供存档归档列表和恢复接口。
|
||||
3. 负责区分正式快照与 preview / test 的临时快照写入语义。
|
||||
|
||||
### `server-rs/crates/api-server/src/runtime_inventory.rs`
|
||||
|
||||
职责:
|
||||
|
||||
1. 承接运行时背包状态读取。
|
||||
|
||||
### `server-rs/crates/api-server/src/runtime_profile.rs`
|
||||
|
||||
职责:
|
||||
|
||||
1. 承接运行时相关玩家资料、统计、充值中心、钱包流水等外围接口。
|
||||
|
||||
### `server-rs/crates/api-server/src/story_battles.rs`
|
||||
|
||||
职责:
|
||||
|
||||
1. 承接故事战斗状态创建、NPC 战斗创建、战斗结算与战斗状态查询。
|
||||
|
||||
### `server-rs/crates/api-server/src/story_sessions.rs`
|
||||
|
||||
职责:
|
||||
|
||||
1. 承接 story session 的 begin / continue / state 查询。
|
||||
2. 是运行时故事会话层的重要接口之一。
|
||||
|
||||
### `server-rs/crates/api-server/src/app.rs`
|
||||
|
||||
职责:
|
||||
|
||||
1. 统一挂接 `api-server` 的 Axum 路由树。
|
||||
2. 把 runtime story、runtime chat、runtime save、story battle、story session 等接口注册到 HTTP 层。
|
||||
|
||||
说明:
|
||||
|
||||
1. 它是路由总装配文件,不是 RPG 运行时业务细节实现文件。
|
||||
|
||||
---
|
||||
|
||||
## 5.7 SpacetimeDB 模块与共享契约层职责
|
||||
|
||||
### `server-rs/crates/shared-contracts/src/runtime_story.rs`
|
||||
|
||||
职责:
|
||||
|
||||
1. 提供 RPG 运行时故事域共享契约。
|
||||
|
||||
### `server-rs/crates/shared-contracts/src/runtime.rs`
|
||||
|
||||
职责:
|
||||
|
||||
1. 提供运行时快照、创作、资料等通用契约。
|
||||
|
||||
### `server-rs/crates/module-runtime/src/lib.rs`
|
||||
|
||||
职责:
|
||||
|
||||
1. 提供运行时快照、保存相关模块基础能力。
|
||||
|
||||
### `server-rs/crates/module-story/src/lib.rs`
|
||||
|
||||
职责:
|
||||
|
||||
1. 提供故事会话、故事状态相关模块能力。
|
||||
|
||||
### `server-rs/crates/module-combat/src/lib.rs`
|
||||
|
||||
职责:
|
||||
|
||||
1. 提供战斗状态相关模块能力。
|
||||
|
||||
### `server-rs/crates/module-quest/src/lib.rs`
|
||||
|
||||
职责:
|
||||
|
||||
1. 提供任务状态、任务推进相关模块能力。
|
||||
|
||||
### `server-rs/crates/module-inventory/src/lib.rs`
|
||||
|
||||
职责:
|
||||
|
||||
1. 提供背包和物品库存相关模块能力。
|
||||
|
||||
### `server-rs/crates/module-runtime-story-compat/src/`
|
||||
|
||||
职责:
|
||||
|
||||
1. 承接当前 runtime story compat 领域逻辑。
|
||||
2. 把战斗、锻造、选项、NPC 支撑、view model 等兼容逻辑收在独立 crate 中。
|
||||
|
||||
关键脚本:
|
||||
|
||||
1. `battle.rs`
|
||||
2. `forge.rs`
|
||||
3. `forge_actions.rs`
|
||||
4. `game_state.rs`
|
||||
5. `npc_support.rs`
|
||||
6. `options.rs`
|
||||
7. `view_model.rs`
|
||||
|
||||
说明:
|
||||
|
||||
1. 这是当前 RPG 运行时兼容逻辑的重要承载层。
|
||||
2. 如果要继续把前端旧本地规则向 Rust 收口,这里是关键迁移落点之一。
|
||||
|
||||
---
|
||||
|
||||
## 6. 支撑 RPG 创作与运行时的工具脚本职责
|
||||
|
||||
这些脚本不直接参与玩法,但直接支撑开发、发布、绑定和检查:
|
||||
|
||||
### `scripts/api-server-maincloud.mjs`
|
||||
|
||||
职责:
|
||||
|
||||
1. 启动当前 Rust API server 主链路开发入口。
|
||||
2. 按仓库约束,后端联调应优先通过它启动。
|
||||
|
||||
### `scripts/generate-spacetime-bindings.mjs`
|
||||
|
||||
职责:
|
||||
|
||||
1. 生成 SpacetimeDB 绑定。
|
||||
2. 支撑前后端契约同步。
|
||||
|
||||
### `scripts/check-encoding.mjs`
|
||||
|
||||
职责:
|
||||
|
||||
1. 检查仓库中文文件编码是否被写坏。
|
||||
2. 修改中文文档或中文注释后应优先运行。
|
||||
|
||||
### `scripts/validate-content.ts`
|
||||
|
||||
职责:
|
||||
|
||||
1. 做内容结构校验。
|
||||
|
||||
### `scripts/validate-overrides.ts`
|
||||
|
||||
职责:
|
||||
|
||||
1. 做覆盖项和配置项校验。
|
||||
|
||||
### `scripts/smoke-content.ts`
|
||||
|
||||
职责:
|
||||
|
||||
1. 做内容层 smoke 验证。
|
||||
|
||||
---
|
||||
|
||||
## 7. 当前最值得优先记住的入口脚本
|
||||
|
||||
如果只想快速建立脑图,建议优先记住下面这些文件:
|
||||
|
||||
### RPG 创作链
|
||||
|
||||
1. `src/components/platform-entry/PlatformEntryFlowShellImpl.tsx`
|
||||
2. `src/components/custom-world-agent/CustomWorldAgentWorkspace.tsx`
|
||||
3. `src/components/rpg-creation-result/useRpgCreationResultActions.ts`
|
||||
4. `src/services/rpg-creation/rpgCreationAgentClient.ts`
|
||||
5. `src/services/rpg-creation/rpgCreationAssetClient.ts`
|
||||
6. `src/services/rpg-creation/rpgCreationLibraryClient.ts`
|
||||
7. `server-rs/crates/api-server/src/custom_world.rs`
|
||||
8. `server-rs/crates/api-server/src/custom_world_ai.rs`
|
||||
|
||||
### RPG 运行时链
|
||||
|
||||
1. `src/hooks/rpg-session/useRpgRuntimeSession.ts`
|
||||
2. `src/hooks/rpg-session/useRpgSessionBootstrap.ts`
|
||||
3. `src/hooks/rpg-session/useRpgSessionPersistence.ts`
|
||||
4. `src/components/rpg-runtime-shell/RpgRuntimeShell.tsx`
|
||||
5. `src/components/rpg-runtime-panels/RpgAdventurePanel.tsx`
|
||||
6. `src/hooks/rpg-runtime-story/useRpgRuntimeStory.ts`
|
||||
7. `src/hooks/rpg-runtime-story/rpgRuntimeStoryGateway.ts`
|
||||
8. `src/services/rpg-runtime/rpgRuntimeStoryClient.ts`
|
||||
9. `server-rs/crates/api-server/src/runtime_story.rs`
|
||||
10. `server-rs/crates/api-server/src/runtime_chat.rs`
|
||||
11. `server-rs/crates/api-server/src/runtime_save.rs`
|
||||
|
||||
---
|
||||
|
||||
## 8. 当前兼容层与后续阅读建议
|
||||
|
||||
### 8.1 兼容层识别
|
||||
|
||||
下面这些脚本当前更多是兼容门面:
|
||||
|
||||
1. `src/components/rpg-entry/RpgEntryFlowShell.tsx`
|
||||
2. `src/services/rpg-runtime/rpgRuntimeChatClient.ts`
|
||||
3. `server-rs/crates/api-server/src/runtime_story.rs`
|
||||
|
||||
阅读这些文件时要注意:
|
||||
|
||||
1. 如果文件很薄,往往真实逻辑在它桥接到的 impl / compat / platform-entry 目录里。
|
||||
2. 不要把复杂新逻辑继续堆回这些 façade。
|
||||
|
||||
### 8.2 推荐阅读顺序
|
||||
|
||||
如果要继续开发 RPG 创作:
|
||||
|
||||
1. 先看 `PlatformEntryFlowShellImpl.tsx`
|
||||
2. 再看 `CustomWorldAgentWorkspace.tsx`
|
||||
3. 再看 `rpgCreationAgentClient.ts`
|
||||
4. 再看 `custom_world.rs`
|
||||
|
||||
如果要继续开发 RPG 运行时:
|
||||
|
||||
1. 先看 `useRpgRuntimeSession.ts`
|
||||
2. 再看 `useRpgRuntimeStory.ts`
|
||||
3. 再看 `rpgRuntimeStoryGateway.ts`
|
||||
4. 再看 `rpgRuntimeStoryClient.ts`
|
||||
5. 最后看 `server-rs/crates/api-server/src/runtime_story/compat/`
|
||||
|
||||
---
|
||||
|
||||
## 9. 结论
|
||||
|
||||
当前仓库里的 RPG 主链已经基本形成两套脚本地图:
|
||||
|
||||
1. **RPG 创作链**:以平台入口分流、共创会话、结果页编辑、资产工坊、创作域 client、`server-rs` 自定义世界接口为主。
|
||||
2. **RPG 运行时链**:以 session bootstrap、session persistence、runtime shell、runtime story hook、runtime story client、`server-rs` runtime story / runtime chat / runtime save 为主。
|
||||
|
||||
如果后续继续做职责收口,优先方向应该是:
|
||||
|
||||
1. 继续减少 façade 承载业务。
|
||||
2. 继续把前端兼容桥接逻辑向 `server-rs` 和 SpacetimeDB 正式域收口。
|
||||
3. 继续让“创作链”和“运行时链”各自维持清晰入口,而不是重新回到通用大文件。
|
||||
115
docs/reference/RPG_SCRIPT_COMMENTARY_PROGRESS_2026-04-28.md
Normal file
115
docs/reference/RPG_SCRIPT_COMMENTARY_PROGRESS_2026-04-28.md
Normal file
@@ -0,0 +1,115 @@
|
||||
# RPG 脚本中文注释补充进度(2026-04-28)
|
||||
|
||||
## 1. 文档目的
|
||||
|
||||
这份文档用于记录当前仓库里 RPG 相关脚本的中文注释补充进度,避免后续“挨个补充”时重复扫描、重复改同一批文件,或者遗漏运行时主链上的关键脚本。
|
||||
|
||||
当前原则:
|
||||
|
||||
1. 先补职责最核心、状态流最复杂、后续最常被继续修改的脚本。
|
||||
2. 每一批都尽量按完整链路补,不只补单点文件。
|
||||
3. 注释以解释“为什么这样编排”“这一层负责什么边界”为主,不堆砌逐行翻译式废话。
|
||||
|
||||
---
|
||||
|
||||
## 2. 本轮已补充的脚本
|
||||
|
||||
### 2.1 RPG 运行时 session 主链
|
||||
|
||||
1. `src/hooks/rpg-session/useRpgRuntimeSession.ts`
|
||||
2. `src/hooks/rpg-session/useRpgSessionBootstrap.ts`
|
||||
3. `src/hooks/rpg-session/useRpgSessionPersistence.ts`
|
||||
|
||||
本轮重点:
|
||||
|
||||
1. 说明 session 装配器如何组合 bootstrap、story、combat、persistence。
|
||||
2. 说明自定义世界开局场景、首遇 NPC、初始装备与初始物品的装配原因。
|
||||
3. 说明自动存档、继续游戏、手动保存退出的状态边界。
|
||||
|
||||
### 2.2 RPG 运行时 story 主链
|
||||
|
||||
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/useRpgRuntimeInteractionFlow.ts`
|
||||
5. `src/hooks/rpg-runtime-story/useRpgRuntimeStoryState.ts`
|
||||
6. `src/hooks/rpg-runtime-story/storyInteractionCoordinator.ts`
|
||||
7. `src/hooks/rpg-runtime-story/rpgRuntimeStoryGateway.ts`
|
||||
|
||||
本轮重点:
|
||||
|
||||
1. 说明 controller、flow、interaction、state 四层的职责切分。
|
||||
2. 说明 NPC 遭遇自动进入交互态、NPC 战斗快照桥接、地图旅行桥接的原因。
|
||||
3. 说明 story reset、story hydration、服务端动作结算的编排边界。
|
||||
|
||||
### 2.3 RPG 运行时 service / client 主链
|
||||
|
||||
1. `src/services/rpg-runtime/rpgRuntimeRequest.ts`
|
||||
2. `src/services/rpg-runtime/rpgRuntimeStoryClient.ts`
|
||||
3. `src/services/rpg-runtime/rpgSnapshotClient.ts`
|
||||
|
||||
本轮重点:
|
||||
|
||||
1. 说明 runtime 请求的统一重试策略。
|
||||
2. 说明服务端 `RuntimeStoryOptionView` 到前端 `StoryOption` 的适配原因。
|
||||
3. 说明远端快照读取、写入后为什么要先 rehydrate。
|
||||
|
||||
---
|
||||
|
||||
## 3. 建议的后续补充顺序
|
||||
|
||||
为了保持“按链路读得通”,下一轮建议继续按下面顺序推进:
|
||||
|
||||
### 3.1 运行时 story 子模块
|
||||
|
||||
1. `src/hooks/rpg-runtime-story/storyChoiceCoordinator.ts`
|
||||
2. `src/hooks/rpg-runtime-story/storyChoiceRuntime.ts`
|
||||
3. `src/hooks/rpg-runtime-story/storyRequestCoordinator.ts`
|
||||
4. `src/hooks/rpg-runtime-story/storyRequestRuntime.ts`
|
||||
5. `src/hooks/rpg-runtime-story/storyGenerationState.ts`
|
||||
6. `src/hooks/rpg-runtime-story/storyEncounterState.ts`
|
||||
7. `src/hooks/rpg-runtime-story/storyPresentation.ts`
|
||||
8. `src/hooks/rpg-runtime-story/sessionActions.ts`
|
||||
9. `src/hooks/rpg-runtime-story/progressionActions.ts`
|
||||
10. `src/hooks/rpg-runtime-story/npcInteraction.ts`
|
||||
11. `src/hooks/rpg-runtime-story/inventoryActions.ts`
|
||||
12. `src/hooks/rpg-runtime-story/goalFlow.ts`
|
||||
|
||||
原因:
|
||||
|
||||
1. 这些文件已经紧贴本轮完成的主编排层。
|
||||
2. 它们包含大量“局部规则 + 状态迁移 + UI 结果”的细节,最需要注释解释。
|
||||
|
||||
### 3.2 运行时 UI 与入口层
|
||||
|
||||
1. `src/components/rpg-runtime-shell/RpgRuntimeShell.tsx`
|
||||
2. `src/components/rpg-runtime-shell/RpgRuntimeStageRouter.tsx`
|
||||
3. `src/components/rpg-runtime-panels/RpgRuntimePanelRouter.tsx`
|
||||
4. `src/components/rpg-runtime-panels/RpgAdventurePanel.tsx`
|
||||
5. `src/hooks/rpg-session/useRpgSessionBootstrap.ts` 周边引用组件
|
||||
6. `src/components/rpg-entry/` 目录里的 RPG 运行时入口桥接脚本
|
||||
|
||||
原因:
|
||||
|
||||
1. 主链编排层补完后,再补表现层会更容易写出真正有用的注释。
|
||||
2. 入口层里有兼容 façade,需要明确标出“不要继续堆复杂逻辑”的边界。
|
||||
|
||||
### 3.3 RPG 创作链
|
||||
|
||||
1. `src/services/rpg-creation/` 目录主 client
|
||||
2. `src/components/rpg-creation-result/` 主动作脚本
|
||||
3. `src/components/rpg-creation-editor/` 主编辑链
|
||||
4. `src/components/rpg-creation-asset-studio/` 角色资产工坊链
|
||||
|
||||
原因:
|
||||
|
||||
1. 创作链文件也很多,但当前运行时主链更核心、更常改。
|
||||
2. 等运行时链注释连续后,再切创作链更不容易打断理解。
|
||||
|
||||
---
|
||||
|
||||
## 4. 本轮备注
|
||||
|
||||
1. 本轮以局部补丁方式补注释,没有整文件重写。
|
||||
2. 本轮没有改业务逻辑,只补中文注释和进度文档。
|
||||
3. 后续每补完一批,建议同步更新本文件,保持可追踪。
|
||||
@@ -192,6 +192,7 @@ npm run dev:rust
|
||||
1. 资源 URL 不再是 `/generated-big-fish/...`
|
||||
2. 而是 `/generated-big-fish-assets/...`
|
||||
3. 结果页状态显示为 `已生成`,而不是 `占位已生成`
|
||||
4. `Lv.x 主图` 与 `idle_float / move_swim` 正式图若下载结果为 PNG,后端会在写 OSS 前复用 RPG 角色主图透明背景 alpha 后处理;`生成背景` 不走该处理
|
||||
|
||||
### 7.2 Custom World 场景图
|
||||
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
# 大鱼吃小鱼草稿进度与会话超时兜底修复 2026-04-28
|
||||
|
||||
## 背景
|
||||
|
||||
大鱼吃小鱼在 `2026-04-28` 完成草稿结构化升级后,结果页草稿已经不再是单纯模板壳,而是会生成等级文本、形象描述、动作描述与运行参数。
|
||||
|
||||
但当前链路仍暴露出两个直接体验问题:
|
||||
|
||||
1. 前端草稿进度页仍把大鱼吃小鱼展示成单个 `compile` 步骤,用户会感觉“整个生成过程只有一步,而且一直卡在第一步”。
|
||||
2. 前端在打开大鱼草稿或结果页时,会通过 `GET /api/runtime/big-fish/agent/sessions/:sessionId` 拉取完整会话;当 Maincloud 上游偶发抖动时,Rust `spacetime-client` 统一 10 秒超时会直接映射成 `502`,用户会看到反复报错。
|
||||
|
||||
## 修复口径
|
||||
|
||||
### 1. 草稿进度页改为多阶段感知
|
||||
|
||||
大鱼吃小鱼的 `big_fish_compile_draft` 仍然保持为一次后端 compile action,不拆成多个新的后端接口。
|
||||
|
||||
但前端进度读模型不再把它渲染成单步,而是拆成下面三段:
|
||||
|
||||
1. `整理玩法骨架`
|
||||
- 收拢玩法承诺、成长阶梯与风险节奏。
|
||||
2. `编译等级蓝图`
|
||||
- 生成每级角色描述、形象描述与动作描述。
|
||||
3. `校准场地与参数`
|
||||
- 整理背景蓝图与运行参数,准备结果页。
|
||||
|
||||
这样做的边界是:
|
||||
|
||||
1. 不把动作正式出图重新塞回 compile action。
|
||||
2. 只增强生成中的阶段反馈,不改动现有结果页资产工坊分工。
|
||||
3. 进度阶段属于前端展示语义,不要求后端额外维护细粒度 procedure 状态。
|
||||
|
||||
### 2. 会话读取增加短重试与超时语义收口
|
||||
|
||||
大鱼会话读取现在补充两层守卫:
|
||||
|
||||
1. `api-server` 在读取大鱼 session 时,对 `SpacetimeClientError::Timeout` 和 `SpacetimeClientError::ConnectDropped` 做一次短重试。
|
||||
2. 若最终仍然超时,则错误状态码从泛化 `502` 收口为更准确的 `504 Gateway Timeout`。
|
||||
|
||||
这样可以覆盖两类常见情况:
|
||||
|
||||
1. Maincloud 连接偶发抖动,第一次 procedure 超时但第二次马上恢复。
|
||||
2. 用户打开草稿页时碰到短暂断链,不再被立即判定成稳定的坏网关故障。
|
||||
|
||||
## 落地范围
|
||||
|
||||
1. `src/services/miniGameDraftGenerationProgress.ts`
|
||||
2. `src/services/miniGameDraftGenerationProgress.test.ts`
|
||||
3. `src/components/platform-entry/PlatformEntryFlowShellImpl.tsx`
|
||||
4. `server-rs/crates/api-server/src/big_fish.rs`
|
||||
|
||||
## 验收口径
|
||||
|
||||
1. 用户点击大鱼吃小鱼“生成草稿”后,进度页至少能看到三个结构化阶段,而不是单个 compile 步骤。
|
||||
2. 这三个阶段都只描述草稿编译,不出现“生成动作素材”之类结果页资产动作。
|
||||
3. `GET /api/runtime/big-fish/agent/sessions/:sessionId` 遇到短暂 SpacetimeDB 抖动时,会先做一次短重试。
|
||||
4. 如果最终仍超时,接口返回语义应体现为超时,而不是继续统一落成泛化 `502`。
|
||||
@@ -0,0 +1,82 @@
|
||||
# 大鱼吃小鱼角色主图透明背景后处理对齐说明 2026-04-28
|
||||
|
||||
## 背景
|
||||
|
||||
当前大鱼吃小鱼的等级主图与动作关键帧 prompt 已经明确要求“按 RPG 角色资产口径生成透明背景”,但正式图片链实际仍主要依赖供应商出图结果本身。
|
||||
|
||||
这会带来一个问题:
|
||||
|
||||
1. prompt 约束只能提高透明背景命中率,不能保证每次都没有残留底色。
|
||||
2. RPG 角色主图链已经在 Rust 后端落了一层 PNG alpha 后处理,大鱼链没有对齐,导致两条“角色主图口径”资产在最终成品一致性上仍有差异。
|
||||
|
||||
## 本次目标
|
||||
|
||||
把“大鱼吃小鱼生成的角色主图后处理流程”对齐到 RPG 角色主图链:
|
||||
|
||||
1. 等级主图正式图生成后,若下载结果为 PNG,则复用 RPG 现有透明背景 alpha 后处理。
|
||||
2. `idle_float` / `move_swim` 动作关键帧静态图同样复用这套处理。
|
||||
3. 场地背景图不走这套处理,避免误把 9:16 场景背景做成透明底。
|
||||
|
||||
## 落地方案
|
||||
|
||||
### 1. 复用 RPG 透明背景后处理能力
|
||||
|
||||
`server-rs/crates/api-server/src/character_visual_assets.rs`
|
||||
|
||||
冻结现有 `try_apply_background_alpha_to_png(...)` 为 `pub(crate)` 复用入口,继续由 RPG 主图链维护这套“绿底/白底/软边缘”透明背景清理逻辑。
|
||||
|
||||
### 2. Big Fish 正式图链按资产类型决定是否启用后处理
|
||||
|
||||
`server-rs/crates/api-server/src/big_fish.rs`
|
||||
|
||||
在 `BigFishFormalAssetContext` 中新增:
|
||||
|
||||
1. `apply_transparent_background_post_process`
|
||||
|
||||
映射规则如下:
|
||||
|
||||
1. `level_main_image`:`true`
|
||||
2. `level_motion`:`true`
|
||||
3. `stage_background`:`false`
|
||||
|
||||
### 3. 下载完成后、写 OSS 前执行统一处理
|
||||
|
||||
`download_big_fish_remote_image(...)` 新增布尔开关参数。
|
||||
|
||||
当满足以下条件时执行后处理:
|
||||
|
||||
1. 当前资产槽位需要透明背景后处理
|
||||
2. 上游下载结果 `mime_type == image/png`
|
||||
|
||||
执行顺序冻结为:
|
||||
|
||||
1. DashScope 出图
|
||||
2. 下载远端 PNG
|
||||
3. 复用 RPG `try_apply_background_alpha_to_png(...)`
|
||||
4. 再写入 Big Fish 正式 OSS 对象
|
||||
5. 确认 `asset_object`
|
||||
6. 绑定到 Big Fish 槽位
|
||||
|
||||
## 为什么这样做
|
||||
|
||||
1. 这次需求说的是“生成后处理流程和 RPG 角色主图一致”,因此不能只继续加强 prompt,必须把后处理链对齐。
|
||||
2. 直接复用 RPG 已有实现,比在 Big Fish 再复制一份抠图算法更稳,也更符合仓库“默认复用现有系统”的约束。
|
||||
3. 背景图是环境资产,不属于“角色主图口径”,如果也启用透明背景后处理,会造成错误裁底风险。
|
||||
|
||||
## 验收口径
|
||||
|
||||
1. 在 Big Fish 结果页点击 `生成并应用正式图 -> Lv.x 主图` 后,若 DashScope 返回 PNG,正式落库前会执行和 RPG 主图相同的透明背景 alpha 处理。
|
||||
2. 在 Big Fish 动作工坊点击 `生成并应用正式图` 后,`idle_float` / `move_swim` 的静态关键帧图同样执行该处理。
|
||||
3. `生成背景` 仍保持完整场景图,不走透明背景后处理。
|
||||
4. 编码检查通过,Rust `api-server` 定向编译通过。
|
||||
|
||||
## 影响范围
|
||||
|
||||
1. `server-rs/crates/api-server/src/character_visual_assets.rs`
|
||||
2. `server-rs/crates/api-server/src/big_fish.rs`
|
||||
|
||||
## 风险与边界
|
||||
|
||||
1. 当前后处理只在下载结果本身是 PNG 时生效;若供应商返回 JPEG/WebP,则仍按原始格式入库。
|
||||
2. 本次不新增新的 Big Fish 专属抠图算法,不改变 DashScope prompt 和 OSS 绑定协议。
|
||||
3. 本次不修改 SpacetimeDB schema,也不涉及 `migration.rs` 变更。
|
||||
126
docs/technical/BIG_FISH_PROMPT_MODULE_EXTRACTION_2026-04-28.md
Normal file
126
docs/technical/BIG_FISH_PROMPT_MODULE_EXTRACTION_2026-04-28.md
Normal file
@@ -0,0 +1,126 @@
|
||||
# 大鱼吃小鱼提示词脚本拆分 2026-04-28
|
||||
|
||||
## 背景
|
||||
|
||||
大鱼吃小鱼当前在 `server-rs/crates/api-server/src/big_fish.rs` 与 `server-rs/crates/api-server/src/big_fish_agent_turn.rs` 中同时承载了三类不同职责的提示词:
|
||||
|
||||
1. Agent 聊天阶段的草稿生成提示词。
|
||||
2. 结果页主图 / 生图提示词。
|
||||
3. 结果页动作关键帧提示词。
|
||||
|
||||
这会带来两个直接问题:
|
||||
|
||||
1. 聊天共创脚本和正式资产脚本混在路由业务文件中,后续继续调词时很容易顺手改到状态编排逻辑。
|
||||
2. 大鱼吃小鱼已经明确要求“草稿编译”和“结果页资产工坊”分离,如果提示词仍散落在业务实现里,后续很容易再次把动作资产逻辑误塞回 compile action。
|
||||
|
||||
## 本轮目标
|
||||
|
||||
把下面三类提示词显式拆到独立 prompt 脚本中:
|
||||
|
||||
1. 草稿生成提示词。
|
||||
2. 生图提示词。
|
||||
3. 动作提示词。
|
||||
|
||||
并保持以下边界不变:
|
||||
|
||||
1. 不改变 Big Fish 的会话表、草稿表、资产表结构。
|
||||
2. 不改变 compile action 只编译草稿、不串行生成资产的现有口径。
|
||||
3. 不改写当前中文提示词语义,只做脚本落位和调用收口。
|
||||
|
||||
## 落位方案
|
||||
|
||||
新增文件:
|
||||
|
||||
```text
|
||||
server-rs/crates/api-server/src/prompt/big_fish.rs
|
||||
```
|
||||
|
||||
该文件统一收口:
|
||||
|
||||
1. `BIG_FISH_AGENT_SYSTEM_PROMPT`
|
||||
2. `build_big_fish_agent_prompt(...)`
|
||||
3. `build_big_fish_level_main_image_prompt(...)`
|
||||
4. `build_big_fish_level_motion_prompt(...)`
|
||||
5. `build_big_fish_stage_background_prompt(...)`
|
||||
6. `BIG_FISH_DEFAULT_NEGATIVE_PROMPT`
|
||||
7. `BIG_FISH_TRANSPARENT_ASSET_NEGATIVE_PROMPT`
|
||||
|
||||
同时把 `prompt/mod.rs` 补齐为正式导出入口,和现有:
|
||||
|
||||
1. `puzzle_image.rs`
|
||||
2. `character_visual.rs`
|
||||
3. `character_animation.rs`
|
||||
4. `scene_background.rs`
|
||||
|
||||
保持同一层级。
|
||||
|
||||
## 调用边界
|
||||
|
||||
### 1. 草稿生成
|
||||
|
||||
`server-rs/crates/api-server/src/big_fish_agent_turn.rs`
|
||||
|
||||
改为只负责:
|
||||
|
||||
1. 调用公共 LLM turn 执行器。
|
||||
2. 解析 `replyText / progressPercent / nextAnchorPack`。
|
||||
3. 组装 finalize input。
|
||||
|
||||
不再内联维护大段 system prompt / output contract / chat prompt 拼接逻辑。
|
||||
|
||||
### 2. 生图与动作
|
||||
|
||||
`server-rs/crates/api-server/src/big_fish.rs`
|
||||
|
||||
改为只负责:
|
||||
|
||||
1. 读取当前 session 与 draft。
|
||||
2. 根据 `asset_kind` 构造正式资产上下文。
|
||||
3. 调用 DashScope 出图。
|
||||
4. 下载、后处理、持久化并写入资产绑定。
|
||||
|
||||
主图、动作关键帧、背景图的正式提示词脚本都从 `crate::prompt::big_fish` 引入,不再内联在路由业务脚本中。
|
||||
|
||||
## 为什么三类脚本要继续分开
|
||||
|
||||
### 草稿生成提示词
|
||||
|
||||
它的职责是把玩法灵感收束成:
|
||||
|
||||
1. 玩法承诺
|
||||
2. 生态视觉主题
|
||||
3. 成长阶梯
|
||||
4. 风险节奏
|
||||
|
||||
它面向的是 LLM 的结构化共创,不面向图片模型。
|
||||
|
||||
### 生图提示词
|
||||
|
||||
它的职责是把已经落库的等级蓝图翻译成“单体鱼形主图”的正式图片提示词。
|
||||
|
||||
它面向的是透明背景主体资产,需要强调:
|
||||
|
||||
1. 单体主体
|
||||
2. 透明背景
|
||||
3. 中心构图
|
||||
4. 不出现 UI / 场景 / 多主体
|
||||
|
||||
### 动作提示词
|
||||
|
||||
它的职责是把等级蓝图和动作槽位翻译成“静态关键帧预览图”的正式图片提示词。
|
||||
|
||||
它和主图区别在于:
|
||||
|
||||
1. 需要显式带入 `motion_key`
|
||||
2. 需要区分 `idle_float / move_swim`
|
||||
3. 需要强调动作方向和关键帧姿态
|
||||
|
||||
因此不能继续复用同一段文本拼接后靠 if 分支临时改句子。
|
||||
|
||||
## 本轮验收
|
||||
|
||||
1. 大鱼吃小鱼草稿生成提示词已从 `big_fish_agent_turn.rs` 抽离。
|
||||
2. 大鱼吃小鱼主图、动作、背景提示词已从 `big_fish.rs` 抽离。
|
||||
3. 路由业务文件只保留编排、鉴权、调用与错误映射职责。
|
||||
4. 新增 prompt 文件具备最小测试覆盖。
|
||||
5. `npm run check:encoding` 通过,确保新增中文文档与 Rust 注释未被写坏。
|
||||
@@ -0,0 +1,63 @@
|
||||
# 创作页公开广场与 Agent 恢复指针兜底修复 2026-04-28
|
||||
|
||||
## 1. 问题现象
|
||||
|
||||
浏览器控制台同时出现两类请求错误:
|
||||
|
||||
1. `GET /api/runtime/custom-world/agent/sessions/:sessionId` 返回 `404`。
|
||||
2. `GET /api/runtime/big-fish/gallery` 返回 `400`。
|
||||
|
||||
第一类错误发生在平台页尝试恢复 RPG / Custom World Agent 旧工作区时。旧 URL 或旧 sessionStorage 指针里可能只有 `customWorldSessionId`,没有本机保存的 `ownerUserId`,登录后前端仍会直接读取受保护 session,后端按 `owner_user_id + session_id` 查不到后返回 `404`。
|
||||
|
||||
第二类错误发生在首页读取大鱼吃小鱼公开广场时。公开广场是平台首屏的可选展示数据,即使 SpacetimeDB procedure 暂未就绪、连接短暂断开或旧环境缺少对应 procedure,也不应该阻断平台主界面。
|
||||
|
||||
## 2. 落地原则
|
||||
|
||||
1. URL 中的 `customWorldSessionId` 只用于深链恢复,不作为鉴权凭据。
|
||||
2. 受保护 Agent session 恢复必须能确认本机 `ownerUserId` 与当前登录用户一致。
|
||||
3. 未登录状态仍保留登录弹窗流程,不提前丢弃深链;登录完成后若仍无法确认归属,再清空恢复指针。
|
||||
4. Big Fish 公开广场只展示 `published` 作品;读取失败时允许空态降级,不把错误写成 UI 主错误。
|
||||
5. 私有作品列表、会话详情、发布、删除仍保持严格错误,不复用公开广场的软降级策略。
|
||||
|
||||
## 3. 本次修改
|
||||
|
||||
### 3.1 RPG Agent 恢复指针
|
||||
|
||||
`src/services/customWorldAgentUiState.ts` 读取 URL query 时,会尝试从 sessionStorage 中匹配同一个 `activeSessionId` 的 `ownerUserId`。
|
||||
|
||||
如果 URL 指针和本机存储匹配:
|
||||
|
||||
1. 返回 `activeSessionId`。
|
||||
2. 同时带回本机 `ownerUserId`。
|
||||
|
||||
如果 URL 指针没有对应本机归属:
|
||||
|
||||
1. 只返回 session 指针。
|
||||
2. 登录后 `useRpgCreationSessionController` 会清空指针。
|
||||
3. 不调用 `getRpgCreationSession()`,避免向后端发起必然 404 的失效恢复请求。
|
||||
|
||||
### 3.2 Big Fish 公开广场
|
||||
|
||||
前端 `listBigFishGallery()` 对 `400 / 404` 返回 `{ items: [] }`,让平台首页可以继续渲染空广场。
|
||||
|
||||
Rust `api-server` 的 `list_big_fish_gallery()` 对以下 SpacetimeDB 读取问题做服务端空态降级:
|
||||
|
||||
1. `SpacetimeClientError::Runtime(_)`
|
||||
2. `SpacetimeClientError::ConnectDropped`
|
||||
3. 明确指向 `list_big_fish_works` procedure 缺失或不可用的 procedure 错误
|
||||
|
||||
服务端会保留 `warn` 日志,便于部署环境继续排查 schema / publish 状态。`Timeout` 不降级,仍按网关超时暴露,避免长时间卡死被误认为正常空广场。
|
||||
|
||||
## 4. 验收标准
|
||||
|
||||
1. 已登录用户打开只带旧 `customWorldSessionId`、但本机没有匹配 `ownerUserId` 的页面时,不再请求 `GET /api/runtime/custom-world/agent/sessions/:sessionId`。
|
||||
2. 未登录用户打开带 `customWorldSessionId` 的深链时,仍先打开登录弹窗。
|
||||
3. Big Fish 公开广场返回 `400 / 404` 时,前端展示空列表,不把“读取大鱼吃小鱼广场失败”写入主错误态。
|
||||
4. 服务端遇到 Big Fish gallery 可降级 SpacetimeDB 错误时返回成功 envelope,`items` 为空,并记录 warn 日志。
|
||||
|
||||
## 5. 回归范围
|
||||
|
||||
1. `src/services/customWorldAgentUiState.test.ts`
|
||||
2. `src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx`
|
||||
3. `src/services/big-fish-gallery/bigFishGalleryClient.test.ts`
|
||||
4. `cargo test -p api-server big_fish_gallery`
|
||||
@@ -31,3 +31,12 @@
|
||||
- 本地与远端部署:`RUST_LOCAL_AND_REMOTE_DEPLOYMENT_SCRIPTS_2026-04-22.md`、`JENKINS_RUST_BUILD_DEPLOY_PIPELINES_2026-04-23.md`。
|
||||
|
||||
如果旧文档与本基线冲突,以本基线和更新日期更近的 Rust / SpacetimeDB 文档为准。
|
||||
|
||||
## 4. DDD 重构总纲补充
|
||||
|
||||
`2026-04-28` 起,`server-rs` 后续后端改动还必须同时遵循 [SERVER_RS_DDD_FULL_REFACTOR_2026-04-28.md](./SERVER_RS_DDD_FULL_REFACTOR_2026-04-28.md):
|
||||
|
||||
- `module-*` 只承载领域模型、命令、应用编排结果、领域事件和领域错误。
|
||||
- `spacetime-module` 只承载 SpacetimeDB 表、reducer、procedure、事务 adapter 与 mapper。
|
||||
- `api-server` 只承载 HTTP / SSE / BFF adapter 和外部平台服务编排。
|
||||
- 任何表结构变化仍必须同步 `migration.rs` 与 [SPACETIMEDB_TABLE_CATALOG.md](./SPACETIMEDB_TABLE_CATALOG.md)。
|
||||
|
||||
@@ -2,14 +2,27 @@
|
||||
|
||||
## 背景
|
||||
|
||||
RPG 在点击生成草稿后会离开聊天工作区,进入独立的生成进度页,并在该页展示生成链路的阶段、锚点与最终草稿内容。拼图与大鱼吃小鱼此前点击“生成结果页”后直接跳到结果页,正式图片、动作与背景仍分散在结果页工坊里逐个生成,导致用户无法看到“正在一次性准备完整草稿”的过程。
|
||||
RPG 在点击生成草稿后会离开聊天工作区,进入独立的生成进度页,并在该页展示生成链路的阶段、锚点与最终草稿内容。拼图与大鱼吃小鱼此前点击“生成结果页”后直接跳到结果页,缺少一个明确的“草稿编译中”过渡态。
|
||||
|
||||
但在 `2026-04-28` 的大鱼吃小鱼链路修正后,产品口径进一步收紧为:
|
||||
|
||||
1. 拼图仍然保留“生成草稿时一并补齐结果页主资产”的收口方式。
|
||||
2. 大鱼吃小鱼的“生成草稿”只负责把玩法锚点编译成结果页草稿。
|
||||
3. 大鱼吃小鱼的主图、动作、背景都留在结果页工坊按需生成,不再塞进草稿编译动作里串行执行。
|
||||
|
||||
这样做的原因是:
|
||||
|
||||
1. 大鱼吃小鱼草稿阶段的核心目标是先稳定产出等级蓝图、背景蓝图和运行参数,而不是在这一刻把整套资产都做完。
|
||||
2. 动作素材生成耗时最长,把它塞进草稿 action 会让用户长时间停留在首步等待态,形成“卡在第一步”的体感。
|
||||
3. 草稿阶段不需要配置动作,动作应当属于结果页资产精修阶段。
|
||||
|
||||
## 落地边界
|
||||
|
||||
- 前端只负责展示生成进度与触发已有后端动作,不新增 server-node 或 PostgreSQL 链路。
|
||||
- 后端继续沿用 `server-rs` + SpacetimeDB 的会话、草稿与资产写入能力;“一次性生成所有需要的东西”必须由 `server-rs` 的 compile action 承担,前端只发起一次 action 并展示进度页。
|
||||
- 拼图生成草稿链路必须包含:结果页草稿、候选图生成、正式图确认。
|
||||
- 大鱼吃小鱼生成草稿链路必须包含:玩法草稿、关卡主图、动作素材、场地背景。
|
||||
- 后端继续沿用 `server-rs` + `SpacetimeDB` 的会话、草稿与资产写入能力。
|
||||
- 拼图生成草稿链路仍包含:结果页草稿、候选图生成、正式图确认。
|
||||
- 大鱼吃小鱼生成草稿链路只包含:玩法草稿、等级蓝图、背景蓝图与运行参数编译。
|
||||
- 大鱼吃小鱼的主图、动作、背景都在结果页工坊单独触发,不再属于草稿编译阶段。
|
||||
- 生成过程中展示的“角色描述、角色图片、动作”等,统一映射为锚点、草稿蓝图与资产步骤,不把规则说明类文本写成默认 UI 文案。
|
||||
|
||||
## 交互设计
|
||||
@@ -17,8 +30,9 @@ RPG 在点击生成草稿后会离开聊天工作区,进入独立的生成进
|
||||
1. 用户在拼图或大鱼吃小鱼 Agent 工作区点击生成按钮。
|
||||
2. 页面立即切换到独立生成进度页,同时只向 `server-rs` 发起一次 compile action,返回按钮在生成中禁用,避免中途回退造成状态漂移。
|
||||
3. 进度页左侧展示阶段进度、步骤卡片与错误信息;右侧展示当前锚点与已成形的草稿结构。
|
||||
4. 全量生成成功后自动进入对应结果页,结果页直接展示已生成的资产。
|
||||
5. 生成失败时停留在进度页,用户可返回工作区补充设定,或点击重试重新执行完整草稿链路。
|
||||
4. 生成成功后自动进入对应结果页。
|
||||
5. 拼图结果页直接展示已生成的正式图;大鱼结果页则展示刚编译完成的玩法草稿,后续资产由结果页工坊继续生成。
|
||||
6. 生成失败时停留在进度页,用户可返回工作区补充设定,或点击重试重新执行完整草稿链路。
|
||||
|
||||
## 阶段映射
|
||||
|
||||
@@ -32,11 +46,14 @@ RPG 在点击生成草稿后会离开聊天工作区,进入独立的生成进
|
||||
### 大鱼吃小鱼
|
||||
|
||||
- `big_fish_compile_draft`:在 `server-rs` 内生成玩法草稿、关卡角色描述、背景蓝图与运行参数。
|
||||
- `big_fish_compile_draft`:同一次后端 action 内按每个关卡生成主角色/鱼群图片。
|
||||
- `big_fish_compile_draft`:同一次后端 action 内按每个关卡生成 `idle_float` 与 `move_swim` 动作素材。
|
||||
- `big_fish_compile_draft`:同一次后端 action 内生成玩法场地背景。
|
||||
- `ready`:进入大鱼吃小鱼结果页。
|
||||
|
||||
补充冻结:
|
||||
|
||||
- 大鱼吃小鱼草稿阶段不展示“生成动作素材”步骤。
|
||||
- `big_fish_generate_level_main_image`、`big_fish_generate_level_motion`、`big_fish_generate_stage_background` 继续保留为结果页中的独立资产动作。
|
||||
- 如果后续需要扩展大鱼草稿生成进度,也只能扩展“草稿结构编译”相关阶段,不能再把动作生成塞回 compile action。
|
||||
|
||||
## 前端流程收口
|
||||
|
||||
- 拼图与大鱼吃小鱼共用 `usePlatformCreationAgentFlowController` 管理会话、流式回复、忙碌态、错误态和草稿恢复,页面层不再重复手写两套 submit/action 流程。
|
||||
@@ -48,8 +65,10 @@ RPG 在点击生成草稿后会离开聊天工作区,进入独立的生成进
|
||||
## 验收点
|
||||
|
||||
- 拼图和大鱼吃小鱼点击生成草稿后不再直接停留在聊天工作区等待。
|
||||
- 生成中可看到独立进度页,且进度步骤随 action 完成逐步推进。
|
||||
- 拼图结果页打开时已有正式图;大鱼结果页打开时主图、动作和背景资产均已写入 `assetSlots`。
|
||||
- 前端点击生成草稿时不串行调用多个资产 action;多阶段业务编排收敛在 `server-rs`。
|
||||
- 生成中可看到独立进度页。
|
||||
- 拼图结果页打开时已有正式图。
|
||||
- 大鱼结果页打开时至少已有完整玩法草稿,不要求主图、动作和背景资产在草稿阶段写入 `assetSlots`。
|
||||
- 大鱼吃小鱼草稿生成进度中不再出现“生成动作素材”步骤。
|
||||
- 前端点击生成草稿时不串行调用多个大鱼资产 action;大鱼资产生成留在结果页独立触发。
|
||||
- 返回 Agent 工作区后,聊天区不出现“拼图结果页草稿已生成。”“本级主图已正式生成,可在结果页继续预览。”这类生成进度页状态消息。
|
||||
- 不新增 server-node 依赖,不复活 legacy public 静态资产路径。
|
||||
|
||||
@@ -4,6 +4,15 @@
|
||||
|
||||
## 文档列表
|
||||
|
||||
- [SERVER_RS_DDD_PARALLEL_TASKLIST_2026-04-29.md](./SERVER_RS_DDD_PARALLEL_TASKLIST_2026-04-29.md):把 `server-rs` DDD 一次性重构拆成全局可并行工作包,覆盖 `module-*`、`spacetime-module`、`spacetime-client`、`api-server`、`platform-*`、共享契约和前端接入的依赖、边界与验收命令。
|
||||
- [SERVER_RS_DDD_FULL_REFACTOR_2026-04-28.md](./SERVER_RS_DDD_FULL_REFACTOR_2026-04-28.md):冻结 `server-rs` 一次性 DDD 重构总纲,明确 crate 依赖方向、模块目录、上下文聚合/命令/事件/读模型、SpacetimeDB adapter 映射和表结构变更约束。
|
||||
- [RPG_PROMPT_FRONTEND_REMOVAL_AND_SERVER_RS_MIGRATION_2026-04-28.md](./RPG_PROMPT_FRONTEND_REMOVAL_AND_SERVER_RS_MIGRATION_2026-04-28.md):冻结 RPG 提示词禁止存在前端的边界,明确前端只保留 API client,角色私聊/NPC 对话/剧情续写等 prompt 统一收口到 `server-rs`。
|
||||
- [RPG_CREATION_RESULT_VIEW_BACKEND_TRUTH_MIGRATION_2026-04-28.md](./RPG_CREATION_RESULT_VIEW_BACKEND_TRUTH_MIGRATION_2026-04-28.md):冻结 RPG 创作结果页保存、Agent session/result preview 真相优先级和结果页入口裁决迁移到后端 result-view 的落地边界。
|
||||
- [RPG_CREATION_PROFILE_GENERATION_BACKEND_MIGRATION_2026-04-28.md](./RPG_CREATION_PROFILE_GENERATION_BACKEND_MIGRATION_2026-04-28.md):记录 RPG 创作 profile 生成移除非浏览器 legacy AI 回退,统一通过 `server-rs` 的 `/api/runtime/custom-world/profile` 生成世界底稿。
|
||||
- [CREATION_PUBLIC_GALLERY_AND_AGENT_RESTORE_GUARD_FIX_2026-04-28.md](./CREATION_PUBLIC_GALLERY_AND_AGENT_RESTORE_GUARD_FIX_2026-04-28.md):记录 RPG Agent 旧 URL 恢复指针必须有本机用户归属才读取受保护 session,以及 Big Fish 公开广场读取失败按空广场降级的修复口径。
|
||||
- [BIG_FISH_DRAFT_PROGRESS_AND_SESSION_TIMEOUT_GUARD_FIX_2026-04-28.md](./BIG_FISH_DRAFT_PROGRESS_AND_SESSION_TIMEOUT_GUARD_FIX_2026-04-28.md):记录大鱼吃小鱼草稿进度页从单步 compile 改为多阶段感知展示,以及大鱼会话读取在 Maincloud 抖动时增加短重试与超时语义收口的修复口径。
|
||||
- [BIG_FISH_PROMPT_MODULE_EXTRACTION_2026-04-28.md](./BIG_FISH_PROMPT_MODULE_EXTRACTION_2026-04-28.md):记录大鱼吃小鱼草稿生成、生图、动作三类提示词从业务脚本中抽离到独立 `prompt/big_fish.rs` 模块的边界与职责划分。
|
||||
- [BIG_FISH_MAIN_IMAGE_TRANSPARENT_BACKGROUND_ALIGNMENT_2026-04-28.md](./BIG_FISH_MAIN_IMAGE_TRANSPARENT_BACKGROUND_ALIGNMENT_2026-04-28.md):记录大鱼吃小鱼等级主图与动作关键帧正式图在 Rust 后端复用 RPG 角色主图透明背景 alpha 后处理的对齐口径,并明确场地背景不走该处理。
|
||||
- [PUZZLE_RESULT_AUTOSAVE_AND_TAG_GATE_FIX_2026-04-28.md](./PUZZLE_RESULT_AUTOSAVE_AND_TAG_GATE_FIX_2026-04-28.md):记录拼图结果页名称与标签编辑自动保存、发布门槛统一到 `3~6` 标签,以及前端发布校验不再被旧 session blocker 卡死的修复口径。
|
||||
- [SPACETIMEDB_START_SH_EARLY_EXIT_DIAGNOSTICS_2026-04-27.md](./SPACETIMEDB_START_SH_EARLY_EXIT_DIAGNOSTICS_2026-04-27.md):记录发布包 `start.sh` 只输出“SpacetimeDB 进程在就绪前退出”时的诊断补强,启动失败或超时时自动回显 `logs/spacetimedb.log`、`server ping`、端口监听和 root-dir 相关进程。
|
||||
- [RPG_AND_AGENT_CHAT_TRUE_SSE_STREAMING_2026-04-26.md](./RPG_AND_AGENT_CHAT_TRUE_SSE_STREAMING_2026-04-26.md):记录 RPG 运行时 NPC 聊天、RPG/自定义世界 Agent 与大鱼 Agent 从“拼完整 SSE 字符串后一次性返回”改为 `mpsc + Sse<Event>` 真流式输出的后端落地口径。
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
# RPG 创作 profile 生成后端迁移(2026-04-28)
|
||||
|
||||
## 1. 背景
|
||||
|
||||
`docs/audits/engineering/RPG_FRONTEND_SCRIPT_BACKEND_MIGRATION_AUDIT_2026-04-28.md` 的 5.3 指出,`src/services/rpg-creation/rpgCreationGenerationClient.ts` 在非浏览器环境仍会动态 `import('../ai')`,让 RPG 创作 profile 生成继续保留前端 legacy AI 后门。
|
||||
|
||||
这与当前边界冲突:
|
||||
|
||||
1. 前端只负责表现和 API client。
|
||||
2. RPG 创作 prompt 与 LLM 编排只能在 `server-rs/crates/api-server/src/prompt/rpg/` 与 `api-server` 侧出现。
|
||||
3. 外部 LLM 调用不能进入 SpacetimeDB reducer,必须由 Axum / `platform-llm` 完成后再把确定结果交给后续持久化链。
|
||||
|
||||
## 2. 本轮落地
|
||||
|
||||
### 2.1 前端
|
||||
|
||||
`src/services/rpg-creation/rpgCreationGenerationClient.ts` 现在不再判断 `typeof window`,也不再动态导入 `src/services/ai.ts`。
|
||||
|
||||
无论浏览器、SSR 还是 Vitest node 环境,`generateRpgWorldProfile(...)` 都只调用:
|
||||
|
||||
```text
|
||||
POST /api/runtime/custom-world/profile
|
||||
```
|
||||
|
||||
测试如需离线运行,应 mock `requestJson`,不能恢复本地 AI 生成链。
|
||||
|
||||
### 2.2 后端
|
||||
|
||||
`server-rs/crates/api-server/src/app.rs` 新增:
|
||||
|
||||
```text
|
||||
POST /api/runtime/custom-world/profile
|
||||
```
|
||||
|
||||
handler 落在 `server-rs/crates/api-server/src/custom_world.rs`:
|
||||
|
||||
1. 校验 `settingText`。
|
||||
2. 要求 Bearer 鉴权。
|
||||
3. 要求 `platform-llm` 可用。
|
||||
4. 复用 `generate_custom_world_foundation_draft(...)` 生成 profile 草稿。
|
||||
5. 补齐结果页需要的 `id / settingText / templateWorldType / compatibilityTemplateWorldType / items / generationMode / generationStatus / creatorIntent`。
|
||||
6. 直接返回 `CustomWorldProfile` JSON,保持前端旧 client contract 不变。
|
||||
|
||||
本轮不新增 SpacetimeDB 表,不修改 `migration.rs`。
|
||||
|
||||
## 3. 验收
|
||||
|
||||
1. `src/services/rpg-creation/**` 不再出现 `import('../ai')`、`LegacyAiModule`、`loadLegacyAiModule`。
|
||||
2. `src/services/rpg-creation/index.ts` 不再导出 `generateLegacyCustomWorldProfile`。
|
||||
3. node 环境测试确认 profile 生成只走 `requestJson` mock。
|
||||
4. Rust `api-server` 测试确认 `/api/runtime/custom-world/profile` 未登录返回 `401`。
|
||||
@@ -0,0 +1,82 @@
|
||||
# RPG 创作结果页后端真相视图迁移方案(2026-04-28)
|
||||
|
||||
## 1. 本次落地边界
|
||||
|
||||
本次只迁移 `RPG_FRONTEND_SCRIPT_BACKEND_MIGRATION_AUDIT_2026-04-28.md` 中 5.4 对应链路:
|
||||
|
||||
1. 创作结果页自动保存前的 profile normalize 与 session 同步顺序。
|
||||
2. Agent session / result preview / legacyResultProfile 的真相优先级。
|
||||
3. 作品草稿点击后应进入 Agent workspace、生成过程页还是结果页的裁决。
|
||||
|
||||
不在本轮处理运行时 GameState、战斗、NPC、背包和锻造规则。
|
||||
|
||||
## 2. 后端读模型
|
||||
|
||||
新增稳定读模型:
|
||||
|
||||
```text
|
||||
GET /api/runtime/custom-world/agent/sessions/:sessionId/result-view
|
||||
```
|
||||
|
||||
响应字段:
|
||||
|
||||
1. `session`:最新 Agent session snapshot。
|
||||
2. `profile`:服务端按优先级选出的结果页 profile。
|
||||
3. `profileSource`:`result_preview` / `draft_profile` / `none`。
|
||||
4. `targetStage`:前端应打开的 stage。
|
||||
5. `generationViewSource` / `resultViewSource`:前端视图来源。
|
||||
6. `canAutosaveLibrary`:作品库自动保存是否可执行。
|
||||
7. `canSyncResultProfile`:结果页编辑是否允许回写 session。
|
||||
8. `recoveryAction`:缺失或失败时的恢复指令。
|
||||
|
||||
## 3. 真相优先级
|
||||
|
||||
服务端统一执行以下优先级,前端不再自己解释:
|
||||
|
||||
1. 首选 `session.resultPreview.preview`。
|
||||
2. 若没有 result preview,但 `draftProfile` 是已可打开结果页的完整 profile,则使用 `draftProfile`。
|
||||
3. `draftProfile.legacyResultProfile` 只作为后端兼容恢复来源,不再由前端直接读取。
|
||||
4. 没有可用 profile 时,服务端返回 `targetStage` 指示前端回生成过程页或 Agent workspace。
|
||||
|
||||
## 4. 保存链路
|
||||
|
||||
结果页编辑仍允许前端持有临时表单态,但保存必须按顺序:
|
||||
|
||||
1. 前端调用 `sync_result_profile` action,把编辑后的 profile 写回 Agent session。
|
||||
2. 前端读取 `result-view`,以服务端返回的 `profile` 刷新界面。
|
||||
3. 自动保存作品库只保存 `result-view.profile`,不再自己决定 session/profile 优先级。
|
||||
4. Agent 结果页保存成功后,作品库响应只刷新列表、详情与自动保存签名;当前编辑界面仍以 `result-view.profile` 为准,避免兼容响应缺少角色、地标等完整字段时覆盖正在编辑的结果页。
|
||||
|
||||
### 4.1 保存前 profile canonicalize
|
||||
|
||||
`creatorIntent -> settingText` 的保存前归一必须在后端执行:
|
||||
|
||||
1. `sync_result_profile` action 入站时,后端基于 `payload.profile.creatorIntent` 生成 canonical `settingText` 后再写入 Agent session 与 `resultPreview`。
|
||||
2. `PUT /api/runtime/custom-world-library/:profileId` 入站时,后端对 `payload.profile` 执行同一规则后再抽取 metadata 与写入作品库。
|
||||
3. 前端结果页、作品详情页、平台壳层只能保存用户当前编辑草稿,不再调用 `normalizeRpgEntryAgentBackedProfile(...)` 改写正式字段。
|
||||
4. 前端自动保存去重签名使用草稿 JSON 本身;保存成功后以服务端返回的 canonical entry/result-view 刷新界面。
|
||||
|
||||
该规则的唯一语义是:当 `creatorIntent` 含有有效锚点时,按“世界一句话 / 玩家开局 / 主题气质 / 核心冲突 / 关键关系 / 标志元素”的固定顺序生成 foundation text,并覆盖保存入库或 session 的 `settingText`。没有有效锚点时不改写用户草稿。
|
||||
|
||||
## 5. 前端职责
|
||||
|
||||
前端只保留:
|
||||
|
||||
1. 页面切换。
|
||||
2. loading / error / autosave 状态。
|
||||
3. 用户正在编辑的临时 profile。
|
||||
4. 调用后端 action 和 result-view。
|
||||
|
||||
前端禁止继续:
|
||||
|
||||
1. 直接读取 `draftProfile.legacyResultProfile`。
|
||||
2. 自行判断草稿应打开 Agent workspace、生成过程页还是结果页。
|
||||
3. 自动保存前只刷新 session 后用 session 旧快照覆盖本地编辑。
|
||||
|
||||
## 6. 验收
|
||||
|
||||
1. `rpgCreationPreviewAdapter` 不再读取 `legacyResultProfile`。
|
||||
2. `useRpgCreationResultAutosave` 对 Agent 草稿结果页会先执行 `sync_result_profile`,再读取后端 result-view。
|
||||
3. `useRpgEntryLibraryDetail` 根据 result-view 的 `targetStage` 切页。
|
||||
4. 测试覆盖编辑后不会被旧 session 覆盖、无 result preview 时由后端决定恢复入口。
|
||||
5. 测试覆盖 `sync_result_profile` 与作品库 upsert 入站时由后端 canonicalize `settingText`,前端 autosave 不再保存前 normalize。
|
||||
@@ -6,44 +6,36 @@
|
||||
|
||||
统一角色属性系统把一个世界中“角色能力如何被理解”收口到 `CustomWorldProfile.attributeSchema.slots`。这六个 slot 是世界级设定,不是单个角色自己的六个字段。
|
||||
|
||||
当前结果页世界页可以展示角色维度,但编辑世界信息时只能修改世界名称、概述、基调、目标等文本,尚不能手动修订六个维度本身的信息。
|
||||
当前结果页世界页可以展示角色维度。旧方案曾允许编辑维度定义、正负信号和战斗/社交/探索用途,但这些字段会让创作和提示词下游过早背负规则说明。本轮收缩为只允许修订六个维度名称。
|
||||
|
||||
## 2. 本次目标
|
||||
|
||||
在“编辑世界信息”独立面板中允许用户编辑六个角色维度的信息:
|
||||
在“编辑基本设定”独立面板中允许用户编辑六个角色维度名称:
|
||||
|
||||
1. 修改 `attributeSchema.slots` 中每个维度的 `name`、`definition`、`positiveSignals`、`negativeSignals`、`combatUseText`、`socialUseText`、`explorationUseText`。
|
||||
1. 只修改 `attributeSchema.slots` 中每个维度的 `name`。
|
||||
2. 不在可扮演角色或场景角色编辑器中新增单角色六维数值编辑。
|
||||
3. 保存时同步更新 `profile.attributeSchema`。
|
||||
4. 若 `profile.ownedSettingLayers.ruleProfile.attributeSchema` 存在,同步写入同一份 schema,避免世界档案和设定层出现双源漂移。
|
||||
5. 前端只负责编辑结构化文本,不新增属性结算逻辑。
|
||||
5. 前端只负责编辑名称,不新增属性结算逻辑,也不再保存维度说明、正负信号和用途文本。
|
||||
|
||||
## 3. 交互设计
|
||||
|
||||
入口位置:
|
||||
|
||||
- 世界页点击“世界概述”里的编辑按钮
|
||||
- 打开现有“编辑世界信息”面板
|
||||
- 世界页点击“基本设定”里的编辑按钮
|
||||
- 打开现有“编辑基本设定”面板
|
||||
- 在基础世界文本字段下方增加“角色维度”区块
|
||||
|
||||
每个维度展示并允许编辑:
|
||||
|
||||
- 维度名称
|
||||
- 定义
|
||||
- 正向信号
|
||||
- 负向信号
|
||||
- 战斗体现
|
||||
- 社交体现
|
||||
- 探索体现
|
||||
|
||||
正向信号与负向信号使用逗号、中文逗号或换行拆分成数组。
|
||||
每个维度只展示并允许编辑“维度名称”。
|
||||
|
||||
## 4. 数据落点
|
||||
|
||||
保存路径:
|
||||
|
||||
- `profile.attributeSchema.slots[n]`
|
||||
- `profile.ownedSettingLayers.ruleProfile.attributeSchema.slots[n]`,仅当 `ownedSettingLayers` 已存在时同步
|
||||
- `profile.attributeSchema.slots[n].name`
|
||||
- `profile.ownedSettingLayers.ruleProfile.attributeSchema.slots[n].name`,仅当 `ownedSettingLayers` 已存在时同步
|
||||
|
||||
系统仍保留 `slotId` 作为稳定键,解析旧草稿时会丢弃旧 `definition`、`positiveSignals`、`negativeSignals`、`combatUseText`、`socialUseText`、`explorationUseText` 字段。
|
||||
|
||||
不修改:
|
||||
|
||||
@@ -52,7 +44,7 @@
|
||||
|
||||
## 5. 验收
|
||||
|
||||
1. 世界信息面板能看到六个角色维度。
|
||||
2. 修改任一维度名称、定义、信号或三类用途说明后,保存到 `profile.attributeSchema.slots`。
|
||||
1. 基本设定面板能看到六个角色维度名称。
|
||||
2. 修改任一维度名称后,保存到 `profile.attributeSchema.slots`,且不会写回旧说明字段。
|
||||
3. 编辑角色自身时不出现单角色六维数值输入区。
|
||||
4. UI 仍读取当前世界 schema,不回退写死旧四维文案。
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
# RPG 选项函数与提示词编辑面整理方案(2026-04-28)
|
||||
|
||||
## 背景
|
||||
|
||||
当前 RPG 运行时已经把不少选项 function 的定义拆到了 `src/data/functionCatalog/`,但仍存在两个影响编辑效率的问题:
|
||||
|
||||
1. `src/data/stateFunctions.ts` 里还残留一批按 `functionId` 分支的运行时文案、优先级与细节逻辑,导致“定义在独立文件,行为还混在总文件里”。
|
||||
2. RPG 运行时提示词虽然已经有独立模块,但前端 `src/prompts/` 与 Rust `server-rs/crates/api-server/src/prompt/` 里仍然缺少按 `rpg` 维度统一收口的子目录,编辑提示词时仍要在多个平铺文件里来回找。
|
||||
|
||||
用户目标是:
|
||||
|
||||
1. RPG 中不同选项 function 拆成独立函数,并且能在同一个脚本中看到所有选项 function 的代码入口。
|
||||
2. RPG 中运行时提示词都整理进 `prompt` 文件夹,并把 RPG prompt 脚本整理到更适合专注编辑提示词的结构中。
|
||||
|
||||
## 本次落地边界
|
||||
|
||||
1. 只整理 RPG 相关的前端运行时 function 与 prompt 结构。
|
||||
2. Rust 侧只整理 `server-rs` 的 prompt 模块结构,不兼容 `server-node`。
|
||||
3. 不改玩法语义,不重写大段中文提示词正文;优先移动文件、补兼容导出、增加聚合入口。
|
||||
4. 不在 UI 里增加说明文案。
|
||||
|
||||
## 目标结构
|
||||
|
||||
### 前端 RPG prompt
|
||||
|
||||
整理为:
|
||||
|
||||
```text
|
||||
src/prompts/
|
||||
├─ customWorldEntityActionPrompts.ts
|
||||
├─ customWorldPrompts.ts
|
||||
└─ rpg/
|
||||
├─ index.ts
|
||||
├─ runtimeStoryPrompts.ts
|
||||
└─ characterChatPrompts.ts
|
||||
```
|
||||
|
||||
说明:
|
||||
|
||||
1. `runtimeStoryPrompts.ts` 承载原 `storyPromptBuilders.ts` 的 RPG 运行时剧情导演、NPC 对话导演、招募对话等提示词。
|
||||
2. `characterChatPrompts.ts` 承载原角色面板私聊提示词。
|
||||
3. 旧入口 `src/services/prompt.ts` 与 `src/services/characterChatPrompt.ts` 保留兼容转发,避免一次性改调用方。
|
||||
4. 角色资产工坊默认 prompt 与缓存合并规则不再放在前端 prompt 目录,统一迁到 `server-rs/crates/api-server/src/prompt/rpg/role_asset_studio.rs`。
|
||||
|
||||
### Rust 侧 RPG prompt
|
||||
|
||||
整理为:
|
||||
|
||||
```text
|
||||
server-rs/crates/api-server/src/prompt/
|
||||
├─ big_fish.rs
|
||||
├─ character_animation.rs
|
||||
├─ character_visual.rs
|
||||
├─ puzzle_image.rs
|
||||
├─ scene_background.rs
|
||||
├─ mod.rs
|
||||
└─ rpg/
|
||||
├─ mod.rs
|
||||
├─ agent_chat.rs
|
||||
├─ foundation_draft.rs
|
||||
├─ role_asset_studio.rs
|
||||
└─ runtime_chat.rs
|
||||
```
|
||||
|
||||
说明:
|
||||
|
||||
1. `prompt/rpg/agent_chat.rs` 承载 RPG 共创聊天提示词。
|
||||
2. `prompt/rpg/foundation_draft.rs` 承载 RPG 草稿生成提示词。
|
||||
3. `prompt/rpg/role_asset_studio.rs` 承载角色资产工坊默认 prompt、legacy prompt 过滤与缓存合并 workflow view。
|
||||
4. `prompt/rpg/runtime_chat.rs` 承载 RPG 运行时剧情、NPC 对话、战斗结果叙事等提示词。
|
||||
5. 顶层 `prompt/mod.rs` 继续向外导出 RPG 子模块,保证原调用点只做最小修改。
|
||||
|
||||
### RPG function 总览
|
||||
|
||||
新增一个面向编辑者的聚合入口,用来同时暴露:
|
||||
|
||||
1. 所有 RPG function 文档项。
|
||||
2. 所有状态类 function source。
|
||||
3. 每个状态类 function 的运行时行为处理器入口。
|
||||
|
||||
目标是让后续查看时可以先打开一个总览文件,再跳到对应 function 文件,而不是先从 `stateFunctions.ts` 的大 `switch` 里反查。
|
||||
|
||||
## 代码落地策略
|
||||
|
||||
### 1. function 运行时逻辑继续拆分
|
||||
|
||||
把 `src/data/stateFunctions.ts` 中这些按 `functionId` 写死的逻辑继续拆出:
|
||||
|
||||
1. 建议 actionText 生成。
|
||||
2. detailText 生成。
|
||||
3. function priority 计算。
|
||||
4. 必要的运行时 definition 微调。
|
||||
|
||||
每个状态类 function 文件在保留 `definition + documentation + promptDescription` 的基础上,追加该 function 的运行时处理器。
|
||||
|
||||
### 2. 总览脚本
|
||||
|
||||
新增聚合入口文件,统一导出:
|
||||
|
||||
1. 各域 function 文档。
|
||||
2. 状态类 function runtime source。
|
||||
3. 便于编辑时查找的数组/映射。
|
||||
|
||||
这样“同一个脚本看到所有选项 function 的代码”具体落地为:
|
||||
|
||||
1. 先看总览脚本知道有哪些 function。
|
||||
2. 每个 function 仍在独立文件维护,避免再次回到一个巨型文件。
|
||||
3. 总览脚本只能依赖 `state / npc / treasure / flow / panel` 等分目录入口,不能从 `src/data/functionCatalog/index.ts` 反向导入聚合常量,避免浏览器 ESM 初始化时出现 `Cannot access before initialization`。
|
||||
|
||||
## 验证
|
||||
|
||||
1. `npm run check:encoding`
|
||||
2. `npm run test -- src/services/prompt.test.ts src/hooks/rpg-runtime-story/storyResponseOptions.test.ts`
|
||||
3. `npm run typecheck`
|
||||
4. 如涉及 Rust prompt 模块编译错误,再补 `cargo check -p api-server`
|
||||
|
||||
## 后续编辑约定
|
||||
|
||||
1. 想改 RPG 运行时提示词时,优先进入:
|
||||
- 前端:`src/prompts/rpg/`
|
||||
- Rust:`server-rs/crates/api-server/src/prompt/rpg/`
|
||||
2. 想改 RPG 选项 function 时,优先进入:
|
||||
- 总览:`src/data/functionCatalog/index.ts`
|
||||
- 状态类分项:`src/data/functionCatalog/state/*.ts`
|
||||
3. 后续不要再把 RPG prompt 正文重新塞回 `services`、路由或运行时编排文件。
|
||||
@@ -0,0 +1,99 @@
|
||||
# RPG 背包 / 装备 / 锻造视图后端迁移落地方案(2026-04-28)
|
||||
|
||||
## 0. 本次目标
|
||||
|
||||
依据 `docs/audits/engineering/RPG_FRONTEND_SCRIPT_BACKEND_MIGRATION_AUDIT_2026-04-28.md` 的 `5.2 P1` 项,本次只收口一类职责:
|
||||
|
||||
**背包、装备、锻造面板的可用性、禁用原因、配方视图由 `server-rs` 计算,前端只渲染后端 view,并提交用户选择。**
|
||||
|
||||
本次不顺手迁移以下 P0 链路:
|
||||
|
||||
1. runtime action 仍携带完整 `GameState` 快照。
|
||||
2. 战斗胜负后处理与旅行桥接仍在既有阶段迁移。
|
||||
3. `inventory_slot` 表真相与 compat `GameState` 快照仍按现状共存。
|
||||
|
||||
## 1. 后端落点
|
||||
|
||||
本阶段采用最小后端落点:
|
||||
|
||||
1. `server-rs/crates/shared-contracts/src/runtime_story.rs`
|
||||
- 扩展 `RuntimeStoryViewModel`,新增 `inventory` 字段。
|
||||
- 定义背包物品、装备槽、物品动作、锻造配方、配方材料需求的 view contract。
|
||||
2. `server-rs/crates/module-runtime-story-compat/src/forge.rs`
|
||||
- 公开确定性的配方定义与需求统计能力。
|
||||
- 配方定义补齐前端已有 `forgeSystem.ts` 的三条 forge 配方。
|
||||
3. `server-rs/crates/module-runtime-story-compat/src/view_model.rs`
|
||||
- 从 `GameState` 快照编译 `inventory` view model。
|
||||
- 输出每个动作的 `enabled / reason`,由后端统一说明为什么不可用。
|
||||
4. `server-rs/crates/api-server/src/runtime_story/compat/*`
|
||||
- 原 action resolver 复用同一套 forge 定义和可用性判断。
|
||||
|
||||
## 2. 前端落点
|
||||
|
||||
1. `packages/shared/src/contracts/rpgRuntimeStoryState.ts`
|
||||
- 与 Rust contract 对齐新增 `inventory` view 类型。
|
||||
2. `src/services/rpg-runtime/rpgRuntimeStoryClient.ts`
|
||||
- 提供 `loadRpgRuntimeInventoryView`,通过 runtime story session state 获取后端 view。
|
||||
- 当前 runtime story 主链已完成不上传完整 `GameState` 的迁移,因此该读取入口只按 `runtimeSessionId` 请求后端持久化状态。
|
||||
3. `src/hooks/rpg-runtime-story/inventoryActions.ts`
|
||||
- 删除 `getForgeRecipeViews(...)` 本地配方计算。
|
||||
- 不再通过本地 `playerInventory.find(...)` / `playerEquipment[...]` 作为正式动作门禁。
|
||||
- 使用后端 view 的 `actions` 与 `forgeRecipes` 判断按钮可用性和 action 文案。
|
||||
4. `src/components/InventoryPanel.tsx`
|
||||
- 继续只展示传入的 view。
|
||||
- 支持展示后端 `disabledReason`,不再自行解释配方规则。
|
||||
- 背包列表优先使用后端 `backpackItems`,货币文案优先使用后端 `currencyText`。
|
||||
|
||||
## 3. 可用性规则
|
||||
|
||||
后端 `inventory` view 应至少输出:
|
||||
|
||||
1. `backpackItems`
|
||||
- 背包里的物品快照。
|
||||
- `actions.use / equip / dismantle / reforge`。
|
||||
2. `equipmentSlots`
|
||||
- `weapon / armor / relic` 三槽。
|
||||
- 每槽当前装备与 `actions.unequip`。
|
||||
3. `forgeRecipes`
|
||||
- 配方 id、名称、类型、说明、产物、货币花费。
|
||||
- 每项需求的 `owned / quantity`。
|
||||
- `canCraft` 与 `disabledReason`。
|
||||
4. `currencyText`
|
||||
- 由后端按 `worldType` 格式化。
|
||||
|
||||
禁用规则:
|
||||
|
||||
1. 缺少玩家角色时,所有正式动作不可用。
|
||||
2. 战斗中,装备 / 卸装 / 锻造 / 拆解 / 重铸不可用;可用物品仍可由战斗动作链处理。
|
||||
3. 不可使用的物品返回 `use.enabled=false`。
|
||||
4. 非装备物品返回 `equip.enabled=false`。
|
||||
5. 无 buildProfile 且非装备的物品不可拆解。
|
||||
6. 非装备或材料不足 / 货币不足的物品不可重铸。
|
||||
7. 材料或货币不足的配方不可制作,并返回原因。
|
||||
|
||||
## 4. 验收
|
||||
|
||||
1. 前端 `inventoryActions.ts` 不再引用 `getForgeRecipeViews`。
|
||||
2. 前端配方按钮使用后端 `forgeRecipes`。
|
||||
3. 后端 `RuntimeStoryViewModel` JSON 中存在 `inventory`。
|
||||
4. Rust contract / compat view model 有单元测试覆盖:
|
||||
- 配方 `canCraft` 与需求数量。
|
||||
- 装备、卸装、拆解、重铸禁用原因。
|
||||
5. TypeScript client 测试覆盖后端 inventory view 获取与保留。
|
||||
6. 修改后执行:
|
||||
- Rust 相关测试。
|
||||
- TypeScript 相关测试。
|
||||
- `npm run api-server:maincloud`。
|
||||
|
||||
## 5. 本次实现结果
|
||||
|
||||
1. `server-rs` 已在 `RuntimeStoryViewModel.inventory` 输出背包、装备槽、锻造配方、动作 payload 与禁用原因。
|
||||
2. `module-runtime-story-compat` 的锻造配方定义已补齐 `forgeSystem.ts` 中仍留在前端的合成 / 锻造配方,并对 `mana / 法力` 标签做后端匹配兼容。
|
||||
3. `src/hooks/rpg-runtime-story/inventoryActions.ts` 已改为:
|
||||
- 读取 `loadRpgRuntimeInventoryView(...)`。
|
||||
- 用户动作只使用后端 action view 的 `functionId / actionText / payload`。
|
||||
- 缺失或禁用时展示后端 `reason / disabledReason`。
|
||||
4. `src/components/InventoryPanel.tsx` 已改为:
|
||||
- 背包格子优先渲染后端 `backpackItems`。
|
||||
- 工坊列表渲染后端 `forgeRecipes`。
|
||||
- 禁用配方展示后端原因。
|
||||
@@ -0,0 +1,102 @@
|
||||
# RPG 提示词前端禁存与 server-rs 收口方案(2026-04-28)
|
||||
|
||||
## 背景
|
||||
|
||||
当前 RPG 运行时虽然已经大面积切到 `server-rs` 的 `/api/runtime/**`,但前端仍残留以下错误边界:
|
||||
|
||||
1. `src/services/ai.ts` 仍保留 RPG 剧情、角色私聊、NPC 对话、招募对话的本地 prompt 生成与浏览器侧 LLM fallback。
|
||||
2. `src/prompts/rpg/`、`src/prompts/storyPromptBuilders.ts`、`src/prompts/characterChatPrompts.ts` 仍存放 RPG 提示词正文。
|
||||
3. `src/services/aiService.ts` 在非浏览器环境下仍会回退到 `./ai`,等价于保留“前端可持有 RPG prompt”的技术后门。
|
||||
|
||||
这与仓库约束“前端只负责表现,逻辑、数据放后端”直接冲突,也会让提示词编辑入口继续分裂。
|
||||
|
||||
## 本次强约束
|
||||
|
||||
1. RPG 提示词禁止存在前端工程。
|
||||
2. RPG 提示词唯一允许存在于 `server-rs/crates/api-server/src/prompt/rpg/`。
|
||||
3. 前端只允许保留:
|
||||
- 运行时请求 contract
|
||||
- API client
|
||||
- UI 展示与交互状态
|
||||
4. 旧 `src/services/ai.ts` 不再承担 RPG 剧情/聊天 prompt 生成职责。
|
||||
|
||||
## 收口目标
|
||||
|
||||
### 后端唯一 prompt 目录
|
||||
|
||||
RPG 运行时提示词统一收口到:
|
||||
|
||||
```text
|
||||
server-rs/crates/api-server/src/prompt/rpg/
|
||||
├─ mod.rs
|
||||
├─ agent_chat.rs
|
||||
├─ foundation_draft.rs
|
||||
└─ runtime_chat.rs
|
||||
```
|
||||
|
||||
其中:
|
||||
|
||||
1. `agent_chat.rs` 负责创作态 RPG Agent prompt。
|
||||
2. `foundation_draft.rs` 负责 RPG 草稿生成 prompt。
|
||||
3. `runtime_chat.rs` 负责运行时剧情、角色私聊、NPC 聊天、招募对话等 prompt。
|
||||
|
||||
### 前端职责缩减
|
||||
|
||||
前端保留:
|
||||
|
||||
1. `src/services/aiService.ts`
|
||||
- 只负责请求 `/api/runtime/**`
|
||||
- 不再回退到本地 RPG prompt 构造
|
||||
2. `src/services/rpg-runtime/*`
|
||||
- 只负责按运行时域转发 client
|
||||
3. `src/hooks/rpg-runtime-story/*`
|
||||
- 只消费 API 回包并驱动 UI
|
||||
|
||||
前端移除:
|
||||
|
||||
1. `src/prompts/rpg/*`
|
||||
2. `src/prompts/storyPromptBuilders.ts`
|
||||
3. `src/prompts/characterChatPrompts.ts`
|
||||
4. `src/services/prompt.ts`
|
||||
5. `src/services/characterChatPrompt.ts`
|
||||
6. `src/services/ai.ts` 中全部 RPG prompt / RPG 本地 LLM fallback 逻辑
|
||||
|
||||
## 运行时接口对齐
|
||||
|
||||
为彻底去掉前端 prompt,`server-rs` 必须承接以下接口:
|
||||
|
||||
1. `POST /api/runtime/story/initial`
|
||||
2. `POST /api/runtime/story/continue`
|
||||
3. `POST /api/runtime/chat/character/suggestions`
|
||||
4. `POST /api/runtime/chat/character/summary`
|
||||
5. `POST /api/runtime/chat/character/reply/stream`
|
||||
6. `POST /api/runtime/chat/npc/dialogue/stream`
|
||||
7. `POST /api/runtime/chat/npc/turn/stream`
|
||||
8. `POST /api/runtime/chat/npc/recruit/stream`
|
||||
|
||||
其中:
|
||||
|
||||
1. 非流式接口统一返回 `{ text: string }`
|
||||
2. 流式接口统一返回可被前端直接消费的纯文本 SSE 增量
|
||||
3. NPC turn 仍保留当前带 `suggestions / functionSuggestions / chatDirective` 的专用 SSE 结构
|
||||
|
||||
## 前端代码落地要求
|
||||
|
||||
1. `aiService.ts` 在 RPG 相关方法中禁止再动态 `import('./ai')`
|
||||
2. 若在非浏览器环境误调用 RPG 运行时能力,应直接报错,明确提示必须走 `api-server`
|
||||
3. 角色私聊目标状态类型等纯类型定义可以留在前端,但必须与 prompt 文件彻底解耦
|
||||
|
||||
## 验证
|
||||
|
||||
1. `npm run check:encoding`
|
||||
2. `npm run test -- src/services/ai.test.ts src/hooks/rpg-runtime-story/storyResponseOptions.test.ts`
|
||||
3. `cargo check -p api-server`
|
||||
4. `npm run api-server:maincloud`
|
||||
|
||||
## 后续编辑约定
|
||||
|
||||
之后如果要改 RPG 提示词:
|
||||
|
||||
1. 只进入 `server-rs/crates/api-server/src/prompt/rpg/`
|
||||
2. 不允许在 `src/` 下新增任何 RPG prompt、system prompt、prompt builder
|
||||
3. 前端若出现“为了临时 fallback 先放一个 prompt”的需求,视为越界,必须改为补后端接口
|
||||
@@ -0,0 +1,103 @@
|
||||
# RPG 角色资产工坊默认 Prompt 与缓存合并后端迁移(2026-04-28)
|
||||
|
||||
## 背景
|
||||
|
||||
`docs/audits/engineering/RPG_FRONTEND_SCRIPT_BACKEND_MIGRATION_AUDIT_2026-04-28.md` 已明确指出:角色资产工坊中用户正在编辑的 prompt 草稿可以留在前端表单,但默认 prompt 生成、legacy prompt 判断、缓存合并和工作流初始态不应继续散落在 `RpgCreationRoleAssetStudioModalImpl.tsx`。
|
||||
|
||||
本次迁移只处理这一项边界,不扩大到角色生图、动作生成的模型参数默认值重构。
|
||||
|
||||
## 落地边界
|
||||
|
||||
1. 后端新增角色资产工坊 workflow view,负责输出:
|
||||
- 从角色字段挑选出的默认视觉 / 动作 / 场景 prompt 种子。
|
||||
- 过滤 legacy 旧生成 prompt 后的视觉 prompt。
|
||||
- 按动作 key 合并后的动作 prompt map。
|
||||
- 从缓存回填的候选图、选中候选、选中动作、形象资产和动作 map。
|
||||
2. 前端只把当前正在编辑的角色快照传给后端解析 workflow view。
|
||||
3. 前端保存缓存时只保存用户当前表单草稿和资产结果,不再计算合并规则。
|
||||
4. 现有 OSS JSON 缓存继续复用,不新增 SpacetimeDB 表结构,因此本轮不修改 `migration.rs`。
|
||||
|
||||
## 接口设计
|
||||
|
||||
新增解析接口:
|
||||
|
||||
```text
|
||||
POST /api/runtime/custom-world/asset-studio/role/{character_id}/workflow
|
||||
```
|
||||
|
||||
请求体:
|
||||
|
||||
```json
|
||||
{
|
||||
"cacheScopeId": "world-id",
|
||||
"role": {
|
||||
"id": "role-id",
|
||||
"name": "角色名",
|
||||
"title": "头衔",
|
||||
"role": "世界身份",
|
||||
"visualDescription": "角色视觉描述",
|
||||
"actionDescription": "角色动作描述",
|
||||
"sceneVisualDescription": "场景描述",
|
||||
"description": "通用描述",
|
||||
"backstory": "背景",
|
||||
"combatStyle": "战斗风格"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
响应体:
|
||||
|
||||
```json
|
||||
{
|
||||
"ok": true,
|
||||
"cache": {},
|
||||
"workflow": {
|
||||
"defaultPromptBundle": {
|
||||
"visualPromptText": "",
|
||||
"animationPromptText": "",
|
||||
"scenePromptText": ""
|
||||
},
|
||||
"visualPromptText": "",
|
||||
"animationPromptTextByKey": {
|
||||
"run": "",
|
||||
"attack": "",
|
||||
"idle": "",
|
||||
"die": ""
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
新增保存接口:
|
||||
|
||||
```text
|
||||
PUT /api/runtime/custom-world/asset-studio/role/{character_id}/workflow
|
||||
```
|
||||
|
||||
它复用原 `POST /api/assets/character-workflow-cache` 的 OSS JSON 缓存保存逻辑,并补齐 `animationPromptTextByKey` 持久化。
|
||||
|
||||
## 合并规则主源
|
||||
|
||||
后端主源:
|
||||
|
||||
```text
|
||||
server-rs/crates/api-server/src/prompt/rpg/role_asset_studio.rs
|
||||
```
|
||||
|
||||
规则保持现有语义:
|
||||
|
||||
1. `visualPromptText` 默认优先取 `visualDescription`,其次 `description`,长度上限 220。
|
||||
2. `animationPromptText` 默认优先取 `actionDescription`,其次 `combatStyle`,长度上限 180。
|
||||
3. `scenePromptText` 默认优先取 `sceneVisualDescription`,其次 `backstory`,长度上限 220。
|
||||
4. 角色存在新的 `visualDescription` 时,不使用缓存视觉 prompt 覆盖默认值。
|
||||
5. 角色存在新的 `actionDescription` 时,所有动作 prompt 使用新的默认动作 prompt。
|
||||
6. 角色没有新的动作描述时,逐动作优先使用 `animationPromptTextByKey`,再回退旧 `animationPromptText`,最后回退默认动作 prompt。
|
||||
7. 命中历史生成模板标记的视觉 / 动作 prompt 不再作为可继承缓存。
|
||||
|
||||
## 验收
|
||||
|
||||
1. `src/` 不再引用 `buildDefaultRolePromptBundle`。
|
||||
2. `RpgCreationRoleAssetStudioModalImpl.tsx` 不再包含 legacy prompt 判断与缓存合并函数。
|
||||
3. `CharacterWorkflowCachePayload` 能读写 `animationPromptTextByKey`。
|
||||
4. Rust 单测覆盖默认 prompt、legacy 过滤、逐动作缓存合并。
|
||||
5. 前端 client 单测覆盖 workflow 解析接口和 PUT 保存接口。
|
||||
@@ -0,0 +1,70 @@
|
||||
# RPG 运行时开局 GameState 后端迁移落地(2026-04-28)
|
||||
|
||||
## 目标
|
||||
|
||||
本次只收口 `RPG_FRONTEND_SCRIPT_BACKEND_MIGRATION_AUDIT_2026-04-28.md` 的 `4.1 P0 运行时开局 GameState 装配仍在前端`。
|
||||
|
||||
## 边界
|
||||
|
||||
前端保留:
|
||||
|
||||
1. 选择世界、选择角色、切换 tab、地图弹层等 UI 状态。
|
||||
2. 世界选择后的“尚未选角”中间态,用于展示角色选择页面。
|
||||
3. 调用后端开局接口并接收快照。
|
||||
|
||||
后端负责:
|
||||
|
||||
1. 生成 `runtimeSessionId` 与 `runtimeActionVersion`。
|
||||
2. 装配正式初始 `GameState`。
|
||||
3. 装配初始场景、opening act、首遇 NPC、NPC state。
|
||||
4. 装配初始背包、初始装备、血蓝、货币、技能冷却。
|
||||
5. 写入 runtime snapshot,成为后续 runtime story 的读取来源。
|
||||
|
||||
## 接口
|
||||
|
||||
新增:
|
||||
|
||||
```text
|
||||
POST /api/runtime/story/sessions
|
||||
```
|
||||
|
||||
请求:
|
||||
|
||||
```json
|
||||
{
|
||||
"worldType": "CUSTOM",
|
||||
"customWorldProfile": {},
|
||||
"character": {},
|
||||
"runtimeMode": "play",
|
||||
"disablePersistence": false
|
||||
}
|
||||
```
|
||||
|
||||
响应:
|
||||
|
||||
```json
|
||||
{
|
||||
"sessionId": "runtime-...",
|
||||
"serverVersion": 1,
|
||||
"snapshot": {
|
||||
"version": 2,
|
||||
"savedAt": "...",
|
||||
"bottomTab": "adventure",
|
||||
"gameState": {},
|
||||
"currentStory": null
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 验收
|
||||
|
||||
1. `useRpgSessionBootstrap.ts` 不再在 `handleCharacterSelect` 中本地构造完整初始 `GameState`。
|
||||
2. 开局后 `gameState.runtimeSessionId` 来自后端。
|
||||
3. 开局后 `gameState.currentScene === "Story"`。
|
||||
4. 自定义世界 opening act 能写入 `storyEngineMemory.currentSceneActState`。
|
||||
5. 自定义世界角色 `initialItems` 能进入背包并自动装配可推断槽位。
|
||||
6. 后端测试覆盖 opening act、首遇 NPC、初始物品、装备。
|
||||
|
||||
## 后续
|
||||
|
||||
本次仍沿用 runtime story compat 的 JSON `GameState` 桥接形态。后续阶段应继续把 `runtime_story` action 从“快照桥接”推进为 SpacetimeDB 表级状态读写。
|
||||
@@ -0,0 +1,63 @@
|
||||
# RPG 运行时战斗后处理后端迁移落地方案(2026-04-28)
|
||||
|
||||
## 目标
|
||||
|
||||
本方案承接 `docs/audits/engineering/RPG_FRONTEND_SCRIPT_BACKEND_MIGRATION_AUDIT_2026-04-28.md` 的 4.5 项,专门收口三类仍由前端补真相的逻辑:
|
||||
|
||||
1. 战斗胜利 / 切磋完成后的正式 `GameState` 清理。
|
||||
2. 玩家死亡后的复活场景、血蓝恢复与首场景 act 状态。
|
||||
3. 战斗结束后章节 act 推进与 `currentStory.deferredOptions` 编排。
|
||||
|
||||
## 边界
|
||||
|
||||
后端负责:
|
||||
|
||||
1. 根据 battle resolver 的 `outcome` 决定 `victory`、`spar_complete`、`defeat`、`escaped` 的最终状态。
|
||||
2. 写回 `inBattle`、`currentEncounter`、`sceneHostileNpcs`、`currentNpcBattleOutcome`、`playerHp`、`playerMana`、`storyEngineMemory.currentSceneActState`。
|
||||
3. 为死亡复活构造回到首场景的快照,并恢复 `playerHp = playerMaxHp`、`playerMana = playerMaxMana`。
|
||||
4. 为胜利 / 切磋完成构造只含 `story_continue_adventure` 的当前 story,并把真实后续 options 放入 `deferredOptions`。
|
||||
5. 在最后一幕或无需等待继续按钮时直接返回场景旅行 / 常规 fallback options。
|
||||
|
||||
前端只负责:
|
||||
|
||||
1. 播放 `presentation.battle` 对应动画。
|
||||
2. 使用 `response.snapshot.gameState` 与 `response.snapshot.currentStory` 渲染。
|
||||
3. 不再调用 `buildPostBattleVictoryState`、`buildPostBattleVictoryStory`、`buildRevivedFirstSceneState`、`buildDeathStory` 作为服务端动作后的正式状态。
|
||||
|
||||
## 后端落点
|
||||
|
||||
1. `server-rs/crates/module-runtime-story-compat/src/post_battle.rs`
|
||||
- 增加纯 JSON helper,迁移战斗后状态、复活和 scene act 推进。
|
||||
2. `server-rs/crates/api-server/src/runtime_story/compat.rs`
|
||||
- 在 `resolve_battle_action` 之后、生成 AI fallback 之前统一调用 post-battle finalizer。
|
||||
3. `server-rs/crates/api-server/src/runtime_story/compat/presentation.rs`
|
||||
- 复用现有 option / current story 构造函数。
|
||||
4. `packages/shared/src/contracts/rpgRuntimeStoryState.ts`
|
||||
- battle outcome 增加 `defeat`,避免前端类型层把失败误判成非战斗终局。
|
||||
|
||||
## 落地补充
|
||||
|
||||
1. 后端 post-battle finalizer 在 `resolve_battle_action` 之后、LLM fallback 之前执行,终局战斗不再生成额外 AI 文本。
|
||||
2. 胜利 / 切磋完成会清理战斗态并推进当前场景 act;非最后一幕只展示 `story_continue_adventure`,真实后续动作写入 `deferredOptions`。
|
||||
3. 败北复活会先写回首场景、回满血蓝、重置首场景 act,再基于复活后的场景重新生成 `deferredOptions`,避免沿用战斗前旧场景选项。
|
||||
4. story engine 投影额外接收 battle outcome;只有 `victory / spar_complete` 会记录胜利信号,`defeat` 不会被“战斗态从 true 变 false”误判成胜利。
|
||||
5. 前端 `runServerRuntimeChoiceAction` 的服务端路径不再调用 `postBattleFlow` 构造正式状态;死亡动画仍可短暂播放,但最终 `GameState/currentStory` 只采用后端 hydrated snapshot。
|
||||
|
||||
## 本轮收口记录
|
||||
|
||||
1. `choiceActions.ts` 删除 `shouldResolveCombatChoiceLocally(...)`,`battle_* / inventory_use` 不再因战斗可见态回落到本地 continuation。
|
||||
2. `storyChoiceContinuation.ts` 对 `battle_* / inventory_use` 以及被分类为 `battle / escape` 的动作加硬保护,误入时不会裁决掉落、复活、任务推进或战后 story。
|
||||
3. `storyChoiceRuntime.ts` 删除本地敌对 NPC 战斗奖励 helper,前端不再调用 `rollHostileNpcLoot(...)` 与 `addInventoryItems(...)` 生成正式战利品。
|
||||
4. 删除 `postBattleFlow.ts` 与其测试,前端不再保留死亡复活、胜利后 story、deferred options、章节推进的正式构造函数。
|
||||
5. `choiceActions.test.ts` 覆盖 `battle_use_skill`、stale `battle_attack_basic`、`inventory_use` 全部进入后端 resolver;`storyChoiceRuntime.test.ts` 继续覆盖服务端胜利 / 失败 snapshot 被直接采用。
|
||||
|
||||
## 验收
|
||||
|
||||
1. Rust 单测覆盖:
|
||||
- 服务端 battle victory 返回后,`currentEncounter = null`、`inBattle = false`、`currentStory.options = [story_continue_adventure]`、`deferredOptions` 存在。
|
||||
- 服务端 battle defeat 返回后,玩家复活到首场景,`playerHp/playerMana` 回满,`currentStory` 为死亡复活故事。
|
||||
2. 前端单测覆盖:
|
||||
- `runServerRuntimeChoiceAction` 对 `victory` 和 `defeat` 都直接采用服务端 snapshot/story,不再本地构造 post battle / revive 状态。
|
||||
3. 搜索确认 `src/hooks/rpg-runtime-story` 不再出现 `shouldResolveCombatChoiceLocally`、`buildPostBattleVictory*`、`buildRevivedFirstSceneState`、`buildDeathStory`、`buildHostileNpcBattleReward`。
|
||||
4. Rust 单测覆盖:
|
||||
- story engine 对 `defeat` outcome 不写入 `win_battle` 信号和敌压 mutation。
|
||||
@@ -0,0 +1,190 @@
|
||||
# RPG 运行时 Story Engine 后端迁移落地方案(2026-04-28)
|
||||
|
||||
## 0. 本轮目标
|
||||
|
||||
本轮只收口 `RPG_FRONTEND_SCRIPT_BACKEND_MIGRATION_AUDIT_2026-04-28.md` 中 `4.4 P0 story engine / chapter / world mutation` 这条。
|
||||
|
||||
目标不是新增一套前端规则,而是让运行时动作完成后由 `server-rs` 统一写回:
|
||||
|
||||
1. `storyHistory`
|
||||
2. `storyEngineMemory`
|
||||
3. `chapterState`
|
||||
4. `currentScenePreset.mutationStateText / currentPressureLevel / description`
|
||||
|
||||
前端只能展示这些后端字段,不能继续在 hook 中运行 `chapterDirector / threadSignalRouter / worldMutationRouter` 等正式状态机。
|
||||
|
||||
## 1. 后端落点
|
||||
|
||||
### 1.1 `module-runtime-story-compat`
|
||||
|
||||
新增 `story_engine.rs`,作为无 HTTP、无 `AppState` 的纯 JSON projector。
|
||||
|
||||
职责:
|
||||
|
||||
1. 确保 `storyEngineMemory` 最小结构存在。
|
||||
2. 按上一帧与下一帧快照生成 story signals。
|
||||
3. 基于信号推进 active thread、recent signal。
|
||||
4. 基于当前场景和任务生成 `ChapterState`。
|
||||
5. 基于章节和信号生成 `WorldMutation`。
|
||||
6. 把 mutation 投影到当前场景展示字段。
|
||||
7. 追加最小 chronicle、journey beat、continue digest。
|
||||
|
||||
### 1.2 `api-server runtime_story compat`
|
||||
|
||||
`resolve_runtime_story_action` 在动作确定性结算和 `storyHistory` 写入后,统一调用 projector,再持久化快照。
|
||||
|
||||
这样即使前端只提交 `functionId/payload`,正式叙事记忆也由后端结果生成。
|
||||
|
||||
## 2. 前端收口
|
||||
|
||||
### 2.1 `progressionActions.ts`
|
||||
|
||||
保留:
|
||||
|
||||
1. 展示层 loading/error。
|
||||
2. encounter 入场动画。
|
||||
3. 调用后端生成 story 或 fallback story。
|
||||
|
||||
移除:
|
||||
|
||||
1. `applyStoryEngineEchoes`
|
||||
2. 本地章节任务补发。
|
||||
3. 本地 thread signal、companion reaction、chapter、journey beat、world mutation、QA、release gate 等编排。
|
||||
|
||||
### 2.2 `storyContextBuilder.ts`
|
||||
|
||||
保留 prompt context 适配职责,但只能读取后端已存在的字段:
|
||||
|
||||
1. `state.chapterState`
|
||||
2. `state.storyEngineMemory.currentChapter`
|
||||
3. `state.storyEngineMemory.currentJourneyBeat`
|
||||
4. `state.storyEngineMemory.worldMutations`
|
||||
5. `state.currentScenePreset`
|
||||
|
||||
禁止继续导入并运行 story engine director。
|
||||
|
||||
## 3. 验收标准
|
||||
|
||||
1. `src/hooks/rpg-runtime-story/progressionActions.ts` 不再导入 `services/storyEngine/*`。
|
||||
2. `src/hooks/rpg-runtime-story/storyContextBuilder.ts` 不再导入 `services/storyEngine/*`。
|
||||
3. `resolve_runtime_story_action` 返回的 snapshot 中包含后端写入的 `storyEngineMemory.currentChapter`。
|
||||
4. 场景动作后 `currentScenePreset.mutationStateText` 由后端 projector 写入。
|
||||
5. `cargo test -p module-runtime-story-compat story_engine --manifest-path server-rs/Cargo.toml` 通过。
|
||||
6. `cargo test -p api-server runtime_story --manifest-path server-rs/Cargo.toml` 通过。
|
||||
7. 前端相关 vitest 与编码检查通过。
|
||||
|
||||
## 4. 本轮落地记录
|
||||
|
||||
### 4.1 后端已落地
|
||||
|
||||
1. `server-rs/crates/module-runtime-story-compat/src/story_engine.rs` 新增确定性 projector。
|
||||
2. `server-rs/crates/api-server/src/runtime_story/compat.rs` 在 action resolve 写入 `storyHistory` 后调用 projector,再保存 snapshot。
|
||||
3. `server-rs/crates/api-server/src/runtime_story/compat/tests.rs` 新增 route 边界测试,覆盖响应 snapshot 中的:
|
||||
- `chapterState.id`
|
||||
- `storyEngineMemory.currentChapter.id`
|
||||
- `quests[].chapterId`
|
||||
- `currentScenePreset.mutationStateText`
|
||||
- `storyEngineMemory.worldMutations`
|
||||
|
||||
### 4.2 前端已收口
|
||||
|
||||
1. `src/hooks/rpg-runtime-story/progressionActions.ts` 不再执行本地 story engine echo、chapter、journey beat、world mutation 编排。
|
||||
2. `src/hooks/rpg-runtime-story/storyContextBuilder.ts` 不再导入 `services/storyEngine/*`,只读取后端快照中已有的章节、旅程、mutation、chronicle、companion reaction 等字段。
|
||||
3. prompt context 中 `visibilitySlice / sceneNarrativeDirective / goalStack / activeScenarioPack / activeCampaignPack` 暂不在前端重建,等待后端后续模块正式写入后直接透传。
|
||||
|
||||
### 4.3 验证结果
|
||||
|
||||
已通过:
|
||||
|
||||
1. `cargo test -p module-runtime-story-compat story_engine --manifest-path server-rs\Cargo.toml`
|
||||
2. `cargo test -p api-server runtime_story --manifest-path server-rs\Cargo.toml`
|
||||
3. `cargo test -p api-server runtime_story_route_boundary_projects_story_engine_state --manifest-path server-rs\Cargo.toml`
|
||||
4. `npm run test -- src/hooks/rpg-runtime-story/storyRequestCoordinator.test.ts src/hooks/rpg-runtime-story/storyRequestRuntime.test.ts src/hooks/rpg-runtime-story/useRpgRuntimeStoryController.test.tsx src/hooks/rpg-runtime-story/choiceActions.test.ts src/hooks/rpg-runtime-story/storyInteractionCoordinator.test.ts`
|
||||
5. `npx eslint src/hooks/rpg-runtime-story/storyContextBuilder.ts src/hooks/rpg-runtime-story/progressionActions.ts --max-warnings 0`
|
||||
6. `cargo fmt --manifest-path server-rs\Cargo.toml --all --check`
|
||||
|
||||
已发现的非本轮阻塞:
|
||||
|
||||
1. `npm run typecheck` 当前被既有 NPC 交易、背包/锻造 UI、测试 fixture、`src/services/ai.ts` 缺 import 等错误拦截。
|
||||
2. `npm run test -- src/hooks/rpg-runtime-story` 当前有 1 个 `storyChoiceRuntime.test.ts` 战斗死亡/复活断言失败,属于审计后续 `4.5` post-battle 迁移范围。
|
||||
|
||||
### 4.4 NPC 聊天半量快照容错补丁
|
||||
|
||||
用户复测角色聊天时,点击 NPC 聊天选项后触发:
|
||||
|
||||
`Cannot read properties of undefined (reading 'length')`
|
||||
|
||||
复查调用链确认,后端 story engine projector 已经成为 `storyEngineMemory` 的主写入方,但部分快照或旧存档可能只携带 `currentChapter / worldMutations` 等增量字段,没有补齐 `activeThreadIds / recentCarrierIds / discoveredFactIds` 等数组字段。前端在 `syncNpcNarrativeState()` 中把半量对象当完整 `StoryEngineMemoryState` 消费,直接读取 `activeThreadIds.length`,导致 NPC 选项点击后的好感与叙事记忆同步中断。
|
||||
|
||||
本轮只做消费边界容错,不恢复前端 story engine 状态机:
|
||||
|
||||
1. `visibilityEngine.ts` 增加 `normalizeStoryEngineMemoryState()`,以 `createEmptyStoryEngineMemoryState()` 为基底补齐数组字段,同时保留后端快照已有字段。
|
||||
2. `syncNpcNarrativeState()` 与 `appendStoryEngineCarrierMemory()` 在读写叙事记忆前统一归一化,避免半量快照在 NPC 聊天、物品回声等路径里崩溃。
|
||||
3. `buildEncounterVisibilitySlice()` 与 `buildQuestVisibilitySlice()` 直接消费外部 memory 时也先归一化,保证 visibility 层独立调用时口径一致。
|
||||
4. 新增 `echoMemory.test.ts` 回归用例,覆盖只有 `currentChapter`、缺少 `activeThreadIds` 的后端投影快照。
|
||||
|
||||
验证:
|
||||
|
||||
1. `npm run test -- src/services/storyEngine/echoMemory.test.ts src/services/storyEngine/visibilityEngine.test.ts`
|
||||
2. `npm run check:encoding -- src/services/storyEngine/echoMemory.ts src/services/storyEngine/visibilityEngine.ts src/services/storyEngine/echoMemory.test.ts docs/technical/RPG_RUNTIME_STORY_ENGINE_BACKEND_MIGRATION_2026-04-28.md`
|
||||
|
||||
### 4.5 story prompt context 后端 projector 收口
|
||||
|
||||
本轮继续收口 `RPG_FRONTEND_SCRIPT_BACKEND_MIGRATION_COMPLETION_CHECK_2026-04-28.md` 中仍未完成的 `story engine / prompt context / AI story 请求编排`:
|
||||
|
||||
1. `server-rs/crates/module-runtime-story-compat/src/prompt_context.rs` 新增 `build_runtime_story_prompt_context(...)`,基于后端持久化 `gameState` 投影:
|
||||
- 场景描述、mutation、压力等级。
|
||||
- encounter / NPC 好感、披露阶段、可谈话题、首次接触姿态。
|
||||
- conversationSituation / conversationPressure / talkPriority。
|
||||
- chapter、journey beat、worldMutations、chronicle、party relationship notes。
|
||||
2. `POST /api/runtime/story/initial` 与 `POST /api/runtime/story/continue` 支持新主链 payload:
|
||||
- `sessionId`
|
||||
- `clientVersion`
|
||||
- `choice`
|
||||
- `lastFunctionId`
|
||||
- `observeSignsRequested`
|
||||
- `recentActionResult`
|
||||
- `requestOptions`
|
||||
3. 后端收到 `sessionId` 后只从服务端 runtime snapshot 读取 `worldType / playerCharacter / sceneHostileNpcs / storyHistory / prompt context`;旧 `worldType / character / history / context` 字段仅保留兼容,不作为正式主链来源。
|
||||
4. `runtime_chat_plain.rs` 与 `runtime_chat.rs` 同步支持 `sessionId`,角色私聊、NPC 对话、NPC 单轮聊天、招募对话的 prompt context 也由后端快照投影;前端只继续提交对话草稿、目标角色、玩家发言和必要 UI 临时项。
|
||||
5. `src/hooks/rpg-runtime-story/storyContextBuilder.ts` 缩减为 session 元信息适配器,不再推导 `conversationSituation / conversationPressure / NPC disclosure / partyRelationshipNotes / scene pressure` 等正式上下文。
|
||||
6. `src/services/aiService.ts` 在存在 `runtimeSessionId` 时,story initial/continue 只提交 session 轻量 payload;聊天接口只附带 `sessionId` 与对话输入,不再上传完整 `StoryGenerationContext`。
|
||||
7. `src/hooks/rpg-runtime-story/sessionActions.ts` 领取任务奖励时不再运行前端 `chapterDirector / echoMemory`,只保留旧 UI 层奖励展示所需的本地字段;章节和 `storyEngineMemory.currentChapter` 等正式叙事字段等待后端 action snapshot 刷新。
|
||||
8. `src/hooks/rpg-runtime-story/useRpgRuntimeNpcInteraction.ts` NPC 聊天闭合后不再调用前端 scene act runtime 推进 `storyEngineMemory.currentSceneActState`,也不再把 `deferredRuntimeState.storyEngineMemory` 写回正式 `GameState`。
|
||||
9. `src/hooks/rpg-runtime-story/choiceActions.ts` 兼容旧 `deferredRuntimeState` 时只允许采用场景字段,不再从 story moment 写入 `storyEngineMemory`。
|
||||
|
||||
新增验证:
|
||||
|
||||
1. `cargo test -p module-runtime-story-compat prompt_context --manifest-path server-rs\Cargo.toml`
|
||||
2. `cargo test -p shared-contracts runtime_story_ai_request --manifest-path server-rs\Cargo.toml`
|
||||
3. `cargo test -p api-server runtime_story_initial_uses_server_snapshot_prompt_context_when_session_id_present --manifest-path server-rs\Cargo.toml`
|
||||
4. `cargo check -p api-server --manifest-path server-rs\Cargo.toml --message-format short`
|
||||
5. `npm run test -- src/services/ai.test.ts src/hooks/rpg-runtime-story/storyRequestCoordinator.test.ts src/hooks/rpg-runtime-story/useRpgRuntimeStoryController.test.tsx`
|
||||
6. `npm run test -- src/hooks/rpg-runtime-story/sessionActions.test.ts src/hooks/rpg-runtime-story/choiceActions.test.ts src/hooks/rpg-runtime-story/npcEncounterActions.test.ts`
|
||||
7. `npx eslint src/hooks/rpg-runtime-story/sessionActions.ts src/hooks/rpg-runtime-story/sessionActions.test.ts src/hooks/rpg-runtime-story/useRpgRuntimeNpcInteraction.ts src/hooks/rpg-runtime-story/choiceActions.ts src/hooks/rpg-runtime-story/choiceActions.test.ts --max-warnings 0`
|
||||
|
||||
### 4.6 `camp_travel_home_scene` 后端收尾
|
||||
|
||||
本轮继续收口完成度核验中最后一个残留点:`camp_travel_home_scene` 不能再被前端 `runCampTravelHomeChoice(...)` 提前拦截并本地拼装正式状态。
|
||||
|
||||
落地规则:
|
||||
|
||||
1. 前端点击 `camp_travel_home_scene` 后统一进入 `runServerRuntimeChoiceAction(...)`,只提交 `sessionId / clientVersion / functionId / optionText / runtimePayload`。
|
||||
2. `server-rs/crates/api-server/src/runtime_story/compat.rs` 负责解析离营目标场景:
|
||||
- 优先使用 action payload 中的 `targetSceneId`。
|
||||
- 内置世界按 `playerCharacter.id + worldType` 映射到角色主场景。
|
||||
- 自定义世界优先找玩家角色在 `landmarks[].sceneNpcIds` 中绑定的地点,否则使用当前营地的 `forwardSceneId / connectedSceneIds` 或第一个 landmark。
|
||||
3. 后端 resolver 写入完整离营状态:
|
||||
- `currentScenePreset`
|
||||
- `currentEncounter / sceneHostileNpcs / npcInteractionActive / inBattle`
|
||||
- `runtimeStats.scenesTraveled`
|
||||
- `playerX / playerFacing / animationState / playerActionMode / scrollWorld`
|
||||
- `lastObserveSigns* / currentBattle* / spar* / activeCombatEffects`
|
||||
4. 后端在目标场景上生成确定性的 encounter preview;内置场景至少带一个可交互 NPC,自定义场景复用 `build_custom_scene_preset(...)` 中的 NPC 投影。
|
||||
5. 后端保存 `storyHistory` 与 `currentStory`,随后继续走 `project_story_engine_after_action(...)` 和持久化快照。
|
||||
6. 前端保留 `camp_travel_home_scene` 作为 function id 与展示用 helper,但不再保留正式状态构造函数。
|
||||
|
||||
验证新增:
|
||||
|
||||
1. 后端 route 测试覆盖 `camp_travel_home_scene` 点击后 hydrated snapshot 已进入角色主场景、生成 encounter preview、递增 `scenesTraveled` 并持久化。
|
||||
2. 前端 `choiceActions.test.ts` 覆盖 `camp_travel_home_scene` 即使命中旧 helper 判定,也只调用 `runServerRuntimeChoiceAction(...)`。
|
||||
@@ -7,7 +7,7 @@ RPG Agent 生成世界草稿时,前端会把 `draftProfile` 归一化成 `Cust
|
||||
## 落地约束
|
||||
|
||||
- `draftProfile.attributeSchema` 是世界草稿真相源的一部分,必须随 foundation draft 一起生成并保存。
|
||||
- 六维固定使用 `axis_a` 到 `axis_f` 六个槽位,但 `schemaName`、每个槽位 `name` 和说明必须贴合本次世界设定。
|
||||
- 六维固定使用 `axis_a` 到 `axis_f` 六个系统槽位,但创作、提示词输出、解析后保存的数据只保留每个槽位的 `name`。`slotId` 由系统补齐用于数值映射,不要求模型理解或生成额外说明字段。
|
||||
- 维度名不得沿用通用旧词:生命、法力、护甲、攻击、防御、力量、敏捷、智力、精神。
|
||||
- 若模型遗漏或结构不合规,后端必须生成中文兜底属性体系,不能让前端只靠固定模板补齐。
|
||||
- 世界页面的“世界”页签必须展示当前 `attributeSchema.slots` 的六个名称,作为玩家进入世界前可见的规则信号。
|
||||
@@ -25,7 +25,7 @@ RPG Agent 生成世界草稿时,前端会把 `draftProfile` 归一化成 `Cust
|
||||
3. `server-rs/crates/api-server/src/custom_world_foundation_draft.rs`
|
||||
- `normalize_framework_shape()` 归一化 `attributeSchema`。
|
||||
- `build_foundation_draft_profile_from_framework()` 将归一化后的 `attributeSchema` 写入 `draftProfile`。
|
||||
- 新增兜底生成器,基于世界名、基调、目标、冲突和种子文本生成六个中文维度。
|
||||
- 新增兜底生成器,基于世界名、基调、目标、冲突和种子文本生成六个中文维度名称。
|
||||
|
||||
4. `src/components/CustomWorldEntityCatalog.tsx`
|
||||
- 在世界页签增加“角色维度”区域,直接渲染 `profile.attributeSchema.slots` 的六个名称。
|
||||
@@ -33,5 +33,5 @@ RPG Agent 生成世界草稿时,前端会把 `draftProfile` 归一化成 `Cust
|
||||
## 验收
|
||||
|
||||
- 新生成的 RPG 世界草稿 JSON 顶层包含 `attributeSchema.slots.length === 6`。
|
||||
- 结果页/世界页展示六个自定义维度名,而非固定的力量、敏捷、智力、精神。
|
||||
- 结果页/世界页展示六个自定义维度名,而非固定的力量、敏捷、智力、精神;页面不展示维度说明、正负信号或用途说明。
|
||||
- 缺失或非法模型输出会被后端兜底为合法中文六维。
|
||||
|
||||
393
docs/technical/SERVER_RS_DDD_FULL_REFACTOR_2026-04-28.md
Normal file
393
docs/technical/SERVER_RS_DDD_FULL_REFACTOR_2026-04-28.md
Normal file
@@ -0,0 +1,393 @@
|
||||
# server-rs DDD 一次性重构落地方案
|
||||
|
||||
日期:`2026-04-28`
|
||||
|
||||
## 1. 背景与当前结论
|
||||
|
||||
当前仓库已经进入 `server-rs + Axum + SpacetimeDB` 单一后端路线,旧 `server-node` 已不存在,后续不再围绕 Express / PostgreSQL 做兼容设计。本轮重构目标不是新增玩法,而是把已有 Rust 后端统一成可长期演进的 DDD 边界:
|
||||
|
||||
1. 领域规则沉到 `module-*`。
|
||||
2. SpacetimeDB 事务、表、reducer、procedure 留在 `spacetime-module`。
|
||||
3. HTTP / SSE / BFF 留在 `api-server`。
|
||||
4. 外部副作用留在 `platform-*` 或 `api-server` 应用编排层。
|
||||
5. 前后端 DTO 留在 `shared-contracts`,跨领域纯值处理留在 `shared-kernel`。
|
||||
|
||||
本文件是后续编码前的总纲。若局部历史文档与本文冲突,以本文和对应 SpacetimeDB skill 为准;若本文仍无法精准指导某一处编码,必须先补本文或新增更细技术文档,再继续改工程。
|
||||
|
||||
## 2. 依赖方向
|
||||
|
||||
允许方向:
|
||||
|
||||
```text
|
||||
api-server
|
||||
-> shared-contracts
|
||||
-> module-*
|
||||
-> spacetime-client
|
||||
-> platform-*
|
||||
|
||||
spacetime-module
|
||||
-> module-*
|
||||
-> shared-kernel
|
||||
|
||||
module-*
|
||||
-> shared-kernel
|
||||
-> 其他 module-* 的纯领域类型(仅在确有跨域规则复用时)
|
||||
|
||||
platform-*
|
||||
-> shared-kernel
|
||||
|
||||
shared-contracts
|
||||
-> shared-kernel
|
||||
```
|
||||
|
||||
禁止方向:
|
||||
|
||||
1. `module-*` 直接依赖 Axum、HTTP client、OSS、LLM、文件系统、SpacetimeDB table/reducer/procedure API。
|
||||
2. `module-*` 新增 `mapper.rs`,映射只能落在 adapter crate。
|
||||
3. `spacetime-module` 反向依赖 `api-server`、`spacetime-client` 或 `platform-*`。
|
||||
4. `shared-kernel` / `shared-contracts` 依赖业务 crate。
|
||||
5. 前端绕过 Axum 直接承接后端业务规则或数据真相。
|
||||
|
||||
阶段性例外必须满足三个条件:
|
||||
|
||||
1. 在本文“阶段性债务表”登记。
|
||||
2. 有明确迁出目标 crate。
|
||||
3. 不允许继续扩大例外范围。
|
||||
|
||||
## 3. crate 职责矩阵
|
||||
|
||||
| crate | 职责 | 禁止内容 |
|
||||
| --- | --- | --- |
|
||||
| `module-ai` | AI 任务、阶段、流式片段、结果引用的纯领域模型和状态机 | 真实 LLM 调用、HTTP、SSE |
|
||||
| `module-assets` | 资产对象、资产绑定、历史查询输入输出和纯校验规则 | OSS head、reqwest、进程内 fallback store 扩散到领域核心 |
|
||||
| `module-auth` | 用户、会话、验证码、微信绑定、密码规则、领域错误 | 文件持久化、真实短信副作用、HTTP cookie 写入 |
|
||||
| `module-big-fish` | 大鱼创作会话、素材槽、运行态规则 | 图片生成、OSS 上传、HTTP handler |
|
||||
| `module-combat` | 战斗聚合、行动结算、奖励结果 | 直接写背包表、直接发放成长账本 |
|
||||
| `module-custom-world` | 世界 profile、Agent 会话、草稿卡、发布门禁规则 | LLM 推理、OSS、Axum response shape |
|
||||
| `module-inventory` | 背包、装备槽、堆叠与消耗规则 | SpacetimeDB table 操作 |
|
||||
| `module-npc` | NPC 关系、好感、互动、招募规则 | 战斗表初始化事务 |
|
||||
| `module-progression` | 玩家等级、章节预算、经验记账规则 | 查询前端视图拼装 |
|
||||
| `module-puzzle` | 拼图创作与运行态纯规则 | 图片生成、排行榜 HTTP shape |
|
||||
| `module-quest` | 任务领取、推进、完成、交付规则 | 奖励跨域副作用直接写表 |
|
||||
| `module-runtime` | 运行时设置、快照、个人页状态、存档领域模型 | 直接读写 SpacetimeDB |
|
||||
| `module-runtime-item` | 宝箱、奖励物品、运行时物品快照 | 背包持久化事务 |
|
||||
| `module-runtime-story` | RPG runtime story 新接口下的纯应用编排、剧情投影、场景旅行、战后收束、prompt context 投影 | Axum、LLM、SpacetimeDB |
|
||||
| `module-story` | story session、story event 与推进输入输出 | 直接调用 LLM 或 HTTP |
|
||||
| `spacetime-module` | 表、reducer、procedure、事务内查询写回、row/snapshot mapper、event table | 领域规则大段堆叠 |
|
||||
| `spacetime-client` | api-server 调用 SpacetimeDB 的客户端 facade 与绑定 mapper | 领域规则 |
|
||||
| `api-server` | 路由、鉴权上下文、请求响应映射、SSE、平台服务编排 | 表结构定义、领域规则主逻辑 |
|
||||
| `platform-*` | JWT/SMS/微信/OSS/LLM/HTTP client 等外部能力 | 玩法领域规则 |
|
||||
| `shared-kernel` | 字符串、时间、ID、纯值归一化 | 业务流程 |
|
||||
| `shared-contracts` | HTTP/前端 DTO、兼容 response shape | 领域状态机 |
|
||||
|
||||
## 4. 统一目录结构
|
||||
|
||||
所有 `module-*` 必须具备以下文件。第一阶段允许旧实现仍在 `lib.rs` 或历史子模块中,但新增和迁移代码必须进入对应落点。
|
||||
|
||||
```text
|
||||
src/
|
||||
├─ lib.rs
|
||||
├─ domain.rs 或 domain/
|
||||
├─ commands.rs
|
||||
├─ application.rs
|
||||
├─ events.rs
|
||||
└─ errors.rs
|
||||
```
|
||||
|
||||
落位规则:
|
||||
|
||||
1. `domain.rs` / `domain/*`:聚合、值对象、领域方法、纯校验、状态迁移。
|
||||
2. `commands.rs`:写入用例输入,只表达意图和必要参数,不含 adapter 类型。
|
||||
3. `application.rs`:纯应用编排函数,输出领域事件或应用结果,不执行外部副作用。
|
||||
4. `events.rs`:领域事件和跨上下文事件,例如奖励待入账、画廊投影待刷新。
|
||||
5. `errors.rs`:领域错误,优先可测试、可映射,不直接绑定 HTTP status。
|
||||
6. `mapper.rs`:只允许在 `api-server`、`spacetime-module`、`spacetime-client` 等 adapter crate 中出现。
|
||||
|
||||
## 5. 上下文设计清单
|
||||
|
||||
### 5.1 认证 `module-auth`
|
||||
|
||||
聚合:
|
||||
|
||||
1. `AuthUser`:账号、公开叙世号、登录方式、绑定状态、token version。
|
||||
2. `RefreshSession`:refresh token hash、客户端信息、过期、吊销、last seen。
|
||||
3. `SmsVerification`:手机号、场景、验证码状态、冷却、失败次数。
|
||||
4. `WechatBinding`:微信 provider 身份、union id、绑定状态。
|
||||
|
||||
命令:
|
||||
|
||||
1. `PasswordEntryInput`
|
||||
2. `SendPhoneCodeInput`
|
||||
3. `PhoneLoginInput`
|
||||
4. `CreateRefreshSessionInput`
|
||||
5. `RevokeRefreshSessionInput`
|
||||
6. `ResolveWechatLoginInput`
|
||||
|
||||
事件:
|
||||
|
||||
1. `AuthUserCreated`
|
||||
2. `RefreshSessionIssued`
|
||||
3. `RefreshSessionRevoked`
|
||||
4. `PhoneCodeAccepted`
|
||||
5. `WechatIdentityLinked`
|
||||
|
||||
读模型:
|
||||
|
||||
1. `AuthMeResult`
|
||||
2. `PublicUserSearchResult`
|
||||
3. `AuthSessionListResult`
|
||||
|
||||
迁移要求:文件持久化和内存 store 从领域核心剥离到 adapter 或临时测试支撑;短信真实发送继续在 `platform-auth`。
|
||||
|
||||
### 5.2 资产 `module-assets`
|
||||
|
||||
聚合:
|
||||
|
||||
1. `AssetObject`:bucket、object key、访问策略、hash、版本、业务归属。
|
||||
2. `AssetEntityBinding`:实体类型、实体 id、slot、资产对象 id。
|
||||
|
||||
命令:
|
||||
|
||||
1. `ConfirmAssetObjectInput`
|
||||
2. `AssetObjectUpsertInput`
|
||||
3. `AssetEntityBindingInput`
|
||||
4. `AssetHistoryListInput`
|
||||
|
||||
事件:
|
||||
|
||||
1. `AssetObjectConfirmed`
|
||||
2. `AssetEntityBindingChanged`
|
||||
|
||||
读模型:
|
||||
|
||||
1. `AssetObjectUpsertSnapshot`
|
||||
2. `AssetEntityBindingSnapshot`
|
||||
3. `AssetHistoryEntrySnapshot`
|
||||
|
||||
迁移要求:OSS `head_object`、reqwest client 和 fallback store 不再放入领域核心;SpacetimeDB 持久化由 `spacetime-module` 完成。
|
||||
|
||||
### 5.3 RPG 运行时
|
||||
|
||||
覆盖 `module-story`、`module-combat`、`module-inventory`、`module-npc`、`module-progression`、`module-quest`、`module-runtime-item`。
|
||||
|
||||
聚合:
|
||||
|
||||
1. `StorySession`、`StoryEvent`
|
||||
2. `BattleState`
|
||||
3. `InventorySlot`
|
||||
4. `NpcState`
|
||||
5. `PlayerProgression`、`ChapterProgression`
|
||||
6. `QuestRecord`、`QuestLog`
|
||||
7. `TreasureRecord`
|
||||
|
||||
跨上下文事件:
|
||||
|
||||
1. `CombatVictoryResolved`
|
||||
2. `QuestTurnedIn`
|
||||
3. `InventoryItemsGranted`
|
||||
4. `ProgressionXpGranted`
|
||||
5. `NpcRelationChanged`
|
||||
6. `RuntimeStoryProjectionChanged`
|
||||
|
||||
事务边界:
|
||||
|
||||
1. 单聚合变更在对应 `module-*` 纯函数中完成。
|
||||
2. 战斗奖励、任务奖励、背包写入、成长记账由 `spacetime-module` 或应用服务显式编排。
|
||||
3. reducer/procedure 不允许复制领域规则,只负责取 row、调用领域函数、写回 row 和事件表。
|
||||
|
||||
### 5.4 世界创作 `module-custom-world`
|
||||
|
||||
聚合:
|
||||
|
||||
1. `CustomWorldProfile`
|
||||
2. `CustomWorldSession`
|
||||
3. `CustomWorldAgentSession`
|
||||
4. `CustomWorldAgentMessage`
|
||||
5. `CustomWorldAgentOperation`
|
||||
6. `CustomWorldDraftCard`
|
||||
7. `CustomWorldGalleryEntry`
|
||||
|
||||
命令:
|
||||
|
||||
1. 创建/恢复 Agent 会话。
|
||||
2. 写入用户消息。
|
||||
3. 写入 LLM 最终回复。
|
||||
4. 更新草稿卡。
|
||||
5. 发布/下架 profile。
|
||||
|
||||
事件:
|
||||
|
||||
1. `CustomWorldDraftChanged`
|
||||
2. `CustomWorldProfilePublished`
|
||||
3. `CustomWorldGalleryProjectionChanged`
|
||||
4. `CustomWorldAgentOperationProgressed`
|
||||
|
||||
迁移要求:LLM 提示词和推理在 `api-server + platform-llm`,SpacetimeDB 只落真相表和投影。
|
||||
|
||||
### 5.5 拼图 `module-puzzle`
|
||||
|
||||
聚合:
|
||||
|
||||
1. `PuzzleAgentSession`
|
||||
2. `PuzzleAgentMessage`
|
||||
3. `PuzzleWorkProfile`
|
||||
4. `PuzzleRuntimeRun`
|
||||
|
||||
事件:
|
||||
|
||||
1. `PuzzleDraftChanged`
|
||||
2. `PuzzleWorkPublished`
|
||||
3. `PuzzleRunAdvanced`
|
||||
|
||||
读模型:
|
||||
|
||||
1. 作品卡片。
|
||||
2. 运行态快照。
|
||||
3. 排行榜结果。
|
||||
|
||||
### 5.6 大鱼吃小鱼 `module-big-fish`
|
||||
|
||||
聚合:
|
||||
|
||||
1. `BigFishCreationSession`
|
||||
2. `BigFishAgentMessage`
|
||||
3. `BigFishAssetSlot`
|
||||
4. `BigFishRuntimeRun`
|
||||
|
||||
事件:
|
||||
|
||||
1. `BigFishDraftChanged`
|
||||
2. `BigFishAssetSlotChanged`
|
||||
3. `BigFishRunTicked`
|
||||
|
||||
### 5.7 AI `module-ai`
|
||||
|
||||
聚合:
|
||||
|
||||
1. `AiTask`
|
||||
2. `AiTaskStage`
|
||||
3. `AiTextChunk`
|
||||
4. `AiResultReference`
|
||||
|
||||
事件:
|
||||
|
||||
1. `AiTaskStarted`
|
||||
2. `AiTaskStageCompleted`
|
||||
3. `AiTaskFailed`
|
||||
4. `AiResultAttached`
|
||||
|
||||
边界:真实模型调用只在 `platform-llm`,`module-ai` 只表达状态机。
|
||||
|
||||
## 6. SpacetimeDB adapter 映射
|
||||
|
||||
`spacetime-module` 中每个上下文遵循:
|
||||
|
||||
```text
|
||||
src/<context>/
|
||||
├─ mod.rs
|
||||
├─ tables.rs # table 和 public/event 标记
|
||||
├─ reducers.rs # 客户端可调用写入口
|
||||
├─ procedures.rs # 需要同步返回或外部 procedure 语义的入口
|
||||
├─ mapper.rs # row <-> module-* snapshot/input
|
||||
└─ queries.rs # 事务内查询辅助,只返回 adapter DTO
|
||||
```
|
||||
|
||||
当前已有历史拆分与本文不同名时,先按现有文档继续维护;新增或迁移时逐步对齐上面结构。
|
||||
|
||||
Reducer / procedure 规则:
|
||||
|
||||
1. reducer 使用 `&ReducerContext`,返回 `Result<(), String>` 处理预期错误。
|
||||
2. reducer 内授权必须基于 `ctx.sender()`,禁止信任参数中的身份。
|
||||
3. reducer 禁止网络、文件系统、外部随机数和全局状态。
|
||||
4. procedure 使用 SpacetimeDB 2.0 正确 API;涉及事务必须显式 `with_tx` / `try_with_tx`。
|
||||
5. 表访问必须使用 `ctx.db.table()`,更新只通过主键。
|
||||
6. 复杂查询不复用写模型;需要前端订阅时新增明确读模型或 public 投影表。
|
||||
|
||||
## 7. 表结构约束
|
||||
|
||||
默认不改现有 SpacetimeDB 主表。确需改表时按以下优先级:
|
||||
|
||||
1. 新增 optional 字段。
|
||||
2. 新增投影表或 event table。
|
||||
3. 新增索引。
|
||||
4. 最后才考虑 rename/delete/type change,且必须单独写迁移方案。
|
||||
|
||||
任何 table 变更必须同步:
|
||||
|
||||
1. `server-rs/crates/spacetime-module/src/migration.rs`
|
||||
2. `docs/technical/SPACETIMEDB_TABLE_CATALOG.md`
|
||||
3. 对应 reducer/procedure 测试或最小 smoke
|
||||
4. 绑定生成和前端/`spacetime-client` 映射
|
||||
|
||||
本阶段只做目录、文档和边界检查,不变更表结构,因此不需要改 `migration.rs`。
|
||||
|
||||
## 8. 查询策略
|
||||
|
||||
1. 写模型不直接服务复杂前端页面。
|
||||
2. 每个前端场景必须有独立 query/result DTO。
|
||||
3. private table 默认不暴露给前端。
|
||||
4. public table 只服务明确订阅场景。
|
||||
5. event table 用于 reducer 后广播一次性事件,客户端必须显式订阅。
|
||||
|
||||
## 9. 第一阶段落地范围
|
||||
|
||||
第一阶段只做低风险基础设施:
|
||||
|
||||
1. 新增本文作为 DDD 总纲。
|
||||
2. 所有 `module-*` 补齐 `domain / commands / application / events / errors` 过渡落位文件。
|
||||
3. 新增 `scripts/check-server-rs-ddd-boundaries.mjs`,检查 DDD 骨架、禁用 mapper 落位和 SpacetimeDB/Axum 绝对边界。
|
||||
4. 更新文档索引和后端 README。
|
||||
5. 不改表、不改 reducer/procedure 名、不改 HTTP contract。
|
||||
|
||||
### 9.1 首个样板切片
|
||||
|
||||
`module-assets` 先作为 DDD 分层导出样板:
|
||||
|
||||
1. `domain.rs` 对外导出资产对象、实体绑定、访问策略、快照和纯校验函数。
|
||||
2. `commands.rs` 对外导出确认资产、绑定实体、资产历史查询等输入。
|
||||
3. `application.rs` 对外导出应用结果和纯构建函数。
|
||||
4. `errors.rs` 对外导出资产领域错误。
|
||||
5. `asset_object_core.rs` 暂作为内部历史实现文件保留,不再由 `lib.rs` 直接对外导出;后续触碰资产规则时继续把实现逐段迁到 DDD 文件。
|
||||
|
||||
## 10. 阶段性债务表
|
||||
|
||||
| 债务 | 当前位置 | 迁出目标 | 约束 |
|
||||
| --- | --- | --- | --- |
|
||||
| 资产 OSS head 与 reqwest 服务仍在 `module-assets` server-service feature | `module-assets/src/asset_object_service.rs` | `api-server` 应用服务或独立 adapter crate | 不得被 `domain.rs` 引用,不得新增更多 OSS 领域规则 |
|
||||
| 认证内存 store / 文件持久化仍在 `module-auth` | `module-auth/src/lib.rs` | adapter / repository 目录或 `api-server` 启动恢复层 | 新业务规则不得继续依赖文件路径 |
|
||||
| `spacetime-module/src/lib.rs` 仍有大量 gameplay 入口 | `spacetime-module/src/lib.rs` | `src/gameplay/*`、`src/custom_world/*`、`src/puzzle/*` | 新增实现禁止继续堆回根入口 |
|
||||
| 部分 `module-*` 仍是单文件领域实现 | 多个 `module-*` 的 `src/lib.rs` | 对应 DDD 文件 | 每次触碰模块时顺手迁移相关片段 |
|
||||
| runtime story 兼容层仍存在 | `module-runtime-story-compat`、`api-server/src/runtime_story/compat*`、前端旧 runtime story client | `module-runtime-story`、session scoped 新接口、前端新 client | 本轮允许 breaking change,不再为旧 HTTP shape 保留双接口 |
|
||||
|
||||
## 11. 全局并行执行清单
|
||||
|
||||
`2026-04-29` 起,`server-rs` DDD 全局重构按 `SERVER_RS_DDD_PARALLEL_TASKLIST_2026-04-29.md` 执行。该清单覆盖:
|
||||
|
||||
1. 文档、契约、DDD 骨架和边界检查。
|
||||
2. `module-auth`、`module-assets`、`module-ai`、`module-custom-world`、`module-big-fish`、`module-puzzle`、`module-runtime`、RPG gameplay 域和 runtime story 域。
|
||||
3. `spacetime-module`、`spacetime-client`、`api-server`、`platform-*` 和前端接入。
|
||||
4. 旧层删除、命名收口、全链验证和 Maincloud smoke。
|
||||
|
||||
## 12. 去兼容层任务
|
||||
|
||||
`2026-04-29` 起,runtime story / chat 改造按 `SERVER_RS_DDD_PARALLEL_TASKLIST_2026-04-29.md` 中的 `WP-RS Runtime Story 去兼容层` 工作包执行。该工作包明确:
|
||||
|
||||
1. 当前 `compat` 层可以物理删除。
|
||||
2. 新接口采用 `POST /api/runtime/story/sessions/:sessionId/...` 的 session scoped 口径。
|
||||
3. 前端允许同步修改以匹配新 contract。
|
||||
4. `api-server` 不再为旧 `worldType / character / monsters / history / context` 请求体保留正式主链分支。
|
||||
|
||||
## 13. 验收命令
|
||||
|
||||
阶段验收至少执行:
|
||||
|
||||
```powershell
|
||||
node scripts/check-server-rs-ddd-boundaries.mjs
|
||||
cargo fmt --all --check --manifest-path server-rs/Cargo.toml
|
||||
cargo test --workspace --manifest-path server-rs/Cargo.toml
|
||||
cargo check -p spacetime-module --manifest-path server-rs/Cargo.toml
|
||||
npm run api-server:maincloud
|
||||
npm run check:encoding
|
||||
```
|
||||
|
||||
若 `npm run api-server:maincloud` 因本机未配置 Maincloud 数据库或令牌失败,必须记录具体错误;不能改用旧后端重启命令。
|
||||
310
docs/technical/SERVER_RS_DDD_PARALLEL_TASKLIST_2026-04-29.md
Normal file
310
docs/technical/SERVER_RS_DDD_PARALLEL_TASKLIST_2026-04-29.md
Normal file
@@ -0,0 +1,310 @@
|
||||
# server-rs DDD 全局并行任务清单(2026-04-29)
|
||||
|
||||
## 1. 目标与范围
|
||||
|
||||
本文件是 `server-rs` DDD 一次性重构的总任务清单,覆盖所有后端 crate、SpacetimeDB adapter、HTTP/BFF、共享契约、平台能力和前端接入。runtime story 去兼容层不再单独维护专项清单,统一归入本文的 `WP-RS` 工作包。
|
||||
|
||||
本轮目标:
|
||||
|
||||
1. 全部业务规则沉到 `module-*`。
|
||||
2. `spacetime-module` 只保留 SpacetimeDB table、reducer、procedure、row mapper、事务编排和 event table。
|
||||
3. `api-server` 只保留 HTTP/BFF、鉴权、SSE、请求响应映射、SpacetimeDB client 调用和平台服务编排。
|
||||
4. `platform-*` 只承载外部副作用实现。
|
||||
5. `shared-contracts` 冻结 HTTP/前端 DTO;`shared-kernel` 承载跨领域纯值处理。
|
||||
6. 前端只消费后端新接口和后端投影,不承接正式业务真相。
|
||||
|
||||
用户已明确允许去掉现有兼容层并修改前端以匹配新接口,因此全局任务不以旧接口兼容为约束;任何 breaking change 必须记录在对应任务交接和 API 文档中。
|
||||
|
||||
## 2. 全局依赖图
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
G0["G0 文档、边界、冻结窗口"]
|
||||
G1["G1 契约与路由矩阵"]
|
||||
G2["G2 module-* DDD 骨架与边界检查"]
|
||||
A["WP-A Auth"]
|
||||
AS["WP-AS Assets"]
|
||||
AI["WP-AI AI Task"]
|
||||
CW["WP-CW Custom World"]
|
||||
BF["WP-BF Big Fish"]
|
||||
PZ["WP-PZ Puzzle"]
|
||||
RT["WP-RT Runtime/Profile/Save"]
|
||||
RPG["WP-RPG Gameplay 域"]
|
||||
RS["WP-RS Runtime Story 去兼容层"]
|
||||
ST["WP-ST SpacetimeDB Adapter"]
|
||||
SC["WP-SC Spacetime Client"]
|
||||
API["WP-API api-server BFF"]
|
||||
PF["WP-PF platform side effects"]
|
||||
FE["WP-FE Frontend Clients/UI"]
|
||||
DEL["WP-DEL 删除旧层与命名收口"]
|
||||
V["WP-V 全链验证与发布 smoke"]
|
||||
|
||||
G0 --> G1
|
||||
G0 --> G2
|
||||
G1 --> A
|
||||
G1 --> AS
|
||||
G1 --> AI
|
||||
G1 --> CW
|
||||
G1 --> BF
|
||||
G1 --> PZ
|
||||
G1 --> RT
|
||||
G1 --> RPG
|
||||
G1 --> RS
|
||||
G2 --> A
|
||||
G2 --> AS
|
||||
G2 --> AI
|
||||
G2 --> CW
|
||||
G2 --> BF
|
||||
G2 --> PZ
|
||||
G2 --> RT
|
||||
G2 --> RPG
|
||||
G2 --> RS
|
||||
A --> ST
|
||||
AS --> ST
|
||||
AI --> ST
|
||||
CW --> ST
|
||||
BF --> ST
|
||||
PZ --> ST
|
||||
RT --> ST
|
||||
RPG --> ST
|
||||
RS --> ST
|
||||
ST --> SC
|
||||
SC --> API
|
||||
PF --> API
|
||||
API --> FE
|
||||
FE --> DEL
|
||||
API --> DEL
|
||||
ST --> DEL
|
||||
DEL --> V
|
||||
```
|
||||
|
||||
## 3. 并行分批
|
||||
|
||||
### 3.1 第一批:冻结边界
|
||||
|
||||
只能串行完成,避免后续并行任务各自定义接口。
|
||||
|
||||
1. `G0 文档、边界、冻结窗口`
|
||||
2. `G1 契约与路由矩阵`
|
||||
3. `G2 module-* DDD 骨架与边界检查`
|
||||
|
||||
### 3.2 第二批:领域纯规则并行迁移
|
||||
|
||||
第二批互相并行,但每个任务只能改自己的 `module-*` 和对应文档。
|
||||
|
||||
1. `WP-A Auth`
|
||||
2. `WP-AS Assets`
|
||||
3. `WP-AI AI Task`
|
||||
4. `WP-CW Custom World`
|
||||
5. `WP-BF Big Fish`
|
||||
6. `WP-PZ Puzzle`
|
||||
7. `WP-RT Runtime/Profile/Save`
|
||||
8. `WP-RPG Gameplay 域`
|
||||
9. `WP-RS Runtime Story 去兼容层`
|
||||
|
||||
### 3.3 第三批:adapter 和 BFF 接线
|
||||
|
||||
领域任务有稳定应用结果后启动。
|
||||
|
||||
1. `WP-ST SpacetimeDB Adapter`
|
||||
2. `WP-SC Spacetime Client`
|
||||
3. `WP-PF platform side effects`
|
||||
4. `WP-API api-server BFF`
|
||||
|
||||
### 3.4 第四批:前端与旧层删除
|
||||
|
||||
后端新接口可用后启动。
|
||||
|
||||
1. `WP-FE Frontend Clients/UI`
|
||||
2. `WP-DEL 删除旧层与命名收口`
|
||||
3. `WP-V 全链验证与发布 smoke`
|
||||
|
||||
## 4. 工作包总表
|
||||
|
||||
| 工作包 | 可并行条件 | 主要文件边界 | 禁止触碰 | 交付物 | 验收 |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| G0 文档、边界、冻结窗口 | 首个串行 | `PLAN.md`、`docs/technical/*DDD*`、`docs/planning/*` | 业务代码 | 全局任务清单、专项清单索引、阶段性交接模板 | 编码检查通过 |
|
||||
| G1 契约与路由矩阵 | G0 后 | `shared-contracts`、`packages/shared/src/contracts/*`、API 路由索引 | 领域实现 | DTO 分组、breaking change 清单、前后端路由矩阵 | shared contract 测试通过 |
|
||||
| G2 DDD 骨架与边界检查 | G0 后 | `module-*` 骨架、`scripts/check-server-rs-ddd-boundaries.mjs` | 业务重写 | 所有 `module-*` 具备 `domain/commands/application/events/errors`,检查脚本覆盖禁用依赖 | `npm.cmd run check:server-rs-ddd` |
|
||||
| WP-A Auth | G1/G2 后 | `module-auth`、`spacetime-module/src/auth*`、`api-server/src/auth*`、`platform-auth` | 其他玩法域 | 账号、会话、验证码、微信绑定领域化;真实短信/微信在 platform | `cargo test -p module-auth`,auth API 测试 |
|
||||
| WP-AS Assets | G1/G2 后 | `module-assets`、`spacetime-module/src/asset_metadata/*`、资产 API、OSS adapter | 玩法业务规则 | 资产对象与绑定规则纯化;OSS head/upload 移出领域核心 | `cargo test -p module-assets`,资产 facade 测试 |
|
||||
| WP-AI AI Task | G1/G2 后 | `module-ai`、`spacetime-module/src/ai/*`、AI task API | LLM prompt 业务规则 | AI task/stage/chunk/result 状态机领域化 | `cargo test -p module-ai`,AI task reducer/procedure smoke |
|
||||
| WP-CW Custom World | G1/G2 后 | `module-custom-world`、`spacetime-module/src/custom_world/*`、`api-server` custom world 路由、前端创作 client | Big Fish/Puzzle | profile、agent session、draft card、gallery、publish gate 领域化;LLM 留在 API/platform | `cargo test -p module-custom-world`,custom world 定向测试 |
|
||||
| WP-BF Big Fish | G1/G2 后 | `module-big-fish`、`spacetime-module/src/big_fish/*`、Big Fish API、Big Fish 前端 client | Puzzle/RPG | 会话、草稿、素材槽、运行态纯规则;草稿校验下沉 | `cargo test -p module-big-fish`,Big Fish API 测试 |
|
||||
| WP-PZ Puzzle | G1/G2 后 | `module-puzzle`、`spacetime-module/src/puzzle*`、Puzzle API、Puzzle 前端 client | Big Fish/RPG | Agent session、work profile、runtime run、排行榜规则领域化 | `cargo test -p module-puzzle`,Puzzle 定向测试 |
|
||||
| WP-RT Runtime/Profile/Save | G1/G2 后 | `module-runtime`、`spacetime-module/src/runtime/*`、runtime/save/profile API | RPG story 规则 | runtime setting、snapshot、wallet、played world、save archive 领域化 | `cargo test -p module-runtime`,runtime API 测试 |
|
||||
| WP-RPG Gameplay 域 | G1/G2 后 | `module-combat`、`module-inventory`、`module-npc`、`module-progression`、`module-quest`、`module-runtime-item`、`module-story` | 创作域 | 战斗、背包、NPC、成长、任务、宝箱、story session 纯规则与跨域事件 | 各 module 测试;跨域应用结果测试 |
|
||||
| WP-RS Runtime Story 去兼容层 | G1/G2 后 | `module-runtime-story`、`api-server/src/runtime_story/*`、`src/hooks/rpg-runtime-story/*` | 非 RPG 创作域 | 删除 compat 层、session scoped 新接口、前端匹配新接口 | 按专项文档验收 |
|
||||
| WP-ST SpacetimeDB Adapter | 领域任务输出稳定后 | `spacetime-module/src/**`、`migration.rs`、表目录 | `api-server` 业务逻辑 | table/reducer/procedure/mapper/queries 按上下文拆分;必要 event/projection table | `cargo check -p spacetime-module`,需要时 `spacetime build/generate` |
|
||||
| WP-SC Spacetime Client | WP-ST 接口稳定后 | `spacetime-client/src/**`、绑定 mapper | 领域规则 | typed facade、错误映射、row snapshot mapper | `cargo check -p spacetime-client` |
|
||||
| WP-PF platform side effects | 可与 WP-API 并行 | `platform-*`、`api-server` platform 接线 | 领域状态机 | LLM、OSS、SMS、微信等副作用统一 adapter | platform crate 测试或 API smoke |
|
||||
| WP-API api-server BFF | WP-SC/PF 可用后 | `api-server/src/**` | SpacetimeDB table 定义、领域主规则 | 路由、鉴权、SSE、请求响应映射、平台编排收口 | `cargo test -p api-server`,`cargo check -p api-server` |
|
||||
| WP-FE Frontend Clients/UI | G1 和 WP-API 接口稳定后 | `src/services/**`、`src/hooks/**`、`src/components/**` | 后端规则复刻 | API client、hooks、UI 流程对齐新 contract;删除前端正式规则 | vitest/ESLint 定向测试 |
|
||||
| WP-DEL 删除旧层与命名收口 | 新接口与前端迁移后 | 旧 compat、旧 facade、旧 contract、旧测试 | 新主链 | 物理删除旧入口、旧命名、旧 fixture 中非必要样本 | 搜索无运行代码引用旧层 |
|
||||
| WP-V 全链验证与发布 smoke | 最后 | 文档、测试脚本、README | 新功能扩展 | 全链命令、Maincloud smoke、文档交接 | 第 8 节命令通过或记录非本轮阻塞 |
|
||||
|
||||
## 5. 工作包边界细则
|
||||
|
||||
### 5.1 G1 契约与路由矩阵
|
||||
|
||||
必须先冻结:
|
||||
|
||||
1. 当前保留、重命名、删除的 HTTP 路由。
|
||||
2. 每个前端页面对应的 query/result DTO。
|
||||
3. 每个写入接口对应的 command DTO。
|
||||
4. breaking change 清单。
|
||||
5. API 错误 envelope 与中文错误字段。
|
||||
|
||||
禁止在 G1 中实现业务逻辑。
|
||||
|
||||
### 5.2 module-* 领域任务通用规则
|
||||
|
||||
每个 `module-*` 工作包必须输出:
|
||||
|
||||
1. `domain.rs` 或 `domain/*`:聚合和值对象。
|
||||
2. `commands.rs`:写入输入。
|
||||
3. `application.rs`:纯应用用例,输出应用结果或领域事件。
|
||||
4. `events.rs`:领域事件。
|
||||
5. `errors.rs`:领域错误。
|
||||
6. 单元测试。
|
||||
|
||||
禁止:
|
||||
|
||||
1. Axum / HTTP status。
|
||||
2. SpacetimeDB `ReducerContext`、table API。
|
||||
3. reqwest、OSS、真实 LLM、文件系统。
|
||||
4. `mapper.rs`。
|
||||
|
||||
### 5.3 WP-ST SpacetimeDB Adapter
|
||||
|
||||
按上下文拆到:
|
||||
|
||||
```text
|
||||
server-rs/crates/spacetime-module/src/<context>/
|
||||
├─ mod.rs
|
||||
├─ tables.rs
|
||||
├─ reducers.rs
|
||||
├─ procedures.rs
|
||||
├─ mapper.rs
|
||||
└─ queries.rs
|
||||
```
|
||||
|
||||
当前已有模块可渐进对齐,但新增实现不得继续堆回 `lib.rs`。
|
||||
|
||||
SpacetimeDB 硬要求:
|
||||
|
||||
1. reducer 使用 `&ReducerContext`。
|
||||
2. 预期失败返回 `Result<(), String>` 或 procedure 错误,不用 panic 表达业务失败。
|
||||
3. 授权基于 `ctx.sender()`。
|
||||
4. 表访问使用 `ctx.db.table()`。
|
||||
5. 更新只通过主键,非主键更新走 delete + insert。
|
||||
6. reducer 无网络、文件、外部随机数和全局可变状态。
|
||||
|
||||
### 5.4 WP-API api-server
|
||||
|
||||
允许:
|
||||
|
||||
1. 鉴权与 request context。
|
||||
2. route handler / extractor。
|
||||
3. DTO 映射。
|
||||
4. 调用 `spacetime-client`。
|
||||
5. 调用 `platform-*`。
|
||||
6. SSE stream。
|
||||
|
||||
禁止:
|
||||
|
||||
1. 大段领域分支。
|
||||
2. SpacetimeDB table 定义。
|
||||
3. 为旧接口继续保留双主链。
|
||||
|
||||
### 5.5 WP-FE Frontend Clients/UI
|
||||
|
||||
前端只负责表现:
|
||||
|
||||
1. 调用 API。
|
||||
2. 管理 loading/error/transition。
|
||||
3. 渲染服务端 query/result DTO。
|
||||
4. 保存 UI 临时输入。
|
||||
|
||||
禁止:
|
||||
|
||||
1. 正式业务状态机。
|
||||
2. 正式任务、战斗、背包、NPC、章节、世界 mutation 规则。
|
||||
3. prompt 正式组装。
|
||||
4. 绕过后端直接写真相。
|
||||
|
||||
## 6. 关键依赖与防冲突边界
|
||||
|
||||
1. `shared-contracts` 由 G1 统一所有权,其他任务只消费,不私自改 DTO shape。
|
||||
2. `spacetime-module/src/lib.rs` 由 WP-ST 统一所有权,领域任务不直接改根入口。
|
||||
3. `api-server/src/app.rs` 路由挂载由 WP-API 统一所有权。
|
||||
4. `src/services/aiService.ts`、`src/services/rpg-runtime/*` 由 WP-FE 统一所有权。
|
||||
5. `module-runtime-story` 与 runtime story 新接口由 WP-RS 所有,不和 WP-RPG 混写。
|
||||
6. 若某任务必须改别人的边界文件,先在交接记录中写明改动动机和待合流点。
|
||||
|
||||
## 7. 文档产出要求
|
||||
|
||||
每个工作包完成后必须更新或新增对应文档:
|
||||
|
||||
1. 技术方案:放 `docs/technical/`。
|
||||
2. 审计和迁移验证:放 `docs/audits/engineering/`。
|
||||
3. 用户体验或踩坑:放 `docs/experience/`。
|
||||
4. 表结构变化:更新 `docs/technical/SPACETIMEDB_TABLE_CATALOG.md`。
|
||||
5. API 路由变化:更新 `docs/technical/RUST_API_SERVER_ROUTE_INDEX_2026-04-22.md` 或新增新版路由索引。
|
||||
|
||||
交接摘要必须包含:
|
||||
|
||||
1. 已改文件。
|
||||
2. 未完成项。
|
||||
3. 依赖的上游/下游任务。
|
||||
4. 验证命令与结果。
|
||||
5. 是否触碰表结构、是否同步 `migration.rs`。
|
||||
|
||||
## 8. 全局验收命令
|
||||
|
||||
基础检查:
|
||||
|
||||
```powershell
|
||||
npm.cmd run check:server-rs-ddd
|
||||
cargo fmt --all --check --manifest-path server-rs/Cargo.toml
|
||||
cargo check -p api-server --manifest-path server-rs/Cargo.toml
|
||||
cargo check -p spacetime-client --manifest-path server-rs/Cargo.toml
|
||||
cargo check -p spacetime-module --manifest-path server-rs/Cargo.toml
|
||||
npm.cmd run check:encoding
|
||||
```
|
||||
|
||||
领域测试:
|
||||
|
||||
```powershell
|
||||
cargo test -p module-ai -p module-assets -p module-auth -p module-big-fish -p module-combat -p module-custom-world -p module-inventory -p module-npc -p module-progression -p module-puzzle -p module-quest -p module-runtime -p module-runtime-item -p module-runtime-story -p module-story --manifest-path server-rs/Cargo.toml
|
||||
```
|
||||
|
||||
接口与前端测试按触碰范围执行:
|
||||
|
||||
```powershell
|
||||
cargo test -p api-server --manifest-path server-rs/Cargo.toml
|
||||
npm.cmd run test -- src/services
|
||||
npm.cmd run test -- src/hooks
|
||||
```
|
||||
|
||||
后端代码变更后必须执行:
|
||||
|
||||
```powershell
|
||||
npm.cmd run api-server:maincloud
|
||||
```
|
||||
|
||||
若改 SpacetimeDB table / reducer / procedure:
|
||||
|
||||
```powershell
|
||||
spacetime build
|
||||
spacetime generate --lang typescript --out-dir <前端绑定目录> --module-path server-rs/crates/spacetime-module
|
||||
spacetime describe <database> --json
|
||||
```
|
||||
|
||||
不能用旧后端重启命令替代 `npm.cmd run api-server:maincloud`。
|
||||
|
||||
## 9. 专项任务引用
|
||||
|
||||
当前不再单独维护专项清单。`WP-RS Runtime Story 去兼容层` 已内联在本文第 4 节工作包总表中。
|
||||
|
||||
后续如果某个工作包仍存在编码级歧义,必须先在本文补齐边界;只有单个工作包过大且无法在本文清晰承载时,才新增对应专项清单。
|
||||
@@ -112,6 +112,8 @@ src/services/creation-agent/
|
||||
1. 4 个玩法锚点映射。
|
||||
2. 输入框占位提示。
|
||||
3. 生成结果页 action:`big_fish_compile_draft`。
|
||||
4. `big_fish_compile_draft` 只负责编译玩法草稿并进入结果页,不在草稿阶段串行生成动作素材。
|
||||
5. 大鱼吃小鱼的主图、动作、背景都在结果页工坊独立触发;统一进度组件里不再为其草稿阶段展示“生成动作素材”步骤。
|
||||
|
||||
### 4.3 拼图
|
||||
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
"preview": "node scripts/vite-cli.mjs preview",
|
||||
"clean": "node -e \"require('fs').rmSync('dist', { recursive: true, force: true })\"",
|
||||
"check:encoding": "node scripts/check-encoding.mjs",
|
||||
"check:server-rs-ddd": "node scripts/check-server-rs-ddd-boundaries.mjs",
|
||||
"lint:eslint": "eslint . --ext .ts,.tsx,.js,.mjs,.cjs --max-warnings 0",
|
||||
"lint:guardrails": "npm run lint:eslint",
|
||||
"typecheck": "tsc -p tsconfig.typecheck-guardrails.json --noEmit",
|
||||
|
||||
@@ -59,9 +59,13 @@ export type BigFishLevelBlueprintResponse = {
|
||||
level: number;
|
||||
name: string;
|
||||
oneLineFantasy: string;
|
||||
textDescription: string;
|
||||
silhouetteDirection: string;
|
||||
sizeRatio: number;
|
||||
visualDescription: string;
|
||||
visualPromptSeed: string;
|
||||
idleMotionDescription: string;
|
||||
moveMotionDescription: string;
|
||||
motionPromptSeed: string;
|
||||
mergeSourceLevel?: number | null;
|
||||
preyWindow: number[];
|
||||
|
||||
@@ -130,12 +130,6 @@ export interface RpgAgentFoundationDraftCamp {
|
||||
export interface RpgAgentWorldAttributeSlot {
|
||||
slotId: 'axis_a' | 'axis_b' | 'axis_c' | 'axis_d' | 'axis_e' | 'axis_f';
|
||||
name: string;
|
||||
definition: string;
|
||||
positiveSignals: string[];
|
||||
negativeSignals: string[];
|
||||
combatUseText: string;
|
||||
socialUseText: string;
|
||||
explorationUseText: string;
|
||||
}
|
||||
|
||||
export interface RpgAgentWorldAttributeSchema {
|
||||
@@ -149,7 +143,6 @@ export interface RpgAgentWorldAttributeSchema {
|
||||
tone: string;
|
||||
conflictCore: string;
|
||||
};
|
||||
schemaName?: string;
|
||||
slots: RpgAgentWorldAttributeSlot[];
|
||||
}
|
||||
|
||||
|
||||
@@ -182,7 +182,6 @@ export function createRpgAgentFoundationDraftProfileFixture(): RpgAgentFoundatio
|
||||
id: 'schema:rpg-agent:tide-fixture',
|
||||
worldId: 'custom:潮雾列岛',
|
||||
schemaVersion: 1,
|
||||
schemaName: '潮雾六脉',
|
||||
generatedFrom: {
|
||||
worldType: 'CUSTOM',
|
||||
worldName: '潮雾列岛',
|
||||
@@ -194,62 +193,26 @@ export function createRpgAgentFoundationDraftProfileFixture(): RpgAgentFoundatio
|
||||
{
|
||||
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: '在漫长远行与恶劣天气里保有余力。',
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
40
packages/shared/src/contracts/rpgCreationResultView.ts
Normal file
40
packages/shared/src/contracts/rpgCreationResultView.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import type { CustomWorldProfileRecord } from './runtime';
|
||||
import type { RpgAgentSessionSnapshot } from './rpgAgentSession';
|
||||
|
||||
export type RpgCreationResultProfileSource =
|
||||
| 'result_preview'
|
||||
| 'draft_profile'
|
||||
| 'none';
|
||||
|
||||
export type RpgCreationResultTargetStage =
|
||||
| 'agent-workspace'
|
||||
| 'custom-world-generating'
|
||||
| 'custom-world-result';
|
||||
|
||||
export type RpgCreationResultGenerationViewSource =
|
||||
| 'agent-draft-foundation'
|
||||
| null;
|
||||
|
||||
export type RpgCreationResultViewSource = 'agent-draft' | null;
|
||||
|
||||
export type RpgCreationResultRecoveryAction =
|
||||
| 'continue_agent'
|
||||
| 'resume_generation'
|
||||
| 'open_result'
|
||||
| 'missing_session';
|
||||
|
||||
export interface RpgCreationResultView {
|
||||
session: RpgAgentSessionSnapshot;
|
||||
profile: CustomWorldProfileRecord | null;
|
||||
profileSource: RpgCreationResultProfileSource;
|
||||
targetStage: RpgCreationResultTargetStage;
|
||||
generationViewSource: RpgCreationResultGenerationViewSource;
|
||||
resultViewSource: RpgCreationResultViewSource;
|
||||
canAutosaveLibrary: boolean;
|
||||
canSyncResultProfile: boolean;
|
||||
publishReady: boolean;
|
||||
canEnterWorld: boolean;
|
||||
blockerCount: number;
|
||||
recoveryAction: RpgCreationResultRecoveryAction;
|
||||
recoveryReason?: string | null;
|
||||
}
|
||||
@@ -47,11 +47,12 @@ export type CharacterChatReplyRequest<
|
||||
TConversationTurn = unknown,
|
||||
TTargetStatus = unknown,
|
||||
> = {
|
||||
worldType: string;
|
||||
playerCharacter: TCharacter;
|
||||
sessionId?: string;
|
||||
worldType?: string;
|
||||
playerCharacter?: TCharacter;
|
||||
targetCharacter: TCharacter;
|
||||
storyHistory: TStoryMoment[];
|
||||
context: TContext;
|
||||
storyHistory?: TStoryMoment[];
|
||||
context?: TContext;
|
||||
conversationHistory: TConversationTurn[];
|
||||
conversationSummary: string;
|
||||
playerMessage: string;
|
||||
@@ -65,11 +66,12 @@ export type CharacterChatSuggestionsRequest<
|
||||
TConversationTurn = unknown,
|
||||
TTargetStatus = unknown,
|
||||
> = {
|
||||
worldType: string;
|
||||
playerCharacter: TCharacter;
|
||||
sessionId?: string;
|
||||
worldType?: string;
|
||||
playerCharacter?: TCharacter;
|
||||
targetCharacter: TCharacter;
|
||||
storyHistory: TStoryMoment[];
|
||||
context: TContext;
|
||||
storyHistory?: TStoryMoment[];
|
||||
context?: TContext;
|
||||
conversationHistory: TConversationTurn[];
|
||||
conversationSummary: string;
|
||||
targetStatus: TTargetStatus;
|
||||
@@ -82,11 +84,12 @@ export type CharacterChatSummaryRequest<
|
||||
TConversationTurn = unknown,
|
||||
TTargetStatus = unknown,
|
||||
> = {
|
||||
worldType: string;
|
||||
playerCharacter: TCharacter;
|
||||
sessionId?: string;
|
||||
worldType?: string;
|
||||
playerCharacter?: TCharacter;
|
||||
targetCharacter: TCharacter;
|
||||
storyHistory: TStoryMoment[];
|
||||
context: TContext;
|
||||
storyHistory?: TStoryMoment[];
|
||||
context?: TContext;
|
||||
conversationHistory: TConversationTurn[];
|
||||
previousSummary: string;
|
||||
targetStatus: TTargetStatus;
|
||||
@@ -99,12 +102,13 @@ export type NpcChatDialogueRequest<
|
||||
TStoryMoment = unknown,
|
||||
TContext = unknown,
|
||||
> = {
|
||||
worldType: string;
|
||||
character: TCharacter;
|
||||
sessionId?: string;
|
||||
worldType?: string;
|
||||
character?: TCharacter;
|
||||
encounter: TEncounter;
|
||||
monsters: TMonster[];
|
||||
history: TStoryMoment[];
|
||||
context: TContext;
|
||||
monsters?: TMonster[];
|
||||
history?: TStoryMoment[];
|
||||
context?: TContext;
|
||||
topic: string;
|
||||
resultSummary: string;
|
||||
npcInitiatesConversation?: boolean;
|
||||
@@ -123,13 +127,14 @@ export type NpcChatTurnRequest<
|
||||
TQuestOfferEncounter = unknown,
|
||||
TChatDirective = NpcChatTurnDirective,
|
||||
> = {
|
||||
worldType: string;
|
||||
sessionId?: string;
|
||||
worldType?: string;
|
||||
character?: TCharacter;
|
||||
player?: TCharacter;
|
||||
encounter: TEncounter;
|
||||
monsters: TMonster[];
|
||||
history: TStoryMoment[];
|
||||
context: TContext;
|
||||
monsters?: TMonster[];
|
||||
history?: TStoryMoment[];
|
||||
context?: TContext;
|
||||
conversationHistory?: TConversationTurn[];
|
||||
dialogue?: TConversationTurn[];
|
||||
combatContext?: TCombatContext | null;
|
||||
@@ -171,12 +176,13 @@ export type NpcRecruitDialogueRequest<
|
||||
TStoryMoment = unknown,
|
||||
TContext = unknown,
|
||||
> = {
|
||||
worldType: string;
|
||||
character: TCharacter;
|
||||
sessionId?: string;
|
||||
worldType?: string;
|
||||
character?: TCharacter;
|
||||
encounter: TEncounter;
|
||||
monsters: TMonster[];
|
||||
history: TStoryMoment[];
|
||||
context: TContext;
|
||||
monsters?: TMonster[];
|
||||
history?: TStoryMoment[];
|
||||
context?: TContext;
|
||||
invitationText: string;
|
||||
recruitSummary: string;
|
||||
};
|
||||
|
||||
@@ -52,7 +52,98 @@ export type RuntimeStoryStatusViewModel = {
|
||||
inBattle: boolean;
|
||||
npcInteractionActive: boolean;
|
||||
currentNpcBattleMode: 'fight' | 'spar' | null;
|
||||
currentNpcBattleOutcome: 'fight_victory' | 'spar_complete' | null;
|
||||
currentNpcBattleOutcome: 'fight_victory' | 'fight_defeat' | 'spar_complete' | null;
|
||||
};
|
||||
|
||||
export type RuntimeStoryInventoryActionView = {
|
||||
functionId: string;
|
||||
actionText: string;
|
||||
payload?: JsonObject;
|
||||
enabled: boolean;
|
||||
reason?: string | null;
|
||||
};
|
||||
|
||||
export type RuntimeStoryInventoryItemActionsView = {
|
||||
use: RuntimeStoryInventoryActionView;
|
||||
equip: RuntimeStoryInventoryActionView;
|
||||
dismantle: RuntimeStoryInventoryActionView;
|
||||
reforge: RuntimeStoryInventoryActionView;
|
||||
};
|
||||
|
||||
export type RuntimeStoryInventoryItemView = {
|
||||
item: JsonObject;
|
||||
actions: RuntimeStoryInventoryItemActionsView;
|
||||
};
|
||||
|
||||
export type RuntimeStoryEquipmentSlotView = {
|
||||
slotId: string;
|
||||
label: string;
|
||||
item?: JsonObject | null;
|
||||
unequip: RuntimeStoryInventoryActionView;
|
||||
};
|
||||
|
||||
export type RuntimeStoryForgeRequirementView = {
|
||||
id: string;
|
||||
label: string;
|
||||
quantity: number;
|
||||
owned: number;
|
||||
};
|
||||
|
||||
export type RuntimeStoryForgeRecipeView = {
|
||||
id: string;
|
||||
name: string;
|
||||
kind: string;
|
||||
description: string;
|
||||
resultLabel: string;
|
||||
currencyCost: number;
|
||||
currencyText: string;
|
||||
requirements: RuntimeStoryForgeRequirementView[];
|
||||
canCraft: boolean;
|
||||
disabledReason?: string | null;
|
||||
action: RuntimeStoryInventoryActionView;
|
||||
};
|
||||
|
||||
export type RuntimeStoryInventoryViewModel = {
|
||||
playerCurrency: number;
|
||||
currencyText: string;
|
||||
inBattle: boolean;
|
||||
backpackItems: RuntimeStoryInventoryItemView[];
|
||||
equipmentSlots: RuntimeStoryEquipmentSlotView[];
|
||||
forgeRecipes: RuntimeStoryForgeRecipeView[];
|
||||
};
|
||||
|
||||
export type RuntimeNpcTradeMode = 'buy' | 'sell';
|
||||
|
||||
export type RuntimeNpcTradeItemView = {
|
||||
itemId: string;
|
||||
item: JsonObject;
|
||||
mode: RuntimeNpcTradeMode;
|
||||
unitPrice: number;
|
||||
maxQuantity: number;
|
||||
canSubmit: boolean;
|
||||
reason?: string | null;
|
||||
};
|
||||
|
||||
export type RuntimeNpcGiftItemView = {
|
||||
itemId: string;
|
||||
item: JsonObject;
|
||||
affinityGain: number;
|
||||
canSubmit: boolean;
|
||||
reason?: string | null;
|
||||
};
|
||||
|
||||
export type RuntimeNpcInteractionView = {
|
||||
npcId: string;
|
||||
npcName: string;
|
||||
playerCurrency: number;
|
||||
currencyName: string;
|
||||
trade: {
|
||||
buyItems: RuntimeNpcTradeItemView[];
|
||||
sellItems: RuntimeNpcTradeItemView[];
|
||||
};
|
||||
gift: {
|
||||
items: RuntimeNpcGiftItemView[];
|
||||
};
|
||||
};
|
||||
|
||||
export type RuntimeBattlePresentation = {
|
||||
@@ -60,15 +151,17 @@ export type RuntimeBattlePresentation = {
|
||||
targetName?: string;
|
||||
damageDealt?: number;
|
||||
damageTaken?: number;
|
||||
outcome?: 'ongoing' | 'victory' | 'spar_complete' | 'escaped';
|
||||
outcome?: 'ongoing' | 'victory' | 'spar_complete' | 'defeat' | 'escaped';
|
||||
};
|
||||
|
||||
export type RuntimeStoryViewModel = {
|
||||
player: RuntimeStoryPlayerViewModel;
|
||||
encounter: RuntimeStoryEncounterViewModel | null;
|
||||
companions: RuntimeStoryCompanionViewModel[];
|
||||
inventory: RuntimeStoryInventoryViewModel;
|
||||
availableOptions: RuntimeStoryOptionView[];
|
||||
status: RuntimeStoryStatusViewModel;
|
||||
npcInteraction?: RuntimeNpcInteractionView | null;
|
||||
};
|
||||
|
||||
export type RuntimeStoryPresentation = {
|
||||
@@ -98,14 +191,14 @@ export type RuntimeStoryPatch =
|
||||
targetId?: string;
|
||||
damageDealt?: number;
|
||||
damageTaken?: number;
|
||||
outcome: 'ongoing' | 'victory' | 'spar_complete' | 'escaped';
|
||||
outcome: 'ongoing' | 'victory' | 'spar_complete' | 'defeat' | 'escaped';
|
||||
}
|
||||
| {
|
||||
type: 'status_changed';
|
||||
inBattle: boolean;
|
||||
npcInteractionActive: boolean;
|
||||
currentNpcBattleMode: 'fight' | 'spar' | null;
|
||||
currentNpcBattleOutcome: 'fight_victory' | 'spar_complete' | null;
|
||||
currentNpcBattleOutcome: 'fight_victory' | 'fight_defeat' | 'spar_complete' | null;
|
||||
}
|
||||
| {
|
||||
type: 'encounter_changed';
|
||||
@@ -117,6 +210,21 @@ export type RuntimeStoryActionRequest =
|
||||
snapshot?: SavedGameSnapshotInput;
|
||||
};
|
||||
|
||||
export type RuntimeStoryAiRequestOptions = {
|
||||
availableOptions?: JsonObject[];
|
||||
optionCatalog?: JsonObject[];
|
||||
};
|
||||
|
||||
export type RuntimeStoryAiRequest = {
|
||||
sessionId: string;
|
||||
clientVersion?: number;
|
||||
choice?: string;
|
||||
lastFunctionId?: string | null;
|
||||
observeSignsRequested?: boolean;
|
||||
recentActionResult?: string | null;
|
||||
requestOptions?: RuntimeStoryAiRequestOptions;
|
||||
};
|
||||
|
||||
export type RuntimeStoryStateRequest<
|
||||
TSnapshotGameState = JsonObject,
|
||||
TSnapshotCurrentStory = JsonObject,
|
||||
@@ -130,6 +238,30 @@ export type RuntimeStoryStateRequest<
|
||||
>;
|
||||
};
|
||||
|
||||
export type RuntimeStoryBootstrapRequest<
|
||||
TProfile = JsonObject,
|
||||
TCharacter = JsonObject,
|
||||
> = {
|
||||
worldType: string;
|
||||
customWorldProfile?: TProfile | null;
|
||||
character: TCharacter;
|
||||
runtimeMode?: 'play' | 'preview' | 'test';
|
||||
disablePersistence?: boolean;
|
||||
};
|
||||
|
||||
export type RuntimeStoryBootstrapResponse<
|
||||
TSnapshotGameState = JsonObject,
|
||||
TSnapshotCurrentStory = JsonObject,
|
||||
> = {
|
||||
sessionId: string;
|
||||
serverVersion: number;
|
||||
snapshot: SavedGameSnapshot<
|
||||
TSnapshotGameState,
|
||||
string,
|
||||
TSnapshotCurrentStory
|
||||
>;
|
||||
};
|
||||
|
||||
export type RuntimeStoryActionResponse<
|
||||
TSnapshotGameState = JsonObject,
|
||||
TSnapshotCurrentStory = JsonObject,
|
||||
|
||||
@@ -29,6 +29,12 @@ export type SavedGameSnapshotInput<
|
||||
savedAt?: string;
|
||||
};
|
||||
|
||||
export type RuntimeSaveCheckpointInput<TBottomTab extends string = string> = {
|
||||
sessionId: string;
|
||||
bottomTab: TBottomTab;
|
||||
savedAt?: string;
|
||||
};
|
||||
|
||||
export type RuntimeSettings = {
|
||||
musicVolume: number;
|
||||
platformTheme: PlatformTheme;
|
||||
|
||||
@@ -9,6 +9,7 @@ export * from './contracts/rpgAgentDraft';
|
||||
export * from './contracts/rpgAgentSession';
|
||||
export * from './contracts/rpgCreationFixtures';
|
||||
export * from './contracts/rpgCreationPreview';
|
||||
export * from './contracts/rpgCreationResultView';
|
||||
export * from './contracts/rpgCreationWorkSummary';
|
||||
export * from './contracts/puzzleAgentActions';
|
||||
export * from './contracts/puzzleAgentDraft';
|
||||
|
||||
155
scripts/check-server-rs-ddd-boundaries.mjs
Normal file
155
scripts/check-server-rs-ddd-boundaries.mjs
Normal file
@@ -0,0 +1,155 @@
|
||||
import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs';
|
||||
import { basename, join, relative } from 'node:path';
|
||||
|
||||
const repoRoot = process.cwd();
|
||||
const cratesDir = join(repoRoot, 'server-rs', 'crates');
|
||||
const requiredModuleFiles = [
|
||||
'domain.rs',
|
||||
'commands.rs',
|
||||
'application.rs',
|
||||
'events.rs',
|
||||
'errors.rs',
|
||||
];
|
||||
const requiredLibModules = ['domain', 'commands', 'application', 'events', 'errors'];
|
||||
const forbiddenModuleWidePatterns = [
|
||||
{
|
||||
pattern: /\baxum::/u,
|
||||
message: 'module-* 不允许直接依赖 Axum',
|
||||
},
|
||||
{
|
||||
pattern: /\bspacetimedb::(?:table|reducer|procedure|ReducerContext|ProcedureContext|Table)\b/u,
|
||||
message: 'module-* 不允许声明 SpacetimeDB table/reducer/procedure 或直接操作表',
|
||||
},
|
||||
];
|
||||
const forbiddenCorePatterns = [
|
||||
{
|
||||
pattern: /\breqwest::/u,
|
||||
message: 'DDD 核心文件不允许直接依赖 reqwest',
|
||||
},
|
||||
{
|
||||
pattern: /\bplatform_oss::/u,
|
||||
message: 'DDD 核心文件不允许直接依赖 OSS adapter',
|
||||
},
|
||||
{
|
||||
pattern: /\bplatform_llm::/u,
|
||||
message: 'DDD 核心文件不允许直接依赖 LLM adapter',
|
||||
},
|
||||
{
|
||||
pattern: /\bspacetime_client::/u,
|
||||
message: 'DDD 核心文件不允许直接依赖 SpacetimeDB client adapter',
|
||||
},
|
||||
{
|
||||
pattern: /\bstd::fs\b/u,
|
||||
message: 'DDD 核心文件不允许直接访问文件系统',
|
||||
},
|
||||
{
|
||||
pattern: /\btokio::/u,
|
||||
message: 'DDD 核心文件不允许绑定异步运行时',
|
||||
},
|
||||
];
|
||||
|
||||
function normalizePath(path) {
|
||||
return path.replace(/\\/gu, '/');
|
||||
}
|
||||
|
||||
function readText(path) {
|
||||
return readFileSync(path, 'utf8');
|
||||
}
|
||||
|
||||
function listRustFiles(dir) {
|
||||
const files = [];
|
||||
|
||||
function walk(currentDir) {
|
||||
for (const name of readdirSync(currentDir)) {
|
||||
const fullPath = join(currentDir, name);
|
||||
const stat = statSync(fullPath);
|
||||
|
||||
if (stat.isDirectory()) {
|
||||
walk(fullPath);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (name.endsWith('.rs')) {
|
||||
files.push(fullPath);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
walk(dir);
|
||||
return files;
|
||||
}
|
||||
|
||||
function collectModuleCrates() {
|
||||
return readdirSync(cratesDir)
|
||||
.filter((name) => name.startsWith('module-'))
|
||||
.filter((name) => existsSync(join(cratesDir, name, 'Cargo.toml')))
|
||||
.sort();
|
||||
}
|
||||
|
||||
const failures = [];
|
||||
const moduleCrates = collectModuleCrates();
|
||||
|
||||
for (const crateName of moduleCrates) {
|
||||
const crateDir = join(cratesDir, crateName);
|
||||
const srcDir = join(crateDir, 'src');
|
||||
const libPath = join(srcDir, 'lib.rs');
|
||||
|
||||
for (const fileName of requiredModuleFiles) {
|
||||
const filePath = join(srcDir, fileName);
|
||||
if (!existsSync(filePath)) {
|
||||
failures.push(`${crateName} 缺少 DDD 落位文件 src/${fileName}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (existsSync(libPath)) {
|
||||
const libText = readText(libPath);
|
||||
for (const moduleName of requiredLibModules) {
|
||||
const moduleDeclaration = new RegExp(
|
||||
`(?:^|\\n)\\s*(?:pub(?:\\([^)]*\\))?\\s+)?mod\\s+${moduleName}\\s*;`,
|
||||
'u',
|
||||
);
|
||||
if (!moduleDeclaration.test(libText)) {
|
||||
failures.push(`${crateName} 的 lib.rs 缺少模块声明 mod ${moduleName};`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const rustFile of listRustFiles(srcDir)) {
|
||||
const relativePath = normalizePath(relative(repoRoot, rustFile));
|
||||
const fileName = basename(rustFile);
|
||||
const text = readText(rustFile);
|
||||
|
||||
if (fileName === 'mapper.rs') {
|
||||
failures.push(`${relativePath} 不能位于 module-*,mapper 只能放在 adapter crate`);
|
||||
}
|
||||
|
||||
for (const rule of forbiddenModuleWidePatterns) {
|
||||
if (rule.pattern.test(text)) {
|
||||
failures.push(`${relativePath}: ${rule.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
const isDddCoreFile = requiredModuleFiles.some((name) =>
|
||||
relativePath.endsWith(`/src/${name}`),
|
||||
);
|
||||
if (!isDddCoreFile) {
|
||||
continue;
|
||||
}
|
||||
|
||||
for (const rule of forbiddenCorePatterns) {
|
||||
if (rule.pattern.test(text)) {
|
||||
failures.push(`${relativePath}: ${rule.message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (failures.length > 0) {
|
||||
console.error('server-rs DDD boundary check failed:');
|
||||
for (const failure of failures) {
|
||||
console.error(`- ${failure}`);
|
||||
}
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log(`server-rs DDD boundary check passed for ${moduleCrates.length} module crate(s).`);
|
||||
@@ -97,6 +97,18 @@
|
||||
3. 前端或 Node 侧的 SpacetimeDB TypeScript SDK、订阅、绑定使用按 `spacetimedb-typescript`、`spacetimedb-concepts` 执行。
|
||||
4. 若仓库内旧实现或旧文档与这些 skill 冲突,先修正文档和方案,再继续编码。
|
||||
|
||||
## 6. DDD 目录与边界
|
||||
|
||||
`2026-04-28` 起,`server-rs` 进入 DDD 边界收口阶段,完整规则见 [../docs/technical/SERVER_RS_DDD_FULL_REFACTOR_2026-04-28.md](../docs/technical/SERVER_RS_DDD_FULL_REFACTOR_2026-04-28.md)。
|
||||
|
||||
新增或迁移业务代码时必须遵守:
|
||||
|
||||
1. `module-*` 统一维护 `domain.rs`、`commands.rs`、`application.rs`、`events.rs`、`errors.rs`。
|
||||
2. `module-*` 不新增 Axum、reqwest、OSS、LLM、文件系统、SpacetimeDB table/reducer/procedure 依赖。
|
||||
3. `mapper.rs` 只允许出现在 `api-server`、`spacetime-module`、`spacetime-client` 等 adapter crate。
|
||||
4. `spacetime-module` 新增业务入口前先确认是否已有对应上下文目录,禁止继续把大段业务流程堆回 `src/lib.rs`。
|
||||
5. 根目录可执行 `npm run check:server-rs-ddd` 检查第一阶段 DDD 骨架与绝对边界。
|
||||
|
||||
## 5. 关联文档
|
||||
|
||||
1. [../docs/technical/SPACETIMEDB_AXUM_OSS_BACKEND_REWRITE_DESIGN_2026-04-20.md](../docs/technical/SPACETIMEDB_AXUM_OSS_BACKEND_REWRITE_DESIGN_2026-04-20.md)
|
||||
|
||||
@@ -40,7 +40,8 @@ use crate::{
|
||||
character_animation_assets::{
|
||||
generate_character_animation, get_character_animation_job, get_character_workflow_cache,
|
||||
import_character_animation_video, list_character_animation_templates,
|
||||
publish_character_animation, save_character_workflow_cache,
|
||||
publish_character_animation, put_role_asset_workflow, resolve_role_asset_workflow,
|
||||
save_character_workflow_cache,
|
||||
},
|
||||
character_visual_assets::{
|
||||
generate_character_visual, get_character_visual_job, publish_character_visual,
|
||||
@@ -49,7 +50,8 @@ use crate::{
|
||||
custom_world::{
|
||||
create_custom_world_agent_session, delete_custom_world_agent_session,
|
||||
delete_custom_world_library_profile, execute_custom_world_agent_action,
|
||||
get_custom_world_agent_card_detail, get_custom_world_agent_operation,
|
||||
generate_custom_world_profile, get_custom_world_agent_card_detail,
|
||||
get_custom_world_agent_operation, get_custom_world_agent_result_view,
|
||||
get_custom_world_agent_session, get_custom_world_gallery_detail,
|
||||
get_custom_world_gallery_detail_by_code, get_custom_world_library,
|
||||
get_custom_world_library_detail, get_custom_world_works, list_custom_world_gallery,
|
||||
@@ -92,6 +94,11 @@ use crate::{
|
||||
delete_runtime_browse_history, get_runtime_browse_history, post_runtime_browse_history,
|
||||
},
|
||||
runtime_chat::stream_runtime_npc_chat_turn,
|
||||
runtime_chat_plain::{
|
||||
generate_runtime_character_chat_suggestions, generate_runtime_character_chat_summary,
|
||||
stream_runtime_character_chat_reply, stream_runtime_npc_chat_dialogue,
|
||||
stream_runtime_npc_recruit_dialogue,
|
||||
},
|
||||
runtime_inventory::get_runtime_inventory_state,
|
||||
runtime_profile::{
|
||||
admin_disable_profile_redeem_code, admin_upsert_profile_redeem_code,
|
||||
@@ -105,8 +112,9 @@ use crate::{
|
||||
},
|
||||
runtime_settings::{get_runtime_settings, put_runtime_settings},
|
||||
runtime_story::{
|
||||
generate_runtime_story_continue, generate_runtime_story_initial, get_runtime_story_state,
|
||||
resolve_runtime_story_action, resolve_runtime_story_state,
|
||||
begin_runtime_story_session, generate_runtime_story_continue,
|
||||
generate_runtime_story_initial, get_runtime_story_state, resolve_runtime_story_action,
|
||||
resolve_runtime_story_state,
|
||||
},
|
||||
state::AppState,
|
||||
story_battles::{
|
||||
@@ -263,6 +271,32 @@ pub fn build_router(state: AppState) -> Router {
|
||||
require_bearer_auth,
|
||||
)),
|
||||
)
|
||||
.route(
|
||||
"/api/runtime/chat/character/suggestions",
|
||||
post(generate_runtime_character_chat_suggestions).route_layer(
|
||||
middleware::from_fn_with_state(state.clone(), require_bearer_auth),
|
||||
),
|
||||
)
|
||||
.route(
|
||||
"/api/runtime/chat/character/summary",
|
||||
post(generate_runtime_character_chat_summary).route_layer(
|
||||
middleware::from_fn_with_state(state.clone(), require_bearer_auth),
|
||||
),
|
||||
)
|
||||
.route(
|
||||
"/api/runtime/chat/character/reply/stream",
|
||||
post(stream_runtime_character_chat_reply).route_layer(middleware::from_fn_with_state(
|
||||
state.clone(),
|
||||
require_bearer_auth,
|
||||
)),
|
||||
)
|
||||
.route(
|
||||
"/api/runtime/chat/npc/dialogue/stream",
|
||||
post(stream_runtime_npc_chat_dialogue).route_layer(middleware::from_fn_with_state(
|
||||
state.clone(),
|
||||
require_bearer_auth,
|
||||
)),
|
||||
)
|
||||
.route(
|
||||
"/api/runtime/chat/npc/turn/stream",
|
||||
post(stream_runtime_npc_chat_turn).route_layer(middleware::from_fn_with_state(
|
||||
@@ -270,6 +304,13 @@ pub fn build_router(state: AppState) -> Router {
|
||||
require_bearer_auth,
|
||||
)),
|
||||
)
|
||||
.route(
|
||||
"/api/runtime/chat/npc/recruit/stream",
|
||||
post(stream_runtime_npc_recruit_dialogue).route_layer(middleware::from_fn_with_state(
|
||||
state.clone(),
|
||||
require_bearer_auth,
|
||||
)),
|
||||
)
|
||||
.route(
|
||||
"/api/runtime/creation-agent/document-inputs/parse",
|
||||
post(parse_creation_agent_document_input).route_layer(middleware::from_fn_with_state(
|
||||
@@ -412,6 +453,10 @@ pub fn build_router(state: AppState) -> Router {
|
||||
"/api/assets/character-workflow-cache/{character_id}",
|
||||
get(get_character_workflow_cache),
|
||||
)
|
||||
.route(
|
||||
"/api/runtime/custom-world/asset-studio/role/{character_id}/workflow",
|
||||
post(resolve_role_asset_workflow).put(put_role_asset_workflow),
|
||||
)
|
||||
.route("/api/assets/read-url", get(get_asset_read_url))
|
||||
.route(
|
||||
"/api/assets/history",
|
||||
@@ -497,6 +542,13 @@ pub fn build_router(state: AppState) -> Router {
|
||||
require_bearer_auth,
|
||||
)),
|
||||
)
|
||||
.route(
|
||||
"/api/runtime/custom-world/agent/sessions/{session_id}/result-view",
|
||||
get(get_custom_world_agent_result_view).route_layer(middleware::from_fn_with_state(
|
||||
state.clone(),
|
||||
require_bearer_auth,
|
||||
)),
|
||||
)
|
||||
.route(
|
||||
"/api/runtime/custom-world/works",
|
||||
get(get_custom_world_works).route_layer(middleware::from_fn_with_state(
|
||||
@@ -709,6 +761,13 @@ pub fn build_router(state: AppState) -> Router {
|
||||
require_bearer_auth,
|
||||
)),
|
||||
)
|
||||
.route(
|
||||
"/api/runtime/custom-world/profile",
|
||||
post(generate_custom_world_profile).route_layer(middleware::from_fn_with_state(
|
||||
state.clone(),
|
||||
require_bearer_auth,
|
||||
)),
|
||||
)
|
||||
.route(
|
||||
"/api/custom-world/entity",
|
||||
post(generate_custom_world_entity).route_layer(middleware::from_fn_with_state(
|
||||
@@ -932,6 +991,13 @@ pub fn build_router(state: AppState) -> Router {
|
||||
require_bearer_auth,
|
||||
)),
|
||||
)
|
||||
.route(
|
||||
"/api/runtime/story/sessions",
|
||||
post(begin_runtime_story_session).route_layer(middleware::from_fn_with_state(
|
||||
state.clone(),
|
||||
require_bearer_auth,
|
||||
)),
|
||||
)
|
||||
.route(
|
||||
"/api/runtime/story/state/resolve",
|
||||
post(resolve_runtime_story_state).route_layer(middleware::from_fn_with_state(
|
||||
|
||||
@@ -32,10 +32,10 @@ use shared_kernel::{build_prefixed_uuid_id, format_timestamp_micros};
|
||||
use spacetime_client::{
|
||||
BigFishAgentMessageRecord, BigFishAnchorItemRecord, BigFishAnchorPackRecord,
|
||||
BigFishAssetCoverageRecord, BigFishAssetGenerateRecordInput, BigFishAssetSlotRecord,
|
||||
BigFishBackgroundBlueprintRecord, BigFishGameDraftRecord, BigFishLevelBlueprintRecord,
|
||||
BigFishMessageSubmitRecordInput, BigFishPlayReportRecordInput, BigFishRuntimeParamsRecord,
|
||||
BigFishSessionCreateRecordInput, BigFishSessionRecord, BigFishWorkSummaryRecord,
|
||||
SpacetimeClientError,
|
||||
BigFishBackgroundBlueprintRecord, BigFishDraftCompileRecordInput, BigFishGameDraftRecord,
|
||||
BigFishLevelBlueprintRecord, BigFishMessageSubmitRecordInput, BigFishPlayReportRecordInput,
|
||||
BigFishRuntimeParamsRecord, BigFishSessionCreateRecordInput, BigFishSessionRecord,
|
||||
BigFishWorkSummaryRecord, SpacetimeClientError,
|
||||
};
|
||||
use tokio::time::sleep;
|
||||
|
||||
@@ -43,6 +43,12 @@ use crate::big_fish_agent_turn::{
|
||||
BigFishAgentTurnRequest, build_failed_finalize_record_input, build_finalize_record_input,
|
||||
run_big_fish_agent_turn,
|
||||
};
|
||||
use crate::big_fish_draft_compiler::compile_big_fish_draft_with_fallback;
|
||||
use crate::prompt::big_fish::{
|
||||
BIG_FISH_DEFAULT_NEGATIVE_PROMPT, BIG_FISH_TRANSPARENT_ASSET_NEGATIVE_PROMPT,
|
||||
build_big_fish_level_main_image_prompt, build_big_fish_level_motion_prompt,
|
||||
build_big_fish_stage_background_prompt,
|
||||
};
|
||||
use crate::{
|
||||
ai_generation_drafts::{
|
||||
AiGenerationDraftContext, AiGenerationDraftSink, AiGenerationDraftWriter,
|
||||
@@ -50,6 +56,7 @@ use crate::{
|
||||
api_response::json_success_body,
|
||||
asset_billing::execute_billable_asset_operation,
|
||||
auth::AuthenticatedAccessToken,
|
||||
character_visual_assets::try_apply_background_alpha_to_png,
|
||||
http_error::AppError,
|
||||
request_context::RequestContext,
|
||||
state::AppState,
|
||||
@@ -103,13 +110,13 @@ pub async fn get_big_fish_session(
|
||||
) -> Result<Json<Value>, Response> {
|
||||
ensure_non_empty(&request_context, &session_id, "sessionId")?;
|
||||
|
||||
let session = state
|
||||
.spacetime_client()
|
||||
.get_big_fish_session(session_id, authenticated.claims().user_id().to_string())
|
||||
.await
|
||||
.map_err(|error| {
|
||||
big_fish_error_response(&request_context, map_big_fish_client_error(error))
|
||||
})?;
|
||||
let session = load_big_fish_session_with_retry(
|
||||
&state,
|
||||
session_id,
|
||||
authenticated.claims().user_id().to_string(),
|
||||
)
|
||||
.await
|
||||
.map_err(|error| big_fish_error_response(&request_context, map_big_fish_client_error(error)))?;
|
||||
|
||||
Ok(json_success_body(
|
||||
Some(&request_context),
|
||||
@@ -147,13 +154,22 @@ pub async fn list_big_fish_gallery(
|
||||
State(state): State<AppState>,
|
||||
Extension(request_context): Extension<RequestContext>,
|
||||
) -> Result<Json<Value>, Response> {
|
||||
let items = state
|
||||
.spacetime_client()
|
||||
.list_big_fish_gallery()
|
||||
.await
|
||||
.map_err(|error| {
|
||||
big_fish_error_response(&request_context, map_big_fish_client_error(error))
|
||||
})?;
|
||||
let items = match state.spacetime_client().list_big_fish_gallery().await {
|
||||
Ok(items) => items,
|
||||
Err(error) if should_soft_fallback_big_fish_gallery(&error) => {
|
||||
tracing::warn!(
|
||||
error = %error,
|
||||
"大鱼吃小鱼公开广场读取失败,已按空广场降级返回"
|
||||
);
|
||||
Vec::new()
|
||||
}
|
||||
Err(error) => {
|
||||
return Err(big_fish_error_response(
|
||||
&request_context,
|
||||
map_big_fish_client_error(error),
|
||||
));
|
||||
}
|
||||
};
|
||||
|
||||
Ok(json_success_body(
|
||||
Some(&request_context),
|
||||
@@ -527,14 +543,11 @@ pub async fn execute_big_fish_action(
|
||||
let billing_asset_id = format!("{session_id}:{now}");
|
||||
let session_operation = async {
|
||||
match action.as_str() {
|
||||
"big_fish_compile_draft" => compile_big_fish_draft_with_all_assets(
|
||||
&state,
|
||||
session_id.clone(),
|
||||
owner_user_id.clone(),
|
||||
now,
|
||||
)
|
||||
.await
|
||||
.map_err(map_big_fish_client_error),
|
||||
"big_fish_compile_draft" => {
|
||||
compile_big_fish_draft_only(&state, session_id.clone(), owner_user_id.clone(), now)
|
||||
.await
|
||||
.map_err(map_big_fish_client_error)
|
||||
}
|
||||
"big_fish_generate_level_main_image" => {
|
||||
let asset_url = generate_big_fish_formal_asset(
|
||||
&state,
|
||||
@@ -714,9 +727,13 @@ fn map_big_fish_level_response(
|
||||
level: level.level,
|
||||
name: level.name,
|
||||
one_line_fantasy: level.one_line_fantasy,
|
||||
text_description: level.text_description,
|
||||
silhouette_direction: level.silhouette_direction,
|
||||
size_ratio: level.size_ratio,
|
||||
visual_description: level.visual_description,
|
||||
visual_prompt_seed: level.visual_prompt_seed,
|
||||
idle_motion_description: level.idle_motion_description,
|
||||
move_motion_description: level.move_motion_description,
|
||||
motion_prompt_seed: level.motion_prompt_seed,
|
||||
merge_source_level: level.merge_source_level,
|
||||
prey_window: level.prey_window,
|
||||
@@ -782,98 +799,88 @@ fn map_big_fish_asset_coverage_response(
|
||||
}
|
||||
}
|
||||
|
||||
async fn compile_big_fish_draft_with_all_assets(
|
||||
async fn compile_big_fish_draft_only(
|
||||
state: &AppState,
|
||||
session_id: String,
|
||||
owner_user_id: String,
|
||||
now: i64,
|
||||
) -> Result<BigFishSessionRecord, SpacetimeClientError> {
|
||||
let session = state
|
||||
.spacetime_client()
|
||||
.compile_big_fish_draft(session_id.clone(), owner_user_id.clone(), now)
|
||||
.await?;
|
||||
let draft = session
|
||||
.draft
|
||||
.clone()
|
||||
.ok_or_else(|| SpacetimeClientError::Runtime("大鱼吃小鱼玩法草稿尚未生成".to_string()))?;
|
||||
// 点击生成草稿时一次性生成所有首版玩法资产,前端只负责展示进度和最终 session。
|
||||
for level in &draft.levels {
|
||||
let asset_url = generate_big_fish_formal_asset(
|
||||
state,
|
||||
&owner_user_id,
|
||||
&session_id,
|
||||
"level_main_image",
|
||||
Some(level.level),
|
||||
None,
|
||||
current_utc_micros(),
|
||||
)
|
||||
.await
|
||||
.map_err(|error| SpacetimeClientError::Runtime(error.message().to_string()))?;
|
||||
state
|
||||
.spacetime_client()
|
||||
.generate_big_fish_asset(BigFishAssetGenerateRecordInput {
|
||||
session_id: session_id.clone(),
|
||||
owner_user_id: owner_user_id.clone(),
|
||||
asset_kind: "level_main_image".to_string(),
|
||||
level: Some(level.level),
|
||||
motion_key: None,
|
||||
asset_url: Some(asset_url),
|
||||
generated_at_micros: current_utc_micros(),
|
||||
})
|
||||
.await?;
|
||||
}
|
||||
for level in &draft.levels {
|
||||
for motion_key in ["idle_float", "move_swim"] {
|
||||
let asset_url = generate_big_fish_formal_asset(
|
||||
state,
|
||||
&owner_user_id,
|
||||
&session_id,
|
||||
"level_motion",
|
||||
Some(level.level),
|
||||
Some(motion_key),
|
||||
current_utc_micros(),
|
||||
)
|
||||
.await
|
||||
.map_err(|error| SpacetimeClientError::Runtime(error.message().to_string()))?;
|
||||
state
|
||||
.spacetime_client()
|
||||
.generate_big_fish_asset(BigFishAssetGenerateRecordInput {
|
||||
session_id: session_id.clone(),
|
||||
owner_user_id: owner_user_id.clone(),
|
||||
asset_kind: "level_motion".to_string(),
|
||||
level: Some(level.level),
|
||||
motion_key: Some(motion_key.to_string()),
|
||||
asset_url: Some(asset_url),
|
||||
generated_at_micros: current_utc_micros(),
|
||||
})
|
||||
.await?;
|
||||
}
|
||||
}
|
||||
let asset_url = generate_big_fish_formal_asset(
|
||||
state,
|
||||
&owner_user_id,
|
||||
&session_id,
|
||||
"stage_background",
|
||||
None,
|
||||
None,
|
||||
current_utc_micros(),
|
||||
)
|
||||
.await
|
||||
.map_err(|error| SpacetimeClientError::Runtime(error.message().to_string()))?;
|
||||
// 中文注释:大鱼吃小鱼草稿阶段只负责编译结果页草稿,不在这一步串行生成主图、动作或背景。
|
||||
// 这些资产统一留在结果页工坊按需触发,避免 compile action 因长耗时资产任务卡在首步等待态。
|
||||
let session =
|
||||
load_big_fish_session_with_retry(state, session_id.clone(), owner_user_id.clone()).await?;
|
||||
let anchor_pack = map_record_anchor_pack_to_domain(&session.anchor_pack);
|
||||
let compiled_draft =
|
||||
compile_big_fish_draft_with_fallback(state.llm_client(), &anchor_pack).await;
|
||||
let draft_json = serde_json::to_string(&compiled_draft).ok();
|
||||
|
||||
state
|
||||
.spacetime_client()
|
||||
.generate_big_fish_asset(BigFishAssetGenerateRecordInput {
|
||||
.compile_big_fish_draft(BigFishDraftCompileRecordInput {
|
||||
session_id,
|
||||
owner_user_id,
|
||||
asset_kind: "stage_background".to_string(),
|
||||
level: None,
|
||||
motion_key: None,
|
||||
asset_url: Some(asset_url),
|
||||
generated_at_micros: current_utc_micros(),
|
||||
draft_json,
|
||||
compiled_at_micros: now,
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
async fn load_big_fish_session_with_retry(
|
||||
state: &AppState,
|
||||
session_id: String,
|
||||
owner_user_id: String,
|
||||
) -> Result<BigFishSessionRecord, SpacetimeClientError> {
|
||||
let mut last_retryable_error = None;
|
||||
|
||||
for attempt in 0..2 {
|
||||
match state
|
||||
.spacetime_client()
|
||||
.get_big_fish_session(session_id.clone(), owner_user_id.clone())
|
||||
.await
|
||||
{
|
||||
Ok(session) => return Ok(session),
|
||||
Err(error @ SpacetimeClientError::Timeout)
|
||||
| Err(error @ SpacetimeClientError::ConnectDropped) => {
|
||||
last_retryable_error = Some(error);
|
||||
if attempt == 0 {
|
||||
sleep(Duration::from_millis(250)).await;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
Err(error) => return Err(error),
|
||||
}
|
||||
}
|
||||
|
||||
Err(last_retryable_error.unwrap_or(SpacetimeClientError::Timeout))
|
||||
}
|
||||
|
||||
fn map_record_anchor_pack_to_domain(
|
||||
anchor_pack: &BigFishAnchorPackRecord,
|
||||
) -> module_big_fish::BigFishAnchorPack {
|
||||
module_big_fish::BigFishAnchorPack {
|
||||
gameplay_promise: map_record_anchor_item_to_domain(&anchor_pack.gameplay_promise),
|
||||
ecology_visual_theme: map_record_anchor_item_to_domain(&anchor_pack.ecology_visual_theme),
|
||||
growth_ladder: map_record_anchor_item_to_domain(&anchor_pack.growth_ladder),
|
||||
risk_tempo: map_record_anchor_item_to_domain(&anchor_pack.risk_tempo),
|
||||
}
|
||||
}
|
||||
|
||||
fn map_record_anchor_item_to_domain(
|
||||
anchor_item: &BigFishAnchorItemRecord,
|
||||
) -> module_big_fish::BigFishAnchorItem {
|
||||
module_big_fish::BigFishAnchorItem {
|
||||
key: anchor_item.key.clone(),
|
||||
label: anchor_item.label.clone(),
|
||||
value: anchor_item.value.clone(),
|
||||
status: match anchor_item.status.as_str() {
|
||||
"confirmed" => module_big_fish::BigFishAnchorStatus::Confirmed,
|
||||
"locked" => module_big_fish::BigFishAnchorStatus::Locked,
|
||||
"inferred" => module_big_fish::BigFishAnchorStatus::Inferred,
|
||||
_ => module_big_fish::BigFishAnchorStatus::Missing,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn map_big_fish_agent_message_response(
|
||||
message: BigFishAgentMessageRecord,
|
||||
) -> BigFishAgentMessageResponse {
|
||||
@@ -941,12 +948,11 @@ struct BigFishFormalAssetContext {
|
||||
asset_object_kind: String,
|
||||
binding_slot: String,
|
||||
path_segments: Vec<String>,
|
||||
apply_transparent_background_post_process: bool,
|
||||
}
|
||||
|
||||
const BIG_FISH_TEXT_TO_IMAGE_MODEL: &str = "wan2.2-t2i-flash";
|
||||
const BIG_FISH_ENTITY_KIND: &str = "big_fish_session";
|
||||
const BIG_FISH_DEFAULT_NEGATIVE_PROMPT: &str = "文字,水印,logo,UI界面,对话框,边框,多余肢体,畸形鱼体,低清晰度,模糊,压缩噪点,现代摄影棚,写实照片背景,复杂背景";
|
||||
const BIG_FISH_TRANSPARENT_ASSET_NEGATIVE_PROMPT: &str = "文字,水印,logo,UI界面,对话框,边框,多余肢体,畸形鱼体,低清晰度,模糊,压缩噪点,现代摄影棚,写实照片背景,场景背景,水草背景,气泡背景,多只主体,阴影地面";
|
||||
|
||||
async fn generate_big_fish_formal_asset(
|
||||
state: &AppState,
|
||||
@@ -990,6 +996,7 @@ async fn generate_big_fish_formal_asset(
|
||||
&http_client,
|
||||
generated.image_url.as_str(),
|
||||
"下载 Big Fish 正式图片失败",
|
||||
context.apply_transparent_background_post_process,
|
||||
)
|
||||
.await?;
|
||||
|
||||
@@ -1030,6 +1037,7 @@ fn build_big_fish_formal_asset_context(
|
||||
level_part,
|
||||
asset_id,
|
||||
],
|
||||
apply_transparent_background_post_process: true,
|
||||
})
|
||||
}
|
||||
"level_motion" => {
|
||||
@@ -1058,6 +1066,7 @@ fn build_big_fish_formal_asset_context(
|
||||
sanitize_big_fish_path_segment(motion_key, "motion"),
|
||||
asset_id,
|
||||
],
|
||||
apply_transparent_background_post_process: true,
|
||||
})
|
||||
}
|
||||
"stage_background" => Ok(BigFishFormalAssetContext {
|
||||
@@ -1072,6 +1081,7 @@ fn build_big_fish_formal_asset_context(
|
||||
"stage-background".to_string(),
|
||||
asset_id,
|
||||
],
|
||||
apply_transparent_background_post_process: false,
|
||||
}),
|
||||
_ => Err(
|
||||
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
|
||||
@@ -1104,79 +1114,6 @@ fn find_big_fish_level_blueprint(
|
||||
})
|
||||
}
|
||||
|
||||
fn build_big_fish_level_main_image_prompt(
|
||||
draft: &BigFishGameDraftRecord,
|
||||
level: &BigFishLevelBlueprintRecord,
|
||||
) -> String {
|
||||
vec![
|
||||
format!(
|
||||
"为竖屏移动游戏《{}》生成一张等级生物主图。",
|
||||
draft.title
|
||||
),
|
||||
format!(
|
||||
"生态主题:{}。核心乐趣:{}。",
|
||||
draft.ecology_theme, draft.core_fun
|
||||
),
|
||||
format!(
|
||||
"等级:Lv.{},名称:{},幻想描述:{}。",
|
||||
level.level, level.name, level.one_line_fantasy
|
||||
),
|
||||
format!("轮廓方向:{}。", level.silhouette_direction),
|
||||
format!("视觉提示词种子:{}。", level.visual_prompt_seed),
|
||||
"画面要求:按 RPG 角色资产口径生成,单体鱼形游戏生物完整入镜,轮廓清晰,中心构图,2D 高完成度游戏插画,深海发光质感。".to_string(),
|
||||
"背景要求:透明背景 PNG 风格,不出现任何场景、水草、气泡、阴影地面、UI、文字、logo、水印、对话框或边框;不要出现多只主体。".to_string(),
|
||||
]
|
||||
.join("")
|
||||
}
|
||||
|
||||
fn build_big_fish_level_motion_prompt(
|
||||
draft: &BigFishGameDraftRecord,
|
||||
level: &BigFishLevelBlueprintRecord,
|
||||
motion_key: &str,
|
||||
) -> String {
|
||||
let motion_text = match motion_key {
|
||||
"move_swim" => "向右游动的关键帧预览,身体与尾鳍有清晰推进姿态,带轻微水流拖尾。",
|
||||
_ => "待机漂浮的关键帧预览,身体轻微摆动,姿态稳定,适合作为 idle 状态。",
|
||||
};
|
||||
vec![
|
||||
format!(
|
||||
"为竖屏移动游戏《{}》生成一张等级生物动作关键帧静态预览图。",
|
||||
draft.title
|
||||
),
|
||||
format!("生态主题:{}。", draft.ecology_theme),
|
||||
format!(
|
||||
"等级:Lv.{},名称:{},幻想描述:{}。",
|
||||
level.level, level.name, level.one_line_fantasy
|
||||
),
|
||||
format!("动作提示词种子:{}。", level.motion_prompt_seed),
|
||||
format!("动作要求:{motion_text}"),
|
||||
"画面要求:按 RPG 角色动画资产口径生成,单体鱼形生物完整入镜,轮廓清晰,动作方向明确,2D 高完成度游戏插画,适合作为 Big Fish 动作槽位的静态 keyframe。".to_string(),
|
||||
"背景要求:透明背景 PNG 风格,不出现任何场景、水草、气泡、阴影地面、UI、文字、logo、水印、对话框或边框;不要生成序列帧拼图,不要出现多只主体。".to_string(),
|
||||
]
|
||||
.join("")
|
||||
}
|
||||
|
||||
fn build_big_fish_stage_background_prompt(draft: &BigFishGameDraftRecord) -> String {
|
||||
let background = &draft.background;
|
||||
vec![
|
||||
format!(
|
||||
"为竖屏移动游戏《{}》生成一张 9:16 全屏活动区域背景。",
|
||||
draft.title
|
||||
),
|
||||
format!("生态主题:{}。", draft.ecology_theme),
|
||||
format!("背景主题:{}。色彩氛围:{}。", background.theme, background.color_mood),
|
||||
format!("前景提示:{}。", background.foreground_hints),
|
||||
format!("中景构图:{}。", background.midground_composition),
|
||||
format!("背景纵深:{}。", background.background_depth),
|
||||
format!("安全操作区:{}。", background.safe_play_area_hint),
|
||||
format!("出生边缘:{}。", background.spawn_edge_hint),
|
||||
format!("背景提示词种子:{}。", background.background_prompt_seed),
|
||||
"画面要求:竖屏 9:16,大场地,全屏运行态背景,中央 80% 保持开阔清爽,边缘只保留少量出生区环境提示。".to_string(),
|
||||
"元素要求:整体元素少,不出现大型主体、密集装饰、鱼群主角、UI、文字、logo、水印、对话框或边框;不要把中央操作区画得过暗或过复杂。".to_string(),
|
||||
]
|
||||
.join("")
|
||||
}
|
||||
|
||||
fn require_big_fish_dashscope_settings(
|
||||
state: &AppState,
|
||||
) -> Result<BigFishDashScopeSettings, AppError> {
|
||||
@@ -1353,6 +1290,7 @@ async fn download_big_fish_remote_image(
|
||||
http_client: &reqwest::Client,
|
||||
image_url: &str,
|
||||
fallback_message: &str,
|
||||
apply_transparent_background_post_process: bool,
|
||||
) -> Result<BigFishDownloadedImage, AppError> {
|
||||
let response = http_client.get(image_url).send().await.map_err(|error| {
|
||||
map_big_fish_dashscope_request_error(format!("{fallback_message}:{error}"))
|
||||
@@ -1378,10 +1316,25 @@ async fn download_big_fish_remote_image(
|
||||
}
|
||||
|
||||
let mime_type = normalize_big_fish_downloaded_image_mime_type(content_type.as_str());
|
||||
let mut normalized_bytes = bytes.to_vec();
|
||||
let mut normalized_mime_type = mime_type;
|
||||
let mut extension = big_fish_mime_to_extension(normalized_mime_type.as_str()).to_string();
|
||||
|
||||
// 中文注释:Big Fish 的等级主图与动作关键帧要和 RPG 角色主图保持同一后处理口径。
|
||||
// 因此在上游已经输出 PNG 时,统一补一层透明背景 alpha 清理,避免只靠 prompt 约束导致残留底色。
|
||||
if apply_transparent_background_post_process
|
||||
&& normalized_mime_type == "image/png"
|
||||
&& let Some(optimized) = try_apply_background_alpha_to_png(normalized_bytes.as_slice())
|
||||
{
|
||||
normalized_bytes = optimized;
|
||||
normalized_mime_type = "image/png".to_string();
|
||||
extension = "png".to_string();
|
||||
}
|
||||
|
||||
Ok(BigFishDownloadedImage {
|
||||
extension: big_fish_mime_to_extension(mime_type.as_str()).to_string(),
|
||||
mime_type,
|
||||
bytes: bytes.to_vec(),
|
||||
extension,
|
||||
mime_type: normalized_mime_type,
|
||||
bytes: normalized_bytes,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1716,15 +1669,37 @@ fn map_big_fish_client_error(error: SpacetimeClientError) -> AppError {
|
||||
StatusCode::BAD_REQUEST
|
||||
}
|
||||
SpacetimeClientError::Runtime(_) => StatusCode::BAD_REQUEST,
|
||||
SpacetimeClientError::Timeout => StatusCode::GATEWAY_TIMEOUT,
|
||||
_ => StatusCode::BAD_GATEWAY,
|
||||
};
|
||||
|
||||
let message = match &error {
|
||||
SpacetimeClientError::Timeout => "SpacetimeDB 会话读取超时,请稍后重试。".to_string(),
|
||||
SpacetimeClientError::ConnectDropped => {
|
||||
"SpacetimeDB 会话连接已断开,请稍后重试。".to_string()
|
||||
}
|
||||
_ => error.to_string(),
|
||||
};
|
||||
|
||||
AppError::from_status(status).with_details(json!({
|
||||
"provider": "spacetimedb",
|
||||
"message": error.to_string(),
|
||||
"message": message,
|
||||
}))
|
||||
}
|
||||
|
||||
fn should_soft_fallback_big_fish_gallery(error: &SpacetimeClientError) -> bool {
|
||||
match error {
|
||||
// 公开广场是首页可选数据,SpacetimeDB procedure 运行态短暂失败时不应阻断平台首屏。
|
||||
SpacetimeClientError::Runtime(_) | SpacetimeClientError::ConnectDropped => true,
|
||||
SpacetimeClientError::Procedure(message) => {
|
||||
message.contains("list_big_fish_works")
|
||||
|| message.contains("procedure")
|
||||
|| message.contains("No such procedure")
|
||||
}
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
fn big_fish_error_response(request_context: &RequestContext, error: AppError) -> Response {
|
||||
error.into_response_with_context(Some(request_context))
|
||||
}
|
||||
@@ -1739,3 +1714,28 @@ fn current_utc_micros() -> i64 {
|
||||
fn current_timestamp_micros_to_string(value: i64) -> String {
|
||||
format_timestamp_micros(value)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn big_fish_gallery_soft_fallbacks_for_runtime_errors() {
|
||||
assert!(should_soft_fallback_big_fish_gallery(
|
||||
&SpacetimeClientError::Runtime("procedure runtime error".to_string())
|
||||
));
|
||||
assert!(should_soft_fallback_big_fish_gallery(
|
||||
&SpacetimeClientError::ConnectDropped
|
||||
));
|
||||
assert!(should_soft_fallback_big_fish_gallery(
|
||||
&SpacetimeClientError::Procedure("No such procedure: list_big_fish_works".to_string(),)
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn big_fish_gallery_keeps_timeout_errors_visible() {
|
||||
assert!(!should_soft_fallback_big_fish_gallery(
|
||||
&SpacetimeClientError::Timeout
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,18 +1,15 @@
|
||||
use module_big_fish::{BigFishAnchorPack, BigFishAnchorStatus, BigFishCreationStage};
|
||||
use platform_llm::LlmClient;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{Value as JsonValue, json};
|
||||
use spacetime_client::{
|
||||
BigFishAgentMessageRecord, BigFishMessageFinalizeRecordInput, BigFishSessionRecord,
|
||||
};
|
||||
use serde_json::Value as JsonValue;
|
||||
use spacetime_client::{BigFishMessageFinalizeRecordInput, BigFishSessionRecord};
|
||||
|
||||
use crate::creation_agent_anchor_templates::{
|
||||
get_creation_agent_anchor_template, render_anchor_question_block,
|
||||
};
|
||||
use crate::creation_agent_chat::render_quick_fill_extra_rules;
|
||||
use crate::creation_agent_llm_turn::{
|
||||
CreationAgentLlmTurnErrorMessages, stream_creation_agent_json_turn,
|
||||
};
|
||||
use crate::prompt::big_fish::{
|
||||
BIG_FISH_AGENT_SYSTEM_PROMPT, build_big_fish_agent_prompt, serialize_record_anchor_pack,
|
||||
};
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub(crate) struct BigFishAgentTurnRequest<'a> {
|
||||
@@ -60,57 +57,6 @@ struct BigFishAgentModelOutput {
|
||||
next_anchor_pack: BigFishAnchorPack,
|
||||
}
|
||||
|
||||
const BIG_FISH_AGENT_SYSTEM_PROMPT: &str = r#"你是一个负责和创作者共创“大鱼吃小鱼”竖屏玩法的中文创意策划。
|
||||
|
||||
你必须把用户灵感收束成可以编译为可玩草稿的玩法、生态视觉、成长阶梯和风险节奏。
|
||||
|
||||
你必须同时输出:
|
||||
1. 一段直接发给用户的中文回复 replyText
|
||||
2. 当前进度 progressPercent
|
||||
3. 下一轮完整可用的 nextAnchorPack
|
||||
|
||||
硬约束:
|
||||
1. 只能输出 JSON,不能输出代码块或解释
|
||||
2. nextAnchorPack 必须是完整对象,不能只输出 patch
|
||||
3. replyText 必须是自然中文,不能提“字段”“锚点”“结构”“JSON”等内部词
|
||||
4. replyText 一次最多推进一个最关键问题
|
||||
5. 必须对齐 RPG 共创的体验:先理解玩家幻想,再收束成能进入运行时的可玩效果
|
||||
6. progressPercent 范围只能是 0 到 100
|
||||
7. status 只能使用 missing / inferred / confirmed / locked
|
||||
"#;
|
||||
|
||||
const BIG_FISH_AGENT_OUTPUT_CONTRACT: &str = r#"请严格按以下 JSON 输出,不要输出其他文字:
|
||||
{
|
||||
"replyText": "",
|
||||
"progressPercent": 0,
|
||||
"nextAnchorPack": {
|
||||
"gameplayPromise": {
|
||||
"key": "gameplayPromise",
|
||||
"label": "玩法承诺",
|
||||
"value": "",
|
||||
"status": "missing"
|
||||
},
|
||||
"ecologyVisualTheme": {
|
||||
"key": "ecologyVisualTheme",
|
||||
"label": "生态视觉主题",
|
||||
"value": "",
|
||||
"status": "missing"
|
||||
},
|
||||
"growthLadder": {
|
||||
"key": "growthLadder",
|
||||
"label": "成长阶梯",
|
||||
"value": "",
|
||||
"status": "missing"
|
||||
},
|
||||
"riskTempo": {
|
||||
"key": "riskTempo",
|
||||
"label": "风险节奏",
|
||||
"value": "",
|
||||
"status": "missing"
|
||||
}
|
||||
}
|
||||
}"#;
|
||||
|
||||
pub(crate) async fn run_big_fish_agent_turn<F>(
|
||||
request: BigFishAgentTurnRequest<'_>,
|
||||
on_reply_update: F,
|
||||
@@ -189,54 +135,6 @@ pub(crate) fn build_failed_finalize_record_input(
|
||||
}
|
||||
}
|
||||
|
||||
fn build_big_fish_agent_prompt(
|
||||
session: &BigFishSessionRecord,
|
||||
quick_fill_requested: bool,
|
||||
) -> String {
|
||||
let anchor_question_block = get_creation_agent_anchor_template("big_fish")
|
||||
.map(render_anchor_question_block)
|
||||
.unwrap_or_else(|| "模板目标:收束成可玩的竖屏大鱼吃小鱼玩法草稿。".to_string());
|
||||
let quick_fill_rules = if quick_fill_requested {
|
||||
format!(
|
||||
"\n\n{}",
|
||||
render_quick_fill_extra_rules(
|
||||
"当前玩法方向里的成长、生态、风险节奏等缺失关键词",
|
||||
"不要要求用户再提供等级、鱼群、场景或节奏信息",
|
||||
"输出完整 nextAnchorPack,直接补齐 value 为空或 status 为 missing 的项",
|
||||
"生成结果页",
|
||||
)
|
||||
)
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
format!(
|
||||
"{anchor_question_block}{quick_fill_rules}\n\n当前是第 {turn} 轮,当前进度 {progress}% 。\n\n是否要求自动补充剩余关键字:{quick_fill_requested_text}\n\n当前 anchor pack:\n{anchor_pack}\n\n最近聊天记录:\n{chat_history}\n\n{contract}",
|
||||
anchor_question_block = anchor_question_block,
|
||||
quick_fill_rules = quick_fill_rules,
|
||||
turn = session.current_turn.saturating_add(1),
|
||||
progress = session.progress_percent,
|
||||
quick_fill_requested_text = if quick_fill_requested { "是" } else { "否" },
|
||||
anchor_pack = serialize_record_anchor_pack(&session.anchor_pack),
|
||||
chat_history =
|
||||
serde_json::to_string_pretty(&build_chat_history(session.messages.as_slice()))
|
||||
.unwrap_or_else(|_| "[]".to_string()),
|
||||
contract = BIG_FISH_AGENT_OUTPUT_CONTRACT,
|
||||
)
|
||||
}
|
||||
|
||||
fn build_chat_history(messages: &[BigFishAgentMessageRecord]) -> Vec<JsonValue> {
|
||||
messages
|
||||
.iter()
|
||||
.map(|message| {
|
||||
json!({
|
||||
"role": message.role,
|
||||
"kind": message.kind,
|
||||
"content": message.text,
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn parse_big_fish_model_output(
|
||||
parsed: &JsonValue,
|
||||
) -> Result<BigFishAgentModelOutput, BigFishAgentTurnError> {
|
||||
@@ -327,33 +225,6 @@ fn default_big_fish_anchor_label(field_name: &str) -> &'static str {
|
||||
}
|
||||
}
|
||||
|
||||
fn serialize_record_anchor_pack(anchor_pack: &spacetime_client::BigFishAnchorPackRecord) -> String {
|
||||
serde_json::to_string_pretty(&map_big_fish_record_anchor_pack(anchor_pack))
|
||||
.unwrap_or_else(|_| "{}".to_string())
|
||||
}
|
||||
|
||||
fn map_big_fish_record_anchor_pack(
|
||||
record: &spacetime_client::BigFishAnchorPackRecord,
|
||||
) -> BigFishAnchorPack {
|
||||
BigFishAnchorPack {
|
||||
gameplay_promise: map_big_fish_record_anchor_item(&record.gameplay_promise),
|
||||
ecology_visual_theme: map_big_fish_record_anchor_item(&record.ecology_visual_theme),
|
||||
growth_ladder: map_big_fish_record_anchor_item(&record.growth_ladder),
|
||||
risk_tempo: map_big_fish_record_anchor_item(&record.risk_tempo),
|
||||
}
|
||||
}
|
||||
|
||||
fn map_big_fish_record_anchor_item(
|
||||
record: &spacetime_client::BigFishAnchorItemRecord,
|
||||
) -> module_big_fish::BigFishAnchorItem {
|
||||
module_big_fish::BigFishAnchorItem {
|
||||
key: record.key.clone(),
|
||||
label: record.label.clone(),
|
||||
value: record.value.clone(),
|
||||
status: parse_big_fish_anchor_status(record.status.as_str()),
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_big_fish_anchor_status(value: &str) -> BigFishAnchorStatus {
|
||||
match value {
|
||||
"confirmed" => BigFishAnchorStatus::Confirmed,
|
||||
|
||||
296
server-rs/crates/api-server/src/big_fish_draft_compiler.rs
Normal file
296
server-rs/crates/api-server/src/big_fish_draft_compiler.rs
Normal file
@@ -0,0 +1,296 @@
|
||||
use module_big_fish::{
|
||||
BIG_FISH_MAX_LEVEL_COUNT, BIG_FISH_MIN_LEVEL_COUNT, BigFishAnchorPack, BigFishGameDraft,
|
||||
BigFishLevelBlueprint, BigFishRuntimeParams, compile_default_draft,
|
||||
};
|
||||
use platform_llm::{LlmClient, LlmMessage, LlmTextRequest};
|
||||
use serde::Deserialize;
|
||||
use serde_json::Value as JsonValue;
|
||||
|
||||
use crate::creation_agent_llm_turn::parse_json_response_text;
|
||||
|
||||
const BIG_FISH_DRAFT_JSON_ONLY_SYSTEM_PROMPT: &str = r#"你是一个负责把“大鱼吃小鱼”玩法锚点编译成首版可实现草稿的中文玩法策划。
|
||||
|
||||
你必须直接输出单个 JSON 对象,不要输出 Markdown、代码块、解释、前言或补充说明。
|
||||
|
||||
硬约束:
|
||||
1. 所有文案必须是中文。
|
||||
2. 必须产出 6 到 12 级的连续等级阶梯,默认优先 8 级。
|
||||
3. 每一级都必须有:name、oneLineFantasy、textDescription、visualDescription、idleMotionDescription、moveMotionDescription。
|
||||
4. 每一级都必须体现等级递进关系:越高等级越大、越强、越有压迫感。
|
||||
5. `visualPromptSeed` 必须能直接作为主图默认提示词。
|
||||
6. `motionPromptSeed` 必须是该等级动作方向总提示词摘要,但不能替代具体 idle / move 描述。
|
||||
7. `preyWindow` 和 `threatWindow` 必须是合法等级数组,围绕当前等级形成可玩窗口。
|
||||
8. `background` 必须是竖屏 9:16 游戏背景口径,不出现主体和 UI。
|
||||
9. `runtimeParams.levelCount` 必须与 levels 长度一致,`winLevel` 必须等于最高等级。
|
||||
"#;
|
||||
|
||||
const BIG_FISH_DRAFT_JSON_REPAIR_SYSTEM_PROMPT: &str = "你是 JSON 修复器。\n你会收到一段本应为单个 JSON 对象的文本。\n你的唯一任务是把它修复成能被 JSON.parse 直接解析的单个 JSON 对象。\n不要输出 Markdown、代码块、解释、注释或额外文字。";
|
||||
|
||||
#[derive(Debug)]
|
||||
pub(crate) struct BigFishDraftCompileError(String);
|
||||
|
||||
impl BigFishDraftCompileError {
|
||||
fn new(message: impl Into<String>) -> Self {
|
||||
Self(message.into())
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Display for BigFishDraftCompileError {
|
||||
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
formatter.write_str(&self.0)
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for BigFishDraftCompileError {}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct BigFishDraftModelOutput {
|
||||
title: String,
|
||||
subtitle: String,
|
||||
core_fun: String,
|
||||
ecology_theme: String,
|
||||
levels: Vec<BigFishLevelBlueprint>,
|
||||
background: module_big_fish::BigFishBackgroundBlueprint,
|
||||
runtime_params: BigFishRuntimeParams,
|
||||
}
|
||||
|
||||
pub(crate) async fn compile_big_fish_draft_with_fallback(
|
||||
llm_client: Option<&LlmClient>,
|
||||
anchor_pack: &BigFishAnchorPack,
|
||||
) -> BigFishGameDraft {
|
||||
let fallback = compile_default_draft(anchor_pack);
|
||||
let Some(llm_client) = llm_client else {
|
||||
return fallback;
|
||||
};
|
||||
|
||||
match request_big_fish_draft(llm_client, anchor_pack).await {
|
||||
Ok(draft) => draft,
|
||||
Err(error) => {
|
||||
tracing::warn!(error = %error, "大鱼吃小鱼草稿结构化编译失败,回退到 deterministic fallback");
|
||||
fallback
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn request_big_fish_draft(
|
||||
llm_client: &LlmClient,
|
||||
anchor_pack: &BigFishAnchorPack,
|
||||
) -> Result<BigFishGameDraft, BigFishDraftCompileError> {
|
||||
let user_prompt = build_big_fish_draft_user_prompt(anchor_pack);
|
||||
let parsed = request_big_fish_json_stage(
|
||||
llm_client,
|
||||
user_prompt,
|
||||
"big-fish-draft-compile",
|
||||
"大鱼吃小鱼草稿编译没有返回有效内容。",
|
||||
)
|
||||
.await?;
|
||||
let output: BigFishDraftModelOutput = serde_json::from_value(parsed).map_err(|error| {
|
||||
BigFishDraftCompileError::new(format!("大鱼吃小鱼草稿 JSON 结构非法:{error}"))
|
||||
})?;
|
||||
validate_big_fish_draft_output(&output)?;
|
||||
|
||||
Ok(BigFishGameDraft {
|
||||
title: output.title,
|
||||
subtitle: output.subtitle,
|
||||
core_fun: output.core_fun,
|
||||
ecology_theme: output.ecology_theme,
|
||||
levels: output.levels,
|
||||
background: output.background,
|
||||
runtime_params: output.runtime_params,
|
||||
})
|
||||
}
|
||||
|
||||
async fn request_big_fish_json_stage(
|
||||
llm_client: &LlmClient,
|
||||
user_prompt: String,
|
||||
debug_label: &str,
|
||||
empty_response_message: &str,
|
||||
) -> Result<JsonValue, BigFishDraftCompileError> {
|
||||
let response = llm_client
|
||||
.request_text(LlmTextRequest::new(vec![
|
||||
LlmMessage::system(BIG_FISH_DRAFT_JSON_ONLY_SYSTEM_PROMPT),
|
||||
LlmMessage::user(user_prompt),
|
||||
]))
|
||||
.await
|
||||
.map_err(|error| {
|
||||
BigFishDraftCompileError::new(format!("{debug_label} LLM 请求失败:{error}"))
|
||||
})?;
|
||||
let text = response.content.trim();
|
||||
if text.is_empty() {
|
||||
return Err(BigFishDraftCompileError::new(empty_response_message));
|
||||
}
|
||||
match parse_json_response_text(text) {
|
||||
Ok(value) => Ok(value),
|
||||
Err(_) => {
|
||||
let repaired = llm_client
|
||||
.request_text(LlmTextRequest::new(vec![
|
||||
LlmMessage::system(BIG_FISH_DRAFT_JSON_REPAIR_SYSTEM_PROMPT),
|
||||
LlmMessage::user(format!(
|
||||
"请把下面这段文本修复成单个合法 JSON 对象,不要补充额外解释:\n\n{text}"
|
||||
)),
|
||||
]))
|
||||
.await
|
||||
.map_err(|error| {
|
||||
BigFishDraftCompileError::new(format!(
|
||||
"{debug_label} JSON 修复请求失败:{error}"
|
||||
))
|
||||
})?;
|
||||
parse_json_response_text(repaired.content.as_str()).map_err(|error| {
|
||||
BigFishDraftCompileError::new(format!("{debug_label} JSON 解析失败:{error}"))
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn build_big_fish_draft_user_prompt(anchor_pack: &BigFishAnchorPack) -> String {
|
||||
format!(
|
||||
r#"请基于下面的大鱼吃小鱼玩法锚点,直接生成首版结果页草稿。
|
||||
|
||||
玩法承诺:{gameplay_promise}
|
||||
生态与视觉母题:{ecology_visual_theme}
|
||||
成长阶梯:{growth_ladder}
|
||||
风险节奏:{risk_tempo}
|
||||
|
||||
请严格输出下列 JSON 结构:
|
||||
{{
|
||||
"title": "",
|
||||
"subtitle": "",
|
||||
"coreFun": "",
|
||||
"ecologyTheme": "",
|
||||
"levels": [
|
||||
{{
|
||||
"level": 1,
|
||||
"name": "",
|
||||
"oneLineFantasy": "",
|
||||
"textDescription": "",
|
||||
"silhouetteDirection": "",
|
||||
"sizeRatio": 1.0,
|
||||
"visualDescription": "",
|
||||
"visualPromptSeed": "",
|
||||
"idleMotionDescription": "",
|
||||
"moveMotionDescription": "",
|
||||
"motionPromptSeed": "",
|
||||
"mergeSourceLevel": null,
|
||||
"preyWindow": [1],
|
||||
"threatWindow": [2],
|
||||
"isFinalLevel": false
|
||||
}}
|
||||
],
|
||||
"background": {{
|
||||
"theme": "",
|
||||
"colorMood": "",
|
||||
"foregroundHints": "",
|
||||
"midgroundComposition": "",
|
||||
"backgroundDepth": "",
|
||||
"safePlayAreaHint": "",
|
||||
"spawnEdgeHint": "",
|
||||
"backgroundPromptSeed": ""
|
||||
}},
|
||||
"runtimeParams": {{
|
||||
"levelCount": 8,
|
||||
"mergeCountPerUpgrade": 3,
|
||||
"spawnTargetCount": 12,
|
||||
"leaderMoveSpeed": 160,
|
||||
"followerCatchUpSpeed": 120,
|
||||
"offscreenCullSeconds": 3,
|
||||
"preySpawnDeltaLevels": [1, 2],
|
||||
"threatSpawnDeltaLevels": [1, 2],
|
||||
"winLevel": 8
|
||||
}}
|
||||
}}
|
||||
|
||||
补充要求:
|
||||
1. `title`、`subtitle`、`coreFun` 必须适合结果页直接展示。
|
||||
2. 每一级 `textDescription` 要解释这一等级在成长链中的定位。
|
||||
3. `visualDescription` 要能直接填入主图工坊。
|
||||
4. `idleMotionDescription` 和 `moveMotionDescription` 要分别对应待机动作与移动动作工坊。
|
||||
5. `visualPromptSeed` 必须是主图生成用的中文提示词,不要只写关键词。
|
||||
6. `motionPromptSeed` 必须是该等级动作生成的总提示词摘要,要同时覆盖待机和移动方向。
|
||||
7. 如果锚点没有明确等级数量,默认输出 8 级。
|
||||
"#,
|
||||
gameplay_promise = anchor_pack.gameplay_promise.value,
|
||||
ecology_visual_theme = anchor_pack.ecology_visual_theme.value,
|
||||
growth_ladder = anchor_pack.growth_ladder.value,
|
||||
risk_tempo = anchor_pack.risk_tempo.value,
|
||||
)
|
||||
}
|
||||
|
||||
fn validate_big_fish_draft_output(
|
||||
output: &BigFishDraftModelOutput,
|
||||
) -> Result<(), BigFishDraftCompileError> {
|
||||
let level_count = output.levels.len() as u32;
|
||||
if !(BIG_FISH_MIN_LEVEL_COUNT..=BIG_FISH_MAX_LEVEL_COUNT).contains(&level_count) {
|
||||
return Err(BigFishDraftCompileError::new(format!(
|
||||
"大鱼吃小鱼草稿等级数非法:{level_count}"
|
||||
)));
|
||||
}
|
||||
if output.runtime_params.level_count != level_count {
|
||||
return Err(BigFishDraftCompileError::new(
|
||||
"runtimeParams.levelCount 必须与 levels 数量一致",
|
||||
));
|
||||
}
|
||||
if output.runtime_params.win_level != level_count {
|
||||
return Err(BigFishDraftCompileError::new(
|
||||
"runtimeParams.winLevel 必须等于最高等级",
|
||||
));
|
||||
}
|
||||
|
||||
for (index, level) in output.levels.iter().enumerate() {
|
||||
let expected_level = (index + 1) as u32;
|
||||
if level.level != expected_level {
|
||||
return Err(BigFishDraftCompileError::new(format!(
|
||||
"等级序号不连续:期望 {expected_level},实际 {}",
|
||||
level.level
|
||||
)));
|
||||
}
|
||||
ensure_non_empty(level.name.as_str(), "level.name")?;
|
||||
ensure_non_empty(level.one_line_fantasy.as_str(), "level.oneLineFantasy")?;
|
||||
ensure_non_empty(level.text_description.as_str(), "level.textDescription")?;
|
||||
ensure_non_empty(level.visual_description.as_str(), "level.visualDescription")?;
|
||||
ensure_non_empty(
|
||||
level.idle_motion_description.as_str(),
|
||||
"level.idleMotionDescription",
|
||||
)?;
|
||||
ensure_non_empty(
|
||||
level.move_motion_description.as_str(),
|
||||
"level.moveMotionDescription",
|
||||
)?;
|
||||
ensure_non_empty(level.visual_prompt_seed.as_str(), "level.visualPromptSeed")?;
|
||||
ensure_non_empty(level.motion_prompt_seed.as_str(), "level.motionPromptSeed")?;
|
||||
}
|
||||
|
||||
ensure_non_empty(output.title.as_str(), "title")?;
|
||||
ensure_non_empty(output.subtitle.as_str(), "subtitle")?;
|
||||
ensure_non_empty(output.core_fun.as_str(), "coreFun")?;
|
||||
ensure_non_empty(output.ecology_theme.as_str(), "ecologyTheme")?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn ensure_non_empty(value: &str, field_name: &str) -> Result<(), BigFishDraftCompileError> {
|
||||
if value.trim().is_empty() {
|
||||
return Err(BigFishDraftCompileError::new(format!(
|
||||
"{field_name} 不能为空"
|
||||
)));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use module_big_fish::infer_anchor_pack;
|
||||
|
||||
use super::build_big_fish_draft_user_prompt;
|
||||
|
||||
#[test]
|
||||
fn big_fish_draft_prompt_requires_all_level_descriptions() {
|
||||
let prompt = build_big_fish_draft_user_prompt(&infer_anchor_pack("深海机械鱼", None));
|
||||
|
||||
assert!(prompt.contains("textDescription"));
|
||||
assert!(prompt.contains("visualDescription"));
|
||||
assert!(prompt.contains("idleMotionDescription"));
|
||||
assert!(prompt.contains("moveMotionDescription"));
|
||||
assert!(prompt.contains("visualPromptSeed"));
|
||||
assert!(prompt.contains("motionPromptSeed"));
|
||||
}
|
||||
}
|
||||
@@ -37,8 +37,9 @@ use shared_contracts::assets::{
|
||||
CharacterAnimationImportVideoResponse, CharacterAnimationPublishRequest,
|
||||
CharacterAnimationPublishResponse, CharacterAnimationStrategy,
|
||||
CharacterAnimationTemplatePayload, CharacterAnimationTemplatesResponse,
|
||||
CharacterAssetJobStatusPayload, CharacterAssetJobStatusText, CharacterVisualDraftPayload,
|
||||
CharacterWorkflowCacheGetResponse, CharacterWorkflowCachePayload,
|
||||
CharacterAssetJobStatusPayload, CharacterAssetJobStatusText,
|
||||
CharacterRoleAssetWorkflowResolveRequest, CharacterRoleAssetWorkflowResponse,
|
||||
CharacterVisualDraftPayload, CharacterWorkflowCacheGetResponse, CharacterWorkflowCachePayload,
|
||||
CharacterWorkflowCacheSaveRequest, CharacterWorkflowCacheSaveResponse,
|
||||
};
|
||||
use spacetime_client::SpacetimeClientError;
|
||||
@@ -49,6 +50,9 @@ use crate::{
|
||||
build_character_animation_prompt, build_fallback_moderation_safe_animation_prompt,
|
||||
},
|
||||
http_error::AppError,
|
||||
prompt::role_asset_studio::{
|
||||
build_role_asset_workflow, normalize_animation_prompt_text_by_key,
|
||||
},
|
||||
request_context::RequestContext,
|
||||
state::AppState,
|
||||
};
|
||||
@@ -646,6 +650,92 @@ pub async fn save_character_workflow_cache(
|
||||
))
|
||||
}
|
||||
|
||||
pub async fn resolve_role_asset_workflow(
|
||||
State(state): State<AppState>,
|
||||
Extension(request_context): Extension<RequestContext>,
|
||||
AxumPath(character_id): AxumPath<String>,
|
||||
payload: Result<Json<CharacterRoleAssetWorkflowResolveRequest>, JsonRejection>,
|
||||
) -> Result<Json<Value>, Response> {
|
||||
let character_id = normalize_required_text(character_id.as_str(), "");
|
||||
if character_id.is_empty() {
|
||||
return Err(character_animation_error_response(
|
||||
&request_context,
|
||||
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
|
||||
"provider": "role-asset-workflow",
|
||||
"message": "characterId is required.",
|
||||
})),
|
||||
));
|
||||
}
|
||||
|
||||
let Json(payload) = payload.map_err(|error| {
|
||||
character_animation_error_response(
|
||||
&request_context,
|
||||
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
|
||||
"provider": "role-asset-workflow",
|
||||
"message": error.body_text(),
|
||||
})),
|
||||
)
|
||||
})?;
|
||||
|
||||
let cache_scope_id = trim_optional_text(payload.cache_scope_id.as_deref());
|
||||
let cache = load_workflow_cache(&state, character_id.as_str(), cache_scope_id.as_deref())
|
||||
.await
|
||||
.map_err(|error| character_animation_error_response(&request_context, error))?;
|
||||
let workflow = build_role_asset_workflow(payload.role, cache.as_ref());
|
||||
|
||||
Ok(json_success_body(
|
||||
Some(&request_context),
|
||||
CharacterRoleAssetWorkflowResponse {
|
||||
ok: true,
|
||||
cache,
|
||||
workflow,
|
||||
},
|
||||
))
|
||||
}
|
||||
|
||||
pub async fn put_role_asset_workflow(
|
||||
State(state): State<AppState>,
|
||||
Extension(request_context): Extension<RequestContext>,
|
||||
AxumPath(character_id): AxumPath<String>,
|
||||
payload: Result<Json<CharacterWorkflowCacheSaveRequest>, JsonRejection>,
|
||||
) -> Result<Json<Value>, Response> {
|
||||
let character_id = normalize_required_text(character_id.as_str(), "");
|
||||
if character_id.is_empty() {
|
||||
return Err(character_animation_error_response(
|
||||
&request_context,
|
||||
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
|
||||
"provider": "role-asset-workflow",
|
||||
"message": "characterId is required.",
|
||||
})),
|
||||
));
|
||||
}
|
||||
|
||||
let Json(mut payload) = payload.map_err(|error| {
|
||||
character_animation_error_response(
|
||||
&request_context,
|
||||
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
|
||||
"provider": "role-asset-workflow",
|
||||
"message": error.body_text(),
|
||||
})),
|
||||
)
|
||||
})?;
|
||||
payload.character_id = character_id;
|
||||
|
||||
let cache = normalize_workflow_cache_payload(payload, current_utc_iso_text());
|
||||
save_workflow_cache(&state, cache.clone())
|
||||
.await
|
||||
.map_err(|error| character_animation_error_response(&request_context, error))?;
|
||||
|
||||
Ok(json_success_body(
|
||||
Some(&request_context),
|
||||
CharacterWorkflowCacheSaveResponse {
|
||||
ok: true,
|
||||
cache,
|
||||
save_message: "角色资产工坊缓存已更新到 OSS。".to_string(),
|
||||
},
|
||||
))
|
||||
}
|
||||
|
||||
fn create_animation_task(
|
||||
state: &AppState,
|
||||
task_id: &str,
|
||||
@@ -1634,6 +1724,9 @@ fn normalize_workflow_cache_payload(
|
||||
cache_scope_id,
|
||||
visual_prompt_text: clamp_prompt_seed_text(payload.visual_prompt_text.as_deref()),
|
||||
animation_prompt_text: clamp_prompt_seed_text(payload.animation_prompt_text.as_deref()),
|
||||
animation_prompt_text_by_key: normalize_animation_prompt_text_by_key(
|
||||
payload.animation_prompt_text_by_key,
|
||||
),
|
||||
visual_drafts: normalize_visual_drafts(character_id.as_str(), payload.visual_drafts),
|
||||
selected_visual_draft_id: trim_optional_text(payload.selected_visual_draft_id.as_deref())
|
||||
.unwrap_or_default(),
|
||||
@@ -3354,6 +3447,10 @@ mod tests {
|
||||
cache_scope_id: None,
|
||||
visual_prompt_text: Some("主形象".to_string()),
|
||||
animation_prompt_text: Some("待机".to_string()),
|
||||
animation_prompt_text_by_key: BTreeMap::from([(
|
||||
"run".to_string(),
|
||||
"奔跑".to_string(),
|
||||
)]),
|
||||
visual_drafts: vec![CharacterVisualDraftPayload {
|
||||
id: "".to_string(),
|
||||
label: "".to_string(),
|
||||
@@ -3373,6 +3470,7 @@ mod tests {
|
||||
|
||||
assert_eq!(cache.character_id, "hero");
|
||||
assert_eq!(cache.selected_animation, "idle");
|
||||
assert_eq!(cache.animation_prompt_text_by_key["run"], "奔跑");
|
||||
assert_eq!(cache.visual_drafts[0].id, "hero-draft-1");
|
||||
assert_eq!(cache.visual_drafts[0].width, 1024);
|
||||
assert_eq!(cache.image_src, None);
|
||||
|
||||
@@ -1154,7 +1154,9 @@ async fn download_generated_image(
|
||||
})
|
||||
}
|
||||
|
||||
fn try_apply_background_alpha_to_png(source: &[u8]) -> Option<Vec<u8>> {
|
||||
/// 统一的 PNG 透明背景后处理入口。
|
||||
/// 目前 RPG 角色主图与其他需要“角色主图口径透明背景”的图片资产都复用这套逻辑。
|
||||
pub(crate) fn try_apply_background_alpha_to_png(source: &[u8]) -> Option<Vec<u8>> {
|
||||
let mut image = image::load_from_memory_with_format(source, ImageFormat::Png)
|
||||
.ok()?
|
||||
.to_rgba8();
|
||||
|
||||
@@ -10,7 +10,8 @@ use axum::{
|
||||
},
|
||||
};
|
||||
use module_custom_world::{
|
||||
CustomWorldThemeMode, empty_agent_anchor_content_json, empty_agent_asset_coverage_json,
|
||||
CustomWorldThemeMode, canonicalize_custom_world_profile_before_save,
|
||||
empty_agent_anchor_content_json, empty_agent_asset_coverage_json,
|
||||
empty_agent_creator_intent_readiness_json, empty_json_array, empty_json_object,
|
||||
};
|
||||
use serde_json::{Map, Value, json};
|
||||
@@ -18,14 +19,15 @@ use shared_contracts::runtime::{
|
||||
CreateCustomWorldAgentSessionRequest, CustomWorldAgentCardDetailResponse,
|
||||
CustomWorldAgentCheckpointResponse, CustomWorldAgentMessageResponse,
|
||||
CustomWorldAgentOperationResponse, CustomWorldAgentSessionResponse,
|
||||
CustomWorldAgentSessionSnapshotResponse, CustomWorldDraftCardDetailResponse,
|
||||
CustomWorldDraftCardDetailSectionResponse, CustomWorldDraftCardSummaryResponse,
|
||||
CustomWorldGalleryCardResponse, CustomWorldGalleryDetailResponse, CustomWorldGalleryResponse,
|
||||
CustomWorldLibraryEntryResponse, CustomWorldLibraryMutationResponse,
|
||||
CustomWorldLibraryResponse, CustomWorldProfileUpsertRequest, CustomWorldPublishGateResponse,
|
||||
CustomWorldAgentSessionSnapshotResponse, CustomWorldCreationResultViewResponse,
|
||||
CustomWorldDraftCardDetailResponse, CustomWorldDraftCardDetailSectionResponse,
|
||||
CustomWorldDraftCardSummaryResponse, CustomWorldGalleryCardResponse,
|
||||
CustomWorldGalleryDetailResponse, CustomWorldGalleryResponse, CustomWorldLibraryEntryResponse,
|
||||
CustomWorldLibraryMutationResponse, CustomWorldLibraryResponse,
|
||||
CustomWorldProfileUpsertRequest, CustomWorldPublishGateResponse,
|
||||
CustomWorldResultPreviewBlockerResponse, CustomWorldSupportedActionResponse,
|
||||
CustomWorldWorkSummaryResponse, CustomWorldWorksResponse, ExecuteCustomWorldAgentActionRequest,
|
||||
SendCustomWorldAgentMessageRequest,
|
||||
GenerateCustomWorldProfileRequest, SendCustomWorldAgentMessageRequest,
|
||||
};
|
||||
use shared_kernel::build_prefixed_uuid_id;
|
||||
use spacetime_client::{
|
||||
@@ -62,7 +64,7 @@ use crate::{
|
||||
custom_world_ai::generate_custom_world_scene_image_for_profile,
|
||||
custom_world_foundation_draft::{
|
||||
DraftFoundationPayloadError, build_draft_foundation_action_payload_json,
|
||||
generate_custom_world_foundation_draft,
|
||||
generate_custom_world_foundation_draft, stable_ascii_slug,
|
||||
},
|
||||
http_error::AppError,
|
||||
prompt::scene_background::{
|
||||
@@ -135,6 +137,251 @@ fn reusable_draft_profile_for_asset_generation(
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn generate_custom_world_profile(
|
||||
State(state): State<AppState>,
|
||||
Extension(request_context): Extension<RequestContext>,
|
||||
Extension(authenticated): Extension<AuthenticatedAccessToken>,
|
||||
payload: Result<Json<GenerateCustomWorldProfileRequest>, JsonRejection>,
|
||||
) -> Result<Json<Value>, Response> {
|
||||
let Json(payload) = payload.map_err(|error| {
|
||||
custom_world_error_response(
|
||||
&request_context,
|
||||
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
|
||||
"provider": "custom-world-profile",
|
||||
"message": error.body_text(),
|
||||
})),
|
||||
)
|
||||
})?;
|
||||
|
||||
let setting_text = payload.setting_text.trim().to_string();
|
||||
if setting_text.is_empty() {
|
||||
return Err(custom_world_error_response(
|
||||
&request_context,
|
||||
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
|
||||
"provider": "custom-world-profile",
|
||||
"message": "settingText is required",
|
||||
})),
|
||||
));
|
||||
}
|
||||
|
||||
let llm_client = state.llm_client().ok_or_else(|| {
|
||||
custom_world_error_response(
|
||||
&request_context,
|
||||
AppError::from_status(StatusCode::SERVICE_UNAVAILABLE).with_details(json!({
|
||||
"provider": "custom-world-profile",
|
||||
"message": "服务端尚未配置可用的 LLM API Key",
|
||||
})),
|
||||
)
|
||||
})?;
|
||||
|
||||
let generation_mode = normalize_profile_generation_mode(payload.generation_mode.as_deref());
|
||||
let creator_intent = payload.creator_intent.unwrap_or(Value::Null);
|
||||
let session = build_profile_generation_session(
|
||||
setting_text.clone(),
|
||||
creator_intent.clone(),
|
||||
authenticated.claims().user_id().to_string(),
|
||||
);
|
||||
|
||||
// 中文注释:profile 生成需要外部 LLM,必须留在 Axum/api-server;SpacetimeDB reducer 只接收确定结果。
|
||||
let result = generate_custom_world_foundation_draft(llm_client, &session, |_| {})
|
||||
.await
|
||||
.map_err(|message| {
|
||||
custom_world_error_response(
|
||||
&request_context,
|
||||
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
|
||||
"provider": "custom-world-profile",
|
||||
"message": message,
|
||||
})),
|
||||
)
|
||||
})?;
|
||||
let mut profile =
|
||||
serde_json::from_str::<Value>(&result.draft_profile_json).map_err(|error| {
|
||||
custom_world_error_response(
|
||||
&request_context,
|
||||
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
|
||||
"provider": "custom-world-profile",
|
||||
"message": format!("profile JSON 解析失败:{error}"),
|
||||
})),
|
||||
)
|
||||
})?;
|
||||
finalize_generated_custom_world_profile(
|
||||
&mut profile,
|
||||
setting_text.as_str(),
|
||||
generation_mode,
|
||||
creator_intent,
|
||||
);
|
||||
|
||||
Ok(json_success_body(Some(&request_context), profile))
|
||||
}
|
||||
|
||||
fn normalize_profile_generation_mode(value: Option<&str>) -> &'static str {
|
||||
if value.is_some_and(|item| item.trim().eq_ignore_ascii_case("fast")) {
|
||||
"fast"
|
||||
} else {
|
||||
"full"
|
||||
}
|
||||
}
|
||||
|
||||
fn build_profile_generation_session(
|
||||
setting_text: String,
|
||||
creator_intent: Value,
|
||||
owner_user_id: String,
|
||||
) -> CustomWorldAgentSessionRecord {
|
||||
CustomWorldAgentSessionRecord {
|
||||
session_id: build_prefixed_uuid_id("profile-generation-session-"),
|
||||
seed_text: setting_text,
|
||||
current_turn: 1,
|
||||
anchor_content: build_profile_generation_anchor_content(&creator_intent),
|
||||
progress_percent: 100,
|
||||
last_assistant_reply: None,
|
||||
stage: "foundation_review".to_string(),
|
||||
focus_card_id: None,
|
||||
creator_intent,
|
||||
creator_intent_readiness: json!({ "isReady": true }),
|
||||
anchor_pack: json!({}),
|
||||
lock_state: json!({}),
|
||||
draft_profile: Value::Null,
|
||||
messages: Vec::new(),
|
||||
draft_cards: Vec::new(),
|
||||
pending_clarifications: Vec::new(),
|
||||
suggested_actions: Vec::new(),
|
||||
recommended_replies: Vec::new(),
|
||||
quality_findings: Vec::new(),
|
||||
asset_coverage: json!({}),
|
||||
checkpoints: Vec::new(),
|
||||
supported_actions: Vec::new(),
|
||||
publish_gate: None,
|
||||
result_preview: None,
|
||||
updated_at: format!("profile-generation:{owner_user_id}"),
|
||||
}
|
||||
}
|
||||
|
||||
fn build_profile_generation_anchor_content(creator_intent: &Value) -> Value {
|
||||
let world_hook = read_value_path_text(creator_intent, &["worldHook"])
|
||||
.or_else(|| read_value_path_text(creator_intent, &["rawSettingText"]));
|
||||
let player_premise = read_value_path_text(creator_intent, &["playerPremise"]);
|
||||
let opening_situation = read_value_path_text(creator_intent, &["openingSituation"]);
|
||||
let core_conflicts = read_value_string_array(creator_intent, "coreConflicts");
|
||||
let iconic_elements = read_value_string_array(creator_intent, "iconicElements");
|
||||
|
||||
json!({
|
||||
"worldPromise": {
|
||||
"hook": world_hook.unwrap_or_default(),
|
||||
"differentiator": iconic_elements.first().cloned().unwrap_or_default(),
|
||||
"desiredExperience": read_value_string_array(creator_intent, "toneDirectives").join("、"),
|
||||
},
|
||||
"playerEntryPoint": {
|
||||
"openingIdentity": player_premise.unwrap_or_default(),
|
||||
"openingProblem": opening_situation.unwrap_or_default(),
|
||||
"entryMotivation": core_conflicts.first().cloned().unwrap_or_default(),
|
||||
},
|
||||
"coreConflict": {
|
||||
"conflicts": core_conflicts,
|
||||
},
|
||||
"iconicElements": iconic_elements,
|
||||
})
|
||||
}
|
||||
|
||||
fn finalize_generated_custom_world_profile(
|
||||
profile: &mut Value,
|
||||
setting_text: &str,
|
||||
generation_mode: &str,
|
||||
creator_intent: Value,
|
||||
) {
|
||||
if !profile.is_object() {
|
||||
*profile = json!({});
|
||||
}
|
||||
let Some(object) = profile.as_object_mut() else {
|
||||
return;
|
||||
};
|
||||
|
||||
insert_profile_text_if_missing(
|
||||
object,
|
||||
"id",
|
||||
format!("custom-world-{}", stable_ascii_slug(setting_text)).as_str(),
|
||||
);
|
||||
insert_profile_text_if_missing(object, "settingText", setting_text);
|
||||
insert_profile_text_if_missing(object, "templateWorldType", "WUXIA");
|
||||
if !object
|
||||
.get("compatibilityTemplateWorldType")
|
||||
.and_then(Value::as_str)
|
||||
.map(str::trim)
|
||||
.is_some_and(|value| !value.is_empty())
|
||||
{
|
||||
let template_world_type = object
|
||||
.get("templateWorldType")
|
||||
.and_then(Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.unwrap_or("WUXIA")
|
||||
.to_string();
|
||||
object.insert(
|
||||
"compatibilityTemplateWorldType".to_string(),
|
||||
Value::String(template_world_type),
|
||||
);
|
||||
}
|
||||
if !object.get("items").is_some_and(Value::is_array) {
|
||||
object.insert("items".to_string(), Value::Array(Vec::new()));
|
||||
}
|
||||
object.insert(
|
||||
"generationMode".to_string(),
|
||||
Value::String(generation_mode.to_string()),
|
||||
);
|
||||
object.insert(
|
||||
"generationStatus".to_string(),
|
||||
Value::String(
|
||||
if generation_mode == "fast" {
|
||||
"key_only"
|
||||
} else {
|
||||
"complete"
|
||||
}
|
||||
.to_string(),
|
||||
),
|
||||
);
|
||||
if !matches!(creator_intent, Value::Null) {
|
||||
object.insert("creatorIntent".to_string(), creator_intent);
|
||||
}
|
||||
}
|
||||
|
||||
fn insert_profile_text_if_missing(object: &mut Map<String, Value>, key: &str, fallback: &str) {
|
||||
if object
|
||||
.get(key)
|
||||
.and_then(Value::as_str)
|
||||
.map(str::trim)
|
||||
.is_some_and(|value| !value.is_empty())
|
||||
{
|
||||
return;
|
||||
}
|
||||
object.insert(key.to_string(), Value::String(fallback.to_string()));
|
||||
}
|
||||
|
||||
fn read_value_path_text(value: &Value, path: &[&str]) -> Option<String> {
|
||||
let mut current = value;
|
||||
for segment in path {
|
||||
current = current.get(*segment)?;
|
||||
}
|
||||
current
|
||||
.as_str()
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(ToOwned::to_owned)
|
||||
}
|
||||
|
||||
fn read_value_string_array(value: &Value, key: &str) -> Vec<String> {
|
||||
value
|
||||
.get(key)
|
||||
.and_then(Value::as_array)
|
||||
.map(|items| {
|
||||
items
|
||||
.iter()
|
||||
.filter_map(|item| item.as_str().map(str::trim))
|
||||
.filter(|item| !item.is_empty())
|
||||
.map(ToOwned::to_owned)
|
||||
.collect::<Vec<_>>()
|
||||
})
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
fn missing_role_asset_text_report(draft_profile: &Value) -> Option<String> {
|
||||
let profile_object = draft_profile.as_object()?;
|
||||
let mut missing_items = Vec::new();
|
||||
@@ -245,15 +492,16 @@ pub async fn put_custom_world_library_profile(
|
||||
));
|
||||
}
|
||||
|
||||
let metadata = extract_custom_world_metadata(&payload.profile).map_err(|error| {
|
||||
custom_world_error_response(
|
||||
&request_context,
|
||||
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
|
||||
"provider": "custom-world-library",
|
||||
"message": error,
|
||||
})),
|
||||
)
|
||||
})?;
|
||||
let (profile, metadata) = canonicalize_custom_world_library_profile_payload(payload.profile)
|
||||
.map_err(|error| {
|
||||
custom_world_error_response(
|
||||
&request_context,
|
||||
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
|
||||
"provider": "custom-world-library",
|
||||
"message": error,
|
||||
})),
|
||||
)
|
||||
})?;
|
||||
let author_display_name = resolve_author_display_name(&state, &authenticated);
|
||||
let author_public_user_code =
|
||||
resolve_author_public_user_code(&state, &authenticated, &request_context)?;
|
||||
@@ -270,7 +518,7 @@ pub async fn put_custom_world_library_profile(
|
||||
summary_text: metadata.summary_text,
|
||||
theme_mode: metadata.theme_mode,
|
||||
cover_image_src: metadata.cover_image_src,
|
||||
profile_payload_json: serde_json::to_string(&payload.profile).map_err(|error| {
|
||||
profile_payload_json: serde_json::to_string(&profile).map_err(|error| {
|
||||
custom_world_error_response(
|
||||
&request_context,
|
||||
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
|
||||
@@ -594,6 +842,27 @@ pub async fn get_custom_world_agent_session(
|
||||
))
|
||||
}
|
||||
|
||||
pub async fn get_custom_world_agent_result_view(
|
||||
State(state): State<AppState>,
|
||||
Path(session_id): Path<String>,
|
||||
Extension(request_context): Extension<RequestContext>,
|
||||
Extension(authenticated): Extension<AuthenticatedAccessToken>,
|
||||
) -> Result<Json<Value>, Response> {
|
||||
ensure_non_empty(&request_context, &session_id, "sessionId")?;
|
||||
|
||||
let session = state
|
||||
.spacetime_client()
|
||||
.get_custom_world_agent_session(session_id, authenticated.claims().user_id().to_string())
|
||||
.await
|
||||
.map_err(|error| {
|
||||
custom_world_error_response(&request_context, map_custom_world_client_error(error))
|
||||
})?;
|
||||
log_custom_world_publish_gate_diagnostics("get_result_view", &session);
|
||||
let result_view = build_custom_world_creation_result_view_response(session);
|
||||
|
||||
Ok(json_success_body(Some(&request_context), result_view))
|
||||
}
|
||||
|
||||
pub async fn get_custom_world_works(
|
||||
State(state): State<AppState>,
|
||||
Extension(request_context): Extension<RequestContext>,
|
||||
@@ -1193,6 +1462,16 @@ pub async fn execute_custom_world_agent_action(
|
||||
})?;
|
||||
generation_result.payload_json
|
||||
}
|
||||
} else if action == "sync_result_profile" {
|
||||
serialize_sync_result_profile_action_payload(&payload).map_err(|error| {
|
||||
custom_world_error_response(
|
||||
&request_context,
|
||||
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
|
||||
"provider": "custom-world-agent",
|
||||
"message": error,
|
||||
})),
|
||||
)
|
||||
})?
|
||||
} else if action == "publish_world" {
|
||||
let mut publish_payload = serde_json::to_value(&payload).map_err(|error| {
|
||||
custom_world_error_response(
|
||||
@@ -1289,6 +1568,27 @@ pub async fn execute_custom_world_agent_action(
|
||||
))
|
||||
}
|
||||
|
||||
fn serialize_sync_result_profile_action_payload(
|
||||
payload: &ExecuteCustomWorldAgentActionRequest,
|
||||
) -> Result<String, String> {
|
||||
let mut payload_value = serde_json::to_value(payload)
|
||||
.map_err(|error| format!("action payload JSON 序列化失败:{error}"))?;
|
||||
if let Some(profile) = payload_value.get_mut("profile") {
|
||||
canonicalize_custom_world_profile_before_save(profile);
|
||||
}
|
||||
|
||||
serde_json::to_string(&payload_value)
|
||||
.map_err(|error| format!("action payload JSON 序列化失败:{error}"))
|
||||
}
|
||||
|
||||
fn canonicalize_custom_world_library_profile_payload(
|
||||
mut profile: Value,
|
||||
) -> Result<(Value, CustomWorldProfileMetadata), String> {
|
||||
canonicalize_custom_world_profile_before_save(&mut profile);
|
||||
let metadata = extract_custom_world_metadata(&profile)?;
|
||||
Ok((profile, metadata))
|
||||
}
|
||||
|
||||
fn spawn_custom_world_draft_foundation_job(
|
||||
state: AppState,
|
||||
session: CustomWorldAgentSessionRecord,
|
||||
@@ -2437,6 +2737,155 @@ fn map_custom_world_agent_session_response(
|
||||
}
|
||||
}
|
||||
|
||||
fn build_custom_world_creation_result_view_response(
|
||||
session: CustomWorldAgentSessionRecord,
|
||||
) -> CustomWorldCreationResultViewResponse {
|
||||
let profile_from_preview = session
|
||||
.result_preview
|
||||
.as_ref()
|
||||
.and_then(|preview| preview.get("preview"))
|
||||
.and_then(normalize_json_object_value);
|
||||
let profile_from_draft =
|
||||
if profile_from_preview.is_none() && is_agent_result_stage(session.stage.as_str()) {
|
||||
normalize_json_object_value(&session.draft_profile)
|
||||
// 中文注释:legacyResultProfile 只在服务端作为历史会话恢复兜底,
|
||||
// 前端不再直接解释 legacy 字段的真相优先级。
|
||||
.or_else(|| {
|
||||
session
|
||||
.draft_profile
|
||||
.get("legacyResultProfile")
|
||||
.and_then(normalize_json_object_value)
|
||||
})
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let (profile, profile_source) = match (profile_from_preview, profile_from_draft) {
|
||||
(Some(profile), _) => (Some(profile), "result_preview"),
|
||||
(None, Some(profile)) => (Some(profile), "draft_profile"),
|
||||
(None, None) => (None, "none"),
|
||||
};
|
||||
let publish_ready = session
|
||||
.publish_gate
|
||||
.as_ref()
|
||||
.map(|gate| gate.publish_ready)
|
||||
.or_else(|| {
|
||||
session
|
||||
.result_preview
|
||||
.as_ref()
|
||||
.and_then(|preview| preview.get("publishReady"))
|
||||
.and_then(Value::as_bool)
|
||||
})
|
||||
.unwrap_or(false);
|
||||
let can_enter_world = session
|
||||
.publish_gate
|
||||
.as_ref()
|
||||
.map(|gate| gate.can_enter_world)
|
||||
.or_else(|| {
|
||||
session
|
||||
.result_preview
|
||||
.as_ref()
|
||||
.and_then(|preview| preview.get("canEnterWorld"))
|
||||
.and_then(Value::as_bool)
|
||||
})
|
||||
.unwrap_or(false);
|
||||
let blocker_count = session
|
||||
.publish_gate
|
||||
.as_ref()
|
||||
.map(|gate| gate.blocker_count)
|
||||
.or_else(|| {
|
||||
session
|
||||
.result_preview
|
||||
.as_ref()
|
||||
.and_then(|preview| preview.get("blockers"))
|
||||
.and_then(Value::as_array)
|
||||
.map(|items| items.len() as u32)
|
||||
})
|
||||
.unwrap_or(0);
|
||||
let has_profile = profile.is_some();
|
||||
let generation_failed = session.stage == "error"
|
||||
|| session
|
||||
.messages
|
||||
.iter()
|
||||
.any(|message| message.kind == "warning" && message.text.contains("失败"));
|
||||
let result_stage = is_agent_result_stage(session.stage.as_str());
|
||||
let (
|
||||
target_stage,
|
||||
generation_view_source,
|
||||
result_view_source,
|
||||
recovery_action,
|
||||
recovery_reason,
|
||||
) = if has_profile && result_stage {
|
||||
(
|
||||
"custom-world-result",
|
||||
None,
|
||||
Some("agent-draft"),
|
||||
"open_result",
|
||||
None,
|
||||
)
|
||||
} else if generation_failed {
|
||||
(
|
||||
"custom-world-generating",
|
||||
Some("agent-draft-foundation"),
|
||||
None,
|
||||
"resume_generation",
|
||||
Some("当前草稿生成失败或缺少结果预览,需要回到生成过程页继续处理。"),
|
||||
)
|
||||
} else {
|
||||
(
|
||||
"agent-workspace",
|
||||
None,
|
||||
None,
|
||||
"continue_agent",
|
||||
Some("当前会话还没有可打开的结果页真相源。"),
|
||||
)
|
||||
};
|
||||
let can_sync_result_profile = is_agent_result_profile_sync_stage(session.stage.as_str());
|
||||
|
||||
CustomWorldCreationResultViewResponse {
|
||||
session: map_custom_world_agent_session_response(session),
|
||||
profile,
|
||||
profile_source: profile_source.to_string(),
|
||||
target_stage: target_stage.to_string(),
|
||||
generation_view_source: generation_view_source.map(ToOwned::to_owned),
|
||||
result_view_source: result_view_source.map(ToOwned::to_owned),
|
||||
can_autosave_library: has_profile && result_stage,
|
||||
can_sync_result_profile,
|
||||
publish_ready,
|
||||
can_enter_world,
|
||||
blocker_count,
|
||||
recovery_action: recovery_action.to_string(),
|
||||
recovery_reason: recovery_reason.map(ToOwned::to_owned),
|
||||
}
|
||||
}
|
||||
|
||||
fn is_agent_result_stage(stage: &str) -> bool {
|
||||
matches!(
|
||||
stage,
|
||||
"object_refining"
|
||||
| "visual_refining"
|
||||
| "long_tail_review"
|
||||
| "ready_to_publish"
|
||||
| "published"
|
||||
)
|
||||
}
|
||||
|
||||
fn is_agent_result_profile_sync_stage(stage: &str) -> bool {
|
||||
matches!(
|
||||
stage,
|
||||
"object_refining" | "visual_refining" | "long_tail_review" | "ready_to_publish"
|
||||
)
|
||||
}
|
||||
|
||||
fn normalize_json_object_value(value: &Value) -> Option<Value> {
|
||||
value.as_object().and_then(|object| {
|
||||
if object.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(Value::Object(object.clone()))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn log_custom_world_publish_gate_diagnostics(
|
||||
source: &str,
|
||||
session: &CustomWorldAgentSessionRecord,
|
||||
@@ -2899,6 +3348,10 @@ fn current_utc_micros() -> i64 {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use axum::{Router, body::Body, http::Request};
|
||||
use tower::ServiceExt;
|
||||
|
||||
use crate::{app::build_router, config::AppConfig};
|
||||
|
||||
#[test]
|
||||
fn incomplete_role_asset_text_draft_profile_is_not_reused() {
|
||||
@@ -2965,6 +3418,173 @@ mod tests {
|
||||
assert!(reusable_draft_profile_for_asset_generation(&session).is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn generated_profile_finalize_adds_required_frontend_fields() {
|
||||
let mut profile = json!({
|
||||
"name": "雾港归航",
|
||||
"summary": "守灯人与群岛议会围绕沉船旧案对峙。",
|
||||
"playableNpcs": [],
|
||||
"storyNpcs": [],
|
||||
"landmarks": []
|
||||
});
|
||||
|
||||
finalize_generated_custom_world_profile(
|
||||
&mut profile,
|
||||
"在失真的海图上追查一场被篡改的沉船事故。",
|
||||
"fast",
|
||||
json!({ "worldHook": "海图会撒谎" }),
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
profile.get("settingText").and_then(Value::as_str),
|
||||
Some("在失真的海图上追查一场被篡改的沉船事故。")
|
||||
);
|
||||
assert_eq!(
|
||||
profile.get("generationMode").and_then(Value::as_str),
|
||||
Some("fast")
|
||||
);
|
||||
assert_eq!(
|
||||
profile.get("generationStatus").and_then(Value::as_str),
|
||||
Some("key_only")
|
||||
);
|
||||
assert_eq!(
|
||||
profile.get("templateWorldType").and_then(Value::as_str),
|
||||
Some("WUXIA")
|
||||
);
|
||||
assert!(profile.get("items").and_then(Value::as_array).is_some());
|
||||
assert!(
|
||||
profile
|
||||
.get("id")
|
||||
.and_then(Value::as_str)
|
||||
.is_some_and(|value| value.starts_with("custom-world-"))
|
||||
);
|
||||
assert!(profile.get("creatorIntent").is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sync_result_profile_payload_is_canonicalized_on_server() {
|
||||
let payload = ExecuteCustomWorldAgentActionRequest {
|
||||
action: "sync_result_profile".to_string(),
|
||||
profile: Some(json!({
|
||||
"id": "cwprof_001",
|
||||
"settingText": "前端保存前不再改写这段文字",
|
||||
"creatorIntent": {
|
||||
"worldHook": "海图会在午夜改写群岛航路",
|
||||
"playerPremise": "玩家是失忆领航员",
|
||||
"openingSituation": "正在禁航区醒来",
|
||||
"themeKeywords": ["海雾"],
|
||||
"toneDirectives": ["悬疑"],
|
||||
"coreConflicts": ["议会隐瞒沉船真相"],
|
||||
"keyCharacters": [{
|
||||
"name": "顾潮音",
|
||||
"role": "守灯人",
|
||||
"relationToPlayer": "旧识",
|
||||
"hiddenHook": "掌握伪造海图"
|
||||
}],
|
||||
"iconicElements": ["会说谎的罗盘"]
|
||||
}
|
||||
})),
|
||||
profile_id: None,
|
||||
draft_profile: None,
|
||||
legacy_result_profile: None,
|
||||
setting_text: None,
|
||||
card_id: None,
|
||||
sections: None,
|
||||
count: None,
|
||||
role_type: None,
|
||||
prompt_text: None,
|
||||
anchor_card_ids: None,
|
||||
role_ids: None,
|
||||
role_id: None,
|
||||
scene_ids: None,
|
||||
portrait_path: None,
|
||||
generated_visual_asset_id: None,
|
||||
generated_animation_set_id: None,
|
||||
animation_map: None,
|
||||
scene_id: None,
|
||||
scene_kind: None,
|
||||
image_src: None,
|
||||
generated_scene_asset_id: None,
|
||||
generated_scene_prompt: None,
|
||||
generated_scene_model: None,
|
||||
checkpoint_id: None,
|
||||
};
|
||||
|
||||
let payload_json =
|
||||
serialize_sync_result_profile_action_payload(&payload).expect("payload serializes");
|
||||
let payload_value: Value =
|
||||
serde_json::from_str(&payload_json).expect("payload should be valid JSON");
|
||||
|
||||
assert_eq!(
|
||||
payload_value
|
||||
.get("profile")
|
||||
.and_then(|profile| profile.get("settingText"))
|
||||
.and_then(Value::as_str),
|
||||
Some(
|
||||
"世界一句话:海图会在午夜改写群岛航路\n玩家开局:玩家是失忆领航员;正在禁航区醒来\n主题气质:海雾 / 悬疑\n核心冲突:议会隐瞒沉船真相\n关键关系:顾潮音 · 守灯人 · 与玩家 旧识 · 暗线 掌握伪造海图\n标志元素:会说谎的罗盘"
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn custom_world_library_profile_payload_is_canonicalized_on_server() {
|
||||
let (profile, metadata) = canonicalize_custom_world_library_profile_payload(json!({
|
||||
"id": "cwprof_001",
|
||||
"name": "潮雾列岛",
|
||||
"summary": "群岛与旧灯塔之间的沉船疑案。",
|
||||
"settingText": "前端保存前不再改写这段文字",
|
||||
"playableNpcs": [{"id": "pc-1"}],
|
||||
"storyNpcs": [{"id": "story-1"}],
|
||||
"landmarks": [{"id": "scene-1"}],
|
||||
"creatorIntent": {
|
||||
"worldHook": "海图会在午夜改写群岛航路",
|
||||
"playerPremise": "玩家是失忆领航员",
|
||||
"openingSituation": "正在禁航区醒来",
|
||||
"themeKeywords": ["海雾"],
|
||||
"toneDirectives": ["悬疑"],
|
||||
"coreConflicts": ["议会隐瞒沉船真相"],
|
||||
"keyCharacters": [{
|
||||
"name": "顾潮音",
|
||||
"role": "守灯人",
|
||||
"relationToPlayer": "旧识",
|
||||
"hiddenHook": "掌握伪造海图"
|
||||
}],
|
||||
"iconicElements": ["会说谎的罗盘"]
|
||||
}
|
||||
}))
|
||||
.expect("profile should be canonicalized");
|
||||
|
||||
assert_eq!(metadata.world_name, "潮雾列岛");
|
||||
assert_eq!(metadata.playable_npc_count, 2);
|
||||
assert_eq!(metadata.landmark_count, 1);
|
||||
assert_eq!(
|
||||
profile.get("settingText").and_then(Value::as_str),
|
||||
Some(
|
||||
"世界一句话:海图会在午夜改写群岛航路\n玩家开局:玩家是失忆领航员;正在禁航区醒来\n主题气质:海雾 / 悬疑\n核心冲突:议会隐瞒沉船真相\n关键关系:顾潮音 · 守灯人 · 与玩家 旧识 · 暗线 掌握伪造海图\n标志元素:会说谎的罗盘"
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn custom_world_profile_generation_requires_authentication() {
|
||||
let app: Router =
|
||||
build_router(AppState::new(AppConfig::default()).expect("state should build"));
|
||||
|
||||
let response = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri("/api/runtime/custom-world/profile")
|
||||
.header("content-type", "application/json")
|
||||
.body(Body::from(r#"{"settingText":"海雾会吞掉记错航线的人。"}"#))
|
||||
.expect("request should build"),
|
||||
)
|
||||
.await
|
||||
.expect("request should succeed");
|
||||
|
||||
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn collect_scene_act_refs_accepts_scene_prompt_text_alias() {
|
||||
let draft_profile = json!({
|
||||
|
||||
@@ -870,24 +870,6 @@ fn normalize_world_attribute_schema(
|
||||
normalized_slots.push(json!({
|
||||
"slotId": slot_id,
|
||||
"name": name,
|
||||
"definition": json_map_text(raw_slot, "definition")
|
||||
.or_else(|| json_map_text(&fallback_slot, "definition"))
|
||||
.unwrap_or_else(|| "这个维度用于描述角色在当前世界中的关键表现。".to_string()),
|
||||
"positiveSignals": json_map_string_array(raw_slot, "positiveSignals")
|
||||
.or_else(|| json_map_string_array(&fallback_slot, "positiveSignals"))
|
||||
.unwrap_or_else(|| vec!["稳定".to_string(), "主动".to_string()]),
|
||||
"negativeSignals": json_map_string_array(raw_slot, "negativeSignals")
|
||||
.or_else(|| json_map_string_array(&fallback_slot, "negativeSignals"))
|
||||
.unwrap_or_else(|| vec!["失衡".to_string(), "被动".to_string()]),
|
||||
"combatUseText": json_map_text(raw_slot, "combatUseText")
|
||||
.or_else(|| json_map_text(&fallback_slot, "combatUseText"))
|
||||
.unwrap_or_else(|| "影响战斗中的推进、承压与应对。".to_string()),
|
||||
"socialUseText": json_map_text(raw_slot, "socialUseText")
|
||||
.or_else(|| json_map_text(&fallback_slot, "socialUseText"))
|
||||
.unwrap_or_else(|| "影响对话中的判断、牵引与立场。".to_string()),
|
||||
"explorationUseText": json_map_text(raw_slot, "explorationUseText")
|
||||
.or_else(|| json_map_text(&fallback_slot, "explorationUseText"))
|
||||
.unwrap_or_else(|| "影响探索中的观察、穿行与续航。".to_string()),
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -901,9 +883,6 @@ fn normalize_world_attribute_schema(
|
||||
.and_then(JsonValue::as_i64)
|
||||
.filter(|value| *value > 0)
|
||||
.unwrap_or(1),
|
||||
"schemaName": json_map_text(schema, "schemaName")
|
||||
.filter(|value| !is_invalid_attribute_schema_name(value))
|
||||
.unwrap_or_else(|| build_attribute_schema_name(framework, setting_text)),
|
||||
"generatedFrom": {
|
||||
"worldType": "CUSTOM",
|
||||
"worldName": framework_world_name(framework, setting_text),
|
||||
@@ -945,7 +924,6 @@ fn build_fallback_world_attribute_schema(framework: &JsonValue, setting_text: &s
|
||||
"id": build_attribute_schema_id(framework, setting_text),
|
||||
"worldId": format!("custom:{world_name}"),
|
||||
"schemaVersion": 1,
|
||||
"schemaName": build_attribute_schema_name(framework, setting_text),
|
||||
"generatedFrom": {
|
||||
"worldType": "CUSTOM",
|
||||
"worldName": world_name,
|
||||
@@ -954,35 +932,20 @@ fn build_fallback_world_attribute_schema(framework: &JsonValue, setting_text: &s
|
||||
"conflictCore": conflict_core,
|
||||
},
|
||||
"slots": [
|
||||
build_attribute_slot("axis_a", format!("{prefix}骨"), format!("承受{prefix}压、正面冲击与长期消耗的底子。"), ["承压", "稳阵"], ["虚浮", "易散"], "顶住正面压力并守住行动空间。", "在强压场面里保持可信和稳固。", "穿过危险环境时维持身体与装备状态。"),
|
||||
build_attribute_slot("axis_b", format!("{prefix_alt}步"), format!("顺应{prefix_alt}势、换位穿行与抢占时机的能力。"), ["借势", "轻快"], ["迟滞", "失位"], "切线换位、闪避、追击和抢先手。", "反应灵活,能顺势调整话术。", "穿越复杂地形、封锁线与危险通路。"),
|
||||
build_attribute_slot("axis_c", format!("{prefix}识"), "看清局势、线索、虚实与隐藏代价的能力。", ["洞察", "辨伪"], ["误读", "迟钝"], "识破破绽并判断战局变化。", "听出隐瞒、试探与交换空间。", "整理线索、辨认路径并推断风险。"),
|
||||
build_attribute_slot("axis_d", format!("{prefix_alt}魄"), "在高压变化里仍能推进目标和拍板的胆气。", ["果断", "压前"], ["犹疑", "退缩"], "顶着高压窗口推进突破口。", "在谈判或对峙中定调。", "面对未知异象仍敢继续前探。"),
|
||||
build_attribute_slot("axis_e", format!("{prefix}契"), "与人、物、誓约、地方关系建立牵引的能力。", ["协同", "守诺"], ["疏离", "失信"], "借同伴协同与牵制形成连锁。", "安抚、结盟、交换与维系信任。", "从人情、传闻和旧物中打开线索。"),
|
||||
build_attribute_slot("axis_f", format!("回{prefix_alt}"), "在长线消耗和局势反复中回稳节奏的能力。", ["回稳", "续航"], ["紊乱", "断续"], "久战不乱,把节奏重新拉回手里。", "情绪稳定,不轻易被带偏。", "在漫长探索与恶劣环境里保有余力。"),
|
||||
build_attribute_slot("axis_a", format!("{prefix}骨")),
|
||||
build_attribute_slot("axis_b", format!("{prefix_alt}步")),
|
||||
build_attribute_slot("axis_c", format!("{prefix}识")),
|
||||
build_attribute_slot("axis_d", format!("{prefix_alt}魄")),
|
||||
build_attribute_slot("axis_e", format!("{prefix}契")),
|
||||
build_attribute_slot("axis_f", format!("回{prefix_alt}")),
|
||||
],
|
||||
})
|
||||
}
|
||||
|
||||
fn build_attribute_slot(
|
||||
slot_id: &str,
|
||||
name: String,
|
||||
definition: impl Into<String>,
|
||||
positive_signals: [&str; 2],
|
||||
negative_signals: [&str; 2],
|
||||
combat_use_text: &str,
|
||||
social_use_text: &str,
|
||||
exploration_use_text: &str,
|
||||
) -> JsonValue {
|
||||
fn build_attribute_slot(slot_id: &str, name: String) -> JsonValue {
|
||||
json!({
|
||||
"slotId": slot_id,
|
||||
"name": name,
|
||||
"definition": definition.into(),
|
||||
"positiveSignals": positive_signals,
|
||||
"negativeSignals": negative_signals,
|
||||
"combatUseText": combat_use_text,
|
||||
"socialUseText": social_use_text,
|
||||
"explorationUseText": exploration_use_text,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1008,20 +971,6 @@ fn build_attribute_schema_id(framework: &JsonValue, setting_text: &str) -> Strin
|
||||
)
|
||||
}
|
||||
|
||||
fn build_attribute_schema_name(framework: &JsonValue, setting_text: &str) -> String {
|
||||
let source = [
|
||||
framework_world_name(framework, setting_text),
|
||||
json_text(framework, "summary").unwrap_or_default(),
|
||||
json_text(framework, "tone").unwrap_or_default(),
|
||||
]
|
||||
.join("。");
|
||||
let terms = collect_attribute_theme_terms(source.as_str());
|
||||
format!(
|
||||
"{}六维",
|
||||
terms.first().cloned().unwrap_or_else(|| "叙境".to_string())
|
||||
)
|
||||
}
|
||||
|
||||
fn collect_attribute_theme_terms(source: &str) -> Vec<String> {
|
||||
let mut terms = Vec::new();
|
||||
let chinese_chars = source
|
||||
@@ -1062,12 +1011,6 @@ fn is_invalid_attribute_name(name: &str, seen_names: &[String]) -> bool {
|
||||
.any(|banned| trimmed.contains(banned))
|
||||
}
|
||||
|
||||
fn is_invalid_attribute_schema_name(name: &str) -> bool {
|
||||
BANNED_ATTRIBUTE_NAMES
|
||||
.iter()
|
||||
.any(|banned| name.trim().contains(banned))
|
||||
}
|
||||
|
||||
fn json_map_text(map: &JsonMap<String, JsonValue>, key: &str) -> Option<String> {
|
||||
map.get(key)
|
||||
.and_then(JsonValue::as_str)
|
||||
@@ -1076,18 +1019,6 @@ fn json_map_text(map: &JsonMap<String, JsonValue>, key: &str) -> Option<String>
|
||||
.map(ToOwned::to_owned)
|
||||
}
|
||||
|
||||
fn json_map_string_array(map: &JsonMap<String, JsonValue>, key: &str) -> Option<Vec<String>> {
|
||||
let items = map
|
||||
.get(key)?
|
||||
.as_array()?
|
||||
.iter()
|
||||
.filter_map(|entry| entry.as_str().map(str::trim))
|
||||
.filter(|entry| !entry.is_empty())
|
||||
.map(ToOwned::to_owned)
|
||||
.collect::<Vec<_>>();
|
||||
if items.is_empty() { None } else { Some(items) }
|
||||
}
|
||||
|
||||
fn first_json_string(value: &JsonValue, key: &str) -> Option<String> {
|
||||
value
|
||||
.get(key)
|
||||
@@ -1099,7 +1030,7 @@ fn first_json_string(value: &JsonValue, key: &str) -> Option<String> {
|
||||
.map(ToOwned::to_owned)
|
||||
}
|
||||
|
||||
fn stable_ascii_slug(value: &str) -> String {
|
||||
pub(crate) fn stable_ascii_slug(value: &str) -> String {
|
||||
let mut hash = 0u32;
|
||||
for character in value.chars() {
|
||||
hash = hash.wrapping_mul(31).wrapping_add(character as u32);
|
||||
@@ -2492,7 +2423,7 @@ mod tests {
|
||||
request_capture.clone(),
|
||||
vec![
|
||||
llm_response(
|
||||
r#"{"name":"雾港归航","subtitle":"失灯旧案","summary":"守灯人与群岛议会围绕沉船旧案对峙。","tone":"海雾悬疑","playerGoal":"查清父亲沉船真相","templateWorldType":"WUXIA","majorFactions":["群岛议会","灯塔署"],"coreConflicts":["守灯塔的旧档案被人改写。"],"attributeSchema":{"schemaName":"雾港六维","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":"远行中保有余力。"}]},"camp":{"name":"旧灯塔归舍","description":"海雾边缘的守灯人旧居。"}}"#,
|
||||
r#"{"name":"雾港归航","subtitle":"失灯旧案","summary":"守灯人与群岛议会围绕沉船旧案对峙。","tone":"海雾悬疑","playerGoal":"查清父亲沉船真相","templateWorldType":"WUXIA","majorFactions":["群岛议会","灯塔署"],"coreConflicts":["守灯塔的旧档案被人改写。"],"attributeSchema":{"slots":[{"name":"灯骨"},{"name":"潮步"},{"name":"灯识"},{"name":"雾魄"},{"name":"旧约"},{"name":"回澜"}]},"camp":{"name":"旧灯塔归舍","description":"海雾边缘的守灯人旧居。"}}"#,
|
||||
),
|
||||
llm_response(
|
||||
r#"{"playableNpcs":[{"name":"岑灯","title":"返乡守灯人","role":"主角代理","description":"追查旧案的人","visualDescription":"灰蓝旧灯披风压着海盐痕,腰侧挂旧海图筒和短灯杖。","actionDescription":"举灯照海图,短杖点地辨认潮声。","sceneVisualDescription":"旧灯塔回廊被海雾压低,墙上挂满潮湿航线图。","initialAffinity":24,"relationshipHooks":["旧案牵连"],"tags":["守灯人"]}]}"#,
|
||||
@@ -2595,6 +2526,16 @@ mod tests {
|
||||
.and_then(JsonValue::as_str),
|
||||
Some("灯骨")
|
||||
);
|
||||
assert_eq!(
|
||||
draft_profile
|
||||
.get("attributeSchema")
|
||||
.and_then(|schema| schema.get("slots"))
|
||||
.and_then(JsonValue::as_array)
|
||||
.and_then(|entries| entries.first())
|
||||
.and_then(JsonValue::as_object)
|
||||
.map(|entry| entry.contains_key("definition")),
|
||||
Some(false)
|
||||
);
|
||||
assert!(
|
||||
draft_profile
|
||||
.get("worldHook")
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
#![recursion_limit = "256"]
|
||||
|
||||
mod admin;
|
||||
mod ai_generation_drafts;
|
||||
mod ai_tasks;
|
||||
@@ -13,6 +15,7 @@ mod auth_session;
|
||||
mod auth_sessions;
|
||||
mod big_fish;
|
||||
mod big_fish_agent_turn;
|
||||
mod big_fish_draft_compiler;
|
||||
mod character_animation_assets;
|
||||
mod character_visual_assets;
|
||||
mod config;
|
||||
@@ -47,6 +50,7 @@ mod request_context;
|
||||
mod response_headers;
|
||||
mod runtime_browse_history;
|
||||
mod runtime_chat;
|
||||
mod runtime_chat_plain;
|
||||
mod runtime_inventory;
|
||||
mod runtime_profile;
|
||||
mod runtime_save;
|
||||
|
||||
389
server-rs/crates/api-server/src/prompt/big_fish.rs
Normal file
389
server-rs/crates/api-server/src/prompt/big_fish.rs
Normal file
@@ -0,0 +1,389 @@
|
||||
use module_big_fish::BigFishAnchorPack;
|
||||
use serde_json::Value as JsonValue;
|
||||
use spacetime_client::{
|
||||
BigFishAgentMessageRecord, BigFishAnchorPackRecord, BigFishGameDraftRecord,
|
||||
BigFishLevelBlueprintRecord, BigFishSessionRecord,
|
||||
};
|
||||
|
||||
use crate::creation_agent_anchor_templates::{
|
||||
get_creation_agent_anchor_template, render_anchor_question_block,
|
||||
};
|
||||
use crate::creation_agent_chat::render_quick_fill_extra_rules;
|
||||
|
||||
pub(crate) const BIG_FISH_AGENT_SYSTEM_PROMPT: &str = r#"你是一个负责和创作者共创“大鱼吃小鱼”竖屏玩法的中文创意策划。
|
||||
|
||||
你必须把用户灵感收束成可以编译为可玩草稿的玩法、生态视觉、成长阶梯和风险节奏。
|
||||
|
||||
你必须同时输出:
|
||||
1. 一段直接发给用户的中文回复 replyText
|
||||
2. 当前进度 progressPercent
|
||||
3. 下一轮完整可用的 nextAnchorPack
|
||||
|
||||
硬约束:
|
||||
1. 只能输出 JSON,不能输出代码块或解释
|
||||
2. nextAnchorPack 必须是完整对象,不能只输出 patch
|
||||
3. replyText 必须是自然中文,不能提“字段”“锚点”“结构”“JSON”等内部词
|
||||
4. replyText 一次最多推进一个最关键问题
|
||||
5. 必须对齐 RPG 共创的体验:先理解玩家幻想,再收束成能进入运行时的可玩效果
|
||||
6. progressPercent 范围只能是 0 到 100
|
||||
7. status 只能使用 missing / inferred / confirmed / locked
|
||||
"#;
|
||||
|
||||
const BIG_FISH_AGENT_OUTPUT_CONTRACT: &str = r#"请严格按以下 JSON 输出,不要输出其他文字:
|
||||
{
|
||||
"replyText": "",
|
||||
"progressPercent": 0,
|
||||
"nextAnchorPack": {
|
||||
"gameplayPromise": {
|
||||
"key": "gameplayPromise",
|
||||
"label": "玩法承诺",
|
||||
"value": "",
|
||||
"status": "missing"
|
||||
},
|
||||
"ecologyVisualTheme": {
|
||||
"key": "ecologyVisualTheme",
|
||||
"label": "生态视觉主题",
|
||||
"value": "",
|
||||
"status": "missing"
|
||||
},
|
||||
"growthLadder": {
|
||||
"key": "growthLadder",
|
||||
"label": "成长阶梯",
|
||||
"value": "",
|
||||
"status": "missing"
|
||||
},
|
||||
"riskTempo": {
|
||||
"key": "riskTempo",
|
||||
"label": "风险节奏",
|
||||
"value": "",
|
||||
"status": "missing"
|
||||
}
|
||||
}
|
||||
}"#;
|
||||
|
||||
/// 大鱼吃小鱼草稿生成对话提示词脚本。
|
||||
///
|
||||
/// 这里单独承载 Agent 共创阶段的 system prompt 与 user prompt 组装,
|
||||
/// 避免聊天契约、草稿编译路由和结果页资产生成混在同一个业务文件里。
|
||||
pub(crate) fn build_big_fish_agent_prompt(
|
||||
session: &BigFishSessionRecord,
|
||||
quick_fill_requested: bool,
|
||||
) -> String {
|
||||
let anchor_question_block = get_creation_agent_anchor_template("big_fish")
|
||||
.map(render_anchor_question_block)
|
||||
.unwrap_or_else(|| "模板目标:收束成可玩的竖屏大鱼吃小鱼玩法草稿。".to_string());
|
||||
let quick_fill_rules = if quick_fill_requested {
|
||||
format!(
|
||||
"\n\n{}",
|
||||
render_quick_fill_extra_rules(
|
||||
"当前玩法方向里的成长、生态、风险节奏等缺失关键词",
|
||||
"不要要求用户再提供等级、鱼群、场景或节奏信息",
|
||||
"输出完整 nextAnchorPack,直接补齐 value 为空或 status 为 missing 的项",
|
||||
"生成结果页",
|
||||
)
|
||||
)
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
format!(
|
||||
"{anchor_question_block}{quick_fill_rules}\n\n当前是第 {turn} 轮,当前进度 {progress}% 。\n\n是否要求自动补充剩余关键字:{quick_fill_requested_text}\n\n当前 anchor pack:\n{anchor_pack}\n\n最近聊天记录:\n{chat_history}\n\n{contract}",
|
||||
anchor_question_block = anchor_question_block,
|
||||
quick_fill_rules = quick_fill_rules,
|
||||
turn = session.current_turn.saturating_add(1),
|
||||
progress = session.progress_percent,
|
||||
quick_fill_requested_text = if quick_fill_requested { "是" } else { "否" },
|
||||
anchor_pack = serialize_record_anchor_pack(&session.anchor_pack),
|
||||
chat_history =
|
||||
serde_json::to_string_pretty(&build_chat_history(session.messages.as_slice()))
|
||||
.unwrap_or_else(|_| "[]".to_string()),
|
||||
contract = BIG_FISH_AGENT_OUTPUT_CONTRACT,
|
||||
)
|
||||
}
|
||||
|
||||
/// 大鱼吃小鱼主图生成提示词脚本。
|
||||
pub(crate) fn build_big_fish_level_main_image_prompt(
|
||||
_draft: &BigFishGameDraftRecord,
|
||||
level: &BigFishLevelBlueprintRecord,
|
||||
) -> String {
|
||||
vec![
|
||||
"生成角色形象图片。".to_string(),
|
||||
format!(
|
||||
"等级:Lv.{},名称:{},幻想描述:{}。",
|
||||
level.level, level.name, level.one_line_fantasy
|
||||
),
|
||||
format!("文字描述:{}。", level.text_description),
|
||||
format!("轮廓方向:{}。", level.silhouette_direction),
|
||||
format!("形象描述:{}。", level.visual_description),
|
||||
format!("主图提示词:{}。", level.visual_prompt_seed),
|
||||
"等级对形象的影响规则:等级越高越霸气、有气场、看起来强大、画面细节丰富,等级级别越低越弱小、普通。最低等级为1级,最高等级可能是6-12级".to_string(),
|
||||
"画面要求:1:1 正方形画布,画面中心构图,角色主体完整置于画面中央,不要裁切主体顶部和底部,不要镜头透视,不要特写。背景固定为纯绿色绿幕,只作为抠像底色,不出现建筑、室内布景、风景、地面道具、漂浮物、烟雾叙事元素、文字或其他角色以外的场景内容。".to_string(),
|
||||
"背景要求:透明背景 PNG 风格,不出现任何场景、水草、气泡、阴影地面、UI、文字、logo、水印、对话框或边框;不要出现多只主体。".to_string(),
|
||||
]
|
||||
.join("")
|
||||
}
|
||||
|
||||
/// 大鱼吃小鱼动作关键帧生成提示词脚本。
|
||||
pub(crate) fn build_big_fish_level_motion_prompt(
|
||||
draft: &BigFishGameDraftRecord,
|
||||
level: &BigFishLevelBlueprintRecord,
|
||||
motion_key: &str,
|
||||
) -> String {
|
||||
let motion_text = match motion_key {
|
||||
"move_swim" => format!(
|
||||
"{} 向右游动的关键帧预览,身体与尾鳍有清晰推进姿态,带轻微水流拖尾。",
|
||||
level.move_motion_description
|
||||
),
|
||||
_ => format!(
|
||||
"{} 待机漂浮的关键帧预览,身体轻微摆动,姿态稳定,适合作为 idle 状态。",
|
||||
level.idle_motion_description
|
||||
),
|
||||
};
|
||||
vec![
|
||||
format!(
|
||||
"为竖屏移动游戏《{}》生成一张等级生物动作关键帧静态预览图。",
|
||||
draft.title
|
||||
),
|
||||
format!("生态主题:{}。", draft.ecology_theme),
|
||||
format!(
|
||||
"等级:Lv.{},名称:{},幻想描述:{}。",
|
||||
level.level, level.name, level.one_line_fantasy
|
||||
),
|
||||
format!("文字描述:{}。", level.text_description),
|
||||
format!("动作提示词种子:{}。", level.motion_prompt_seed),
|
||||
format!("动作要求:{motion_text}"),
|
||||
"画面要求:按 RPG 角色动画资产口径生成,单体鱼形生物完整入镜,轮廓清晰,动作方向明确,2D 高完成度游戏插画,适合作为 Big Fish 动作槽位的静态 keyframe。".to_string(),
|
||||
"背景要求:透明背景 PNG 风格,不出现任何场景、水草、气泡、阴影地面、UI、文字、logo、水印、对话框或边框;不要生成序列帧拼图,不要出现多只主体。".to_string(),
|
||||
]
|
||||
.join("")
|
||||
}
|
||||
|
||||
/// 大鱼吃小鱼场地背景生成提示词脚本。
|
||||
pub(crate) fn build_big_fish_stage_background_prompt(draft: &BigFishGameDraftRecord) -> String {
|
||||
let background = &draft.background;
|
||||
vec![
|
||||
"生成一张 9:16 的游戏场景背景图。".to_string(),
|
||||
format!("生态主题:{}。", draft.ecology_theme),
|
||||
format!("背景主题:{}。色彩氛围:{}。", background.theme, background.color_mood),
|
||||
format!("前景提示:{}。", background.foreground_hints),
|
||||
format!("中景构图:{}。", background.midground_composition),
|
||||
format!("背景纵深:{}。", background.background_depth),
|
||||
format!("安全操作区:{}。", background.safe_play_area_hint),
|
||||
format!("出生边缘:{}。", background.spawn_edge_hint),
|
||||
format!("背景提示词种子:{}。", background.background_prompt_seed),
|
||||
"画面要求:竖屏9:16,大场地,全屏运行态背景,中央 80% 保持开阔清爽,边缘只保留少量出生区环境提示。".to_string(),
|
||||
"元素要求:画面中不出现任何形象主体、密集装饰、UI、文字、logo、水印、对话框或边框;不要把中央操作区画得过暗或过复杂。".to_string(),
|
||||
]
|
||||
.join("")
|
||||
}
|
||||
|
||||
/// 大鱼吃小鱼图片生成默认反向提示词脚本。
|
||||
pub(crate) const BIG_FISH_DEFAULT_NEGATIVE_PROMPT: &str = "文字,水印,logo,UI界面,对话框,边框,多余肢体,畸形鱼体,低清晰度,模糊,压缩噪点,现代摄影棚,写实照片背景,复杂背景";
|
||||
|
||||
/// 大鱼吃小鱼透明主体类图片生成默认反向提示词脚本。
|
||||
pub(crate) const BIG_FISH_TRANSPARENT_ASSET_NEGATIVE_PROMPT: &str = "文字,水印,logo,UI界面,对话框,边框,多余肢体,畸形鱼体,低清晰度,模糊,压缩噪点,现代摄影棚,写实照片背景,场景背景,水草背景,气泡背景,多只主体,阴影地面";
|
||||
|
||||
fn build_chat_history(messages: &[BigFishAgentMessageRecord]) -> Vec<JsonValue> {
|
||||
messages
|
||||
.iter()
|
||||
.map(|message| {
|
||||
serde_json::json!({
|
||||
"role": message.role,
|
||||
"kind": message.kind,
|
||||
"content": message.text,
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub(crate) fn serialize_record_anchor_pack(anchor_pack: &BigFishAnchorPackRecord) -> String {
|
||||
serde_json::to_string_pretty(&map_big_fish_record_anchor_pack(anchor_pack))
|
||||
.unwrap_or_else(|_| "{}".to_string())
|
||||
}
|
||||
|
||||
fn map_big_fish_record_anchor_pack(record: &BigFishAnchorPackRecord) -> BigFishAnchorPack {
|
||||
BigFishAnchorPack {
|
||||
gameplay_promise: map_big_fish_record_anchor_item(&record.gameplay_promise),
|
||||
ecology_visual_theme: map_big_fish_record_anchor_item(&record.ecology_visual_theme),
|
||||
growth_ladder: map_big_fish_record_anchor_item(&record.growth_ladder),
|
||||
risk_tempo: map_big_fish_record_anchor_item(&record.risk_tempo),
|
||||
}
|
||||
}
|
||||
|
||||
fn map_big_fish_record_anchor_item(
|
||||
record: &spacetime_client::BigFishAnchorItemRecord,
|
||||
) -> module_big_fish::BigFishAnchorItem {
|
||||
module_big_fish::BigFishAnchorItem {
|
||||
key: record.key.clone(),
|
||||
label: record.label.clone(),
|
||||
value: record.value.clone(),
|
||||
status: match record.status.as_str() {
|
||||
"confirmed" => module_big_fish::BigFishAnchorStatus::Confirmed,
|
||||
"locked" => module_big_fish::BigFishAnchorStatus::Locked,
|
||||
"inferred" => module_big_fish::BigFishAnchorStatus::Inferred,
|
||||
_ => module_big_fish::BigFishAnchorStatus::Missing,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{
|
||||
BIG_FISH_DEFAULT_NEGATIVE_PROMPT, BIG_FISH_TRANSPARENT_ASSET_NEGATIVE_PROMPT,
|
||||
build_big_fish_agent_prompt, build_big_fish_level_main_image_prompt,
|
||||
build_big_fish_level_motion_prompt, build_big_fish_stage_background_prompt,
|
||||
};
|
||||
|
||||
fn anchor_item(
|
||||
key: &str,
|
||||
label: &str,
|
||||
value: &str,
|
||||
status: &str,
|
||||
) -> spacetime_client::BigFishAnchorItemRecord {
|
||||
spacetime_client::BigFishAnchorItemRecord {
|
||||
key: key.to_string(),
|
||||
label: label.to_string(),
|
||||
value: value.to_string(),
|
||||
status: status.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
fn empty_session_record() -> spacetime_client::BigFishSessionRecord {
|
||||
spacetime_client::BigFishSessionRecord {
|
||||
session_id: "big-fish-session-test".to_string(),
|
||||
current_turn: 2,
|
||||
progress_percent: 60,
|
||||
stage: "collecting_anchors".to_string(),
|
||||
anchor_pack: spacetime_client::BigFishAnchorPackRecord {
|
||||
gameplay_promise: anchor_item(
|
||||
"gameplayPromise",
|
||||
"玩法承诺",
|
||||
"微光小鱼逆袭深海巨兽",
|
||||
"confirmed",
|
||||
),
|
||||
ecology_visual_theme: anchor_item(
|
||||
"ecologyVisualTheme",
|
||||
"生态视觉主题",
|
||||
"幽蓝珊瑚海沟",
|
||||
"confirmed",
|
||||
),
|
||||
growth_ladder: anchor_item("growthLadder", "成长阶梯", "", "missing"),
|
||||
risk_tempo: anchor_item("riskTempo", "风险节奏", "", "missing"),
|
||||
},
|
||||
draft: None,
|
||||
asset_slots: Vec::new(),
|
||||
asset_coverage: spacetime_client::BigFishAssetCoverageRecord {
|
||||
level_main_image_ready_count: 0,
|
||||
level_motion_ready_count: 0,
|
||||
background_ready: false,
|
||||
required_level_count: 8,
|
||||
publish_ready: false,
|
||||
blockers: Vec::new(),
|
||||
},
|
||||
messages: Vec::new(),
|
||||
last_assistant_reply: None,
|
||||
publish_ready: false,
|
||||
updated_at: "2026-04-24T10:00:00.000Z".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
fn sample_draft() -> spacetime_client::BigFishGameDraftRecord {
|
||||
spacetime_client::BigFishGameDraftRecord {
|
||||
title: "深海逆袭".to_string(),
|
||||
subtitle: "从微光幼体吞噬到深渊王座".to_string(),
|
||||
core_fun: "吞噬成长与风险闪避".to_string(),
|
||||
ecology_theme: "幽蓝海沟珊瑚裂谷".to_string(),
|
||||
levels: vec![spacetime_client::BigFishLevelBlueprintRecord {
|
||||
level: 3,
|
||||
name: "裂潮猎游者".to_string(),
|
||||
one_line_fantasy: "在电光海沟中疾行收割的中阶猎鱼".to_string(),
|
||||
text_description: "裂潮猎游者是中阶进化体,已经具备更清晰的猎食轮廓和压迫感。"
|
||||
.to_string(),
|
||||
silhouette_direction: "长尾前探、背鳍后掠".to_string(),
|
||||
size_ratio: 1.8,
|
||||
visual_description: "深海霓虹风格的中阶猎鱼,长尾锐利,骨质鳍刃明显,轮廓成熟。"
|
||||
.to_string(),
|
||||
visual_prompt_seed: "深海霓虹、锐利长尾、骨质鳍刃".to_string(),
|
||||
idle_motion_description:
|
||||
"待机时身体轻微悬停,尾鳍保持低频摆动,像是在观察猎物距离。".to_string(),
|
||||
move_motion_description: "移动时长尾快速摆动,身体前探,形成明显突进巡游姿态。"
|
||||
.to_string(),
|
||||
motion_prompt_seed: "突进摆尾、鳍面拉伸、水流拖尾".to_string(),
|
||||
merge_source_level: Some(2),
|
||||
prey_window: vec![1, 2],
|
||||
threat_window: vec![4, 5],
|
||||
is_final_level: false,
|
||||
}],
|
||||
background: spacetime_client::BigFishBackgroundBlueprintRecord {
|
||||
theme: "裂谷荧光水域".to_string(),
|
||||
color_mood: "蓝绿冷光、边缘紫雾".to_string(),
|
||||
foreground_hints: "边角保留细碎水母草和岩脊".to_string(),
|
||||
midground_composition: "中央留大面积清爽水道".to_string(),
|
||||
background_depth: "远处海沟层叠透视".to_string(),
|
||||
safe_play_area_hint: "中央 80% 为操作安全区".to_string(),
|
||||
spawn_edge_hint: "左右边缘保留出生点环境提示".to_string(),
|
||||
background_prompt_seed: "荧光裂谷、冷色纵深、轻体积光".to_string(),
|
||||
},
|
||||
runtime_params: spacetime_client::BigFishRuntimeParamsRecord {
|
||||
level_count: 8,
|
||||
merge_count_per_upgrade: 3,
|
||||
spawn_target_count: 12,
|
||||
leader_move_speed: 240.0,
|
||||
follower_catch_up_speed: 280.0,
|
||||
offscreen_cull_seconds: 4.5,
|
||||
prey_spawn_delta_levels: vec![0, 1],
|
||||
threat_spawn_delta_levels: vec![1, 2],
|
||||
win_level: 8,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn quick_fill_prompt_forbids_follow_up_questions() {
|
||||
let prompt = build_big_fish_agent_prompt(&empty_session_record(), true);
|
||||
|
||||
assert!(prompt.contains("用户刚刚主动要求你自动补充剩余关键字"));
|
||||
assert!(prompt.contains("不要再继续提问"));
|
||||
assert!(prompt.contains("progressPercent 直接输出为 100"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn level_main_image_prompt_keeps_core_constraints() {
|
||||
let draft = sample_draft();
|
||||
let prompt = build_big_fish_level_main_image_prompt(&draft, &draft.levels[0]);
|
||||
|
||||
assert!(prompt.contains("裂潮猎游者"));
|
||||
assert!(prompt.contains("形象描述"));
|
||||
assert!(prompt.contains("透明背景 PNG 风格"));
|
||||
assert!(prompt.contains("主图提示词"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn level_motion_prompt_varies_with_motion_key() {
|
||||
let draft = sample_draft();
|
||||
let move_prompt = build_big_fish_level_motion_prompt(&draft, &draft.levels[0], "move_swim");
|
||||
let idle_prompt =
|
||||
build_big_fish_level_motion_prompt(&draft, &draft.levels[0], "idle_float");
|
||||
|
||||
assert!(move_prompt.contains("向右游动的关键帧预览"));
|
||||
assert!(idle_prompt.contains("待机漂浮的关键帧预览"));
|
||||
assert!(move_prompt.contains("透明背景 PNG 风格"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stage_background_prompt_keeps_runtime_field_constraints() {
|
||||
let draft = sample_draft();
|
||||
let prompt = build_big_fish_stage_background_prompt(&draft);
|
||||
|
||||
assert!(prompt.contains("生成一张 9:16 的游戏场景背景图"));
|
||||
assert!(prompt.contains("中央 80% 保持开阔清爽"));
|
||||
assert!(prompt.contains("背景提示词种子"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn negative_prompts_keep_text_and_background_blockers() {
|
||||
assert!(BIG_FISH_DEFAULT_NEGATIVE_PROMPT.contains("文字"));
|
||||
assert!(BIG_FISH_DEFAULT_NEGATIVE_PROMPT.contains("复杂背景"));
|
||||
assert!(BIG_FISH_TRANSPARENT_ASSET_NEGATIVE_PROMPT.contains("场景背景"));
|
||||
assert!(BIG_FISH_TRANSPARENT_ASSET_NEGATIVE_PROMPT.contains("多只主体"));
|
||||
}
|
||||
}
|
||||
@@ -199,7 +199,7 @@ fn build_video_action_prompt(
|
||||
) -> String {
|
||||
[
|
||||
format!("生成有创意细节饱满的角色动作视频,动作英文名是 {}。", action_id),
|
||||
"角色固定为图1同一角色,保持右向斜侧身动作视角,镜头稳定,轮廓清晰,禁止退化成完全 90 度纯右视图。".to_string(),
|
||||
"角色固定为图1同一角色,保持右向斜侧身动作视角,镜头稳定,轮廓清晰,禁止退化成完全90度纯右视图。".to_string(),
|
||||
"画面要求:1:1 正方形画布,画面中心构图,角色主体完整置于画面中央,不要裁切主体顶部和底部,不要镜头透视,不要特写。背景固定为纯绿色绿幕,只作为抠像底色,不出现建筑、室内布景、风景等场景内容。".to_string(),
|
||||
format!("动作结构:{}。结尾要求:动作收束清楚,便于后续抽帧。", action_sequence),
|
||||
if use_chroma_key {
|
||||
|
||||
@@ -47,7 +47,7 @@ fn resolve_original_role_archetype(source: &str) -> &'static str {
|
||||
/// 角色主图统一提示词骨架,迁移自旧共享 qwenSprite 主链。
|
||||
fn build_master_prompt(character_brief: &str) -> String {
|
||||
[
|
||||
"单人,2D像素角色形象,头身比必须控制在 1.5 到 2 头身,主体完整可见,底部轮廓完整,身体比例稳定,轮廓清楚,适合后续制作 sprite sheet 动画。".to_string(),
|
||||
"单人,2D像素角色形象,头身比必须控制在1.5头身,主体完整可见,底部轮廓完整,身体比例稳定,轮廓清楚,适合后续制作 sprite sheet 动画。".to_string(),
|
||||
"视角要求:角色采用横版动作素材常用的右向斜侧身站姿,身体整体朝右,但保留少量正面信息,能读到面部轮廓与胸肩结构,不是完全 90 度纯右视图,也不是正面立绘。".to_string(),
|
||||
"主体要求:画面中只保留单个角色主体,不要额外人物、动物、召唤物、载具或陪体。".to_string(),
|
||||
"画面要求:1:1 正方形画布,画面中心构图,角色主体完整置于画面中央,不要裁切主体顶部和底部,不要镜头透视,不要特写。背景固定为纯绿色绿幕,只作为抠像底色,不出现建筑、室内布景、风景、地面道具、漂浮物、烟雾叙事元素、文字或其他角色以外的场景内容。".to_string(),
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
pub(crate) mod agent_chat;
|
||||
pub(crate) mod big_fish;
|
||||
pub(crate) mod character_animation;
|
||||
pub(crate) mod character_visual;
|
||||
pub(crate) mod foundation_draft;
|
||||
pub(crate) mod puzzle_image;
|
||||
pub(crate) mod runtime_chat;
|
||||
pub(crate) mod rpg;
|
||||
pub(crate) mod scene_background;
|
||||
|
||||
pub(crate) use rpg::agent_chat;
|
||||
pub(crate) use rpg::foundation_draft;
|
||||
pub(crate) use rpg::role_asset_studio;
|
||||
pub(crate) use rpg::runtime_chat;
|
||||
|
||||
@@ -21,14 +21,13 @@ pub(crate) fn build_custom_world_framework_prompt(setting_text: &str) -> String
|
||||
" \"majorFactions\": [\"势力甲\", \"势力乙\"],".to_string(),
|
||||
" \"coreConflicts\": [\"冲突甲\", \"冲突乙\"],".to_string(),
|
||||
" \"attributeSchema\": {".to_string(),
|
||||
" \"schemaName\": \"本世界六维名称\",".to_string(),
|
||||
" \"slots\": [".to_string(),
|
||||
" { \"slotId\": \"axis_a\", \"name\": \"维度名\", \"definition\": \"维度定义\", \"positiveSignals\": [\"正向表现\"], \"negativeSignals\": [\"负向表现\"], \"combatUseText\": \"战斗用途\", \"socialUseText\": \"社交用途\", \"explorationUseText\": \"探索用途\" },".to_string(),
|
||||
" { \"slotId\": \"axis_b\", \"name\": \"维度名\", \"definition\": \"维度定义\", \"positiveSignals\": [\"正向表现\"], \"negativeSignals\": [\"负向表现\"], \"combatUseText\": \"战斗用途\", \"socialUseText\": \"社交用途\", \"explorationUseText\": \"探索用途\" },".to_string(),
|
||||
" { \"slotId\": \"axis_c\", \"name\": \"维度名\", \"definition\": \"维度定义\", \"positiveSignals\": [\"正向表现\"], \"negativeSignals\": [\"负向表现\"], \"combatUseText\": \"战斗用途\", \"socialUseText\": \"社交用途\", \"explorationUseText\": \"探索用途\" },".to_string(),
|
||||
" { \"slotId\": \"axis_d\", \"name\": \"维度名\", \"definition\": \"维度定义\", \"positiveSignals\": [\"正向表现\"], \"negativeSignals\": [\"负向表现\"], \"combatUseText\": \"战斗用途\", \"socialUseText\": \"社交用途\", \"explorationUseText\": \"探索用途\" },".to_string(),
|
||||
" { \"slotId\": \"axis_e\", \"name\": \"维度名\", \"definition\": \"维度定义\", \"positiveSignals\": [\"正向表现\"], \"negativeSignals\": [\"负向表现\"], \"combatUseText\": \"战斗用途\", \"socialUseText\": \"社交用途\", \"explorationUseText\": \"探索用途\" },".to_string(),
|
||||
" { \"slotId\": \"axis_f\", \"name\": \"维度名\", \"definition\": \"维度定义\", \"positiveSignals\": [\"正向表现\"], \"negativeSignals\": [\"负向表现\"], \"combatUseText\": \"战斗用途\", \"socialUseText\": \"社交用途\", \"explorationUseText\": \"探索用途\" }".to_string(),
|
||||
" { \"name\": \"维度名\" },".to_string(),
|
||||
" { \"name\": \"维度名\" },".to_string(),
|
||||
" { \"name\": \"维度名\" },".to_string(),
|
||||
" { \"name\": \"维度名\" },".to_string(),
|
||||
" { \"name\": \"维度名\" },".to_string(),
|
||||
" { \"name\": \"维度名\" }".to_string(),
|
||||
" ]".to_string(),
|
||||
" },".to_string(),
|
||||
" \"camp\": {".to_string(),
|
||||
@@ -45,9 +44,9 @@ pub(crate) fn build_custom_world_framework_prompt(setting_text: &str) -> String
|
||||
"- camp 只表示玩家开局时的落脚处占位,更接近归舍、住处、栖居、前哨居所这类“家/归处”的概念;不要在这一步生成开局场景任务、三幕事件或三幕背景。".to_string(),
|
||||
"- 不要输出 playableNpcs、storyNpcs、landmarks、items,也不要输出任何角色和地图细节。".to_string(),
|
||||
"- majorFactions 保持 2 到 3 个,coreConflicts 保持 2 到 3 个。".to_string(),
|
||||
"- attributeSchema 必须是本世界专属的角色六维属性体系,slots 必须恰好 6 个,slotId 固定为 axis_a 到 axis_f,维度名必须是 2 到 4 个汉字且互不重复。".to_string(),
|
||||
"- attributeSchema 必须是本世界专属的角色六维名称体系,slots 必须恰好 6 个,每个 slot 只输出 name,维度名必须是 2 到 4 个汉字且互不重复。".to_string(),
|
||||
"- attributeSchema.slots 的 name 禁止使用:生命、法力、护甲、攻击、防御、力量、敏捷、智力、精神;不要写通用 DND 或传统四维属性。".to_string(),
|
||||
"- 每个属性维度都要同时能服务战斗、社交、探索三种场景,definition、combatUseText、socialUseText、explorationUseText 必须贴合本世界主题。".to_string(),
|
||||
"- 不要在 attributeSchema.slots 内输出 definition、positiveSignals、negativeSignals、combatUseText、socialUseText、explorationUseText 或其他说明字段。".to_string(),
|
||||
"- 世界设定必须直接源自玩家输入,不要脱离主题乱扩写。".to_string(),
|
||||
"- 每个字符串尽量简洁:subtitle 控制在 8 到 18 个汉字内,summary 控制在 16 到 32 个汉字内,tone 控制在 6 到 16 个汉字内,playerGoal 控制在 16 到 32 个汉字内,camp.description 控制在 18 到 40 个汉字内。".to_string(),
|
||||
"- 返回前自检:必须是一个能被 JSON.parse 直接解析的单个 JSON 对象。".to_string(),
|
||||
@@ -61,7 +60,7 @@ pub(crate) fn build_custom_world_framework_json_repair_prompt(response_text: &st
|
||||
"顶层必须只包含:name、subtitle、summary、tone、playerGoal、templateWorldType、majorFactions、coreConflicts、attributeSchema、camp。",
|
||||
"不要输出 playableNpcs、storyNpcs、landmarks、items 或任何其他字段。",
|
||||
"majorFactions 与 coreConflicts 必须是字符串数组。",
|
||||
"attributeSchema 必须是对象,且包含 schemaName 与 slots;slots 必须恰好 6 个,slotId 固定为 axis_a 到 axis_f。",
|
||||
"attributeSchema 必须是对象,且只包含 slots;slots 必须恰好 6 个,每个 slot 只保留 name。",
|
||||
"camp 必须是对象,且只包含:name、description。",
|
||||
"原始文本:",
|
||||
response_text.trim(),
|
||||
4
server-rs/crates/api-server/src/prompt/rpg/mod.rs
Normal file
4
server-rs/crates/api-server/src/prompt/rpg/mod.rs
Normal file
@@ -0,0 +1,4 @@
|
||||
pub(crate) mod agent_chat;
|
||||
pub(crate) mod foundation_draft;
|
||||
pub(crate) mod role_asset_studio;
|
||||
pub(crate) mod runtime_chat;
|
||||
348
server-rs/crates/api-server/src/prompt/rpg/role_asset_studio.rs
Normal file
348
server-rs/crates/api-server/src/prompt/rpg/role_asset_studio.rs
Normal file
@@ -0,0 +1,348 @@
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use serde_json::Value;
|
||||
use shared_contracts::assets::{
|
||||
CharacterAssetRolePromptInput, CharacterRoleAssetWorkflowPayload,
|
||||
CharacterRolePromptBundlePayload, CharacterWorkflowCachePayload,
|
||||
};
|
||||
|
||||
const CORE_ANIMATION_KEYS: [&str; 4] = ["run", "attack", "idle", "die"];
|
||||
|
||||
/// 角色资产工坊默认 prompt 与缓存合并的后端主源。
|
||||
///
|
||||
/// 前端只保留输入框中的用户草稿;默认值挑选、旧 prompt 过滤、逐动作缓存继承都在这里统一执行。
|
||||
pub(crate) fn build_role_asset_workflow(
|
||||
role: CharacterAssetRolePromptInput,
|
||||
cache: Option<&CharacterWorkflowCachePayload>,
|
||||
) -> CharacterRoleAssetWorkflowPayload {
|
||||
let default_prompt_bundle = build_default_role_prompt_bundle(&role);
|
||||
let visual_prompt_text =
|
||||
resolve_visual_prompt_text(&role, cache, &default_prompt_bundle.visual_prompt_text);
|
||||
let animation_prompt_text_by_key =
|
||||
resolve_animation_prompt_text_by_key(&role, cache, &default_prompt_bundle);
|
||||
let animation_prompt_text = animation_prompt_text_by_key
|
||||
.get("idle")
|
||||
.cloned()
|
||||
.unwrap_or_else(|| default_prompt_bundle.animation_prompt_text.clone());
|
||||
|
||||
CharacterRoleAssetWorkflowPayload {
|
||||
role: role.clone(),
|
||||
default_prompt_bundle,
|
||||
visual_prompt_text,
|
||||
animation_prompt_text,
|
||||
animation_prompt_text_by_key,
|
||||
visual_drafts: cache
|
||||
.map(|cache| cache.visual_drafts.clone())
|
||||
.unwrap_or_default(),
|
||||
selected_visual_draft_id: cache
|
||||
.map(|cache| cache.selected_visual_draft_id.clone())
|
||||
.unwrap_or_default(),
|
||||
selected_animation: cache
|
||||
.map(|cache| cache.selected_animation.clone())
|
||||
.filter(|value| CORE_ANIMATION_KEYS.contains(&value.as_str()))
|
||||
.unwrap_or_else(|| "run".to_string()),
|
||||
image_src: cache
|
||||
.and_then(|cache| cache.image_src.clone())
|
||||
.or_else(|| trim_optional_text(role.image_src.as_deref())),
|
||||
generated_visual_asset_id: cache
|
||||
.and_then(|cache| cache.generated_visual_asset_id.clone())
|
||||
.or_else(|| trim_optional_text(role.generated_visual_asset_id.as_deref())),
|
||||
generated_animation_set_id: cache
|
||||
.and_then(|cache| cache.generated_animation_set_id.clone())
|
||||
.or_else(|| trim_optional_text(role.generated_animation_set_id.as_deref())),
|
||||
animation_map: cache
|
||||
.and_then(|cache| cache.animation_map.clone())
|
||||
.or_else(|| role.animation_map.clone())
|
||||
.filter(Value::is_object),
|
||||
updated_at: cache.and_then(|cache| cache.updated_at.clone()),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn build_default_role_prompt_bundle(
|
||||
role: &CharacterAssetRolePromptInput,
|
||||
) -> CharacterRolePromptBundlePayload {
|
||||
CharacterRolePromptBundlePayload {
|
||||
visual_prompt_text: pick_first_description(
|
||||
[
|
||||
role.visual_description.as_deref(),
|
||||
role.description.as_deref(),
|
||||
],
|
||||
220,
|
||||
),
|
||||
animation_prompt_text: pick_first_description(
|
||||
[
|
||||
role.action_description.as_deref(),
|
||||
role.combat_style.as_deref(),
|
||||
],
|
||||
180,
|
||||
),
|
||||
scene_prompt_text: pick_first_description(
|
||||
[
|
||||
role.scene_visual_description.as_deref(),
|
||||
role.backstory.as_deref(),
|
||||
],
|
||||
220,
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn normalize_animation_prompt_text_by_key(
|
||||
prompt_text_by_key: BTreeMap<String, String>,
|
||||
) -> BTreeMap<String, String> {
|
||||
prompt_text_by_key
|
||||
.into_iter()
|
||||
.filter_map(|(key, value)| {
|
||||
let key = trim_optional_text(Some(key.as_str()))?;
|
||||
let value = clamp_seed_text(value.as_str(), 280);
|
||||
if value.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some((key, value))
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn resolve_visual_prompt_text(
|
||||
role: &CharacterAssetRolePromptInput,
|
||||
cache: Option<&CharacterWorkflowCachePayload>,
|
||||
fallback_text: &str,
|
||||
) -> String {
|
||||
if trim_optional_text(role.visual_description.as_deref()).is_none() {
|
||||
if let Some(cached_text) = cache
|
||||
.map(|cache| cache.visual_prompt_text.as_str())
|
||||
.and_then(|value| trim_optional_text(Some(value)))
|
||||
.filter(|value| !is_legacy_generated_visual_description(value))
|
||||
{
|
||||
return cached_text;
|
||||
}
|
||||
}
|
||||
|
||||
fallback_text.to_string()
|
||||
}
|
||||
|
||||
fn resolve_animation_prompt_text_by_key(
|
||||
role: &CharacterAssetRolePromptInput,
|
||||
cache: Option<&CharacterWorkflowCachePayload>,
|
||||
default_prompt_bundle: &CharacterRolePromptBundlePayload,
|
||||
) -> BTreeMap<String, String> {
|
||||
let fallback_text = default_prompt_bundle.animation_prompt_text.as_str();
|
||||
let prefer_fresh_role_text = trim_optional_text(role.action_description.as_deref()).is_some();
|
||||
let cached_by_key = cache
|
||||
.map(|cache| &cache.animation_prompt_text_by_key)
|
||||
.cloned()
|
||||
.unwrap_or_default();
|
||||
let legacy_text = cache
|
||||
.map(|cache| cache.animation_prompt_text.as_str())
|
||||
.and_then(|value| trim_optional_text(Some(value)))
|
||||
.filter(|value| !is_legacy_generated_action_description(value));
|
||||
|
||||
CORE_ANIMATION_KEYS
|
||||
.iter()
|
||||
.map(|animation| {
|
||||
let cached_text = cached_by_key
|
||||
.get(*animation)
|
||||
.and_then(|value| trim_optional_text(Some(value.as_str())))
|
||||
.filter(|value| !is_legacy_generated_action_description(value));
|
||||
let prompt_text = if prefer_fresh_role_text {
|
||||
fallback_text.to_string()
|
||||
} else {
|
||||
cached_text
|
||||
.or_else(|| legacy_text.clone())
|
||||
.unwrap_or_else(|| fallback_text.to_string())
|
||||
};
|
||||
|
||||
((*animation).to_string(), prompt_text)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn pick_first_description<const N: usize>(values: [Option<&str>; N], max_length: usize) -> String {
|
||||
values
|
||||
.into_iter()
|
||||
.filter_map(|value| value.map(|value| clamp_seed_text(value, max_length)))
|
||||
.find(|value| !value.is_empty())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
fn trim_optional_text(value: Option<&str>) -> Option<String> {
|
||||
value
|
||||
.map(|value| value.split_whitespace().collect::<Vec<_>>().join(" "))
|
||||
.map(|value| value.trim().to_string())
|
||||
.filter(|value| !value.is_empty())
|
||||
}
|
||||
|
||||
fn clamp_seed_text(value: &str, max_length: usize) -> String {
|
||||
trim_optional_text(Some(value))
|
||||
.unwrap_or_default()
|
||||
.chars()
|
||||
.take(max_length)
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn is_legacy_generated_visual_description(value: &str) -> bool {
|
||||
let normalized = value.trim();
|
||||
!normalized.is_empty()
|
||||
&& [
|
||||
"2D 横版 RPG",
|
||||
"纯绿色绿幕",
|
||||
"2 到 2.5 头身",
|
||||
"深色粗轮廓",
|
||||
"身体整体朝右",
|
||||
"脚底完整可见",
|
||||
]
|
||||
.iter()
|
||||
.any(|marker| normalized.contains(marker))
|
||||
}
|
||||
|
||||
fn is_legacy_generated_action_description(value: &str) -> bool {
|
||||
let normalized = value.trim();
|
||||
!normalized.is_empty()
|
||||
&& [
|
||||
"动作气质参考:",
|
||||
"发力起手明确",
|
||||
"收招利落",
|
||||
"动作表现偏向",
|
||||
"起手克制",
|
||||
]
|
||||
.iter()
|
||||
.any(|marker| normalized.contains(marker))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn role_input() -> CharacterAssetRolePromptInput {
|
||||
CharacterAssetRolePromptInput {
|
||||
id: "hero".to_string(),
|
||||
name: "沈砺".to_string(),
|
||||
title: "灰炬向导".to_string(),
|
||||
role: "边路同行者".to_string(),
|
||||
visual_description: Some("灰黑短斗篷压着风痕。".to_string()),
|
||||
action_description: Some("起手先观察风向,再用短弓牵制。".to_string()),
|
||||
scene_visual_description: Some("边路哨点铺着潮湿石板。".to_string()),
|
||||
description: Some("熟悉裂潮边路的向导。".to_string()),
|
||||
backstory: Some("他把旧案痕迹留在边路。".to_string()),
|
||||
personality: None,
|
||||
motivation: None,
|
||||
combat_style: Some("短弓牵制后贴近补刀。".to_string()),
|
||||
tags: Vec::new(),
|
||||
image_src: None,
|
||||
generated_visual_asset_id: None,
|
||||
generated_animation_set_id: None,
|
||||
animation_map: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_prompt_bundle_keeps_existing_mapping_rules() {
|
||||
let bundle = build_default_role_prompt_bundle(&role_input());
|
||||
|
||||
assert_eq!(bundle.visual_prompt_text, "灰黑短斗篷压着风痕。");
|
||||
assert_eq!(
|
||||
bundle.animation_prompt_text,
|
||||
"起手先观察风向,再用短弓牵制。"
|
||||
);
|
||||
assert_eq!(bundle.scene_prompt_text, "边路哨点铺着潮湿石板。");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn workflow_prefers_fresh_role_prompt_over_cache() {
|
||||
let cache = CharacterWorkflowCachePayload {
|
||||
character_id: "hero".to_string(),
|
||||
cache_scope_id: None,
|
||||
visual_prompt_text: "缓存视觉".to_string(),
|
||||
animation_prompt_text: "缓存动作".to_string(),
|
||||
animation_prompt_text_by_key: BTreeMap::from([(
|
||||
"run".to_string(),
|
||||
"缓存奔跑".to_string(),
|
||||
)]),
|
||||
visual_drafts: Vec::new(),
|
||||
selected_visual_draft_id: String::new(),
|
||||
selected_animation: "idle".to_string(),
|
||||
image_src: None,
|
||||
generated_visual_asset_id: None,
|
||||
generated_animation_set_id: None,
|
||||
animation_map: None,
|
||||
updated_at: None,
|
||||
};
|
||||
|
||||
let workflow = build_role_asset_workflow(role_input(), Some(&cache));
|
||||
|
||||
assert_eq!(workflow.visual_prompt_text, "灰黑短斗篷压着风痕。");
|
||||
assert_eq!(
|
||||
workflow.animation_prompt_text_by_key["run"],
|
||||
"起手先观察风向,再用短弓牵制。"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn workflow_uses_non_legacy_cache_when_role_has_no_fresh_text() {
|
||||
let mut role = role_input();
|
||||
role.visual_description = None;
|
||||
role.action_description = None;
|
||||
let cache = CharacterWorkflowCachePayload {
|
||||
character_id: "hero".to_string(),
|
||||
cache_scope_id: None,
|
||||
visual_prompt_text: "缓存视觉".to_string(),
|
||||
animation_prompt_text: "缓存旧动作".to_string(),
|
||||
animation_prompt_text_by_key: BTreeMap::from([(
|
||||
"attack".to_string(),
|
||||
"缓存攻击动作".to_string(),
|
||||
)]),
|
||||
visual_drafts: Vec::new(),
|
||||
selected_visual_draft_id: String::new(),
|
||||
selected_animation: "attack".to_string(),
|
||||
image_src: None,
|
||||
generated_visual_asset_id: None,
|
||||
generated_animation_set_id: None,
|
||||
animation_map: None,
|
||||
updated_at: None,
|
||||
};
|
||||
|
||||
let workflow = build_role_asset_workflow(role, Some(&cache));
|
||||
|
||||
assert_eq!(workflow.visual_prompt_text, "缓存视觉");
|
||||
assert_eq!(
|
||||
workflow.animation_prompt_text_by_key["attack"],
|
||||
"缓存攻击动作"
|
||||
);
|
||||
assert_eq!(workflow.animation_prompt_text_by_key["run"], "缓存旧动作");
|
||||
assert_eq!(workflow.selected_animation, "attack");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn workflow_filters_legacy_cache_prompts() {
|
||||
let mut role = role_input();
|
||||
role.visual_description = None;
|
||||
role.action_description = None;
|
||||
let cache = CharacterWorkflowCachePayload {
|
||||
character_id: "hero".to_string(),
|
||||
cache_scope_id: None,
|
||||
visual_prompt_text: "2D 横版 RPG,纯绿色绿幕。".to_string(),
|
||||
animation_prompt_text: "动作气质参考:发力起手明确。".to_string(),
|
||||
animation_prompt_text_by_key: BTreeMap::from([(
|
||||
"run".to_string(),
|
||||
"收招利落,动作表现偏向快速。".to_string(),
|
||||
)]),
|
||||
visual_drafts: Vec::new(),
|
||||
selected_visual_draft_id: String::new(),
|
||||
selected_animation: "unknown".to_string(),
|
||||
image_src: None,
|
||||
generated_visual_asset_id: None,
|
||||
generated_animation_set_id: None,
|
||||
animation_map: None,
|
||||
updated_at: None,
|
||||
};
|
||||
|
||||
let workflow = build_role_asset_workflow(role, Some(&cache));
|
||||
|
||||
assert_eq!(workflow.visual_prompt_text, "熟悉裂潮边路的向导。");
|
||||
assert_eq!(
|
||||
workflow.animation_prompt_text_by_key["run"],
|
||||
"短弓牵制后贴近补刀。"
|
||||
);
|
||||
assert_eq!(workflow.selected_animation, "run");
|
||||
}
|
||||
}
|
||||
@@ -65,6 +65,48 @@ pub(crate) fn runtime_npc_dialogue_system_prompt() -> &'static str {
|
||||
"你是游戏运行时 NPC 对话导演。只输出中文正文,不要输出 JSON、Markdown 或规则说明;不要新增系统尚未结算的奖励、任务结果或战斗结果。"
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub(crate) struct CharacterChatPromptParams<'a> {
|
||||
pub world_type: &'a str,
|
||||
pub player_character: &'a Value,
|
||||
pub target_character: &'a Value,
|
||||
pub story_history: &'a [Value],
|
||||
pub context: &'a Value,
|
||||
pub conversation_history: &'a [Value],
|
||||
pub conversation_summary: &'a str,
|
||||
pub previous_summary: &'a str,
|
||||
pub player_message: &'a str,
|
||||
pub target_status: &'a Value,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub(crate) struct NpcRecruitDialoguePromptParams<'a> {
|
||||
pub world_type: &'a str,
|
||||
pub character: &'a Value,
|
||||
pub encounter: &'a Value,
|
||||
pub monsters: &'a [Value],
|
||||
pub history: &'a [Value],
|
||||
pub context: &'a Value,
|
||||
pub invitation_text: &'a str,
|
||||
pub recruit_summary: &'a str,
|
||||
}
|
||||
|
||||
pub(crate) fn build_character_chat_reply_system_prompt() -> &'static str {
|
||||
"你是像素动作 RPG 中正在与玩家私下交谈的同行角色。只输出这名角色此刻会说的话,只允许中文,不要输出角色名、引号、旁白、动作描写、Markdown、JSON 或解释。"
|
||||
}
|
||||
|
||||
pub(crate) fn build_character_chat_suggestions_system_prompt() -> &'static str {
|
||||
"你要为玩家生成 3 条下一句可直接发送的中文回复建议。只输出 3 行纯文本,不要序号、引号、Markdown 或解释。三条建议要分别偏关心、追问、轻松拉近关系。"
|
||||
}
|
||||
|
||||
pub(crate) fn build_character_chat_summary_system_prompt() -> &'static str {
|
||||
"你要把玩家与该角色的聊天沉淀成一段后续剧情可用的关系摘要。只输出一段中文摘要,不要标题、Markdown、JSON 或解释。"
|
||||
}
|
||||
|
||||
pub(crate) fn build_npc_recruit_dialogue_system_prompt() -> &'static str {
|
||||
"你是角色扮演 RPG 的招募剧情对话编剧。只输出纯中文对话正文,不要输出解释、代码、Markdown、JSON 或额外说明。最后一行必须由对方明确答应加入队伍。"
|
||||
}
|
||||
|
||||
pub(crate) fn build_runtime_npc_dialogue_user_prompt(
|
||||
npc_name: &str,
|
||||
params: RuntimeNpcDialoguePromptParams<'_>,
|
||||
@@ -88,6 +130,76 @@ pub(crate) fn build_runtime_npc_dialogue_user_prompt(
|
||||
)
|
||||
}
|
||||
|
||||
pub(crate) fn build_character_chat_reply_user_prompt(
|
||||
params: CharacterChatPromptParams<'_>,
|
||||
) -> String {
|
||||
json!({
|
||||
"worldType": params.world_type,
|
||||
"playerCharacter": params.player_character,
|
||||
"targetCharacter": params.target_character,
|
||||
"storyHistory": params.story_history,
|
||||
"context": params.context,
|
||||
"conversationHistory": params.conversation_history,
|
||||
"conversationSummary": params.conversation_summary,
|
||||
"playerMessage": params.player_message,
|
||||
"targetStatus": params.target_status,
|
||||
})
|
||||
.to_string()
|
||||
}
|
||||
|
||||
pub(crate) fn build_character_chat_suggestions_user_prompt(
|
||||
params: CharacterChatPromptParams<'_>,
|
||||
) -> String {
|
||||
json!({
|
||||
"worldType": params.world_type,
|
||||
"playerCharacter": params.player_character,
|
||||
"targetCharacter": params.target_character,
|
||||
"storyHistory": params.story_history,
|
||||
"context": params.context,
|
||||
"conversationHistory": params.conversation_history,
|
||||
"conversationSummary": params.conversation_summary,
|
||||
"targetStatus": params.target_status,
|
||||
})
|
||||
.to_string()
|
||||
}
|
||||
|
||||
pub(crate) fn build_character_chat_summary_user_prompt(
|
||||
params: CharacterChatPromptParams<'_>,
|
||||
) -> String {
|
||||
json!({
|
||||
"worldType": params.world_type,
|
||||
"playerCharacter": params.player_character,
|
||||
"targetCharacter": params.target_character,
|
||||
"storyHistory": params.story_history,
|
||||
"context": params.context,
|
||||
"conversationHistory": params.conversation_history,
|
||||
"previousSummary": params.previous_summary,
|
||||
"targetStatus": params.target_status,
|
||||
})
|
||||
.to_string()
|
||||
}
|
||||
|
||||
pub(crate) fn build_npc_recruit_dialogue_user_prompt(
|
||||
npc_name: &str,
|
||||
params: NpcRecruitDialoguePromptParams<'_>,
|
||||
) -> String {
|
||||
let state_prompt = json!({
|
||||
"worldType": params.world_type,
|
||||
"character": params.character,
|
||||
"encounter": params.encounter,
|
||||
"monsters": params.monsters,
|
||||
"history": params.history,
|
||||
"context": params.context,
|
||||
"invitationText": params.invitation_text,
|
||||
"recruitSummary": params.recruit_summary,
|
||||
})
|
||||
.to_string();
|
||||
|
||||
format!(
|
||||
"请基于以下运行时状态,把“邀请 {npc_name} 入队”这件事写成 4 到 6 行可直接展示的中文对话。最后一行必须由 {npc_name} 明确答应加入。\n{state_prompt}"
|
||||
)
|
||||
}
|
||||
|
||||
pub(crate) fn runtime_reasoned_story_system_prompt() -> &'static str {
|
||||
"你是游戏运行时剧情导演。只输出中文剧情正文,不要输出 JSON、Markdown 或规则说明;必须尊重已结算的战斗 outcome、伤害和状态,不要发明额外奖励。"
|
||||
}
|
||||
@@ -414,6 +526,116 @@ pub(crate) fn build_deterministic_npc_reply(
|
||||
format!("{npc_name}听完你的话,回应道:“{player_message}。我明白你的意思,我们继续说。”")
|
||||
}
|
||||
|
||||
pub(crate) fn build_character_chat_reply_fallback(
|
||||
target_character: &Value,
|
||||
player_message: &str,
|
||||
conversation_summary: &str,
|
||||
) -> String {
|
||||
let target_name =
|
||||
read_name_field(target_character, "name").unwrap_or_else(|| "对方".to_string());
|
||||
let focus = if player_message.trim().is_empty() {
|
||||
"我听见你刚才的话了。".to_string()
|
||||
} else if player_message.trim().ends_with('。') {
|
||||
player_message.trim().to_string()
|
||||
} else {
|
||||
format!("{}。", player_message.trim())
|
||||
};
|
||||
|
||||
if conversation_summary.trim().is_empty() {
|
||||
format!("{focus}我会认真回答你。既然你愿意直接来问,我们就把这件事说清楚。")
|
||||
} else {
|
||||
format!("{focus}{target_name}显然记得你们之前谈过的事,所以这次回答也比先前更直接。")
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn build_character_chat_suggestions_fallback(target_character: &Value) -> String {
|
||||
let target_name = read_name_field(target_character, "name").unwrap_or_else(|| "你".to_string());
|
||||
[
|
||||
"我想先听你把真正担心的事说出来。".to_string(),
|
||||
format!("{target_name},这件事你还瞒了我什么?"),
|
||||
"先别谈别的,我想多了解你一点。".to_string(),
|
||||
]
|
||||
.join("\n")
|
||||
}
|
||||
|
||||
pub(crate) fn build_character_chat_summary_fallback(
|
||||
target_character: &Value,
|
||||
conversation_history: &[Value],
|
||||
previous_summary: &str,
|
||||
) -> String {
|
||||
let target_name =
|
||||
read_name_field(target_character, "name").unwrap_or_else(|| "这名角色".to_string());
|
||||
let latest_turns = conversation_history
|
||||
.iter()
|
||||
.rev()
|
||||
.take(4)
|
||||
.collect::<Vec<_>>()
|
||||
.into_iter()
|
||||
.rev()
|
||||
.filter_map(|item| {
|
||||
let record = as_record(item)?;
|
||||
let speaker =
|
||||
read_string(record.get("speaker")).unwrap_or_else(|| "character".to_string());
|
||||
let text = read_string(record.get("text"))?;
|
||||
Some(format!(
|
||||
"{}:{}",
|
||||
if speaker == "player" {
|
||||
"玩家"
|
||||
} else {
|
||||
target_name.as_str()
|
||||
},
|
||||
text
|
||||
))
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join(" ");
|
||||
|
||||
let current = if latest_turns.is_empty() {
|
||||
format!("{target_name}愿意继续私下交谈,对玩家的态度正在慢慢松动。")
|
||||
} else {
|
||||
format!("{target_name}在私下交谈中比先前更愿意回应。最近交流:{latest_turns}")
|
||||
};
|
||||
|
||||
if previous_summary.trim().is_empty() {
|
||||
current
|
||||
} else {
|
||||
format!("{} {}", previous_summary.trim(), current)
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn build_npc_chat_dialogue_fallback(encounter: &Value, topic: &str) -> String {
|
||||
let npc_name = read_name_field(encounter, "npcName")
|
||||
.or_else(|| read_name_field(encounter, "name"))
|
||||
.unwrap_or_else(|| "对方".to_string());
|
||||
[
|
||||
format!(
|
||||
"你:{}。我想先听听你的看法。",
|
||||
if topic.trim().is_empty() {
|
||||
"这件事我还没看透"
|
||||
} else {
|
||||
topic.trim()
|
||||
}
|
||||
),
|
||||
format!("{npc_name}:你问得并不随意,看来是真想弄清这里的底细。"),
|
||||
"你:前面的局势我还没看透。你若知道什么,就别只说一半。".to_string(),
|
||||
format!("{npc_name}:我能告诉你的,是这里近来一直不太平。接下来多留神些。"),
|
||||
]
|
||||
.join("\n")
|
||||
}
|
||||
|
||||
pub(crate) fn build_npc_recruit_dialogue_fallback(encounter: &Value) -> String {
|
||||
let npc_name = read_name_field(encounter, "npcName")
|
||||
.or_else(|| read_name_field(encounter, "name"))
|
||||
.unwrap_or_else(|| "对方".to_string());
|
||||
[
|
||||
"你:这不是客套。我是真心希望你能加入队伍,和我一起走下去。".to_string(),
|
||||
format!("{npc_name}:你这番话够坦诚,我听得出你不是随口一提。"),
|
||||
"你:前路不会轻松,但我还是希望你能与我并肩同行。".to_string(),
|
||||
format!("{npc_name}:好,我答应你。从现在起,我便与你结伴同行。"),
|
||||
]
|
||||
.join("\n")
|
||||
}
|
||||
|
||||
pub(crate) fn build_deterministic_chat_suggestions(
|
||||
npc_name: &str,
|
||||
player_message: &str,
|
||||
@@ -794,6 +1016,15 @@ fn read_string_field(value: &Value, field: &str) -> Option<String> {
|
||||
.map(ToOwned::to_owned)
|
||||
}
|
||||
|
||||
fn read_name_field(value: &Value, field: &str) -> Option<String> {
|
||||
value
|
||||
.get(field)
|
||||
.and_then(Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|text| !text.is_empty())
|
||||
.map(ToOwned::to_owned)
|
||||
}
|
||||
|
||||
fn read_string(value: Option<&Value>) -> Option<String> {
|
||||
value
|
||||
.and_then(Value::as_str)
|
||||
@@ -12,7 +12,14 @@ use serde::Deserialize;
|
||||
use serde_json::{Value, json};
|
||||
use std::convert::Infallible;
|
||||
|
||||
use module_runtime_story_compat::{
|
||||
RuntimeStoryPromptContextExtras, build_runtime_story_prompt_context, current_world_type,
|
||||
normalize_required_string, read_array_field, read_field, read_i32_field, read_object_field,
|
||||
read_optional_string_field, read_runtime_session_id,
|
||||
};
|
||||
|
||||
use crate::{
|
||||
auth::AuthenticatedAccessToken,
|
||||
http_error::AppError,
|
||||
prompt::runtime_chat::{
|
||||
NPC_CHAT_TURN_REPLY_SYSTEM_PROMPT, NPC_CHAT_TURN_SUGGESTION_SYSTEM_PROMPT,
|
||||
@@ -28,6 +35,8 @@ use crate::{
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct NpcChatTurnRequest {
|
||||
#[serde(default)]
|
||||
session_id: Option<String>,
|
||||
#[serde(default)]
|
||||
world_type: String,
|
||||
#[serde(default)]
|
||||
@@ -53,14 +62,25 @@ pub struct NpcChatTurnRequest {
|
||||
#[serde(default)]
|
||||
npc_initiates_conversation: bool,
|
||||
#[serde(default)]
|
||||
quest_offer_context: Option<Value>,
|
||||
#[serde(default)]
|
||||
chat_directive: Option<Value>,
|
||||
}
|
||||
|
||||
pub async fn stream_runtime_npc_chat_turn(
|
||||
State(state): State<AppState>,
|
||||
Extension(request_context): Extension<RequestContext>,
|
||||
Json(payload): Json<NpcChatTurnRequest>,
|
||||
Extension(authenticated): Extension<AuthenticatedAccessToken>,
|
||||
Json(mut payload): Json<NpcChatTurnRequest>,
|
||||
) -> Result<Response, Response> {
|
||||
hydrate_npc_chat_turn_request_from_session(
|
||||
&state,
|
||||
&request_context,
|
||||
authenticated.claims().user_id().to_string(),
|
||||
&mut payload,
|
||||
)
|
||||
.await?;
|
||||
|
||||
let npc_name = read_string_field(&payload.encounter, "npcName")
|
||||
.or_else(|| read_string_field(&payload.encounter, "name"))
|
||||
.unwrap_or_else(|| "对方".to_string());
|
||||
@@ -258,6 +278,112 @@ where
|
||||
Some((npc_reply, suggestions, function_suggestions, force_exit))
|
||||
}
|
||||
|
||||
async fn hydrate_npc_chat_turn_request_from_session(
|
||||
state: &AppState,
|
||||
request_context: &RequestContext,
|
||||
user_id: String,
|
||||
payload: &mut NpcChatTurnRequest,
|
||||
) -> Result<(), Response> {
|
||||
let Some(session_id) = payload
|
||||
.session_id
|
||||
.as_deref()
|
||||
.and_then(normalize_required_string)
|
||||
else {
|
||||
// 中文注释:旧调用没有 sessionId 时继续使用请求体字段;正式运行态由后端快照投影上下文。
|
||||
return Ok(());
|
||||
};
|
||||
let record = state
|
||||
.get_runtime_snapshot_record(user_id)
|
||||
.await
|
||||
.map_err(|error| {
|
||||
runtime_chat_error_response(
|
||||
request_context,
|
||||
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
|
||||
"provider": "spacetimedb",
|
||||
"message": error.to_string(),
|
||||
})),
|
||||
)
|
||||
})?
|
||||
.ok_or_else(|| {
|
||||
runtime_chat_error_response(
|
||||
request_context,
|
||||
AppError::from_status(StatusCode::CONFLICT).with_details(json!({
|
||||
"provider": "runtime-chat",
|
||||
"message": "运行时快照不存在,请先初始化并保存一次游戏",
|
||||
})),
|
||||
)
|
||||
})?;
|
||||
let game_state = record.game_state;
|
||||
let snapshot_session_id =
|
||||
read_runtime_session_id(&game_state).unwrap_or_else(|| session_id.clone());
|
||||
if snapshot_session_id != session_id {
|
||||
return Err(runtime_chat_error_response(
|
||||
request_context,
|
||||
AppError::from_status(StatusCode::CONFLICT).with_details(json!({
|
||||
"provider": "runtime-chat",
|
||||
"message": "请求的运行时会话与服务端快照不一致,请重新进入游戏",
|
||||
"sessionId": session_id,
|
||||
"snapshotSessionId": snapshot_session_id,
|
||||
})),
|
||||
));
|
||||
}
|
||||
|
||||
payload.world_type = current_world_type(&game_state).unwrap_or_default();
|
||||
payload.character = read_field(&game_state, "playerCharacter").cloned();
|
||||
payload.player = payload.character.clone();
|
||||
payload.encounter = read_field(&game_state, "currentEncounter")
|
||||
.cloned()
|
||||
.unwrap_or_else(|| payload.encounter.clone());
|
||||
payload.monsters = read_array_field(&game_state, "sceneHostileNpcs")
|
||||
.into_iter()
|
||||
.cloned()
|
||||
.collect();
|
||||
payload.history = read_array_field(&game_state, "storyHistory")
|
||||
.into_iter()
|
||||
.rev()
|
||||
.take(12)
|
||||
.collect::<Vec<_>>()
|
||||
.into_iter()
|
||||
.rev()
|
||||
.cloned()
|
||||
.collect();
|
||||
payload.context = build_runtime_story_prompt_context(
|
||||
&game_state,
|
||||
RuntimeStoryPromptContextExtras {
|
||||
last_function_id: Some("npc_chat".to_string()),
|
||||
..RuntimeStoryPromptContextExtras::default()
|
||||
},
|
||||
);
|
||||
payload.npc_state =
|
||||
resolve_current_request_npc_state(&game_state).unwrap_or_else(|| payload.npc_state.clone());
|
||||
if let Some(quest_context) = payload.quest_offer_context.as_mut() {
|
||||
if let Some(object) = quest_context.as_object_mut() {
|
||||
object.insert("state".to_string(), game_state);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn resolve_current_request_npc_state(game_state: &Value) -> Option<Value> {
|
||||
let encounter = read_object_field(game_state, "currentEncounter")?;
|
||||
let npc_name = read_optional_string_field(encounter, "npcName")
|
||||
.or_else(|| read_optional_string_field(encounter, "name"))
|
||||
.unwrap_or_else(|| "当前遭遇".to_string());
|
||||
let npc_id = read_optional_string_field(encounter, "id").unwrap_or_else(|| npc_name.clone());
|
||||
let state = read_object_field(game_state, "npcStates").and_then(|states| {
|
||||
states
|
||||
.get(npc_id.as_str())
|
||||
.or_else(|| states.get(npc_name.as_str()))
|
||||
})?;
|
||||
|
||||
Some(json!({
|
||||
"affinity": read_i32_field(state, "affinity").unwrap_or(0),
|
||||
"chattedCount": read_i32_field(state, "chattedCount").unwrap_or(0),
|
||||
"recruited": state.get("recruited").and_then(Value::as_bool).unwrap_or(false),
|
||||
}))
|
||||
}
|
||||
|
||||
fn build_completion_directive(chat_directive: Option<&Value>, force_exit: bool) -> Value {
|
||||
let Some(directive) = chat_directive else {
|
||||
return Value::Null;
|
||||
|
||||
615
server-rs/crates/api-server/src/runtime_chat_plain.rs
Normal file
615
server-rs/crates/api-server/src/runtime_chat_plain.rs
Normal file
@@ -0,0 +1,615 @@
|
||||
use axum::{
|
||||
Json,
|
||||
extract::{Extension, State},
|
||||
http::StatusCode,
|
||||
response::{
|
||||
IntoResponse, Response,
|
||||
sse::{Event, Sse},
|
||||
},
|
||||
};
|
||||
use platform_llm::{LlmMessage, LlmTextRequest};
|
||||
use serde::Deserialize;
|
||||
use serde_json::{Value, json};
|
||||
use std::convert::Infallible;
|
||||
|
||||
use crate::{
|
||||
api_response::json_success_body, auth::AuthenticatedAccessToken, http_error::AppError,
|
||||
prompt::runtime_chat::*, request_context::RequestContext, state::AppState,
|
||||
};
|
||||
use module_runtime_story_compat::{
|
||||
RuntimeStoryPromptContextExtras, build_runtime_story_prompt_context, current_world_type,
|
||||
normalize_required_string, read_array_field, read_field, read_runtime_session_id,
|
||||
};
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct RuntimeCharacterChatRequest {
|
||||
#[serde(default)]
|
||||
session_id: Option<String>,
|
||||
#[serde(default)]
|
||||
world_type: String,
|
||||
#[serde(default)]
|
||||
player_character: Value,
|
||||
#[serde(default)]
|
||||
target_character: Value,
|
||||
#[serde(default)]
|
||||
story_history: Vec<Value>,
|
||||
#[serde(default)]
|
||||
context: Value,
|
||||
#[serde(default)]
|
||||
conversation_history: Vec<Value>,
|
||||
#[serde(default)]
|
||||
conversation_summary: String,
|
||||
#[serde(default)]
|
||||
previous_summary: String,
|
||||
#[serde(default)]
|
||||
player_message: String,
|
||||
#[serde(default)]
|
||||
target_status: Value,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct RuntimeNpcDialogueRequest {
|
||||
#[serde(default)]
|
||||
session_id: Option<String>,
|
||||
#[serde(default)]
|
||||
world_type: String,
|
||||
#[serde(default)]
|
||||
character: Value,
|
||||
#[serde(default)]
|
||||
encounter: Value,
|
||||
#[serde(default)]
|
||||
monsters: Vec<Value>,
|
||||
#[serde(default)]
|
||||
history: Vec<Value>,
|
||||
#[serde(default)]
|
||||
context: Value,
|
||||
#[serde(default)]
|
||||
topic: String,
|
||||
#[serde(default)]
|
||||
result_summary: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct RuntimeNpcRecruitDialogueRequest {
|
||||
#[serde(default)]
|
||||
session_id: Option<String>,
|
||||
#[serde(default)]
|
||||
world_type: String,
|
||||
#[serde(default)]
|
||||
character: Value,
|
||||
#[serde(default)]
|
||||
encounter: Value,
|
||||
#[serde(default)]
|
||||
monsters: Vec<Value>,
|
||||
#[serde(default)]
|
||||
history: Vec<Value>,
|
||||
#[serde(default)]
|
||||
context: Value,
|
||||
#[serde(default)]
|
||||
invitation_text: String,
|
||||
#[serde(default)]
|
||||
recruit_summary: String,
|
||||
}
|
||||
|
||||
pub async fn generate_runtime_character_chat_suggestions(
|
||||
State(state): State<AppState>,
|
||||
Extension(request_context): Extension<RequestContext>,
|
||||
Extension(authenticated): Extension<AuthenticatedAccessToken>,
|
||||
Json(mut payload): Json<RuntimeCharacterChatRequest>,
|
||||
) -> Result<Json<Value>, Response> {
|
||||
hydrate_character_chat_request_from_session(
|
||||
&state,
|
||||
&request_context,
|
||||
authenticated.claims().user_id().to_string(),
|
||||
&mut payload,
|
||||
)
|
||||
.await?;
|
||||
|
||||
let text = request_runtime_plain_text(
|
||||
&state,
|
||||
build_character_chat_suggestions_system_prompt(),
|
||||
build_character_chat_suggestions_user_prompt(CharacterChatPromptParams {
|
||||
world_type: payload.world_type.as_str(),
|
||||
player_character: &payload.player_character,
|
||||
target_character: &payload.target_character,
|
||||
story_history: &payload.story_history,
|
||||
context: &payload.context,
|
||||
conversation_history: &payload.conversation_history,
|
||||
conversation_summary: payload.conversation_summary.as_str(),
|
||||
previous_summary: payload.previous_summary.as_str(),
|
||||
player_message: payload.player_message.as_str(),
|
||||
target_status: &payload.target_status,
|
||||
}),
|
||||
Some(build_character_chat_suggestions_fallback(
|
||||
&payload.target_character,
|
||||
)),
|
||||
)
|
||||
.await;
|
||||
|
||||
Ok(json_success_body(
|
||||
Some(&request_context),
|
||||
json!({ "text": text }),
|
||||
))
|
||||
}
|
||||
|
||||
pub async fn generate_runtime_character_chat_summary(
|
||||
State(state): State<AppState>,
|
||||
Extension(request_context): Extension<RequestContext>,
|
||||
Extension(authenticated): Extension<AuthenticatedAccessToken>,
|
||||
Json(mut payload): Json<RuntimeCharacterChatRequest>,
|
||||
) -> Result<Json<Value>, Response> {
|
||||
hydrate_character_chat_request_from_session(
|
||||
&state,
|
||||
&request_context,
|
||||
authenticated.claims().user_id().to_string(),
|
||||
&mut payload,
|
||||
)
|
||||
.await?;
|
||||
|
||||
let text = request_runtime_plain_text(
|
||||
&state,
|
||||
build_character_chat_summary_system_prompt(),
|
||||
build_character_chat_summary_user_prompt(CharacterChatPromptParams {
|
||||
world_type: payload.world_type.as_str(),
|
||||
player_character: &payload.player_character,
|
||||
target_character: &payload.target_character,
|
||||
story_history: &payload.story_history,
|
||||
context: &payload.context,
|
||||
conversation_history: &payload.conversation_history,
|
||||
conversation_summary: payload.conversation_summary.as_str(),
|
||||
previous_summary: payload.previous_summary.as_str(),
|
||||
player_message: payload.player_message.as_str(),
|
||||
target_status: &payload.target_status,
|
||||
}),
|
||||
Some(build_character_chat_summary_fallback(
|
||||
&payload.target_character,
|
||||
&payload.conversation_history,
|
||||
payload.previous_summary.as_str(),
|
||||
)),
|
||||
)
|
||||
.await;
|
||||
|
||||
Ok(json_success_body(
|
||||
Some(&request_context),
|
||||
json!({ "text": text }),
|
||||
))
|
||||
}
|
||||
|
||||
pub async fn stream_runtime_character_chat_reply(
|
||||
State(state): State<AppState>,
|
||||
Extension(request_context): Extension<RequestContext>,
|
||||
Extension(authenticated): Extension<AuthenticatedAccessToken>,
|
||||
Json(mut payload): Json<RuntimeCharacterChatRequest>,
|
||||
) -> Result<Response, Response> {
|
||||
hydrate_character_chat_request_from_session(
|
||||
&state,
|
||||
&request_context,
|
||||
authenticated.claims().user_id().to_string(),
|
||||
&mut payload,
|
||||
)
|
||||
.await?;
|
||||
|
||||
let player_message = payload.player_message.trim().to_string();
|
||||
if player_message.is_empty() {
|
||||
return Err(runtime_plain_chat_error_response(
|
||||
&request_context,
|
||||
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
|
||||
"provider": "runtime-chat",
|
||||
"message": "playerMessage 不能为空",
|
||||
})),
|
||||
));
|
||||
}
|
||||
|
||||
let stream = stream_plain_text_response(
|
||||
state.llm_client().cloned(),
|
||||
state.config.rpg_llm_web_search_enabled,
|
||||
build_character_chat_reply_system_prompt(),
|
||||
build_character_chat_reply_user_prompt(CharacterChatPromptParams {
|
||||
world_type: payload.world_type.as_str(),
|
||||
player_character: &payload.player_character,
|
||||
target_character: &payload.target_character,
|
||||
story_history: &payload.story_history,
|
||||
context: &payload.context,
|
||||
conversation_history: &payload.conversation_history,
|
||||
conversation_summary: payload.conversation_summary.as_str(),
|
||||
previous_summary: payload.previous_summary.as_str(),
|
||||
player_message: payload.player_message.as_str(),
|
||||
target_status: &payload.target_status,
|
||||
}),
|
||||
build_character_chat_reply_fallback(
|
||||
&payload.target_character,
|
||||
payload.player_message.as_str(),
|
||||
payload.conversation_summary.as_str(),
|
||||
),
|
||||
);
|
||||
|
||||
Ok(Sse::new(stream).into_response())
|
||||
}
|
||||
|
||||
pub async fn stream_runtime_npc_chat_dialogue(
|
||||
State(state): State<AppState>,
|
||||
Extension(request_context): Extension<RequestContext>,
|
||||
Extension(authenticated): Extension<AuthenticatedAccessToken>,
|
||||
Json(mut payload): Json<RuntimeNpcDialogueRequest>,
|
||||
) -> Result<Response, Response> {
|
||||
hydrate_npc_dialogue_request_from_session(
|
||||
&state,
|
||||
&request_context,
|
||||
authenticated.claims().user_id().to_string(),
|
||||
&mut payload,
|
||||
)
|
||||
.await?;
|
||||
|
||||
let topic = payload.topic.trim().to_string();
|
||||
if topic.is_empty() {
|
||||
return Err(runtime_plain_chat_error_response(
|
||||
&request_context,
|
||||
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
|
||||
"provider": "runtime-chat",
|
||||
"message": "topic 不能为空",
|
||||
})),
|
||||
));
|
||||
}
|
||||
|
||||
let stream = stream_plain_text_response(
|
||||
state.llm_client().cloned(),
|
||||
state.config.rpg_llm_web_search_enabled,
|
||||
runtime_npc_dialogue_system_prompt(),
|
||||
{
|
||||
let npc_name = read_name_field(&payload.encounter, "npcName")
|
||||
.or_else(|| read_name_field(&payload.encounter, "name"))
|
||||
.unwrap_or_else(|| "对方".to_string());
|
||||
build_runtime_npc_dialogue_user_prompt(
|
||||
npc_name.as_str(),
|
||||
RuntimeNpcDialoguePromptParams {
|
||||
world_type: payload.world_type.as_str(),
|
||||
character: &payload.character,
|
||||
encounter: &payload.encounter,
|
||||
monsters: payload.monsters.clone(),
|
||||
history: payload.history.clone(),
|
||||
context: payload.context.clone(),
|
||||
topic: payload.topic.as_str(),
|
||||
result_summary: payload.result_summary.as_str(),
|
||||
requested_option: Value::Null,
|
||||
available_options: Vec::new(),
|
||||
},
|
||||
)
|
||||
},
|
||||
build_npc_chat_dialogue_fallback(&payload.encounter, payload.topic.as_str()),
|
||||
);
|
||||
|
||||
Ok(Sse::new(stream).into_response())
|
||||
}
|
||||
|
||||
pub async fn stream_runtime_npc_recruit_dialogue(
|
||||
State(state): State<AppState>,
|
||||
Extension(request_context): Extension<RequestContext>,
|
||||
Extension(authenticated): Extension<AuthenticatedAccessToken>,
|
||||
Json(mut payload): Json<RuntimeNpcRecruitDialogueRequest>,
|
||||
) -> Result<Response, Response> {
|
||||
hydrate_npc_recruit_request_from_session(
|
||||
&state,
|
||||
&request_context,
|
||||
authenticated.claims().user_id().to_string(),
|
||||
&mut payload,
|
||||
)
|
||||
.await?;
|
||||
|
||||
let invitation_text = payload.invitation_text.trim().to_string();
|
||||
if invitation_text.is_empty() {
|
||||
return Err(runtime_plain_chat_error_response(
|
||||
&request_context,
|
||||
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
|
||||
"provider": "runtime-chat",
|
||||
"message": "invitationText 不能为空",
|
||||
})),
|
||||
));
|
||||
}
|
||||
|
||||
let npc_name = read_name_field(&payload.encounter, "npcName")
|
||||
.or_else(|| read_name_field(&payload.encounter, "name"))
|
||||
.unwrap_or_else(|| "对方".to_string());
|
||||
let stream = stream_plain_text_response(
|
||||
state.llm_client().cloned(),
|
||||
state.config.rpg_llm_web_search_enabled,
|
||||
build_npc_recruit_dialogue_system_prompt(),
|
||||
build_npc_recruit_dialogue_user_prompt(
|
||||
npc_name.as_str(),
|
||||
NpcRecruitDialoguePromptParams {
|
||||
world_type: payload.world_type.as_str(),
|
||||
character: &payload.character,
|
||||
encounter: &payload.encounter,
|
||||
monsters: &payload.monsters,
|
||||
history: &payload.history,
|
||||
context: &payload.context,
|
||||
invitation_text: payload.invitation_text.as_str(),
|
||||
recruit_summary: payload.recruit_summary.as_str(),
|
||||
},
|
||||
),
|
||||
build_npc_recruit_dialogue_fallback(&payload.encounter),
|
||||
);
|
||||
|
||||
Ok(Sse::new(stream).into_response())
|
||||
}
|
||||
|
||||
async fn hydrate_character_chat_request_from_session(
|
||||
state: &AppState,
|
||||
request_context: &RequestContext,
|
||||
user_id: String,
|
||||
payload: &mut RuntimeCharacterChatRequest,
|
||||
) -> Result<(), Response> {
|
||||
let Some(game_state) = resolve_runtime_chat_game_state(
|
||||
state,
|
||||
request_context,
|
||||
user_id,
|
||||
payload.session_id.as_deref(),
|
||||
)
|
||||
.await?
|
||||
else {
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
payload.world_type = current_world_type(&game_state).unwrap_or_default();
|
||||
payload.player_character = read_field(&game_state, "playerCharacter")
|
||||
.cloned()
|
||||
.unwrap_or(Value::Null);
|
||||
payload.story_history = read_array_field(&game_state, "storyHistory")
|
||||
.into_iter()
|
||||
.rev()
|
||||
.take(12)
|
||||
.collect::<Vec<_>>()
|
||||
.into_iter()
|
||||
.rev()
|
||||
.cloned()
|
||||
.collect();
|
||||
payload.context =
|
||||
build_runtime_story_prompt_context(&game_state, RuntimeStoryPromptContextExtras::default());
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn hydrate_npc_dialogue_request_from_session(
|
||||
state: &AppState,
|
||||
request_context: &RequestContext,
|
||||
user_id: String,
|
||||
payload: &mut RuntimeNpcDialogueRequest,
|
||||
) -> Result<(), Response> {
|
||||
let Some(game_state) = resolve_runtime_chat_game_state(
|
||||
state,
|
||||
request_context,
|
||||
user_id,
|
||||
payload.session_id.as_deref(),
|
||||
)
|
||||
.await?
|
||||
else {
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
payload.world_type = current_world_type(&game_state).unwrap_or_default();
|
||||
payload.character = read_field(&game_state, "playerCharacter")
|
||||
.cloned()
|
||||
.unwrap_or(Value::Null);
|
||||
payload.encounter = read_field(&game_state, "currentEncounter")
|
||||
.cloned()
|
||||
.unwrap_or_else(|| payload.encounter.clone());
|
||||
payload.monsters = read_array_field(&game_state, "sceneHostileNpcs")
|
||||
.into_iter()
|
||||
.cloned()
|
||||
.collect();
|
||||
payload.history = read_array_field(&game_state, "storyHistory")
|
||||
.into_iter()
|
||||
.rev()
|
||||
.take(12)
|
||||
.collect::<Vec<_>>()
|
||||
.into_iter()
|
||||
.rev()
|
||||
.cloned()
|
||||
.collect();
|
||||
payload.context = build_runtime_story_prompt_context(
|
||||
&game_state,
|
||||
RuntimeStoryPromptContextExtras {
|
||||
last_function_id: Some("npc_chat".to_string()),
|
||||
..RuntimeStoryPromptContextExtras::default()
|
||||
},
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn hydrate_npc_recruit_request_from_session(
|
||||
state: &AppState,
|
||||
request_context: &RequestContext,
|
||||
user_id: String,
|
||||
payload: &mut RuntimeNpcRecruitDialogueRequest,
|
||||
) -> Result<(), Response> {
|
||||
let Some(game_state) = resolve_runtime_chat_game_state(
|
||||
state,
|
||||
request_context,
|
||||
user_id,
|
||||
payload.session_id.as_deref(),
|
||||
)
|
||||
.await?
|
||||
else {
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
payload.world_type = current_world_type(&game_state).unwrap_or_default();
|
||||
payload.character = read_field(&game_state, "playerCharacter")
|
||||
.cloned()
|
||||
.unwrap_or(Value::Null);
|
||||
payload.encounter = read_field(&game_state, "currentEncounter")
|
||||
.cloned()
|
||||
.unwrap_or_else(|| payload.encounter.clone());
|
||||
payload.monsters = read_array_field(&game_state, "sceneHostileNpcs")
|
||||
.into_iter()
|
||||
.cloned()
|
||||
.collect();
|
||||
payload.history = read_array_field(&game_state, "storyHistory")
|
||||
.into_iter()
|
||||
.rev()
|
||||
.take(12)
|
||||
.collect::<Vec<_>>()
|
||||
.into_iter()
|
||||
.rev()
|
||||
.cloned()
|
||||
.collect();
|
||||
payload.context = build_runtime_story_prompt_context(
|
||||
&game_state,
|
||||
RuntimeStoryPromptContextExtras {
|
||||
last_function_id: Some("npc_recruit".to_string()),
|
||||
..RuntimeStoryPromptContextExtras::default()
|
||||
},
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn resolve_runtime_chat_game_state(
|
||||
state: &AppState,
|
||||
request_context: &RequestContext,
|
||||
user_id: String,
|
||||
session_id: Option<&str>,
|
||||
) -> Result<Option<Value>, Response> {
|
||||
let Some(session_id) = session_id.and_then(normalize_required_string) else {
|
||||
// 中文注释:未携带 sessionId 的旧调用仅保留兼容,后续正式运行态应全部走后端快照。
|
||||
return Ok(None);
|
||||
};
|
||||
let record = state
|
||||
.get_runtime_snapshot_record(user_id)
|
||||
.await
|
||||
.map_err(|error| {
|
||||
runtime_plain_chat_error_response(
|
||||
request_context,
|
||||
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
|
||||
"provider": "spacetimedb",
|
||||
"message": error.to_string(),
|
||||
})),
|
||||
)
|
||||
})?
|
||||
.ok_or_else(|| {
|
||||
runtime_plain_chat_error_response(
|
||||
request_context,
|
||||
AppError::from_status(StatusCode::CONFLICT).with_details(json!({
|
||||
"provider": "runtime-chat",
|
||||
"message": "运行时快照不存在,请先初始化并保存一次游戏",
|
||||
})),
|
||||
)
|
||||
})?;
|
||||
let game_state = record.game_state;
|
||||
let snapshot_session_id =
|
||||
read_runtime_session_id(&game_state).unwrap_or_else(|| session_id.clone());
|
||||
if snapshot_session_id != session_id {
|
||||
return Err(runtime_plain_chat_error_response(
|
||||
request_context,
|
||||
AppError::from_status(StatusCode::CONFLICT).with_details(json!({
|
||||
"provider": "runtime-chat",
|
||||
"message": "请求的运行时会话与服务端快照不一致,请重新进入游戏",
|
||||
"sessionId": session_id,
|
||||
"snapshotSessionId": snapshot_session_id,
|
||||
})),
|
||||
));
|
||||
}
|
||||
|
||||
Ok(Some(game_state))
|
||||
}
|
||||
|
||||
async fn request_runtime_plain_text(
|
||||
state: &AppState,
|
||||
system_prompt: &'static str,
|
||||
user_prompt: String,
|
||||
fallback_text: Option<String>,
|
||||
) -> String {
|
||||
let Some(llm_client) = state.llm_client() else {
|
||||
return fallback_text.unwrap_or_default();
|
||||
};
|
||||
|
||||
let mut request = LlmTextRequest::new(vec![
|
||||
LlmMessage::system(system_prompt),
|
||||
LlmMessage::user(user_prompt),
|
||||
]);
|
||||
request.max_tokens = Some(400);
|
||||
request.enable_web_search = state.config.rpg_llm_web_search_enabled;
|
||||
|
||||
llm_client
|
||||
.request_text(request)
|
||||
.await
|
||||
.ok()
|
||||
.map(|response| response.content.trim().to_string())
|
||||
.filter(|text| !text.is_empty())
|
||||
.or(fallback_text)
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
fn stream_plain_text_response<'a>(
|
||||
llm_client: Option<platform_llm::LlmClient>,
|
||||
enable_web_search: bool,
|
||||
system_prompt: &'static str,
|
||||
user_prompt: String,
|
||||
fallback_text: String,
|
||||
) -> impl tokio_stream::Stream<Item = Result<Event, Infallible>> {
|
||||
async_stream::stream! {
|
||||
let Some(llm_client) = llm_client else {
|
||||
yield Ok::<Event, Infallible>(Event::default().data(runtime_plain_text_sse_payload(fallback_text.as_str())));
|
||||
yield Ok::<Event, Infallible>(Event::default().data("[DONE]"));
|
||||
return;
|
||||
};
|
||||
|
||||
let mut request = LlmTextRequest::new(vec![
|
||||
LlmMessage::system(system_prompt),
|
||||
LlmMessage::user(user_prompt),
|
||||
]);
|
||||
request.max_tokens = Some(700);
|
||||
request.enable_web_search = enable_web_search;
|
||||
|
||||
let response = llm_client
|
||||
.stream_text(request, |_| {})
|
||||
.await;
|
||||
|
||||
match response {
|
||||
Ok(response) => {
|
||||
let final_text = response.content.trim();
|
||||
let output = if final_text.is_empty() {
|
||||
fallback_text.as_str()
|
||||
} else {
|
||||
final_text
|
||||
};
|
||||
yield Ok::<Event, Infallible>(Event::default().data(runtime_plain_text_sse_payload(output)));
|
||||
}
|
||||
Err(_) => {
|
||||
yield Ok::<Event, Infallible>(Event::default().data(runtime_plain_text_sse_payload(fallback_text.as_str())));
|
||||
}
|
||||
}
|
||||
|
||||
yield Ok::<Event, Infallible>(Event::default().data("[DONE]"));
|
||||
}
|
||||
}
|
||||
|
||||
fn runtime_plain_text_sse_payload(text: &str) -> String {
|
||||
json!({
|
||||
"choices": [{
|
||||
"delta": {
|
||||
"content": text,
|
||||
}
|
||||
}]
|
||||
})
|
||||
.to_string()
|
||||
}
|
||||
|
||||
fn runtime_plain_chat_error_response(
|
||||
request_context: &RequestContext,
|
||||
error: AppError,
|
||||
) -> Response {
|
||||
error.into_response_with_context(Some(request_context))
|
||||
}
|
||||
|
||||
fn read_name_field(value: &Value, field: &str) -> Option<String> {
|
||||
value
|
||||
.get(field)
|
||||
.and_then(Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|text| !text.is_empty())
|
||||
.map(ToOwned::to_owned)
|
||||
}
|
||||
@@ -4,12 +4,12 @@ use axum::{
|
||||
http::StatusCode,
|
||||
response::Response,
|
||||
};
|
||||
use module_runtime::{SAVE_SNAPSHOT_VERSION, format_utc_micros};
|
||||
use module_runtime::format_utc_micros;
|
||||
use serde::Deserialize;
|
||||
use serde_json::{Value, json};
|
||||
use shared_contracts::runtime::{
|
||||
BasicOkResponse, ProfileSaveArchiveListResponse, ProfileSaveArchiveResumeResponse,
|
||||
ProfileSaveArchiveSummaryResponse, PutSavedGameSnapshotRequest, SavedGameSnapshotResponse,
|
||||
ProfileSaveArchiveSummaryResponse, PutRuntimeSaveCheckpointRequest, SavedGameSnapshotResponse,
|
||||
};
|
||||
use shared_kernel::{offset_datetime_to_unix_micros, parse_rfc3339};
|
||||
use spacetime_client::SpacetimeClientError;
|
||||
@@ -49,9 +49,29 @@ pub async fn put_runtime_snapshot(
|
||||
State(state): State<AppState>,
|
||||
Extension(request_context): Extension<RequestContext>,
|
||||
Extension(authenticated): Extension<AuthenticatedAccessToken>,
|
||||
Json(payload): Json<PutSavedGameSnapshotRequest>,
|
||||
Json(payload): Json<PutRuntimeSaveCheckpointRequest>,
|
||||
) -> Result<Json<Value>, Response> {
|
||||
let user_id = authenticated.claims().user_id().to_string();
|
||||
let session_id = normalize_required_string(payload.session_id.as_str()).ok_or_else(|| {
|
||||
runtime_save_error_response(
|
||||
&request_context,
|
||||
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
|
||||
"provider": "runtime-save",
|
||||
"field": "sessionId",
|
||||
"message": "sessionId 不能为空",
|
||||
})),
|
||||
)
|
||||
})?;
|
||||
let bottom_tab = normalize_required_string(payload.bottom_tab.as_str()).ok_or_else(|| {
|
||||
runtime_save_error_response(
|
||||
&request_context,
|
||||
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
|
||||
"provider": "runtime-save",
|
||||
"field": "bottomTab",
|
||||
"message": "bottomTab 不能为空",
|
||||
})),
|
||||
)
|
||||
})?;
|
||||
let now = OffsetDateTime::now_utc();
|
||||
let saved_at = payload
|
||||
.saved_at
|
||||
@@ -71,30 +91,37 @@ pub async fn put_runtime_snapshot(
|
||||
let updated_at_micros = offset_datetime_to_unix_micros(now);
|
||||
let saved_at_micros = offset_datetime_to_unix_micros(saved_at);
|
||||
|
||||
let record = if is_non_persistent_runtime_snapshot(&payload.game_state) {
|
||||
build_transient_runtime_snapshot_record(
|
||||
let existing = state
|
||||
.get_runtime_snapshot_record(user_id.clone())
|
||||
.await
|
||||
.map_err(|error| {
|
||||
runtime_save_error_response(&request_context, map_runtime_save_client_error(error))
|
||||
})?
|
||||
.ok_or_else(|| {
|
||||
runtime_save_error_response(
|
||||
&request_context,
|
||||
AppError::from_status(StatusCode::CONFLICT).with_details(json!({
|
||||
"provider": "runtime-save",
|
||||
"message": "运行时快照不存在,无法创建后端 checkpoint",
|
||||
})),
|
||||
)
|
||||
})?;
|
||||
|
||||
validate_checkpoint_snapshot(&request_context, &session_id, &existing.game_state)?;
|
||||
let game_state = sync_runtime_snapshot_play_time(existing.game_state, updated_at_micros);
|
||||
let record = state
|
||||
.put_runtime_snapshot_record(
|
||||
user_id,
|
||||
saved_at_micros,
|
||||
payload.bottom_tab,
|
||||
payload.game_state,
|
||||
payload.current_story,
|
||||
bottom_tab,
|
||||
game_state,
|
||||
existing.current_story,
|
||||
updated_at_micros,
|
||||
)
|
||||
} else {
|
||||
state
|
||||
.put_runtime_snapshot_record(
|
||||
user_id,
|
||||
saved_at_micros,
|
||||
payload.bottom_tab,
|
||||
payload.game_state,
|
||||
payload.current_story,
|
||||
updated_at_micros,
|
||||
)
|
||||
.await
|
||||
.map_err(|error| {
|
||||
runtime_save_error_response(&request_context, map_runtime_save_client_error(error))
|
||||
})?
|
||||
};
|
||||
.await
|
||||
.map_err(|error| {
|
||||
runtime_save_error_response(&request_context, map_runtime_save_client_error(error))
|
||||
})?;
|
||||
|
||||
Ok(json_success_body(
|
||||
Some(&request_context),
|
||||
@@ -196,30 +223,6 @@ fn build_saved_game_snapshot_response(
|
||||
}
|
||||
}
|
||||
|
||||
fn build_transient_runtime_snapshot_record(
|
||||
user_id: String,
|
||||
saved_at_micros: i64,
|
||||
bottom_tab: String,
|
||||
game_state: Value,
|
||||
current_story: Option<Value>,
|
||||
updated_at_micros: i64,
|
||||
) -> module_runtime::RuntimeSnapshotRecord {
|
||||
// 中文注释:预览/测试入口可得到本次响应,但不能覆盖用户正式当前快照。
|
||||
module_runtime::RuntimeSnapshotRecord {
|
||||
user_id,
|
||||
version: SAVE_SNAPSHOT_VERSION,
|
||||
saved_at: format_utc_micros(saved_at_micros),
|
||||
saved_at_micros,
|
||||
bottom_tab,
|
||||
game_state_json: game_state.to_string(),
|
||||
current_story_json: current_story.as_ref().map(Value::to_string),
|
||||
game_state,
|
||||
current_story,
|
||||
created_at_micros: updated_at_micros,
|
||||
updated_at_micros,
|
||||
}
|
||||
}
|
||||
|
||||
fn is_non_persistent_runtime_snapshot(game_state: &Value) -> bool {
|
||||
let Some(game_state) = game_state.as_object() else {
|
||||
return false;
|
||||
@@ -242,6 +245,110 @@ fn is_non_persistent_runtime_snapshot(game_state: &Value) -> bool {
|
||||
)
|
||||
}
|
||||
|
||||
fn validate_checkpoint_snapshot(
|
||||
request_context: &RequestContext,
|
||||
session_id: &str,
|
||||
game_state: &Value,
|
||||
) -> Result<(), Response> {
|
||||
if is_non_persistent_runtime_snapshot(game_state) {
|
||||
return Err(runtime_save_error_response(
|
||||
request_context,
|
||||
AppError::from_status(StatusCode::CONFLICT).with_details(json!({
|
||||
"provider": "runtime-save",
|
||||
"message": "预览或测试运行态不能创建正式 checkpoint",
|
||||
})),
|
||||
));
|
||||
}
|
||||
|
||||
let persisted_session_id =
|
||||
read_string_field(game_state, "runtimeSessionId").ok_or_else(|| {
|
||||
runtime_save_error_response(
|
||||
request_context,
|
||||
AppError::from_status(StatusCode::CONFLICT).with_details(json!({
|
||||
"provider": "runtime-save",
|
||||
"message": "服务端运行时快照缺少 runtimeSessionId,无法创建 checkpoint",
|
||||
})),
|
||||
)
|
||||
})?;
|
||||
|
||||
if persisted_session_id != session_id {
|
||||
return Err(runtime_save_error_response(
|
||||
request_context,
|
||||
AppError::from_status(StatusCode::CONFLICT).with_details(json!({
|
||||
"provider": "runtime-save",
|
||||
"message": "checkpoint sessionId 与服务端运行时快照不一致",
|
||||
"expectedSessionId": persisted_session_id,
|
||||
"actualSessionId": session_id,
|
||||
})),
|
||||
));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn sync_runtime_snapshot_play_time(mut game_state: Value, now_micros: i64) -> Value {
|
||||
let Some(game_state_object) = game_state.as_object_mut() else {
|
||||
return game_state;
|
||||
};
|
||||
let now_text = format_utc_micros(now_micros);
|
||||
let Some(runtime_stats) = game_state_object
|
||||
.get_mut("runtimeStats")
|
||||
.and_then(Value::as_object_mut)
|
||||
else {
|
||||
game_state_object.insert(
|
||||
"runtimeStats".to_string(),
|
||||
json!({
|
||||
"playTimeMs": 0,
|
||||
"lastPlayTickAt": now_text,
|
||||
"hostileNpcsDefeated": 0,
|
||||
"questsAccepted": 0,
|
||||
"itemsUsed": 0,
|
||||
"scenesTraveled": 0,
|
||||
}),
|
||||
);
|
||||
return game_state;
|
||||
};
|
||||
|
||||
let current_play_time = runtime_stats
|
||||
.get("playTimeMs")
|
||||
.and_then(Value::as_f64)
|
||||
.filter(|value| value.is_finite() && *value >= 0.0)
|
||||
.unwrap_or(0.0);
|
||||
let elapsed_ms = runtime_stats
|
||||
.get("lastPlayTickAt")
|
||||
.and_then(Value::as_str)
|
||||
.and_then(|last_tick| parse_rfc3339(last_tick).ok())
|
||||
.map(offset_datetime_to_unix_micros)
|
||||
.map(|last_tick_micros| now_micros.saturating_sub(last_tick_micros).max(0) as f64 / 1000.0)
|
||||
.unwrap_or(0.0);
|
||||
let next_play_time = (current_play_time + elapsed_ms).floor().max(0.0);
|
||||
|
||||
// 中文注释:checkpoint 只刷新服务端已有 runtimeStats 的时间水位,
|
||||
// 不从浏览器接收任何任务、背包、战斗或剧情状态。
|
||||
runtime_stats.insert("playTimeMs".to_string(), Value::from(next_play_time as i64));
|
||||
runtime_stats.insert("lastPlayTickAt".to_string(), Value::String(now_text));
|
||||
game_state
|
||||
}
|
||||
|
||||
fn read_string_field(value: &Value, field: &str) -> Option<String> {
|
||||
value
|
||||
.as_object()?
|
||||
.get(field)?
|
||||
.as_str()
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(ToOwned::to_owned)
|
||||
}
|
||||
|
||||
fn normalize_required_string(value: &str) -> Option<String> {
|
||||
let normalized = value.trim();
|
||||
if normalized.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(normalized.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
fn build_profile_save_archive_summary_response(
|
||||
record: &module_runtime::RuntimeProfileSaveArchiveRecord,
|
||||
) -> ProfileSaveArchiveSummaryResponse {
|
||||
@@ -302,7 +409,7 @@ mod tests {
|
||||
use platform_auth::{
|
||||
AccessTokenClaims, AccessTokenClaimsInput, AuthProvider, BindingStatus, sign_access_token,
|
||||
};
|
||||
use serde_json::Value;
|
||||
use serde_json::{Value, json};
|
||||
use time::OffsetDateTime;
|
||||
use tower::ServiceExt;
|
||||
|
||||
@@ -325,6 +432,151 @@ mod tests {
|
||||
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn runtime_snapshot_checkpoint_rejects_legacy_full_snapshot_upload() {
|
||||
let state = seed_authenticated_state().await;
|
||||
let token = issue_access_token(&state);
|
||||
let app = build_router(state);
|
||||
|
||||
let response = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("PUT")
|
||||
.uri("/api/runtime/save/snapshot")
|
||||
.header("authorization", format!("Bearer {token}"))
|
||||
.header("content-type", "application/json")
|
||||
.body(Body::from(
|
||||
json!({
|
||||
"sessionId": "runtime-main",
|
||||
"bottomTab": "adventure",
|
||||
"gameState": {
|
||||
"runtimeSessionId": "runtime-main"
|
||||
},
|
||||
"currentStory": null
|
||||
})
|
||||
.to_string(),
|
||||
))
|
||||
.expect("request should build"),
|
||||
)
|
||||
.await
|
||||
.expect("request should succeed");
|
||||
|
||||
assert_eq!(response.status(), StatusCode::UNPROCESSABLE_ENTITY);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn runtime_snapshot_checkpoint_requires_existing_server_snapshot() {
|
||||
let state = seed_authenticated_state().await;
|
||||
let token = issue_access_token(&state);
|
||||
let app = build_router(state);
|
||||
|
||||
let response = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("PUT")
|
||||
.uri("/api/runtime/save/snapshot")
|
||||
.header("authorization", format!("Bearer {token}"))
|
||||
.header("content-type", "application/json")
|
||||
.body(Body::from(
|
||||
json!({
|
||||
"sessionId": "runtime-main",
|
||||
"bottomTab": "adventure"
|
||||
})
|
||||
.to_string(),
|
||||
))
|
||||
.expect("request should build"),
|
||||
)
|
||||
.await
|
||||
.expect("request should succeed");
|
||||
|
||||
assert_eq!(response.status(), StatusCode::CONFLICT);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn runtime_snapshot_checkpoint_rejects_session_mismatch() {
|
||||
let state = seed_authenticated_state().await;
|
||||
seed_runtime_snapshot(&state, "runtime-server", "adventure").await;
|
||||
let token = issue_access_token(&state);
|
||||
let app = build_router(state);
|
||||
|
||||
let response = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("PUT")
|
||||
.uri("/api/runtime/save/snapshot")
|
||||
.header("authorization", format!("Bearer {token}"))
|
||||
.header("content-type", "application/json")
|
||||
.body(Body::from(
|
||||
json!({
|
||||
"sessionId": "runtime-client",
|
||||
"bottomTab": "inventory"
|
||||
})
|
||||
.to_string(),
|
||||
))
|
||||
.expect("request should build"),
|
||||
)
|
||||
.await
|
||||
.expect("request should succeed");
|
||||
|
||||
assert_eq!(response.status(), StatusCode::CONFLICT);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn runtime_snapshot_checkpoint_uses_persisted_server_snapshot() {
|
||||
let state = seed_authenticated_state().await;
|
||||
seed_runtime_snapshot(&state, "runtime-main", "adventure").await;
|
||||
let token = issue_access_token(&state);
|
||||
let app = build_router(state);
|
||||
|
||||
let response = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("PUT")
|
||||
.uri("/api/runtime/save/snapshot")
|
||||
.header("authorization", format!("Bearer {token}"))
|
||||
.header("x-genarrative-response-envelope", "v1")
|
||||
.header("content-type", "application/json")
|
||||
.body(Body::from(
|
||||
json!({
|
||||
"sessionId": "runtime-main",
|
||||
"bottomTab": "inventory"
|
||||
})
|
||||
.to_string(),
|
||||
))
|
||||
.expect("request should build"),
|
||||
)
|
||||
.await
|
||||
.expect("request should succeed");
|
||||
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
let payload: Value = serde_json::from_slice(
|
||||
&response
|
||||
.into_body()
|
||||
.collect()
|
||||
.await
|
||||
.expect("body should collect")
|
||||
.to_bytes(),
|
||||
)
|
||||
.expect("response body should be valid json");
|
||||
|
||||
assert_eq!(payload["data"]["bottomTab"], json!("inventory"));
|
||||
assert_eq!(
|
||||
payload["data"]["gameState"]["runtimeSessionId"],
|
||||
json!("runtime-main")
|
||||
);
|
||||
assert_eq!(
|
||||
payload["data"]["gameState"]["serverOnlyField"],
|
||||
json!("persisted")
|
||||
);
|
||||
assert_eq!(payload["data"]["currentStory"]["text"], json!("服务端故事"));
|
||||
assert!(
|
||||
payload["data"]["gameState"]["runtimeStats"]["playTimeMs"]
|
||||
.as_i64()
|
||||
.unwrap_or_default()
|
||||
>= 2000
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn profile_save_archives_requires_authentication() {
|
||||
let app = build_router(AppState::new(AppConfig::default()).expect("state should build"));
|
||||
@@ -444,6 +696,39 @@ mod tests {
|
||||
state
|
||||
}
|
||||
|
||||
async fn seed_runtime_snapshot(state: &AppState, session_id: &str, bottom_tab: &str) {
|
||||
let now = OffsetDateTime::now_utc();
|
||||
let now_micros = shared_kernel::offset_datetime_to_unix_micros(now);
|
||||
state
|
||||
.put_runtime_snapshot_record(
|
||||
"user_00000001".to_string(),
|
||||
now_micros - 2_000_000,
|
||||
bottom_tab.to_string(),
|
||||
json!({
|
||||
"runtimeSessionId": session_id,
|
||||
"runtimeMode": "play",
|
||||
"runtimePersistenceDisabled": false,
|
||||
"currentScene": "Story",
|
||||
"serverOnlyField": "persisted",
|
||||
"runtimeStats": {
|
||||
"playTimeMs": 0,
|
||||
"lastPlayTickAt": module_runtime::format_utc_micros(now_micros - 2_000_000),
|
||||
"hostileNpcsDefeated": 0,
|
||||
"questsAccepted": 0,
|
||||
"itemsUsed": 0,
|
||||
"scenesTraveled": 0
|
||||
}
|
||||
}),
|
||||
Some(json!({
|
||||
"text": "服务端故事",
|
||||
"options": []
|
||||
})),
|
||||
now_micros - 2_000_000,
|
||||
)
|
||||
.await
|
||||
.expect("runtime snapshot should seed");
|
||||
}
|
||||
|
||||
fn issue_access_token(state: &AppState) -> String {
|
||||
let claims = AccessTokenClaims::from_input(
|
||||
AccessTokenClaimsInput {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
mod compat;
|
||||
|
||||
pub use compat::{
|
||||
generate_runtime_story_continue, generate_runtime_story_initial, get_runtime_story_state,
|
||||
resolve_runtime_story_action, resolve_runtime_story_state,
|
||||
begin_runtime_story_session, generate_runtime_story_continue, generate_runtime_story_initial,
|
||||
get_runtime_story_state, resolve_runtime_story_action, resolve_runtime_story_state,
|
||||
};
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
1101
server-rs/crates/api-server/src/runtime_story/compat/bootstrap.rs
Normal file
1101
server-rs/crates/api-server/src/runtime_story/compat/bootstrap.rs
Normal file
File diff suppressed because it is too large
Load Diff
@@ -110,11 +110,23 @@ pub(super) fn resolve_npc_battle_entry_action(
|
||||
} else {
|
||||
"fight"
|
||||
};
|
||||
let return_encounter = read_object_field(game_state, "currentEncounter").cloned();
|
||||
let resolved_formation =
|
||||
resolve_npc_battle_formation(game_state, return_encounter.as_ref(), battle_mode);
|
||||
|
||||
write_bool_field(game_state, "inBattle", true);
|
||||
write_bool_field(game_state, "npcInteractionActive", false);
|
||||
write_string_field(game_state, "currentBattleNpcId", npc_id.as_str());
|
||||
write_string_field(game_state, "currentNpcBattleMode", battle_mode);
|
||||
write_null_field(game_state, "currentNpcBattleOutcome");
|
||||
write_null_field(game_state, "currentEncounter");
|
||||
ensure_json_object(game_state).insert(
|
||||
"sceneHostileNpcs".to_string(),
|
||||
Value::Array(resolved_formation),
|
||||
);
|
||||
if let Some(return_encounter) = return_encounter {
|
||||
ensure_json_object(game_state).insert("sparReturnEncounter".to_string(), return_encounter);
|
||||
}
|
||||
|
||||
Ok(StoryResolution {
|
||||
action_text: resolve_action_text(
|
||||
@@ -144,6 +156,117 @@ pub(super) fn resolve_npc_battle_entry_action(
|
||||
})
|
||||
}
|
||||
|
||||
fn resolve_npc_battle_formation(
|
||||
game_state: &Value,
|
||||
encounter: Option<&Value>,
|
||||
battle_mode: &str,
|
||||
) -> Vec<Value> {
|
||||
let visible_formation = read_array_field(game_state, "sceneHostileNpcs")
|
||||
.into_iter()
|
||||
.cloned()
|
||||
.collect::<Vec<_>>();
|
||||
if !visible_formation.is_empty() {
|
||||
return visible_formation
|
||||
.into_iter()
|
||||
.map(|monster| normalize_npc_battle_monster(monster, battle_mode))
|
||||
.collect();
|
||||
}
|
||||
|
||||
encounter
|
||||
.map(|encounter| {
|
||||
vec![build_npc_battle_monster_from_encounter(
|
||||
game_state,
|
||||
encounter,
|
||||
battle_mode,
|
||||
3.2,
|
||||
0,
|
||||
)]
|
||||
})
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
fn normalize_npc_battle_monster(mut monster: Value, battle_mode: &str) -> Value {
|
||||
let Some(monster_object) = monster.as_object_mut() else {
|
||||
return monster;
|
||||
};
|
||||
monster_object
|
||||
.entry("animation".to_string())
|
||||
.or_insert_with(|| Value::String("idle".to_string()));
|
||||
monster_object
|
||||
.entry("facing".to_string())
|
||||
.or_insert_with(|| Value::String("left".to_string()));
|
||||
monster_object
|
||||
.entry("renderKind".to_string())
|
||||
.or_insert_with(|| Value::String("npc".to_string()));
|
||||
monster_object
|
||||
.entry("attackRange".to_string())
|
||||
.or_insert_with(|| json!(1.8));
|
||||
monster_object
|
||||
.entry("speed".to_string())
|
||||
.or_insert_with(|| json!(7));
|
||||
let max_hp = monster_object
|
||||
.get("maxHp")
|
||||
.and_then(Value::as_i64)
|
||||
.unwrap_or_else(|| if battle_mode == "spar" { 10 } else { 80 });
|
||||
monster_object
|
||||
.entry("hp".to_string())
|
||||
.or_insert_with(|| json!(max_hp));
|
||||
monster
|
||||
}
|
||||
|
||||
fn build_npc_battle_monster_from_encounter(
|
||||
game_state: &Value,
|
||||
encounter: &Value,
|
||||
battle_mode: &str,
|
||||
x_meters: f64,
|
||||
y_offset: i32,
|
||||
) -> Value {
|
||||
let npc_id = read_optional_string_field(encounter, "id")
|
||||
.unwrap_or_else(|| current_encounter_name(game_state));
|
||||
let npc_name = current_encounter_name(game_state);
|
||||
let npc_state =
|
||||
resolve_current_encounter_npc_state(game_state, npc_id.as_str(), npc_name.as_str());
|
||||
let affinity = npc_state
|
||||
.and_then(|state| read_i32_field(state, "affinity"))
|
||||
.or_else(|| read_i32_field(encounter, "initialAffinity"))
|
||||
.unwrap_or(0);
|
||||
let base_hp = if battle_mode == "spar" {
|
||||
10
|
||||
} else {
|
||||
(80 + affinity).max(24)
|
||||
};
|
||||
let monster_id = read_optional_string_field(encounter, "monsterPresetId")
|
||||
.unwrap_or_else(|| format!("npc-opponent-{npc_id}"));
|
||||
let mut battle_encounter = encounter.clone();
|
||||
if let Some(entry) = battle_encounter.as_object_mut() {
|
||||
entry.insert("hostile".to_string(), Value::Bool(true));
|
||||
entry.insert("xMeters".to_string(), json!(x_meters));
|
||||
}
|
||||
|
||||
json!({
|
||||
"id": monster_id,
|
||||
"name": npc_name,
|
||||
"action": if battle_mode == "spar" {
|
||||
"抱拳行礼,准备点到为止地切磋武艺"
|
||||
} else {
|
||||
"摆开架势,随时准备出手"
|
||||
},
|
||||
"description": read_optional_string_field(encounter, "npcDescription").unwrap_or_default(),
|
||||
"animation": "idle",
|
||||
"xMeters": x_meters,
|
||||
"yOffset": y_offset,
|
||||
"facing": "left",
|
||||
"attackRange": 1.8,
|
||||
"speed": 7,
|
||||
"hp": base_hp,
|
||||
"maxHp": base_hp,
|
||||
"renderKind": "npc",
|
||||
"levelProfile": read_field(encounter, "levelProfile").cloned(),
|
||||
"experienceReward": read_i32_field(encounter, "experienceReward").unwrap_or(0),
|
||||
"encounter": battle_encounter
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) fn resolve_npc_recruit_action(
|
||||
game_state: &mut Value,
|
||||
request: &RuntimeStoryActionRequest,
|
||||
@@ -232,8 +355,10 @@ pub(super) fn resolve_npc_trade_action(
|
||||
.ok_or_else(|| "npc_trade 缺少 itemId".to_string())?;
|
||||
let quantity = payload
|
||||
.and_then(|value| read_i32_field(value, "quantity"))
|
||||
.unwrap_or(1)
|
||||
.max(1);
|
||||
.unwrap_or(1);
|
||||
if quantity <= 0 {
|
||||
return Err("npc_trade.quantity 必须大于 0".to_string());
|
||||
}
|
||||
|
||||
if mode == "buy" {
|
||||
let npc_item = read_current_npc_inventory_item(game_state, item_id.as_str())
|
||||
|
||||
@@ -6,6 +6,7 @@ pub(super) fn build_runtime_story_state_response(
|
||||
mut snapshot: RuntimeStorySnapshotPayload,
|
||||
) -> RuntimeStoryActionResponse {
|
||||
ensure_runtime_story_bridge_state(&mut snapshot.game_state);
|
||||
write_runtime_npc_interaction_view(&mut snapshot.game_state);
|
||||
let session_id = read_runtime_session_id(&snapshot.game_state)
|
||||
.unwrap_or_else(|| requested_session_id.to_string());
|
||||
let options =
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
4
server-rs/crates/module-ai/src/application.rs
Normal file
4
server-rs/crates/module-ai/src/application.rs
Normal file
@@ -0,0 +1,4 @@
|
||||
//! AI 应用编排过渡落位。
|
||||
//!
|
||||
//! 这里仅返回纯应用结果或领域事件;真实 LLM 调用继续留在 `platform-llm`
|
||||
//! 与 `api-server` 编排层。
|
||||
4
server-rs/crates/module-ai/src/commands.rs
Normal file
4
server-rs/crates/module-ai/src/commands.rs
Normal file
@@ -0,0 +1,4 @@
|
||||
//! AI 写入命令过渡落位。
|
||||
//!
|
||||
//! 只描述创建任务、推进阶段、追加文本片段和挂接结果引用等用例输入,
|
||||
//! 不承载外部模型请求或持久化细节。
|
||||
4
server-rs/crates/module-ai/src/domain.rs
Normal file
4
server-rs/crates/module-ai/src/domain.rs
Normal file
@@ -0,0 +1,4 @@
|
||||
//! AI 领域模型过渡落位。
|
||||
//!
|
||||
//! 当前历史实现仍在 `lib.rs`。后续迁移 `AiTask`、阶段、流式片段和结果引用时,
|
||||
//! 只能放入纯领域类型与状态迁移,不能引入 LLM、HTTP 或 SpacetimeDB adapter。
|
||||
3
server-rs/crates/module-ai/src/errors.rs
Normal file
3
server-rs/crates/module-ai/src/errors.rs
Normal file
@@ -0,0 +1,3 @@
|
||||
//! AI 领域错误过渡落位。
|
||||
//!
|
||||
//! 错误必须可被 HTTP adapter 和 SpacetimeDB adapter 显式映射,不能直接绑定状态码。
|
||||
3
server-rs/crates/module-ai/src/events.rs
Normal file
3
server-rs/crates/module-ai/src/events.rs
Normal file
@@ -0,0 +1,3 @@
|
||||
//! AI 领域事件过渡落位。
|
||||
//!
|
||||
//! 用于表达任务开始、阶段完成、任务失败和结果引用挂接等跨上下文事实。
|
||||
@@ -1,3 +1,9 @@
|
||||
mod application;
|
||||
mod commands;
|
||||
mod domain;
|
||||
mod errors;
|
||||
mod events;
|
||||
|
||||
use std::{
|
||||
collections::HashMap,
|
||||
error::Error,
|
||||
|
||||
8
server-rs/crates/module-assets/src/application.rs
Normal file
8
server-rs/crates/module-assets/src/application.rs
Normal file
@@ -0,0 +1,8 @@
|
||||
//! 资产应用编排落位。
|
||||
//!
|
||||
//! 这里只组合纯校验与应用结果;对象探测、签名和持久化由 adapter 层完成。
|
||||
|
||||
pub use crate::asset_object_core::{
|
||||
AssetEntityBindingProcedureResult, AssetHistoryListResult, AssetObjectProcedureResult,
|
||||
ConfirmAssetObjectResult, build_asset_entity_binding_input, build_asset_object_upsert_input,
|
||||
};
|
||||
7
server-rs/crates/module-assets/src/commands.rs
Normal file
7
server-rs/crates/module-assets/src/commands.rs
Normal file
@@ -0,0 +1,7 @@
|
||||
//! 资产写入命令落位。
|
||||
//!
|
||||
//! 用于表达确认资产对象、绑定实体槽位和查询资产历史的输入,不直接访问 OSS。
|
||||
|
||||
pub use crate::asset_object_core::{
|
||||
AssetEntityBindingInput, AssetHistoryListInput, AssetObjectUpsertInput, ConfirmAssetObjectInput,
|
||||
};
|
||||
15
server-rs/crates/module-assets/src/domain.rs
Normal file
15
server-rs/crates/module-assets/src/domain.rs
Normal file
@@ -0,0 +1,15 @@
|
||||
//! 资产领域模型落位。
|
||||
//!
|
||||
//! 当前先通过本文件承接对外领域 API 分层导出,旧实现仍留在
|
||||
//! `asset_object_core.rs` 内部文件中,后续再逐段搬入本文件或 `domain/` 子目录。
|
||||
//! 本层只允许保留资产对象、实体绑定、访问策略、版本和业务归属等纯规则。
|
||||
|
||||
pub use crate::asset_object_core::{
|
||||
ASSET_BINDING_ID_PREFIX, ASSET_OBJECT_ID_PREFIX, AssetEntityBindingRecord,
|
||||
AssetEntityBindingSnapshot, AssetHistoryEntryRecord, AssetHistoryEntrySnapshot,
|
||||
AssetObjectAccessPolicy, AssetObjectRecord, AssetObjectUpsertSnapshot,
|
||||
INITIAL_ASSET_OBJECT_VERSION, build_asset_entity_binding_record,
|
||||
build_asset_history_entry_record, build_asset_object_record, generate_asset_binding_id,
|
||||
generate_asset_object_id, normalize_optional_value, validate_asset_entity_binding_fields,
|
||||
validate_asset_object_fields,
|
||||
};
|
||||
5
server-rs/crates/module-assets/src/errors.rs
Normal file
5
server-rs/crates/module-assets/src/errors.rs
Normal file
@@ -0,0 +1,5 @@
|
||||
//! 资产领域错误落位。
|
||||
//!
|
||||
//! 字段错误和业务错误在这里收口,HTTP 状态码与 SpacetimeDB 字符串错误由 adapter 映射。
|
||||
|
||||
pub use crate::asset_object_core::AssetObjectFieldError;
|
||||
4
server-rs/crates/module-assets/src/events.rs
Normal file
4
server-rs/crates/module-assets/src/events.rs
Normal file
@@ -0,0 +1,4 @@
|
||||
//! 资产领域事件过渡落位。
|
||||
//!
|
||||
//! 用于表达资产已确认、绑定已变更和资产历史投影待刷新等事实。
|
||||
//! 当前阶段暂不新增事件类型,避免在 SpacetimeDB 表未补 event table 前扩散未消费 API。
|
||||
@@ -1,20 +1,31 @@
|
||||
mod application;
|
||||
mod commands;
|
||||
mod domain;
|
||||
mod errors;
|
||||
mod events;
|
||||
|
||||
mod asset_object_core;
|
||||
#[cfg(feature = "server-service")]
|
||||
mod asset_object_service;
|
||||
|
||||
pub use asset_object_core::{
|
||||
ASSET_BINDING_ID_PREFIX, ASSET_OBJECT_ID_PREFIX, AssetEntityBindingInput,
|
||||
AssetEntityBindingProcedureResult, AssetEntityBindingRecord, AssetEntityBindingSnapshot,
|
||||
AssetHistoryEntryRecord, AssetHistoryEntrySnapshot, AssetHistoryListInput,
|
||||
AssetHistoryListResult, AssetObjectAccessPolicy, AssetObjectFieldError,
|
||||
AssetObjectProcedureResult, AssetObjectRecord, AssetObjectUpsertInput,
|
||||
AssetObjectUpsertSnapshot, ConfirmAssetObjectInput, ConfirmAssetObjectResult,
|
||||
INITIAL_ASSET_OBJECT_VERSION, build_asset_entity_binding_input,
|
||||
build_asset_entity_binding_record, build_asset_history_entry_record, build_asset_object_record,
|
||||
build_asset_object_upsert_input, generate_asset_binding_id, generate_asset_object_id,
|
||||
normalize_optional_value, validate_asset_entity_binding_fields, validate_asset_object_fields,
|
||||
pub use application::{
|
||||
AssetEntityBindingProcedureResult, AssetHistoryListResult, AssetObjectProcedureResult,
|
||||
ConfirmAssetObjectResult, build_asset_entity_binding_input, build_asset_object_upsert_input,
|
||||
};
|
||||
#[cfg(feature = "server-service")]
|
||||
pub use asset_object_service::{
|
||||
AssetObjectService, ConfirmAssetObjectError, InMemoryAssetObjectStore,
|
||||
};
|
||||
pub use commands::{
|
||||
AssetEntityBindingInput, AssetHistoryListInput, AssetObjectUpsertInput, ConfirmAssetObjectInput,
|
||||
};
|
||||
pub use domain::{
|
||||
ASSET_BINDING_ID_PREFIX, ASSET_OBJECT_ID_PREFIX, AssetEntityBindingRecord,
|
||||
AssetEntityBindingSnapshot, AssetHistoryEntryRecord, AssetHistoryEntrySnapshot,
|
||||
AssetObjectAccessPolicy, AssetObjectRecord, AssetObjectUpsertSnapshot,
|
||||
INITIAL_ASSET_OBJECT_VERSION, build_asset_entity_binding_record,
|
||||
build_asset_history_entry_record, build_asset_object_record, generate_asset_binding_id,
|
||||
generate_asset_object_id, normalize_optional_value, validate_asset_entity_binding_fields,
|
||||
validate_asset_object_fields,
|
||||
};
|
||||
pub use errors::AssetObjectFieldError;
|
||||
|
||||
3
server-rs/crates/module-auth/src/application.rs
Normal file
3
server-rs/crates/module-auth/src/application.rs
Normal file
@@ -0,0 +1,3 @@
|
||||
//! 认证应用编排过渡落位。
|
||||
//!
|
||||
//! 这里只返回纯应用结果与领域事件;短信 provider、JWT 签发和持久化由外层 adapter 完成。
|
||||
3
server-rs/crates/module-auth/src/commands.rs
Normal file
3
server-rs/crates/module-auth/src/commands.rs
Normal file
@@ -0,0 +1,3 @@
|
||||
//! 认证写入命令过渡落位。
|
||||
//!
|
||||
//! 用于表达密码入口、手机号验证码、微信登录、刷新会话签发和吊销等用例输入。
|
||||
4
server-rs/crates/module-auth/src/domain.rs
Normal file
4
server-rs/crates/module-auth/src/domain.rs
Normal file
@@ -0,0 +1,4 @@
|
||||
//! 认证领域模型过渡落位。
|
||||
//!
|
||||
//! 后续迁移账号、刷新会话、验证码和微信绑定聚合时,只保留认证规则;
|
||||
//! 文件持久化、真实短信发送、cookie 写入和 HTTP 上下文都不属于领域核心。
|
||||
3
server-rs/crates/module-auth/src/errors.rs
Normal file
3
server-rs/crates/module-auth/src/errors.rs
Normal file
@@ -0,0 +1,3 @@
|
||||
//! 认证领域错误过渡落位。
|
||||
//!
|
||||
//! 领域错误保持可测试、可映射,不能直接依赖 Axum、cookie 或平台 provider 错误模型。
|
||||
3
server-rs/crates/module-auth/src/events.rs
Normal file
3
server-rs/crates/module-auth/src/events.rs
Normal file
@@ -0,0 +1,3 @@
|
||||
//! 认证领域事件过渡落位。
|
||||
//!
|
||||
//! 用于表达用户创建、会话签发/吊销、手机号验证通过和微信身份绑定等事实。
|
||||
@@ -1,3 +1,9 @@
|
||||
mod application;
|
||||
mod commands;
|
||||
mod domain;
|
||||
mod errors;
|
||||
mod events;
|
||||
|
||||
use std::{
|
||||
collections::HashMap,
|
||||
error::Error,
|
||||
|
||||
3
server-rs/crates/module-big-fish/src/application.rs
Normal file
3
server-rs/crates/module-big-fish/src/application.rs
Normal file
@@ -0,0 +1,3 @@
|
||||
//! 大鱼吃小鱼应用编排过渡落位。
|
||||
//!
|
||||
//! 这里只组合领域规则并返回结果或事件,不直接调用外部图片、视频或存储服务。
|
||||
3
server-rs/crates/module-big-fish/src/commands.rs
Normal file
3
server-rs/crates/module-big-fish/src/commands.rs
Normal file
@@ -0,0 +1,3 @@
|
||||
//! 大鱼吃小鱼写入命令过渡落位。
|
||||
//!
|
||||
//! 用于表达创建会话、写入消息、更新资产槽和推进运行态等输入。
|
||||
4
server-rs/crates/module-big-fish/src/domain.rs
Normal file
4
server-rs/crates/module-big-fish/src/domain.rs
Normal file
@@ -0,0 +1,4 @@
|
||||
//! 大鱼吃小鱼领域模型过渡落位。
|
||||
//!
|
||||
//! 后续迁移创作会话、资产槽和运行态聚合时,只保留玩法状态与规则;
|
||||
//! 图片生成、OSS 与 HTTP handler 均留在 adapter 层。
|
||||
3
server-rs/crates/module-big-fish/src/errors.rs
Normal file
3
server-rs/crates/module-big-fish/src/errors.rs
Normal file
@@ -0,0 +1,3 @@
|
||||
//! 大鱼吃小鱼领域错误过渡落位。
|
||||
//!
|
||||
//! 错误只表达玩法规则失败,由 HTTP 和 SpacetimeDB adapter 分别映射展示。
|
||||
3
server-rs/crates/module-big-fish/src/events.rs
Normal file
3
server-rs/crates/module-big-fish/src/events.rs
Normal file
@@ -0,0 +1,3 @@
|
||||
//! 大鱼吃小鱼领域事件过渡落位。
|
||||
//!
|
||||
//! 用于表达草稿变化、资产槽变化和运行态 tick 等事实。
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user