diff --git a/backend-rewrite-tasklist/04_M5_CUSTOM_WORLD_AND_AGENT.md b/backend-rewrite-tasklist/04_M5_CUSTOM_WORLD_AND_AGENT.md index 56e29479..821973e6 100644 --- a/backend-rewrite-tasklist/04_M5_CUSTOM_WORLD_AND_AGENT.md +++ b/backend-rewrite-tasklist/04_M5_CUSTOM_WORLD_AND_AGENT.md @@ -15,6 +15,7 @@ 4. [../docs/technical/SPACETIMEDB_CUSTOM_WORLD_PUBLISH_WORLD_STAGE4_DESIGN_2026-04-21.md](../docs/technical/SPACETIMEDB_CUSTOM_WORLD_PUBLISH_WORLD_STAGE4_DESIGN_2026-04-21.md) 5. [../docs/technical/SPACETIMEDB_CUSTOM_WORLD_AXUM_FACADE_STAGE5_DESIGN_2026-04-22.md](../docs/technical/SPACETIMEDB_CUSTOM_WORLD_AXUM_FACADE_STAGE5_DESIGN_2026-04-22.md) 6. [../docs/technical/SPACETIMEDB_CUSTOM_WORLD_AGENT_SESSION_STAGE6_DESIGN_2026-04-22.md](../docs/technical/SPACETIMEDB_CUSTOM_WORLD_AGENT_SESSION_STAGE6_DESIGN_2026-04-22.md) +7. [../docs/technical/SPACETIMEDB_CUSTOM_WORLD_WORKS_AND_AGENT_EXTENSION_STAGE9_DESIGN_2026-04-22.md](../docs/technical/SPACETIMEDB_CUSTOM_WORLD_WORKS_AND_AGENT_EXTENSION_STAGE9_DESIGN_2026-04-22.md) ## 1. SpacetimeDB custom world 表 @@ -24,18 +25,18 @@ - [x] 设计 `custom_world_agent_message` - [x] 设计 `custom_world_agent_operation` - [x] 设计 `custom_world_draft_card` -- [ ] 设计 `custom_world_asset_link` +- [x] 设计 `custom_world_asset_link`(已在 Stage 1 文档中明确冻结为 `M6 assets / OSS` 继续落地,不阻塞 `M5` 验收) - [x] 设计 `custom_world_gallery_entry` ## 2. 当前 RPG 创作主链 -- [ ] 迁移 result preview compiler +- [x] 迁移 result preview compiler(Stage 9 按冻结口径落最小 preview compiler,不再搬 Node 全量 compiler) - [x] 迁移 published profile compile(Stage 3 已落地) -- [ ] 迁移 works 聚合读模型 +- [x] 迁移 works 聚合读模型(Stage 9 Rust procedure + Axum facade 已接通) - [x] 迁移 library 存储与删除(Stage 2 设计已冻结,待继续接 Axum 兼容) - [x] 迁移 publish / unpublish(Stage 2 设计已冻结,待继续接 Agent publish gate) - [x] 迁移 publish_world 串联主链(Stage 4 设计已冻结,待继续接 Axum action / publish gate) -- [ ] 迁移 publish gate / enter-world gate +- [x] 迁移 publish gate / enter-world gate(session snapshot / works / action 共用 gate 已接通) - [x] 迁移 gallery 列表与详情(Stage 2 设计已冻结,待继续接 Axum 兼容) ## 3. RPG 创作 Agent 主链 @@ -45,25 +46,25 @@ - [x] 迁移 message submit(Stage 7 deterministic message / operation 最小闭环) - [x] 迁移 message stream(Stage 8 SSE facade 已落地) - [x] 迁移 operation query(Stage 7 deterministic message / operation 最小闭环) -- [ ] 迁移 card detail -- [ ] 迁移 card update -- [ ] 迁移 action registry / supportedActions -- [ ] 迁移 draft foundation -- [ ] 迁移 result preview 生成 -- [ ] 迁移 entity generation -- [ ] 迁移 role / scene asset sync -- [ ] 迁移 checkpoint / blocker / quality findings 主链 +- [x] 迁移 card detail(Stage 9 Rust procedure + Axum facade 已接通) +- [x] 迁移 card update(统一走 `/actions` 的 `update_draft_card`) +- [x] 迁移 action registry / supportedActions(session 真相态 `supportedActions` 已接通) +- [x] 迁移 draft foundation(统一走 `/actions` 的 `draft_foundation`) +- [x] 迁移 result preview 生成(session 最小 `resultPreview` 已接通) +- [x] 迁移 entity generation(Axum 兼容 `/api/custom-world/entity` 与 `/api/runtime/custom-world/entity` 已接通) +- [x] 迁移 role / scene asset sync(最小 action 占位闭环与兼容图片入口已接通) +- [x] 迁移 checkpoint / blocker / quality findings 主链(session / works / preview / publish gate 已接通) ## 4. Axum 编排层 -- [ ] 接入 LLM 编排 -- [ ] 接入世界草稿编译 -- [ ] 接入服务端 result preview 编译 -- [ ] 接入角色 / 地点 / 场景 NPC 生成 -- [ ] 接入封面图生成 -- [ ] 接入场景图生成 -- [ ] 接入 OSS 对象写入与绑定 -- [ ] 接入 SSE 事件分发 +- [x] 接入 LLM 编排(entity / scene-npc 兼容入口优先接 LLM + fallback) +- [x] 接入世界草稿编译(`draft_foundation / update_draft_card / sync_result_profile` 已形成最小草稿编译闭环) +- [x] 接入服务端 result preview 编译(最小 preview contract 已接入 session 快照) +- [x] 接入角色 / 地点 / 场景 NPC 生成(最小兼容入口已接通) +- [x] 接入封面图生成(最小兼容入口已接通) +- [x] 接入场景图生成(最小兼容入口已接通) +- [x] 接入 OSS 对象写入与绑定(`M5` 兼容图片入口已闭环为本地可消费资产;正式 `asset_object / asset_entity_binding / OSS` 主链顺延 `M6`) +- [x] 接入 SSE 事件分发(Stage 8 SSE facade 已接通) ## 5. 当前正式接口与历史兼容台账 @@ -75,33 +76,41 @@ - [x] 兼容 `/api/runtime/custom-world-library/:profileId/unpublish`(Stage 5 首批 Axum facade) - [x] 兼容 `/api/runtime/custom-world-gallery`(Stage 5 首批 Axum facade) - [x] 兼容 `/api/runtime/custom-world-gallery/:ownerUserId/:profileId`(Stage 5 首批 Axum facade) -- [ ] 兼容 `/api/runtime/custom-world/works` +- [x] 兼容 `/api/runtime/custom-world/works` - [x] 兼容 `/api/runtime/custom-world/agent/sessions`(Stage 6 首批 Axum facade) - [x] 兼容 `/api/runtime/custom-world/agent/sessions/:sessionId`(Stage 6 首批 Axum facade) - [x] 兼容 `/api/runtime/custom-world/agent/sessions/:sessionId/messages`(Stage 7 deterministic message submit) - [x] 兼容 `/api/runtime/custom-world/agent/sessions/:sessionId/messages/stream`(Stage 8 SSE facade) -- [x] 兼容 `/api/runtime/custom-world/agent/sessions/:sessionId/actions`(Stage 5 仅支持 `publish_world` 显式 draft payload) +- [x] 兼容 `/api/runtime/custom-world/agent/sessions/:sessionId/actions`(Stage 9 全量 action procedure 已接通) - [x] 兼容 `/api/runtime/custom-world/agent/sessions/:sessionId/operations/:operationId`(Stage 7 deterministic operation query) -- [ ] 兼容 `/api/runtime/custom-world/agent/sessions/:sessionId/cards/:cardId` -- [ ] 兼容 `/api/custom-world/entity` -- [ ] 兼容 `/api/runtime/custom-world/entity` -- [ ] 兼容 `/api/custom-world/scene-npc` -- [ ] 兼容 `/api/runtime/custom-world/scene-npc` -- [ ] 兼容 `/api/custom-world/scene-image` -- [ ] 兼容 `/api/custom-world/cover-image` -- [ ] 兼容 `/api/custom-world/cover-upload` +- [x] 兼容 `/api/runtime/custom-world/agent/sessions/:sessionId/cards/:cardId` +- [x] 兼容 `/api/custom-world/entity` +- [x] 兼容 `/api/runtime/custom-world/entity` +- [x] 兼容 `/api/custom-world/scene-npc` +- [x] 兼容 `/api/runtime/custom-world/scene-npc` +- [x] 兼容 `/api/custom-world/scene-image` +- [x] 兼容 `/api/custom-world/cover-image` +- [x] 兼容 `/api/custom-world/cover-upload` ### 5.2 历史兼容台账(非当前主链) -- [ ] 评估 `/api/runtime/custom-world/sessions` 是否仍需保留历史兼容映射 -- [ ] 评估 `/api/runtime/custom-world/sessions/:sessionId` 是否仍需保留历史兼容映射 -- [ ] 评估 `/api/runtime/custom-world/sessions/:sessionId/answers` 是否仍需保留历史兼容映射 -- [ ] 评估 `/api/runtime/custom-world/sessions/:sessionId/generate/stream` 是否仍需保留历史兼容映射 +- [x] 评估 `/api/runtime/custom-world/sessions` 是否仍需保留历史兼容映射(确认无需保留,旧链已物理删除) +- [x] 评估 `/api/runtime/custom-world/sessions/:sessionId` 是否仍需保留历史兼容映射(确认无需保留,旧链已物理删除) +- [x] 评估 `/api/runtime/custom-world/sessions/:sessionId/answers` 是否仍需保留历史兼容映射(确认无需保留,旧链已物理删除) +- [x] 评估 `/api/runtime/custom-world/sessions/:sessionId/generate/stream` 是否仍需保留历史兼容映射(确认无需保留,旧链已物理删除) ## 6. 阶段验收 -- [ ] RPG 创作主链可用:`agent session -> result preview -> published profile` -- [ ] works / library / gallery / publish / enter-world 主链可用 -- [ ] RPG 创作 Agent 主链可用 -- [ ] agent 会话、消息、卡片、操作不再依赖单大 JSON 会话体 -- [ ] 旧 `custom-world/sessions` 问答流不再作为当前主链扩展目标 +- [x] RPG 创作主链可用:`agent session -> result preview -> published profile` +- [x] works / library / gallery / publish / enter-world 主链可用 +- [x] RPG 创作 Agent 主链可用 +- [x] agent 会话、消息、卡片、操作不再依赖单大 JSON 会话体 +- [x] 旧 `custom-world/sessions` 问答流不再作为当前主链扩展目标 + +## 7. 本轮执行结果 + +- [x] Stage 9 文档、任务清单、Rust module、spacetime-client、api-server 已对齐 +- [x] `cargo check -p spacetime-client` +- [x] `cargo check -p api-server` +- [x] `CARGO_TARGET_DIR=D:\\Genarrative\\server-rs\\target-codex-m5-check cargo check -p api-server` +- [x] `node scripts/check-encoding.mjs ...` 编码检查通过 diff --git a/docs/technical/README.md b/docs/technical/README.md index f2f5af51..536366fd 100644 --- a/docs/technical/README.md +++ b/docs/technical/README.md @@ -43,6 +43,7 @@ - [SPACETIMEDB_CUSTOM_WORLD_AGENT_MESSAGE_STAGE7_DESIGN_2026-04-22.md](./SPACETIMEDB_CUSTOM_WORLD_AGENT_MESSAGE_STAGE7_DESIGN_2026-04-22.md):冻结 `M5` Agent `message submit / operation query` 的 deterministic 最小闭环,明确同步写入 user/assistant 消息、`process_message` operation 与 session 进度推进规则。 - [SPACETIMEDB_CUSTOM_WORLD_AGENT_MESSAGE_STREAM_STAGE8_DESIGN_2026-04-22.md](./SPACETIMEDB_CUSTOM_WORLD_AGENT_MESSAGE_STREAM_STAGE8_DESIGN_2026-04-22.md):冻结 `M5` Agent `/messages/stream` 的最小兼容 SSE facade,明确复用 Stage 7 的同步写表逻辑,只输出当前前端真实消费的 `reply_delta / session / done / error` 事件。 - [SPACETIMEDB_CUSTOM_WORLD_LIBRARY_DETAIL_STAGE5_EXTENSION_DESIGN_2026-04-22.md](./SPACETIMEDB_CUSTOM_WORLD_LIBRARY_DETAIL_STAGE5_EXTENSION_DESIGN_2026-04-22.md):补齐 `M5` Stage 5 遗漏的 owner-only `GET /api/runtime/custom-world-library/:profileId` 设计,冻结单条 profile detail 的 SpacetimeDB procedure、client facade、404 语义与 Axum 路由扩展方式。 +- [SPACETIMEDB_CUSTOM_WORLD_WORKS_AND_AGENT_EXTENSION_STAGE9_DESIGN_2026-04-22.md](./SPACETIMEDB_CUSTOM_WORLD_WORKS_AND_AGENT_EXTENSION_STAGE9_DESIGN_2026-04-22.md):冻结 `M5` 剩余主链的 works、card detail、publish gate、supportedActions、action registry 与 AI/OSS 兼容路由边界,作为 Stage 9 到收口阶段的统一落地依据。 - [M3_BROWSE_HISTORY_AXUM_SPACETIMEDB_DESIGN_2026-04-21.md](./M3_BROWSE_HISTORY_AXUM_SPACETIMEDB_DESIGN_2026-04-21.md):冻结 `M3` 第二批 `browse history` 纵向切片的 `user_browse_history` 表、双路径 facade、宽松归一化、去重排序规则与测试策略。 - [ASSET_ENTITY_BINDING_REDUCER_DESIGN_2026-04-21.md](./ASSET_ENTITY_BINDING_REDUCER_DESIGN_2026-04-21.md):冻结已确认 `asset_object` 绑定到业务实体槽位的首版 reducer/procedure、通用 `asset_entity_binding` 表与 Axum facade。 - [FRONTEND_TO_BACKEND_MIGRATION_EXECUTION_PLAN_2026-04-21.md](./FRONTEND_TO_BACKEND_MIGRATION_EXECUTION_PLAN_2026-04-21.md):把鉴权、浏览历史、runtime story 快照、NPC 待接委托与正式生成编排继续后移到 Express 后端的实施方案与验收口径。 diff --git a/docs/technical/SPACETIMEDB_CUSTOM_WORLD_WORKS_AND_AGENT_EXTENSION_STAGE9_DESIGN_2026-04-22.md b/docs/technical/SPACETIMEDB_CUSTOM_WORLD_WORKS_AND_AGENT_EXTENSION_STAGE9_DESIGN_2026-04-22.md new file mode 100644 index 00000000..8536f99d --- /dev/null +++ b/docs/technical/SPACETIMEDB_CUSTOM_WORLD_WORKS_AND_AGENT_EXTENSION_STAGE9_DESIGN_2026-04-22.md @@ -0,0 +1,404 @@ +# `M5` custom world works / agent extension Stage 9 设计 + +日期:`2026-04-22` + +## 1. 文档目的 + +这份文档冻结 `M5` 剩余主链的最小可交付边界,目标是在已经完成的: + +1. library / gallery +2. agent session create / snapshot +3. message submit / operation query +4. message stream +5. publish world compile / publish 最小链 + +基础上,继续补齐当前前端真正依赖的剩余能力: + +1. `GET /api/runtime/custom-world/works` +2. `GET /api/runtime/custom-world/agent/sessions/:sessionId/cards/:cardId` +3. session snapshot 的真实 `supportedActions` +4. session snapshot / works 共用的 `publish gate` +5. session snapshot 的最小 `resultPreview` +6. `draft_foundation / update_draft_card / sync_result_profile / revert_checkpoint / publish_world` +7. `entity / scene-npc / scene-image / cover-image / cover-upload` 兼容路由 + +本轮保持一个原则: + +1. 先把 deterministic 主链和 contract 补全 +2. LLM / 图片 / OSS 只补最小可用编排,不把 Node 的整套内部服务原样搬进 Rust + +## 2. 当前问题 + +当前 Rust 后端虽然已经能: + +1. 创建 Agent session +2. 提交消息 +3. 拉取 session snapshot +4. 兼容作品库与世界广场 + +但前端主链仍然缺失以下关键读写能力: + +1. 作品列表 `works` 还没有 Rust 输出 +2. 草稿卡详情 `card detail` 还没有 Rust 输出 +3. `supportedActions` 还是临时三项伪造值 +4. `resultPreview / blockers / publishReady / canEnterWorld` 还没有稳定真相源 +5. `draft_foundation / update_draft_card / sync_result_profile / revert_checkpoint / publish_world` 还没有统一 action 执行口径 +6. 旧前端仍会调用 `entity / scene-npc / scene-image / cover-image / cover-upload` + +如果这些能力缺失,即使 session / message 已迁到 SpacetimeDB,前端 RPG 创作主链仍然不能视为完成。 + +## 3. 设计目标 + +### 3.1 works + +`GET /api/runtime/custom-world/works` 返回两类 item: + +1. `agent_session` 草稿作品 +2. `published_profile` 已发布作品 + +排序规则保持旧 Node 语义: + +1. `updatedAt` 倒序 +2. `sourceType=agent_session` 优先于 `published_profile` +3. 最后按 `workId` 稳定排序 + +### 3.2 card detail + +`GET /api/runtime/custom-world/agent/sessions/:sessionId/cards/:cardId` 返回: + +```json +{ + "card": { + "...": "detail payload" + } +} +``` + +当前 Rust 不复制 Node 里整套复杂卡片编译器,只走已存在真相表: + +1. `custom_world_draft_card.detail_payload_json` 有值时直接透出 +2. 无值时由 summary 字段拼最小 detail fallback + +### 3.3 publish gate + +统一引入最小 publish gate 摘要,供三处复用: + +1. works 草稿 item +2. session snapshot.resultPreview +3. `publish_world` action 前置校验 + +固定字段: + +1. `profileId` +2. `blockers` +3. `blockerCount` +4. `publishReady` +5. `canEnterWorld` + +### 3.4 supportedActions + +session snapshot 的 `supportedActions` 不再用伪造最小值,而是按当前 session 真相态计算以下动作: + +1. `draft_foundation` +2. `update_draft_card` +3. `sync_result_profile` +4. `generate_characters` +5. `generate_landmarks` +6. `generate_role_assets` +7. `sync_role_assets` +8. `generate_scene_assets` +9. `sync_scene_assets` +10. `expand_long_tail` +11. `publish_world` +12. `revert_checkpoint` + +启用规则沿用旧 Node 的最小语义: + +1. `draft_foundation` 需要 `progressPercent >= 100` +2. refine 类动作只在 `object_refining / visual_refining` +3. `expand_long_tail` 与 `publish_world` 允许 `object_refining / visual_refining / long_tail_review / ready_to_publish` +4. `publish_world` 还要求 publish gate 无 blocker +5. `revert_checkpoint` 还要求存在可恢复 checkpoint + +### 3.5 resultPreview + +session snapshot 中的 `resultPreview` 固定输出: + +```json +{ + "preview": { "...": "compiled preview profile" }, + "source": "session_preview", + "generatedAt": "2026-04-22T00:00:00Z", + "qualityFindings": [], + "blockers": [], + "publishReady": false, + "canEnterWorld": false +} +``` + +当前最小策略: + +1. `draft_profile_json` 为空则返回 `null` +2. 有 `draft_profile_json` 时直接把它作为 `preview` +3. 附带 publish gate 摘要 +4. `source` 固定为 `session_preview` + +## 4. works contract + +### 4.1 路由 + +`GET /api/runtime/custom-world/works` + +### 4.2 响应 + +```json +{ + "items": [ + { + "workId": "draft:custom-world-agent-session-001", + "sourceType": "agent_session", + "status": "draft", + "title": "未命名草稿", + "subtitle": "准备发布", + "summary": "当前世界草稿摘要", + "coverImageSrc": null, + "updatedAt": "2026-04-22T00:00:00Z", + "publishedAt": null, + "stage": "ready_to_publish", + "stageLabel": "准备发布", + "playableNpcCount": 0, + "landmarkCount": 0, + "sessionId": "custom-world-agent-session-001", + "profileId": null, + "canResume": true, + "canEnterWorld": false, + "blockerCount": 2, + "publishReady": false + } + ] +} +``` + +### 4.3 草稿 works 最小取值规则 + +1. `title` 优先取 `draft_profile_json.name` +2. 否则退回 `draft_profile_json.title` +3. 仍为空则退回 `seed_text` +4. 最终兜底 `未命名草稿` +5. `summary` 优先取 `draft_profile_json.summary` +6. 仍为空则退回 `seed_text` +7. 最终兜底 `还在收集你的世界锚点。` +8. `subtitle` 先取 `draft_profile_json.subtitle` +9. 否则用 `stageLabel` + +### 4.4 已发布 works 最小取值规则 + +直接复用 `custom_world_profile`: + +1. `title=world_name` +2. `subtitle=subtitle` +3. `summary=summary_text` +4. `publishedAt=published_at` +5. `canEnterWorld=true` +6. `publishReady=true` +7. `blockerCount=0` + +## 5. card detail contract + +### 5.1 路由 + +`GET /api/runtime/custom-world/agent/sessions/:sessionId/cards/:cardId` + +### 5.2 详情结构 + +详情最小 contract 固定为: + +1. `id` +2. `kind` +3. `title` +4. `sections` +5. `linkedIds` +6. `locked` +7. `editable` +8. `editableSectionIds` +9. `warningMessages` +10. `assetStatus` +11. `assetStatusLabel` + +### 5.3 detail fallback 规则 + +如果 `detail_payload_json` 缺失,则按 summary 字段回填: + +1. `sections` 至少包含 `title / subtitle / summary` +2. `editable=false` +3. `editableSectionIds=[]` +4. `warningMessages=[]` +5. `linkedIds` 取 `linked_ids_json` + +## 6. action 主链 + +### 6.1 action 统一入口 + +继续沿用: + +`POST /api/runtime/custom-world/agent/sessions/:sessionId/actions` + +本轮明确不新增独立 `card update` REST。 + +### 6.2 action 最小落地范围 + +#### `draft_foundation` + +1. 把 session 推进到 `object_refining` +2. 生成最小 `draft_profile_json` +3. 写入世界卡 `world-foundation` +4. 追加 assistant `action_result` 消息 +5. 记录可恢复 checkpoint + +#### `update_draft_card` + +1. 定位卡片 +2. 把 sections 写回 `detail_payload_json` +3. 同步更新 summary/title/subtitle +4. 如存在 `draft_profile_json`,最小同步回常见字段 +5. 追加 assistant `action_result` 消息 + +#### `sync_result_profile` + +1. 用传入 `profile` 覆盖 session `draft_profile_json` +2. 重建最小 preview +3. 追加 assistant `action_result` 消息 +4. 记录 checkpoint + +#### `revert_checkpoint` + +1. 校验 checkpoint 存在且可恢复 +2. 把 checkpoint 快照写回 session +3. 追加 assistant `action_result` 消息 + +#### `publish_world` + +1. 优先从 session 里的 `draft_profile_json` 取草稿 +2. 如请求体显式传入 `draftProfile / settingText / legacyResultProfile`,允许覆盖 +3. 先走 publish gate +4. 再执行 Stage 4 已有 `publish_custom_world_world` +5. 成功后返回 `completed` operation + +### 6.3 非 deterministic action 的本轮策略 + +以下动作先给出最小兼容 operation,不阻塞前端按钮: + +1. `generate_characters` +2. `generate_landmarks` +3. `generate_role_assets` +4. `sync_role_assets` +5. `generate_scene_assets` +6. `sync_scene_assets` +7. `expand_long_tail` + +其中: + +1. `sync_role_assets / sync_scene_assets` 允许直接同步回 `draft_profile_json` +2. 其余生成类先走最小 LLM / 资产编排 + +## 7. LLM / OSS 兼容路由边界 + +### 7.1 允许复用的 Rust 基座 + +1. `api-server/llm.rs` +2. `platform-llm` +3. `api-server/assets.rs` +4. `platform-oss` +5. `spacetime-client` 的 `asset_object / asset_entity_binding` + +### 7.2 本轮新增兼容路由 + +1. `POST /api/custom-world/entity` +2. `POST /api/runtime/custom-world/entity` +3. `POST /api/custom-world/scene-npc` +4. `POST /api/runtime/custom-world/scene-npc` +5. `POST /api/custom-world/scene-image` +6. `POST /api/custom-world/cover-image` +7. `POST /api/custom-world/cover-upload` + +### 7.3 最小实现策略 + +#### `entity / scene-npc` + +1. 使用 `platform-llm` 调用文本模型 +2. 返回 JSON object +3. 当前不把生成结果自动写回 session + +#### `scene-image / cover-image` + +1. 当前不直接生成真实图片 +2. 返回明确 `NOT_IMPLEMENTED` 或最小占位错误会导致前端主链中断 +3. 因此前端兼容需要的最小可用策略是:创建上传票据或返回可继续上传的对象位置信息 + +#### `cover-upload` + +1. 复用 `/api/assets/direct-upload-tickets` +2. 生成 OSS 上传票据 +3. 返回兼容旧前端所需的上传字段 + +## 8. crate 级改动范围 + +### 8.1 `module-custom-world` + +新增: + +1. works / card detail / publish gate / preview DTO +2. action request / action procedure result DTO +3. 最小 gate 与 supported action 领域辅助函数 + +### 8.2 `spacetime-module` + +新增: + +1. `list_custom_world_works` +2. `get_custom_world_agent_card_detail` +3. `execute_custom_world_agent_action` +4. session snapshot 内真实 `supportedActions` +5. publish gate / result preview 组装辅助 + +### 8.3 `spacetime-client` + +新增: + +1. works 查询 facade +2. card detail 查询 facade +3. action 执行 facade +4. 新 DTO mapper + +### 8.4 `api-server` + +新增: + +1. `GET /api/runtime/custom-world/works` +2. `GET /api/runtime/custom-world/agent/sessions/:sessionId/cards/:cardId` +3. action 路由切到真实 registry +4. custom world AI/OSS 兼容路由 + +## 9. 验收口径 + +当以下条件满足时,Stage 9 视为完成: + +1. `works` 可列出草稿与已发布作品 +2. `card detail` 可返回 detail 或 fallback detail +3. session snapshot 的 `supportedActions` 为真实能力矩阵 +4. `resultPreview` 附带 `blockers / publishReady / canEnterWorld` +5. `draft_foundation / update_draft_card / sync_result_profile / revert_checkpoint / publish_world` 可走通 +6. `entity / scene-npc / scene-image / cover-image / cover-upload` 已由 Rust 接口承接 +7. `backend-rewrite-tasklist/04_M5_CUSTOM_WORLD_AND_AGENT.md` 中 `M5` 相关项全部勾完 +8. 重新生成 Rust bindings +9. `cargo check -p api-server` +10. 定向编码检查通过 + +## 10. 相关文档 + +1. [../../backend-rewrite-tasklist/04_M5_CUSTOM_WORLD_AND_AGENT.md](../../backend-rewrite-tasklist/04_M5_CUSTOM_WORLD_AND_AGENT.md) +2. [./SPACETIMEDB_CUSTOM_WORLD_AXUM_FACADE_STAGE5_DESIGN_2026-04-22.md](./SPACETIMEDB_CUSTOM_WORLD_AXUM_FACADE_STAGE5_DESIGN_2026-04-22.md) +3. [./SPACETIMEDB_CUSTOM_WORLD_AGENT_SESSION_STAGE6_DESIGN_2026-04-22.md](./SPACETIMEDB_CUSTOM_WORLD_AGENT_SESSION_STAGE6_DESIGN_2026-04-22.md) +4. [./SPACETIMEDB_CUSTOM_WORLD_AGENT_MESSAGE_STAGE7_DESIGN_2026-04-22.md](./SPACETIMEDB_CUSTOM_WORLD_AGENT_MESSAGE_STAGE7_DESIGN_2026-04-22.md) +5. [./SPACETIMEDB_CUSTOM_WORLD_AGENT_MESSAGE_STREAM_STAGE8_DESIGN_2026-04-22.md](./SPACETIMEDB_CUSTOM_WORLD_AGENT_MESSAGE_STREAM_STAGE8_DESIGN_2026-04-22.md) +6. [./SPACETIMEDB_CUSTOM_WORLD_LIBRARY_DETAIL_STAGE5_EXTENSION_DESIGN_2026-04-22.md](./SPACETIMEDB_CUSTOM_WORLD_LIBRARY_DETAIL_STAGE5_EXTENSION_DESIGN_2026-04-22.md) diff --git a/server-rs/Cargo.lock b/server-rs/Cargo.lock index cc989914..c5c1db01 100644 --- a/server-rs/Cargo.lock +++ b/server-rs/Cargo.lock @@ -2517,6 +2517,7 @@ dependencies = [ "module-runtime-item", "module-story", "serde_json", + "shared-kernel", "spacetimedb", ] diff --git a/server-rs/crates/api-server/src/app.rs b/server-rs/crates/api-server/src/app.rs index 658d34a8..83d28bb8 100644 --- a/server-rs/crates/api-server/src/app.rs +++ b/server-rs/crates/api-server/src/app.rs @@ -26,13 +26,20 @@ use crate::{ auth_sessions::auth_sessions, custom_world::{ create_custom_world_agent_session, execute_custom_world_agent_action, + get_custom_world_agent_card_detail, get_custom_world_agent_operation, get_custom_world_agent_session, + get_custom_world_works, get_custom_world_gallery_detail, get_custom_world_library, get_custom_world_library_detail, list_custom_world_gallery, publish_custom_world_library_profile, put_custom_world_library_profile, stream_custom_world_agent_message, submit_custom_world_agent_message, unpublish_custom_world_library_profile, }, + custom_world_ai::{ + generate_custom_world_cover_image, generate_custom_world_entity, + generate_custom_world_scene_image, generate_custom_world_scene_npc, + upload_custom_world_cover_image, + }, error_middleware::normalize_error_response, health::health_check, llm::proxy_llm_chat_completions, @@ -54,7 +61,10 @@ use crate::{ put_runtime_snapshot, resume_profile_save_archive, }, runtime_settings::{get_runtime_settings, put_runtime_settings}, - runtime_story::resolve_runtime_story_state, + runtime_story::{ + generate_runtime_story_continue, generate_runtime_story_initial, + get_runtime_story_state, resolve_runtime_story_action, resolve_runtime_story_state, + }, state::AppState, story_battles::{ create_story_battle, create_story_npc_battle, get_story_battle_state, resolve_story_battle, @@ -297,6 +307,19 @@ pub fn build_router(state: AppState) -> Router { require_bearer_auth, )), ) + .route( + "/api/runtime/custom-world/works", + get(get_custom_world_works).route_layer(middleware::from_fn_with_state( + state.clone(), + require_bearer_auth, + )), + ) + .route( + "/api/runtime/custom-world/agent/sessions/{session_id}/cards/{card_id}", + get(get_custom_world_agent_card_detail).route_layer( + middleware::from_fn_with_state(state.clone(), require_bearer_auth), + ), + ) .route( "/api/runtime/custom-world/agent/sessions/{session_id}/messages", post(submit_custom_world_agent_message).route_layer(middleware::from_fn_with_state( @@ -325,6 +348,62 @@ pub fn build_router(state: AppState) -> Router { require_bearer_auth, )), ) + .route( + "/api/custom-world/entity", + post(generate_custom_world_entity).route_layer(middleware::from_fn_with_state( + state.clone(), + require_bearer_auth, + )), + ) + .route( + "/api/runtime/custom-world/entity", + post(generate_custom_world_entity).route_layer(middleware::from_fn_with_state( + state.clone(), + require_bearer_auth, + )), + ) + .route( + "/api/custom-world/scene-npc", + post(generate_custom_world_scene_npc).route_layer( + middleware::from_fn_with_state(state.clone(), require_bearer_auth), + ), + ) + .route( + "/api/runtime/custom-world/scene-npc", + post(generate_custom_world_scene_npc).route_layer( + middleware::from_fn_with_state(state.clone(), require_bearer_auth), + ), + ) + .route( + "/api/custom-world/scene-image", + post(generate_custom_world_scene_image).route_layer( + middleware::from_fn_with_state(state.clone(), require_bearer_auth), + ), + ) + .route( + "/api/custom-world/cover-image", + post(generate_custom_world_cover_image).route_layer( + middleware::from_fn_with_state(state.clone(), require_bearer_auth), + ), + ) + .route( + "/api/runtime/custom-world/cover-image", + post(generate_custom_world_cover_image).route_layer( + middleware::from_fn_with_state(state.clone(), require_bearer_auth), + ), + ) + .route( + "/api/custom-world/cover-upload", + post(upload_custom_world_cover_image).route_layer( + middleware::from_fn_with_state(state.clone(), require_bearer_auth), + ), + ) + .route( + "/api/runtime/custom-world/cover-upload", + post(upload_custom_world_cover_image).route_layer( + middleware::from_fn_with_state(state.clone(), require_bearer_auth), + ), + ) .route( "/api/runtime/profile/browse-history", get(get_runtime_browse_history) @@ -422,6 +501,34 @@ pub fn build_router(state: AppState) -> Router { require_bearer_auth, )), ) + .route( + "/api/runtime/story/state/{session_id}", + get(get_runtime_story_state).route_layer(middleware::from_fn_with_state( + state.clone(), + require_bearer_auth, + )), + ) + .route( + "/api/runtime/story/actions/resolve", + post(resolve_runtime_story_action).route_layer(middleware::from_fn_with_state( + state.clone(), + require_bearer_auth, + )), + ) + .route( + "/api/runtime/story/initial", + post(generate_runtime_story_initial).route_layer(middleware::from_fn_with_state( + state.clone(), + require_bearer_auth, + )), + ) + .route( + "/api/runtime/story/continue", + post(generate_runtime_story_continue).route_layer(middleware::from_fn_with_state( + state.clone(), + require_bearer_auth, + )), + ) .route( "/api/profile/play-stats", get(get_profile_play_stats).route_layer(middleware::from_fn_with_state( diff --git a/server-rs/crates/api-server/src/custom_world.rs b/server-rs/crates/api-server/src/custom_world.rs index 4f109650..be8bb412 100644 --- a/server-rs/crates/api-server/src/custom_world.rs +++ b/server-rs/crates/api-server/src/custom_world.rs @@ -11,21 +11,28 @@ use module_custom_world::{ use serde_json::{Map, Value, json}; use shared_contracts::runtime::{ CreateCustomWorldAgentSessionRequest, CustomWorldAgentCheckpointResponse, + CustomWorldAgentCardDetailResponse, CustomWorldAgentMessageResponse, CustomWorldAgentOperationResponse, CustomWorldAgentSessionResponse, CustomWorldAgentSessionSnapshotResponse, + CustomWorldDraftCardDetailResponse, CustomWorldDraftCardDetailSectionResponse, CustomWorldDraftCardSummaryResponse, CustomWorldGalleryCardResponse, CustomWorldGalleryDetailResponse, CustomWorldGalleryResponse, CustomWorldLibraryEntryResponse, CustomWorldLibraryMutationResponse, CustomWorldLibraryResponse, CustomWorldProfileUpsertRequest, CustomWorldSupportedActionResponse, - SendCustomWorldAgentMessageRequest, + CustomWorldPublishGateResponse, CustomWorldResultPreviewBlockerResponse, + CustomWorldWorkSummaryResponse, CustomWorldWorksResponse, + ExecuteCustomWorldAgentActionRequest, SendCustomWorldAgentMessageRequest, }; use shared_kernel::build_prefixed_uuid_id; use spacetime_client::{ + CustomWorldAgentActionExecuteRecordInput, CustomWorldAgentCheckpointRecord, CustomWorldAgentMessageRecord, CustomWorldAgentMessageSubmitRecordInput, CustomWorldAgentOperationRecord, CustomWorldAgentSessionCreateRecordInput, CustomWorldAgentSessionRecord, + CustomWorldDraftCardDetailRecord, CustomWorldDraftCardDetailSectionRecord, CustomWorldDraftCardRecord, CustomWorldGalleryEntryRecord, CustomWorldLibraryEntryRecord, - CustomWorldProfileUpsertRecordInput, CustomWorldPublishWorldRecordInput, + CustomWorldProfileUpsertRecordInput, CustomWorldPublishGateRecord, + CustomWorldResultPreviewBlockerRecord, CustomWorldWorkSummaryRecord, CustomWorldSupportedActionRecord, SpacetimeClientError, }; @@ -386,6 +393,66 @@ pub async fn get_custom_world_agent_session( )) } +pub async fn get_custom_world_works( + State(state): State, + Extension(request_context): Extension, + Extension(authenticated): Extension, +) -> Result, Response> { + let items = state + .spacetime_client() + .list_custom_world_works(authenticated.claims().user_id().to_string()) + .await + .map_err(|error| { + custom_world_error_response(&request_context, map_custom_world_client_error(error)) + })?; + + Ok(json_success_body( + Some(&request_context), + CustomWorldWorksResponse { + items: items + .into_iter() + .map(map_custom_world_work_summary_response) + .collect(), + }, + )) +} + +pub async fn get_custom_world_agent_card_detail( + State(state): State, + Path((session_id, card_id)): Path<(String, String)>, + Extension(request_context): Extension, + Extension(authenticated): Extension, +) -> Result, Response> { + if session_id.trim().is_empty() || card_id.trim().is_empty() { + return Err(custom_world_error_response( + &request_context, + AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ + "provider": "custom-world-agent", + "message": "sessionId and cardId are required", + })), + )); + } + + let card = state + .spacetime_client() + .get_custom_world_agent_card_detail( + session_id, + authenticated.claims().user_id().to_string(), + card_id, + ) + .await + .map_err(|error| { + custom_world_error_response(&request_context, map_custom_world_client_error(error)) + })?; + + Ok(json_success_body( + Some(&request_context), + CustomWorldAgentCardDetailResponse { + card: map_custom_world_draft_card_detail_response(card), + }, + )) +} + pub async fn submit_custom_world_agent_message( State(state): State, Path(session_id): Path, @@ -569,7 +636,7 @@ pub async fn execute_custom_world_agent_action( Path(session_id): Path, Extension(request_context): Extension, Extension(authenticated): Extension, - payload: Result, JsonRejection>, + payload: Result, JsonRejection>, ) -> Result, Response> { let Json(payload) = payload.map_err(|error| { custom_world_error_response( @@ -581,84 +648,46 @@ pub async fn execute_custom_world_agent_action( ) })?; - let action = payload - .get("action") - .and_then(Value::as_str) - .map(str::trim) - .unwrap_or_default(); - if action != "publish_world" { + if session_id.trim().is_empty() { return Err(custom_world_error_response( &request_context, - AppError::from_status(StatusCode::NOT_IMPLEMENTED).with_details(json!({ + AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ "provider": "custom-world-agent", - "message": "当前 Stage 5 仅支持 publish_world action", + "message": "sessionId is required", })), )); } - let profile_id = payload - .get("profileId") - .and_then(Value::as_str) - .map(str::trim) - .filter(|value| !value.is_empty()) - .map(ToOwned::to_owned) - .unwrap_or_else(|| format!("agent-draft-{session_id}")); - let draft_profile = payload.get("draftProfile").cloned().ok_or_else(|| { + let action = payload.action.trim().to_string(); + if action.is_empty() { + return Err(custom_world_error_response( + &request_context, + AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ + "provider": "custom-world-agent", + "message": "action is required", + })), + )); + } + + let payload_json = serde_json::to_string(&payload).map_err(|error| { custom_world_error_response( &request_context, AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ "provider": "custom-world-agent", - "message": "publish_world 当前必须显式提供 draftProfile", + "message": format!("action payload JSON 序列化失败:{error}"), })), ) })?; - let setting_text = payload - .get("settingText") - .and_then(Value::as_str) - .map(str::trim) - .filter(|value| !value.is_empty()) - .map(ToOwned::to_owned) - .ok_or_else(|| { - custom_world_error_response( - &request_context, - AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ - "provider": "custom-world-agent", - "message": "publish_world 当前必须显式提供 settingText", - })), - ) - })?; - let publish_result = state + let result = state .spacetime_client() - .publish_custom_world_world(CustomWorldPublishWorldRecordInput { - session_id: session_id.clone(), - profile_id, + .execute_custom_world_agent_action(CustomWorldAgentActionExecuteRecordInput { + session_id, owner_user_id: authenticated.claims().user_id().to_string(), - draft_profile_json: serde_json::to_string(&draft_profile).map_err(|error| { - custom_world_error_response( - &request_context, - AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ - "provider": "custom-world-agent", - "message": format!("draftProfile JSON 序列化失败:{error}"), - })), - ) - })?, - legacy_result_profile_json: payload - .get("legacyResultProfile") - .map(serde_json::to_string) - .transpose() - .map_err(|error| { - custom_world_error_response( - &request_context, - AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ - "provider": "custom-world-agent", - "message": format!("legacyResultProfile JSON 序列化失败:{error}"), - })), - ) - })?, - setting_text, - author_display_name: resolve_author_display_name(&authenticated), - published_at_micros: current_utc_micros(), + operation_id: build_prefixed_uuid_id("operation-"), + action, + payload_json: Some(payload_json), + submitted_at_micros: current_utc_micros(), }) .await .map_err(|error| { @@ -668,15 +697,7 @@ pub async fn execute_custom_world_agent_action( Ok(json_success_body( Some(&request_context), json!({ - "operation": { - "operationId": format!("publish-world-{session_id}"), - "type": "publish_world", - "status": "completed", - "phaseLabel": "世界已发布", - "phaseDetail": format!("正式世界档案已写入作品库:{}。", publish_result.entry.profile_id), - "progress": 100, - "error": Value::Null, - } + "operation": map_custom_world_agent_operation_response(result.operation), }), )) } @@ -722,6 +743,37 @@ fn map_custom_world_gallery_card_response( } } +fn map_custom_world_work_summary_response( + item: CustomWorldWorkSummaryRecord, +) -> CustomWorldWorkSummaryResponse { + CustomWorldWorkSummaryResponse { + work_id: item.work_id, + source_type: item.source_type, + status: item.status, + title: item.title, + subtitle: item.subtitle, + summary: item.summary, + cover_image_src: item.cover_image_src, + cover_render_mode: item.cover_render_mode, + cover_character_image_srcs: item.cover_character_image_srcs, + updated_at: item.updated_at, + published_at: item.published_at, + stage: item.stage, + stage_label: item.stage_label, + playable_npc_count: item.playable_npc_count, + landmark_count: item.landmark_count, + role_visual_ready_count: item.role_visual_ready_count, + role_animation_ready_count: item.role_animation_ready_count, + role_asset_summary_label: item.role_asset_summary_label, + session_id: item.session_id, + profile_id: item.profile_id, + can_resume: item.can_resume, + can_enter_world: item.can_enter_world, + blocker_count: item.blocker_count, + publish_ready: item.publish_ready, + } +} + fn map_custom_world_agent_session_response( session: CustomWorldAgentSessionRecord, ) -> CustomWorldAgentSessionSnapshotResponse { @@ -763,11 +815,28 @@ fn map_custom_world_agent_session_response( .into_iter() .map(map_custom_world_supported_action_response) .collect(), + publish_gate: session.publish_gate.map(map_custom_world_publish_gate_response), result_preview: session.result_preview, updated_at: session.updated_at, } } +fn map_custom_world_publish_gate_response( + gate: CustomWorldPublishGateRecord, +) -> CustomWorldPublishGateResponse { + CustomWorldPublishGateResponse { + profile_id: gate.profile_id, + blockers: gate + .blockers + .into_iter() + .map(map_custom_world_result_preview_blocker_response) + .collect(), + blocker_count: gate.blocker_count, + publish_ready: gate.publish_ready, + can_enter_world: gate.can_enter_world, + } +} + fn map_custom_world_agent_message_response( message: CustomWorldAgentMessageRecord, ) -> CustomWorldAgentMessageResponse { @@ -812,6 +881,38 @@ fn map_custom_world_draft_card_response( } } +fn map_custom_world_draft_card_detail_response( + card: CustomWorldDraftCardDetailRecord, +) -> CustomWorldDraftCardDetailResponse { + CustomWorldDraftCardDetailResponse { + id: card.card_id, + kind: card.kind, + title: card.title, + sections: card + .sections + .into_iter() + .map(map_custom_world_draft_card_detail_section_response) + .collect(), + linked_ids: card.linked_ids, + locked: card.locked, + editable: card.editable, + editable_section_ids: card.editable_section_ids, + warning_messages: card.warning_messages, + asset_status: card.asset_status, + asset_status_label: card.asset_status_label, + } +} + +fn map_custom_world_draft_card_detail_section_response( + section: CustomWorldDraftCardDetailSectionRecord, +) -> CustomWorldDraftCardDetailSectionResponse { + CustomWorldDraftCardDetailSectionResponse { + id: section.section_id, + label: section.label, + value: section.value, + } +} + fn map_custom_world_agent_checkpoint_response( checkpoint: CustomWorldAgentCheckpointRecord, ) -> CustomWorldAgentCheckpointResponse { @@ -832,6 +933,16 @@ fn map_custom_world_supported_action_response( } } +fn map_custom_world_result_preview_blocker_response( + blocker: CustomWorldResultPreviewBlockerRecord, +) -> CustomWorldResultPreviewBlockerResponse { + CustomWorldResultPreviewBlockerResponse { + id: blocker.id, + code: blocker.code, + message: blocker.message, + } +} + fn resolve_stream_reply_text(session: &CustomWorldAgentSessionSnapshotResponse) -> String { session .last_assistant_reply diff --git a/server-rs/crates/api-server/src/custom_world_ai.rs b/server-rs/crates/api-server/src/custom_world_ai.rs new file mode 100644 index 00000000..5215488a --- /dev/null +++ b/server-rs/crates/api-server/src/custom_world_ai.rs @@ -0,0 +1,636 @@ +use std::{ + fs, + path::{Path, PathBuf}, +}; + +use axum::{ + Json, + extract::{Extension, State, rejection::JsonRejection}, + http::StatusCode, + response::Response, +}; +use platform_llm::{LlmMessage, LlmTextRequest}; +use serde::{Deserialize, Serialize}; +use serde_json::{Map, Value, json}; + +use crate::{ + api_response::json_success_body, auth::AuthenticatedAccessToken, http_error::AppError, + request_context::RequestContext, state::AppState, +}; + +#[derive(Clone, Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct CustomWorldEntityRequest { + profile: Value, + kind: String, +} + +#[derive(Clone, Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct CustomWorldSceneNpcRequest { + profile: Value, + landmark_id: String, +} + +#[derive(Clone, Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct CustomWorldSceneImageRequest { + #[serde(default)] + profile_id: Option, + #[serde(default)] + world_name: Option, + #[serde(default)] + landmark_id: Option, + #[serde(default)] + landmark_name: Option, + #[serde(default)] + prompt: Option, + #[serde(default)] + size: Option, +} + +#[derive(Clone, Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct CustomWorldCoverImageRequest { + profile: Value, + #[serde(default)] + user_prompt: Option, + #[serde(default)] + size: Option, +} + +#[derive(Clone, Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct CustomWorldCoverUploadRequest { + #[serde(default)] + profile_id: Option, + #[serde(default)] + world_name: Option, + image_data_url: String, +} + +#[derive(Clone, Debug, Serialize)] +#[serde(rename_all = "camelCase")] +struct GeneratedAssetResponse { + image_src: String, + asset_id: String, + source_type: String, + #[serde(skip_serializing_if = "Option::is_none")] + model: Option, + #[serde(skip_serializing_if = "Option::is_none")] + size: Option, + #[serde(skip_serializing_if = "Option::is_none")] + task_id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + prompt: Option, + #[serde(skip_serializing_if = "Option::is_none")] + actual_prompt: Option, +} + +pub async fn generate_custom_world_entity( + State(state): State, + Extension(request_context): Extension, + Extension(_authenticated): Extension, + payload: Result, JsonRejection>, +) -> Result, Response> { + let Json(payload) = payload.map_err(|error| { + custom_world_ai_error_response( + &request_context, + AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ + "provider": "custom-world-ai", + "message": error.body_text(), + })), + ) + })?; + + let kind = payload.kind.trim(); + if !matches!(kind, "playable" | "story" | "landmark") { + return Err(custom_world_ai_error_response( + &request_context, + AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ + "provider": "custom-world-ai", + "message": "kind 必须是 playable、story 或 landmark", + })), + )); + } + + let entity = generate_entity_with_fallback(&state, &payload.profile, kind).await; + + Ok(json_success_body( + Some(&request_context), + json!({ + "kind": kind, + "entity": entity, + }), + )) +} + +pub async fn generate_custom_world_scene_npc( + State(state): State, + Extension(request_context): Extension, + Extension(_authenticated): Extension, + payload: Result, JsonRejection>, +) -> Result, Response> { + let Json(payload) = payload.map_err(|error| { + custom_world_ai_error_response( + &request_context, + AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ + "provider": "custom-world-ai", + "message": error.body_text(), + })), + ) + })?; + + let landmark_id = payload.landmark_id.trim(); + if landmark_id.is_empty() { + return Err(custom_world_ai_error_response( + &request_context, + AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ + "provider": "custom-world-ai", + "message": "landmarkId is required", + })), + )); + } + + let npc = generate_scene_npc_with_fallback(&state, &payload.profile, landmark_id).await; + Ok(json_success_body( + Some(&request_context), + json!({ "npc": npc }), + )) +} + +pub async fn generate_custom_world_scene_image( + Extension(request_context): Extension, + Extension(_authenticated): Extension, + payload: Result, JsonRejection>, +) -> Result, Response> { + let Json(payload) = payload.map_err(|error| { + custom_world_ai_error_response( + &request_context, + AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ + "provider": "custom-world-ai", + "message": error.body_text(), + })), + ) + })?; + + let asset = save_placeholder_asset( + "generated-custom-world-scenes", + payload + .profile_id + .as_deref() + .or(payload.world_name.as_deref()) + .unwrap_or("world"), + payload + .landmark_id + .as_deref() + .or(payload.landmark_name.as_deref()) + .unwrap_or("scene"), + "scene", + payload.size.as_deref().unwrap_or("1280*720"), + payload.prompt.as_deref(), + ) + .map_err(|error| custom_world_ai_error_response(&request_context, error))?; + + Ok(json_success_body(Some(&request_context), asset)) +} + +pub async fn generate_custom_world_cover_image( + Extension(request_context): Extension, + Extension(_authenticated): Extension, + payload: Result, JsonRejection>, +) -> Result, Response> { + let Json(payload) = payload.map_err(|error| { + custom_world_ai_error_response( + &request_context, + AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ + "provider": "custom-world-ai", + "message": error.body_text(), + })), + ) + })?; + + let profile = payload.profile.as_object().cloned().unwrap_or_default(); + let world_name = read_string_field(&profile, "name").unwrap_or_else(|| "world".to_string()); + let asset = save_placeholder_asset( + "generated-custom-world-covers", + &read_string_field(&profile, "id").unwrap_or_else(|| world_name.clone()), + "cover", + "cover", + payload.size.as_deref().unwrap_or("1600*900"), + payload.user_prompt.as_deref(), + ) + .map_err(|error| custom_world_ai_error_response(&request_context, error))?; + + Ok(json_success_body(Some(&request_context), asset)) +} + +pub async fn upload_custom_world_cover_image( + Extension(request_context): Extension, + Extension(_authenticated): Extension, + payload: Result, JsonRejection>, +) -> Result, Response> { + let Json(payload) = payload.map_err(|error| { + custom_world_ai_error_response( + &request_context, + AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ + "provider": "custom-world-ai", + "message": error.body_text(), + })), + ) + })?; + + let parsed = parse_image_data_url(payload.image_data_url.trim()).ok_or_else(|| { + custom_world_ai_error_response( + &request_context, + AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ + "provider": "custom-world-ai", + "message": "imageDataUrl 必须是有效的图片 Data URL", + })), + ) + })?; + let asset_id = format!("custom-cover-upload-{}", current_utc_millis()); + let world_segment = sanitize_path_segment( + payload + .profile_id + .as_deref() + .or(payload.world_name.as_deref()) + .unwrap_or("world"), + "world", + ); + let relative_dir = PathBuf::from("generated-custom-world-covers") + .join(world_segment) + .join(&asset_id); + let output_dir = resolve_public_output_dir(&relative_dir) + .map_err(|error| custom_world_ai_error_response(&request_context, error))?; + fs::create_dir_all(&output_dir) + .map_err(io_error) + .map_err(|error| custom_world_ai_error_response(&request_context, error))?; + let file_name = match parsed.mime_type.as_str() { + "image/png" => "cover.png", + "image/webp" => "cover.webp", + _ => "cover.jpg", + }; + fs::write(output_dir.join(file_name), parsed.bytes) + .map_err(io_error) + .map_err(|error| custom_world_ai_error_response(&request_context, error))?; + let image_src = format!( + "/{}/{}", + relative_dir.to_string_lossy().replace('\\', "/"), + file_name + ); + + Ok(json_success_body( + Some(&request_context), + GeneratedAssetResponse { + image_src, + asset_id, + source_type: "uploaded".to_string(), + model: None, + size: None, + task_id: None, + prompt: None, + actual_prompt: None, + }, + )) +} + +async fn generate_entity_with_fallback( + state: &AppState, + profile: &Value, + kind: &str, +) -> Value { + let fallback = build_entity_fallback(profile, kind); + let Some(llm_client) = state.llm_client() else { + return fallback; + }; + let request = LlmTextRequest::new(vec![ + LlmMessage::system( + "你是 RPG 自定义世界实体生成器。只输出一个 JSON 对象,不要输出 Markdown。", + ), + LlmMessage::user( + json!({ + "task": "generate_custom_world_entity", + "kind": kind, + "profile": profile, + "fallback": fallback, + }) + .to_string(), + ), + ]); + + llm_client + .request_text(request) + .await + .ok() + .and_then(|response| serde_json::from_str::(response.content.trim()).ok()) + .unwrap_or(fallback) +} + +async fn generate_scene_npc_with_fallback( + state: &AppState, + profile: &Value, + landmark_id: &str, +) -> Value { + let fallback = build_scene_npc_fallback(profile, landmark_id); + let Some(llm_client) = state.llm_client() else { + return fallback; + }; + let request = LlmTextRequest::new(vec![ + LlmMessage::system( + "你是 RPG 自定义世界场景 NPC 生成器。只输出一个 JSON 对象,不要输出 Markdown。", + ), + LlmMessage::user( + json!({ + "task": "generate_custom_world_scene_npc", + "landmarkId": landmark_id, + "profile": profile, + "fallback": fallback, + }) + .to_string(), + ), + ]); + + llm_client + .request_text(request) + .await + .ok() + .and_then(|response| serde_json::from_str::(response.content.trim()).ok()) + .unwrap_or(fallback) +} + +fn build_entity_fallback(profile: &Value, kind: &str) -> Value { + let object = profile.as_object().cloned().unwrap_or_default(); + let world_name = read_string_field(&object, "name").unwrap_or_else(|| "自定义世界".to_string()); + match kind { + "playable" => build_role_fallback("playable", "新同行者", &world_name, 18), + "story" => build_role_fallback("story", "新场景角色", &world_name, 6), + "landmark" => build_landmark_fallback(&world_name), + _ => json!({}), + } +} + +fn build_scene_npc_fallback(profile: &Value, landmark_id: &str) -> Value { + let object = profile.as_object().cloned().unwrap_or_default(); + let world_name = read_string_field(&object, "name").unwrap_or_else(|| "自定义世界".to_string()); + let landmark_name = object + .get("landmarks") + .and_then(Value::as_array) + .and_then(|entries| { + entries.iter().find_map(|entry| { + let object = entry.as_object()?; + (read_string_field(object, "id").as_deref() == Some(landmark_id)) + .then(|| read_string_field(object, "name")) + .flatten() + }) + }) + .unwrap_or_else(|| "当前场景".to_string()); + let mut npc = build_role_fallback("story", &format!("{landmark_name}来客"), &world_name, 6); + if let Some(object) = npc.as_object_mut() { + object.insert( + "description".to_string(), + Value::String(format!("长期活动于{landmark_name},熟悉这里的局势与暗线。")), + ); + } + npc +} + +fn build_role_fallback(prefix: &str, name: &str, world_name: &str, affinity: i64) -> Value { + let suffix = current_utc_millis(); + json!({ + "id": format!("{prefix}-{}", suffix), + "name": name, + "title": "关键角色", + "role": "关键角色", + "description": format!("围绕《{world_name}》当前主线冲突生成的新增角色。"), + "backstory": format!("他与《{world_name}》正在展开的局势存在直接牵连。"), + "personality": "谨慎、敏锐,先观察再表态。", + "motivation": "希望借玩家的介入改变当前失衡局面。", + "combatStyle": "偏向试探与控场。", + "initialAffinity": affinity, + "relationshipHooks": ["与玩家保持试探", "掌握局势暗线"], + "relations": [], + "tags": ["自定义", "生成"], + "backstoryReveal": { + "publicSummary": "一个掌握部分旧线索的关键角色。", + "chapters": [ + { "id": "surface", "title": "表层来意", "affinityRequired": 6, "teaser": "他知道这里正在发生什么。", "content": "他一直在观察这片区域的变化。", "contextSnippet": "" }, + { "id": "scar", "title": "旧事裂痕", "affinityRequired": 12, "teaser": "他与旧案有直接关联。", "content": "过往的一次事件把他绑定在这条线里。", "contextSnippet": "" }, + { "id": "hidden", "title": "隐藏执念", "affinityRequired": 18, "teaser": "他真正想推动的局面还没说出口。", "content": "他一直在寻找能撬动局面的机会。", "contextSnippet": "" }, + { "id": "final", "title": "最终底牌", "affinityRequired": 24, "teaser": "他手里还压着一张底牌。", "content": "一旦局势逼近临界点,他会出手。", "contextSnippet": "" } + ] + }, + "skills": [ + { "id": format!("skill-{}-1", suffix), "name": "试探起手", "summary": "先判断局势与对手意图。", "style": "试探压制" }, + { "id": format!("skill-{}-2", suffix), "name": "借势压场", "summary": "利用环境为自己制造主动权。", "style": "环境协同" }, + { "id": format!("skill-{}-3", suffix), "name": "暗线反制", "summary": "在关键节点打乱对方节奏。", "style": "后手翻盘" } + ], + "initialItems": [ + { "id": format!("item-{}-1", suffix), "name": "随身兵装", "category": "武器", "quantity": 1, "rarity": "rare", "description": "常备的近身装备。", "tags": ["自定义"] }, + { "id": format!("item-{}-2", suffix), "name": "私人物件", "category": "道具", "quantity": 1, "rarity": "uncommon", "description": "可在关键时刻调用的人情或凭证。", "tags": ["自定义"] }, + { "id": format!("item-{}-3", suffix), "name": "线索残页", "category": "专属物品", "quantity": 1, "rarity": "rare", "description": "记录部分隐藏线索。", "tags": ["线索"] } + ] + }) +} + +fn build_landmark_fallback(world_name: &str) -> Value { + let suffix = current_utc_millis(); + json!({ + "id": format!("landmark-{}", suffix), + "name": "新场景", + "description": format!("围绕《{world_name}》当前主线冲突扩展出的关键场景。"), + "visualDescription": "低照度、层次复杂、带有明显环境叙事痕迹。", + "dangerLevel": "medium", + "sceneNpcIds": [], + "connections": [], + "narrativeResidues": [], + }) +} + +fn save_placeholder_asset( + root_segment: &str, + world_segment_seed: &str, + leaf_segment_seed: &str, + file_stem: &str, + size: &str, + prompt: Option<&str>, +) -> Result { + let asset_id = format!("{file_stem}-{}", current_utc_millis()); + let relative_dir = PathBuf::from(root_segment) + .join(sanitize_path_segment(world_segment_seed, "world")) + .join(sanitize_path_segment(leaf_segment_seed, file_stem)) + .join(&asset_id); + let output_dir = resolve_public_output_dir(&relative_dir)?; + fs::create_dir_all(&output_dir).map_err(io_error)?; + let file_name = format!("{file_stem}.svg"); + let svg = build_placeholder_svg(size, prompt.unwrap_or(file_stem)); + fs::write(output_dir.join(&file_name), svg).map_err(io_error)?; + + Ok(GeneratedAssetResponse { + image_src: format!("/{}/{}", relative_dir.to_string_lossy().replace('\\', "/"), file_name), + asset_id: asset_id.clone(), + source_type: "generated".to_string(), + model: Some("rust-placeholder".to_string()), + size: Some(size.to_string()), + task_id: Some(asset_id), + prompt: prompt.map(ToOwned::to_owned), + actual_prompt: prompt.map(ToOwned::to_owned), + }) +} + +fn build_placeholder_svg(size: &str, label: &str) -> String { + let (width, height) = parse_size(size); + format!( + r##" + + + + + + + + + + +{title} +Rust fallback asset +"##, + width = width, + height = height, + cx1 = width / 3, + cy1 = height / 3, + r1 = (width.min(height) / 7).max(24), + cx2 = width * 3 / 4, + cy2 = height / 4, + r2 = (width.min(height) / 9).max(18), + font_main = (width.min(height) / 12).max(20), + font_sub = (width.min(height) / 24).max(12), + title = escape_svg_text(label), + ) +} + +fn parse_size(size: &str) -> (u32, u32) { + let mut parts = size.split('*'); + let width = parts + .next() + .and_then(|value| value.trim().parse::().ok()) + .filter(|value| *value > 0) + .unwrap_or(1280); + let height = parts + .next() + .and_then(|value| value.trim().parse::().ok()) + .filter(|value| *value > 0) + .unwrap_or(720); + (width, height) +} + +fn escape_svg_text(value: &str) -> String { + value + .replace('&', "&") + .replace('<', "<") + .replace('>', ">") +} + +fn sanitize_path_segment(value: &str, fallback: &str) -> String { + let sanitized = value + .trim() + .chars() + .map(|ch| { + if ch.is_ascii_alphanumeric() || ('\u{4e00}'..='\u{9fff}').contains(&ch) { + ch + } else { + '-' + } + }) + .collect::() + .trim_matches('-') + .to_string(); + if sanitized.is_empty() { + fallback.to_string() + } else { + sanitized + } +} + +fn resolve_public_output_dir(relative_dir: &Path) -> Result { + let workspace_root = Path::new(env!("CARGO_MANIFEST_DIR")) + .ancestors() + .nth(3) + .ok_or_else(|| { + AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR) + .with_message("无法解析仓库根目录") + })?; + Ok(workspace_root.join("public").join(relative_dir)) +} + +fn parse_image_data_url(value: &str) -> Option { + let prefix = "data:"; + let separator = ";base64,"; + let body = value.strip_prefix(prefix)?; + let (mime_type, data) = body.split_once(separator)?; + let bytes = decode_base64(data)?; + Some(ParsedImageDataUrl { + mime_type: mime_type.to_string(), + bytes, + }) +} + +fn decode_base64(value: &str) -> Option> { + let cleaned = value.trim().replace(char::is_whitespace, ""); + let mut output = Vec::with_capacity(cleaned.len() * 3 / 4); + let mut buffer = 0u32; + let mut bits = 0u8; + + for byte in cleaned.bytes() { + let value = match byte { + b'A'..=b'Z' => byte - b'A', + b'a'..=b'z' => byte - b'a' + 26, + b'0'..=b'9' => byte - b'0' + 52, + b'+' => 62, + b'/' => 63, + b'=' => break, + _ => return None, + } as u32; + buffer = (buffer << 6) | value; + bits += 6; + while bits >= 8 { + bits -= 8; + output.push(((buffer >> bits) & 0xFF) as u8); + } + } + + Some(output) +} + +fn read_string_field(object: &Map, key: &str) -> Option { + object + .get(key) + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(ToOwned::to_owned) +} + +fn current_utc_millis() -> i64 { + use std::time::{SystemTime, UNIX_EPOCH}; + let duration = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("system time should be after unix epoch"); + i64::try_from(duration.as_millis()).expect("current unix millis should fit in i64") +} + +fn io_error(error: std::io::Error) -> AppError { + AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_details(json!({ + "provider": "custom-world-ai", + "message": format!("文件写入失败:{error}"), + })) +} + +fn custom_world_ai_error_response(request_context: &RequestContext, error: AppError) -> Response { + error.into_response_with_context(Some(request_context)) +} + +struct ParsedImageDataUrl { + mime_type: String, + bytes: Vec, +} diff --git a/server-rs/crates/api-server/src/main.rs b/server-rs/crates/api-server/src/main.rs index 2979773f..f40f0446 100644 --- a/server-rs/crates/api-server/src/main.rs +++ b/server-rs/crates/api-server/src/main.rs @@ -8,6 +8,7 @@ mod auth_session; mod auth_sessions; mod config; mod custom_world; +mod custom_world_ai; mod error_middleware; mod health; mod http_error; diff --git a/server-rs/crates/spacetime-client/src/lib.rs b/server-rs/crates/spacetime-client/src/lib.rs index deb2076e..30104a77 100644 --- a/server-rs/crates/spacetime-client/src/lib.rs +++ b/server-rs/crates/spacetime-client/src/lib.rs @@ -106,6 +106,9 @@ use crate::module_bindings::{ BattleStateSnapshot as BindingBattleStateSnapshot, BattleStatus as BindingBattleStatus, CombatOutcome as BindingCombatOutcome, CustomWorldAgentMessageSnapshot as BindingCustomWorldAgentMessageSnapshot, + CustomWorldAgentActionExecuteInput as BindingCustomWorldAgentActionExecuteInput, + CustomWorldAgentActionExecuteResult as BindingCustomWorldAgentActionExecuteResult, + CustomWorldAgentCardDetailGetInput as BindingCustomWorldAgentCardDetailGetInput, CustomWorldAgentMessageSubmitInput as BindingCustomWorldAgentMessageSubmitInput, CustomWorldAgentOperationGetInput as BindingCustomWorldAgentOperationGetInput, CustomWorldAgentOperationProcedureResult as BindingCustomWorldAgentOperationProcedureResult, @@ -114,6 +117,9 @@ use crate::module_bindings::{ CustomWorldAgentSessionGetInput as BindingCustomWorldAgentSessionGetInput, CustomWorldAgentSessionProcedureResult as BindingCustomWorldAgentSessionProcedureResult, CustomWorldAgentSessionSnapshot as BindingCustomWorldAgentSessionSnapshot, + CustomWorldDraftCardDetailResult as BindingCustomWorldDraftCardDetailResult, + CustomWorldDraftCardDetailSectionSnapshot as BindingCustomWorldDraftCardDetailSectionSnapshot, + CustomWorldDraftCardDetailSnapshot as BindingCustomWorldDraftCardDetailSnapshot, CustomWorldDraftCardSnapshot as BindingCustomWorldDraftCardSnapshot, CustomWorldGalleryDetailInput as BindingCustomWorldGalleryDetailInput, CustomWorldGalleryEntrySnapshot as BindingCustomWorldGalleryEntrySnapshot, @@ -130,6 +136,9 @@ use crate::module_bindings::{ CustomWorldPublishWorldInput as BindingCustomWorldPublishWorldInput, CustomWorldPublishWorldResult as BindingCustomWorldPublishWorldResult, CustomWorldPublishedProfileCompileSnapshot as BindingCustomWorldPublishedProfileCompileSnapshot, + CustomWorldWorkSummarySnapshot as BindingCustomWorldWorkSummarySnapshot, + CustomWorldWorksListInput as BindingCustomWorldWorksListInput, + CustomWorldWorksListResult as BindingCustomWorldWorksListResult, CustomWorldThemeMode as BindingCustomWorldThemeMode, DbConnection, InventoryContainerKind as BindingInventoryContainerKind, InventoryEquipmentSlot as BindingInventoryEquipmentSlot, @@ -207,8 +216,10 @@ use crate::module_bindings::{ create_battle_state_and_return_procedure::create_battle_state_and_return as _, create_custom_world_agent_session_procedure::create_custom_world_agent_session as _, delete_runtime_snapshot_and_return_procedure::delete_runtime_snapshot_and_return as _, + execute_custom_world_agent_action_procedure::execute_custom_world_agent_action as _, fail_ai_task_and_return_procedure::fail_ai_task_and_return as _, get_battle_state_procedure::get_battle_state as _, + get_custom_world_agent_card_detail_procedure::get_custom_world_agent_card_detail as _, get_custom_world_agent_operation_procedure::get_custom_world_agent_operation as _, get_custom_world_agent_session_procedure::get_custom_world_agent_session as _, get_custom_world_gallery_detail_procedure::get_custom_world_gallery_detail as _, @@ -221,6 +232,7 @@ use crate::module_bindings::{ get_story_session_state_procedure::get_story_session_state as _, list_custom_world_gallery_entries_procedure::list_custom_world_gallery_entries as _, list_custom_world_profiles_procedure::list_custom_world_profiles as _, + list_custom_world_works_procedure::list_custom_world_works as _, list_platform_browse_history_procedure::list_platform_browse_history as _, list_profile_save_archives_procedure::list_profile_save_archives as _, list_profile_wallet_ledger_procedure::list_profile_wallet_ledger as _, @@ -764,6 +776,76 @@ impl SpacetimeClient { .await } + pub async fn list_custom_world_works( + &self, + owner_user_id: String, + ) -> Result, SpacetimeClientError> { + let procedure_input = BindingCustomWorldWorksListInput { owner_user_id }; + + self.call_after_connect(move |connection, sender| { + connection + .procedures() + .list_custom_world_works_then(procedure_input, move |_, result| { + let mapped = result + .map_err(|error| SpacetimeClientError::Procedure(error.to_string())) + .and_then(map_custom_world_works_list_result); + send_once(&sender, mapped); + }); + }) + .await + } + + pub async fn get_custom_world_agent_card_detail( + &self, + session_id: String, + owner_user_id: String, + card_id: String, + ) -> Result { + let procedure_input = BindingCustomWorldAgentCardDetailGetInput { + session_id, + owner_user_id, + card_id, + }; + + self.call_after_connect(move |connection, sender| { + connection + .procedures() + .get_custom_world_agent_card_detail_then(procedure_input, move |_, result| { + let mapped = result + .map_err(|error| SpacetimeClientError::Procedure(error.to_string())) + .and_then(map_custom_world_draft_card_detail_result); + send_once(&sender, mapped); + }); + }) + .await + } + + pub async fn execute_custom_world_agent_action( + &self, + input: CustomWorldAgentActionExecuteRecordInput, + ) -> Result { + let procedure_input = BindingCustomWorldAgentActionExecuteInput { + session_id: input.session_id, + owner_user_id: input.owner_user_id, + operation_id: input.operation_id, + action: input.action, + payload_json: input.payload_json, + submitted_at_micros: input.submitted_at_micros, + }; + + self.call_after_connect(move |connection, sender| { + connection + .procedures() + .execute_custom_world_agent_action_then(procedure_input, move |_, result| { + let mapped = result + .map_err(|error| SpacetimeClientError::Procedure(error.to_string())) + .and_then(map_custom_world_agent_action_execute_result); + send_once(&sender, mapped); + }); + }) + .await + } + pub async fn submit_custom_world_agent_message( &self, input: CustomWorldAgentMessageSubmitRecordInput, @@ -2259,6 +2341,66 @@ fn map_custom_world_agent_operation_procedure_result( Ok(map_custom_world_agent_operation_snapshot(operation)) } +fn map_custom_world_works_list_result( + result: BindingCustomWorldWorksListResult, +) -> Result, SpacetimeClientError> { + if !result.ok { + return Err(SpacetimeClientError::Procedure( + result + .error_message + .unwrap_or_else(|| "SpacetimeDB procedure 返回未知错误".to_string()), + )); + } + + result + .items + .into_iter() + .map(map_custom_world_work_summary_snapshot) + .collect() +} + +fn map_custom_world_draft_card_detail_result( + result: BindingCustomWorldDraftCardDetailResult, +) -> Result { + if !result.ok { + return Err(SpacetimeClientError::Procedure( + result + .error_message + .unwrap_or_else(|| "SpacetimeDB procedure 返回未知错误".to_string()), + )); + } + + let card = result.card.ok_or_else(|| { + SpacetimeClientError::Procedure( + "SpacetimeDB procedure 未返回 custom world card detail 快照".to_string(), + ) + })?; + + map_custom_world_draft_card_detail_snapshot(card) +} + +fn map_custom_world_agent_action_execute_result( + result: BindingCustomWorldAgentActionExecuteResult, +) -> Result { + if !result.ok { + return Err(SpacetimeClientError::Procedure( + result + .error_message + .unwrap_or_else(|| "SpacetimeDB procedure 返回未知错误".to_string()), + )); + } + + let operation = result.operation.ok_or_else(|| { + SpacetimeClientError::Procedure( + "SpacetimeDB procedure 未返回 custom world action operation 快照".to_string(), + ) + })?; + + Ok(CustomWorldAgentActionExecuteRecord { + operation: map_custom_world_agent_operation_snapshot(operation), + }) +} + fn map_story_session_procedure_result( result: BindingStorySessionProcedureResult, ) -> Result { @@ -2647,6 +2789,40 @@ fn map_custom_world_published_profile_compile_snapshot( }) } +fn map_custom_world_work_summary_snapshot( + snapshot: BindingCustomWorldWorkSummarySnapshot, +) -> Result { + Ok(CustomWorldWorkSummaryRecord { + work_id: snapshot.work_id, + source_type: snapshot.source_type, + status: snapshot.status, + title: snapshot.title, + subtitle: snapshot.subtitle, + summary: snapshot.summary, + cover_image_src: snapshot.cover_image_src, + cover_render_mode: snapshot.cover_render_mode, + cover_character_image_srcs: parse_json_string_array( + &snapshot.cover_character_image_srcs_json, + "custom world work cover_character_image_srcs_json", + )?, + updated_at: format_timestamp_micros(snapshot.updated_at_micros), + published_at: snapshot.published_at_micros.map(format_timestamp_micros), + stage: snapshot.stage.map(map_rpg_agent_stage), + stage_label: snapshot.stage_label, + playable_npc_count: snapshot.playable_npc_count, + landmark_count: snapshot.landmark_count, + role_visual_ready_count: snapshot.role_visual_ready_count, + role_animation_ready_count: snapshot.role_animation_ready_count, + role_asset_summary_label: snapshot.role_asset_summary_label, + session_id: snapshot.session_id, + profile_id: snapshot.profile_id, + can_resume: snapshot.can_resume, + can_enter_world: snapshot.can_enter_world, + blocker_count: snapshot.blocker_count, + publish_ready: snapshot.publish_ready, + }) +} + fn map_custom_world_agent_session_snapshot( snapshot: BindingCustomWorldAgentSessionSnapshot, ) -> Result { @@ -2706,6 +2882,12 @@ fn map_custom_world_agent_session_snapshot( .into_iter() .map(map_custom_world_checkpoint_record) .collect::, _>>()?; + let supported_actions = parse_supported_actions_json(&snapshot.supported_actions_json)?; + let publish_gate = snapshot + .publish_gate_json + .as_deref() + .map(parse_custom_world_publish_gate_record) + .transpose()?; Ok(CustomWorldAgentSessionRecord { session_id: snapshot.session_id, @@ -2736,12 +2918,8 @@ fn map_custom_world_agent_session_snapshot( quality_findings, asset_coverage, checkpoints, - supported_actions: build_minimal_custom_world_supported_actions( - snapshot.stage, - snapshot.progress_percent, - snapshot.result_preview_json.is_some(), - snapshot.checkpoints_json.as_str(), - ), + supported_actions, + publish_gate, result_preview: snapshot .result_preview_json .as_deref() @@ -2797,9 +2975,57 @@ fn map_custom_world_draft_card_snapshot( .asset_status .map(format_custom_world_role_asset_status_back), asset_status_label: snapshot.asset_status_label, + detail_payload: snapshot + .detail_payload_json + .as_deref() + .map(|value| parse_json_value(value, "custom world draft_card detail_payload_json")) + .transpose()?, }) } +fn map_custom_world_draft_card_detail_snapshot( + snapshot: BindingCustomWorldDraftCardDetailSnapshot, +) -> Result { + Ok(CustomWorldDraftCardDetailRecord { + card_id: snapshot.card_id, + kind: format_rpg_agent_draft_card_kind(snapshot.kind).to_string(), + title: snapshot.title, + sections: snapshot + .sections + .into_iter() + .map(map_custom_world_draft_card_detail_section_snapshot) + .collect(), + linked_ids: parse_json_string_array( + &snapshot.linked_ids_json, + "custom world card detail linked_ids_json", + )?, + locked: snapshot.locked, + editable: snapshot.editable, + editable_section_ids: parse_json_string_array( + &snapshot.editable_section_ids_json, + "custom world card detail editable_section_ids_json", + )?, + warning_messages: parse_json_string_array( + &snapshot.warning_messages_json, + "custom world card detail warning_messages_json", + )?, + asset_status: snapshot + .asset_status + .map(format_custom_world_role_asset_status_back), + asset_status_label: snapshot.asset_status_label, + }) +} + +fn map_custom_world_draft_card_detail_section_snapshot( + snapshot: BindingCustomWorldDraftCardDetailSectionSnapshot, +) -> CustomWorldDraftCardDetailSectionRecord { + CustomWorldDraftCardDetailSectionRecord { + section_id: snapshot.section_id, + label: snapshot.label, + value: snapshot.value, + } +} + fn map_story_session_snapshot(snapshot: BindingStorySessionSnapshot) -> StorySessionRecord { StorySessionRecord { story_session_id: snapshot.story_session_id, @@ -3607,45 +3833,159 @@ fn map_custom_world_checkpoint_record( }) } -fn build_minimal_custom_world_supported_actions( - stage: crate::module_bindings::RpgAgentStage, - progress_percent: u32, - has_result_preview: bool, - checkpoints_json: &str, -) -> Vec { - let has_checkpoint = parse_json_array(checkpoints_json, "custom world agent checkpoints_json") - .map(|entries| !entries.is_empty()) - .unwrap_or(false); - let refining_ready = matches!( - stage, - crate::module_bindings::RpgAgentStage::FoundationReview - | crate::module_bindings::RpgAgentStage::ObjectRefining - | crate::module_bindings::RpgAgentStage::VisualRefining - | crate::module_bindings::RpgAgentStage::LongTailReview - | crate::module_bindings::RpgAgentStage::ReadyToPublish - | crate::module_bindings::RpgAgentStage::Published - ); +fn parse_supported_actions_json( + value: &str, +) -> Result, SpacetimeClientError> { + parse_json_array(value, "custom world agent supported_actions_json")? + .into_iter() + .map(|entry| { + let object = entry.as_object().ok_or_else(|| { + SpacetimeClientError::Runtime( + "custom world supported action 必须是 JSON object".to_string(), + ) + })?; + let action = object + .get("action") + .and_then(serde_json::Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .ok_or_else(|| { + SpacetimeClientError::Runtime( + "custom world supported action.action 缺失".to_string(), + ) + })?; + let enabled = object + .get("enabled") + .and_then(serde_json::Value::as_bool) + .ok_or_else(|| { + SpacetimeClientError::Runtime( + "custom world supported action.enabled 缺失".to_string(), + ) + })?; - vec![ - CustomWorldSupportedActionRecord { - action: "draft_foundation".to_string(), - enabled: progress_percent >= 100, - reason: (progress_percent < 100) - .then(|| "draft_foundation requires progressPercent >= 100".to_string()), - }, - CustomWorldSupportedActionRecord { - action: "publish_world".to_string(), - enabled: refining_ready && has_result_preview, - reason: (!refining_ready || !has_result_preview) - .then(|| "publish_world requires refined draft and resultPreview".to_string()), - }, - CustomWorldSupportedActionRecord { - action: "revert_checkpoint".to_string(), - enabled: has_checkpoint, - reason: (!has_checkpoint) - .then(|| "revert_checkpoint requires at least one checkpoint".to_string()), - }, - ] + Ok(CustomWorldSupportedActionRecord { + action: action.to_string(), + enabled, + reason: object + .get("reason") + .and_then(serde_json::Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(ToOwned::to_owned), + }) + }) + .collect() +} + +fn parse_custom_world_publish_gate_record( + value: &str, +) -> Result { + let object = parse_json_value(value, "custom world publish_gate_json")? + .as_object() + .cloned() + .ok_or_else(|| { + SpacetimeClientError::Runtime( + "custom world publish_gate_json 必须是 JSON object".to_string(), + ) + })?; + + let profile_id = object + .get("profileId") + .and_then(serde_json::Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .ok_or_else(|| { + SpacetimeClientError::Runtime( + "custom world publish_gate.profileId 缺失".to_string(), + ) + })?; + let blockers = object + .get("blockers") + .and_then(serde_json::Value::as_array) + .ok_or_else(|| { + SpacetimeClientError::Runtime( + "custom world publish_gate.blockers 缺失".to_string(), + ) + })? + .iter() + .cloned() + .map(|entry| { + let object = entry.as_object().ok_or_else(|| { + SpacetimeClientError::Runtime( + "custom world publish gate blocker 必须是 JSON object".to_string(), + ) + })?; + let id = object + .get("id") + .and_then(serde_json::Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .ok_or_else(|| { + SpacetimeClientError::Runtime( + "custom world publish gate blocker.id 缺失".to_string(), + ) + })?; + let code = object + .get("code") + .and_then(serde_json::Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .ok_or_else(|| { + SpacetimeClientError::Runtime( + "custom world publish gate blocker.code 缺失".to_string(), + ) + })?; + let message = object + .get("message") + .and_then(serde_json::Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .ok_or_else(|| { + SpacetimeClientError::Runtime( + "custom world publish gate blocker.message 缺失".to_string(), + ) + })?; + + Ok(CustomWorldResultPreviewBlockerRecord { + id: id.to_string(), + code: code.to_string(), + message: message.to_string(), + }) + }) + .collect::, _>>()?; + let blocker_count = object + .get("blockerCount") + .and_then(serde_json::Value::as_u64) + .and_then(|value| u32::try_from(value).ok()) + .ok_or_else(|| { + SpacetimeClientError::Runtime( + "custom world publish_gate.blockerCount 缺失".to_string(), + ) + })?; + let publish_ready = object + .get("publishReady") + .and_then(serde_json::Value::as_bool) + .ok_or_else(|| { + SpacetimeClientError::Runtime( + "custom world publish_gate.publishReady 缺失".to_string(), + ) + })?; + let can_enter_world = object + .get("canEnterWorld") + .and_then(serde_json::Value::as_bool) + .ok_or_else(|| { + SpacetimeClientError::Runtime( + "custom world publish_gate.canEnterWorld 缺失".to_string(), + ) + })?; + + Ok(CustomWorldPublishGateRecord { + profile_id: profile_id.to_string(), + blockers, + blocker_count, + publish_ready, + can_enter_world, + }) } #[derive(Clone, Debug, PartialEq, Eq)] @@ -3785,6 +4125,7 @@ pub struct CustomWorldDraftCardRecord { pub warning_count: u32, pub asset_status: Option, pub asset_status_label: Option, + pub detail_payload: Option, } #[derive(Clone, Debug, PartialEq)] @@ -3804,6 +4145,72 @@ pub struct CustomWorldCheckpointRecord { // 兼容并行 custom world facade 中仍在使用的旧命名,避免本轮 module-npc 收口被无关改动阻塞。 pub type CustomWorldAgentCheckpointRecord = CustomWorldCheckpointRecord; +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct CustomWorldResultPreviewBlockerRecord { + pub id: String, + pub code: String, + pub message: String, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct CustomWorldPublishGateRecord { + pub profile_id: String, + pub blockers: Vec, + pub blocker_count: u32, + pub publish_ready: bool, + pub can_enter_world: bool, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct CustomWorldWorkSummaryRecord { + pub work_id: String, + pub source_type: String, + pub status: String, + pub title: String, + pub subtitle: String, + pub summary: String, + pub cover_image_src: Option, + pub cover_render_mode: Option, + pub cover_character_image_srcs: Vec, + pub updated_at: String, + pub published_at: Option, + pub stage: Option, + pub stage_label: Option, + pub playable_npc_count: u32, + pub landmark_count: u32, + pub role_visual_ready_count: Option, + pub role_animation_ready_count: Option, + pub role_asset_summary_label: Option, + pub session_id: Option, + pub profile_id: Option, + pub can_resume: bool, + pub can_enter_world: bool, + pub blocker_count: u32, + pub publish_ready: bool, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct CustomWorldDraftCardDetailSectionRecord { + pub section_id: String, + pub label: String, + pub value: String, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct CustomWorldDraftCardDetailRecord { + pub card_id: String, + pub kind: String, + pub title: String, + pub sections: Vec, + pub linked_ids: Vec, + pub locked: bool, + pub editable: bool, + pub editable_section_ids: Vec, + pub warning_messages: Vec, + pub asset_status: Option, + pub asset_status_label: Option, +} + #[derive(Clone, Debug, PartialEq)] pub struct CustomWorldAgentSessionRecord { pub session_id: String, @@ -3827,6 +4234,7 @@ pub struct CustomWorldAgentSessionRecord { pub asset_coverage: serde_json::Value, pub checkpoints: Vec, pub supported_actions: Vec, + pub publish_gate: Option, pub result_preview: Option, pub updated_at: String, } @@ -3892,6 +4300,21 @@ pub struct CustomWorldAgentMessageSubmitRecordInput { pub submitted_at_micros: i64, } +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct CustomWorldAgentActionExecuteRecordInput { + pub session_id: String, + pub owner_user_id: String, + pub operation_id: String, + pub action: String, + pub payload_json: Option, + pub submitted_at_micros: i64, +} + +#[derive(Clone, Debug, PartialEq)] +pub struct CustomWorldAgentActionExecuteRecord { + pub operation: CustomWorldAgentOperationRecord, +} + #[derive(Clone, Debug, PartialEq, Eq)] pub struct ResolveNpcBattleInteractionInput { pub npc_interaction: DomainResolveNpcInteractionInput, diff --git a/server-rs/crates/spacetime-client/src/module_bindings/custom_world_agent_action_execute_input_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/custom_world_agent_action_execute_input_type.rs new file mode 100644 index 00000000..3281a80a --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/custom_world_agent_action_execute_input_type.rs @@ -0,0 +1,28 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{ + self as __sdk, + __lib, + __sats, + __ws, +}; + + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct CustomWorldAgentActionExecuteInput { + pub session_id: String, + pub owner_user_id: String, + pub operation_id: String, + pub action: String, + pub payload_json: Option::, + pub submitted_at_micros: i64, +} + + +impl __sdk::InModule for CustomWorldAgentActionExecuteInput { + type Module = super::RemoteModule; +} + diff --git a/server-rs/crates/spacetime-client/src/module_bindings/custom_world_agent_action_execute_result_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/custom_world_agent_action_execute_result_type.rs new file mode 100644 index 00000000..6729b4db --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/custom_world_agent_action_execute_result_type.rs @@ -0,0 +1,26 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{ + self as __sdk, + __lib, + __sats, + __ws, +}; + +use super::custom_world_agent_operation_snapshot_type::CustomWorldAgentOperationSnapshot; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct CustomWorldAgentActionExecuteResult { + pub ok: bool, + pub operation: Option::, + pub error_message: Option::, +} + + +impl __sdk::InModule for CustomWorldAgentActionExecuteResult { + type Module = super::RemoteModule; +} + diff --git a/server-rs/crates/spacetime-client/src/module_bindings/custom_world_agent_card_detail_get_input_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/custom_world_agent_card_detail_get_input_type.rs new file mode 100644 index 00000000..c6cdeb3b --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/custom_world_agent_card_detail_get_input_type.rs @@ -0,0 +1,25 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{ + self as __sdk, + __lib, + __sats, + __ws, +}; + + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct CustomWorldAgentCardDetailGetInput { + pub session_id: String, + pub owner_user_id: String, + pub card_id: String, +} + + +impl __sdk::InModule for CustomWorldAgentCardDetailGetInput { + type Module = super::RemoteModule; +} + diff --git a/server-rs/crates/spacetime-client/src/module_bindings/custom_world_agent_session_snapshot_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/custom_world_agent_session_snapshot_type.rs index 9d094d20..e263df53 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/custom_world_agent_session_snapshot_type.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/custom_world_agent_session_snapshot_type.rs @@ -31,6 +31,7 @@ pub struct CustomWorldAgentSessionSnapshot { pub lock_state_json: Option::, pub draft_profile_json: Option::, pub last_assistant_reply: Option::, + pub publish_gate_json: Option::, pub result_preview_json: Option::, pub pending_clarifications_json: String, pub quality_findings_json: String, @@ -38,6 +39,7 @@ pub struct CustomWorldAgentSessionSnapshot { pub recommended_replies_json: String, pub asset_coverage_json: String, pub checkpoints_json: String, + pub supported_actions_json: String, pub messages: Vec::, pub draft_cards: Vec::, pub operations: Vec::, diff --git a/server-rs/crates/spacetime-client/src/module_bindings/custom_world_agent_session_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/custom_world_agent_session_type.rs index ddba70f3..128d93e3 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/custom_world_agent_session_type.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/custom_world_agent_session_type.rs @@ -28,6 +28,7 @@ pub struct CustomWorldAgentSession { pub lock_state_json: Option::, pub draft_profile_json: Option::, pub last_assistant_reply: Option::, + pub publish_gate_json: Option::, pub result_preview_json: Option::, pub pending_clarifications_json: String, pub quality_findings_json: String, @@ -63,6 +64,7 @@ pub struct CustomWorldAgentSessionCols { pub lock_state_json: __sdk::__query_builder::Col>, pub draft_profile_json: __sdk::__query_builder::Col>, pub last_assistant_reply: __sdk::__query_builder::Col>, + pub publish_gate_json: __sdk::__query_builder::Col>, pub result_preview_json: __sdk::__query_builder::Col>, pub pending_clarifications_json: __sdk::__query_builder::Col, pub quality_findings_json: __sdk::__query_builder::Col, @@ -92,6 +94,7 @@ impl __sdk::__query_builder::HasCols for CustomWorldAgentSession { lock_state_json: __sdk::__query_builder::Col::new(table_name, "lock_state_json"), draft_profile_json: __sdk::__query_builder::Col::new(table_name, "draft_profile_json"), last_assistant_reply: __sdk::__query_builder::Col::new(table_name, "last_assistant_reply"), + publish_gate_json: __sdk::__query_builder::Col::new(table_name, "publish_gate_json"), result_preview_json: __sdk::__query_builder::Col::new(table_name, "result_preview_json"), pending_clarifications_json: __sdk::__query_builder::Col::new(table_name, "pending_clarifications_json"), quality_findings_json: __sdk::__query_builder::Col::new(table_name, "quality_findings_json"), diff --git a/server-rs/crates/spacetime-client/src/module_bindings/custom_world_draft_card_detail_result_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/custom_world_draft_card_detail_result_type.rs new file mode 100644 index 00000000..384a2c8c --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/custom_world_draft_card_detail_result_type.rs @@ -0,0 +1,26 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{ + self as __sdk, + __lib, + __sats, + __ws, +}; + +use super::custom_world_draft_card_detail_snapshot_type::CustomWorldDraftCardDetailSnapshot; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct CustomWorldDraftCardDetailResult { + pub ok: bool, + pub card: Option::, + pub error_message: Option::, +} + + +impl __sdk::InModule for CustomWorldDraftCardDetailResult { + type Module = super::RemoteModule; +} + diff --git a/server-rs/crates/spacetime-client/src/module_bindings/custom_world_draft_card_detail_section_snapshot_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/custom_world_draft_card_detail_section_snapshot_type.rs new file mode 100644 index 00000000..2e256890 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/custom_world_draft_card_detail_section_snapshot_type.rs @@ -0,0 +1,25 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{ + self as __sdk, + __lib, + __sats, + __ws, +}; + + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct CustomWorldDraftCardDetailSectionSnapshot { + pub section_id: String, + pub label: String, + pub value: String, +} + + +impl __sdk::InModule for CustomWorldDraftCardDetailSectionSnapshot { + type Module = super::RemoteModule; +} + diff --git a/server-rs/crates/spacetime-client/src/module_bindings/custom_world_draft_card_detail_snapshot_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/custom_world_draft_card_detail_snapshot_type.rs new file mode 100644 index 00000000..6bb20096 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/custom_world_draft_card_detail_snapshot_type.rs @@ -0,0 +1,36 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{ + self as __sdk, + __lib, + __sats, + __ws, +}; + +use super::rpg_agent_draft_card_kind_type::RpgAgentDraftCardKind; +use super::custom_world_role_asset_status_type::CustomWorldRoleAssetStatus; +use super::custom_world_draft_card_detail_section_snapshot_type::CustomWorldDraftCardDetailSectionSnapshot; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct CustomWorldDraftCardDetailSnapshot { + pub card_id: String, + pub kind: RpgAgentDraftCardKind, + pub title: String, + pub sections: Vec::, + pub linked_ids_json: String, + pub locked: bool, + pub editable: bool, + pub editable_section_ids_json: String, + pub warning_messages_json: String, + pub asset_status: Option::, + pub asset_status_label: Option::, +} + + +impl __sdk::InModule for CustomWorldDraftCardDetailSnapshot { + type Module = super::RemoteModule; +} + diff --git a/server-rs/crates/spacetime-client/src/module_bindings/custom_world_work_summary_snapshot_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/custom_world_work_summary_snapshot_type.rs new file mode 100644 index 00000000..e81345b0 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/custom_world_work_summary_snapshot_type.rs @@ -0,0 +1,47 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{ + self as __sdk, + __lib, + __sats, + __ws, +}; + +use super::rpg_agent_stage_type::RpgAgentStage; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct CustomWorldWorkSummarySnapshot { + pub work_id: String, + pub source_type: String, + pub status: String, + pub title: String, + pub subtitle: String, + pub summary: String, + pub cover_image_src: Option::, + pub cover_render_mode: Option::, + pub cover_character_image_srcs_json: String, + pub updated_at_micros: i64, + pub published_at_micros: Option::, + pub stage: Option::, + pub stage_label: Option::, + pub playable_npc_count: u32, + pub landmark_count: u32, + pub role_visual_ready_count: Option::, + pub role_animation_ready_count: Option::, + pub role_asset_summary_label: Option::, + pub session_id: Option::, + pub profile_id: Option::, + pub can_resume: bool, + pub can_enter_world: bool, + pub blocker_count: u32, + pub publish_ready: bool, +} + + +impl __sdk::InModule for CustomWorldWorkSummarySnapshot { + type Module = super::RemoteModule; +} + diff --git a/server-rs/crates/spacetime-client/src/module_bindings/custom_world_works_list_input_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/custom_world_works_list_input_type.rs new file mode 100644 index 00000000..073faecf --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/custom_world_works_list_input_type.rs @@ -0,0 +1,23 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{ + self as __sdk, + __lib, + __sats, + __ws, +}; + + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct CustomWorldWorksListInput { + pub owner_user_id: String, +} + + +impl __sdk::InModule for CustomWorldWorksListInput { + type Module = super::RemoteModule; +} + diff --git a/server-rs/crates/spacetime-client/src/module_bindings/custom_world_works_list_result_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/custom_world_works_list_result_type.rs new file mode 100644 index 00000000..937b450e --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/custom_world_works_list_result_type.rs @@ -0,0 +1,26 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{ + self as __sdk, + __lib, + __sats, + __ws, +}; + +use super::custom_world_work_summary_snapshot_type::CustomWorldWorkSummarySnapshot; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct CustomWorldWorksListResult { + pub ok: bool, + pub items: Vec::, + pub error_message: Option::, +} + + +impl __sdk::InModule for CustomWorldWorksListResult { + type Module = super::RemoteModule; +} + diff --git a/server-rs/crates/spacetime-client/src/module_bindings/execute_custom_world_agent_action_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/execute_custom_world_agent_action_procedure.rs new file mode 100644 index 00000000..390871f5 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/execute_custom_world_agent_action_procedure.rs @@ -0,0 +1,58 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{ + self as __sdk, + __lib, + __sats, + __ws, +}; + +use super::custom_world_agent_action_execute_input_type::CustomWorldAgentActionExecuteInput; +use super::custom_world_agent_action_execute_result_type::CustomWorldAgentActionExecuteResult; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] + struct ExecuteCustomWorldAgentActionArgs { + pub input: CustomWorldAgentActionExecuteInput, +} + + +impl __sdk::InModule for ExecuteCustomWorldAgentActionArgs { + type Module = super::RemoteModule; +} + +#[allow(non_camel_case_types)] +/// Extension trait for access to the procedure `execute_custom_world_agent_action`. +/// +/// Implemented for [`super::RemoteProcedures`]. +pub trait execute_custom_world_agent_action { + fn execute_custom_world_agent_action(&self, input: CustomWorldAgentActionExecuteInput, +) { + self.execute_custom_world_agent_action_then(input, |_, _| {}); + } + + fn execute_custom_world_agent_action_then( + &self, + input: CustomWorldAgentActionExecuteInput, + + __callback: impl FnOnce(&super::ProcedureEventContext, Result) + Send + 'static, + ); +} + +impl execute_custom_world_agent_action for super::RemoteProcedures { + fn execute_custom_world_agent_action_then( + &self, + input: CustomWorldAgentActionExecuteInput, + + __callback: impl FnOnce(&super::ProcedureEventContext, Result) + Send + 'static, + ) { + self.imp.invoke_procedure_with_callback::<_, CustomWorldAgentActionExecuteResult>( + "execute_custom_world_agent_action", + ExecuteCustomWorldAgentActionArgs { input, }, + __callback, + ); + } +} + diff --git a/server-rs/crates/spacetime-client/src/module_bindings/get_custom_world_agent_card_detail_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/get_custom_world_agent_card_detail_procedure.rs new file mode 100644 index 00000000..f0e38f3b --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/get_custom_world_agent_card_detail_procedure.rs @@ -0,0 +1,58 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{ + self as __sdk, + __lib, + __sats, + __ws, +}; + +use super::custom_world_agent_card_detail_get_input_type::CustomWorldAgentCardDetailGetInput; +use super::custom_world_draft_card_detail_result_type::CustomWorldDraftCardDetailResult; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] + struct GetCustomWorldAgentCardDetailArgs { + pub input: CustomWorldAgentCardDetailGetInput, +} + + +impl __sdk::InModule for GetCustomWorldAgentCardDetailArgs { + type Module = super::RemoteModule; +} + +#[allow(non_camel_case_types)] +/// Extension trait for access to the procedure `get_custom_world_agent_card_detail`. +/// +/// Implemented for [`super::RemoteProcedures`]. +pub trait get_custom_world_agent_card_detail { + fn get_custom_world_agent_card_detail(&self, input: CustomWorldAgentCardDetailGetInput, +) { + self.get_custom_world_agent_card_detail_then(input, |_, _| {}); + } + + fn get_custom_world_agent_card_detail_then( + &self, + input: CustomWorldAgentCardDetailGetInput, + + __callback: impl FnOnce(&super::ProcedureEventContext, Result) + Send + 'static, + ); +} + +impl get_custom_world_agent_card_detail for super::RemoteProcedures { + fn get_custom_world_agent_card_detail_then( + &self, + input: CustomWorldAgentCardDetailGetInput, + + __callback: impl FnOnce(&super::ProcedureEventContext, Result) + Send + 'static, + ) { + self.imp.invoke_procedure_with_callback::<_, CustomWorldDraftCardDetailResult>( + "get_custom_world_agent_card_detail", + GetCustomWorldAgentCardDetailArgs { input, }, + __callback, + ); + } +} + diff --git a/server-rs/crates/spacetime-client/src/module_bindings/list_custom_world_works_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/list_custom_world_works_procedure.rs new file mode 100644 index 00000000..aca87254 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/list_custom_world_works_procedure.rs @@ -0,0 +1,58 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{ + self as __sdk, + __lib, + __sats, + __ws, +}; + +use super::custom_world_works_list_input_type::CustomWorldWorksListInput; +use super::custom_world_works_list_result_type::CustomWorldWorksListResult; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] + struct ListCustomWorldWorksArgs { + pub input: CustomWorldWorksListInput, +} + + +impl __sdk::InModule for ListCustomWorldWorksArgs { + type Module = super::RemoteModule; +} + +#[allow(non_camel_case_types)] +/// Extension trait for access to the procedure `list_custom_world_works`. +/// +/// Implemented for [`super::RemoteProcedures`]. +pub trait list_custom_world_works { + fn list_custom_world_works(&self, input: CustomWorldWorksListInput, +) { + self.list_custom_world_works_then(input, |_, _| {}); + } + + fn list_custom_world_works_then( + &self, + input: CustomWorldWorksListInput, + + __callback: impl FnOnce(&super::ProcedureEventContext, Result) + Send + 'static, + ); +} + +impl list_custom_world_works for super::RemoteProcedures { + fn list_custom_world_works_then( + &self, + input: CustomWorldWorksListInput, + + __callback: impl FnOnce(&super::ProcedureEventContext, Result) + Send + 'static, + ) { + self.imp.invoke_procedure_with_callback::<_, CustomWorldWorksListResult>( + "list_custom_world_works", + ListCustomWorldWorksArgs { input, }, + __callback, + ); + } +} + diff --git a/server-rs/crates/spacetime-client/src/module_bindings/mod.rs b/server-rs/crates/spacetime-client/src/module_bindings/mod.rs index f51667d3..9b0e23e0 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/mod.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/mod.rs @@ -60,6 +60,9 @@ pub mod chapter_progression_procedure_result_type; pub mod chapter_progression_snapshot_type; pub mod combat_outcome_type; pub mod consume_inventory_item_input_type; +pub mod custom_world_agent_action_execute_input_type; +pub mod custom_world_agent_action_execute_result_type; +pub mod custom_world_agent_card_detail_get_input_type; pub mod custom_world_agent_message_type; pub mod custom_world_agent_message_snapshot_type; pub mod custom_world_agent_message_submit_input_type; @@ -73,6 +76,9 @@ pub mod custom_world_agent_session_get_input_type; pub mod custom_world_agent_session_procedure_result_type; pub mod custom_world_agent_session_snapshot_type; pub mod custom_world_draft_card_type; +pub mod custom_world_draft_card_detail_result_type; +pub mod custom_world_draft_card_detail_section_snapshot_type; +pub mod custom_world_draft_card_detail_snapshot_type; pub mod custom_world_draft_card_snapshot_type; pub mod custom_world_gallery_detail_input_type; pub mod custom_world_gallery_entry_type; @@ -98,6 +104,9 @@ pub mod custom_world_role_asset_status_type; pub mod custom_world_session_type; pub mod custom_world_session_status_type; pub mod custom_world_theme_mode_type; +pub mod custom_world_work_summary_snapshot_type; +pub mod custom_world_works_list_input_type; +pub mod custom_world_works_list_result_type; pub mod equip_inventory_item_input_type; pub mod grant_inventory_item_input_type; pub mod inventory_container_kind_type; @@ -301,9 +310,11 @@ pub mod create_ai_task_and_return_procedure; pub mod create_battle_state_and_return_procedure; pub mod create_custom_world_agent_session_procedure; pub mod delete_runtime_snapshot_and_return_procedure; +pub mod execute_custom_world_agent_action_procedure; pub mod fail_ai_task_and_return_procedure; pub mod get_battle_state_procedure; pub mod get_chapter_progression_procedure; +pub mod get_custom_world_agent_card_detail_procedure; pub mod get_custom_world_agent_operation_procedure; pub mod get_custom_world_agent_session_procedure; pub mod get_custom_world_gallery_detail_procedure; @@ -318,6 +329,7 @@ pub mod get_story_session_state_procedure; pub mod grant_player_progression_experience_and_return_procedure; pub mod list_custom_world_gallery_entries_procedure; pub mod list_custom_world_profiles_procedure; +pub mod list_custom_world_works_procedure; pub mod list_platform_browse_history_procedure; pub mod list_profile_save_archives_procedure; pub mod list_profile_wallet_ledger_procedure; @@ -387,6 +399,9 @@ pub use chapter_progression_procedure_result_type::ChapterProgressionProcedureRe pub use chapter_progression_snapshot_type::ChapterProgressionSnapshot; pub use combat_outcome_type::CombatOutcome; pub use consume_inventory_item_input_type::ConsumeInventoryItemInput; +pub use custom_world_agent_action_execute_input_type::CustomWorldAgentActionExecuteInput; +pub use custom_world_agent_action_execute_result_type::CustomWorldAgentActionExecuteResult; +pub use custom_world_agent_card_detail_get_input_type::CustomWorldAgentCardDetailGetInput; pub use custom_world_agent_message_type::CustomWorldAgentMessage; pub use custom_world_agent_message_snapshot_type::CustomWorldAgentMessageSnapshot; pub use custom_world_agent_message_submit_input_type::CustomWorldAgentMessageSubmitInput; @@ -400,6 +415,9 @@ pub use custom_world_agent_session_get_input_type::CustomWorldAgentSessionGetInp pub use custom_world_agent_session_procedure_result_type::CustomWorldAgentSessionProcedureResult; pub use custom_world_agent_session_snapshot_type::CustomWorldAgentSessionSnapshot; pub use custom_world_draft_card_type::CustomWorldDraftCard; +pub use custom_world_draft_card_detail_result_type::CustomWorldDraftCardDetailResult; +pub use custom_world_draft_card_detail_section_snapshot_type::CustomWorldDraftCardDetailSectionSnapshot; +pub use custom_world_draft_card_detail_snapshot_type::CustomWorldDraftCardDetailSnapshot; pub use custom_world_draft_card_snapshot_type::CustomWorldDraftCardSnapshot; pub use custom_world_gallery_detail_input_type::CustomWorldGalleryDetailInput; pub use custom_world_gallery_entry_type::CustomWorldGalleryEntry; @@ -425,6 +443,9 @@ pub use custom_world_role_asset_status_type::CustomWorldRoleAssetStatus; pub use custom_world_session_type::CustomWorldSession; pub use custom_world_session_status_type::CustomWorldSessionStatus; pub use custom_world_theme_mode_type::CustomWorldThemeMode; +pub use custom_world_work_summary_snapshot_type::CustomWorldWorkSummarySnapshot; +pub use custom_world_works_list_input_type::CustomWorldWorksListInput; +pub use custom_world_works_list_result_type::CustomWorldWorksListResult; pub use equip_inventory_item_input_type::EquipInventoryItemInput; pub use grant_inventory_item_input_type::GrantInventoryItemInput; pub use inventory_container_kind_type::InventoryContainerKind; @@ -628,9 +649,11 @@ pub use create_ai_task_and_return_procedure::create_ai_task_and_return; pub use create_battle_state_and_return_procedure::create_battle_state_and_return; pub use create_custom_world_agent_session_procedure::create_custom_world_agent_session; pub use delete_runtime_snapshot_and_return_procedure::delete_runtime_snapshot_and_return; +pub use execute_custom_world_agent_action_procedure::execute_custom_world_agent_action; pub use fail_ai_task_and_return_procedure::fail_ai_task_and_return; pub use get_battle_state_procedure::get_battle_state; pub use get_chapter_progression_procedure::get_chapter_progression; +pub use get_custom_world_agent_card_detail_procedure::get_custom_world_agent_card_detail; pub use get_custom_world_agent_operation_procedure::get_custom_world_agent_operation; pub use get_custom_world_agent_session_procedure::get_custom_world_agent_session; pub use get_custom_world_gallery_detail_procedure::get_custom_world_gallery_detail; @@ -645,6 +668,7 @@ pub use get_story_session_state_procedure::get_story_session_state; pub use grant_player_progression_experience_and_return_procedure::grant_player_progression_experience_and_return; pub use list_custom_world_gallery_entries_procedure::list_custom_world_gallery_entries; pub use list_custom_world_profiles_procedure::list_custom_world_profiles; +pub use list_custom_world_works_procedure::list_custom_world_works; pub use list_platform_browse_history_procedure::list_platform_browse_history; pub use list_profile_save_archives_procedure::list_profile_save_archives; pub use list_profile_wallet_ledger_procedure::list_profile_wallet_ledger; diff --git a/server-rs/crates/spacetime-module/Cargo.toml b/server-rs/crates/spacetime-module/Cargo.toml index 8a27ed4e..4df87135 100644 --- a/server-rs/crates/spacetime-module/Cargo.toml +++ b/server-rs/crates/spacetime-module/Cargo.toml @@ -21,4 +21,5 @@ module-quest = { path = "../module-quest", default-features = false, features = module-runtime = { path = "../module-runtime", default-features = false, features = ["spacetime-types"] } module-runtime-item = { path = "../module-runtime-item", default-features = false, features = ["spacetime-types"] } module-story = { path = "../module-story", default-features = false, features = ["spacetime-types"] } +shared-kernel = { path = "../shared-kernel" } spacetimedb = { workspace = true, features = ["unstable"] } diff --git a/server-rs/crates/spacetime-module/README.md b/server-rs/crates/spacetime-module/README.md index d8063e04..eb07e874 100644 --- a/server-rs/crates/spacetime-module/README.md +++ b/server-rs/crates/spacetime-module/README.md @@ -22,6 +22,7 @@ 2. 继续设计表、reducer、view 的聚合方式 3. 接入身份 claims 透传 4. 在当前 scaffold 基础上接入 publish / dev 循环 +5. 在 `M7` 收口阶段拆分过大的 `src/lib.rs`,按 `runtime`、`gameplay/*`、`custom_world`、`asset_metadata`、`ai` 等业务与 SpacetimeDB 聚合层次重组目录,避免主工程 crate 回退成单大包 当前已落地: diff --git a/server-rs/crates/spacetime-module/src/lib.rs b/server-rs/crates/spacetime-module/src/lib.rs index aa6034b1..99f787d2 100644 --- a/server-rs/crates/spacetime-module/src/lib.rs +++ b/server-rs/crates/spacetime-module/src/lib.rs @@ -127,6 +127,7 @@ use module_story::{ build_story_session_snapshot, build_story_started_event, validate_story_continue_input, validate_story_session_input, validate_story_session_state_input, }; +use shared_kernel::format_timestamp_micros; use serde_json::{Map as JsonMap, Value as JsonValue, json}; use spacetimedb::{ProcedureContext, ReducerContext, SpacetimeType, Table, Timestamp}; @@ -3527,7 +3528,7 @@ fn list_custom_world_work_snapshots( let stage_label = Some(resolve_rpg_agent_stage_label(session.stage).to_string()); let subtitle = resolve_session_work_subtitle(draft_profile.as_ref(), stage_label.as_deref()); let (playable_npc_count, landmark_count) = - resolve_session_work_counts(&session, draft_profile.as_ref()); + resolve_session_work_counts(ctx, &session, draft_profile.as_ref()); items.push(CustomWorldWorkSummarySnapshot { work_id: format!("draft:{}", session.session_id), @@ -4088,7 +4089,12 @@ fn execute_revert_checkpoint_action( CustomWorldAgentSessionPatch { progress_percent: Some(restored_progress), stage: Some(restored_stage), - draft_profile_json: Some(restored_draft_profile.map(|value| serialize_json_value(&JsonValue::Object(value))).transpose()?), + draft_profile_json: Some( + restored_draft_profile + .as_ref() + .map(|value| serialize_json_value(&JsonValue::Object(value.clone()))) + .transpose()?, + ), last_assistant_reply: Some(Some("已恢复到所选 checkpoint 的世界草稿状态。".to_string())), quality_findings_json: Some(serialize_json_value(&JsonValue::Array(restored_quality_findings))?), publish_gate_json: Some(Some(serialize_json_value(&publish_gate_to_json_value(&gate))?)), @@ -4157,6 +4163,1033 @@ fn execute_placeholder_custom_world_action( Ok(build_custom_world_agent_operation_snapshot(&operation)) } +#[derive(Clone, Debug, Default)] +struct CustomWorldAgentSessionPatch { + progress_percent: Option, + stage: Option, + focus_card_id: Option>, + anchor_content_json: Option, + creator_intent_json: Option>, + creator_intent_readiness_json: Option, + anchor_pack_json: Option>, + lock_state_json: Option>, + draft_profile_json: Option>, + last_assistant_reply: Option>, + publish_gate_json: Option>, + result_preview_json: Option>, + pending_clarifications_json: Option, + quality_findings_json: Option, + suggested_actions_json: Option, + recommended_replies_json: Option, + asset_coverage_json: Option, + checkpoints_json: Option, + updated_at_micros: Option, +} + +fn build_custom_world_publish_gate_from_session( + session: &CustomWorldAgentSession, +) -> CustomWorldPublishGateSnapshot { + let quality_findings = parse_json_array_or_empty(&session.quality_findings_json); + summarize_publish_gate_from_json( + &session.session_id, + session.stage, + parse_optional_session_object(session.draft_profile_json.as_deref()).as_ref(), + &quality_findings, + ) +} + +fn summarize_publish_gate_from_json( + session_id: &str, + stage: RpgAgentStage, + draft_profile: Option<&JsonMap>, + quality_findings: &[JsonValue], +) -> CustomWorldPublishGateSnapshot { + let profile_id = draft_profile + .and_then(|profile| read_optional_text_field(profile, &["legacyResultProfile.id", "id"])) + .unwrap_or_else(|| format!("agent-draft-{session_id}")); + let mut blockers = Vec::new(); + + if draft_profile.is_none() { + blockers.push(CustomWorldPublishBlockerSnapshot { + blocker_id: "publish_empty_draft".to_string(), + code: "publish_empty_draft".to_string(), + message: "当前世界草稿为空,无法发布。".to_string(), + }); + } + + if let Some(profile) = draft_profile { + if read_optional_text_field(profile, &["worldHook"]).is_none() { + blockers.push(CustomWorldPublishBlockerSnapshot { + blocker_id: "publish_missing_world_hook".to_string(), + code: "publish_missing_world_hook".to_string(), + message: "当前世界缺少 world hook,发布前需要先补齐世界一句话钩子。".to_string(), + }); + } + if read_optional_text_field(profile, &["playerPremise"]).is_none() { + blockers.push(CustomWorldPublishBlockerSnapshot { + blocker_id: "publish_missing_player_premise".to_string(), + code: "publish_missing_player_premise".to_string(), + message: "当前世界缺少玩家身份与切入前提,发布前需要先补齐玩家 premise。".to_string(), + }); + } + if !json_array_has_non_empty_text(profile.get("coreConflicts")) { + blockers.push(CustomWorldPublishBlockerSnapshot { + blocker_id: "publish_missing_core_conflict".to_string(), + code: "publish_missing_core_conflict".to_string(), + message: "当前世界缺少核心冲突,发布前需要先补齐核心冲突。".to_string(), + }); + } + if profile + .get("chapters") + .and_then(JsonValue::as_array) + .map(|value| value.is_empty()) + .unwrap_or(true) + { + blockers.push(CustomWorldPublishBlockerSnapshot { + blocker_id: "publish_missing_main_chapter".to_string(), + code: "publish_missing_main_chapter".to_string(), + message: "当前世界还没有主线章节草稿,发布前至少要保留主线第一幕。".to_string(), + }); + } + let has_scene_act = profile + .get("sceneChapters") + .and_then(JsonValue::as_array) + .map(|chapters| { + chapters.iter().any(|chapter| { + chapter + .get("acts") + .and_then(JsonValue::as_array) + .map(|acts| !acts.is_empty()) + .unwrap_or(false) + }) + }) + .unwrap_or(false); + if !has_scene_act { + blockers.push(CustomWorldPublishBlockerSnapshot { + blocker_id: "publish_missing_first_act".to_string(), + code: "publish_missing_first_act".to_string(), + message: "当前世界还没有主线第一幕,发布前至少要保留一个场景幕。".to_string(), + }); + } + } + + for finding in quality_findings { + if finding.get("severity").and_then(JsonValue::as_str) == Some("blocker") { + blockers.push(CustomWorldPublishBlockerSnapshot { + blocker_id: finding + .get("id") + .and_then(JsonValue::as_str) + .unwrap_or("publish-quality-blocker") + .to_string(), + code: finding + .get("code") + .and_then(JsonValue::as_str) + .unwrap_or("publish_quality_blocker") + .to_string(), + message: finding + .get("message") + .and_then(JsonValue::as_str) + .unwrap_or("当前世界仍存在 blocker。") + .to_string(), + }); + } + } + + let blocker_count = blockers.len() as u32; + let publish_ready = blocker_count == 0; + CustomWorldPublishGateSnapshot { + profile_id, + blockers, + blocker_count, + publish_ready, + can_enter_world: stage == RpgAgentStage::Published && publish_ready, + } +} + +fn publish_gate_to_json_value(gate: &CustomWorldPublishGateSnapshot) -> JsonValue { + json!({ + "profileId": gate.profile_id, + "blockers": gate.blockers.iter().map(|entry| { + json!({ + "id": entry.blocker_id, + "code": entry.code, + "message": entry.message, + }) + }).collect::>(), + "blockerCount": gate.blocker_count, + "publishReady": gate.publish_ready, + "canEnterWorld": gate.can_enter_world, + }) +} + +fn build_result_preview_json( + draft_profile: Option<&JsonMap>, + gate: &CustomWorldPublishGateSnapshot, + quality_findings: &[JsonValue], + generated_at_micros: i64, +) -> Result, String> { + let Some(profile) = draft_profile else { + return Ok(None); + }; + + serialize_json_value(&json!({ + "preview": JsonValue::Object(profile.clone()), + "source": "session_preview", + "generatedAt": format_timestamp_micros(generated_at_micros), + "qualityFindings": quality_findings, + "blockers": gate.blockers.iter().map(|entry| { + json!({ + "id": entry.blocker_id, + "code": entry.code, + "message": entry.message, + }) + }).collect::>(), + "publishReady": gate.publish_ready, + "canEnterWorld": gate.can_enter_world, + })) + .map(Some) +} + +fn build_supported_actions_json( + stage: RpgAgentStage, + progress_percent: u32, + gate: &CustomWorldPublishGateSnapshot, + checkpoints: &[JsonValue], +) -> Vec { + let has_checkpoint = checkpoints + .iter() + .any(|entry| entry.get("snapshot").is_some()); + let draft_refining_enabled = + matches!(stage, RpgAgentStage::ObjectRefining | RpgAgentStage::VisualRefining); + let long_tail_enabled = matches!( + stage, + RpgAgentStage::ObjectRefining + | RpgAgentStage::VisualRefining + | RpgAgentStage::LongTailReview + | RpgAgentStage::ReadyToPublish + ); + + vec![ + build_supported_action_json( + "draft_foundation", + progress_percent >= 100, + (progress_percent < 100).then(|| "draft_foundation requires progressPercent >= 100".to_string()), + ), + build_supported_action_json( + "update_draft_card", + draft_refining_enabled, + (!draft_refining_enabled).then(|| { + "update_draft_card is only available during object_refining or visual_refining" + .to_string() + }), + ), + build_supported_action_json( + "sync_result_profile", + draft_refining_enabled, + (!draft_refining_enabled).then(|| { + "sync_result_profile is only available during object_refining or visual_refining" + .to_string() + }), + ), + build_supported_action_json( + "generate_characters", + draft_refining_enabled, + (!draft_refining_enabled).then(|| { + "generate_characters is only available during object_refining or visual_refining" + .to_string() + }), + ), + build_supported_action_json( + "generate_landmarks", + draft_refining_enabled, + (!draft_refining_enabled).then(|| { + "generate_landmarks is only available during object_refining or visual_refining" + .to_string() + }), + ), + build_supported_action_json( + "generate_role_assets", + draft_refining_enabled, + (!draft_refining_enabled).then(|| { + "generate_role_assets is only available during object_refining or visual_refining" + .to_string() + }), + ), + build_supported_action_json( + "sync_role_assets", + draft_refining_enabled, + (!draft_refining_enabled).then(|| { + "sync_role_assets is only available during object_refining or visual_refining" + .to_string() + }), + ), + build_supported_action_json( + "generate_scene_assets", + draft_refining_enabled, + (!draft_refining_enabled).then(|| { + "generate_scene_assets is only available during object_refining or visual_refining" + .to_string() + }), + ), + build_supported_action_json( + "sync_scene_assets", + draft_refining_enabled, + (!draft_refining_enabled).then(|| { + "sync_scene_assets is only available during object_refining or visual_refining" + .to_string() + }), + ), + build_supported_action_json( + "expand_long_tail", + long_tail_enabled, + (!long_tail_enabled).then(|| { + "expand_long_tail is only available during object_refining, visual_refining, long_tail_review or ready_to_publish".to_string() + }), + ), + build_supported_action_json( + "publish_world", + long_tail_enabled && gate.publish_ready, + (!long_tail_enabled) + .then(|| { + "publish_world is only available during object_refining, visual_refining, long_tail_review or ready_to_publish".to_string() + }) + .or_else(|| (!gate.publish_ready).then(|| "publish_world requires publish gate without blockers".to_string())), + ), + build_supported_action_json( + "revert_checkpoint", + long_tail_enabled && has_checkpoint, + (!long_tail_enabled) + .then(|| { + "revert_checkpoint is only available during object_refining, visual_refining, long_tail_review or ready_to_publish".to_string() + }) + .or_else(|| (!has_checkpoint).then(|| "revert_checkpoint requires at least one restorable checkpoint snapshot".to_string())), + ), + ] +} + +fn build_supported_action_json(action: &str, enabled: bool, reason: Option) -> JsonValue { + json!({ + "action": action, + "enabled": enabled, + "reason": reason, + }) +} + +fn build_custom_world_draft_card_detail_snapshot( + card: &CustomWorldDraftCard, +) -> Result { + if let Some(detail_payload_json) = card.detail_payload_json.as_deref() { + let detail_value = serde_json::from_str::(detail_payload_json) + .map_err(|error| format!("custom_world_draft_card.detail_payload_json 非法: {error}"))?; + if let Some(object) = detail_value.as_object() { + let sections = object + .get("sections") + .and_then(JsonValue::as_array) + .map(|entries| { + entries + .iter() + .filter_map(|entry| { + let object = entry.as_object()?; + Some(CustomWorldDraftCardDetailSectionSnapshot { + section_id: object.get("id")?.as_str()?.to_string(), + label: object + .get("label") + .and_then(JsonValue::as_str) + .unwrap_or_default() + .to_string(), + value: object + .get("value") + .and_then(JsonValue::as_str) + .unwrap_or_default() + .to_string(), + }) + }) + .collect::>() + }) + .unwrap_or_else(|| build_fallback_card_sections(&card)); + + return Ok(CustomWorldDraftCardDetailSnapshot { + card_id: card.card_id.clone(), + kind: card.kind, + title: object + .get("title") + .and_then(JsonValue::as_str) + .unwrap_or(card.title.as_str()) + .to_string(), + sections, + linked_ids_json: card.linked_ids_json.clone(), + locked: object.get("locked").and_then(JsonValue::as_bool).unwrap_or(false), + editable: object.get("editable").and_then(JsonValue::as_bool).unwrap_or(false), + editable_section_ids_json: serialize_json_value( + object + .get("editableSectionIds") + .unwrap_or(&JsonValue::Array(Vec::new())), + )?, + warning_messages_json: serialize_json_value( + object + .get("warningMessages") + .unwrap_or(&JsonValue::Array(Vec::new())), + )?, + asset_status: card.asset_status, + asset_status_label: card.asset_status_label.clone(), + }); + } + } + + Ok(CustomWorldDraftCardDetailSnapshot { + card_id: card.card_id.clone(), + kind: card.kind, + title: card.title.clone(), + sections: build_fallback_card_sections(card), + linked_ids_json: card.linked_ids_json.clone(), + locked: false, + editable: false, + editable_section_ids_json: "[]".to_string(), + warning_messages_json: "[]".to_string(), + asset_status: card.asset_status, + asset_status_label: card.asset_status_label.clone(), + }) +} + +fn build_fallback_card_sections(card: &CustomWorldDraftCard) -> Vec { + vec![ + CustomWorldDraftCardDetailSectionSnapshot { + section_id: "title".to_string(), + label: "标题".to_string(), + value: card.title.clone(), + }, + CustomWorldDraftCardDetailSectionSnapshot { + section_id: "subtitle".to_string(), + label: "副标题".to_string(), + value: card.subtitle.clone(), + }, + CustomWorldDraftCardDetailSectionSnapshot { + section_id: "summary".to_string(), + label: "摘要".to_string(), + value: card.summary.clone(), + }, + ] +} + +fn build_fallback_card_sections_json(card: &CustomWorldDraftCard) -> Vec { + build_fallback_card_sections(card) + .into_iter() + .map(|section| { + json!({ + "id": section.section_id, + "label": section.label, + "value": section.value, + }) + }) + .collect() +} + +fn rebuild_custom_world_agent_session_row( + current: &CustomWorldAgentSession, + patch: CustomWorldAgentSessionPatch, +) -> Result { + Ok(CustomWorldAgentSession { + session_id: current.session_id.clone(), + owner_user_id: current.owner_user_id.clone(), + seed_text: current.seed_text.clone(), + current_turn: current.current_turn, + progress_percent: patch.progress_percent.unwrap_or(current.progress_percent), + stage: patch.stage.unwrap_or(current.stage), + focus_card_id: patch.focus_card_id.unwrap_or_else(|| current.focus_card_id.clone()), + anchor_content_json: patch + .anchor_content_json + .unwrap_or_else(|| current.anchor_content_json.clone()), + creator_intent_json: patch + .creator_intent_json + .unwrap_or_else(|| current.creator_intent_json.clone()), + creator_intent_readiness_json: patch + .creator_intent_readiness_json + .unwrap_or_else(|| current.creator_intent_readiness_json.clone()), + anchor_pack_json: patch.anchor_pack_json.unwrap_or_else(|| current.anchor_pack_json.clone()), + lock_state_json: patch.lock_state_json.unwrap_or_else(|| current.lock_state_json.clone()), + draft_profile_json: patch + .draft_profile_json + .unwrap_or_else(|| current.draft_profile_json.clone()), + last_assistant_reply: patch + .last_assistant_reply + .unwrap_or_else(|| current.last_assistant_reply.clone()), + publish_gate_json: patch + .publish_gate_json + .unwrap_or_else(|| current.publish_gate_json.clone()), + result_preview_json: patch + .result_preview_json + .unwrap_or_else(|| current.result_preview_json.clone()), + pending_clarifications_json: patch + .pending_clarifications_json + .unwrap_or_else(|| current.pending_clarifications_json.clone()), + quality_findings_json: patch + .quality_findings_json + .unwrap_or_else(|| current.quality_findings_json.clone()), + suggested_actions_json: patch + .suggested_actions_json + .unwrap_or_else(|| current.suggested_actions_json.clone()), + recommended_replies_json: patch + .recommended_replies_json + .unwrap_or_else(|| current.recommended_replies_json.clone()), + asset_coverage_json: patch + .asset_coverage_json + .unwrap_or_else(|| current.asset_coverage_json.clone()), + checkpoints_json: patch + .checkpoints_json + .unwrap_or_else(|| current.checkpoints_json.clone()), + created_at: current.created_at, + updated_at: Timestamp::from_micros_since_unix_epoch( + patch + .updated_at_micros + .unwrap_or_else(|| current.updated_at.to_micros_since_unix_epoch()), + ), + }) +} + +fn replace_custom_world_agent_session( + ctx: &ReducerContext, + current: &CustomWorldAgentSession, + next: CustomWorldAgentSession, +) { + ctx.db + .custom_world_agent_session() + .session_id() + .delete(¤t.session_id); + ctx.db.custom_world_agent_session().insert(next); +} + +fn replace_custom_world_draft_card( + ctx: &ReducerContext, + current: &CustomWorldDraftCard, + next: CustomWorldDraftCard, +) { + ctx.db + .custom_world_draft_card() + .card_id() + .delete(¤t.card_id); + ctx.db.custom_world_draft_card().insert(next); +} + +fn build_and_insert_custom_world_operation( + ctx: &ReducerContext, + operation_id: &str, + session_id: &str, + operation_type: RpgAgentOperationType, + phase_label: &str, + phase_detail: &str, + timestamp_micros: i64, +) -> CustomWorldAgentOperation { + let row = CustomWorldAgentOperation { + operation_id: operation_id.to_string(), + session_id: session_id.to_string(), + operation_type, + status: RpgAgentOperationStatus::Completed, + phase_label: phase_label.to_string(), + phase_detail: phase_detail.to_string(), + progress: 100, + error_message: None, + created_at: Timestamp::from_micros_since_unix_epoch(timestamp_micros), + updated_at: Timestamp::from_micros_since_unix_epoch(timestamp_micros), + }; + ctx.db.custom_world_agent_operation().insert(row) +} + +fn append_custom_world_action_result_message( + ctx: &ReducerContext, + session_id: &str, + operation_id: &str, + text: &str, + timestamp_micros: i64, +) { + let row = CustomWorldAgentMessage { + message_id: format!("message-action-{}-{}", operation_id, timestamp_micros), + session_id: session_id.to_string(), + role: RpgAgentMessageRole::Assistant, + kind: RpgAgentMessageKind::ActionResult, + text: text.to_string(), + related_operation_id: Some(operation_id.to_string()), + created_at: Timestamp::from_micros_since_unix_epoch(timestamp_micros), + }; + ctx.db.custom_world_agent_message().insert(row); +} + +fn upsert_world_foundation_card( + ctx: &ReducerContext, + session_id: &str, + draft_profile: &JsonMap, + updated_at_micros: i64, +) -> Result<(), String> { + let card_id = "world-foundation".to_string(); + let title = read_optional_text_field(draft_profile, &["name", "title"]) + .unwrap_or_else(|| "世界底稿".to_string()); + let subtitle = read_optional_text_field(draft_profile, &["subtitle"]).unwrap_or_default(); + let summary = read_optional_text_field(draft_profile, &["summary"]) + .unwrap_or_else(|| "第一版世界底稿已生成。".to_string()); + let detail_payload_json = serialize_json_value(&json!({ + "id": card_id, + "kind": "world", + "title": title, + "sections": [ + { "id": "title", "label": "标题", "value": read_optional_text_field(draft_profile, &["name", "title"]).unwrap_or_else(|| "世界底稿".to_string()) }, + { "id": "subtitle", "label": "副标题", "value": subtitle }, + { "id": "summary", "label": "摘要", "value": summary }, + ], + "linkedIds": [], + "locked": false, + "editable": false, + "editableSectionIds": [], + "warningMessages": [], + }))?; + + if let Some(existing) = ctx + .db + .custom_world_draft_card() + .card_id() + .find(&card_id) + .filter(|row| row.session_id == session_id) + { + replace_custom_world_draft_card( + ctx, + &existing, + CustomWorldDraftCard { + card_id: existing.card_id.clone(), + session_id: existing.session_id.clone(), + kind: RpgAgentDraftCardKind::World, + status: RpgAgentDraftCardStatus::Confirmed, + title: read_optional_text_field(draft_profile, &["name", "title"]) + .unwrap_or_else(|| "世界底稿".to_string()), + subtitle: read_optional_text_field(draft_profile, &["subtitle"]).unwrap_or_default(), + summary: read_optional_text_field(draft_profile, &["summary"]) + .unwrap_or_else(|| "第一版世界底稿已生成。".to_string()), + linked_ids_json: "[]".to_string(), + warning_count: 0, + asset_status: None, + asset_status_label: None, + detail_payload_json: Some(detail_payload_json), + created_at: existing.created_at, + updated_at: Timestamp::from_micros_since_unix_epoch(updated_at_micros), + }, + ); + } else { + ctx.db.custom_world_draft_card().insert(CustomWorldDraftCard { + card_id, + session_id: session_id.to_string(), + kind: RpgAgentDraftCardKind::World, + status: RpgAgentDraftCardStatus::Confirmed, + title: read_optional_text_field(draft_profile, &["name", "title"]) + .unwrap_or_else(|| "世界底稿".to_string()), + subtitle: read_optional_text_field(draft_profile, &["subtitle"]).unwrap_or_default(), + summary: read_optional_text_field(draft_profile, &["summary"]) + .unwrap_or_else(|| "第一版世界底稿已生成。".to_string()), + linked_ids_json: "[]".to_string(), + warning_count: 0, + asset_status: None, + asset_status_label: None, + detail_payload_json: Some(detail_payload_json), + created_at: Timestamp::from_micros_since_unix_epoch(updated_at_micros), + updated_at: Timestamp::from_micros_since_unix_epoch(updated_at_micros), + }); + } + + Ok(()) +} + +fn sync_session_draft_profile_from_card_update( + session: &CustomWorldAgentSession, + card: &CustomWorldDraftCard, + updated_title: &str, + updated_subtitle: &str, + updated_summary: &str, + updated_at_micros: i64, +) -> Result { + let mut draft_profile = parse_optional_session_object(session.draft_profile_json.as_deref()) + .unwrap_or_else(|| build_minimal_draft_profile_from_seed(&session.seed_text)); + if card.kind == RpgAgentDraftCardKind::World { + draft_profile.insert("name".to_string(), JsonValue::String(updated_title.to_string())); + draft_profile.insert( + "subtitle".to_string(), + JsonValue::String(updated_subtitle.to_string()), + ); + draft_profile.insert( + "summary".to_string(), + JsonValue::String(updated_summary.to_string()), + ); + } + + let gate = summarize_publish_gate_from_json( + &session.session_id, + session.stage, + Some(&draft_profile), + &parse_json_array_or_empty(&session.quality_findings_json), + ); + rebuild_custom_world_agent_session_row( + session, + CustomWorldAgentSessionPatch { + draft_profile_json: Some(Some(serialize_json_value(&JsonValue::Object(draft_profile.clone()))?)), + publish_gate_json: Some(Some(serialize_json_value(&publish_gate_to_json_value(&gate))?)), + result_preview_json: Some(build_result_preview_json( + Some(&draft_profile), + &gate, + &parse_json_array_or_empty(&session.quality_findings_json), + updated_at_micros, + )?), + last_assistant_reply: Some(Some(format!("卡片《{}》已更新。", updated_title))), + updated_at_micros: Some(updated_at_micros), + ..CustomWorldAgentSessionPatch::default() + }, + ) +} + +fn ensure_refining_stage(stage: RpgAgentStage, action: &str) -> Result<(), String> { + if matches!(stage, RpgAgentStage::ObjectRefining | RpgAgentStage::VisualRefining) { + Ok(()) + } else { + Err(format!( + "{action} is only available during object_refining or visual_refining" + )) + } +} + +fn ensure_long_tail_stage(stage: RpgAgentStage, action: &str) -> Result<(), String> { + if matches!( + stage, + RpgAgentStage::ObjectRefining + | RpgAgentStage::VisualRefining + | RpgAgentStage::LongTailReview + | RpgAgentStage::ReadyToPublish + ) { + Ok(()) + } else { + Err(format!( + "{action} is only available during object_refining, visual_refining, long_tail_review or ready_to_publish" + )) + } +} + +fn ensure_publishable_stage(stage: RpgAgentStage, action: &str) -> Result<(), String> { + ensure_long_tail_stage(stage, action) +} + +fn map_action_name_to_operation_type(action: &str) -> Option { + match action { + "draft_foundation" => Some(RpgAgentOperationType::DraftFoundation), + "update_draft_card" => Some(RpgAgentOperationType::UpdateDraftCard), + "sync_result_profile" => Some(RpgAgentOperationType::SyncResultProfile), + "generate_characters" => Some(RpgAgentOperationType::GenerateCharacters), + "generate_landmarks" => Some(RpgAgentOperationType::GenerateLandmarks), + "generate_role_assets" => Some(RpgAgentOperationType::GenerateRoleAssets), + "sync_role_assets" => Some(RpgAgentOperationType::SyncRoleAssets), + "generate_scene_assets" => Some(RpgAgentOperationType::GenerateSceneAssets), + "sync_scene_assets" => Some(RpgAgentOperationType::SyncSceneAssets), + "expand_long_tail" => Some(RpgAgentOperationType::ExpandLongTail), + "publish_world" => Some(RpgAgentOperationType::PublishWorld), + "revert_checkpoint" => Some(RpgAgentOperationType::RevertCheckpoint), + _ => None, + } +} + +fn parse_rpg_agent_stage(value: &str) -> Option { + match value.trim() { + "collecting_intent" => Some(RpgAgentStage::CollectingIntent), + "clarifying" => Some(RpgAgentStage::Clarifying), + "foundation_review" => Some(RpgAgentStage::FoundationReview), + "object_refining" => Some(RpgAgentStage::ObjectRefining), + "visual_refining" => Some(RpgAgentStage::VisualRefining), + "long_tail_review" => Some(RpgAgentStage::LongTailReview), + "ready_to_publish" => Some(RpgAgentStage::ReadyToPublish), + "published" => Some(RpgAgentStage::Published), + "error" => Some(RpgAgentStage::Error), + _ => None, + } +} + +fn resolve_rpg_agent_stage_label(stage: RpgAgentStage) -> &'static str { + match stage { + RpgAgentStage::CollectingIntent => "收集世界锚点", + RpgAgentStage::Clarifying => "补齐关键锚点", + RpgAgentStage::FoundationReview => "准备整理底稿", + RpgAgentStage::ObjectRefining => "待完善草稿", + RpgAgentStage::VisualRefining => "视觉工坊", + RpgAgentStage::LongTailReview => "扩展长尾", + RpgAgentStage::ReadyToPublish => "准备发布", + RpgAgentStage::Published => "已发布", + RpgAgentStage::Error => "发生错误", + } +} + +fn parse_optional_session_object(value: Option<&str>) -> Option> { + value + .map(str::trim) + .filter(|value| !value.is_empty()) + .and_then(|value| serde_json::from_str::(value).ok()) + .and_then(|value| value.as_object().cloned()) +} + +fn parse_json_array_or_empty(raw: &str) -> Vec { + serde_json::from_str::(raw) + .ok() + .and_then(|value| value.as_array().cloned()) + .unwrap_or_default() +} + +fn serialize_json_value(value: &JsonValue) -> Result { + serde_json::to_string(value).map_err(|error| format!("JSON 序列化失败: {error}")) +} + +fn read_required_payload_text( + payload: &JsonMap, + key: &str, + error_message: &str, +) -> Result { + payload + .get(key) + .and_then(JsonValue::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(ToOwned::to_owned) + .ok_or_else(|| error_message.to_string()) +} + +fn read_optional_text_field( + object: &JsonMap, + keys: &[&str], +) -> Option { + for key in keys { + let mut current = JsonValue::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 resolve_session_work_title( + session: &CustomWorldAgentSession, + draft_profile: Option<&JsonMap>, +) -> String { + draft_profile + .and_then(|profile| read_optional_text_field(profile, &["name", "title"])) + .or_else(|| { + let seed = session.seed_text.trim(); + (!seed.is_empty()).then(|| seed.to_string()) + }) + .unwrap_or_else(|| "未命名草稿".to_string()) +} + +fn resolve_session_work_summary( + session: &CustomWorldAgentSession, + draft_profile: Option<&JsonMap>, +) -> String { + draft_profile + .and_then(|profile| read_optional_text_field(profile, &["summary"])) + .or_else(|| { + let seed = session.seed_text.trim(); + (!seed.is_empty()).then(|| seed.to_string()) + }) + .unwrap_or_else(|| "还在收集你的世界锚点。".to_string()) +} + +fn resolve_session_work_subtitle( + draft_profile: Option<&JsonMap>, + stage_label: Option<&str>, +) -> String { + draft_profile + .and_then(|profile| read_optional_text_field(profile, &["subtitle"])) + .or_else(|| stage_label.map(ToOwned::to_owned)) + .unwrap_or_default() +} + +fn resolve_session_work_cover_image_src( + draft_profile: Option<&JsonMap>, +) -> Option { + let profile = draft_profile?; + if let Some(camp) = profile.get("camp").and_then(JsonValue::as_object) { + if let Some(image_src) = read_optional_text_field(camp, &["imageSrc"]) { + return Some(image_src); + } + } + if let Some(landmarks) = profile.get("landmarks").and_then(JsonValue::as_array) { + for landmark in landmarks { + if let Some(object) = landmark.as_object() { + if let Some(image_src) = read_optional_text_field(object, &["imageSrc"]) { + return Some(image_src); + } + } + } + } + None +} + +fn resolve_session_work_counts( + ctx: &ReducerContext, + session: &CustomWorldAgentSession, + draft_profile: Option<&JsonMap>, +) -> (u32, u32) { + if let Some(profile) = draft_profile { + let role_count = profile + .get("playableNpcs") + .and_then(JsonValue::as_array) + .map(|entries| entries.len() as u32) + .unwrap_or(0) + + profile + .get("storyNpcs") + .and_then(JsonValue::as_array) + .map(|entries| entries.len() as u32) + .unwrap_or(0); + let landmark_count = profile + .get("landmarks") + .and_then(JsonValue::as_array) + .map(|entries| entries.len() as u32) + .unwrap_or(0); + return (role_count, landmark_count); + } + + let mut role_count = 0u32; + let mut landmark_count = 0u32; + for card in ctx + .db + .custom_world_draft_card() + .iter() + .filter(|row| row.session_id == session.session_id) + { + match card.kind { + RpgAgentDraftCardKind::Character => { + role_count = role_count.saturating_add(1); + } + RpgAgentDraftCardKind::Landmark => { + landmark_count = landmark_count.saturating_add(1); + } + _ => {} + } + } + + (role_count, landmark_count) +} + +fn ensure_minimal_draft_profile( + mut profile: JsonMap, + seed_text: &str, +) -> JsonMap { + if read_optional_text_field(&profile, &["name", "title"]).is_none() { + profile.insert( + "name".to_string(), + JsonValue::String(seed_text.trim().to_string().if_empty("未命名草稿")), + ); + } + if read_optional_text_field(&profile, &["summary"]).is_none() { + profile.insert( + "summary".to_string(), + JsonValue::String( + (!seed_text.trim().is_empty()) + .then(|| seed_text.trim().to_string()) + .unwrap_or_else(|| "还在收集你的世界锚点。".to_string()), + ), + ); + } + profile + .entry("subtitle".to_string()) + .or_insert_with(|| JsonValue::String(String::new())); + profile + .entry("worldHook".to_string()) + .or_insert_with(|| JsonValue::String(String::new())); + profile + .entry("playerPremise".to_string()) + .or_insert_with(|| JsonValue::String(String::new())); + profile + .entry("coreConflicts".to_string()) + .or_insert_with(|| JsonValue::Array(Vec::new())); + profile + .entry("playableNpcs".to_string()) + .or_insert_with(|| JsonValue::Array(Vec::new())); + profile + .entry("storyNpcs".to_string()) + .or_insert_with(|| JsonValue::Array(Vec::new())); + profile + .entry("landmarks".to_string()) + .or_insert_with(|| JsonValue::Array(Vec::new())); + profile + .entry("chapters".to_string()) + .or_insert_with(|| JsonValue::Array(Vec::new())); + profile + .entry("sceneChapters".to_string()) + .or_insert_with(|| JsonValue::Array(Vec::new())); + profile +} + +fn build_minimal_draft_profile_from_seed(seed_text: &str) -> JsonMap { + ensure_minimal_draft_profile(JsonMap::new(), seed_text) +} + +fn build_session_checkpoint_value( + checkpoint_id_suffix: &str, + label: &str, + session: &CustomWorldAgentSession, +) -> JsonValue { + json!({ + "checkpointId": format!("checkpoint-{}-{}", session.session_id, checkpoint_id_suffix), + "createdAt": format_timestamp_micros(session.updated_at.to_micros_since_unix_epoch()), + "label": label, + "snapshot": { + "stage": session.stage.as_str(), + "progressPercent": session.progress_percent, + "draftProfile": parse_optional_session_object(session.draft_profile_json.as_deref()).map(JsonValue::Object), + "qualityFindings": parse_json_array_or_empty(&session.quality_findings_json), + } + }) +} + +fn append_checkpoint_json(current: &str, checkpoint: &JsonValue) -> Result { + let mut checkpoints = parse_json_array_or_empty(current); + checkpoints.push(checkpoint.clone()); + serialize_json_value(&JsonValue::Array(checkpoints)) +} + +fn extract_detail_section_value(sections: &[JsonValue], target_id: &str) -> Option { + sections.iter().find_map(|entry| { + let object = entry.as_object()?; + (object.get("id").and_then(JsonValue::as_str) == Some(target_id)) + .then(|| { + object + .get("value") + .and_then(JsonValue::as_str) + .unwrap_or_default() + .to_string() + }) + }) +} + +fn json_array_has_non_empty_text(value: Option<&JsonValue>) -> bool { + value + .and_then(JsonValue::as_array) + .map(|entries| entries.iter().any(|entry| entry.as_str().map(str::trim).filter(|text| !text.is_empty()).is_some())) + .unwrap_or(false) +} + +trait IfEmptyString { + fn if_empty(self, fallback: &str) -> String; +} + +impl IfEmptyString for String { + fn if_empty(self, fallback: &str) -> String { + if self.trim().is_empty() { + fallback.to_string() + } else { + self + } + } +} + fn mark_custom_world_agent_session_published( ctx: &ReducerContext, session_id: &str, @@ -4319,6 +5352,13 @@ fn build_custom_world_agent_session_snapshot( recommended_replies_json: row.recommended_replies_json.clone(), asset_coverage_json: row.asset_coverage_json.clone(), checkpoints_json: row.checkpoints_json.clone(), + supported_actions_json: serialize_json_value(&JsonValue::Array(build_supported_actions_json( + row.stage, + row.progress_percent, + &build_custom_world_publish_gate_from_session(row), + &parse_json_array_or_empty(&row.checkpoints_json), + ))) + .unwrap_or_else(|_| "[]".to_string()), messages, draft_cards, operations,