codex/rpg-creation-cg-fix #29
1
.gitignore
vendored
1
.gitignore
vendored
@@ -36,6 +36,7 @@ temp*build*/
|
||||
/.codex-temp
|
||||
/target/
|
||||
/logs
|
||||
/server-rs/crates/*/logs/
|
||||
.worktrees/
|
||||
.env.secrets.local
|
||||
spacetime.local.json
|
||||
|
||||
@@ -149,6 +149,14 @@
|
||||
- 验证方式:新增玩法 PRD 必须显式声明单图资产槽位和系列素材槽位;新增工作台测试确认没有默认聊天式 Agent 输入;skill 通过 `quick_validate.py`。
|
||||
- 关联文档:`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`、`.codex/skills/genarrative-play-type-integration/SKILL.md`、`.hermes/skills/genarrative-play-type-integration/SKILL.md`。
|
||||
|
||||
## 2026-05-21 RPG publish_world 设定文本以后端草稿真相派生
|
||||
|
||||
- 背景:RPG 结果页发布动作只保证提交 `{ action: 'publish_world' }`;旧 agent 会话可能没有 `seed_text`,但 `draft_profile_json` 已经通过 `publish_gate` 并可发布。
|
||||
- 决策:发布正式世界时,`spacetime-module` 不再把 `session.seed_text` 当作唯一 `setting_text` 兜底,而是调用 `module-custom-world::resolve_custom_world_publish_setting_text(...)` 从 payload、当前草稿 profile 和 seed 依次派生。
|
||||
- 影响范围:RPG / custom-world agent 发布链路、`custom_world_profile` 编译入库、公开 gallery 投影。
|
||||
- 验证方式:`cargo test -p module-custom-world publish_setting_text --manifest-path server-rs\Cargo.toml`;`cargo check -p spacetime-module --manifest-path server-rs\Cargo.toml`;本地 api-server 重启后检查 `/healthz`。
|
||||
- 关联文档:`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`、`docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md`、`.hermes/shared-memory/pitfalls.md`。
|
||||
|
||||
## 2026-05-19 系列素材 n*n 图集抽为 api-server 通用模块
|
||||
|
||||
- 背景:抓大鹅物品 sheet 已包含 prompt 组装、固定网格切图、绿幕 / 近白底透明化、切片 PNG 持久化和 prompt 追踪;继续留在 Match3D 私有模块会让跳一跳、后续地块 / 道具类玩法重复复制同一套算法和 OSS 元数据口径。
|
||||
@@ -387,6 +395,14 @@
|
||||
- 验证方式:执行入口配置、创作 Hub、平台入口交互和 api-server 路由熔断定向测试,确认“视觉小说”不出现在创作页且 `/api/creation/visual-novel/*` 默认被熔断。
|
||||
- 关联文档:`docs/design/PLATFORM_CREATE_TAB_CREATIVE_AGENT_HOME_2026-05-05.md`、`docs/technical/ADMIN_CREATION_ENTRY_SWITCH_CONFIG_2026-05-11.md`。
|
||||
|
||||
## 2026-05-20 RPG 创作入口开放
|
||||
|
||||
- 背景:RPG 文字冒险能力已经具备历史 custom-world 创作和运行闭环,但入口默认种子仍 `visible=false`,创作页不展示。
|
||||
- 决策:SpacetimeDB `creation_entry_type_config` 默认种子中 `rpg.visible=true` 且 `open=true`,旧默认隐藏配置只在标题、subtitle、badge、图片、排序和开关完全匹配时迁移为可见可创建。`airp` 仍保持 AI RPG 占位,不接管当前 RPG 链路。结构化创作 / RPG JSON 链路默认关闭 Responses `web_search`,需要联网增强时才通过 `GENARRATIVE_RPG_LLM_WEB_SEARCH_ENABLED=true` 或 `GENARRATIVE_CREATION_AGENT_LLM_WEB_SEARCH_ENABLED=true` 显式启用;未开通工具的上游会返回 `ToolNotOpen`,不能把这类失败暴露成“模型返回结果解析失败”。
|
||||
- 影响范围:创作入口默认种子、旧库入口纠偏、`api-server` 入口熔断、创作页模板 Tab、创作 Hub 测试、玩法链路文档和后端路由文档。
|
||||
- 验证方式:执行入口配置、api-server 路由熔断、创作 Hub 和平台入口交互定向测试,确认“文字冒险”出现在创作入口,`/api/runtime/custom-world*`、`/api/story/*`、`/api/runtime/chat/*` 都按 `rpg` 入口开关熔断。
|
||||
- 关联文档:`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`、`docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md`。
|
||||
|
||||
## 2026-05-10 运行态输入设备抽象层全项目通用化
|
||||
|
||||
- 背景:拼图运行态接入 mocap 后,鼠标/触控和 mocap 各自维护输入逻辑会导致合并大块、拖拽语义和取消会话行为不一致;后续其他玩法也需要复用体感、摇杆、键盘等设备输入。
|
||||
|
||||
@@ -48,6 +48,46 @@
|
||||
- 验证:拼图入口测试仍可通过,且新组件可通过不同页面复用而不需要复制上传卡实现。
|
||||
- 关联:`src/components/common/CreativeImageInputPanel.tsx`、`src/components/puzzle-agent/PuzzleAgentWorkspace.tsx`。
|
||||
|
||||
## RPG 发布不能只依赖 agent session seed_text
|
||||
|
||||
- 现象:RPG 结果页 `publish_world` 返回 `UPSTREAM_ERROR`,details 为 `custom_world.setting_text 不能为空`;同一 session 的 `result-view` 日志显示 `publish_ready=true`。
|
||||
- 原因:前端发布动作只提交 `{ action: 'publish_world' }`,旧 agent 会话的 `seed_text` 可能为空;如果后端只从 action payload 或 `seed_text` 取 `setting_text`,就会在最终 compile / publish 校验阶段失败。
|
||||
- 处理:`module-custom-world::resolve_custom_world_publish_setting_text(...)` 以当前 `draft_profile_json` 为草稿真相,优先读取 `settingText`、`creatorIntent.rawSettingText`、`creatorIntent.worldHook`、`worldHook`、`anchorContent.worldPromise(.hook)`、`summary`、`name/title`,最后才回退 `seed_text`。
|
||||
- 验证:`cargo test -p module-custom-world publish_setting_text --manifest-path server-rs\Cargo.toml`;`cargo check -p spacetime-module --manifest-path server-rs\Cargo.toml`。
|
||||
- 关联:`server-rs/crates/module-custom-world/src/application.rs`、`server-rs/crates/spacetime-module/src/custom_world.rs`、`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`。
|
||||
|
||||
## RPG 已发布结果页进入世界不能重复 publish_world
|
||||
|
||||
- 现象:RPG 草稿发布成功后,按钮文案已变为“进入世界”,但点击仍请求 `POST /api/runtime/custom-world/agent/sessions/{sessionId}/actions` 且 payload 为 `{"action":"publish_world"}`,后端返回 `publish_world is only available during object_refining, visual_refining, long_tail_review or ready_to_publish`。
|
||||
- 原因:按钮文案依据 agent session `stage === 'published'` 切换,但点击处理仍走发布协调路径;如果前端只依赖草稿同步回包判断是否已发布,回包为空或缺少可进入状态时就会继续重复发送 `publish_world`。
|
||||
- 处理:进入世界协调器接收当前 agent session stage;当 stage 已为 `published` 时,只调用 `result-view` 回读已发布 profile 并启动运行态,不再调用 `sync_result_profile` 或 `publish_world`。
|
||||
- 验证:`npm run test -- src/components/rpg-entry/useRpgCreationEnterWorld.test.tsx`;确认已发布场景下 `syncAgentDraftResultProfile` 与 `executePublishWorld` 均未被调用。
|
||||
- 关联:`src/components/rpg-entry/useRpgCreationEnterWorld.ts`、`src/components/platform-entry/PlatformEntryFlowShellImpl.tsx`、`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`。
|
||||
|
||||
## RPG 点击启动黑屏 / 默认 profile 先查 profile 归一化和摘要覆盖
|
||||
|
||||
- 现象:作品详情点击“启动”后页面切到 RPG runtime,但用户只看到黑屏、空白,或进入默认角色 / 默认 profile;从作品详情点“作品编辑”后开局 CG、封面、角色图、技能动作预览、初始物品图标或场景背景图丢失;DevTools 里可能同时看到旧自动存档 `/api/runtime/save/snapshot` 被主动 cancel。
|
||||
- 原因:`/custom-world-library` / `/custom-world-gallery` 详情接口可能返回历史或摘要式 `profile`,缺少 `playableNpcs`、`storyNpcs`、`landmarks`、`attributeSchema` 等运行态字段;前端 client 若直接把该对象传给 runtime,角色选择首屏会在 `buildCustomWorldPlayableCharacters(profile)` 或后续属性解析处抛错。另一类常见原因是详情接口已回读完整 profile 后,`savedCustomWorldEntries` 里的列表摘要又把 `selectedDetailEntry` 覆盖回空 profile,导致启动或编辑时只剩卡片摘要。发布 / 回读 result-view 若返回字段更少的旧视图,也可能把当前结果页已编辑资产降级掉。`save/snapshot (canceled)` 通常是切 runtime 或卸载时 `AbortController` 取消旧自动存档,不是黑屏根因。
|
||||
- 处理:RPG 入口作品库 client 在所有返回 `CustomWorldLibraryEntry<CustomWorldProfile>` 的接口边界统一调用 `normalizeCustomWorldProfileRecord`,并用 `profileId/worldName/subtitle/summaryText` 补齐旧数据缺字段;详情页已拿到运行态字段或资产槽位更多的完整 profile 时,不允许列表摘要覆盖当前详情;同一 `profile.id` 下,正式进入世界发布 / 回读不得用字段更少的后端旧视图降级当前结果页 profile。`normalizeCustomWorldProfileRecord` 必须近似无损保留 `cover`、`openingCg`、`camp.narrativeResidues`、`landmark.visualDescription/narrativeResidues`、`skills[].actionPreviewConfig`、`initialItems[].iconSrc`、`attributeSchema`、角色 `attributeProfile` 和 `sceneChapterBlueprints[].acts[]` 的背景与结构字段;只有背景资产的 act 也不能被过滤。角色选择页对角色生成异常或空数组回退默认角色,并保留返回按钮/轻量空态;顶层 runtime 懒加载 fallback 不使用纯 `null`。
|
||||
- 验证:`npm run test -- src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx -t "creation hub published work start uses loaded detail profile instead of library summary|creation hub published work edit keeps loaded detail profile assets instead of library summary"`;`npm run test -- src/data/customWorldLibrary.test.ts -t "保留结果页封面和关键图片资产槽位|近似无损保留编辑态和运行态结构字段|保留只有背景资产的场景幕"`;`npm run test -- src/components/rpg-entry/useRpgEntryAgentDraftRestore.test.tsx -t "默认封面和角色编辑结构差异也不能被列表摘要覆盖"`;`npm run test -- src/components/rpg-entry/useRpgCreationEnterWorld.test.tsx -t "正式进入世界回读结果页字段更少时不降级当前完整 profile"`;`npm run typecheck`。
|
||||
- 关联:`src/components/rpg-entry/useRpgEntryLibraryDetail.ts`、`src/components/rpg-entry/useRpgCreationEnterWorld.ts`、`src/data/customWorldLibrary.ts`、`src/services/rpg-entry/rpgEntryLibraryClient.ts`、`src/components/rpg-entry/RpgEntryCharacterSelectView.tsx`、`src/App.tsx`、`src/components/rpg-runtime-shell/RpgRuntimeShell.tsx`、`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`。
|
||||
|
||||
## RPG 战后一轮战斗后卡在观察/试探/调息先查 post-battle finalization
|
||||
|
||||
- 现象:RPG 一轮战斗胜利后,运行态只显示默认 `观察周围迹象 / 主动出声试探 / 原地调息`,这些按钮只有文字反馈;点“继续冒险”后又回到同样选项,点探索只播退场/进场动画,场景和剧情不推进。
|
||||
- 原因:终局战斗 action 如果只走通用 `resolve_story_runtime_action` fallback,而没有在后端调用 `finalize_post_battle_resolution(...)`,就不会持久写入 `story_continue_adventure`、`deferredOptions` 和下一幕 `currentSceneActState`。另外旧 bootstrap 快照可能只有 `connectedSceneIds` / `forwardSceneId`、没有 `connections`,战后选项生成若只读 `connections` 也会退回 `idle_explore_forward` 循环。
|
||||
- 处理:`module-runtime-story` 在 story action 投影后统一调用 post-battle finalization;`idle_explore_forward` 清理战斗态并生成下一段遭遇预览;`idle_travel_next_scene` / `camp_travel_home_scene` 由后端写入新 `currentScenePreset`、场景 act 状态、遭遇预览和 `runtimeStats.scenesTraveled`。前端只负责播放继续、探索和切场景动画,不承接正式剧情推进真相。
|
||||
- 验证:`cargo test -p module-runtime-story --manifest-path server-rs\Cargo.toml battle_tests -- --nocapture` 应覆盖战斗终局持久化 `story_continue_adventure`、`deferredOptions`、下一幕 act,以及 `idle_travel_next_scene` 真正切换场景。
|
||||
- 关联:`server-rs/crates/module-runtime-story/src/session_action.rs`、`server-rs/crates/module-runtime-story/src/post_battle.rs`、`server-rs/crates/module-runtime-story/src/battle_tests.rs`、`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`。
|
||||
|
||||
## RPG 战斗飘字不要只靠低对比红绿文字
|
||||
|
||||
- 现象:暗色或棕黑噪声背景下,战斗伤害飘字看起来像背景纹理,尤其是远端敌人头顶的小号红字几乎不可读。
|
||||
- 原因:旧 `CombatFloatingNumber` 主要依赖 `text-rose-200` / `text-emerald-200` 和 8px 同色 glow;在暗红、棕黑、像素噪声背景上,颜色与背景混在一起,1px 深色描边也不足以形成轮廓。
|
||||
- 处理:飘字本体使用高亮近白文字、小面积半透明深色底、明显深色描边和多层黑色阴影;只增强瞬时反馈,不新增说明面板,不遮挡主要战斗画面。
|
||||
- 验证:`npm run test -- src/components/game-canvas/GameCanvasEntityLayer.test.tsx` 覆盖伤害/治疗飘字样式策略;运行态截图中敌方头顶伤害数字应能在暗场景上辨认。
|
||||
- 关联:`src/components/game-canvas/GameCanvasEntityLayer.tsx`、`docs/【项目基线】当前产品与工程约束-2026-05-15.md`。
|
||||
|
||||
## 弹窗里复用 CreativeImageInputPanel 要保留画面卡高度
|
||||
|
||||
- 现象:拼图草稿结果页的关卡详情弹窗中仍能看到“画面图”标题、画面描述和生成按钮,但实际画面图卡片视觉上消失。
|
||||
@@ -432,6 +472,30 @@
|
||||
- 验证:运行 `npm run test -- src/services/creation-agent/creationAgentClientFactory.test.ts src/services/apiClient.test.ts`、`cargo test -p api-server puzzle_vector_engine --manifest-path server-rs/Cargo.toml`,真实联调重启 `npm run dev:api-server` 后检查 `/healthz`。
|
||||
- 关联:`src/services/creation-agent/creationAgentClientFactory.ts`、`server-rs/crates/api-server/src/puzzle.rs`、`docs/technical/API_SERVER_EXTERNAL_SERVICE_ENV_CONFIG_2026-05-07.md`。
|
||||
|
||||
## 开局 CG 故事板生图失败先查 VectorEngine 请求预算和旧进程
|
||||
|
||||
- 现象:RPG 结果页点击开局 CG 后,`POST /api/runtime/custom-world/opening-cg` 在较长等待后返回“开局 CG 故事板生成失败:创建图片生成任务失败:error sending request for url (https://api.vectorengine.ai/v1/images/generations)”。
|
||||
- 原因:该故事板会把角色图和首幕背景图作为参考图一起传给 VectorEngine `gpt-image-2-all`,请求体和上游生成耗时都比普通单图更大;若运行中的 `api-server` 仍沿用旧 `VECTOR_ENGINE_IMAGE_REQUEST_TIMEOUT_MS`,或者参考图过大,会在请求发送/等待阶段被 reqwest 截断。日志里 `timeout=false connect=false request=true body=false source=client error (SendRequest)` 表示还没拿到上游 HTTP 响应,通常优先怀疑大 JSON 请求体、上游网关中断或 HTTP 协议兼容,而不是业务响应解析失败。直接请求 VectorEngine 若无效 token 可快速返回 401,不能据此判断真实生图不会超时。
|
||||
- 处理:开局 CG 参考图入参先压到单边 768 的 JPEG;`/v1/images/generations` 保持 reqwest 默认 HTTP 协商,只有 multipart `/v1/images/edits` 单独强制 HTTP/1.1。后端图片 helper 将 `request_body_bytes`、每张参考图 Data URL 长度、`timeout/connect/body/source/rootSource/sourceChain/endpoint` 分类写入日志和 `error.details`,前端优先展示 `details.reason`。修改 `.env.secrets.local` 后必须重启 `api-server`,`npm run dev` 终端用 `rs api-server`,否则旧进程仍按旧超时运行。
|
||||
- 验证:分别运行 `cargo test -p api-server custom_world_ai --manifest-path server-rs/Cargo.toml` 和 `cargo test -p api-server openai_image_generation --manifest-path server-rs/Cargo.toml`;真实联调重启后再触发开局 CG,若仍失败看返回的 `details.reason/source/rootSource/sourceChain/timeout/connect/body/endpoint` 和 `logs/api-server/` 同一 request_id。
|
||||
- 关联:`server-rs/crates/api-server/src/custom_world_ai.rs`、`server-rs/crates/api-server/src/custom_world_ai/opening_cg.rs`、`server-rs/crates/api-server/src/openai_image_generation.rs`、`docs/【开发运维】本地开发验证与生产运维-2026-05-15.md`。
|
||||
|
||||
## 开局 CG 成功后又变空白要保留 profile.openingCg
|
||||
|
||||
- 现象:RPG 结果页里的开局 CG 成功显示一瞬后,窗口又退回空白占位。
|
||||
- 原因:`openingCg` 只存在于结果页 profile 槽位,如果父层在 `onProfileChange` 后重新同步了 profile,却经过 `normalizeCustomWorldProfileRecord` 或作品库写回时丢掉 `openingCg`,预览就会从视频 / 故事板回退为空白。
|
||||
- 处理:`src/data/customWorldLibrary.ts` 的 profile 归一化必须透传 `openingCg`;结果页和父层后续同步都应把它当作受控资产槽位,而不是临时 UI 状态。
|
||||
- 验证:`npm run test -- src/data/customWorldLibrary.test.ts src/components/CustomWorldResultView.test.tsx`,确认生成后即使父层做一次归一化回写,开局 CG 仍继续显示。
|
||||
- 关联:`src/data/customWorldLibrary.ts`、`src/components/rpg-creation-result/RpgCreationResultViewImpl.tsx`、`src/components/CustomWorldEntityCatalog.tsx`。
|
||||
|
||||
## RPG 发布报 legacy_result_profile_json 非法先查 null 兼容
|
||||
|
||||
- 现象:RPG 结果页发布动作返回 `UPSTREAM_ERROR`,SpacetimeDB details 里是 `custom_world.compile.legacy_result_profile_json 不是合法 JSON object`。
|
||||
- 原因:`publish_world` 前端契约只要求 `{ action: 'publish_world' }`;`ExecuteCustomWorldAgentActionRequest.legacy_result_profile` 是可选字段,经 HTTP / serde / SpacetimeDB payload 传递时可能显式成为 JSON `null`。旧的编译器只接受 object 或缺省,把 `Some("null")` 当成非法 legacy JSON。
|
||||
- 处理:`module-custom-world` 的 optional JSON object 解析要把 `null` 视为未提供,仍拒绝数组、字符串、数字和坏 JSON;正式发布继续以 session `draft_profile_json` 为草稿真相。
|
||||
- 验证:`cargo test -p module-custom-world published_profile_compile --manifest-path server-rs/Cargo.toml`。
|
||||
- 关联:`server-rs/crates/module-custom-world/src/application.rs`、`server-rs/crates/spacetime-module/src/custom_world.rs`、`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`。
|
||||
|
||||
## 本地脚本调 VectorEngine 生图卡住先区分 fetch 首部超时
|
||||
|
||||
- 现象:用 Node `fetch` 直接请求 `POST /v1/images/generations`,已经设置较长的 AbortController 超时,但仍在约 180 到 300 秒后抛 `AbortError`、`TypeError: fetch failed` 或 `UND_ERR_HEADERS_TIMEOUT`;同一 prompt 改用原生 `https.request` 可以在较短时间内成功返回图片。
|
||||
@@ -1147,3 +1211,11 @@
|
||||
- 处理:打开草稿时把持久化 `generationStatus=generating` 等同于生成中 notice,恢复对应玩法生成进度页;恢复计时使用作品摘要 `updatedAt` 推导 `startedAtMs`。
|
||||
- 验证:`npm test -- src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx -t "persisted generating"`。
|
||||
- 关联:`src/components/platform-entry/PlatformEntryFlowShellImpl.tsx`、`src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx`、`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`。
|
||||
|
||||
## 存档选择入口不要只藏在“玩过”弹窗里
|
||||
|
||||
- 现象:用户有 RPG / 拼图运行态存档,但平台底部 `草稿` Tab 只展示作品架,个人中心只有点击 `玩过` 后才可能看到“可继续”,导致看起来没有存档选择入口。
|
||||
- 原因:`/api/profile/save-archives` 已在入口 bootstrap 加载,但前端只把 `saveEntries` 注入 `ProfilePlayedWorksModal`;没有独立的存档入口。
|
||||
- 处理:个人中心 `常用功能` 必须保留 `存档` 快捷入口,点击后打开独立存档选择弹窗并复用 `SaveArchiveCard`;恢复仍走 `/api/profile/save-archives/{worldKey}`,拼图存档继续走拼图 resume 分支,RPG 走 `handleContinueGame(snapshot)`。
|
||||
- 验证:`npm run test -- src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx -t "profile page exposes save archive picker"`。
|
||||
- 关联:`src/components/rpg-entry/RpgEntryHomeView.tsx`、`src/components/platform-entry/PlatformEntryFlowShellImpl.tsx`、`src/components/rpg-entry/useRpgEntryBootstrap.ts`、`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`。
|
||||
|
||||
@@ -329,6 +329,7 @@ npm run check:server-rs-ddd
|
||||
|
||||
- Rust 结构体:`CustomWorldAgentSession`
|
||||
- 源码:`server-rs/crates/spacetime-module/src/custom_world.rs`
|
||||
- 发布约束:`publish_world` 的 action payload 不要求携带 `settingText`;`spacetime-module` 调用 `module-custom-world::resolve_custom_world_publish_setting_text(...)`,优先从当前 `draft_profile_json` 草稿真相派生正式 `setting_text`,避免旧会话 `seed_text` 为空时在最终 compile / publish 阶段触发 `custom_world.setting_text 不能为空`。
|
||||
|
||||
### `custom_world_draft_card`
|
||||
|
||||
@@ -601,6 +602,10 @@ npm run check:server-rs-ddd
|
||||
|
||||
`GET /api/creation-entry/config` 和入口熔断优先从订阅 cache 读取创作入口配置;cache 缺失时使用最近一次成功读取的内存快照,再兜底调用 `get_creation_entry_config` procedure 完成空库种子或旧库兼容。
|
||||
|
||||
RPG 创作入口的配置 ID 是 `rpg`,当前 `visible=true`、`open=true`;历史 `custom-world` 路由仍是 RPG 的工程域与运行态源类型。入口熔断把 `/api/runtime/custom-world*`、`/api/story/*` 和 `/api/runtime/chat/*` 统一映射到 `rpg`,不要新增平行 `airp` 路由或用 `airp` 接管当前文字冒险链路。
|
||||
|
||||
结构化创作和 RPG 的 LLM JSON 链路默认不启用 Responses `web_search`;只有在明确需要联网增强时,才通过 `GENARRATIVE_RPG_LLM_WEB_SEARCH_ENABLED` 或 `GENARRATIVE_CREATION_AGENT_LLM_WEB_SEARCH_ENABLED` 显式打开。否则未开通工具的上游会先吐自然语言再返回 `ToolNotOpen`,这类失败要按上游工具不可用处理,不要误判成模型返回结果解析失败。
|
||||
|
||||
未来可选:若发现页、推荐流和各玩法广场需要统一给浏览器前端直接订阅公开作品列表,只新增 / 统一专用 public read model,例如 `public_work_gallery_entry`。该 read model 必须是后端投影后的公开作品卡片契约,覆盖作品类型、公开作品号、标题、摘要、封面、作者展示名、排序键、公开统计和入口开关后的可见性,不暴露玩法领域源表 row shape。前端可选择订阅这个稳定投影来减少 HTTP 拉取,但不能订阅 `puzzle_work_profile`、`custom_world_profile` 等源表后自行拼装列表;BFF 仍保留首屏、SEO / 分享、旧客户端、订阅失败和灰度期间的 HTTP fallback。
|
||||
|
||||
### `quest_log`
|
||||
|
||||
@@ -45,6 +45,8 @@ npm run dev:api-server
|
||||
|
||||
后端日志默认写入 `logs/api-server/`。后端 API smoke 使用 `npm run dev:api-server` 并检查 `/healthz`;不要使用旧 `api-server:maincloud` 或任何 `GENARRATIVE_SPACETIME_MAINCLOUD_*` 口径。
|
||||
|
||||
本地 `.env`、`.env.local` 或 `.env.secrets.local` 修改后必须重启 `api-server` 才会生效;若已经通过 `npm run dev` 启动完整联调,可在该终端输入 `rs api-server`。排查 RPG / 拼图 / 抓大鹅等 VectorEngine 生图链路时,确认 `VECTOR_ENGINE_BASE_URL`、`VECTOR_ENGINE_API_KEY` 和 `VECTOR_ENGINE_IMAGE_REQUEST_TIMEOUT_MS` 只在本地或服务器密钥文件中配置,不能写入 Git。开局 CG 故事板、首图、背景和图集都属于长耗时图片请求;后端默认会把 `VECTOR_ENGINE_IMAGE_REQUEST_TIMEOUT_MS` 下限收口到 `1000000`,旧进程仍可能沿用重启前的短超时。若开局 CG 故事板在 `send()` 阶段失败且日志显示 `SendRequest`,先看同一 request_id 的 `request_body_bytes`、`reference_data_url_bytes`、`sourceChain` 和 `rootSource`;当前开局 CG 会把角色图与首幕背景图压到单边 768 的 JPEG 后再作为 generations `image` 数组发送,`/v1/images/generations` 使用默认 HTTP 协商,只有 multipart `/v1/images/edits` 单独强制 HTTP/1.1。
|
||||
|
||||
查看本地 Rust / SpacetimeDB 日志:
|
||||
|
||||
```bash
|
||||
@@ -241,6 +243,8 @@ OpenTelemetry 现阶段默认开启 OTLP traces / metrics / logs,但本地日
|
||||
- `WECHAT_*`
|
||||
- `ALIYUN_OSS_*`
|
||||
|
||||
结构化创作 / RPG 的 Responses JSON 链路默认不打开 `web_search`;本地和生产如需联网增强,必须显式配置 `GENARRATIVE_RPG_LLM_WEB_SEARCH_ENABLED=true` 或 `GENARRATIVE_CREATION_AGENT_LLM_WEB_SEARCH_ENABLED=true`。如果上游未开通工具,Responses 可能先吐自然语言再返回 `ToolNotOpen`,这类报错应按工具不可用排查,不要先当成 JSON 解析 bug。
|
||||
|
||||
### 手机验证码短信
|
||||
|
||||
手机验证码发送走阿里云普通短信 `SendSms`,验证码由 `module-auth` 在当前 `api-server` 进程内生成、哈希存储和校验,不再调用阿里云托管验证码的 `SendSmsVerifyCode` / `CheckSmsVerifyCode`。因此 `api-server` 重启后,已发送但未校验的验证码会失效。
|
||||
|
||||
@@ -36,6 +36,34 @@
|
||||
6. 点击 `generationStatus=generating` 的草稿卡必须恢复对应玩法的生成进度页,不能进入空白结果页或普通工作区;恢复生成页的 `startedAtMs` 使用作品摘要 `updatedAt` 推导。
|
||||
7. 私有 generated 图片必须通过 `ResolvedAssetImage` / `/api/assets/read-url` 换签读取。
|
||||
|
||||
## RPG / 自定义世界
|
||||
|
||||
当前 RPG 创作入口使用 `playId = rpg`,工程域和运行态源类型沿用历史 `custom-world`。默认入口状态为 `visible=true`、`open=true`,对外展示为“文字冒险”;`airp` 仍是独立的“AI RPG”占位入口,保持 `open=false`,不要把它当作当前 RPG 创作链路开放。
|
||||
|
||||
当前链路为:
|
||||
|
||||
```text
|
||||
创作入口 -> RPG Agent 共创工作台 -> 生成过程页 -> 结果页 -> 进入世界/试玩 -> 发布 -> RPG 运行态
|
||||
```
|
||||
|
||||
RPG 是历史既有链路例外:当前仍使用对话式 Agent 共创工作台和 RPG 资产编辑器体系,不作为新增玩法默认模板复制。新增玩法继续遵循本文默认的表单/图片输入工作台、`CreativeImageInputPanel` 单图槽位和通用系列素材图集生成流程;如果要把 RPG 逐步迁回默认模式,应先补 PRD 和迁移方案,再改代码。
|
||||
|
||||
RPG API 仍沿用历史命名空间:`/api/runtime/custom-world*`、`/api/story/*`、`/api/runtime/chat/*`。这些路由在 `api-server` 入口熔断中统一映射到 `rpg`,只按 `open` 判断是否允许调用;`visible` 只控制创作页入口展示和作品架可见性。
|
||||
|
||||
RPG Agent 结果页点击发布或发布并进入世界时,必须先把结果页当前 profile 通过 `sync_result_profile` 保存回 `custom_world_agent_session.draft_profile_json`,再发送发布动作;发布动作前端契约只允许提交 `{ action: 'publish_world' }`,`api-server` 只补作者公开信息,不转发 `profile`、`draftProfile`、`legacyResultProfile` 或 `settingText`。`spacetime-module` 发布时只读取当前 session 的 `draft_profile_json` 作为草稿真相,从 `settingText`、`creatorIntent.rawSettingText`、`creatorIntent.worldHook`、`worldHook`、`anchorContent.worldPromise(.hook)`、`summary`、`name/title` 依次派生正式 `setting_text`,最后才回退 `seed_text`。不要把 `seed_text` 当作唯一设定来源,旧会话可能为空。
|
||||
|
||||
Agent session 已进入 `published` 后,结果页按钮只能执行“进入世界”:前端需先通过 `result-view` 回读已发布 profile 并启动运行态,不得再次调用 `sync_result_profile` 或发送 `{ action: 'publish_world' }`。`publish_world` 只允许在 `object_refining`、`visual_refining`、`long_tail_review`、`ready_to_publish` 等发布前阶段触发;否则会被后端阶段门槛拒绝。
|
||||
|
||||
`legacyResultProfile` 只作为历史结果页 profile 兼容兜底;编译正式 profile 时,session 草稿内已保存字段优先于 legacy 字段,legacy 只能补缺失字段。`publish_world` 不再接受前端临时传入的 legacy 载荷;历史兼容路径中 legacy 缺省或显式为 `null` 时等价于未提供,不得因此报 `custom_world.compile.legacy_result_profile_json 不是合法 JSON object`。真正的数组、字符串、数字等非 object legacy 载荷仍应拒绝。
|
||||
|
||||
RPG 结果页开局 CG 是 `profile.openingCg` 资产槽位:`api-server` 负责 VectorEngine / OSS 副作用并返回故事板和视频引用,前端只把结果写回当前 profile;`sync_result_profile`、作品库保存和 `normalizeCustomWorldProfileRecord` 都必须保留该槽位。封面是 `profile.cover` 资产槽位,默认封面也要保留 `sourceType='default'` 和 `characterRoleIds`,不能因为没有 `imageSrc` 就当作空封面。若生成成功后画面短暂显示又变回空白,优先检查父层重新同步或 profile 归一化是否把 `openingCg` / `cover` 丢掉,而不是先怀疑已生成资源本身失效。
|
||||
|
||||
RPG 从作品架、广场详情或作品号搜索点击“启动”前,入口 client 必须把后端返回的完整 `profile` 先经过 `normalizeCustomWorldProfileRecord`,并用作品条目的 `profileId/worldName/subtitle/summaryText` 补齐旧数据缺失字段;运行态和详情页不得直接消费未归一化的旧 profile。作品架列表或 `savedCustomWorldEntries` 中的摘要 profile 只可用于卡片展示,不可在详情接口已回读完整 profile 后覆盖 `selectedDetailEntry`;若摘要缺少 `playableNpcs`、`storyNpcs`、`landmarks`、`items`、`sceneChapterBlueprints`、`cover`、`openingCg`、`skills[].actionPreviewConfig`、`initialItems[].iconSrc`、`attributeSchema`、角色 `attributeProfile`、场景残留或场景幕背景资产,启动和编辑必须继续使用详情 profile,否则会进入默认角色 / 默认 profile,或在编辑页丢 CG、封面、技能预览和初始物品图标。正式“进入世界”发布 / 回读结果页时,同一 `profile.id` 下也不得用字段更少的后端旧视图降级当前结果页完整 profile。角色选择页还需要在角色数组异常或为空时回退默认角色,并显示可返回的轻量空态,不能 `return null` 造成黑屏。运行态懒加载 fallback 必须可见,不能用纯 `null` 让用户误判为黑屏。
|
||||
|
||||
RPG 运行态的战斗终局、继续冒险、继续探索和切场景都属于服务端 runtime 快照真相:`module-runtime-story` 必须在终局战斗 action 后调用 post-battle finalization,持久写入 `story_continue_adventure`、`deferredOptions`、`deferredRuntimeState.storyEngineMemory.currentSceneActState` 和清理后的战斗状态;`idle_travel_next_scene` / `camp_travel_home_scene` 必须由后端写入新的 `currentScenePreset`、`currentSceneActState`、`currentEncounter` 和 `runtimeStats.scenesTraveled`。前端只播放退场、进场和继续按钮表现,不能用默认 `观察/试探/调息` fallback 或本地动画假装推进剧情。旧 bootstrap 快照可能只有 `connectedSceneIds` / `forwardSceneId` 而没有 `connections`,后端生成战后旅行选项时必须兼容这些字段。
|
||||
|
||||
RPG / 拼图等运行态存档选择入口统一在个人中心 `常用功能 > 存档` 暴露为独立弹窗;“玩过”弹窗可以继续合并展示可继续存档,但不能成为唯一入口。前端只展示 `/api/profile/save-archives` 返回的列表并在用户选择后调用对应恢复接口,不能本地拼装或筛选正式存档真相。
|
||||
|
||||
## 拼图
|
||||
|
||||
当前拼图链路:
|
||||
|
||||
@@ -94,6 +94,7 @@ server-rs + Axum + SpacetimeDB
|
||||
8. 图像输入通用 UI 统一走 `src/components/common/CreativeImageInputPanel.tsx`。外层页面持有业务状态,组件只承担上传卡、预览、参考图缩略图、AI 重绘开关、错误展示和提交按钮。
|
||||
9. 发现页 `分类` 子频道的筛选必须打开独立 dialog / drawer / modal,至少支持玩法类型过滤与排序切换;筛选结果为空时显示空状态,不把筛选内容展开在当前列表下方。
|
||||
10. “我的”页泥点、游戏时长、玩过三张统计卡只展示各自标签和值,内容居中且不换行,不在统计区底部展示“更新于”时间。
|
||||
11. RPG 等运行态的战斗飘字、血量变化和即时反馈必须在暗色、噪声高的场景背景上保持可读:使用高亮文字、深色描边、强阴影或小面积半透明底,不只依赖红/绿文字本身表达伤害或治疗。
|
||||
11. 平台亮色 UI 配色以陶泥儿主视觉为准:暖白 / 米杏底、陶土橙主按钮、深棕正文与浅杏边框;新增界面优先复用 `src/index.css` 的 `--platform-*` 主题变量和 `apps/admin-web/src/styles/admin.css` 的同系色值,不再引入粉红、蓝绿等独立主色方案。
|
||||
|
||||
## 文案与编码
|
||||
|
||||
@@ -619,6 +619,31 @@ mod tests {
|
||||
assert_eq!(body["error"]["details"]["creationTypeId"], "visual-novel");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn disabled_rpg_route_returns_service_unavailable() {
|
||||
let state = AppState::new(AppConfig::default()).expect("state should build");
|
||||
state.set_test_creation_entry_route_enabled("rpg", false);
|
||||
let app = build_router(state);
|
||||
|
||||
let response = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri("/api/runtime/custom-world/agent/sessions")
|
||||
.body(Body::empty())
|
||||
.expect("request should build"),
|
||||
)
|
||||
.await
|
||||
.expect("request should succeed");
|
||||
|
||||
assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE);
|
||||
let body = read_json_response(response).await;
|
||||
assert_eq!(
|
||||
body["error"]["details"]["reason"],
|
||||
"creation_entry_disabled"
|
||||
);
|
||||
assert_eq!(body["error"]["details"]["creationTypeId"], "rpg");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn healthz_returns_standard_envelope_when_requested() {
|
||||
let app = build_router(AppState::new(AppConfig::default()).expect("state should build"));
|
||||
|
||||
@@ -260,8 +260,11 @@ impl Default for AppConfig {
|
||||
llm_request_timeout_ms: DEFAULT_REQUEST_TIMEOUT_MS,
|
||||
llm_max_retries: DEFAULT_MAX_RETRIES,
|
||||
llm_retry_backoff_ms: DEFAULT_RETRY_BACKOFF_MS,
|
||||
rpg_llm_web_search_enabled: true,
|
||||
creation_agent_llm_web_search_enabled: true,
|
||||
// 中文注释:创作/RPG 的结构化 JSON 链路默认不启用 Responses web_search。
|
||||
// 未开通工具的上游会先吐自然语言再返回 ToolNotOpen,容易污染严格 JSON 结果;
|
||||
// 需要联网增强时由部署环境显式打开对应开关。
|
||||
rpg_llm_web_search_enabled: false,
|
||||
creation_agent_llm_web_search_enabled: false,
|
||||
dashscope_base_url: "https://dashscope.aliyuncs.com/api/v1".to_string(),
|
||||
dashscope_api_key: None,
|
||||
dashscope_scene_image_model: String::new(),
|
||||
@@ -1467,6 +1470,14 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_keeps_structured_llm_web_search_disabled() {
|
||||
let config = AppConfig::default();
|
||||
|
||||
assert!(!config.rpg_llm_web_search_enabled);
|
||||
assert!(!config.creation_agent_llm_web_search_enabled);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn from_env_reads_rpg_llm_web_search_switch() {
|
||||
let _guard = ENV_LOCK
|
||||
@@ -1476,11 +1487,11 @@ mod tests {
|
||||
|
||||
unsafe {
|
||||
std::env::remove_var("GENARRATIVE_RPG_LLM_WEB_SEARCH_ENABLED");
|
||||
std::env::set_var("GENARRATIVE_RPG_LLM_WEB_SEARCH_ENABLED", "false");
|
||||
std::env::set_var("GENARRATIVE_RPG_LLM_WEB_SEARCH_ENABLED", "true");
|
||||
}
|
||||
|
||||
let config = AppConfig::from_env();
|
||||
assert!(!config.rpg_llm_web_search_enabled);
|
||||
assert!(config.rpg_llm_web_search_enabled);
|
||||
|
||||
unsafe {
|
||||
std::env::remove_var("GENARRATIVE_RPG_LLM_WEB_SEARCH_ENABLED");
|
||||
@@ -1496,11 +1507,11 @@ mod tests {
|
||||
|
||||
unsafe {
|
||||
std::env::remove_var("GENARRATIVE_CREATION_AGENT_LLM_WEB_SEARCH_ENABLED");
|
||||
std::env::set_var("GENARRATIVE_CREATION_AGENT_LLM_WEB_SEARCH_ENABLED", "false");
|
||||
std::env::set_var("GENARRATIVE_CREATION_AGENT_LLM_WEB_SEARCH_ENABLED", "true");
|
||||
}
|
||||
|
||||
let config = AppConfig::from_env();
|
||||
assert!(!config.creation_agent_llm_web_search_enabled);
|
||||
assert!(config.creation_agent_llm_web_search_enabled);
|
||||
|
||||
unsafe {
|
||||
std::env::remove_var("GENARRATIVE_CREATION_AGENT_LLM_WEB_SEARCH_ENABLED");
|
||||
|
||||
@@ -93,15 +93,74 @@ where
|
||||
F: FnMut(&str),
|
||||
{
|
||||
let mut latest_reply_text = String::new();
|
||||
let turn_output = match request_stream_creation_agent_json_turn_once(
|
||||
llm_client,
|
||||
system_prompt.clone(),
|
||||
user_prompt.clone(),
|
||||
enable_web_search,
|
||||
on_reply_update,
|
||||
&mut latest_reply_text,
|
||||
!enable_web_search,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(turn_output) => turn_output,
|
||||
Err(CreationAgentJsonTurnFailure::Stream(error))
|
||||
if enable_web_search && is_web_search_tool_unavailable(&error) =>
|
||||
{
|
||||
tracing::warn!(
|
||||
error = %error,
|
||||
"创作 Agent 流式联网搜索插件不可用,自动降级为无联网搜索重试"
|
||||
);
|
||||
latest_reply_text.clear();
|
||||
request_stream_creation_agent_json_turn_once(
|
||||
llm_client,
|
||||
system_prompt,
|
||||
user_prompt,
|
||||
false,
|
||||
on_reply_update,
|
||||
&mut latest_reply_text,
|
||||
true,
|
||||
)
|
||||
.await?
|
||||
}
|
||||
Err(error) => return Err(error),
|
||||
};
|
||||
|
||||
let reply_text = read_reply_text(&turn_output.parsed);
|
||||
if let Some(reply_text) = reply_text.as_deref()
|
||||
&& reply_text != latest_reply_text
|
||||
{
|
||||
on_reply_update(reply_text);
|
||||
}
|
||||
|
||||
Ok(turn_output)
|
||||
}
|
||||
|
||||
async fn request_stream_creation_agent_json_turn_once<F>(
|
||||
llm_client: &LlmClient,
|
||||
system_prompt: String,
|
||||
user_prompt: String,
|
||||
enable_web_search: bool,
|
||||
on_reply_update: &mut F,
|
||||
latest_reply_text: &mut String,
|
||||
emit_reply_updates: bool,
|
||||
) -> Result<CreationAgentJsonTurnOutput, CreationAgentJsonTurnFailure>
|
||||
where
|
||||
F: FnMut(&str),
|
||||
{
|
||||
let response = llm_client
|
||||
.stream_text(
|
||||
build_creation_agent_llm_request(system_prompt, user_prompt, enable_web_search),
|
||||
|delta: &LlmStreamDelta| {
|
||||
if !emit_reply_updates {
|
||||
return;
|
||||
}
|
||||
if let Some(reply_progress) =
|
||||
extract_reply_text_from_partial_json(delta.accumulated_text.as_str())
|
||||
&& reply_progress != latest_reply_text
|
||||
&& reply_progress != *latest_reply_text
|
||||
{
|
||||
latest_reply_text = reply_progress.clone();
|
||||
*latest_reply_text = reply_progress.clone();
|
||||
on_reply_update(reply_progress.as_str());
|
||||
}
|
||||
},
|
||||
@@ -110,12 +169,6 @@ where
|
||||
.map_err(CreationAgentJsonTurnFailure::Stream)?;
|
||||
let parsed = parse_json_response_text(response.content.as_str())
|
||||
.map_err(|_| CreationAgentJsonTurnFailure::Parse)?;
|
||||
let reply_text = read_reply_text(&parsed);
|
||||
if let Some(reply_text) = reply_text.as_deref()
|
||||
&& reply_text != latest_reply_text
|
||||
{
|
||||
on_reply_update(reply_text);
|
||||
}
|
||||
|
||||
Ok(CreationAgentJsonTurnOutput { parsed })
|
||||
}
|
||||
@@ -327,6 +380,7 @@ mod tests {
|
||||
let server = spawn_capturing_mock_server(vec![
|
||||
MockResponse {
|
||||
body: concat!(
|
||||
"data: {\"type\":\"response.output_text.delta\",\"delta\":\"我需要先搜索玩具王国资料。\"}\n\n",
|
||||
"data: {\"type\":\"error\",\"code\":\"ToolNotOpen\",\"message\":\"Your account has not activated web search.\"}\n\n",
|
||||
"data: [DONE]\n\n"
|
||||
)
|
||||
@@ -391,6 +445,55 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn stream_turn_keeps_partial_updates_when_web_search_is_disabled() {
|
||||
let server = spawn_capturing_mock_server(vec![MockResponse {
|
||||
body: concat!(
|
||||
"data: {\"type\":\"response.output_text.delta\",\"delta\":\"{\\\"replyText\\\":\\\"我先\"}\n\n",
|
||||
"data: {\"type\":\"response.output_text.delta\",\"delta\":\"把玩具王国定住。\\\",\\\"progressPercent\\\":12}\"}\n\n",
|
||||
"data: {\"type\":\"response.completed\"}\n\n",
|
||||
)
|
||||
.to_string(),
|
||||
}]);
|
||||
let config = LlmConfig::new(
|
||||
LlmProvider::Ark,
|
||||
server.base_url,
|
||||
"test-key".to_string(),
|
||||
"test-model".to_string(),
|
||||
30_000,
|
||||
0,
|
||||
1,
|
||||
)
|
||||
.expect("LLM config should build");
|
||||
let llm_client = platform_llm::LlmClient::new(config).expect("LLM client should build");
|
||||
let mut visible_replies = Vec::new();
|
||||
|
||||
let output = stream_creation_agent_json_turn(
|
||||
Some(&llm_client),
|
||||
"系统提示".to_string(),
|
||||
"用户提示",
|
||||
false,
|
||||
CreationAgentLlmTurnErrorMessages {
|
||||
model_unavailable: "模型不可用",
|
||||
generation_failed: "生成失败",
|
||||
parse_failed: "解析失败",
|
||||
},
|
||||
|text| visible_replies.push(text.to_string()),
|
||||
|message| message,
|
||||
)
|
||||
.await
|
||||
.expect("stream without web search should succeed");
|
||||
|
||||
assert_eq!(
|
||||
output.parsed["replyText"].as_str(),
|
||||
Some("我先把玩具王国定住。")
|
||||
);
|
||||
assert_eq!(
|
||||
visible_replies,
|
||||
vec!["我先".to_string(), "我先把玩具王国定住。".to_string()]
|
||||
);
|
||||
}
|
||||
|
||||
struct MockResponse {
|
||||
body: String,
|
||||
}
|
||||
|
||||
@@ -96,6 +96,14 @@ pub fn resolve_creation_entry_route_id(path: &str) -> Option<&'static str> {
|
||||
if normalized.starts_with("/api/runtime/big-fish") {
|
||||
return Some("big-fish");
|
||||
}
|
||||
if normalized.starts_with("/api/runtime/custom-world")
|
||||
|| normalized.starts_with("/api/runtime/custom-world-library")
|
||||
|| normalized.starts_with("/api/runtime/custom-world-gallery")
|
||||
|| normalized.starts_with("/api/runtime/chat")
|
||||
|| normalized.starts_with("/api/story")
|
||||
{
|
||||
return Some("rpg");
|
||||
}
|
||||
if normalized.starts_with("/api/runtime/visual-novel") {
|
||||
return Some("visual-novel");
|
||||
}
|
||||
@@ -161,6 +169,26 @@ mod tests {
|
||||
resolve_creation_entry_route_id("/api/creation/visual-novel/sessions"),
|
||||
Some("visual-novel"),
|
||||
);
|
||||
assert_eq!(
|
||||
resolve_creation_entry_route_id("/api/runtime/custom-world/agent/sessions"),
|
||||
Some("rpg"),
|
||||
);
|
||||
assert_eq!(
|
||||
resolve_creation_entry_route_id("/api/runtime/custom-world-library/profile-1"),
|
||||
Some("rpg"),
|
||||
);
|
||||
assert_eq!(
|
||||
resolve_creation_entry_route_id("/api/runtime/custom-world-gallery/user-1/profile-1"),
|
||||
Some("rpg"),
|
||||
);
|
||||
assert_eq!(
|
||||
resolve_creation_entry_route_id("/api/story/sessions/runtime"),
|
||||
Some("rpg"),
|
||||
);
|
||||
assert_eq!(
|
||||
resolve_creation_entry_route_id("/api/runtime/chat/npc/turn/stream"),
|
||||
Some("rpg"),
|
||||
);
|
||||
assert_eq!(
|
||||
resolve_creation_entry_route_id("/api/runtime/bark-battle/works/work-1/config"),
|
||||
Some("bark-battle"),
|
||||
|
||||
@@ -1626,39 +1626,20 @@ pub async fn execute_custom_world_agent_action(
|
||||
)
|
||||
})?
|
||||
} else if action == "publish_world" {
|
||||
let mut publish_payload = serde_json::to_value(&payload).map_err(|error| {
|
||||
let publish_payload = serialize_publish_world_action_payload(
|
||||
resolve_author_public_user_code(&state, &authenticated, &request_context)?,
|
||||
resolve_author_display_name(&state, &authenticated),
|
||||
)
|
||||
.map_err(|error| {
|
||||
custom_world_error_response(
|
||||
&request_context,
|
||||
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
|
||||
"provider": "custom-world-agent",
|
||||
"message": format!("action payload JSON 序列化失败:{error}"),
|
||||
"message": error,
|
||||
})),
|
||||
)
|
||||
})?;
|
||||
if let Some(object) = publish_payload.as_object_mut() {
|
||||
// 发布到广场时必须写入真实作者公开信息,避免 gallery 投影落成匿名兜底数据。
|
||||
object.insert(
|
||||
"authorPublicUserCode".to_string(),
|
||||
Value::String(resolve_author_public_user_code(
|
||||
&state,
|
||||
&authenticated,
|
||||
&request_context,
|
||||
)?),
|
||||
);
|
||||
object.insert(
|
||||
"authorDisplayName".to_string(),
|
||||
Value::String(resolve_author_display_name(&state, &authenticated)),
|
||||
);
|
||||
}
|
||||
serde_json::to_string(&publish_payload).map_err(|error| {
|
||||
custom_world_error_response(
|
||||
&request_context,
|
||||
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
|
||||
"provider": "custom-world-agent",
|
||||
"message": format!("action payload JSON 序列化失败:{error}"),
|
||||
})),
|
||||
)
|
||||
})?
|
||||
publish_payload
|
||||
} else {
|
||||
serde_json::to_string(&payload).map_err(|error| {
|
||||
custom_world_error_response(
|
||||
@@ -1734,6 +1715,23 @@ fn serialize_sync_result_profile_action_payload(
|
||||
.map_err(|error| format!("action payload JSON 序列化失败:{error}"))
|
||||
}
|
||||
|
||||
fn serialize_publish_world_action_payload(
|
||||
author_public_user_code: String,
|
||||
author_display_name: String,
|
||||
) -> Result<String, String> {
|
||||
// 中文注释:发布动作只提交动作名和作者公开信息。
|
||||
// 结果页当前 profile 必须先通过 sync_result_profile 写入 session;
|
||||
// SpacetimeDB 发布时再从 session.draft_profile_json 读取草稿真相,避免前端
|
||||
// draftProfile / legacyResultProfile / profile 旧载荷覆盖刚保存的内容。
|
||||
let payload_value = json!({
|
||||
"action": "publish_world",
|
||||
"authorPublicUserCode": author_public_user_code,
|
||||
"authorDisplayName": author_display_name,
|
||||
});
|
||||
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> {
|
||||
@@ -3414,6 +3412,36 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn publish_world_payload_only_contains_action_and_author_identity() {
|
||||
let payload_json =
|
||||
serialize_publish_world_action_payload("TN-0001".to_string(), "潮汐作者".to_string())
|
||||
.expect("publish payload serializes");
|
||||
let payload_value: Value =
|
||||
serde_json::from_str(&payload_json).expect("payload should be valid JSON");
|
||||
let object = payload_value
|
||||
.as_object()
|
||||
.expect("publish payload should be object");
|
||||
|
||||
assert_eq!(object.len(), 3);
|
||||
assert_eq!(
|
||||
object.get("action").and_then(Value::as_str),
|
||||
Some("publish_world")
|
||||
);
|
||||
assert_eq!(
|
||||
object.get("authorPublicUserCode").and_then(Value::as_str),
|
||||
Some("TN-0001")
|
||||
);
|
||||
assert_eq!(
|
||||
object.get("authorDisplayName").and_then(Value::as_str),
|
||||
Some("潮汐作者")
|
||||
);
|
||||
assert!(!object.contains_key("profile"));
|
||||
assert!(!object.contains_key("draftProfile"));
|
||||
assert!(!object.contains_key("legacyResultProfile"));
|
||||
assert!(!object.contains_key("settingText"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn custom_world_library_profile_payload_is_canonicalized_on_server() {
|
||||
let (profile, metadata) = canonicalize_custom_world_library_profile_payload(json!({
|
||||
|
||||
@@ -10,7 +10,9 @@ use axum::{
|
||||
response::Response,
|
||||
};
|
||||
use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64_STANDARD};
|
||||
use image::{DynamicImage, GenericImageView, imageops::FilterType};
|
||||
use image::{
|
||||
DynamicImage, GenericImageView, ImageFormat, codecs::jpeg::JpegEncoder, imageops::FilterType,
|
||||
};
|
||||
use module_assets::{
|
||||
AssetObjectAccessPolicy, AssetObjectFieldError, build_asset_entity_binding_input,
|
||||
build_asset_object_upsert_input, generate_asset_binding_id, generate_asset_object_id,
|
||||
@@ -375,6 +377,8 @@ const OPENING_CG_ENTITY_KIND: &str = "custom_world_profile";
|
||||
const OPENING_CG_STORYBOARD_SLOT: &str = "opening_cg_storyboard";
|
||||
const OPENING_CG_VIDEO_SLOT: &str = "opening_cg_video";
|
||||
const ARK_VIDEO_TASK_POLL_INTERVAL_MS: u64 = 5_000;
|
||||
const OPENING_CG_REFERENCE_MAX_EDGE: u32 = 768;
|
||||
const OPENING_CG_REFERENCE_JPEG_QUALITY: u8 = 82;
|
||||
|
||||
struct CoverPromptContext {
|
||||
opening_act_title: String,
|
||||
@@ -1025,6 +1029,16 @@ pub async fn generate_custom_world_opening_cg(
|
||||
"openingSceneImageSrc",
|
||||
)
|
||||
.await?;
|
||||
let player_role_reference = resize_image_reference_data_url(
|
||||
player_role_reference,
|
||||
OPENING_CG_REFERENCE_MAX_EDGE,
|
||||
OPENING_CG_REFERENCE_JPEG_QUALITY,
|
||||
)?;
|
||||
let opening_scene_reference = resize_image_reference_data_url(
|
||||
opening_scene_reference,
|
||||
OPENING_CG_REFERENCE_MAX_EDGE,
|
||||
OPENING_CG_REFERENCE_JPEG_QUALITY,
|
||||
)?;
|
||||
let storyboard = generate_opening_cg_storyboard(
|
||||
&state,
|
||||
&owner_user_id,
|
||||
@@ -1617,6 +1631,52 @@ async fn resolve_reference_image_as_data_url(
|
||||
))
|
||||
}
|
||||
|
||||
fn resize_image_reference_data_url(
|
||||
data_url: String,
|
||||
max_edge: u32,
|
||||
jpeg_quality: u8,
|
||||
) -> Result<String, AppError> {
|
||||
if max_edge == 0 {
|
||||
return Ok(data_url);
|
||||
}
|
||||
let Some(parsed) = parse_image_data_url(data_url.as_str()) else {
|
||||
return Ok(data_url);
|
||||
};
|
||||
let image = image::load_from_memory(parsed.bytes.as_slice()).map_err(|error| {
|
||||
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
|
||||
"provider": "custom-world-ai",
|
||||
"message": format!("无法解析参考图:{error}"),
|
||||
}))
|
||||
})?;
|
||||
let (width, height) = image.dimensions();
|
||||
let already_within_budget = width <= max_edge && height <= max_edge;
|
||||
if already_within_budget && parsed.mime_type == "image/jpeg" {
|
||||
return Ok(data_url);
|
||||
}
|
||||
|
||||
// 中文注释:开局 CG 故事板会同时带角色和场景两张参考图;先压到较小 JPEG,避免大图 PNG Data URL 让 VectorEngine 网关在请求发送阶段中断。
|
||||
let resized = if already_within_budget {
|
||||
image
|
||||
} else {
|
||||
image.resize(max_edge, max_edge, FilterType::Triangle)
|
||||
};
|
||||
let encoded_image = DynamicImage::ImageRgb8(resized.to_rgb8());
|
||||
let mut encoded = Vec::new();
|
||||
JpegEncoder::new_with_quality(&mut encoded, jpeg_quality)
|
||||
.encode_image(&encoded_image)
|
||||
.map_err(|error| {
|
||||
AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_details(json!({
|
||||
"provider": "custom-world-ai",
|
||||
"message": format!("压缩参考图失败:{error}"),
|
||||
}))
|
||||
})?;
|
||||
|
||||
Ok(format!(
|
||||
"data:image/jpeg;base64,{}",
|
||||
BASE64_STANDARD.encode(encoded)
|
||||
))
|
||||
}
|
||||
|
||||
async fn create_text_to_image_generation(
|
||||
http_client: &reqwest::Client,
|
||||
settings: &DashScopeSettings,
|
||||
@@ -3065,6 +3125,34 @@ mod tests {
|
||||
assert_eq!(parsed.bytes, b"hello".to_vec());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn opening_cg_reference_data_url_is_resized_to_request_budget() {
|
||||
let image = DynamicImage::ImageRgb8(image::RgbImage::new(2048, 1152));
|
||||
let mut cursor = std::io::Cursor::new(Vec::new());
|
||||
image
|
||||
.write_to(&mut cursor, ImageFormat::Png)
|
||||
.expect("test image should encode");
|
||||
let data_url = format!(
|
||||
"data:image/png;base64,{}",
|
||||
BASE64_STANDARD.encode(cursor.into_inner())
|
||||
);
|
||||
|
||||
let resized = resize_image_reference_data_url(
|
||||
data_url,
|
||||
OPENING_CG_REFERENCE_MAX_EDGE,
|
||||
OPENING_CG_REFERENCE_JPEG_QUALITY,
|
||||
)
|
||||
.expect("reference should resize");
|
||||
let parsed = parse_image_data_url(resized.as_str()).expect("resized data url should parse");
|
||||
let resized_image =
|
||||
image::load_from_memory(parsed.bytes.as_slice()).expect("resized image should decode");
|
||||
let (width, height) = resized_image.dimensions();
|
||||
|
||||
assert!(width <= OPENING_CG_REFERENCE_MAX_EDGE);
|
||||
assert!(height <= OPENING_CG_REFERENCE_MAX_EDGE);
|
||||
assert_eq!(parsed.mime_type, "image/jpeg");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn push_cover_reference_source_keeps_full_data_url() {
|
||||
let mut sources = Vec::new();
|
||||
|
||||
@@ -544,7 +544,7 @@ pub fn build_custom_world_published_profile_compile_snapshot(
|
||||
let subtitle = resolve_text_field(&draft, &legacy, "subtitle").unwrap_or_default();
|
||||
let summary_text = resolve_text_field(&draft, &legacy, "summary").unwrap_or_default();
|
||||
let cover_image_src = resolve_cover_image_src(&draft, &legacy);
|
||||
let theme_mode = resolve_theme_mode(&legacy);
|
||||
let theme_mode = resolve_theme_mode(&draft, &legacy);
|
||||
let playable_npc_count =
|
||||
count_distinct_roles(draft.get("playableNpcs"), draft.get("storyNpcs"));
|
||||
let landmark_count = to_array(draft.get("landmarks")).len() as u32;
|
||||
@@ -599,6 +599,37 @@ pub fn canonicalize_custom_world_profile_before_save(profile: &mut Value) -> boo
|
||||
true
|
||||
}
|
||||
|
||||
pub fn resolve_custom_world_publish_setting_text(
|
||||
payload: &Map<String, Value>,
|
||||
draft_profile: &Map<String, Value>,
|
||||
seed_text: &str,
|
||||
) -> String {
|
||||
// 中文注释:发布按钮的前端契约只保证提交动作名;正式 settingText 必须从草稿真相补齐,
|
||||
// 避免旧会话 seed_text 为空时通过 publish gate,却在最终 compile/publish 阶段失败。
|
||||
read_nested_text_field(payload, &["settingText"])
|
||||
.or_else(|| {
|
||||
read_nested_text_field(
|
||||
draft_profile,
|
||||
&[
|
||||
"settingText",
|
||||
"creatorIntent.rawSettingText",
|
||||
"creatorIntent.worldHook",
|
||||
"worldHook",
|
||||
"anchorContent.worldPromise",
|
||||
"anchorContent.worldPromise.hook",
|
||||
"summary",
|
||||
"name",
|
||||
"title",
|
||||
],
|
||||
)
|
||||
})
|
||||
.or_else(|| {
|
||||
let seed = seed_text.trim();
|
||||
(!seed.is_empty()).then(|| seed.to_string())
|
||||
})
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
pub fn empty_agent_anchor_content_json() -> String {
|
||||
r#"{"worldPromise":null,"playerFantasy":null,"themeBoundary":null,"playerEntryPoint":null,"coreConflict":null,"keyRelationships":null,"hiddenLines":null,"iconicElements":null}"#.to_string()
|
||||
}
|
||||
@@ -663,7 +694,13 @@ fn parse_optional_json_object(
|
||||
error: CustomWorldFieldError,
|
||||
) -> Result<Map<String, Value>, CustomWorldFieldError> {
|
||||
match normalize_optional_json_slice(value) {
|
||||
Some(value) => parse_required_json_object(&value, error),
|
||||
Some(value) => match serde_json::from_str::<Value>(&value) {
|
||||
Ok(Value::Object(object)) => Ok(object),
|
||||
// 中文注释:跨层可选字段经 serde 结构体序列化后可能显式落成 null;
|
||||
// 对 optional JSON object 而言 null 等价于未提供,不能阻断发布链路。
|
||||
Ok(Value::Null) => Ok(Map::new()),
|
||||
_ => Err(error),
|
||||
},
|
||||
None => Ok(Map::new()),
|
||||
}
|
||||
}
|
||||
@@ -804,6 +841,32 @@ fn read_text(object: &Map<String, Value>, key: &str) -> Option<String> {
|
||||
.map(ToOwned::to_owned)
|
||||
}
|
||||
|
||||
fn read_nested_text_field(object: &Map<String, Value>, keys: &[&str]) -> Option<String> {
|
||||
for key in keys {
|
||||
let mut current = Value::Object(object.clone());
|
||||
let mut found = true;
|
||||
for segment in key.split('.') {
|
||||
if let Some(next) = current.get(segment) {
|
||||
current = next.clone();
|
||||
} else {
|
||||
found = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if found {
|
||||
if let Some(value) = current
|
||||
.as_str()
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
{
|
||||
return Some(value.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
fn read_string_list(object: &Map<String, Value>, key: &str) -> Vec<String> {
|
||||
object
|
||||
.get(key)
|
||||
@@ -849,11 +912,17 @@ fn resolve_text_field(
|
||||
legacy: &Map<String, Value>,
|
||||
key: &str,
|
||||
) -> Option<String> {
|
||||
// 中文注释:发布链路的草稿真相来自 session.draft_profile_json,
|
||||
// legacyResultProfile 只补历史草稿缺失字段,不能覆盖结果页刚保存的内容。
|
||||
to_text(draft.get(key)).or_else(|| to_text(legacy.get(key)))
|
||||
}
|
||||
|
||||
fn resolve_theme_mode(legacy: &Map<String, Value>) -> CustomWorldThemeMode {
|
||||
to_text(legacy.get("themeMode"))
|
||||
fn resolve_theme_mode(
|
||||
draft: &Map<String, Value>,
|
||||
legacy: &Map<String, Value>,
|
||||
) -> CustomWorldThemeMode {
|
||||
to_text(draft.get("themeMode"))
|
||||
.or_else(|| to_text(legacy.get("themeMode")))
|
||||
.and_then(|value| CustomWorldThemeMode::from_client_str(&value))
|
||||
.unwrap_or(CustomWorldThemeMode::Mythic)
|
||||
}
|
||||
@@ -955,3 +1024,139 @@ fn build_compiled_profile_payload_json(
|
||||
serde_json::to_string(&Value::Object(payload))
|
||||
.map_err(|_| CustomWorldFieldError::InvalidDraftProfileJson)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
fn build_test_compile_input(
|
||||
legacy_result_profile_json: Option<String>,
|
||||
) -> CustomWorldPublishedProfileCompileInput {
|
||||
CustomWorldPublishedProfileCompileInput {
|
||||
session_id: "session-1".to_string(),
|
||||
profile_id: "cwprof_001".to_string(),
|
||||
owner_user_id: "user-1".to_string(),
|
||||
draft_profile_json: json!({
|
||||
"name": "潮雾列岛",
|
||||
"summary": "群岛与旧灯塔之间的沉船疑案。",
|
||||
"playableNpcs": [],
|
||||
"storyNpcs": [],
|
||||
"landmarks": []
|
||||
})
|
||||
.to_string(),
|
||||
legacy_result_profile_json,
|
||||
setting_text: "海图会在午夜改写群岛航路。".to_string(),
|
||||
author_display_name: "创作者".to_string(),
|
||||
updated_at_micros: 1,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn published_profile_compile_treats_null_legacy_result_profile_as_absent() {
|
||||
let snapshot = build_custom_world_published_profile_compile_snapshot(
|
||||
build_test_compile_input(Some("null".to_string())),
|
||||
)
|
||||
.expect("null legacy result profile should be treated as absent");
|
||||
|
||||
assert_eq!(snapshot.profile_id, "cwprof_001");
|
||||
assert_eq!(snapshot.world_name, "潮雾列岛");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn published_profile_compile_rejects_non_object_legacy_result_profile() {
|
||||
let error = build_custom_world_published_profile_compile_snapshot(
|
||||
build_test_compile_input(Some("[]".to_string())),
|
||||
)
|
||||
.expect_err("array legacy result profile should still be invalid");
|
||||
|
||||
assert_eq!(error, CustomWorldFieldError::InvalidLegacyResultProfileJson);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn published_profile_compile_prefers_saved_draft_over_legacy_profile() {
|
||||
let input = CustomWorldPublishedProfileCompileInput {
|
||||
draft_profile_json: json!({
|
||||
"name": "结果页保存后的世界",
|
||||
"summary": "发布前最后一次填写的摘要。",
|
||||
"themeMode": "tide",
|
||||
"playableNpcs": [],
|
||||
"storyNpcs": [],
|
||||
"landmarks": []
|
||||
})
|
||||
.to_string(),
|
||||
legacy_result_profile_json: Some(
|
||||
json!({
|
||||
"name": "旧结果页世界",
|
||||
"summary": "旧摘要不应覆盖保存草稿。",
|
||||
"themeMode": "mythic"
|
||||
})
|
||||
.to_string(),
|
||||
),
|
||||
..build_test_compile_input(None)
|
||||
};
|
||||
|
||||
let snapshot = build_custom_world_published_profile_compile_snapshot(input)
|
||||
.expect("compile should prefer saved draft");
|
||||
let payload: Value = serde_json::from_str(&snapshot.compiled_profile_payload_json)
|
||||
.expect("compiled payload should be json");
|
||||
|
||||
assert_eq!(snapshot.world_name, "结果页保存后的世界");
|
||||
assert_eq!(snapshot.summary_text, "发布前最后一次填写的摘要。");
|
||||
assert_eq!(snapshot.theme_mode, CustomWorldThemeMode::Tide);
|
||||
assert_eq!(
|
||||
payload.get("name").and_then(Value::as_str),
|
||||
Some("结果页保存后的世界")
|
||||
);
|
||||
assert_eq!(
|
||||
payload.get("summary").and_then(Value::as_str),
|
||||
Some("发布前最后一次填写的摘要。")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn publish_setting_text_falls_back_to_draft_profile_when_seed_is_empty() {
|
||||
let payload = Map::new();
|
||||
let draft_profile = json!({
|
||||
"settingText": "海雾会吞掉记错航线的人。",
|
||||
"worldHook": "在失真的海图上追查一场被篡改的沉船事故。",
|
||||
"summary": "守灯人与群岛议会围绕沉船旧案对峙。"
|
||||
})
|
||||
.as_object()
|
||||
.cloned()
|
||||
.expect("draft profile should be object");
|
||||
|
||||
let setting_text = resolve_custom_world_publish_setting_text(&payload, &draft_profile, "");
|
||||
|
||||
assert_eq!(setting_text, "海雾会吞掉记错航线的人。");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn publish_setting_text_prefers_payload_then_draft_then_seed() {
|
||||
let mut payload = Map::new();
|
||||
payload.insert(
|
||||
"settingText".to_string(),
|
||||
Value::String("发布载荷设定".to_string()),
|
||||
);
|
||||
let draft_profile = json!({
|
||||
"worldHook": "草稿世界一句话",
|
||||
"summary": "草稿摘要"
|
||||
})
|
||||
.as_object()
|
||||
.cloned()
|
||||
.expect("draft profile should be object");
|
||||
|
||||
assert_eq!(
|
||||
resolve_custom_world_publish_setting_text(&payload, &draft_profile, "用户原始设定"),
|
||||
"发布载荷设定"
|
||||
);
|
||||
assert_eq!(
|
||||
resolve_custom_world_publish_setting_text(&Map::new(), &draft_profile, "用户原始设定"),
|
||||
"草稿世界一句话"
|
||||
);
|
||||
assert_eq!(
|
||||
resolve_custom_world_publish_setting_text(&Map::new(), &Map::new(), "用户原始设定"),
|
||||
"用户原始设定"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,8 +5,8 @@ use shared_contracts::runtime_story::{
|
||||
};
|
||||
|
||||
use crate::{
|
||||
battle::resolve_battle_action, build_status_patch, read_bool_field, read_i32_field,
|
||||
read_optional_string_field,
|
||||
StoryRuntimeActionResolveInput, battle::resolve_battle_action, build_status_patch,
|
||||
read_bool_field, read_i32_field, read_optional_string_field, resolve_story_runtime_action,
|
||||
};
|
||||
|
||||
fn build_battle_fixture() -> serde_json::Value {
|
||||
@@ -61,6 +61,115 @@ fn build_request(function_id: &str, option_text: &str) -> RuntimeStoryActionRequ
|
||||
}
|
||||
}
|
||||
|
||||
fn build_runtime_action_request(
|
||||
function_id: &str,
|
||||
action_text: &str,
|
||||
payload: Option<serde_json::Value>,
|
||||
) -> shared_contracts::story::ResolveStoryRuntimeActionRequest {
|
||||
shared_contracts::story::ResolveStoryRuntimeActionRequest {
|
||||
story_session_id: "storysess-1".to_string(),
|
||||
client_version: Some(1),
|
||||
function_id: function_id.to_string(),
|
||||
action_text: action_text.to_string(),
|
||||
target_id: None,
|
||||
payload,
|
||||
}
|
||||
}
|
||||
|
||||
fn build_custom_world_profile_with_two_landmarks() -> serde_json::Value {
|
||||
json!({
|
||||
"id": "profile-1",
|
||||
"name": "雾桥旧约",
|
||||
"summary": "雾桥边的旧约正在复苏。",
|
||||
"camp": {
|
||||
"id": "camp-1",
|
||||
"name": "雾桥营地",
|
||||
"description": "营火压着雾气。",
|
||||
"connections": [
|
||||
{
|
||||
"targetLandmarkId": "landmark-1",
|
||||
"relativePosition": "forward",
|
||||
"summary": "沿桥面继续前进"
|
||||
},
|
||||
{
|
||||
"targetLandmarkId": "landmark-2",
|
||||
"relativePosition": "right",
|
||||
"summary": "转入雾中支路"
|
||||
}
|
||||
]
|
||||
},
|
||||
"landmarks": [
|
||||
{
|
||||
"id": "landmark-1",
|
||||
"name": "断桥口",
|
||||
"description": "桥口挂着旧灯。"
|
||||
},
|
||||
{
|
||||
"id": "landmark-2",
|
||||
"name": "雾中渡",
|
||||
"description": "渡口只有潮声。"
|
||||
}
|
||||
],
|
||||
"storyNpcs": [
|
||||
{
|
||||
"id": "npc-bridge",
|
||||
"name": "桥影",
|
||||
"description": "桥下逼来的敌影",
|
||||
"initialAffinity": -20
|
||||
},
|
||||
{
|
||||
"id": "npc-ferryman",
|
||||
"name": "摆渡人",
|
||||
"description": "守着雾中渡的人",
|
||||
"initialAffinity": 0
|
||||
}
|
||||
],
|
||||
"sceneChapterBlueprints": [
|
||||
{
|
||||
"id": "chapter-camp",
|
||||
"sceneId": "camp-1",
|
||||
"linkedLandmarkIds": ["camp-1"],
|
||||
"acts": [
|
||||
{
|
||||
"id": "act-camp-1",
|
||||
"sceneId": "camp-1",
|
||||
"oppositeNpcId": "npc-bridge"
|
||||
},
|
||||
{
|
||||
"id": "act-camp-2",
|
||||
"sceneId": "camp-1",
|
||||
"oppositeNpcId": "npc-ferryman"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "chapter-landmark-1",
|
||||
"sceneId": "landmark-1",
|
||||
"linkedLandmarkIds": ["landmark-1"],
|
||||
"acts": [
|
||||
{
|
||||
"id": "act-landmark-1",
|
||||
"sceneId": "landmark-1",
|
||||
"oppositeNpcId": "npc-ferryman"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
})
|
||||
}
|
||||
|
||||
fn build_story_runtime_snapshot(
|
||||
game_state: serde_json::Value,
|
||||
current_story: Option<serde_json::Value>,
|
||||
) -> shared_contracts::story::StoryRuntimeSnapshotPayload {
|
||||
shared_contracts::story::StoryRuntimeSnapshotPayload {
|
||||
saved_at: None,
|
||||
bottom_tab: "adventure".to_string(),
|
||||
game_state,
|
||||
current_story,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn battle_resolution_prefers_player_defeat_when_both_sides_fall_in_same_turn() {
|
||||
let request = build_request("battle_all_in_crush", "全力压制");
|
||||
@@ -89,3 +198,210 @@ fn battle_resolution_prefers_player_defeat_when_both_sides_fall_in_same_turn() {
|
||||
Some("defeat".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn terminal_battle_action_persists_post_battle_continue_story() {
|
||||
let mut game_state = build_battle_fixture();
|
||||
game_state["runtimeSessionId"] = json!("runtime-1");
|
||||
game_state["currentScene"] = json!("Story");
|
||||
game_state["worldType"] = json!("CUSTOM");
|
||||
game_state["playerHp"] = json!(30);
|
||||
game_state["customWorldProfile"] = build_custom_world_profile_with_two_landmarks();
|
||||
game_state["currentScenePreset"] = json!({
|
||||
"id": "custom-scene-camp",
|
||||
"name": "雾桥营地",
|
||||
"description": "营火压着雾气。",
|
||||
"connectedSceneIds": ["custom-scene-landmark-1", "custom-scene-landmark-2"],
|
||||
"forwardSceneId": "custom-scene-landmark-1",
|
||||
"treasureHints": [],
|
||||
"npcs": []
|
||||
});
|
||||
game_state["storyEngineMemory"] = json!({
|
||||
"currentSceneActState": {
|
||||
"sceneId": "camp-1",
|
||||
"chapterId": "chapter-camp",
|
||||
"currentActId": "act-camp-1",
|
||||
"currentActIndex": 0,
|
||||
"completedActIds": [],
|
||||
"visitedActIds": ["act-camp-1"]
|
||||
}
|
||||
});
|
||||
|
||||
let output = resolve_story_runtime_action(StoryRuntimeActionResolveInput {
|
||||
story_session_id: "storysess-1".to_string(),
|
||||
runtime_session_id: "runtime-1".to_string(),
|
||||
snapshot: build_story_runtime_snapshot(game_state, None),
|
||||
request: build_runtime_action_request("battle_all_in_crush", "全力压制", None),
|
||||
})
|
||||
.expect("terminal battle should resolve");
|
||||
|
||||
assert_eq!(
|
||||
output.presentation.battle.unwrap().outcome.as_deref(),
|
||||
Some("victory")
|
||||
);
|
||||
assert_eq!(
|
||||
output.presentation.options[0].function_id,
|
||||
"story_continue_adventure"
|
||||
);
|
||||
assert_eq!(
|
||||
output.snapshot.current_story.as_ref().unwrap()["options"][0]["functionId"],
|
||||
json!("story_continue_adventure")
|
||||
);
|
||||
assert!(
|
||||
output.snapshot.current_story.as_ref().unwrap()["deferredOptions"]
|
||||
.as_array()
|
||||
.is_some_and(|items| {
|
||||
items
|
||||
.iter()
|
||||
.any(|item| item["functionId"] == json!("idle_travel_next_scene"))
|
||||
})
|
||||
);
|
||||
assert_eq!(
|
||||
output.snapshot.current_story.as_ref().unwrap()["deferredRuntimeState"]["storyEngineMemory"]
|
||||
["currentSceneActState"]["currentActId"],
|
||||
json!("act-camp-2")
|
||||
);
|
||||
assert_eq!(
|
||||
output.snapshot.game_state["storyEngineMemory"]["currentSceneActState"]["currentActId"],
|
||||
json!("act-camp-2")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn idle_travel_next_scene_changes_scene_from_target_payload() {
|
||||
let game_state = json!({
|
||||
"runtimeSessionId": "runtime-1",
|
||||
"runtimeActionVersion": 1,
|
||||
"currentScene": "Story",
|
||||
"worldType": "CUSTOM",
|
||||
"customWorldProfile": build_custom_world_profile_with_two_landmarks(),
|
||||
"playerHp": 30,
|
||||
"playerMaxHp": 40,
|
||||
"playerMana": 10,
|
||||
"playerMaxMana": 20,
|
||||
"playerCurrency": 0,
|
||||
"playerInventory": [],
|
||||
"playerEquipment": { "weapon": null, "armor": null, "relic": null },
|
||||
"runtimeStats": {
|
||||
"hostileNpcsDefeated": 0,
|
||||
"itemsUsed": 0,
|
||||
"questsAccepted": 0,
|
||||
"scenesTraveled": 0,
|
||||
"playTimeMs": 0,
|
||||
"lastPlayTickAt": null
|
||||
},
|
||||
"currentScenePreset": {
|
||||
"id": "custom-scene-camp",
|
||||
"name": "雾桥营地",
|
||||
"description": "营火压着雾气。",
|
||||
"connectedSceneIds": ["custom-scene-landmark-1", "custom-scene-landmark-2"],
|
||||
"connections": [
|
||||
{
|
||||
"sceneId": "custom-scene-landmark-1",
|
||||
"relativePosition": "forward",
|
||||
"summary": "沿桥面继续前进"
|
||||
}
|
||||
],
|
||||
"forwardSceneId": "custom-scene-landmark-1",
|
||||
"treasureHints": [],
|
||||
"npcs": []
|
||||
},
|
||||
"currentEncounter": null,
|
||||
"npcInteractionActive": false,
|
||||
"sceneHostileNpcs": [],
|
||||
"inBattle": false,
|
||||
"storyHistory": [],
|
||||
"storyEngineMemory": {}
|
||||
});
|
||||
|
||||
let output = resolve_story_runtime_action(StoryRuntimeActionResolveInput {
|
||||
story_session_id: "storysess-1".to_string(),
|
||||
runtime_session_id: "runtime-1".to_string(),
|
||||
snapshot: build_story_runtime_snapshot(game_state, None),
|
||||
request: build_runtime_action_request(
|
||||
"idle_travel_next_scene",
|
||||
"向前走,前往断桥口",
|
||||
Some(json!({ "targetSceneId": "custom-scene-landmark-1" })),
|
||||
),
|
||||
})
|
||||
.expect("travel action should resolve");
|
||||
|
||||
assert_eq!(
|
||||
output.snapshot.game_state["currentScenePreset"]["id"],
|
||||
json!("custom-scene-landmark-1")
|
||||
);
|
||||
assert_eq!(
|
||||
output.snapshot.game_state["runtimeStats"]["scenesTraveled"],
|
||||
json!(1)
|
||||
);
|
||||
assert_eq!(
|
||||
output.snapshot.game_state["currentEncounter"]["id"],
|
||||
json!("npc-ferryman")
|
||||
);
|
||||
assert_eq!(
|
||||
output.snapshot.game_state["storyEngineMemory"]["currentSceneActState"]["currentActId"],
|
||||
json!("act-landmark-1")
|
||||
);
|
||||
assert!(output.presentation.options.iter().any(|option| {
|
||||
option.function_id == "idle_travel_next_scene"
|
||||
|| option.function_id == "idle_explore_forward"
|
||||
}));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn idle_travel_next_scene_normalizes_custom_landmark_id_payload() {
|
||||
let game_state = json!({
|
||||
"runtimeSessionId": "runtime-1",
|
||||
"runtimeActionVersion": 1,
|
||||
"currentScene": "Story",
|
||||
"worldType": "CUSTOM",
|
||||
"customWorldProfile": build_custom_world_profile_with_two_landmarks(),
|
||||
"playerHp": 30,
|
||||
"playerMaxHp": 40,
|
||||
"playerMana": 10,
|
||||
"playerMaxMana": 20,
|
||||
"playerCurrency": 0,
|
||||
"playerInventory": [],
|
||||
"playerEquipment": { "weapon": null, "armor": null, "relic": null },
|
||||
"runtimeStats": {
|
||||
"hostileNpcsDefeated": 0,
|
||||
"itemsUsed": 0,
|
||||
"questsAccepted": 0,
|
||||
"scenesTraveled": 0,
|
||||
"playTimeMs": 0,
|
||||
"lastPlayTickAt": null
|
||||
},
|
||||
"currentScenePreset": {
|
||||
"id": "custom-scene-camp",
|
||||
"name": "雾桥营地",
|
||||
"description": "营火压着雾气。",
|
||||
"connectedSceneIds": ["landmark-1", "landmark-2"],
|
||||
"forwardSceneId": "landmark-2",
|
||||
"treasureHints": [],
|
||||
"npcs": []
|
||||
},
|
||||
"currentEncounter": null,
|
||||
"npcInteractionActive": false,
|
||||
"sceneHostileNpcs": [],
|
||||
"inBattle": false,
|
||||
"storyHistory": [],
|
||||
"storyEngineMemory": {}
|
||||
});
|
||||
|
||||
let output = resolve_story_runtime_action(StoryRuntimeActionResolveInput {
|
||||
story_session_id: "storysess-1".to_string(),
|
||||
runtime_session_id: "runtime-1".to_string(),
|
||||
snapshot: build_story_runtime_snapshot(game_state, None),
|
||||
request: build_runtime_action_request(
|
||||
"idle_travel_next_scene",
|
||||
"前往雾中渡",
|
||||
Some(json!({ "targetSceneId": "landmark-2" })),
|
||||
),
|
||||
})
|
||||
.expect("raw custom landmark id should resolve");
|
||||
|
||||
assert_eq!(
|
||||
output.snapshot.game_state["currentScenePreset"]["id"],
|
||||
json!("custom-scene-landmark-2")
|
||||
);
|
||||
}
|
||||
|
||||
@@ -76,7 +76,9 @@ pub use options::{
|
||||
build_static_runtime_story_option, build_story_option_from_runtime_option, infer_option_scope,
|
||||
};
|
||||
pub use post_battle::{
|
||||
finalize_post_battle_resolution, is_terminal_battle_outcome, resolve_post_battle_story_options,
|
||||
clear_post_battle_state, ensure_scene_act_state, ensure_scene_encounter_preview,
|
||||
finalize_post_battle_resolution, is_terminal_battle_outcome, resolve_forward_scene_id,
|
||||
resolve_post_battle_story_options, resolve_runtime_scene_preset,
|
||||
};
|
||||
pub use projection::{StoryRuntimeProjectionSource, build_story_runtime_projection};
|
||||
pub use prompt_context::{RuntimeStoryPromptContextExtras, build_runtime_story_prompt_context};
|
||||
|
||||
@@ -2,10 +2,11 @@ use serde_json::{Value, json};
|
||||
use shared_contracts::runtime_story::RuntimeStoryOptionView;
|
||||
|
||||
use crate::{
|
||||
CONTINUE_ADVENTURE_FUNCTION_ID, build_static_runtime_story_option,
|
||||
CONTINUE_ADVENTURE_FUNCTION_ID, build_custom_scene_preset, build_static_runtime_story_option,
|
||||
build_story_option_from_runtime_option, ensure_json_object, read_array_field, read_bool_field,
|
||||
read_field, read_i32_field, read_object_field, read_optional_string_field, write_bool_field,
|
||||
write_i32_field, write_null_field, write_string_field,
|
||||
read_field, read_i32_field, read_object_field, read_optional_string_field,
|
||||
resolve_custom_runtime_scene_id, write_bool_field, write_i32_field, write_null_field,
|
||||
write_string_field,
|
||||
};
|
||||
|
||||
const WUXIA_FIRST_SCENE_ID: &str = "wuxia-bamboo-road";
|
||||
@@ -36,6 +37,8 @@ pub fn finalize_post_battle_resolution(
|
||||
return None;
|
||||
}
|
||||
|
||||
let original_scene_act_state = current_scene_act_state(game_state);
|
||||
|
||||
if outcome == "defeat" {
|
||||
return Some(finalize_defeat_revive(game_state, fallback_options));
|
||||
}
|
||||
@@ -45,6 +48,7 @@ pub fn finalize_post_battle_resolution(
|
||||
game_state,
|
||||
result_text,
|
||||
fallback_options,
|
||||
original_scene_act_state,
|
||||
));
|
||||
}
|
||||
|
||||
@@ -64,13 +68,14 @@ fn finalize_victory_or_spar(
|
||||
game_state: &mut Value,
|
||||
result_text: &str,
|
||||
fallback_options: Vec<RuntimeStoryOptionView>,
|
||||
original_scene_act_state: Option<Value>,
|
||||
) -> PostBattleFinalization {
|
||||
clear_post_battle_state(game_state);
|
||||
let is_last_act = is_current_scene_act_last(game_state);
|
||||
let next_act_state = if is_last_act {
|
||||
None
|
||||
} else {
|
||||
resolve_next_scene_act_runtime_state(game_state)
|
||||
resolve_next_scene_act_runtime_state(game_state, original_scene_act_state.as_ref())
|
||||
};
|
||||
if let Some(next_act_state) = next_act_state {
|
||||
write_current_scene_act_state(game_state, next_act_state);
|
||||
@@ -141,7 +146,7 @@ fn finalize_defeat_revive(
|
||||
{
|
||||
write_current_scene_act_state(game_state, first_act_state);
|
||||
}
|
||||
ensure_first_scene_encounter_preview(game_state);
|
||||
ensure_scene_encounter_preview(game_state);
|
||||
|
||||
let story_text = if first_scene.name.is_empty() {
|
||||
"你在战斗中倒下,随后重新醒来。".to_string()
|
||||
@@ -160,7 +165,7 @@ fn finalize_defeat_revive(
|
||||
}
|
||||
}
|
||||
|
||||
fn clear_post_battle_state(game_state: &mut Value) {
|
||||
pub fn clear_post_battle_state(game_state: &mut Value) {
|
||||
write_null_field(game_state, "currentEncounter");
|
||||
write_bool_field(game_state, "npcInteractionActive", false);
|
||||
ensure_json_object(game_state).insert("sceneHostileNpcs".to_string(), Value::Array(Vec::new()));
|
||||
@@ -421,7 +426,7 @@ fn write_first_scene(game_state: &mut Value, scene: &RuntimeScene) {
|
||||
);
|
||||
}
|
||||
|
||||
fn ensure_first_scene_encounter_preview(game_state: &mut Value) {
|
||||
pub fn ensure_scene_encounter_preview(game_state: &mut Value) {
|
||||
if read_bool_field(game_state, "inBattle").unwrap_or(false) {
|
||||
return;
|
||||
}
|
||||
@@ -436,7 +441,13 @@ fn ensure_first_scene_encounter_preview(game_state: &mut Value) {
|
||||
};
|
||||
let scene_id = read_object_field(game_state, "currentScenePreset")
|
||||
.and_then(|scene| read_optional_string_field(scene, "id"));
|
||||
let focus_npc_id = resolve_active_scene_act_focus_npc_id(profile, scene_id.as_deref());
|
||||
let current_act_id = current_scene_act_state(game_state)
|
||||
.and_then(|state| read_optional_string_field(&state, "currentActId"));
|
||||
let focus_npc_id = resolve_active_scene_act_focus_npc_id(
|
||||
profile,
|
||||
scene_id.as_deref(),
|
||||
current_act_id.as_deref(),
|
||||
);
|
||||
let Some(focus_npc_id) = focus_npc_id else {
|
||||
return;
|
||||
};
|
||||
@@ -450,6 +461,22 @@ fn ensure_first_scene_encounter_preview(game_state: &mut Value) {
|
||||
);
|
||||
}
|
||||
|
||||
pub fn ensure_scene_act_state(game_state: &mut Value) {
|
||||
if read_bool_field(game_state, "inBattle").unwrap_or(false) {
|
||||
return;
|
||||
}
|
||||
let Some(scene_id) = read_object_field(game_state, "currentScenePreset")
|
||||
.and_then(|scene| read_optional_string_field(scene, "id"))
|
||||
else {
|
||||
return;
|
||||
};
|
||||
let Some(act_state) = build_initial_scene_act_runtime_state(game_state, scene_id.as_str())
|
||||
else {
|
||||
return;
|
||||
};
|
||||
write_current_scene_act_state(game_state, act_state);
|
||||
}
|
||||
|
||||
fn build_scene_travel_options(game_state: &Value) -> Vec<RuntimeStoryOptionView> {
|
||||
let Some(current_scene) = read_object_field(game_state, "currentScenePreset") else {
|
||||
return vec![build_static_runtime_story_option(
|
||||
@@ -459,32 +486,53 @@ fn build_scene_travel_options(game_state: &Value) -> Vec<RuntimeStoryOptionView>
|
||||
)];
|
||||
};
|
||||
let current_scene_id = read_optional_string_field(current_scene, "id");
|
||||
let mut options = read_array_field(current_scene, "connections")
|
||||
.into_iter()
|
||||
.filter_map(|connection| {
|
||||
let scene_id = read_optional_string_field(connection, "sceneId")?;
|
||||
if current_scene_id.as_deref() == Some(scene_id.as_str()) {
|
||||
return None;
|
||||
let forward_scene_id = read_optional_string_field(current_scene, "forwardSceneId");
|
||||
let mut option_scene_ids = Vec::new();
|
||||
let mut options = Vec::new();
|
||||
|
||||
for connection in read_array_field(current_scene, "connections") {
|
||||
let Some(scene_id) = read_optional_string_field(connection, "sceneId") else {
|
||||
continue;
|
||||
};
|
||||
if current_scene_id.as_deref() == Some(scene_id.as_str())
|
||||
|| option_scene_ids.iter().any(|id| id == scene_id.as_str())
|
||||
{
|
||||
continue;
|
||||
}
|
||||
let relative_position = read_optional_string_field(connection, "relativePosition")
|
||||
.unwrap_or_else(|| "forward".to_string());
|
||||
let scene_name = resolve_scene_name(game_state, scene_id.as_str())
|
||||
.unwrap_or_else(|| scene_id.clone());
|
||||
Some(RuntimeStoryOptionView {
|
||||
payload: Some(json!({ "targetSceneId": scene_id })),
|
||||
..build_static_runtime_story_option(
|
||||
"idle_travel_next_scene",
|
||||
format!(
|
||||
"{},前往{}",
|
||||
direction_text(relative_position.as_str()),
|
||||
scene_name
|
||||
)
|
||||
.as_str(),
|
||||
"story",
|
||||
)
|
||||
})
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
options.push(build_scene_travel_option(
|
||||
game_state,
|
||||
scene_id.as_str(),
|
||||
relative_position.as_str(),
|
||||
));
|
||||
option_scene_ids.push(scene_id);
|
||||
}
|
||||
|
||||
for scene_id in read_array_field(current_scene, "connectedSceneIds")
|
||||
.into_iter()
|
||||
.filter_map(|scene_id| scene_id.as_str().map(str::to_string))
|
||||
.chain(forward_scene_id.clone())
|
||||
{
|
||||
// 中文注释:bootstrap 生成的旧快照常只有 connectedSceneIds / forwardSceneId,
|
||||
// 没有展开 connections;这里也要生成旅行 action,避免战后只剩默认 idle 选项循环。
|
||||
if current_scene_id.as_deref() == Some(scene_id.as_str())
|
||||
|| option_scene_ids.iter().any(|id| id == scene_id.as_str())
|
||||
{
|
||||
continue;
|
||||
}
|
||||
let relative_position = if forward_scene_id.as_deref() == Some(scene_id.as_str()) {
|
||||
"forward"
|
||||
} else {
|
||||
"portal"
|
||||
};
|
||||
options.push(build_scene_travel_option(
|
||||
game_state,
|
||||
scene_id.as_str(),
|
||||
relative_position,
|
||||
));
|
||||
option_scene_ids.push(scene_id);
|
||||
}
|
||||
|
||||
if options.is_empty() {
|
||||
options.push(build_static_runtime_story_option(
|
||||
@@ -497,6 +545,163 @@ fn build_scene_travel_options(game_state: &Value) -> Vec<RuntimeStoryOptionView>
|
||||
options
|
||||
}
|
||||
|
||||
fn build_scene_travel_option(
|
||||
game_state: &Value,
|
||||
scene_id: &str,
|
||||
relative_position: &str,
|
||||
) -> RuntimeStoryOptionView {
|
||||
let scene_name =
|
||||
resolve_scene_name(game_state, scene_id).unwrap_or_else(|| scene_id.to_string());
|
||||
RuntimeStoryOptionView {
|
||||
payload: Some(json!({ "targetSceneId": scene_id })),
|
||||
..build_static_runtime_story_option(
|
||||
"idle_travel_next_scene",
|
||||
format!("{},前往{}", direction_text(relative_position), scene_name).as_str(),
|
||||
"story",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
pub fn resolve_runtime_scene_preset(game_state: &Value, scene_id: &str) -> Option<Value> {
|
||||
let normalized_scene_id = scene_id.trim();
|
||||
if normalized_scene_id.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
if let Some(profile) = read_object_field(game_state, "customWorldProfile")
|
||||
&& let Some(scene) = build_custom_scene_preset(
|
||||
profile,
|
||||
resolve_custom_runtime_scene_id(profile, normalized_scene_id).as_str(),
|
||||
)
|
||||
{
|
||||
return Some(scene);
|
||||
}
|
||||
|
||||
resolve_builtin_runtime_scene_preset(game_state, normalized_scene_id)
|
||||
}
|
||||
|
||||
pub fn resolve_forward_scene_id(game_state: &Value) -> Option<String> {
|
||||
read_object_field(game_state, "currentScenePreset").and_then(|scene| {
|
||||
read_optional_string_field(scene, "forwardSceneId")
|
||||
.or_else(|| {
|
||||
read_array_field(scene, "connections")
|
||||
.into_iter()
|
||||
.find_map(|connection| read_optional_string_field(connection, "sceneId"))
|
||||
})
|
||||
.or_else(|| {
|
||||
read_array_field(scene, "connectedSceneIds")
|
||||
.into_iter()
|
||||
.find_map(|scene_id| scene_id.as_str().map(str::to_string))
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
fn resolve_builtin_runtime_scene_preset(game_state: &Value, scene_id: &str) -> Option<Value> {
|
||||
let template = builtin_runtime_scene_template(scene_id)?;
|
||||
Some(json!({
|
||||
"id": template.id,
|
||||
"name": template.name,
|
||||
"description": template.description,
|
||||
"imageSrc": read_object_field(game_state, "currentScenePreset")
|
||||
.and_then(|scene| read_optional_string_field(scene, "imageSrc"))
|
||||
.unwrap_or_default(),
|
||||
"connectedSceneIds": template.connected_scene_ids,
|
||||
"connections": template.connections,
|
||||
"forwardSceneId": template.forward_scene_id,
|
||||
"treasureHints": template.treasure_hints,
|
||||
"npcs": [],
|
||||
}))
|
||||
}
|
||||
|
||||
fn builtin_runtime_scene_template(scene_id: &str) -> Option<RuntimeScene> {
|
||||
let is_xianxia = matches!(
|
||||
scene_id,
|
||||
"xianxia-cloud-gate"
|
||||
| "xianxia-floating-isle"
|
||||
| "xianxia-celestial-corridor"
|
||||
| "xianxia-star-vessel"
|
||||
);
|
||||
if is_xianxia {
|
||||
return Some(RuntimeScene {
|
||||
id: scene_id.to_string(),
|
||||
name: match scene_id {
|
||||
"xianxia-floating-isle" => "浮空灵岛",
|
||||
"xianxia-celestial-corridor" => "天门长廊",
|
||||
"xianxia-star-vessel" => "星槎泊台",
|
||||
_ => XIANXIA_FIRST_SCENE_NAME,
|
||||
}
|
||||
.to_string(),
|
||||
description: match scene_id {
|
||||
"xianxia-floating-isle" => "浮岛边缘灵雾翻涌,远处有阵纹一明一暗。",
|
||||
"xianxia-celestial-corridor" => "长廊悬在云海上方,符光沿石柱缓慢游走。",
|
||||
"xianxia-star-vessel" => "星槎泊在云海边缘,船身仍有星砂微光。",
|
||||
_ => XIANXIA_FIRST_SCENE_DESCRIPTION,
|
||||
}
|
||||
.to_string(),
|
||||
image_src: String::new(),
|
||||
connected_scene_ids: vec![
|
||||
"xianxia-cloud-gate".to_string(),
|
||||
"xianxia-floating-isle".to_string(),
|
||||
"xianxia-celestial-corridor".to_string(),
|
||||
]
|
||||
.into_iter()
|
||||
.filter(|id| id != scene_id)
|
||||
.collect(),
|
||||
connections: vec![json!({
|
||||
"sceneId": if scene_id == "xianxia-cloud-gate" { "xianxia-celestial-corridor" } else { "xianxia-cloud-gate" },
|
||||
"relativePosition": if scene_id == "xianxia-cloud-gate" { "forward" } else { "back" },
|
||||
"summary": "沿主路继续移动"
|
||||
})],
|
||||
forward_scene_id: Some(if scene_id == "xianxia-cloud-gate" {
|
||||
"xianxia-celestial-corridor".to_string()
|
||||
} else {
|
||||
"xianxia-cloud-gate".to_string()
|
||||
}),
|
||||
treasure_hints: vec!["云阶边缘的灵光残痕".to_string()],
|
||||
npcs: Vec::new(),
|
||||
});
|
||||
}
|
||||
|
||||
Some(RuntimeScene {
|
||||
id: scene_id.to_string(),
|
||||
name: match scene_id {
|
||||
"wuxia-mountain-gate" => "山门石阶",
|
||||
"wuxia-mist-woods" => "迷雾竹林",
|
||||
"wuxia-ferry-bridge" => "渡口断桥",
|
||||
_ => WUXIA_FIRST_SCENE_NAME,
|
||||
}
|
||||
.to_string(),
|
||||
description: match scene_id {
|
||||
"wuxia-mountain-gate" => "山门石阶覆着苔痕,旧旗在风里压得很低。",
|
||||
"wuxia-mist-woods" => "迷雾在竹林间翻卷,脚下泥印很快又被雾水抹平。",
|
||||
"wuxia-ferry-bridge" => "渡口断桥横在冷水上,桥边灯笼只剩半截残光。",
|
||||
_ => WUXIA_FIRST_SCENE_DESCRIPTION,
|
||||
}
|
||||
.to_string(),
|
||||
image_src: String::new(),
|
||||
connected_scene_ids: vec![
|
||||
"wuxia-bamboo-road".to_string(),
|
||||
"wuxia-mountain-gate".to_string(),
|
||||
"wuxia-mist-woods".to_string(),
|
||||
]
|
||||
.into_iter()
|
||||
.filter(|id| id != scene_id)
|
||||
.collect(),
|
||||
connections: vec![json!({
|
||||
"sceneId": if scene_id == "wuxia-bamboo-road" { "wuxia-mountain-gate" } else { "wuxia-bamboo-road" },
|
||||
"relativePosition": if scene_id == "wuxia-bamboo-road" { "forward" } else { "back" },
|
||||
"summary": "沿主路继续移动"
|
||||
})],
|
||||
forward_scene_id: Some(if scene_id == "wuxia-bamboo-road" {
|
||||
"wuxia-mountain-gate".to_string()
|
||||
} else {
|
||||
"wuxia-bamboo-road".to_string()
|
||||
}),
|
||||
treasure_hints: vec!["路边半埋的旧物".to_string()],
|
||||
npcs: Vec::new(),
|
||||
})
|
||||
}
|
||||
|
||||
fn resolve_scene_name(game_state: &Value, scene_id: &str) -> Option<String> {
|
||||
if read_object_field(game_state, "currentScenePreset")
|
||||
.and_then(|scene| read_optional_string_field(scene, "id"))
|
||||
@@ -553,7 +758,10 @@ fn direction_text(relative_position: &str) -> &'static str {
|
||||
}
|
||||
}
|
||||
|
||||
fn resolve_next_scene_act_runtime_state(game_state: &Value) -> Option<Value> {
|
||||
fn resolve_next_scene_act_runtime_state(
|
||||
game_state: &Value,
|
||||
current_act_state_override: Option<&Value>,
|
||||
) -> Option<Value> {
|
||||
let profile = read_object_field(game_state, "customWorldProfile")?;
|
||||
let scene_id = read_object_field(game_state, "currentScenePreset")
|
||||
.and_then(|scene| read_optional_string_field(scene, "id"));
|
||||
@@ -563,7 +771,9 @@ fn resolve_next_scene_act_runtime_state(game_state: &Value) -> Option<Value> {
|
||||
if acts.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let runtime_state = build_initial_scene_act_runtime_state(game_state, scene_id_text)?;
|
||||
let runtime_state = current_act_state_override
|
||||
.cloned()
|
||||
.or_else(|| build_initial_scene_act_runtime_state(game_state, scene_id_text))?;
|
||||
let current_act_id = read_optional_string_field(&runtime_state, "currentActId");
|
||||
let current_index = acts
|
||||
.iter()
|
||||
@@ -762,9 +972,17 @@ fn resolve_scene_aliases(profile: &Value, scene_id: &str) -> Vec<String> {
|
||||
fn resolve_active_scene_act_focus_npc_id(
|
||||
profile: &Value,
|
||||
scene_id: Option<&str>,
|
||||
current_act_id: Option<&str>,
|
||||
) -> Option<String> {
|
||||
let chapter = resolve_scene_chapter_blueprint(profile, scene_id)?;
|
||||
let act_state = read_array_field(chapter, "acts").first().copied()?;
|
||||
let acts = read_array_field(chapter, "acts");
|
||||
let act_state = current_act_id
|
||||
.and_then(|act_id| {
|
||||
acts.iter()
|
||||
.copied()
|
||||
.find(|act| read_optional_string_field(act, "id").as_deref() == Some(act_id))
|
||||
})
|
||||
.or_else(|| acts.first().copied())?;
|
||||
read_optional_string_field(act_state, "oppositeNpcId")
|
||||
.or_else(|| read_optional_string_field(act_state, "primaryNpcId"))
|
||||
.or_else(|| {
|
||||
|
||||
@@ -14,16 +14,18 @@ use crate::{
|
||||
build_current_build_toast, build_npc_gift_result_text,
|
||||
build_runtime_story_option_from_story_option, build_runtime_story_view_model,
|
||||
build_static_runtime_story_option, build_status_patch, build_story_option_from_runtime_option,
|
||||
clear_encounter_state, clone_inventory_item_with_quantity, current_encounter_name,
|
||||
ensure_json_object, find_player_inventory_entry, normalize_equipment_slot_id,
|
||||
normalize_required_string, npc_buyback_price, npc_purchase_price,
|
||||
clear_encounter_state, clear_post_battle_state, clone_inventory_item_with_quantity,
|
||||
current_encounter_name, ensure_json_object, ensure_scene_act_state,
|
||||
ensure_scene_encounter_preview, finalize_post_battle_resolution, find_player_inventory_entry,
|
||||
normalize_equipment_slot_id, normalize_required_string, npc_buyback_price, npc_purchase_price,
|
||||
project_story_engine_after_action, read_array_field, read_bool_field, read_field,
|
||||
read_i32_field, read_inventory_item_name, read_object_field, read_optional_string_field,
|
||||
read_player_equipment_item, read_player_inventory_values, read_runtime_session_id,
|
||||
read_u32_field, recruit_companion_to_party, remove_inventory_item_from_list,
|
||||
resolve_action_text, resolve_battle_action, resolve_equipment_slot_for_item,
|
||||
resolve_forge_craft_action, resolve_forge_dismantle_action, resolve_forge_reforge_action,
|
||||
resolve_npc_gift_affinity_gain, restore_player_resource, simple_story_resolution,
|
||||
resolve_forward_scene_id, resolve_npc_gift_affinity_gain, resolve_post_battle_story_options,
|
||||
resolve_runtime_scene_preset, restore_player_resource, simple_story_resolution,
|
||||
write_bool_field, write_i32_field, write_null_field, write_player_equipment_item,
|
||||
write_player_inventory_values, write_runtime_npc_interaction_view, write_string_field,
|
||||
write_u32_field,
|
||||
@@ -97,23 +99,7 @@ pub fn resolve_story_runtime_action(
|
||||
requested_runtime_session_id.as_str(),
|
||||
);
|
||||
|
||||
let mut options = resolution
|
||||
.presentation_options
|
||||
.take()
|
||||
.unwrap_or_else(|| build_fallback_runtime_story_options(&game_state));
|
||||
if options.is_empty() {
|
||||
options = build_fallback_runtime_story_options(&game_state);
|
||||
}
|
||||
|
||||
let story_text = resolution
|
||||
.story_text
|
||||
.clone()
|
||||
.unwrap_or_else(|| resolution.result_text.clone());
|
||||
let history_result_text = resolution.result_text.clone();
|
||||
let saved_current_story = resolution
|
||||
.saved_current_story
|
||||
.take()
|
||||
.unwrap_or_else(|| build_current_story(story_text.as_str(), &options));
|
||||
|
||||
append_story_history(
|
||||
&mut game_state,
|
||||
@@ -132,6 +118,37 @@ pub fn resolve_story_runtime_action(
|
||||
.and_then(|battle| battle.outcome.as_deref()),
|
||||
);
|
||||
|
||||
if let Some(post_battle) = finalize_post_battle_resolution(
|
||||
&mut game_state,
|
||||
history_result_text.as_str(),
|
||||
resolution
|
||||
.battle
|
||||
.as_ref()
|
||||
.and_then(|battle| battle.outcome.as_deref()),
|
||||
Vec::new(),
|
||||
) {
|
||||
resolution.story_text = Some(post_battle.story_text);
|
||||
resolution.presentation_options = Some(post_battle.presentation_options);
|
||||
resolution.saved_current_story = Some(post_battle.saved_current_story);
|
||||
}
|
||||
|
||||
let mut options = resolution
|
||||
.presentation_options
|
||||
.take()
|
||||
.unwrap_or_else(|| build_fallback_runtime_story_options(&game_state));
|
||||
if options.is_empty() {
|
||||
options = build_fallback_runtime_story_options(&game_state);
|
||||
}
|
||||
|
||||
let story_text = resolution
|
||||
.story_text
|
||||
.clone()
|
||||
.unwrap_or_else(|| resolution.result_text.clone());
|
||||
let saved_current_story = resolution
|
||||
.saved_current_story
|
||||
.take()
|
||||
.unwrap_or_else(|| build_current_story(story_text.as_str(), &options));
|
||||
|
||||
let mut patches = vec![RuntimeStoryPatch::StoryHistoryAppend {
|
||||
action_text: resolution.action_text.clone(),
|
||||
result_text: history_result_text.clone(),
|
||||
@@ -212,11 +229,10 @@ fn resolve_runtime_story_choice_action(
|
||||
resolve_action_text("主动出声试探", request),
|
||||
"你的喊话打破了当前静场,周围潜着的动静也更难继续藏住。",
|
||||
)),
|
||||
"idle_explore_forward" => Ok(simple_story_resolution(
|
||||
game_state,
|
||||
resolve_action_text("继续向前探索", request),
|
||||
"你没有停在原地,而是继续向前压,把下一段遭遇主动推到自己面前。",
|
||||
)),
|
||||
"idle_explore_forward" => resolve_idle_explore_forward_action(game_state, request),
|
||||
"idle_travel_next_scene" | "camp_travel_home_scene" => {
|
||||
resolve_idle_travel_next_scene_action(game_state, request)
|
||||
}
|
||||
"idle_observe_signs" => Ok(simple_story_resolution(
|
||||
game_state,
|
||||
resolve_action_text("观察周围迹象", request),
|
||||
@@ -309,6 +325,62 @@ fn resolve_continue_adventure_action(
|
||||
})
|
||||
}
|
||||
|
||||
fn resolve_idle_explore_forward_action(
|
||||
game_state: &mut Value,
|
||||
request: &RuntimeStoryActionRequest,
|
||||
) -> Result<StoryResolution, String> {
|
||||
// 中文注释:探索前进是战后继续链路的一环,必须在后端清掉战斗态并生成下一段遭遇预览。
|
||||
// 前端只播放表现动画,不能只靠本地状态把同一组 idle 选项重新展示一遍。
|
||||
clear_post_battle_state(game_state);
|
||||
ensure_scene_encounter_preview(game_state);
|
||||
Ok(StoryResolution {
|
||||
action_text: resolve_action_text("继续向前探索", request),
|
||||
result_text: "你没有停在原地,而是继续向前压,把下一段遭遇主动推到自己面前。".to_string(),
|
||||
story_text: None,
|
||||
presentation_options: Some(resolve_post_battle_story_options(game_state)),
|
||||
saved_current_story: None,
|
||||
patches: vec![build_status_patch(game_state)],
|
||||
battle: None,
|
||||
toast: None,
|
||||
})
|
||||
}
|
||||
|
||||
fn resolve_idle_travel_next_scene_action(
|
||||
game_state: &mut Value,
|
||||
request: &RuntimeStoryActionRequest,
|
||||
) -> Result<StoryResolution, String> {
|
||||
// 中文注释:切场景会改变 currentScenePreset、章节 act 状态和运行统计,
|
||||
// 这些都是 runtime 快照真相,不能只在前端播放退场/进场动画。
|
||||
let payload = request.action.payload.as_ref();
|
||||
let target_scene_id = payload
|
||||
.and_then(|payload| read_optional_string_field(payload, "targetSceneId"))
|
||||
.or_else(|| resolve_forward_scene_id(game_state))
|
||||
.ok_or_else(|| "idle_travel_next_scene 缺少 targetSceneId".to_string())?;
|
||||
let next_scene = resolve_runtime_scene_preset(game_state, target_scene_id.as_str())
|
||||
.ok_or_else(|| format!("未找到目标场景:{target_scene_id}"))?;
|
||||
let next_scene_name =
|
||||
read_optional_string_field(&next_scene, "name").unwrap_or_else(|| target_scene_id.clone());
|
||||
|
||||
clear_post_battle_state(game_state);
|
||||
ensure_json_object(game_state).insert("currentScenePreset".to_string(), next_scene);
|
||||
write_i32_field(game_state, "playerX", 0);
|
||||
write_string_field(game_state, "playerFacing", "right");
|
||||
ensure_scene_act_state(game_state);
|
||||
ensure_scene_encounter_preview(game_state);
|
||||
increment_runtime_stat_local(game_state, "scenesTraveled", 1);
|
||||
|
||||
Ok(StoryResolution {
|
||||
action_text: resolve_action_text(&format!("前往{next_scene_name}"), request),
|
||||
result_text: format!("你离开当前区域,抵达了{next_scene_name}。"),
|
||||
story_text: None,
|
||||
presentation_options: Some(resolve_post_battle_story_options(game_state)),
|
||||
saved_current_story: None,
|
||||
patches: vec![build_status_patch(game_state)],
|
||||
battle: None,
|
||||
toast: None,
|
||||
})
|
||||
}
|
||||
|
||||
fn resolve_npc_preview_talk_action(
|
||||
game_state: &mut Value,
|
||||
request: &RuntimeStoryActionRequest,
|
||||
|
||||
@@ -54,9 +54,9 @@ pub fn default_creation_entry_type_snapshots(
|
||||
"rpg",
|
||||
"文字冒险",
|
||||
"经典 RPG 体验",
|
||||
"内测",
|
||||
"可创建",
|
||||
"/creation-type-references/rpg.webp",
|
||||
false,
|
||||
true,
|
||||
true,
|
||||
10,
|
||||
updated_at_micros,
|
||||
|
||||
@@ -236,6 +236,23 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_creation_entry_types_open_rpg_entry() {
|
||||
let configs = default_creation_entry_type_snapshots(1);
|
||||
let rpg = configs
|
||||
.iter()
|
||||
.find(|item| item.id == "rpg")
|
||||
.expect("rpg creation entry should be seeded");
|
||||
|
||||
assert_eq!(rpg.title, "文字冒险");
|
||||
assert_eq!(rpg.subtitle, "经典 RPG 体验");
|
||||
assert!(rpg.visible);
|
||||
assert!(rpg.open);
|
||||
assert_eq!(rpg.badge, "可创建");
|
||||
assert_eq!(rpg.sort_order, 10);
|
||||
assert_eq!(rpg.image_src, "/creation-type-references/rpg.webp");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_creation_entry_types_include_bark_battle() {
|
||||
let configs = default_creation_entry_type_snapshots(1);
|
||||
|
||||
@@ -2562,6 +2562,18 @@ fn upsert_nested_result_profile_id(
|
||||
}
|
||||
}
|
||||
|
||||
fn resolve_publish_world_setting_text(
|
||||
payload: &JsonMap<String, JsonValue>,
|
||||
draft_profile: &JsonMap<String, JsonValue>,
|
||||
session: &CustomWorldAgentSession,
|
||||
) -> String {
|
||||
module_custom_world::resolve_custom_world_publish_setting_text(
|
||||
payload,
|
||||
draft_profile,
|
||||
&session.seed_text,
|
||||
)
|
||||
}
|
||||
|
||||
fn is_same_agent_draft_profile_candidate(
|
||||
row: &CustomWorldProfile,
|
||||
owner_user_id: &str,
|
||||
@@ -2581,13 +2593,10 @@ fn execute_publish_world_action(
|
||||
) -> Result<CustomWorldAgentOperationSnapshot, String> {
|
||||
ensure_publishable_stage(session.stage, "publish_world")?;
|
||||
|
||||
let draft_profile =
|
||||
if let Some(explicit) = payload.get("draftProfile").and_then(JsonValue::as_object) {
|
||||
explicit.clone()
|
||||
} else {
|
||||
parse_optional_session_object(session.draft_profile_json.as_deref())
|
||||
.ok_or_else(|| "publish_world requires draft_profile_json".to_string())?
|
||||
};
|
||||
// 中文注释:发布动作不再信任前端携带的 draftProfile。
|
||||
// 点击发布前,结果页 profile 必须先通过 sync_result_profile 写回
|
||||
// custom_world_agent_session.draft_profile_json;正式发布只读取这份会话真相。
|
||||
let draft_profile = read_publish_world_draft_profile_from_session(session)?;
|
||||
let gate = summarize_publish_gate_from_json(
|
||||
&session.session_id,
|
||||
session.stage,
|
||||
@@ -2601,24 +2610,9 @@ fn execute_publish_world_action(
|
||||
));
|
||||
}
|
||||
|
||||
let profile_id = payload
|
||||
.get("profileId")
|
||||
.and_then(JsonValue::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(ToOwned::to_owned)
|
||||
.unwrap_or_else(|| gate.profile_id.clone());
|
||||
let setting_text = payload
|
||||
.get("settingText")
|
||||
.and_then(JsonValue::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(ToOwned::to_owned)
|
||||
.unwrap_or_else(|| session.seed_text.clone());
|
||||
let legacy_result_profile_json = payload
|
||||
.get("legacyResultProfile")
|
||||
.map(serialize_json_value)
|
||||
.transpose()?;
|
||||
let profile_id = gate.profile_id.clone();
|
||||
let setting_text = resolve_publish_world_setting_text(payload, &draft_profile, session);
|
||||
let legacy_result_profile_json = None;
|
||||
let author_public_user_code = read_optional_text_field(payload, &["authorPublicUserCode"])
|
||||
.unwrap_or_else(|| build_public_user_code_from_owner_user_id(&session.owner_user_id));
|
||||
let author_display_name = read_optional_text_field(payload, &["authorDisplayName"])
|
||||
@@ -2663,6 +2657,13 @@ fn execute_publish_world_action(
|
||||
Ok(build_custom_world_agent_operation_snapshot(&operation))
|
||||
}
|
||||
|
||||
fn read_publish_world_draft_profile_from_session(
|
||||
session: &CustomWorldAgentSession,
|
||||
) -> Result<JsonMap<String, JsonValue>, String> {
|
||||
parse_optional_session_object(session.draft_profile_json.as_deref())
|
||||
.ok_or_else(|| "publish_world requires draft_profile_json".to_string())
|
||||
}
|
||||
|
||||
fn execute_revert_checkpoint_action(
|
||||
ctx: &ReducerContext,
|
||||
session: &CustomWorldAgentSession,
|
||||
@@ -5250,6 +5251,26 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn publish_world_draft_profile_comes_from_session_not_payload() {
|
||||
let session = build_test_custom_world_agent_session(
|
||||
"seed",
|
||||
RpgAgentStage::ReadyToPublish,
|
||||
Some(r#"{"id":"saved-profile","name":"已保存草稿"}"#),
|
||||
);
|
||||
let draft_profile =
|
||||
read_publish_world_draft_profile_from_session(&session).expect("session draft exists");
|
||||
|
||||
assert_eq!(
|
||||
draft_profile.get("id").and_then(JsonValue::as_str),
|
||||
Some("saved-profile")
|
||||
);
|
||||
assert_eq!(
|
||||
draft_profile.get("name").and_then(JsonValue::as_str),
|
||||
Some("已保存草稿")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn custom_world_agent_session_direct_work_content_ignores_empty_created_session() {
|
||||
let empty_session =
|
||||
|
||||
@@ -179,6 +179,7 @@ fn seed_creation_entry_config_if_missing(ctx: &ReducerContext) {
|
||||
}
|
||||
}
|
||||
|
||||
migrate_rpg_entry_from_old_hidden_default(ctx, now);
|
||||
migrate_visual_novel_entry_from_old_visible_default(ctx, now);
|
||||
migrate_coming_soon_entry_from_old_open_default(
|
||||
ctx,
|
||||
@@ -204,6 +205,36 @@ fn seed_creation_entry_config_if_missing(ctx: &ReducerContext) {
|
||||
);
|
||||
}
|
||||
|
||||
fn migrate_rpg_entry_from_old_hidden_default(ctx: &ReducerContext, now: Timestamp) {
|
||||
let id = "rpg".to_string();
|
||||
let Some(row) = ctx.db.creation_entry_type_config().id().find(&id) else {
|
||||
return;
|
||||
};
|
||||
|
||||
// 中文注释:只开放历史默认隐藏的 RPG 入口,不覆盖后台入口开关后续手动配置。
|
||||
let still_old_hidden_default = row.title == "文字冒险"
|
||||
&& row.subtitle == "经典 RPG 体验"
|
||||
&& row.badge == "内测"
|
||||
&& row.image_src == "/creation-type-references/rpg.webp"
|
||||
&& !row.visible
|
||||
&& row.open
|
||||
&& row.sort_order == 10;
|
||||
if !still_old_hidden_default {
|
||||
return;
|
||||
}
|
||||
|
||||
ctx.db
|
||||
.creation_entry_type_config()
|
||||
.id()
|
||||
.update(CreationEntryTypeConfig {
|
||||
badge: "可创建".to_string(),
|
||||
visible: true,
|
||||
open: true,
|
||||
updated_at: now,
|
||||
..row
|
||||
});
|
||||
}
|
||||
|
||||
fn migrate_visual_novel_entry_from_old_visible_default(ctx: &ReducerContext, now: Timestamp) {
|
||||
let id = "visual-novel".to_string();
|
||||
let Some(row) = ctx.db.creation_entry_type_config().id().find(&id) else {
|
||||
|
||||
12
src/App.tsx
12
src/App.tsx
@@ -32,6 +32,16 @@ const RpgRuntimeApp = lazy(async () => {
|
||||
};
|
||||
});
|
||||
|
||||
function RuntimeLoadingFallback() {
|
||||
return (
|
||||
<div className="platform-ui-shell platform-viewport-shell platform-theme platform-theme--dark flex h-screen items-center justify-center bg-[image:var(--platform-body-fill)] p-4 font-sans text-[var(--platform-text-strong)]">
|
||||
<div className="platform-subpanel rounded-2xl px-5 py-4 text-sm text-zinc-300">
|
||||
正在启动
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function isRpgRuntimeRoute(pathname: string) {
|
||||
const normalizedPath = normalizeAppPath(pathname);
|
||||
return (
|
||||
@@ -126,7 +136,7 @@ export default function App() {
|
||||
|
||||
if (isRuntimeActive) {
|
||||
return (
|
||||
<Suspense fallback={null}>
|
||||
<Suspense fallback={<RuntimeLoadingFallback />}>
|
||||
<RpgRuntimeApp
|
||||
initialIntent={runtimeIntent}
|
||||
onExitRuntime={() => {
|
||||
|
||||
@@ -5,6 +5,7 @@ import userEvent from '@testing-library/user-event';
|
||||
import { useState } from 'react';
|
||||
import { expect, test, vi } from 'vitest';
|
||||
|
||||
import { normalizeCustomWorldProfileRecord } from '../data/customWorldLibrary';
|
||||
import * as rpgCreationAssetClient from '../services/rpg-creation/rpgCreationAssetClient';
|
||||
import type { CustomWorldPlayableNpc, CustomWorldProfile } from '../types';
|
||||
import { RpgCreationResultView } from './rpg-creation-result/RpgCreationResultView';
|
||||
@@ -286,6 +287,40 @@ function ResultViewHarness() {
|
||||
);
|
||||
}
|
||||
|
||||
function ResultViewRehydratingHarness() {
|
||||
const [profile, setProfile] = useState(baseProfile);
|
||||
const [rehydrated, setRehydrated] = useState(false);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div data-testid="rehydrated">{rehydrated ? 'yes' : 'no'}</div>
|
||||
<RpgCreationResultView
|
||||
profile={profile}
|
||||
previewCharacters={[]}
|
||||
isGenerating={false}
|
||||
progress={0}
|
||||
progressLabel=""
|
||||
error={null}
|
||||
onBack={() => {}}
|
||||
onProfileChange={(nextProfile) => {
|
||||
setProfile(nextProfile);
|
||||
if (!nextProfile.openingCg) {
|
||||
return;
|
||||
}
|
||||
|
||||
window.setTimeout(() => {
|
||||
const normalized = normalizeCustomWorldProfileRecord(nextProfile);
|
||||
if (normalized) {
|
||||
setProfile(normalized);
|
||||
}
|
||||
setRehydrated(true);
|
||||
}, 0);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
test('clicking新增可扮演角色 shows pending item, disables button, and marks result as new', async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
@@ -385,6 +420,40 @@ test('world tab generates opening cg only after manual click and writes it back
|
||||
});
|
||||
});
|
||||
|
||||
test('world tab keeps opening cg visible after parent rehydrates normalized profile', async () => {
|
||||
const user = userEvent.setup();
|
||||
mockedRpgCreationAssetClient.generateOpeningCg.mockResolvedValue({
|
||||
id: 'opening-cg-1',
|
||||
status: 'ready',
|
||||
storyboardImageSrc: '/generated-custom-world-scenes/world/opening/storyboard.png',
|
||||
storyboardAssetId: 'storyboard-1',
|
||||
videoSrc: '/generated-custom-world-scenes/world/opening/opening.mp4',
|
||||
videoAssetId: 'video-1',
|
||||
imageModel: 'gpt-image-2',
|
||||
videoModel: 'doubao-seedance-2-0-fast-260128',
|
||||
aspectRatio: '16:9',
|
||||
imageSize: '2k',
|
||||
videoResolution: '480p',
|
||||
durationSeconds: 15,
|
||||
pointCost: 80,
|
||||
estimatedWaitMinutes: 10,
|
||||
updatedAt: '2026-05-03T00:00:00Z',
|
||||
});
|
||||
|
||||
render(<ResultViewRehydratingHarness />);
|
||||
|
||||
await user.click(screen.getByRole('button', { name: '生成' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('rehydrated').textContent).toBe('yes');
|
||||
});
|
||||
expect(
|
||||
document.querySelector(
|
||||
'video[src="/generated-custom-world-scenes/world/opening/opening.mp4"]',
|
||||
),
|
||||
).toBeTruthy();
|
||||
});
|
||||
|
||||
test('playable tab prefers generated portrait over runtime preview placeholder', async () => {
|
||||
const user = userEvent.setup();
|
||||
const profile = {
|
||||
|
||||
@@ -25,6 +25,17 @@ const testEntryConfig = {
|
||||
description: '先选玩法类型,再进入对应创作工作台。',
|
||||
},
|
||||
creationTypes: [
|
||||
{
|
||||
id: 'rpg',
|
||||
title: '文字冒险',
|
||||
subtitle: '经典 RPG 体验',
|
||||
badge: '可创建',
|
||||
imageSrc: '/creation-type-references/rpg.webp',
|
||||
visible: true,
|
||||
open: true,
|
||||
sortOrder: 10,
|
||||
updatedAtMicros: 1,
|
||||
},
|
||||
{
|
||||
id: 'puzzle',
|
||||
title: '拼图',
|
||||
@@ -253,14 +264,18 @@ test('creation hub reflects updated draft title summary and counts after rerende
|
||||
const match3dButton = screen.getByRole('button', {
|
||||
name: /抓大鹅.*3D 消除关卡/u,
|
||||
});
|
||||
const rpgButton = screen.getByRole('button', {
|
||||
name: /文字冒险.*经典 RPG 体验/u,
|
||||
});
|
||||
expect(puzzleButton).toBeTruthy();
|
||||
expect(match3dButton).toBeTruthy();
|
||||
expect(rpgButton).toBeTruthy();
|
||||
expect((puzzleButton as HTMLButtonElement).disabled).toBe(false);
|
||||
expect((match3dButton as HTMLButtonElement).disabled).toBe(false);
|
||||
expect((rpgButton as HTMLButtonElement).disabled).toBe(false);
|
||||
expect(screen.queryByRole('button', { name: /方洞挑战/u })).toBeNull();
|
||||
expect(screen.queryByText('反直觉形状分拣')).toBeNull();
|
||||
expect(screen.queryByRole('button', { name: /智能创作/u })).toBeNull();
|
||||
expect(screen.queryByRole('button', { name: /文字冒险/u })).toBeNull();
|
||||
expect(screen.queryByRole('button', { name: /大鱼吃小鱼/u })).toBeNull();
|
||||
|
||||
await user.click(match3dButton);
|
||||
|
||||
@@ -19,6 +19,17 @@ const testEntryConfig = {
|
||||
description: '先选玩法类型,再进入对应创作工作台。',
|
||||
},
|
||||
creationTypes: [
|
||||
{
|
||||
id: 'rpg',
|
||||
title: '文字冒险',
|
||||
subtitle: '经典 RPG 体验',
|
||||
badge: '可创建',
|
||||
imageSrc: '/creation-type-references/rpg.webp',
|
||||
visible: true,
|
||||
open: true,
|
||||
sortOrder: 10,
|
||||
updatedAtMicros: 1,
|
||||
},
|
||||
{
|
||||
id: 'puzzle',
|
||||
title: '拼图',
|
||||
@@ -124,7 +135,8 @@ test('creation hub draft card renders compiled work summary fields', () => {
|
||||
expect(html).toContain('拼图关卡创作');
|
||||
expect(html).toContain('抓大鹅');
|
||||
expect(html).toContain('3D 消除关卡');
|
||||
expect(html).not.toContain('文字冒险');
|
||||
expect(html).toContain('文字冒险');
|
||||
expect(html).toContain('经典 RPG 体验');
|
||||
expect(html).not.toContain('大鱼吃小鱼');
|
||||
});
|
||||
|
||||
|
||||
@@ -7,7 +7,10 @@ import {
|
||||
type Encounter,
|
||||
type SceneHostileNpc,
|
||||
} from '../../types';
|
||||
import { GameCanvasEntityLayer } from './GameCanvasEntityLayer';
|
||||
import {
|
||||
GameCanvasEntityLayer,
|
||||
getCombatFloatingNumberPresentation,
|
||||
} from './GameCanvasEntityLayer';
|
||||
import {
|
||||
CHARACTER_COMBAT_HP_TOP_PX,
|
||||
ENTITY_CONTAINER_REM,
|
||||
@@ -125,6 +128,21 @@ function renderEntityLayer(effectNpcId: string | null) {
|
||||
}
|
||||
|
||||
describe('GameCanvasEntityLayer', () => {
|
||||
it('keeps combat floating numbers readable on dark noisy battle backgrounds', () => {
|
||||
const damage = getCombatFloatingNumberPresentation(false);
|
||||
const healing = getCombatFloatingNumberPresentation(true);
|
||||
|
||||
expect(damage.toneClass).toContain('bg-rose-950/72');
|
||||
expect(damage.toneClass).toContain('text-rose-50');
|
||||
expect(damage.textStyle.WebkitTextStroke).toContain('rgba(127, 29, 29');
|
||||
expect(damage.textStyle.textShadow).toContain('rgba(0, 0, 0');
|
||||
|
||||
expect(healing.toneClass).toContain('bg-emerald-950/70');
|
||||
expect(healing.toneClass).toContain('text-emerald-50');
|
||||
expect(healing.textStyle.WebkitTextStroke).toContain('rgba(6, 78, 59');
|
||||
expect(healing.textStyle.textShadow).toContain('rgba(0, 0, 0');
|
||||
});
|
||||
|
||||
it('uses mirrored stage anchors for player and opponent containers', () => {
|
||||
expect(getMirroredStageEntityLeft('15%', 'player')).toBe('15%');
|
||||
expect(getMirroredStageEntityLeft('15%', 'opponent')).toBe(`calc(100% - 15% - ${ENTITY_CONTAINER_REM}rem)`);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import {motion} from 'motion/react';
|
||||
import {type ReactNode, useEffect, useMemo, useRef, useState} from 'react';
|
||||
import {type CSSProperties, type ReactNode, useEffect, useMemo, useRef, useState} from 'react';
|
||||
|
||||
import {getCharacterById} from '../../data/characterPresets';
|
||||
import {getFacingTowardPlayer, MONSTERS_BY_WORLD} from '../../data/hostileNpcs';
|
||||
@@ -130,6 +130,45 @@ function getSceneTransitionMotionConfig(
|
||||
};
|
||||
}
|
||||
|
||||
export function getCombatFloatingNumberPresentation(isHealing: boolean): {
|
||||
toneClass: string;
|
||||
textStyle: CSSProperties;
|
||||
} {
|
||||
const textShadow = [
|
||||
'0 1px 0 rgba(0, 0, 0, 0.98)',
|
||||
'0 0 8px rgba(0, 0, 0, 0.92)',
|
||||
'0 0 16px rgba(0, 0, 0, 0.72)',
|
||||
].join(', ');
|
||||
|
||||
if (isHealing) {
|
||||
return {
|
||||
toneClass: [
|
||||
'border-emerald-100/70',
|
||||
'bg-emerald-950/70',
|
||||
'text-emerald-50',
|
||||
'shadow-[0_0_18px_rgba(52,211,153,0.55)]',
|
||||
].join(' '),
|
||||
textStyle: {
|
||||
WebkitTextStroke: '1.45px rgba(6, 78, 59, 0.95)',
|
||||
textShadow,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
toneClass: [
|
||||
'border-rose-100/75',
|
||||
'bg-rose-950/72',
|
||||
'text-rose-50',
|
||||
'shadow-[0_0_20px_rgba(248,113,113,0.68)]',
|
||||
].join(' '),
|
||||
textStyle: {
|
||||
WebkitTextStroke: '1.55px rgba(127, 29, 29, 0.98)',
|
||||
textShadow,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function CombatFloatingNumber({
|
||||
event,
|
||||
onDone,
|
||||
@@ -139,23 +178,20 @@ function CombatFloatingNumber({
|
||||
}) {
|
||||
const isHealing = event.delta > 0;
|
||||
const deltaText = `${isHealing ? '+' : ''}${event.delta}`;
|
||||
const colorClass = isHealing ? 'text-emerald-200' : 'text-rose-200';
|
||||
const glowClass = isHealing
|
||||
? 'drop-shadow-[0_0_8px_rgba(52,211,153,0.9)]'
|
||||
: 'drop-shadow-[0_0_8px_rgba(248,113,113,0.9)]';
|
||||
const presentation = getCombatFloatingNumberPresentation(isHealing);
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
key={event.id}
|
||||
initial={{opacity: 0, y: 10, scale: 0.76}}
|
||||
animate={{opacity: [0, 1, 1, 0], y: [10, -12, -31, -50], scale: [0.76, 1.22, 1, 0.9]}}
|
||||
initial={{opacity: 0, y: 8, scale: 0.72}}
|
||||
animate={{opacity: [0, 1, 1, 0], y: [8, -14, -36, -58], scale: [0.72, 1.18, 1.04, 0.92]}}
|
||||
transition={{duration: 0.92, ease: 'easeOut'}}
|
||||
onAnimationComplete={() => onDone(event.id)}
|
||||
className={`pointer-events-none absolute -top-16 left-1/2 z-[14] -translate-x-1/2 text-lg font-black leading-none ${colorClass} ${glowClass}`}
|
||||
className={`pointer-events-none absolute -top-[4.65rem] left-1/2 z-[38] flex min-w-[2.4rem] -translate-x-1/2 select-none items-center justify-center rounded-full border px-1.5 py-0.5 text-[1.45rem] font-black leading-none tracking-[-0.04em] sm:text-[1.6rem] ${presentation.toneClass}`}
|
||||
data-testid={`combat-feedback-${event.targetKey}`}
|
||||
aria-label={`战斗数值 ${deltaText}`}
|
||||
>
|
||||
<span className="[-webkit-text-stroke:1px_rgba(24,24,27,0.76)]">
|
||||
<span style={presentation.textStyle}>
|
||||
{deltaText}
|
||||
</span>
|
||||
</motion.div>
|
||||
|
||||
@@ -3235,6 +3235,7 @@ export function PlatformEntryFlowShellImpl({
|
||||
const enterWorldCoordinator = useRpgCreationEnterWorld({
|
||||
isAgentDraftResultView: sessionController.isAgentDraftResultView,
|
||||
activeAgentSessionId: sessionController.activeAgentSessionId,
|
||||
currentAgentSessionStage: sessionController.agentSession?.stage ?? null,
|
||||
generatedCustomWorldProfile: sessionController.generatedCustomWorldProfile,
|
||||
handleCustomWorldSelect,
|
||||
syncAgentDraftResultProfile:
|
||||
|
||||
@@ -15,7 +15,51 @@ import {
|
||||
import { RpgEntryCharacterSelectView } from './RpgEntryCharacterSelectView';
|
||||
|
||||
vi.mock('../../data/characterPresets', () => ({
|
||||
ROLE_TEMPLATE_CHARACTERS: [],
|
||||
ROLE_TEMPLATE_CHARACTERS: [
|
||||
{
|
||||
id: 'fallback-hero',
|
||||
name: '兜底侠',
|
||||
title: '默认角色',
|
||||
description: '兜底角色',
|
||||
backstory: '兜底背景',
|
||||
personality: '冷静 果断',
|
||||
gender: 'unknown',
|
||||
portrait: '/portraits/fallback.png',
|
||||
attributes: {
|
||||
strength: 8,
|
||||
agility: 8,
|
||||
intelligence: 8,
|
||||
spirit: 8,
|
||||
},
|
||||
attributeProfile: {
|
||||
schemaId: 'schema:custom:fallback',
|
||||
values: {
|
||||
axis_a: 8,
|
||||
axis_b: 8,
|
||||
axis_c: 8,
|
||||
axis_d: 8,
|
||||
axis_e: 8,
|
||||
axis_f: 8,
|
||||
},
|
||||
evidence: [],
|
||||
},
|
||||
attributeProfiles: {
|
||||
CUSTOM: {
|
||||
schemaId: 'schema:custom:fallback',
|
||||
values: {
|
||||
axis_a: 8,
|
||||
axis_b: 8,
|
||||
axis_c: 8,
|
||||
axis_d: 8,
|
||||
axis_e: 8,
|
||||
axis_f: 8,
|
||||
},
|
||||
evidence: [],
|
||||
},
|
||||
},
|
||||
skills: [],
|
||||
},
|
||||
],
|
||||
buildCustomWorldPlayableCharacters: vi.fn(),
|
||||
}));
|
||||
|
||||
@@ -190,3 +234,46 @@ test('custom world character selection stays stable when character ids are empty
|
||||
|
||||
expect(duplicateKeyCalls).toHaveLength(0);
|
||||
});
|
||||
|
||||
test('custom world character selection falls back instead of rendering a blank screen when profile characters are malformed', () => {
|
||||
vi.spyOn(console, 'warn').mockImplementation(() => undefined);
|
||||
vi.mocked(buildCustomWorldPlayableCharacters).mockImplementation(() => {
|
||||
throw new TypeError('profile.playableNpcs is not iterable');
|
||||
});
|
||||
|
||||
render(
|
||||
<RpgEntryCharacterSelectView
|
||||
worldType={WorldType.CUSTOM}
|
||||
customWorldProfile={{
|
||||
id: 'broken-profile',
|
||||
name: '坏数据',
|
||||
attributeSchema: {
|
||||
id: 'schema:custom:fallback',
|
||||
worldId: 'broken-profile',
|
||||
schemaVersion: 1,
|
||||
generatedFrom: {
|
||||
worldType: WorldType.CUSTOM,
|
||||
worldName: '坏数据',
|
||||
settingSummary: '坏数据',
|
||||
tone: '测试',
|
||||
conflictCore: '测试',
|
||||
},
|
||||
slots: [
|
||||
{ slotId: 'axis_a', name: '骨势' },
|
||||
{ slotId: 'axis_b', name: '身法' },
|
||||
{ slotId: 'axis_c', name: '眼脉' },
|
||||
{ slotId: 'axis_d', name: '心焰' },
|
||||
{ slotId: 'axis_e', name: '尘缘' },
|
||||
{ slotId: 'axis_f', name: '玄息' },
|
||||
],
|
||||
},
|
||||
} as unknown as CustomWorldProfile}
|
||||
onBack={() => {}}
|
||||
onConfirm={() => {}}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText('选择你的角色')).toBeTruthy();
|
||||
expect(screen.getAllByText('兜底侠').length).toBeGreaterThan(0);
|
||||
expect(screen.getByRole('button', { name: /进入营地/u })).toBeTruthy();
|
||||
});
|
||||
|
||||
@@ -112,6 +112,19 @@ function buildSelectionCharacterKey(character: Character, index: number) {
|
||||
return `selection-character-${index}-${fallbackSeed}`;
|
||||
}
|
||||
|
||||
function resolveSelectionCharacters(profile: CustomWorldProfile | null) {
|
||||
try {
|
||||
const characters = profile
|
||||
? buildCustomWorldPlayableCharacters(profile)
|
||||
: ROLE_TEMPLATE_CHARACTERS;
|
||||
|
||||
return characters.length > 0 ? characters : ROLE_TEMPLATE_CHARACTERS;
|
||||
} catch (error) {
|
||||
console.warn('自定义世界角色数据异常,已回退默认角色。', error);
|
||||
return ROLE_TEMPLATE_CHARACTERS;
|
||||
}
|
||||
}
|
||||
|
||||
function applyCharacterSelectionDraft(
|
||||
character: Character | null,
|
||||
draft?: CharacterSelectionDraft | null,
|
||||
@@ -209,7 +222,7 @@ export function RpgEntryCharacterSelectView({
|
||||
onConfirm,
|
||||
}: RpgEntryCharacterSelectViewProps) {
|
||||
const selectionCharacters = useMemo(
|
||||
() => (customWorldProfile ? buildCustomWorldPlayableCharacters(customWorldProfile) : ROLE_TEMPLATE_CHARACTERS),
|
||||
() => resolveSelectionCharacters(customWorldProfile),
|
||||
[customWorldProfile],
|
||||
);
|
||||
const selectionEntries = useMemo(
|
||||
@@ -329,7 +342,18 @@ export function RpgEntryCharacterSelectView({
|
||||
};
|
||||
|
||||
if (!selectedCharacter || !selectedCharacterMeta) {
|
||||
return null;
|
||||
return (
|
||||
<div className="flex h-full min-h-0 flex-col items-center justify-center gap-4 text-center">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onBack}
|
||||
className="rounded-full border border-white/10 bg-black/18 px-3 py-1.5 text-[11px] text-zinc-300 transition-colors hover:text-white"
|
||||
>
|
||||
返回
|
||||
</button>
|
||||
<div className="text-sm text-zinc-300">角色数据暂不可用</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
|
||||
@@ -35,6 +35,7 @@ import type {
|
||||
CustomWorldLibraryEntry,
|
||||
} from '../../../packages/shared/src/contracts/runtime';
|
||||
import type { HydratedSavedGameSnapshot } from '../../persistence/runtimeSnapshotTypes';
|
||||
import { normalizeCustomWorldProfileRecord } from '../../data/customWorldLibrary';
|
||||
import {
|
||||
readPublicWorkCodeFromLocationSearch,
|
||||
resolveSelectionStageFromPath,
|
||||
@@ -271,6 +272,17 @@ const testCreationEntryConfig = {
|
||||
description: '先选玩法类型,再进入对应创作工作台。',
|
||||
},
|
||||
creationTypes: [
|
||||
{
|
||||
id: 'rpg',
|
||||
title: '文字冒险',
|
||||
subtitle: '经典 RPG 体验',
|
||||
badge: '可创建',
|
||||
imageSrc: '/creation-type-references/rpg.webp',
|
||||
visible: true,
|
||||
open: true,
|
||||
sortOrder: 10,
|
||||
updatedAtMicros: 1,
|
||||
},
|
||||
{
|
||||
id: 'puzzle',
|
||||
title: '拼图',
|
||||
@@ -1910,6 +1922,14 @@ const compiledAgentDraftSession: CustomWorldAgentSessionSnapshot = {
|
||||
},
|
||||
};
|
||||
|
||||
const compiledAgentResultPreview = normalizeCustomWorldProfileRecord(
|
||||
compiledAgentDraftSession.resultPreview?.preview,
|
||||
);
|
||||
|
||||
if (!compiledAgentResultPreview) {
|
||||
throw new Error('failed to normalize compiled agent result preview');
|
||||
}
|
||||
|
||||
function buildResultViewForSession(
|
||||
session: CustomWorldAgentSessionSnapshot,
|
||||
): RpgCreationResultView {
|
||||
@@ -3205,8 +3225,8 @@ test('create tab shows template tabs and embeds puzzle form by default', async (
|
||||
screen.getByRole('tab', { name: '拼图' }).querySelector('img')?.src,
|
||||
).toContain('/creation-type-references/puzzle.webp');
|
||||
expect(
|
||||
screen.getByRole('tab', { name: 'AIRP' }).querySelector('img')?.src,
|
||||
).toContain('/creation-type-references/airp.webp');
|
||||
screen.getByRole('tab', { name: '文字冒险' }).querySelector('img')?.src,
|
||||
).toContain('/creation-type-references/rpg.webp');
|
||||
expect(
|
||||
screen.getByRole('tab', { name: '抓大鹅' }).querySelector('img')?.src,
|
||||
).toContain('/creation-type-references/match3d.webp');
|
||||
@@ -8000,6 +8020,288 @@ test('agent draft result test button enters current draft without publish gate',
|
||||
).toBe(false);
|
||||
}, 10_000);
|
||||
|
||||
test('agent draft result test button enters the opened draft profile instead of a previous session', async () => {
|
||||
const user = userEvent.setup();
|
||||
const handleCustomWorldSelect = vi.fn();
|
||||
|
||||
const previousDraftSession = {
|
||||
...compiledAgentDraftSession,
|
||||
sessionId: 'custom-world-agent-session-1',
|
||||
resultPreview: {
|
||||
...compiledAgentDraftSession.resultPreview!,
|
||||
publishReady: false,
|
||||
canEnterWorld: true,
|
||||
preview: {
|
||||
...compiledAgentResultPreview,
|
||||
id: 'agent-draft-custom-world-agent-session-1',
|
||||
name: '潮雾列岛',
|
||||
summary: '上一份草稿内容,不能被本次启动复用。',
|
||||
playableNpcs: [
|
||||
{
|
||||
...compiledAgentResultPreview.playableNpcs[0]!,
|
||||
id: 'playable-previous-1',
|
||||
name: '沈砺',
|
||||
},
|
||||
],
|
||||
sessionId: 'custom-world-agent-session-1',
|
||||
},
|
||||
},
|
||||
} satisfies CustomWorldAgentSessionSnapshot;
|
||||
const openedDraftSession = {
|
||||
...compiledAgentDraftSession,
|
||||
sessionId: 'custom-world-agent-session-2',
|
||||
resultPreview: {
|
||||
...compiledAgentDraftSession.resultPreview!,
|
||||
publishReady: false,
|
||||
canEnterWorld: true,
|
||||
preview: {
|
||||
...compiledAgentResultPreview,
|
||||
id: 'agent-draft-custom-world-agent-session-2',
|
||||
name: '星砂废都',
|
||||
subtitle: '坠星沙海与废都钟楼',
|
||||
summary: '本次从草稿架打开的目标草稿内容。',
|
||||
playerGoal: '找到废都钟楼下被星砂掩埋的旧约。',
|
||||
playableNpcs: [
|
||||
{
|
||||
...compiledAgentResultPreview.playableNpcs[0]!,
|
||||
id: 'playable-opened-1',
|
||||
name: '砂眠',
|
||||
title: '废都引路人',
|
||||
},
|
||||
],
|
||||
storyNpcs: [],
|
||||
landmarks: [
|
||||
{
|
||||
...compiledAgentResultPreview.landmarks[0]!,
|
||||
id: 'landmark-opened-1',
|
||||
name: '坠星钟楼',
|
||||
},
|
||||
],
|
||||
sessionId: 'custom-world-agent-session-2',
|
||||
},
|
||||
},
|
||||
} satisfies CustomWorldAgentSessionSnapshot;
|
||||
const sessionsById = new Map([
|
||||
[previousDraftSession.sessionId, previousDraftSession],
|
||||
[openedDraftSession.sessionId, openedDraftSession],
|
||||
]);
|
||||
|
||||
vi.mocked(getRpgCreationOperation).mockResolvedValue({
|
||||
operationId: 'operation-draft-foundation-1',
|
||||
type: 'draft_foundation',
|
||||
status: 'completed',
|
||||
phaseLabel: '世界底稿已生成',
|
||||
phaseDetail: '第一版世界底稿和草稿卡已经整理完成。',
|
||||
progress: 100,
|
||||
error: null,
|
||||
});
|
||||
vi.mocked(getRpgCreationSession).mockImplementation(async (sessionId) => {
|
||||
const session = sessionsById.get(sessionId);
|
||||
if (!session) {
|
||||
throw new Error(`Missing test session: ${sessionId}`);
|
||||
}
|
||||
return session;
|
||||
});
|
||||
vi.mocked(getRpgCreationResultView).mockImplementation(async (sessionId) => {
|
||||
const session = sessionsById.get(sessionId);
|
||||
if (!session) {
|
||||
throw new Error(`Missing test result view: ${sessionId}`);
|
||||
}
|
||||
return buildResultViewForSession(session);
|
||||
});
|
||||
vi.mocked(listRpgCreationWorks).mockResolvedValue([
|
||||
buildExistingRpgDraftWork({
|
||||
workId: 'draft:custom-world-agent-session-1',
|
||||
title: '潮雾列岛',
|
||||
summary: '上一份草稿内容,不能被本次启动复用。',
|
||||
sessionId: 'custom-world-agent-session-1',
|
||||
playableNpcCount: 1,
|
||||
landmarkCount: 1,
|
||||
}),
|
||||
buildExistingRpgDraftWork({
|
||||
workId: 'draft:custom-world-agent-session-2',
|
||||
title: '星砂废都',
|
||||
subtitle: '待完善草稿',
|
||||
summary: '本次从草稿架打开的目标草稿内容。',
|
||||
sessionId: 'custom-world-agent-session-2',
|
||||
playableNpcCount: 1,
|
||||
landmarkCount: 1,
|
||||
}),
|
||||
]);
|
||||
|
||||
render(<TestWrapper withAuth onSelectWorld={handleCustomWorldSelect} />);
|
||||
|
||||
await openDraftHub(user);
|
||||
const draftPanel = getPlatformTabPanel('saves');
|
||||
await user.click(
|
||||
await within(draftPanel).findByRole('button', {
|
||||
name: /继续完善《星砂废都》/u,
|
||||
}),
|
||||
);
|
||||
expect(await screen.findByText('世界档案', {}, { timeout: 5000 })).toBeTruthy();
|
||||
expect(screen.getByText('星砂废都')).toBeTruthy();
|
||||
|
||||
await user.click(
|
||||
await screen.findByRole('button', { name: '作品测试' }, { timeout: 5000 }),
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(handleCustomWorldSelect).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
id: 'agent-draft-custom-world-agent-session-2',
|
||||
name: '星砂废都',
|
||||
summary: '本次从草稿架打开的目标草稿内容。',
|
||||
playableNpcs: [
|
||||
expect.objectContaining({
|
||||
id: 'playable-opened-1',
|
||||
name: '砂眠',
|
||||
}),
|
||||
],
|
||||
}),
|
||||
expect.objectContaining({
|
||||
mode: 'play',
|
||||
disablePersistence: true,
|
||||
returnStage: 'custom-world-result',
|
||||
}),
|
||||
);
|
||||
});
|
||||
expect(
|
||||
vi
|
||||
.mocked(executeRpgCreationAction)
|
||||
.mock.calls.some(([, payload]) => payload?.action === 'publish_world'),
|
||||
).toBe(false);
|
||||
}, 10_000);
|
||||
|
||||
test('agent draft result start button enters the opened published draft profile instead of a previous session', async () => {
|
||||
const user = userEvent.setup();
|
||||
const handleCustomWorldSelect = vi.fn();
|
||||
|
||||
const previousDraftSession = {
|
||||
...compiledAgentDraftSession,
|
||||
sessionId: 'custom-world-agent-session-1',
|
||||
stage: 'published',
|
||||
resultPreview: {
|
||||
...compiledAgentDraftSession.resultPreview!,
|
||||
publishReady: true,
|
||||
canEnterWorld: true,
|
||||
preview: {
|
||||
...compiledAgentResultPreview,
|
||||
id: 'agent-draft-custom-world-agent-session-1',
|
||||
name: '潮雾列岛',
|
||||
summary: '上一份已发布草稿内容,不能被本次启动复用。',
|
||||
sessionId: 'custom-world-agent-session-1',
|
||||
},
|
||||
},
|
||||
} satisfies CustomWorldAgentSessionSnapshot;
|
||||
const openedPublishedDraftSession = {
|
||||
...compiledAgentDraftSession,
|
||||
sessionId: 'custom-world-agent-session-2',
|
||||
stage: 'published',
|
||||
resultPreview: {
|
||||
...compiledAgentDraftSession.resultPreview!,
|
||||
publishReady: true,
|
||||
canEnterWorld: true,
|
||||
preview: {
|
||||
...compiledAgentResultPreview,
|
||||
id: 'agent-draft-custom-world-agent-session-2',
|
||||
name: '星砂废都',
|
||||
subtitle: '坠星沙海与废都钟楼',
|
||||
summary: '本次从草稿架打开且已发布的目标草稿内容。',
|
||||
playableNpcs: [
|
||||
{
|
||||
...compiledAgentResultPreview.playableNpcs[0]!,
|
||||
id: 'playable-opened-1',
|
||||
name: '砂眠',
|
||||
title: '废都引路人',
|
||||
},
|
||||
],
|
||||
sessionId: 'custom-world-agent-session-2',
|
||||
},
|
||||
},
|
||||
} satisfies CustomWorldAgentSessionSnapshot;
|
||||
const sessionsById = new Map([
|
||||
[previousDraftSession.sessionId, previousDraftSession],
|
||||
[openedPublishedDraftSession.sessionId, openedPublishedDraftSession],
|
||||
]);
|
||||
|
||||
vi.mocked(getRpgCreationSession).mockImplementation(async (sessionId) => {
|
||||
const session = sessionsById.get(sessionId);
|
||||
if (!session) {
|
||||
throw new Error(`Missing test session: ${sessionId}`);
|
||||
}
|
||||
return session;
|
||||
});
|
||||
vi.mocked(getRpgCreationResultView).mockImplementation(async (sessionId) => {
|
||||
const session = sessionsById.get(sessionId);
|
||||
if (!session) {
|
||||
throw new Error(`Missing test result view: ${sessionId}`);
|
||||
}
|
||||
return buildResultViewForSession(session);
|
||||
});
|
||||
vi.mocked(listRpgCreationWorks).mockResolvedValue([
|
||||
buildExistingRpgDraftWork({
|
||||
workId: 'draft:custom-world-agent-session-1',
|
||||
title: '潮雾列岛',
|
||||
summary: '上一份已发布草稿内容,不能被本次启动复用。',
|
||||
stage: 'published',
|
||||
stageLabel: '已发布',
|
||||
sessionId: 'custom-world-agent-session-1',
|
||||
playableNpcCount: 1,
|
||||
landmarkCount: 1,
|
||||
canEnterWorld: true,
|
||||
}),
|
||||
buildExistingRpgDraftWork({
|
||||
workId: 'draft:custom-world-agent-session-2',
|
||||
title: '星砂废都',
|
||||
subtitle: '已发布草稿',
|
||||
summary: '本次从草稿架打开且已发布的目标草稿内容。',
|
||||
stage: 'published',
|
||||
stageLabel: '已发布',
|
||||
sessionId: 'custom-world-agent-session-2',
|
||||
playableNpcCount: 1,
|
||||
landmarkCount: 1,
|
||||
canEnterWorld: true,
|
||||
}),
|
||||
]);
|
||||
|
||||
render(<TestWrapper withAuth onSelectWorld={handleCustomWorldSelect} />);
|
||||
|
||||
await openDraftHub(user);
|
||||
const draftPanel = getPlatformTabPanel('saves');
|
||||
await user.click(
|
||||
await within(draftPanel).findByRole('button', {
|
||||
name: /继续完善《星砂废都》/u,
|
||||
}),
|
||||
);
|
||||
expect(await screen.findByText('世界档案', {}, { timeout: 5000 })).toBeTruthy();
|
||||
expect(screen.getByText('星砂废都')).toBeTruthy();
|
||||
|
||||
await user.click(
|
||||
await screen.findByRole('button', { name: '进入世界' }, { timeout: 5000 }),
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(handleCustomWorldSelect).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
id: 'agent-draft-custom-world-agent-session-2',
|
||||
name: '星砂废都',
|
||||
summary: '本次从草稿架打开且已发布的目标草稿内容。',
|
||||
playableNpcs: [
|
||||
expect.objectContaining({
|
||||
id: 'playable-opened-1',
|
||||
name: '砂眠',
|
||||
}),
|
||||
],
|
||||
}),
|
||||
);
|
||||
});
|
||||
expect(
|
||||
vi
|
||||
.mocked(executeRpgCreationAction)
|
||||
.mock.calls.some(([, payload]) => payload?.action === 'publish_world'),
|
||||
).toBe(false);
|
||||
}, 10_000);
|
||||
|
||||
test('agent result view does not keep legacy publish blockers when preview uses anchorContent and sceneChapterBlueprints', async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
@@ -8046,7 +8348,7 @@ test('agent result view does not keep legacy publish blockers when preview uses
|
||||
publishReady: true,
|
||||
blockers: [],
|
||||
preview: {
|
||||
...compiledAgentDraftSession.resultPreview!.preview,
|
||||
...compiledAgentResultPreview,
|
||||
settingText: '被海雾吞没的旧航路群岛',
|
||||
anchorContent: {
|
||||
worldPromise:
|
||||
@@ -8684,6 +8986,65 @@ test('save tab can resume a selected archive directly into the game', async () =
|
||||
});
|
||||
});
|
||||
|
||||
test('profile page exposes save archive picker as a direct entry', async () => {
|
||||
const user = userEvent.setup();
|
||||
const handleContinueGame = vi.fn();
|
||||
|
||||
vi.mocked(listProfileSaveArchives).mockResolvedValue([
|
||||
{
|
||||
worldKey: 'custom:world-1',
|
||||
ownerUserId: null,
|
||||
profileId: 'world-1',
|
||||
worldType: 'CUSTOM',
|
||||
worldName: '潮雾列岛',
|
||||
subtitle: '旧灯塔与失控航路',
|
||||
summaryText: '回到旧灯塔继续推进调查。',
|
||||
coverImageSrc: null,
|
||||
lastPlayedAt: '2026-04-19T12:00:00.000Z',
|
||||
},
|
||||
]);
|
||||
vi.mocked(resumeProfileSaveArchive).mockResolvedValue({
|
||||
entry: {
|
||||
worldKey: 'custom:world-1',
|
||||
ownerUserId: null,
|
||||
profileId: 'world-1',
|
||||
worldType: 'CUSTOM',
|
||||
worldName: '潮雾列岛',
|
||||
subtitle: '旧灯塔与失控航路',
|
||||
summaryText: '回到旧灯塔继续推进调查。',
|
||||
coverImageSrc: null,
|
||||
lastPlayedAt: '2026-04-19T12:00:00.000Z',
|
||||
},
|
||||
snapshot: {
|
||||
version: 2,
|
||||
savedAt: '2026-04-19T12:00:00.000Z',
|
||||
bottomTab: 'adventure',
|
||||
currentStory: null,
|
||||
gameState: {
|
||||
worldType: 'CUSTOM',
|
||||
},
|
||||
} as HydratedSavedGameSnapshot,
|
||||
});
|
||||
|
||||
render(<TestWrapper withAuth onContinueGame={handleContinueGame} />);
|
||||
|
||||
await clickFirstButtonByName(user, '我的');
|
||||
const shortcutRegion = await screen.findByRole('region', { name: '常用功能' });
|
||||
await user.click(within(shortcutRegion).getByRole('button', { name: /存档/u }));
|
||||
|
||||
const closeButton = await screen.findByLabelText('关闭存档');
|
||||
const modal = closeButton.closest('.fixed') as HTMLElement;
|
||||
expect(modal).toBeTruthy();
|
||||
expect(within(modal).getByText('SAVES')).toBeTruthy();
|
||||
|
||||
await user.click(within(modal).getByRole('button', { name: /潮雾列岛/u }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(resumeProfileSaveArchive).toHaveBeenCalledWith('custom:world-1');
|
||||
expect(handleContinueGame).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
test('creation hub published work can open detail view before deleting from detail page', async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
@@ -8917,6 +9278,342 @@ test('creation hub published work experience button enters world directly', asyn
|
||||
expect(handleCustomWorldSelect).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test('creation hub published work start uses loaded detail profile instead of library summary', async () => {
|
||||
const user = userEvent.setup();
|
||||
const handleCustomWorldSelect = vi.fn();
|
||||
const workProfileId = 'world-detail-launch-1';
|
||||
const summaryEntry = buildMockRpgGalleryDetail({
|
||||
ownerUserId: mockAuthUser.id,
|
||||
profileId: workProfileId,
|
||||
publicWorkCode: 'work-detail-launch-1',
|
||||
authorPublicUserCode: mockAuthUser.publicUserCode,
|
||||
visibility: 'published',
|
||||
publishedAt: '2026-04-20T10:00:00.000Z',
|
||||
updatedAt: '2026-04-20T10:00:00.000Z',
|
||||
authorDisplayName: mockAuthUser.displayName,
|
||||
worldName: '星砂废都',
|
||||
subtitle: '坠星沙海与废都钟楼',
|
||||
summaryText: '列表摘要只提供卡片信息,不能作为运行态 profile。',
|
||||
coverImageSrc: null,
|
||||
themeMode: 'tide',
|
||||
playableNpcCount: 1,
|
||||
landmarkCount: 1,
|
||||
likeCount: 0,
|
||||
});
|
||||
summaryEntry.profile = {
|
||||
...summaryEntry.profile,
|
||||
name: '默认档案',
|
||||
summary: '列表摘要不含运行态角色。',
|
||||
playableNpcs: [],
|
||||
storyNpcs: [],
|
||||
landmarks: [],
|
||||
};
|
||||
const detailEntry = buildMockRpgGalleryDetail({
|
||||
...summaryEntry,
|
||||
summaryText: '详情接口返回完整草稿内容。',
|
||||
});
|
||||
detailEntry.profile = {
|
||||
...detailEntry.profile,
|
||||
name: '星砂废都',
|
||||
subtitle: '坠星沙海与废都钟楼',
|
||||
summary: '详情接口返回完整草稿内容。',
|
||||
playableNpcs: [
|
||||
{
|
||||
...compiledAgentResultPreview.playableNpcs[0]!,
|
||||
id: 'playable-stardust-1',
|
||||
name: '砂眠',
|
||||
title: '废都引路人',
|
||||
},
|
||||
],
|
||||
landmarks: [
|
||||
{
|
||||
...compiledAgentResultPreview.landmarks[0]!,
|
||||
id: 'landmark-stardust-1',
|
||||
name: '坠星钟楼',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
vi.mocked(listRpgCreationWorks).mockResolvedValue([
|
||||
{
|
||||
workId: `published:${workProfileId}`,
|
||||
sourceType: 'published_profile',
|
||||
status: 'published',
|
||||
title: '星砂废都',
|
||||
subtitle: '坠星沙海与废都钟楼',
|
||||
summary: '详情接口返回完整草稿内容。',
|
||||
coverImageSrc: null,
|
||||
coverRenderMode: 'image',
|
||||
coverCharacterImageSrcs: [],
|
||||
updatedAt: '2026-04-20T10:00:00.000Z',
|
||||
publishedAt: '2026-04-20T10:00:00.000Z',
|
||||
stage: null,
|
||||
stageLabel: '已发布',
|
||||
playableNpcCount: 1,
|
||||
landmarkCount: 1,
|
||||
roleVisualReadyCount: 1,
|
||||
roleAnimationReadyCount: 0,
|
||||
roleAssetSummaryLabel: null,
|
||||
sessionId: null,
|
||||
profileId: workProfileId,
|
||||
canResume: false,
|
||||
canEnterWorld: true,
|
||||
},
|
||||
]);
|
||||
vi.mocked(listRpgEntryWorldLibrary).mockResolvedValue([summaryEntry]);
|
||||
vi.mocked(
|
||||
rpgEntryLibraryServiceMocks.getRpgEntryWorldLibraryDetail,
|
||||
).mockResolvedValue(detailEntry);
|
||||
|
||||
render(<TestWrapper withAuth onSelectWorld={handleCustomWorldSelect} />);
|
||||
|
||||
await openDraftHub(user);
|
||||
await user.click(await screen.findByRole('button', { name: /查看详情/u }));
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
rpgEntryLibraryServiceMocks.getRpgEntryWorldLibraryDetail,
|
||||
).toHaveBeenCalledWith(workProfileId);
|
||||
});
|
||||
await user.click(await screen.findByRole('button', { name: '启动' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(handleCustomWorldSelect).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
id: workProfileId,
|
||||
name: '星砂废都',
|
||||
summary: '详情接口返回完整草稿内容。',
|
||||
playableNpcs: [
|
||||
expect.objectContaining({
|
||||
id: 'playable-stardust-1',
|
||||
name: '砂眠',
|
||||
}),
|
||||
],
|
||||
landmarks: [
|
||||
expect.objectContaining({
|
||||
id: 'landmark-stardust-1',
|
||||
name: '坠星钟楼',
|
||||
}),
|
||||
],
|
||||
}),
|
||||
);
|
||||
});
|
||||
expect(handleCustomWorldSelect).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test('creation hub published work edit keeps loaded detail profile assets instead of library summary', async () => {
|
||||
const user = userEvent.setup();
|
||||
const workProfileId = 'world-detail-edit-assets-1';
|
||||
const summaryEntry = buildMockRpgGalleryDetail({
|
||||
ownerUserId: mockAuthUser.id,
|
||||
profileId: workProfileId,
|
||||
publicWorkCode: 'work-detail-edit-assets-1',
|
||||
authorPublicUserCode: mockAuthUser.publicUserCode,
|
||||
visibility: 'published',
|
||||
publishedAt: '2026-04-20T10:00:00.000Z',
|
||||
updatedAt: '2026-04-20T10:00:00.000Z',
|
||||
authorDisplayName: mockAuthUser.displayName,
|
||||
worldName: '星砂废都',
|
||||
subtitle: '坠星沙海与废都钟楼',
|
||||
summaryText: '列表摘要字段齐全但不含详情资产。',
|
||||
coverImageSrc: null,
|
||||
themeMode: 'tide',
|
||||
playableNpcCount: 1,
|
||||
landmarkCount: 1,
|
||||
likeCount: 0,
|
||||
});
|
||||
summaryEntry.profile = {
|
||||
...summaryEntry.profile,
|
||||
name: '星砂废都',
|
||||
summary: '列表摘要字段齐全但不含详情资产。',
|
||||
playableNpcs: [
|
||||
{
|
||||
...compiledAgentResultPreview.playableNpcs[0]!,
|
||||
id: 'playable-stardust-1',
|
||||
name: '砂眠',
|
||||
imageSrc: undefined,
|
||||
},
|
||||
],
|
||||
storyNpcs: [
|
||||
{
|
||||
...compiledAgentResultPreview.storyNpcs[0]!,
|
||||
id: 'story-clock-keeper-1',
|
||||
name: '钟守',
|
||||
imageSrc: undefined,
|
||||
},
|
||||
],
|
||||
landmarks: [
|
||||
{
|
||||
...compiledAgentResultPreview.landmarks[0]!,
|
||||
id: 'landmark-stardust-1',
|
||||
name: '坠星钟楼',
|
||||
imageSrc: undefined,
|
||||
},
|
||||
],
|
||||
sceneChapterBlueprints: [
|
||||
{
|
||||
id: 'scene-chapter-stardust-1',
|
||||
sceneId: 'landmark-stardust-1',
|
||||
title: '坠星钟楼',
|
||||
summary: '星砂覆盖钟楼入口,钟守等待第一位访客。',
|
||||
sceneTaskDescription: '调查钟楼旧铃自鸣的原因。',
|
||||
linkedThreadIds: [],
|
||||
linkedLandmarkIds: ['landmark-stardust-1'],
|
||||
acts: [
|
||||
{
|
||||
id: 'act-stardust-opening-1',
|
||||
sceneId: 'landmark-stardust-1',
|
||||
title: '第一幕',
|
||||
summary: '砂眠带玩家进入坠星钟楼。',
|
||||
stageCoverage: ['opening'],
|
||||
backgroundImageSrc: undefined,
|
||||
encounterNpcIds: ['playable-stardust-1'],
|
||||
primaryNpcId: 'playable-stardust-1',
|
||||
oppositeNpcId: 'story-clock-keeper-1',
|
||||
eventDescription: '钟楼旧铃忽然自鸣。',
|
||||
linkedThreadIds: [],
|
||||
advanceRule: 'after_primary_contact',
|
||||
actGoal: '进入钟楼。',
|
||||
transitionHook: '星砂开始倒流。',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
cover: null,
|
||||
openingCg: null,
|
||||
};
|
||||
const detailEntry = buildMockRpgGalleryDetail({
|
||||
...summaryEntry,
|
||||
summaryText: '详情接口返回完整草稿内容。',
|
||||
});
|
||||
detailEntry.profile = {
|
||||
...summaryEntry.profile,
|
||||
summary: '详情接口返回完整草稿内容。',
|
||||
cover: {
|
||||
sourceType: 'generated',
|
||||
imageSrc: '/assets/custom-world/star-waste-cover.png',
|
||||
characterRoleIds: ['playable-stardust-1'],
|
||||
},
|
||||
openingCg: {
|
||||
id: 'opening-cg-stardust-1',
|
||||
status: 'ready',
|
||||
storyboardImageSrc: '/assets/custom-world/opening-storyboard.png',
|
||||
videoSrc: '/assets/custom-world/opening.mp4',
|
||||
imageModel: 'gpt-image-2',
|
||||
videoModel: 'doubao-seedance-2-0-fast-260128',
|
||||
aspectRatio: '16:9',
|
||||
imageSize: '2k',
|
||||
videoResolution: '480p',
|
||||
durationSeconds: 15,
|
||||
pointCost: 80,
|
||||
estimatedWaitMinutes: 10,
|
||||
updatedAt: '2026-05-21T00:00:00.000Z',
|
||||
},
|
||||
camp: {
|
||||
id: 'camp-stardust-1',
|
||||
name: '废都营地',
|
||||
description: '钟楼阴影下的临时营地。',
|
||||
imageSrc: '/assets/custom-world/star-waste-camp.png',
|
||||
sceneNpcIds: ['playable-stardust-1'],
|
||||
connections: [],
|
||||
},
|
||||
playableNpcs: [
|
||||
{
|
||||
...summaryEntry.profile.playableNpcs[0]!,
|
||||
imageSrc: '/assets/custom-world/playable-stardust-1.png',
|
||||
},
|
||||
],
|
||||
storyNpcs: [
|
||||
{
|
||||
...summaryEntry.profile.storyNpcs[0]!,
|
||||
imageSrc: '/assets/custom-world/story-clock-keeper-1.png',
|
||||
},
|
||||
],
|
||||
landmarks: [
|
||||
{
|
||||
...summaryEntry.profile.landmarks[0]!,
|
||||
imageSrc: '/assets/custom-world/landmark-stardust-1.png',
|
||||
},
|
||||
],
|
||||
sceneChapterBlueprints: [
|
||||
{
|
||||
...summaryEntry.profile.sceneChapterBlueprints![0]!,
|
||||
acts: [
|
||||
{
|
||||
...summaryEntry.profile.sceneChapterBlueprints![0]!.acts[0]!,
|
||||
backgroundImageSrc:
|
||||
'/assets/custom-world/act-stardust-opening-1.png',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
vi.mocked(listRpgCreationWorks).mockResolvedValue([
|
||||
{
|
||||
workId: `published:${workProfileId}`,
|
||||
sourceType: 'published_profile',
|
||||
status: 'published',
|
||||
title: '星砂废都',
|
||||
subtitle: '坠星沙海与废都钟楼',
|
||||
summary: '列表摘要字段齐全但不含详情资产。',
|
||||
coverImageSrc: null,
|
||||
coverRenderMode: 'image',
|
||||
coverCharacterImageSrcs: [],
|
||||
updatedAt: '2026-04-20T10:00:00.000Z',
|
||||
publishedAt: '2026-04-20T10:00:00.000Z',
|
||||
stage: null,
|
||||
stageLabel: '已发布',
|
||||
playableNpcCount: 1,
|
||||
landmarkCount: 1,
|
||||
roleVisualReadyCount: 0,
|
||||
roleAnimationReadyCount: 0,
|
||||
roleAssetSummaryLabel: null,
|
||||
sessionId: null,
|
||||
profileId: workProfileId,
|
||||
canResume: false,
|
||||
canEnterWorld: true,
|
||||
},
|
||||
]);
|
||||
vi.mocked(listRpgEntryWorldLibrary).mockResolvedValue([summaryEntry]);
|
||||
vi.mocked(
|
||||
rpgEntryLibraryServiceMocks.getRpgEntryWorldLibraryDetail,
|
||||
).mockResolvedValue(detailEntry);
|
||||
|
||||
render(<TestWrapper withAuth />);
|
||||
|
||||
await openDraftHub(user);
|
||||
await user.click(await screen.findByRole('button', { name: /查看详情/u }));
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
rpgEntryLibraryServiceMocks.getRpgEntryWorldLibraryDetail,
|
||||
).toHaveBeenCalledWith(workProfileId);
|
||||
});
|
||||
await user.click(await screen.findByRole('button', { name: '作品编辑' }));
|
||||
|
||||
expect(await screen.findByText('世界档案', {}, { timeout: 5000 })).toBeTruthy();
|
||||
expect(
|
||||
document.querySelector('video[src="/assets/custom-world/opening.mp4"]'),
|
||||
).toBeTruthy();
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /场景\s+2/u }));
|
||||
expect((await screen.findByAltText('废都营地')).getAttribute('src')).toBe(
|
||||
'/assets/custom-world/star-waste-camp.png',
|
||||
);
|
||||
expect(screen.getByAltText('坠星钟楼-第一幕').getAttribute('src')).toBe(
|
||||
'/assets/custom-world/act-stardust-opening-1.png',
|
||||
);
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /可扮演角色\s+1/u }));
|
||||
expect((await screen.findByAltText('砂眠')).getAttribute('src')).toBe(
|
||||
'/assets/custom-world/playable-stardust-1.png',
|
||||
);
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /场景角色\s+1/u }));
|
||||
expect((await screen.findByAltText('钟守')).getAttribute('src')).toBe(
|
||||
'/assets/custom-world/story-clock-keeper-1.png',
|
||||
);
|
||||
});
|
||||
|
||||
test('creation hub published work card reveals delete action after card action reveal', async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import {
|
||||
AlertCircle,
|
||||
Archive,
|
||||
ArrowRight,
|
||||
BookOpen,
|
||||
Camera,
|
||||
@@ -233,7 +234,8 @@ const WECHAT_JS_SDK_URL = 'https://res.wx.qq.com/open/js/jweixin-1.6.0.js';
|
||||
const WECHAT_PAY_CONFIRM_RETRY_DELAYS_MS = [800, 1600, 3000] as const;
|
||||
const WECHAT_NATIVE_PAY_QR_IMAGE_SIZE = 180;
|
||||
|
||||
type ProfilePopupPanel = 'invite' | 'redeem' | 'community';
|
||||
type ProfileReferralPanel = 'invite' | 'redeem' | 'community';
|
||||
type ProfilePopupPanel = ProfileReferralPanel | 'saveArchives';
|
||||
type RechargeTab = 'points' | 'membership';
|
||||
type WechatMiniProgramPaymentStatus = 'success' | 'fail' | 'cancel';
|
||||
type WechatPayResult = {
|
||||
@@ -3314,7 +3316,7 @@ function ProfileReferralModal({
|
||||
onRedeemCodeChange,
|
||||
onSubmitRedeemCode,
|
||||
}: {
|
||||
panel: ProfilePopupPanel;
|
||||
panel: ProfileReferralPanel;
|
||||
center: ProfileReferralInviteCenterResponse | null;
|
||||
isLoading: boolean;
|
||||
isSubmittingRedeem: boolean;
|
||||
@@ -3485,6 +3487,66 @@ function ProfileReferralModal({
|
||||
);
|
||||
}
|
||||
|
||||
function ProfileSaveArchivesModal({
|
||||
saveEntries,
|
||||
saveError,
|
||||
isResumingSaveWorldKey,
|
||||
onClose,
|
||||
onResumeSave,
|
||||
}: {
|
||||
saveEntries: ProfileSaveArchiveSummary[];
|
||||
saveError: string | null;
|
||||
isResumingSaveWorldKey: string | null;
|
||||
onClose: () => void;
|
||||
onResumeSave: (entry: ProfileSaveArchiveSummary) => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="fixed inset-0 z-[80] flex items-center justify-center bg-black/48 px-3 py-5">
|
||||
<div className="relative max-h-[min(92vh,42rem)] w-full max-w-[38rem] overflow-hidden rounded-[1.35rem] bg-white text-zinc-950 shadow-2xl">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="absolute right-3 top-3 z-10 flex h-8 w-8 items-center justify-center rounded-full bg-white/80 text-[#ff4056] shadow-sm"
|
||||
aria-label="关闭存档"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
<div className="max-h-[min(92vh,42rem)] overflow-y-auto px-4 pb-5 pt-4 sm:px-5">
|
||||
<div className="pr-10">
|
||||
<div className="text-[10px] font-black tracking-[0.22em] text-[#ff4056]">
|
||||
SAVES
|
||||
</div>
|
||||
<div className="mt-1 text-2xl font-black">存档</div>
|
||||
</div>
|
||||
|
||||
{saveError ? (
|
||||
<div className="mt-4 rounded-xl border border-rose-200 bg-rose-50 px-3 py-2 text-sm text-rose-700">
|
||||
{saveError}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{saveEntries.length > 0 ? (
|
||||
<div className="mt-5 grid gap-3">
|
||||
{saveEntries.map((entry) => (
|
||||
<SaveArchiveCard
|
||||
key={`${entry.worldKey}:profile-archive`}
|
||||
entry={entry}
|
||||
loading={isResumingSaveWorldKey === entry.worldKey}
|
||||
onClick={() => onResumeSave(entry)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="mt-5 rounded-xl bg-zinc-50 px-4 py-5 text-center text-sm font-semibold text-zinc-500">
|
||||
暂无存档
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ProfilePlayedWorksModal({
|
||||
stats,
|
||||
isLoading,
|
||||
@@ -4512,7 +4574,7 @@ export function RpgEntryHomeView({
|
||||
|
||||
loadReferralCenter();
|
||||
}, [activeTab, authUi?.user?.createdAt, isAuthenticated, loadReferralCenter]);
|
||||
const openProfilePopupPanel = (panel: ProfilePopupPanel) => {
|
||||
const openProfilePopupPanel = (panel: ProfileReferralPanel) => {
|
||||
setProfilePopupPanel(panel);
|
||||
setReferralError(null);
|
||||
setReferralSuccess(null);
|
||||
@@ -5850,6 +5912,16 @@ export function RpgEntryHomeView({
|
||||
icon={showRechargeEntry ? Coins : Ticket}
|
||||
onClick={openRechargeOrRewardCodeModal}
|
||||
/>
|
||||
<ProfileShortcutButton
|
||||
label="存档"
|
||||
subLabel={
|
||||
saveEntries.length > 0
|
||||
? `${saveEntries.length}个可继续`
|
||||
: '继续游玩'
|
||||
}
|
||||
icon={Archive}
|
||||
onClick={() => setProfilePopupPanel('saveArchives')}
|
||||
/>
|
||||
{showRechargeEntry ? (
|
||||
<ProfileShortcutButton
|
||||
label="兑换码"
|
||||
@@ -6438,7 +6510,15 @@ export function RpgEntryHomeView({
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
{profilePopupPanel ? (
|
||||
{profilePopupPanel === 'saveArchives' ? (
|
||||
<ProfileSaveArchivesModal
|
||||
saveEntries={saveEntries}
|
||||
saveError={saveError}
|
||||
isResumingSaveWorldKey={isResumingSaveWorldKey}
|
||||
onClose={() => setProfilePopupPanel(null)}
|
||||
onResumeSave={onResumeSave}
|
||||
/>
|
||||
) : profilePopupPanel ? (
|
||||
<ProfileReferralModal
|
||||
panel={profilePopupPanel}
|
||||
center={referralCenter}
|
||||
@@ -6603,7 +6683,15 @@ export function RpgEntryHomeView({
|
||||
onClaim={claimTaskReward}
|
||||
/>
|
||||
) : null}
|
||||
{profilePopupPanel ? (
|
||||
{profilePopupPanel === 'saveArchives' ? (
|
||||
<ProfileSaveArchivesModal
|
||||
saveEntries={saveEntries}
|
||||
saveError={saveError}
|
||||
isResumingSaveWorldKey={isResumingSaveWorldKey}
|
||||
onClose={() => setProfilePopupPanel(null)}
|
||||
onResumeSave={onResumeSave}
|
||||
/>
|
||||
) : profilePopupPanel ? (
|
||||
<ProfileReferralModal
|
||||
panel={profilePopupPanel}
|
||||
center={referralCenter}
|
||||
|
||||
157
src/components/rpg-entry/rpgProfileCompleteness.ts
Normal file
157
src/components/rpg-entry/rpgProfileCompleteness.ts
Normal file
@@ -0,0 +1,157 @@
|
||||
import type { CustomWorldProfile } from '../../types';
|
||||
|
||||
export function countCustomWorldProfileDetailSlots(
|
||||
profile: Partial<CustomWorldProfile> | null | undefined,
|
||||
) {
|
||||
if (!profile) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return (
|
||||
(profile.playableNpcs?.length ?? 0) +
|
||||
(profile.storyNpcs?.length ?? 0) +
|
||||
(profile.items?.length ?? 0) +
|
||||
(profile.landmarks?.length ?? 0) +
|
||||
(profile.sceneChapterBlueprints?.length ?? 0)
|
||||
);
|
||||
}
|
||||
|
||||
export function countCustomWorldProfileAssetSlots(
|
||||
profile: Partial<CustomWorldProfile> | null | undefined,
|
||||
) {
|
||||
if (!profile) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return [
|
||||
profile.cover?.imageSrc,
|
||||
profile.openingCg?.storyboardImageSrc,
|
||||
profile.openingCg?.videoSrc,
|
||||
profile.openingCg?.posterImageSrc,
|
||||
profile.camp?.imageSrc,
|
||||
...(profile.playableNpcs ?? []).flatMap((role) => [
|
||||
role.imageSrc,
|
||||
role.generatedVisualAssetId,
|
||||
role.generatedAnimationSetId,
|
||||
...(role.skills ?? []).flatMap((skill) => [
|
||||
skill.actionPreviewConfig?.basePath,
|
||||
skill.actionPreviewConfig?.previewVideoPath,
|
||||
skill.actionPreviewConfig?.file,
|
||||
]),
|
||||
...role.initialItems.flatMap((item) => [item.iconSrc]),
|
||||
...Object.values(role.animationMap ?? {}).flatMap((config) => [
|
||||
config?.basePath,
|
||||
config?.previewVideoPath,
|
||||
config?.file,
|
||||
]),
|
||||
]),
|
||||
...(profile.storyNpcs ?? []).flatMap((npc) => [
|
||||
npc.imageSrc,
|
||||
npc.generatedVisualAssetId,
|
||||
npc.generatedAnimationSetId,
|
||||
...(npc.skills ?? []).flatMap((skill) => [
|
||||
skill.actionPreviewConfig?.basePath,
|
||||
skill.actionPreviewConfig?.previewVideoPath,
|
||||
skill.actionPreviewConfig?.file,
|
||||
]),
|
||||
...npc.initialItems.flatMap((item) => [item.iconSrc]),
|
||||
...Object.values(npc.animationMap ?? {}).flatMap((config) => [
|
||||
config?.basePath,
|
||||
config?.previewVideoPath,
|
||||
config?.file,
|
||||
]),
|
||||
]),
|
||||
...(profile.items ?? []).flatMap((item) => [item.iconSrc, item.sourcePath]),
|
||||
...(profile.landmarks ?? []).map((landmark) => landmark.imageSrc),
|
||||
...(profile.sceneChapterBlueprints ?? []).flatMap((chapter) =>
|
||||
chapter.acts.flatMap((act) => [
|
||||
act.backgroundImageSrc,
|
||||
act.backgroundAssetId,
|
||||
]),
|
||||
),
|
||||
].filter((value) => value?.trim()).length;
|
||||
}
|
||||
|
||||
export function countCustomWorldProfileStructuredSlots(
|
||||
profile: Partial<CustomWorldProfile> | null | undefined,
|
||||
) {
|
||||
if (!profile) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return [
|
||||
profile.cover,
|
||||
profile.attributeSchema,
|
||||
profile.themePack,
|
||||
profile.storyGraph,
|
||||
profile.knowledgeFacts?.length ? profile.knowledgeFacts : null,
|
||||
profile.threadContracts?.length ? profile.threadContracts : null,
|
||||
profile.anchorContent,
|
||||
profile.creatorIntent,
|
||||
profile.anchorPack,
|
||||
profile.lockState,
|
||||
profile.ownedSettingLayers,
|
||||
profile.generationMode,
|
||||
profile.generationStatus,
|
||||
profile.scenarioPackId,
|
||||
profile.campaignPackId,
|
||||
...(profile.playableNpcs ?? []).flatMap((role) => [
|
||||
role.attributeProfile,
|
||||
...(role.skills ?? []).map((skill) => skill.actionPreviewConfig),
|
||||
...role.initialItems,
|
||||
]),
|
||||
...(profile.storyNpcs ?? []).flatMap((npc) => [
|
||||
npc.attributeProfile,
|
||||
...(npc.skills ?? []).map((skill) => skill.actionPreviewConfig),
|
||||
...npc.initialItems,
|
||||
]),
|
||||
...(profile.landmarks ?? []).flatMap((landmark) => [
|
||||
landmark.visualDescription,
|
||||
landmark.narrativeResidues?.length ? landmark.narrativeResidues : null,
|
||||
]),
|
||||
...((profile.camp?.narrativeResidues ?? []).length
|
||||
? [profile.camp?.narrativeResidues]
|
||||
: []),
|
||||
...(profile.sceneChapterBlueprints ?? []).flatMap((chapter) => [
|
||||
chapter.sceneTaskDescription,
|
||||
chapter.linkedThreadIds.length ? chapter.linkedThreadIds : null,
|
||||
chapter.linkedLandmarkIds.length ? chapter.linkedLandmarkIds : null,
|
||||
...chapter.acts.flatMap((act) => [
|
||||
act.eventDescription,
|
||||
act.linkedThreadIds.length ? act.linkedThreadIds : null,
|
||||
act.actGoal,
|
||||
act.transitionHook,
|
||||
]),
|
||||
]),
|
||||
].filter(Boolean).length;
|
||||
}
|
||||
|
||||
export function getCustomWorldProfileCompletenessScore(
|
||||
profile: Partial<CustomWorldProfile> | null | undefined,
|
||||
) {
|
||||
return (
|
||||
countCustomWorldProfileDetailSlots(profile) +
|
||||
countCustomWorldProfileAssetSlots(profile) +
|
||||
countCustomWorldProfileStructuredSlots(profile)
|
||||
);
|
||||
}
|
||||
|
||||
export function chooseMoreCompleteCustomWorldProfile(
|
||||
fallbackProfile: CustomWorldProfile,
|
||||
candidateProfile: CustomWorldProfile | null | undefined,
|
||||
) {
|
||||
if (!candidateProfile) {
|
||||
return fallbackProfile;
|
||||
}
|
||||
|
||||
if (candidateProfile.id !== fallbackProfile.id) {
|
||||
return candidateProfile;
|
||||
}
|
||||
|
||||
// 中文注释:发布 / 回读可能只返回列表摘要或旧快照。
|
||||
// 同一个 profileId 下,进入世界不能把当前结果页的封面、CG、角色资产降级掉。
|
||||
return getCustomWorldProfileCompletenessScore(candidateProfile) >=
|
||||
getCustomWorldProfileCompletenessScore(fallbackProfile)
|
||||
? candidateProfile
|
||||
: fallbackProfile;
|
||||
}
|
||||
@@ -4,6 +4,8 @@ import { act, render } from '@testing-library/react';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import type { CustomWorldAgentSessionSnapshot } from '../../../packages/shared/src/contracts/customWorldAgent';
|
||||
import type { RpgCreationResultView } from '../../../packages/shared/src/contracts/rpgCreationResultView';
|
||||
import type { CustomWorldProfileRecord } from '../../../packages/shared/src/contracts/runtime';
|
||||
import { type CustomWorldProfile, WorldType } from '../../types';
|
||||
import { useRpgCreationEnterWorld } from './useRpgCreationEnterWorld';
|
||||
|
||||
@@ -69,7 +71,9 @@ function buildProfile(params: {
|
||||
};
|
||||
}
|
||||
|
||||
function buildSession(): CustomWorldAgentSessionSnapshot {
|
||||
function buildSession(
|
||||
stage: CustomWorldAgentSessionSnapshot['stage'] = 'ready_to_publish',
|
||||
): CustomWorldAgentSessionSnapshot {
|
||||
return {
|
||||
sessionId: 'session-1',
|
||||
currentTurn: 1,
|
||||
@@ -85,7 +89,7 @@ function buildSession(): CustomWorldAgentSessionSnapshot {
|
||||
},
|
||||
progressPercent: 100,
|
||||
lastAssistantReply: '',
|
||||
stage: 'ready_to_publish',
|
||||
stage,
|
||||
focusCardId: null,
|
||||
creatorIntent: null,
|
||||
creatorIntentReadiness: {
|
||||
@@ -113,6 +117,31 @@ function buildSession(): CustomWorldAgentSessionSnapshot {
|
||||
};
|
||||
}
|
||||
|
||||
function buildResultView(params: {
|
||||
stage?: CustomWorldAgentSessionSnapshot['stage'];
|
||||
profile: CustomWorldProfile | null;
|
||||
canEnterWorld?: boolean;
|
||||
}): RpgCreationResultView {
|
||||
const stage = params.stage ?? 'ready_to_publish';
|
||||
const profileRecord = params.profile
|
||||
? (structuredClone(params.profile) as unknown as CustomWorldProfileRecord)
|
||||
: null;
|
||||
return {
|
||||
session: buildSession(stage),
|
||||
profile: profileRecord,
|
||||
profileSource: profileRecord ? 'result_preview' : 'none',
|
||||
targetStage: 'custom-world-result',
|
||||
generationViewSource: null,
|
||||
resultViewSource: profileRecord ? 'agent-draft' : null,
|
||||
canAutosaveLibrary: true,
|
||||
canSyncResultProfile: stage !== 'published',
|
||||
publishReady: true,
|
||||
canEnterWorld: params.canEnterWorld ?? stage === 'published',
|
||||
blockerCount: 0,
|
||||
recoveryAction: 'open_result',
|
||||
};
|
||||
}
|
||||
|
||||
describe('useRpgCreationEnterWorld', () => {
|
||||
it('Agent 草稿测试进入游戏时优先使用结果页当前 profile,而不是回退到会话快照', async () => {
|
||||
const resultProfile = buildProfile({
|
||||
@@ -167,4 +196,280 @@ describe('useRpgCreationEnterWorld', () => {
|
||||
handleCustomWorldSelect.mock.calls[0]?.[0].playableNpcs[0]?.imageSrc,
|
||||
).toBe('/generated-characters/draft-role/portrait.png');
|
||||
});
|
||||
|
||||
it('Agent 草稿发布时先保存当前结果页 profile,再发送 publish_world 并回读结果页', async () => {
|
||||
const resultProfile = buildProfile({
|
||||
id: 'draft-profile',
|
||||
name: '发布前填写内容',
|
||||
imageSrc: '/generated-characters/draft-role/portrait.png',
|
||||
});
|
||||
const syncedProfile = buildProfile({
|
||||
id: 'draft-profile',
|
||||
name: '已保存的填写内容',
|
||||
imageSrc: '/generated-characters/draft-role/synced.png',
|
||||
});
|
||||
const publishedProfile = buildProfile({
|
||||
id: 'draft-profile',
|
||||
name: '已发布世界',
|
||||
imageSrc: '/generated-characters/draft-role/published.png',
|
||||
});
|
||||
const callOrder: string[] = [];
|
||||
const handleCustomWorldSelect = vi.fn();
|
||||
const setGeneratedCustomWorldProfile = vi.fn();
|
||||
const syncAgentDraftResultProfile = vi.fn(async () => {
|
||||
callOrder.push('save');
|
||||
return {
|
||||
profile: syncedProfile,
|
||||
view: buildResultView({
|
||||
stage: 'ready_to_publish',
|
||||
profile: syncedProfile,
|
||||
canEnterWorld: false,
|
||||
}),
|
||||
};
|
||||
});
|
||||
const executePublishWorld = vi.fn(async () => {
|
||||
callOrder.push('publish');
|
||||
return buildSession('published');
|
||||
});
|
||||
const syncAgentCreationResultView = vi.fn(async () => {
|
||||
callOrder.push('reload');
|
||||
return buildResultView({
|
||||
stage: 'published',
|
||||
profile: publishedProfile,
|
||||
canEnterWorld: true,
|
||||
});
|
||||
});
|
||||
|
||||
function Harness() {
|
||||
const { publishCurrentResult } = useRpgCreationEnterWorld({
|
||||
isAgentDraftResultView: true,
|
||||
activeAgentSessionId: 'session-1',
|
||||
currentAgentSessionStage: 'ready_to_publish',
|
||||
generatedCustomWorldProfile: resultProfile,
|
||||
handleCustomWorldSelect,
|
||||
syncAgentDraftResultProfile,
|
||||
executePublishWorld,
|
||||
syncAgentCreationResultView,
|
||||
setGeneratedCustomWorldProfile,
|
||||
});
|
||||
|
||||
return (
|
||||
<button type="button" onClick={() => void publishCurrentResult()}>
|
||||
发布
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
const { getByText } = render(<Harness />);
|
||||
await act(async () => {
|
||||
getByText('发布').click();
|
||||
});
|
||||
|
||||
expect(callOrder).toEqual(['save', 'publish', 'reload']);
|
||||
expect(syncAgentDraftResultProfile).toHaveBeenCalledWith(resultProfile);
|
||||
expect(executePublishWorld).toHaveBeenCalledTimes(1);
|
||||
expect(syncAgentCreationResultView).toHaveBeenCalledWith('session-1');
|
||||
expect(setGeneratedCustomWorldProfile).toHaveBeenCalledWith(syncedProfile);
|
||||
expect(
|
||||
setGeneratedCustomWorldProfile.mock.calls.at(-1)?.[0]?.id,
|
||||
).toBe('draft-profile');
|
||||
expect(
|
||||
setGeneratedCustomWorldProfile.mock.calls.at(-1)?.[0]?.playableNpcs[0]
|
||||
?.imageSrc,
|
||||
).toBe('/generated-characters/draft-role/published.png');
|
||||
expect(handleCustomWorldSelect).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('Agent 会话已发布后点击进入世界不再重复发送 publish_world', async () => {
|
||||
const resultProfile = buildProfile({
|
||||
id: 'published-profile',
|
||||
name: '已发布世界',
|
||||
imageSrc: '/generated-characters/published-role/portrait.png',
|
||||
});
|
||||
const publishedView = buildResultView({
|
||||
stage: 'published',
|
||||
profile: resultProfile,
|
||||
canEnterWorld: true,
|
||||
});
|
||||
const handleCustomWorldSelect = vi.fn();
|
||||
const setGeneratedCustomWorldProfile = vi.fn();
|
||||
const executePublishWorld = vi.fn(async () => buildSession('published'));
|
||||
const syncAgentCreationResultView = vi.fn(async () => publishedView);
|
||||
const syncAgentDraftResultProfile = vi.fn(async () => ({
|
||||
profile: resultProfile,
|
||||
view: null,
|
||||
}));
|
||||
|
||||
function Harness() {
|
||||
const { enterWorldFromCurrentResult } = useRpgCreationEnterWorld({
|
||||
isAgentDraftResultView: true,
|
||||
activeAgentSessionId: 'session-1',
|
||||
currentAgentSessionStage: 'published',
|
||||
generatedCustomWorldProfile: resultProfile,
|
||||
handleCustomWorldSelect,
|
||||
syncAgentDraftResultProfile,
|
||||
executePublishWorld,
|
||||
syncAgentCreationResultView,
|
||||
setGeneratedCustomWorldProfile,
|
||||
});
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void enterWorldFromCurrentResult()}
|
||||
>
|
||||
进入世界
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
const { getByText } = render(<Harness />);
|
||||
await act(async () => {
|
||||
getByText('进入世界').click();
|
||||
});
|
||||
|
||||
expect(syncAgentDraftResultProfile).not.toHaveBeenCalled();
|
||||
expect(executePublishWorld).not.toHaveBeenCalled();
|
||||
expect(syncAgentCreationResultView).toHaveBeenCalledWith('session-1');
|
||||
expect(setGeneratedCustomWorldProfile).toHaveBeenCalledTimes(1);
|
||||
expect(setGeneratedCustomWorldProfile.mock.calls[0]?.[0]?.id).toBe(
|
||||
'published-profile',
|
||||
);
|
||||
expect(handleCustomWorldSelect).toHaveBeenCalledTimes(1);
|
||||
expect(handleCustomWorldSelect.mock.calls[0]?.[0]?.id).toBe(
|
||||
'published-profile',
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
it('正式进入世界回读结果页字段更少时不降级当前完整 profile', async () => {
|
||||
const resultProfile = {
|
||||
...buildProfile({
|
||||
id: 'draft-profile-rich-assets',
|
||||
name: '星砂废都',
|
||||
imageSrc: '/generated-characters/draft-role/portrait.png',
|
||||
}),
|
||||
cover: {
|
||||
sourceType: 'generated' as const,
|
||||
imageSrc: '/generated-custom-world-covers/star-waste/cover.webp',
|
||||
characterRoleIds: ['draft-profile-rich-assets-role'],
|
||||
},
|
||||
openingCg: {
|
||||
id: 'opening-cg-stardust',
|
||||
status: 'ready' as const,
|
||||
storyboardImageSrc: '/generated-custom-world-scenes/opening/storyboard.png',
|
||||
videoSrc: '/generated-custom-world-scenes/opening/opening.mp4',
|
||||
imageModel: 'gpt-image-2' as const,
|
||||
videoModel: 'doubao-seedance-2-0-fast-260128',
|
||||
aspectRatio: '16:9' as const,
|
||||
imageSize: '2k' as const,
|
||||
videoResolution: '480p' as const,
|
||||
durationSeconds: 15 as const,
|
||||
pointCost: 80 as const,
|
||||
estimatedWaitMinutes: 10 as const,
|
||||
updatedAt: '2026-05-21T00:00:00.000Z',
|
||||
},
|
||||
sceneChapterBlueprints: [
|
||||
{
|
||||
id: 'scene-chapter-stardust',
|
||||
sceneId: 'landmark-stardust',
|
||||
title: '钟楼第一夜',
|
||||
summary: '钟楼第一夜。',
|
||||
sceneTaskDescription: '进入钟楼。',
|
||||
linkedThreadIds: [],
|
||||
linkedLandmarkIds: ['landmark-stardust'],
|
||||
acts: [
|
||||
{
|
||||
id: 'act-stardust-opening',
|
||||
sceneId: 'landmark-stardust',
|
||||
title: '第一幕',
|
||||
summary: '砂眠带玩家进入坠星钟楼。',
|
||||
stageCoverage: ['opening' as const],
|
||||
backgroundImageSrc:
|
||||
'/assets/custom-world/act-stardust-opening.png',
|
||||
backgroundAssetId: 'asset-act-stardust-opening',
|
||||
encounterNpcIds: ['draft-profile-rich-assets-role'],
|
||||
primaryNpcId: 'draft-profile-rich-assets-role',
|
||||
oppositeNpcId: 'draft-profile-rich-assets-role',
|
||||
eventDescription: '钟楼旧铃忽然自鸣。',
|
||||
linkedThreadIds: [],
|
||||
advanceRule: 'after_primary_contact' as const,
|
||||
actGoal: '进入钟楼。',
|
||||
transitionHook: '星砂开始倒流。',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
} satisfies CustomWorldProfile;
|
||||
const stalePublishedProfile = {
|
||||
...resultProfile,
|
||||
name: '星砂废都',
|
||||
cover: null,
|
||||
openingCg: null,
|
||||
playableNpcs: [],
|
||||
sceneChapterBlueprints: null,
|
||||
} satisfies CustomWorldProfile;
|
||||
const handleCustomWorldSelect = vi.fn();
|
||||
const setGeneratedCustomWorldProfile = vi.fn();
|
||||
const syncAgentDraftResultProfile = vi.fn(async () => ({
|
||||
profile: resultProfile,
|
||||
view: buildResultView({
|
||||
stage: 'ready_to_publish',
|
||||
profile: resultProfile,
|
||||
canEnterWorld: false,
|
||||
}),
|
||||
}));
|
||||
const executePublishWorld = vi.fn(async () => buildSession('published'));
|
||||
const syncAgentCreationResultView = vi.fn(async () =>
|
||||
buildResultView({
|
||||
stage: 'published',
|
||||
profile: stalePublishedProfile,
|
||||
canEnterWorld: true,
|
||||
}),
|
||||
);
|
||||
|
||||
function Harness() {
|
||||
const { enterWorldFromCurrentResult } = useRpgCreationEnterWorld({
|
||||
isAgentDraftResultView: true,
|
||||
activeAgentSessionId: 'session-1',
|
||||
currentAgentSessionStage: 'ready_to_publish',
|
||||
generatedCustomWorldProfile: resultProfile,
|
||||
handleCustomWorldSelect,
|
||||
syncAgentDraftResultProfile,
|
||||
executePublishWorld,
|
||||
syncAgentCreationResultView,
|
||||
setGeneratedCustomWorldProfile,
|
||||
});
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void enterWorldFromCurrentResult()}
|
||||
>
|
||||
进入世界
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
const { getByText } = render(<Harness />);
|
||||
await act(async () => {
|
||||
getByText('进入世界').click();
|
||||
});
|
||||
|
||||
const launchedProfile = handleCustomWorldSelect.mock.calls[0]?.[0];
|
||||
expect(launchedProfile?.id).toBe('draft-profile-rich-assets');
|
||||
expect(launchedProfile?.cover?.imageSrc).toBe(
|
||||
'/generated-custom-world-covers/star-waste/cover.webp',
|
||||
);
|
||||
expect(launchedProfile?.openingCg?.videoSrc).toBe(
|
||||
'/generated-custom-world-scenes/opening/opening.mp4',
|
||||
);
|
||||
expect(launchedProfile?.playableNpcs[0]?.imageSrc).toBe(
|
||||
'/generated-characters/draft-role/portrait.png',
|
||||
);
|
||||
expect(
|
||||
launchedProfile?.sceneChapterBlueprints?.[0]?.acts[0]
|
||||
?.backgroundImageSrc,
|
||||
).toBe('/assets/custom-world/act-stardust-opening.png');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,13 +1,16 @@
|
||||
import { useCallback } from 'react';
|
||||
|
||||
import type { RpgCreationResultView } from '../../../packages/shared/src/contracts/rpgCreationResultView';
|
||||
import type { CustomWorldAgentSessionSnapshot } from '../../../packages/shared/src/contracts/customWorldAgent';
|
||||
import type { CustomWorldRuntimeLaunchOptions } from '../platform-entry/platformEntryTypes';
|
||||
import { rpgCreationPreviewAdapter } from '../../services/rpg-creation/rpgCreationPreviewAdapter';
|
||||
import type { CustomWorldProfile } from '../../types';
|
||||
import { chooseMoreCompleteCustomWorldProfile } from './rpgProfileCompleteness';
|
||||
|
||||
type UseRpgCreationEnterWorldParams = {
|
||||
isAgentDraftResultView: boolean;
|
||||
activeAgentSessionId: string | null;
|
||||
currentAgentSessionStage?: CustomWorldAgentSessionSnapshot['stage'] | null;
|
||||
generatedCustomWorldProfile: CustomWorldProfile | null;
|
||||
handleCustomWorldSelect: (
|
||||
customWorldProfile: CustomWorldProfile,
|
||||
@@ -33,6 +36,7 @@ export function useRpgCreationEnterWorld(
|
||||
const {
|
||||
isAgentDraftResultView,
|
||||
activeAgentSessionId,
|
||||
currentAgentSessionStage,
|
||||
generatedCustomWorldProfile,
|
||||
handleCustomWorldSelect,
|
||||
syncAgentDraftResultProfile,
|
||||
@@ -77,6 +81,18 @@ export function useRpgCreationEnterWorld(
|
||||
return generatedCustomWorldProfile;
|
||||
}
|
||||
|
||||
if (currentAgentSessionStage === 'published') {
|
||||
const latestView = await syncAgentCreationResultView(activeAgentSessionId);
|
||||
const publishedProfile = chooseMoreCompleteCustomWorldProfile(
|
||||
generatedCustomWorldProfile,
|
||||
rpgCreationPreviewAdapter.buildPreviewFromResultView(latestView),
|
||||
);
|
||||
// 中文注释:已发布会话的“进入世界”只读取后端结果页真相,
|
||||
// 不能再同步草稿或重复发送 publish_world,否则会被发布阶段门槛拒绝。
|
||||
setGeneratedCustomWorldProfile(publishedProfile);
|
||||
return publishedProfile;
|
||||
}
|
||||
|
||||
const syncedResult = await syncAgentDraftResultProfile(
|
||||
generatedCustomWorldProfile,
|
||||
);
|
||||
@@ -96,22 +112,24 @@ export function useRpgCreationEnterWorld(
|
||||
|
||||
if (canEnterPublishedWorld) {
|
||||
const latestView = await syncAgentCreationResultView(activeAgentSessionId);
|
||||
return (
|
||||
rpgCreationPreviewAdapter.buildPreviewFromResultView(latestView) ??
|
||||
latestProfile
|
||||
return chooseMoreCompleteCustomWorldProfile(
|
||||
latestProfile,
|
||||
rpgCreationPreviewAdapter.buildPreviewFromResultView(latestView),
|
||||
);
|
||||
}
|
||||
|
||||
await executePublishWorld();
|
||||
const latestView = await syncAgentCreationResultView(activeAgentSessionId);
|
||||
const publishedProfile =
|
||||
rpgCreationPreviewAdapter.buildPreviewFromResultView(latestView) ??
|
||||
latestProfile;
|
||||
const publishedProfile = chooseMoreCompleteCustomWorldProfile(
|
||||
latestProfile,
|
||||
rpgCreationPreviewAdapter.buildPreviewFromResultView(latestView),
|
||||
);
|
||||
|
||||
setGeneratedCustomWorldProfile(publishedProfile);
|
||||
return publishedProfile;
|
||||
}, [
|
||||
activeAgentSessionId,
|
||||
currentAgentSessionStage,
|
||||
executePublishWorld,
|
||||
generatedCustomWorldProfile,
|
||||
isAgentDraftResultView,
|
||||
|
||||
@@ -1,17 +1,19 @@
|
||||
/** @vitest-environment jsdom */
|
||||
|
||||
import { act, render } from '@testing-library/react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import type { CustomWorldAgentSessionSnapshot } from '../../../packages/shared/src/contracts/customWorldAgent';
|
||||
import type { CustomWorldWorkSummary } from '../../../packages/shared/src/contracts/customWorldWorkSummary';
|
||||
import type { RpgCreationResultView } from '../../../packages/shared/src/contracts/rpgCreationResultView';
|
||||
import type { CustomWorldLibraryEntry } from '../../../packages/shared/src/contracts/runtime';
|
||||
import {
|
||||
executeRpgCreationAction,
|
||||
getRpgCreationOperation,
|
||||
upsertRpgWorldProfile,
|
||||
} from '../../services/rpg-creation';
|
||||
import { type CustomWorldProfile,WorldType } from '../../types';
|
||||
import { type CustomWorldProfile, WorldType } from '../../types';
|
||||
import { useRpgCreationResultAutosave } from './useRpgCreationResultAutosave';
|
||||
import { useRpgEntryLibraryDetail } from './useRpgEntryLibraryDetail';
|
||||
|
||||
@@ -64,6 +66,30 @@ function buildProfile(name: string): CustomWorldProfile {
|
||||
};
|
||||
}
|
||||
|
||||
function buildLibraryEntry(
|
||||
profile: CustomWorldProfile,
|
||||
): CustomWorldLibraryEntry<CustomWorldProfile> {
|
||||
return {
|
||||
ownerUserId: 'user-1',
|
||||
profileId: profile.id,
|
||||
publicWorkCode: null,
|
||||
authorPublicUserCode: null,
|
||||
profile,
|
||||
visibility: 'published' as const,
|
||||
publishedAt: '2026-04-25T00:00:00.000Z',
|
||||
updatedAt: '2026-04-25T00:00:00.000Z',
|
||||
authorDisplayName: '测试玩家',
|
||||
worldName: profile.name,
|
||||
subtitle: profile.subtitle,
|
||||
summaryText: profile.summary,
|
||||
coverImageSrc: null,
|
||||
themeMode: 'tide' as const,
|
||||
playableNpcCount: profile.playableNpcs.length,
|
||||
landmarkCount: profile.landmarks.length,
|
||||
likeCount: 0,
|
||||
};
|
||||
}
|
||||
|
||||
function buildSession(
|
||||
overrides: Partial<CustomWorldAgentSessionSnapshot> = {},
|
||||
): CustomWorldAgentSessionSnapshot {
|
||||
@@ -221,6 +247,361 @@ describe('RPG Agent 草稿恢复', () => {
|
||||
expect(setSelectionStage).not.toHaveBeenCalledWith('custom-world-result');
|
||||
});
|
||||
|
||||
it('作品详情已加载完整编辑资产时列表摘要不能覆盖 selectedDetailEntry', async () => {
|
||||
const fullProfile: CustomWorldProfile = {
|
||||
...buildProfile('星砂废都'),
|
||||
id: 'profile-stardust',
|
||||
cover: {
|
||||
sourceType: 'default',
|
||||
imageSrc: null,
|
||||
characterRoleIds: ['playable-shamian'],
|
||||
},
|
||||
playableNpcs: [
|
||||
{
|
||||
id: 'playable-shamian',
|
||||
name: '砂眠',
|
||||
title: '废都引路人',
|
||||
role: '主角代理',
|
||||
description: '追查旧约的人。',
|
||||
backstory: '从星砂潮汐里醒来。',
|
||||
personality: '冷静。',
|
||||
motivation: '找到旧约。',
|
||||
combatStyle: '踏砂突进。',
|
||||
initialAffinity: 45,
|
||||
relationshipHooks: [],
|
||||
tags: [],
|
||||
relations: [],
|
||||
backstoryReveal: {
|
||||
publicSummary: '废都引路人。',
|
||||
privateChatUnlockAffinity: 60,
|
||||
chapters: [],
|
||||
},
|
||||
skills: [
|
||||
{
|
||||
id: 'skill-star-step',
|
||||
name: '星砂步',
|
||||
summary: '踏砂突进。',
|
||||
style: 'mobility',
|
||||
actionPreviewConfig: {
|
||||
folder: 'characters/shamian',
|
||||
prefix: 'skill_',
|
||||
frames: 8,
|
||||
basePath: '/assets/custom-world/shamian/skill',
|
||||
previewVideoPath: '/assets/custom-world/shamian/skill.mp4',
|
||||
},
|
||||
},
|
||||
],
|
||||
initialItems: [
|
||||
{
|
||||
id: 'item-sand-compass',
|
||||
name: '星砂罗盘',
|
||||
category: '专属物品',
|
||||
quantity: 1,
|
||||
rarity: 'rare',
|
||||
description: '能指出旧约埋藏方向。',
|
||||
tags: [],
|
||||
iconSrc: '/assets/custom-world/items/sand-compass.png',
|
||||
},
|
||||
],
|
||||
imageSrc: '/assets/custom-world/playable-shamian.png',
|
||||
attributeProfile: {
|
||||
schemaId: 'schema-星砂废都',
|
||||
values: { axis_a: 8 },
|
||||
topTraits: ['星砂共鸣'],
|
||||
evidence: [
|
||||
{ slotId: 'axis_a', reason: '能听见星砂潮汐。' },
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
sceneChapterBlueprints: [
|
||||
{
|
||||
id: 'scene-chapter-clocktower',
|
||||
sceneId: 'landmark-clocktower',
|
||||
title: '钟楼第一夜',
|
||||
summary: '钟楼第一夜。',
|
||||
sceneTaskDescription: '进入钟楼。',
|
||||
linkedThreadIds: [],
|
||||
linkedLandmarkIds: ['landmark-clocktower'],
|
||||
acts: [
|
||||
{
|
||||
id: 'act-clocktower-opening',
|
||||
sceneId: 'landmark-clocktower',
|
||||
title: '第一幕',
|
||||
summary: '砂眠带玩家进入坠星钟楼。',
|
||||
stageCoverage: ['opening'],
|
||||
backgroundImageSrc:
|
||||
'/assets/custom-world/act-clocktower-opening.png',
|
||||
backgroundAssetId: 'asset-act-clocktower-opening',
|
||||
encounterNpcIds: ['playable-shamian'],
|
||||
primaryNpcId: 'playable-shamian',
|
||||
oppositeNpcId: 'playable-shamian',
|
||||
eventDescription: '钟楼旧铃忽然自鸣。',
|
||||
linkedThreadIds: ['thread-old-vow'],
|
||||
advanceRule: 'after_primary_contact',
|
||||
actGoal: '进入钟楼。',
|
||||
transitionHook: '星砂开始倒流。',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
const summaryProfile: CustomWorldProfile = {
|
||||
...fullProfile,
|
||||
cover: null,
|
||||
playableNpcs: [
|
||||
{
|
||||
...fullProfile.playableNpcs[0]!,
|
||||
skills: [
|
||||
{
|
||||
id: 'skill-star-step',
|
||||
name: '星砂步',
|
||||
summary: '踏砂突进。',
|
||||
style: 'mobility',
|
||||
},
|
||||
],
|
||||
initialItems: [
|
||||
{
|
||||
...fullProfile.playableNpcs[0]!.initialItems[0]!,
|
||||
iconSrc: undefined,
|
||||
},
|
||||
],
|
||||
imageSrc: undefined,
|
||||
attributeProfile: undefined,
|
||||
},
|
||||
],
|
||||
sceneChapterBlueprints: [
|
||||
{
|
||||
...fullProfile.sceneChapterBlueprints![0]!,
|
||||
acts: [
|
||||
{
|
||||
...fullProfile.sceneChapterBlueprints![0]!.acts[0]!,
|
||||
backgroundImageSrc: undefined,
|
||||
backgroundAssetId: undefined,
|
||||
linkedThreadIds: [],
|
||||
actGoal: '',
|
||||
transitionHook: '',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
const detailEntry = buildLibraryEntry(fullProfile);
|
||||
const summaryEntry = buildLibraryEntry(summaryProfile);
|
||||
const selectedEntries: CustomWorldLibraryEntry<CustomWorldProfile>[] = [];
|
||||
|
||||
function Harness() {
|
||||
const [selectedDetailEntry, setSelectedDetailEntry] = useState<
|
||||
CustomWorldLibraryEntry<CustomWorldProfile> | null
|
||||
>(detailEntry);
|
||||
useEffect(() => {
|
||||
if (selectedDetailEntry) {
|
||||
selectedEntries.push(selectedDetailEntry);
|
||||
}
|
||||
}, [selectedDetailEntry]);
|
||||
useRpgEntryLibraryDetail({
|
||||
userId: 'user-1',
|
||||
selectedDetailEntry,
|
||||
setSelectedDetailEntry,
|
||||
savedCustomWorldEntries: [summaryEntry],
|
||||
setSavedCustomWorldEntries: vi.fn(),
|
||||
setGeneratedCustomWorldProfile: vi.fn(),
|
||||
setCustomWorldError: vi.fn(),
|
||||
setCustomWorldAutoSaveError: vi.fn(),
|
||||
setCustomWorldAutoSaveState: vi.fn(),
|
||||
setCustomWorldGenerationViewSource: vi.fn(),
|
||||
setCustomWorldResultViewSource: vi.fn(),
|
||||
setSelectionStage: vi.fn(),
|
||||
setPlatformTabToCreate: vi.fn(),
|
||||
setPlatformTabToDraft: vi.fn(),
|
||||
setPlatformError: vi.fn(),
|
||||
appendBrowseHistoryEntry: vi.fn(async () => {}),
|
||||
refreshCustomWorldWorks: vi.fn(async () => []),
|
||||
refreshPublishedGallery: vi.fn(async () => []),
|
||||
persistAgentUiState: vi.fn(),
|
||||
syncAgentCreationResultView: vi.fn(),
|
||||
buildDraftResultProfile: () => null,
|
||||
suppressAgentDraftResultAutoOpen: vi.fn(),
|
||||
releaseAgentDraftResultAutoOpenSuppression: vi.fn(),
|
||||
resetAutoSaveTrackingToIdle: vi.fn(),
|
||||
markAutoSavedProfile: vi.fn(),
|
||||
});
|
||||
return null;
|
||||
}
|
||||
|
||||
render(<Harness />);
|
||||
|
||||
await act(async () => {});
|
||||
const lastSelected = selectedEntries.at(-1);
|
||||
expect(lastSelected?.profile.cover?.characterRoleIds).toEqual([
|
||||
'playable-shamian',
|
||||
]);
|
||||
expect(
|
||||
lastSelected?.profile.playableNpcs[0]?.skills[0]?.actionPreviewConfig
|
||||
?.previewVideoPath,
|
||||
).toBe('/assets/custom-world/shamian/skill.mp4');
|
||||
expect(lastSelected?.profile.playableNpcs[0]?.initialItems[0]?.iconSrc).toBe(
|
||||
'/assets/custom-world/items/sand-compass.png',
|
||||
);
|
||||
expect(lastSelected?.profile.playableNpcs[0]?.attributeProfile).toBeTruthy();
|
||||
expect(
|
||||
lastSelected?.profile.sceneChapterBlueprints?.[0]?.acts[0]
|
||||
?.backgroundImageSrc,
|
||||
).toBe('/assets/custom-world/act-clocktower-opening.png');
|
||||
});
|
||||
|
||||
it('默认封面和角色编辑结构差异也不能被列表摘要覆盖', async () => {
|
||||
const fullRole = {
|
||||
id: 'playable-shamian',
|
||||
name: '砂眠',
|
||||
title: '废都引路人',
|
||||
role: '主角代理',
|
||||
description: '追查旧约的人。',
|
||||
backstory: '从星砂潮汐里醒来。',
|
||||
personality: '冷静。',
|
||||
motivation: '找到旧约。',
|
||||
combatStyle: '踏砂突进。',
|
||||
initialAffinity: 45,
|
||||
relationshipHooks: [],
|
||||
tags: [],
|
||||
relations: [],
|
||||
backstoryReveal: {
|
||||
publicSummary: '废都引路人。',
|
||||
privateChatUnlockAffinity: 60,
|
||||
chapters: [],
|
||||
},
|
||||
skills: [
|
||||
{
|
||||
id: 'skill-star-step',
|
||||
name: '星砂步',
|
||||
summary: '踏砂突进。',
|
||||
style: 'mobility',
|
||||
actionPreviewConfig: {
|
||||
folder: 'characters/shamian',
|
||||
prefix: 'skill_',
|
||||
frames: 8,
|
||||
basePath: '/assets/custom-world/shamian/skill',
|
||||
previewVideoPath: '/assets/custom-world/shamian/skill.mp4',
|
||||
},
|
||||
},
|
||||
],
|
||||
initialItems: [
|
||||
{
|
||||
id: 'item-sand-compass',
|
||||
name: '星砂罗盘',
|
||||
category: '专属物品',
|
||||
quantity: 1,
|
||||
rarity: 'rare',
|
||||
description: '能指出旧约埋藏方向。',
|
||||
tags: [],
|
||||
iconSrc: '/assets/custom-world/items/sand-compass.png',
|
||||
},
|
||||
],
|
||||
attributeProfile: {
|
||||
schemaId: 'schema-星砂废都',
|
||||
values: { axis_a: 8 },
|
||||
topTraits: ['星砂共鸣'],
|
||||
evidence: [
|
||||
{ slotId: 'axis_a', reason: '能听见星砂潮汐。' },
|
||||
],
|
||||
},
|
||||
} satisfies CustomWorldProfile['playableNpcs'][number];
|
||||
const fullProfile: CustomWorldProfile = {
|
||||
...buildProfile('星砂废都'),
|
||||
id: 'profile-stardust-structure',
|
||||
cover: {
|
||||
sourceType: 'default',
|
||||
imageSrc: null,
|
||||
characterRoleIds: ['playable-shamian'],
|
||||
},
|
||||
playableNpcs: [fullRole],
|
||||
};
|
||||
const summaryProfile: CustomWorldProfile = {
|
||||
...fullProfile,
|
||||
cover: null,
|
||||
playableNpcs: [
|
||||
{
|
||||
...fullRole,
|
||||
skills: [
|
||||
{
|
||||
id: 'skill-star-step',
|
||||
name: '星砂步',
|
||||
summary: '踏砂突进。',
|
||||
style: 'mobility',
|
||||
},
|
||||
],
|
||||
initialItems: [
|
||||
{
|
||||
...fullRole.initialItems[0]!,
|
||||
iconSrc: undefined,
|
||||
},
|
||||
],
|
||||
attributeProfile: undefined,
|
||||
},
|
||||
],
|
||||
};
|
||||
const detailEntry = buildLibraryEntry(fullProfile);
|
||||
const summaryEntry = buildLibraryEntry(summaryProfile);
|
||||
const selectedEntries: CustomWorldLibraryEntry<CustomWorldProfile>[] = [];
|
||||
|
||||
function Harness() {
|
||||
const [selectedDetailEntry, setSelectedDetailEntry] = useState<
|
||||
CustomWorldLibraryEntry<CustomWorldProfile> | null
|
||||
>(detailEntry);
|
||||
useEffect(() => {
|
||||
if (selectedDetailEntry) {
|
||||
selectedEntries.push(selectedDetailEntry);
|
||||
}
|
||||
}, [selectedDetailEntry]);
|
||||
useRpgEntryLibraryDetail({
|
||||
userId: 'user-1',
|
||||
selectedDetailEntry,
|
||||
setSelectedDetailEntry,
|
||||
savedCustomWorldEntries: [summaryEntry],
|
||||
setSavedCustomWorldEntries: vi.fn(),
|
||||
setGeneratedCustomWorldProfile: vi.fn(),
|
||||
setCustomWorldError: vi.fn(),
|
||||
setCustomWorldAutoSaveError: vi.fn(),
|
||||
setCustomWorldAutoSaveState: vi.fn(),
|
||||
setCustomWorldGenerationViewSource: vi.fn(),
|
||||
setCustomWorldResultViewSource: vi.fn(),
|
||||
setSelectionStage: vi.fn(),
|
||||
setPlatformTabToCreate: vi.fn(),
|
||||
setPlatformTabToDraft: vi.fn(),
|
||||
setPlatformError: vi.fn(),
|
||||
appendBrowseHistoryEntry: vi.fn(async () => {}),
|
||||
refreshCustomWorldWorks: vi.fn(async () => []),
|
||||
refreshPublishedGallery: vi.fn(async () => []),
|
||||
persistAgentUiState: vi.fn(),
|
||||
syncAgentCreationResultView: vi.fn(),
|
||||
buildDraftResultProfile: () => null,
|
||||
suppressAgentDraftResultAutoOpen: vi.fn(),
|
||||
releaseAgentDraftResultAutoOpenSuppression: vi.fn(),
|
||||
resetAutoSaveTrackingToIdle: vi.fn(),
|
||||
markAutoSavedProfile: vi.fn(),
|
||||
});
|
||||
return null;
|
||||
}
|
||||
|
||||
render(<Harness />);
|
||||
|
||||
await act(async () => {});
|
||||
const lastSelected = selectedEntries.at(-1);
|
||||
expect(lastSelected?.profile.cover?.characterRoleIds).toEqual([
|
||||
'playable-shamian',
|
||||
]);
|
||||
expect(
|
||||
lastSelected?.profile.playableNpcs[0]?.skills[0]?.actionPreviewConfig
|
||||
?.previewVideoPath,
|
||||
).toBe('/assets/custom-world/shamian/skill.mp4');
|
||||
expect(lastSelected?.profile.playableNpcs[0]?.initialItems[0]?.iconSrc).toBe(
|
||||
'/assets/custom-world/items/sand-compass.png',
|
||||
);
|
||||
expect(lastSelected?.profile.playableNpcs[0]?.attributeProfile?.values).toEqual(
|
||||
{ axis_a: 8 },
|
||||
);
|
||||
});
|
||||
|
||||
it('Agent 结果页自动保存先回写 session,再保存后端 result-view profile', async () => {
|
||||
const oldProfile = buildProfile('旧前端快照');
|
||||
const latestProfile = {
|
||||
|
||||
@@ -21,6 +21,11 @@ import {
|
||||
unpublishRpgEntryWorldProfile,
|
||||
} from '../../services/rpg-entry/rpgEntryLibraryClient';
|
||||
import type { CustomWorldProfile } from '../../types';
|
||||
import {
|
||||
countCustomWorldProfileAssetSlots,
|
||||
countCustomWorldProfileDetailSlots,
|
||||
countCustomWorldProfileStructuredSlots,
|
||||
} from './rpgProfileCompleteness';
|
||||
import { resolveRpgEntryErrorMessage } from './rpgEntryShared';
|
||||
import type {
|
||||
CustomWorldAutoSaveState,
|
||||
@@ -86,6 +91,46 @@ function isMissingRpgEntryAgentSessionError(error: unknown) {
|
||||
);
|
||||
}
|
||||
|
||||
function shouldKeepSelectedDetailProfile(
|
||||
selectedEntry: CustomWorldLibraryEntry<CustomWorldProfile>,
|
||||
nextOwnedEntry: CustomWorldLibraryEntry<CustomWorldProfile>,
|
||||
) {
|
||||
if (
|
||||
selectedEntry.ownerUserId !== nextOwnedEntry.ownerUserId ||
|
||||
selectedEntry.profileId !== nextOwnedEntry.profileId
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const selectedDetailCount = countCustomWorldProfileDetailSlots(
|
||||
selectedEntry.profile,
|
||||
);
|
||||
const nextDetailCount = countCustomWorldProfileDetailSlots(
|
||||
nextOwnedEntry.profile,
|
||||
);
|
||||
const selectedAssetSlotCount = countCustomWorldProfileAssetSlots(
|
||||
selectedEntry.profile,
|
||||
);
|
||||
const nextAssetSlotCount = countCustomWorldProfileAssetSlots(
|
||||
nextOwnedEntry.profile,
|
||||
);
|
||||
const selectedStructuredSlotCount =
|
||||
countCustomWorldProfileStructuredSlots(selectedEntry.profile);
|
||||
const nextStructuredSlotCount = countCustomWorldProfileStructuredSlots(
|
||||
nextOwnedEntry.profile,
|
||||
);
|
||||
const expectedRuntimeCount =
|
||||
nextOwnedEntry.playableNpcCount + nextOwnedEntry.landmarkCount;
|
||||
|
||||
// 作品架列表只保证卡片摘要,不能在详情接口已经拿到完整运行态字段后覆盖详情。
|
||||
return (
|
||||
(selectedDetailCount > nextDetailCount &&
|
||||
expectedRuntimeCount > nextDetailCount) ||
|
||||
selectedAssetSlotCount > nextAssetSlotCount ||
|
||||
selectedStructuredSlotCount > nextStructuredSlotCount
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 负责平台详情、创作作品入口和结果页打开路径。
|
||||
* 平台壳层只消费“打开哪个面板”的结果,不再自己拼接恢复流程细节。
|
||||
@@ -136,6 +181,10 @@ export function useRpgEntryLibraryDetail(
|
||||
entry.profileId === selectedDetailEntry.profileId,
|
||||
);
|
||||
if (nextOwnedEntry && nextOwnedEntry !== selectedDetailEntry) {
|
||||
if (shouldKeepSelectedDetailProfile(selectedDetailEntry, nextOwnedEntry)) {
|
||||
return;
|
||||
}
|
||||
|
||||
setSelectedDetailEntry(nextOwnedEntry);
|
||||
}
|
||||
}, [savedCustomWorldEntries, selectedDetailEntry, setSelectedDetailEntry]);
|
||||
|
||||
@@ -25,6 +25,16 @@ const RpgRuntimeOverlayHost = lazy(async () => {
|
||||
};
|
||||
});
|
||||
|
||||
function RuntimeLayerLoadingFallback({ label }: { label: string }) {
|
||||
return (
|
||||
<div className="pointer-events-none fixed inset-x-0 top-4 z-[24] flex justify-center px-4">
|
||||
<div className="rounded-full border border-white/10 bg-black/55 px-4 py-2 text-xs text-zinc-200 shadow-[0_12px_30px_rgba(0,0,0,0.32)] backdrop-blur-sm">
|
||||
{label}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* RPG 运行态总外壳。
|
||||
* 这里承接运行时主布局、画布舞台、主阶段路由和 overlay host,
|
||||
@@ -167,7 +177,7 @@ export function RpgRuntimeShell({
|
||||
}}
|
||||
>
|
||||
{gameState.worldType ? (
|
||||
<Suspense fallback={null}>
|
||||
<Suspense fallback={<RuntimeLayerLoadingFallback label="正在加载场景" />}>
|
||||
<RpgRuntimeCanvasStage
|
||||
gameState={gameState}
|
||||
visibleGameState={visibleGameState}
|
||||
@@ -275,7 +285,7 @@ export function RpgRuntimeShell({
|
||||
/>
|
||||
|
||||
{gameState.worldType ? (
|
||||
<Suspense fallback={null}>
|
||||
<Suspense fallback={<RuntimeLayerLoadingFallback label="正在加载界面" />}>
|
||||
<RpgRuntimeOverlayHost
|
||||
gameState={gameState}
|
||||
isLoading={isLoading}
|
||||
|
||||
@@ -165,4 +165,318 @@ describe('normalizeCustomWorldProfileRecord role asset descriptions', () => {
|
||||
'/generated-characters/story-yizhang/portrait.png',
|
||||
);
|
||||
});
|
||||
|
||||
it('保留结果页生成的开局 CG 槽位', () => {
|
||||
const profile = normalizeCustomWorldProfileRecord({
|
||||
name: '雾港归航',
|
||||
settingText: '海雾旧案',
|
||||
openingCg: {
|
||||
id: 'opening-cg-1',
|
||||
status: 'ready',
|
||||
storyboardImageSrc: '/generated-custom-world-scenes/opening/storyboard.png',
|
||||
storyboardAssetId: 'storyboard-1',
|
||||
videoSrc: '/generated-custom-world-scenes/opening/opening.mp4',
|
||||
videoAssetId: 'video-1',
|
||||
imageModel: 'gpt-image-2',
|
||||
videoModel: 'doubao-seedance-2-0-fast-260128',
|
||||
aspectRatio: '16:9',
|
||||
imageSize: '2k',
|
||||
videoResolution: '480p',
|
||||
durationSeconds: 15,
|
||||
pointCost: 80,
|
||||
estimatedWaitMinutes: 10,
|
||||
updatedAt: '2026-05-21T00:00:00.000Z',
|
||||
},
|
||||
});
|
||||
|
||||
expect(profile?.openingCg?.videoSrc).toBe(
|
||||
'/generated-custom-world-scenes/opening/opening.mp4',
|
||||
);
|
||||
expect(profile?.openingCg?.storyboardImageSrc).toBe(
|
||||
'/generated-custom-world-scenes/opening/storyboard.png',
|
||||
);
|
||||
});
|
||||
|
||||
it('保留结果页封面和关键图片资产槽位', () => {
|
||||
const profile = normalizeCustomWorldProfileRecord({
|
||||
name: '星砂废都',
|
||||
settingText: '坠星沙海与废都钟楼',
|
||||
cover: {
|
||||
sourceType: 'generated',
|
||||
imageSrc: '/generated-custom-world-covers/star-waste/cover.webp',
|
||||
characterRoleIds: ['playable-shamian'],
|
||||
},
|
||||
camp: {
|
||||
id: 'camp-star-waste',
|
||||
name: '废都营地',
|
||||
description: '钟楼阴影下的临时营地。',
|
||||
imageSrc: '/assets/custom-world/camp-star-waste.png',
|
||||
sceneNpcIds: ['playable-shamian'],
|
||||
connections: [],
|
||||
},
|
||||
playableNpcs: [
|
||||
{
|
||||
id: 'playable-shamian',
|
||||
name: '砂眠',
|
||||
title: '废都引路人',
|
||||
role: '主角代理',
|
||||
imageSrc: '/assets/custom-world/playable-shamian.png',
|
||||
},
|
||||
],
|
||||
storyNpcs: [
|
||||
{
|
||||
id: 'story-clock-keeper',
|
||||
name: '钟守',
|
||||
title: '钟楼守夜者',
|
||||
role: '第一幕主NPC',
|
||||
imageSrc: '/assets/custom-world/story-clock-keeper.png',
|
||||
},
|
||||
],
|
||||
landmarks: [
|
||||
{
|
||||
id: 'landmark-clocktower',
|
||||
name: '坠星钟楼',
|
||||
description: '半截钟楼被星砂埋住。',
|
||||
imageSrc: '/assets/custom-world/landmark-clocktower.png',
|
||||
sceneNpcIds: ['story-clock-keeper'],
|
||||
connections: [],
|
||||
},
|
||||
],
|
||||
sceneChapterBlueprints: [
|
||||
{
|
||||
id: 'scene-chapter-clocktower',
|
||||
sceneId: 'landmark-clocktower',
|
||||
title: '钟楼第一夜',
|
||||
acts: [
|
||||
{
|
||||
id: 'act-clocktower-opening',
|
||||
sceneId: 'landmark-clocktower',
|
||||
title: '第一幕',
|
||||
summary: '砂眠带玩家进入坠星钟楼。',
|
||||
backgroundImageSrc:
|
||||
'/assets/custom-world/act-clocktower-opening.png',
|
||||
encounterNpcIds: ['砂眠', '钟守'],
|
||||
primaryNpcId: '砂眠',
|
||||
oppositeNpcId: '钟守',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(profile?.cover?.sourceType).toBe('generated');
|
||||
expect(profile?.cover?.imageSrc).toBe(
|
||||
'/generated-custom-world-covers/star-waste/cover.webp',
|
||||
);
|
||||
expect(profile?.camp?.imageSrc).toBe(
|
||||
'/assets/custom-world/camp-star-waste.png',
|
||||
);
|
||||
expect(profile?.playableNpcs[0]?.imageSrc).toBe(
|
||||
'/assets/custom-world/playable-shamian.png',
|
||||
);
|
||||
expect(profile?.storyNpcs[0]?.imageSrc).toBe(
|
||||
'/assets/custom-world/story-clock-keeper.png',
|
||||
);
|
||||
expect(profile?.landmarks[0]?.imageSrc).toBe(
|
||||
'/assets/custom-world/landmark-clocktower.png',
|
||||
);
|
||||
expect(
|
||||
profile?.sceneChapterBlueprints?.[0]?.acts[0]?.backgroundImageSrc,
|
||||
).toBe('/assets/custom-world/act-clocktower-opening.png');
|
||||
});
|
||||
|
||||
it('近似无损保留编辑态和运行态结构字段', () => {
|
||||
const profile = normalizeCustomWorldProfileRecord({
|
||||
name: '星砂废都',
|
||||
settingText: '坠星沙海与废都钟楼',
|
||||
attributeSchema: {
|
||||
id: 'schema-stardust',
|
||||
worldId: 'world-stardust',
|
||||
schemaVersion: 1,
|
||||
generatedFrom: {
|
||||
worldType: 'CUSTOM',
|
||||
worldName: '星砂废都',
|
||||
settingSummary: '坠星沙海与废都钟楼',
|
||||
tone: '苍凉',
|
||||
conflictCore: '旧约与星砂潮汐冲突',
|
||||
},
|
||||
slots: [
|
||||
{ slotId: 'axis_a', name: '星砂共鸣' },
|
||||
{ slotId: 'axis_b', name: '废都步法' },
|
||||
{ slotId: 'axis_c', name: '钟楼感知' },
|
||||
{ slotId: 'axis_d', name: '旧约心火' },
|
||||
{ slotId: 'axis_e', name: '尘缘牵引' },
|
||||
{ slotId: 'axis_f', name: '潮汐玄息' },
|
||||
],
|
||||
},
|
||||
camp: {
|
||||
id: 'camp-star-waste',
|
||||
name: '废都营地',
|
||||
description: '钟楼阴影下的临时营地。',
|
||||
narrativeResidues: [
|
||||
{
|
||||
id: 'residue-camp-1',
|
||||
summary: '营地火盆里混着星砂。',
|
||||
},
|
||||
],
|
||||
},
|
||||
playableNpcs: [
|
||||
{
|
||||
id: 'playable-shamian',
|
||||
name: '砂眠',
|
||||
title: '废都引路人',
|
||||
role: '主角代理',
|
||||
skills: [
|
||||
{
|
||||
id: 'skill-star-step',
|
||||
name: '星砂步',
|
||||
summary: '踏砂突进。',
|
||||
style: 'mobility',
|
||||
actionPreviewConfig: {
|
||||
folder: 'characters/shamian',
|
||||
prefix: 'skill_',
|
||||
frames: 8,
|
||||
basePath: '/assets/custom-world/shamian/skill',
|
||||
previewVideoPath: '/assets/custom-world/shamian/skill.mp4',
|
||||
file: 'skill.png',
|
||||
},
|
||||
},
|
||||
],
|
||||
initialItems: [
|
||||
{
|
||||
id: 'item-sand-compass',
|
||||
name: '星砂罗盘',
|
||||
category: '专属物品',
|
||||
quantity: 1,
|
||||
rarity: 'rare',
|
||||
description: '能指出旧约埋藏方向。',
|
||||
tags: ['旧约'],
|
||||
iconSrc: '/assets/custom-world/items/sand-compass.png',
|
||||
},
|
||||
],
|
||||
attributeProfile: {
|
||||
schemaId: 'schema-stardust',
|
||||
values: { axis_a: 8, axis_b: 7 },
|
||||
topTraits: ['星砂共鸣'],
|
||||
evidence: [
|
||||
{ slotId: 'axis_a', reason: '能听见星砂潮汐。' },
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
storyNpcs: [
|
||||
{
|
||||
id: 'story-clock-keeper',
|
||||
name: '钟守',
|
||||
title: '钟楼守夜者',
|
||||
role: '第一幕主NPC',
|
||||
attributeProfile: {
|
||||
schemaId: 'schema-stardust',
|
||||
values: { axis_c: 9 },
|
||||
topTraits: ['钟楼感知'],
|
||||
evidence: [
|
||||
{ slotId: 'axis_c', reason: '能辨认旧铃回声。' },
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
landmarks: [
|
||||
{
|
||||
id: 'landmark-clocktower',
|
||||
name: '坠星钟楼',
|
||||
description: '半截钟楼被星砂埋住。',
|
||||
visualDescription: '钟楼外墙布满蓝白星砂结晶。',
|
||||
narrativeResidues: [
|
||||
{
|
||||
id: 'residue-clocktower-1',
|
||||
summary: '钟楼第十三声铃响仍未散去。',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
sceneChapterBlueprints: [
|
||||
{
|
||||
id: 'scene-chapter-clocktower',
|
||||
sceneId: 'landmark-clocktower',
|
||||
title: '钟楼第一夜',
|
||||
acts: [
|
||||
{
|
||||
id: 'act-clocktower-opening',
|
||||
sceneId: 'landmark-clocktower',
|
||||
title: '第一幕',
|
||||
summary: '砂眠带玩家进入坠星钟楼。',
|
||||
backgroundAssetId: 'asset-act-clocktower-opening',
|
||||
backgroundImageSrc:
|
||||
'/assets/custom-world/act-clocktower-opening.png',
|
||||
eventDescription: '钟楼旧铃忽然自鸣。',
|
||||
linkedThreadIds: ['thread-old-vow'],
|
||||
advanceRule: 'after_primary_contact',
|
||||
actGoal: '进入钟楼。',
|
||||
transitionHook: '星砂开始倒流。',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(profile?.attributeSchema.id).toBe('schema-stardust');
|
||||
expect(profile?.attributeSchema.slots[0]?.name).toBe('星砂共鸣');
|
||||
expect(profile?.camp?.narrativeResidues?.[0]?.summary).toBe(
|
||||
'营地火盆里混着星砂。',
|
||||
);
|
||||
expect(
|
||||
profile?.playableNpcs[0]?.skills[0]?.actionPreviewConfig
|
||||
?.previewVideoPath,
|
||||
).toBe('/assets/custom-world/shamian/skill.mp4');
|
||||
expect(profile?.playableNpcs[0]?.initialItems[0]?.iconSrc).toBe(
|
||||
'/assets/custom-world/items/sand-compass.png',
|
||||
);
|
||||
expect(profile?.playableNpcs[0]?.attributeProfile?.values.axis_a).toBe(8);
|
||||
expect(profile?.storyNpcs[0]?.attributeProfile?.topTraits).toContain(
|
||||
'钟楼感知',
|
||||
);
|
||||
expect(profile?.landmarks[0]?.visualDescription).toBe(
|
||||
'钟楼外墙布满蓝白星砂结晶。',
|
||||
);
|
||||
expect(profile?.landmarks[0]?.narrativeResidues?.[0]?.summary).toBe(
|
||||
'钟楼第十三声铃响仍未散去。',
|
||||
);
|
||||
const act = profile?.sceneChapterBlueprints?.[0]?.acts[0];
|
||||
expect(act?.backgroundAssetId).toBe('asset-act-clocktower-opening');
|
||||
expect(act?.eventDescription).toBe('钟楼旧铃忽然自鸣。');
|
||||
expect(act?.linkedThreadIds).toEqual(['thread-old-vow']);
|
||||
expect(act?.actGoal).toBe('进入钟楼。');
|
||||
expect(act?.transitionHook).toBe('星砂开始倒流。');
|
||||
});
|
||||
|
||||
it('保留只有背景资产的场景幕,避免恢复详情时丢失场景 CG', () => {
|
||||
const profile = normalizeCustomWorldProfileRecord({
|
||||
name: '星砂废都',
|
||||
settingText: '坠星沙海与废都钟楼',
|
||||
sceneChapterBlueprints: [
|
||||
{
|
||||
id: 'scene-chapter-clocktower',
|
||||
sceneId: 'landmark-clocktower',
|
||||
title: '钟楼第一夜',
|
||||
acts: [
|
||||
{
|
||||
id: 'act-background-only',
|
||||
sceneId: 'landmark-clocktower',
|
||||
backgroundAssetId: 'asset-background-only',
|
||||
backgroundImageSrc: '/assets/custom-world/background-only.png',
|
||||
backgroundPromptText: '坠星钟楼被蓝白星砂照亮。',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const act = profile?.sceneChapterBlueprints?.[0]?.acts[0];
|
||||
expect(act?.id).toBe('act-background-only');
|
||||
expect(act?.backgroundImageSrc).toBe(
|
||||
'/assets/custom-world/background-only.png',
|
||||
);
|
||||
expect(act?.backgroundAssetId).toBe('asset-background-only');
|
||||
expect(act?.backgroundPromptText).toBe('坠星钟楼被蓝白星砂照亮。');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -27,6 +27,8 @@ import {
|
||||
CustomWorldNpcVisualGear,
|
||||
CustomWorldNpcVisualGearType,
|
||||
CustomWorldNpcVisualRace,
|
||||
CustomWorldOpeningCgProfile,
|
||||
CustomWorldCoverProfile,
|
||||
CustomWorldPlayableNpc,
|
||||
CustomWorldProfile,
|
||||
CustomWorldRoleInitialItem,
|
||||
@@ -863,6 +865,7 @@ function normalizeLandmark(
|
||||
id: toText(value.id, `saved-landmark-${index + 1}`),
|
||||
name,
|
||||
description: toText(value.description),
|
||||
visualDescription: toText(value.visualDescription) || undefined,
|
||||
imageSrc: toText(value.imageSrc) || undefined,
|
||||
narrativeResidues:
|
||||
preserveStructuredRecordArray<SceneNarrativeResidue>(
|
||||
@@ -908,7 +911,10 @@ function normalizeCampScene(
|
||||
summary: toText(connection.summary) || toText(connection.description),
|
||||
}))
|
||||
.filter((connection) => connection.targetLandmarkId),
|
||||
narrativeResidues: null,
|
||||
narrativeResidues:
|
||||
preserveStructuredRecordArray<SceneNarrativeResidue>(
|
||||
value.narrativeResidues,
|
||||
) ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -988,6 +994,13 @@ function normalizeSceneActBlueprint(
|
||||
const advanceRule = toText(value.advanceRule);
|
||||
const title = toText(value.title);
|
||||
const summary = toText(value.summary);
|
||||
const backgroundImageSrc = toText(value.backgroundImageSrc);
|
||||
const backgroundPromptText = toText(value.backgroundPromptText);
|
||||
const backgroundAssetId = toText(value.backgroundAssetId);
|
||||
const eventDescription = toText(value.eventDescription);
|
||||
const linkedThreadIds = toStringArray(value.linkedThreadIds);
|
||||
const actGoal = toText(value.actGoal);
|
||||
const transitionHook = toText(value.transitionHook);
|
||||
const primaryNpcId = resolveCustomWorldRoleIdReference(
|
||||
profileRoles,
|
||||
toText(value.primaryNpcId, encounterNpcIds[0] ?? ''),
|
||||
@@ -997,7 +1010,18 @@ function normalizeSceneActBlueprint(
|
||||
toText(value.oppositeNpcId, primaryNpcId),
|
||||
);
|
||||
|
||||
if (!title && !summary && encounterNpcIds.length === 0) {
|
||||
if (
|
||||
!title &&
|
||||
!summary &&
|
||||
encounterNpcIds.length === 0 &&
|
||||
!backgroundImageSrc &&
|
||||
!backgroundPromptText &&
|
||||
!backgroundAssetId &&
|
||||
!eventDescription &&
|
||||
linkedThreadIds.length === 0 &&
|
||||
!actGoal &&
|
||||
!transitionHook
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -1012,24 +1036,24 @@ function normalizeSceneActBlueprint(
|
||||
: index === 0
|
||||
? ['opening']
|
||||
: ['climax', 'aftermath'],
|
||||
backgroundImageSrc: toText(value.backgroundImageSrc) || undefined,
|
||||
backgroundPromptText: toText(value.backgroundPromptText) || undefined,
|
||||
backgroundAssetId: toText(value.backgroundAssetId) || undefined,
|
||||
backgroundImageSrc: backgroundImageSrc || undefined,
|
||||
backgroundPromptText: backgroundPromptText || undefined,
|
||||
backgroundAssetId: backgroundAssetId || undefined,
|
||||
encounterNpcIds,
|
||||
primaryNpcId,
|
||||
oppositeNpcId,
|
||||
eventDescription: toText(
|
||||
value.eventDescription,
|
||||
eventDescription,
|
||||
oppositeNpcId
|
||||
? `第 ${index + 1} 幕中,玩家与${oppositeNpcId}正面接触,推动当前场景事件升级。`
|
||||
: `第 ${index + 1} 幕中,玩家处理当前场景的关键事件。`,
|
||||
),
|
||||
linkedThreadIds: toStringArray(value.linkedThreadIds),
|
||||
linkedThreadIds,
|
||||
advanceRule: SCENE_ACT_ADVANCE_RULES.has(advanceRule as never)
|
||||
? (advanceRule as SceneActBlueprint['advanceRule'])
|
||||
: 'after_active_step_complete',
|
||||
actGoal: toText(value.actGoal),
|
||||
transitionHook: toText(value.transitionHook),
|
||||
actGoal,
|
||||
transitionHook,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1155,6 +1179,12 @@ function normalizeProfile(value: unknown): CustomWorldProfile | null {
|
||||
.map((entry, index) => normalizePlayableNpc(entry, index))
|
||||
.filter((entry): entry is CustomWorldPlayableNpc => Boolean(entry))
|
||||
: [];
|
||||
const openingCg = preserveStructuredRecord<CustomWorldOpeningCgProfile>(
|
||||
value.openingCg,
|
||||
);
|
||||
const cover = preserveStructuredRecord<CustomWorldCoverProfile>(
|
||||
value.cover,
|
||||
);
|
||||
const normalizedProfile = {
|
||||
id: toText(value.id, `saved-custom-world-${Date.now().toString(36)}`),
|
||||
settingText,
|
||||
@@ -1180,6 +1210,8 @@ function normalizeProfile(value: unknown): CustomWorldProfile | null {
|
||||
.map((entry, index) => normalizeItem(entry, index))
|
||||
.filter((entry): entry is CustomWorldItem => Boolean(entry))
|
||||
: [],
|
||||
cover,
|
||||
openingCg,
|
||||
camp,
|
||||
landmarks: normalizeCustomWorldLandmarks({
|
||||
landmarks: landmarkDrafts,
|
||||
|
||||
@@ -776,3 +776,123 @@ test('custom world opening act accepts runtime npc id references and still start
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
test('switching between custom worlds sends the newly selected profile to runtime bootstrap', async () => {
|
||||
const user = userEvent.setup();
|
||||
const oldProfile = buildSavedProfile();
|
||||
const openedDraftProfile = normalizeCustomWorldProfileRecord({
|
||||
...oldProfile,
|
||||
id: 'opened-draft-profile',
|
||||
name: '星砂废都',
|
||||
subtitle: '坠星沙海与废都钟楼',
|
||||
summary: '本次从草稿架打开并启动的目标草稿。',
|
||||
settingText: '星砂覆盖旧废都,钟楼下埋着旧约。',
|
||||
playableNpcs: [
|
||||
{
|
||||
...oldProfile.playableNpcs[0],
|
||||
id: 'opened-playable-1',
|
||||
name: '砂眠',
|
||||
title: '废都引路人',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
if (!openedDraftProfile) {
|
||||
throw new Error('failed to build opened draft profile');
|
||||
}
|
||||
const normalizedOpenedDraftProfile = openedDraftProfile;
|
||||
|
||||
function SwitchWorldHarness() {
|
||||
const {
|
||||
gameState,
|
||||
handleCustomWorldSelect,
|
||||
handleCharacterSelect,
|
||||
} = useRpgSessionBootstrap();
|
||||
const openedCharacters = buildCustomWorldPlayableCharacters(
|
||||
normalizedOpenedDraftProfile,
|
||||
);
|
||||
const selectedCharacter = openedCharacters[0] ?? null;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleCustomWorldSelect(oldProfile, { mode: 'play' })}
|
||||
>
|
||||
选择旧世界
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
handleCustomWorldSelect(normalizedOpenedDraftProfile, {
|
||||
mode: 'play',
|
||||
})
|
||||
}
|
||||
>
|
||||
选择打开草稿
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (selectedCharacter) {
|
||||
handleCharacterSelect(selectedCharacter);
|
||||
}
|
||||
}}
|
||||
>
|
||||
确认草稿角色
|
||||
</button>
|
||||
<pre data-testid="state-snapshot">
|
||||
{JSON.stringify({
|
||||
profileId: gameState.customWorldProfile?.id ?? null,
|
||||
profileName: gameState.customWorldProfile?.name ?? null,
|
||||
})}
|
||||
</pre>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
runtimeStoryClientMocks.beginRuntimeStorySession.mockResolvedValue(
|
||||
buildRuntimeStoryBootstrapSnapshot({
|
||||
profile: normalizedOpenedDraftProfile,
|
||||
character: buildCustomWorldPlayableCharacters(
|
||||
normalizedOpenedDraftProfile,
|
||||
)[0]!,
|
||||
}),
|
||||
);
|
||||
|
||||
render(<SwitchWorldHarness />);
|
||||
|
||||
await user.click(screen.getByRole('button', { name: '选择旧世界' }));
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
JSON.parse(screen.getByTestId('state-snapshot').textContent ?? '{}')
|
||||
.profileName,
|
||||
).toBe('回潮群岛');
|
||||
});
|
||||
|
||||
await user.click(screen.getByRole('button', { name: '选择打开草稿' }));
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
JSON.parse(screen.getByTestId('state-snapshot').textContent ?? '{}')
|
||||
.profileName,
|
||||
).toBe('星砂废都');
|
||||
});
|
||||
|
||||
await user.click(screen.getByRole('button', { name: '确认草稿角色' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(runtimeStoryClientMocks.beginRuntimeStorySession).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
customWorldProfile: expect.objectContaining({
|
||||
id: 'opened-draft-profile',
|
||||
name: '星砂废都',
|
||||
summary: '本次从草稿架打开并启动的目标草稿。',
|
||||
}),
|
||||
character: expect.objectContaining({
|
||||
id: 'opened-playable-1',
|
||||
name: '砂眠',
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -79,6 +79,72 @@ describe('rpgEntryLibraryClient world library routes', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('normalizes detail profiles before runtime launch consumes them', async () => {
|
||||
requestJsonMock.mockResolvedValueOnce({
|
||||
entry: {
|
||||
ownerUserId: 'owner-1',
|
||||
profileId: 'profile-1',
|
||||
publicWorkCode: 'CW-1',
|
||||
authorPublicUserCode: 'U-1',
|
||||
profile: {
|
||||
id: 'profile-1',
|
||||
name: '旧数据世界',
|
||||
summary: '只有摘要字段的旧 profile。',
|
||||
},
|
||||
visibility: 'published',
|
||||
publishedAt: '2026-05-21T00:00:00.000Z',
|
||||
updatedAt: '2026-05-21T00:00:00.000Z',
|
||||
authorDisplayName: '作者',
|
||||
worldName: '旧数据世界',
|
||||
subtitle: '旧数据',
|
||||
summaryText: '只有摘要字段的旧 profile。',
|
||||
coverImageSrc: null,
|
||||
themeMode: 'martial',
|
||||
playableNpcCount: 0,
|
||||
landmarkCount: 0,
|
||||
},
|
||||
});
|
||||
|
||||
const entry = await getRpgEntryWorldGalleryDetail('owner-1', 'profile-1');
|
||||
|
||||
expect(Array.isArray(entry.profile.playableNpcs)).toBe(true);
|
||||
expect(Array.isArray(entry.profile.storyNpcs)).toBe(true);
|
||||
expect(Array.isArray(entry.profile.landmarks)).toBe(true);
|
||||
expect(entry.profile.attributeSchema.schemaVersion).toBe(1);
|
||||
});
|
||||
|
||||
it('falls back to entry summary when old detail profile cannot be normalized', async () => {
|
||||
requestJsonMock.mockResolvedValueOnce({
|
||||
entry: {
|
||||
ownerUserId: 'owner-1',
|
||||
profileId: 'profile-1',
|
||||
publicWorkCode: 'CW-1',
|
||||
authorPublicUserCode: 'U-1',
|
||||
profile: {
|
||||
id: 'profile-1',
|
||||
summary: '缺少 name 的旧 profile。',
|
||||
},
|
||||
visibility: 'published',
|
||||
publishedAt: '2026-05-21T00:00:00.000Z',
|
||||
updatedAt: '2026-05-21T00:00:00.000Z',
|
||||
authorDisplayName: '作者',
|
||||
worldName: '摘要兜底世界',
|
||||
subtitle: '旧数据',
|
||||
summaryText: '缺少 name 的旧 profile。',
|
||||
coverImageSrc: null,
|
||||
themeMode: 'martial',
|
||||
playableNpcCount: 0,
|
||||
landmarkCount: 0,
|
||||
},
|
||||
});
|
||||
|
||||
const entry = await getRpgEntryWorldGalleryDetail('owner-1', 'profile-1');
|
||||
|
||||
expect(entry.profile.id).toBe('profile-1');
|
||||
expect(entry.profile.name).toBe('摘要兜底世界');
|
||||
expect(Array.isArray(entry.profile.playableNpcs)).toBe(true);
|
||||
});
|
||||
|
||||
it('reads owned library detail from the runtime entry route', async () => {
|
||||
requestJsonMock.mockResolvedValueOnce({
|
||||
entry: {
|
||||
|
||||
@@ -7,13 +7,62 @@ import {
|
||||
import type {
|
||||
CustomWorldGalleryDetailResponse,
|
||||
CustomWorldGalleryResponse,
|
||||
CustomWorldLibraryEntry,
|
||||
CustomWorldLibraryMutationResponse,
|
||||
CustomWorldLibraryResponse,
|
||||
} from '../../../packages/shared/src/contracts/runtime';
|
||||
import { normalizeCustomWorldProfileRecord } from '../../data/customWorldLibrary';
|
||||
import type { CustomWorldProfile } from '../../types';
|
||||
|
||||
export type { RuntimeRequestOptions };
|
||||
|
||||
type RpgEntryWorldEntry = CustomWorldLibraryEntry<CustomWorldProfile>;
|
||||
type RpgEntryWorldMutationResponse =
|
||||
CustomWorldLibraryMutationResponse<CustomWorldProfile>;
|
||||
|
||||
function normalizeRpgEntryWorldProfile(entry: RpgEntryWorldEntry) {
|
||||
const rawProfile =
|
||||
entry.profile && typeof entry.profile === 'object' ? entry.profile : {};
|
||||
const fallbackProfile = {
|
||||
id: entry.profileId,
|
||||
name: entry.worldName,
|
||||
subtitle: entry.subtitle,
|
||||
summary: entry.summaryText,
|
||||
settingText: entry.summaryText || entry.worldName,
|
||||
playableNpcs: [],
|
||||
storyNpcs: [],
|
||||
items: [],
|
||||
landmarks: [],
|
||||
};
|
||||
const normalizedProfile =
|
||||
normalizeCustomWorldProfileRecord({
|
||||
...fallbackProfile,
|
||||
...rawProfile,
|
||||
}) ?? normalizeCustomWorldProfileRecord(fallbackProfile);
|
||||
|
||||
return {
|
||||
...entry,
|
||||
profile: normalizedProfile ?? entry.profile,
|
||||
} as RpgEntryWorldEntry;
|
||||
}
|
||||
|
||||
function normalizeRpgEntryWorldEntries(
|
||||
entries: RpgEntryWorldEntry[] | null | undefined,
|
||||
) {
|
||||
return Array.isArray(entries)
|
||||
? entries.map((entry) => normalizeRpgEntryWorldProfile(entry))
|
||||
: [];
|
||||
}
|
||||
|
||||
function normalizeRpgEntryWorldMutationResponse(
|
||||
response: RpgEntryWorldMutationResponse,
|
||||
) {
|
||||
return {
|
||||
entry: normalizeRpgEntryWorldProfile(response.entry),
|
||||
entries: normalizeRpgEntryWorldEntries(response.entries),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* RPG 入口世界库 client 的真实实现。
|
||||
* 第三批收口后,平台首页/详情页开始游戏链直接走 rpg-entry 域请求,不再反向穿旧 storageService 兼容层。
|
||||
@@ -33,7 +82,7 @@ export async function listRpgEntryWorldLibrary(
|
||||
},
|
||||
);
|
||||
|
||||
return Array.isArray(response?.entries) ? response.entries : [];
|
||||
return normalizeRpgEntryWorldEntries(response?.entries);
|
||||
}
|
||||
|
||||
export async function listRpgEntryWorldGallery(
|
||||
@@ -63,7 +112,7 @@ export async function getRpgEntryWorldGalleryDetail(
|
||||
options,
|
||||
);
|
||||
|
||||
return response.entry;
|
||||
return normalizeRpgEntryWorldProfile(response.entry);
|
||||
}
|
||||
|
||||
export async function getRpgEntryWorldGalleryDetailByCode(
|
||||
@@ -79,7 +128,7 @@ export async function getRpgEntryWorldGalleryDetailByCode(
|
||||
options,
|
||||
);
|
||||
|
||||
return response.entry;
|
||||
return normalizeRpgEntryWorldProfile(response.entry);
|
||||
}
|
||||
|
||||
export async function remixRpgEntryWorldGallery(
|
||||
@@ -96,10 +145,7 @@ export async function remixRpgEntryWorldGallery(
|
||||
options,
|
||||
);
|
||||
|
||||
return {
|
||||
entry: response.entry,
|
||||
entries: Array.isArray(response?.entries) ? response.entries : [],
|
||||
};
|
||||
return normalizeRpgEntryWorldMutationResponse(response);
|
||||
}
|
||||
|
||||
export async function recordRpgEntryWorldGalleryPlay(
|
||||
@@ -116,7 +162,7 @@ export async function recordRpgEntryWorldGalleryPlay(
|
||||
options,
|
||||
);
|
||||
|
||||
return response.entry;
|
||||
return normalizeRpgEntryWorldProfile(response.entry);
|
||||
}
|
||||
|
||||
export async function likeRpgEntryWorldGallery(
|
||||
@@ -133,7 +179,7 @@ export async function likeRpgEntryWorldGallery(
|
||||
options,
|
||||
);
|
||||
|
||||
return response.entry;
|
||||
return normalizeRpgEntryWorldProfile(response.entry);
|
||||
}
|
||||
|
||||
export async function getRpgEntryWorldLibraryDetail(
|
||||
@@ -149,7 +195,7 @@ export async function getRpgEntryWorldLibraryDetail(
|
||||
options,
|
||||
);
|
||||
|
||||
return response.entry;
|
||||
return normalizeRpgEntryWorldProfile(response.entry);
|
||||
}
|
||||
|
||||
export async function upsertRpgEntryWorldProfile(
|
||||
@@ -171,10 +217,7 @@ export async function upsertRpgEntryWorldProfile(
|
||||
options,
|
||||
);
|
||||
|
||||
return {
|
||||
entry: response.entry,
|
||||
entries: Array.isArray(response?.entries) ? response.entries : [],
|
||||
};
|
||||
return normalizeRpgEntryWorldMutationResponse(response);
|
||||
}
|
||||
|
||||
export async function deleteRpgEntryWorldProfile(
|
||||
@@ -190,7 +233,7 @@ export async function deleteRpgEntryWorldProfile(
|
||||
options,
|
||||
);
|
||||
|
||||
return Array.isArray(response?.entries) ? response.entries : [];
|
||||
return normalizeRpgEntryWorldEntries(response?.entries);
|
||||
}
|
||||
|
||||
export async function publishRpgEntryWorldProfile(
|
||||
@@ -206,10 +249,7 @@ export async function publishRpgEntryWorldProfile(
|
||||
options,
|
||||
);
|
||||
|
||||
return {
|
||||
entry: response.entry,
|
||||
entries: Array.isArray(response?.entries) ? response.entries : [],
|
||||
};
|
||||
return normalizeRpgEntryWorldMutationResponse(response);
|
||||
}
|
||||
|
||||
export async function unpublishRpgEntryWorldProfile(
|
||||
@@ -225,10 +265,7 @@ export async function unpublishRpgEntryWorldProfile(
|
||||
options,
|
||||
);
|
||||
|
||||
return {
|
||||
entry: response.entry,
|
||||
entries: Array.isArray(response?.entries) ? response.entries : [],
|
||||
};
|
||||
return normalizeRpgEntryWorldMutationResponse(response);
|
||||
}
|
||||
|
||||
export const rpgEntryLibraryClient = {
|
||||
|
||||
Reference in New Issue
Block a user