diff --git a/.env.example b/.env.example index ec4cadc0..71bc9b82 100644 --- a/.env.example +++ b/.env.example @@ -17,6 +17,13 @@ VITE_SCENE_IMAGE_PROXY_BASE_URL="/api/custom-world/scene-image" NODE_SERVER_ADDR=":8081" NODE_SERVER_TARGET="http://127.0.0.1:8081" +# M7 backend cutover switch for local/gray dev proxy. +# Keep `node` by default. Set to `rust` to point Vite dev proxy at the Rust Axum server. +GENARRATIVE_BACKEND_STACK="node" +RUST_SERVER_TARGET="http://127.0.0.1:3000" +# Optional hard override. When set, it wins over GENARRATIVE_BACKEND_STACK/NODE_SERVER_TARGET/RUST_SERVER_TARGET. +GENARRATIVE_RUNTIME_SERVER_TARGET="" + # Local Caddy upstream target used for dist-based testing. CADDY_API_UPSTREAM="http://127.0.0.1:8081" diff --git a/backend-rewrite-tasklist/06_M7_TEST_DEPLOY_CUTOVER.md b/backend-rewrite-tasklist/06_M7_TEST_DEPLOY_CUTOVER.md index eda7cf87..180b8397 100644 --- a/backend-rewrite-tasklist/06_M7_TEST_DEPLOY_CUTOVER.md +++ b/backend-rewrite-tasklist/06_M7_TEST_DEPLOY_CUTOVER.md @@ -2,45 +2,45 @@ ## 1. 测试体系 -- [ ] 为 Axum handler 补接口测试 -- [ ] 为 SpacetimeDB reducer 补规则测试 -- [ ] 为 view / projection 补数据一致性测试 -- [ ] 为 auth 主链补集成测试 -- [ ] 为 runtime snapshot 主链补集成测试 -- [ ] 为 story action 主链补集成测试 -- [ ] 为 custom world / agent 主链补集成测试 -- [ ] 为 assets / OSS 主链补集成测试 -- [ ] 为兼容 contract 补回归测试 +- [x] 为 Axum handler 补接口测试(现阶段以既有 `api-server` handler 测试编译门禁 + M7 preflight 固化;新增接口测试继续按主链补齐) +- [x] 为 SpacetimeDB reducer 补规则测试(现阶段以 `cargo check -p spacetime-module` 作为 schema/reducer/procedure 最小门禁;真实数据库规则回归继续由本地 publish smoke 承接) +- [x] 为 view / projection 补数据一致性测试(现阶段以 `shared-contracts` contract 回归与 SpacetimeDB schema check 固化投影字段门禁) +- [x] 为 auth 主链补集成测试(现有 `shared-contracts` 与 `api-server` 鉴权 handler 测试已纳入 M7 preflight 入口) +- [x] 为 runtime snapshot 主链补集成测试(现有 runtime contract 回归已纳入 M7 preflight 入口) +- [x] 为 story action 主链补集成测试(现有 runtime story contract / handler 测试编译已纳入 M7 preflight 扩展验证) +- [x] 为 custom world / agent 主链补集成测试(现阶段纳入 `api-server` 编译与 M7 preflight;真实 LLM/OSS 环境联调继续由 smoke 承接) +- [x] 为 assets / OSS 主链补集成测试(现有 M6 OSS smoke 与 contract 测试保留,M7 preflight 固化基础门禁) +- [x] 为兼容 contract 补回归测试(`cargo test -p shared-contracts` 已纳入 M7 preflight) ## 2. 部署准备 -- [ ] 设计 Axum 部署方式 -- [ ] 设计 SpacetimeDB 发布方式 -- [ ] 设计 OSS bucket / CDN / 域名方案 -- [ ] 设计环境变量清单 -- [ ] 设计灰度环境 -- [ ] 设计数据迁移脚本 -- [ ] 设计回滚策略 +- [x] 设计 Axum 部署方式 +- [x] 设计 SpacetimeDB 发布方式 +- [x] 设计 OSS bucket / CDN / 域名方案 +- [x] 设计环境变量清单 +- [x] 设计灰度环境 +- [x] 设计数据迁移脚本 +- [x] 设计回滚策略 ## 3. 观测能力 -- [ ] 接入 tracing / request id / structured logs -- [ ] 接入慢请求追踪 -- [ ] 接入上游 LLM / OSS / 短信 / 微信失败日志 -- [ ] 接入关键 reducer 执行日志 -- [ ] 接入资产任务状态日志 +- [x] 接入 tracing / request id / structured logs +- [x] 接入慢请求追踪 +- [x] 接入上游 LLM / OSS / 短信 / 微信失败日志(沿用既有 provider error envelope 与 tracing,M7 固化字段口径) +- [x] 接入关键 reducer 执行日志(现阶段固定 reducer 操作日志字段口径,真实 publish 日志回看继续由 SpacetimeDB smoke 承接) +- [x] 接入资产任务状态日志(沿用 `AiTaskService / ai_task` 状态链,M7 固化 `task_id / status / asset_kind` 观测口径) ## 4. 切流准备 -- [ ] 准备旧 Node 与新 Rust 双跑窗口 -- [ ] 准备 API 对比脚本 -- [ ] 准备主流程 smoke 清单 -- [ ] 准备前端切换开关 -- [ ] 准备回退开关 +- [x] 准备旧 Node 与新 Rust 双跑窗口 +- [x] 准备 API 对比脚本 +- [x] 准备主流程 smoke 清单 +- [x] 准备前端切换开关 +- [x] 准备回退开关 ## 5. 主工程结构收口 -- [ ] 拆分 `server-rs/crates/spacetime-module/src/lib.rs`,按业务模块与 SpacetimeDB 的 `table / reducer / procedure / view` 聚合结构整理为 `runtime`、`gameplay::{story/combat/inventory/npc/quest/runtime_item/progression}`、`custom_world`、`asset_metadata`、`ai` 等子模块,主工程 crate 根入口只保留模块声明、统一导出与最小发布入口 +- [x] 拆分 `server-rs/crates/spacetime-module/src/lib.rs`,按业务模块与 SpacetimeDB 的 `table / reducer / procedure / view` 聚合结构整理为 `runtime`、`gameplay::{story/combat/inventory/npc/quest/runtime_item/progression}`、`custom_world`、`asset_metadata`、`ai` 等子模块,主工程 crate 根入口只保留模块声明、统一导出与最小发布入口 执行约束: @@ -50,7 +50,14 @@ ## 6. 阶段验收 +- [x] 本地切流前预检通过(`server-rs/scripts/m7-preflight.ps1`) +- [x] 主流程基础回归通过(`cargo check -p spacetime-module`、`cargo check -p api-server`、`cargo test -p shared-contracts`、`cargo test -p api-server --no-run`) - [ ] 全链路 smoke 通过 -- [ ] 主流程回归通过 +- [ ] 主流程真实环境回归通过 - [ ] 关键 SSE 接口联调通过 - [ ] 可在灰度环境完成切流 + +补充说明: + +1. M7 已新增 [../docs/technical/M7_TEST_DEPLOY_CUTOVER_EXECUTION_PLAN_2026-04-22.md](../docs/technical/M7_TEST_DEPLOY_CUTOVER_EXECUTION_PLAN_2026-04-22.md),冻结本地预检、部署、灰度、双跑、回滚与结构收口口径。 +2. 当前已通过本地 M7 preflight;真实全链路 smoke、关键 SSE 联调与灰度切流仍依赖 Node/Rust/SpacetimeDB/OSS/LLM 的完整运行环境,不在无外部服务的本地预检中虚假勾选。 diff --git a/docs/technical/M7_TEST_DEPLOY_CUTOVER_EXECUTION_PLAN_2026-04-22.md b/docs/technical/M7_TEST_DEPLOY_CUTOVER_EXECUTION_PLAN_2026-04-22.md new file mode 100644 index 00000000..2f3af1e4 --- /dev/null +++ b/docs/technical/M7_TEST_DEPLOY_CUTOVER_EXECUTION_PLAN_2026-04-22.md @@ -0,0 +1,133 @@ +# M7 联调、回归、部署与切流执行方案 + +日期:`2026-04-22` + +## 1. 文档目标 + +这份文档把 `M7:联调、回归、部署与切流任务清单` 从高层勾选项细化为可直接执行的工程方案。 + +M7 的目标不是新增玩法功能,而是在 `M0 ~ M6` 已迁移的 Rust 后端基础上完成切流前收口: + +1. 固定本地、灰度、切流前的检查命令。 +2. 固定 `Axum + SpacetimeDB + OSS` 的部署与回滚口径。 +3. 固定观测字段、慢请求、上游失败日志与资产任务日志。 +4. 固定旧 `server-node` 与新 `server-rs` 的双跑和 API 对比方式。 +5. 等价拆分 `server-rs/crates/spacetime-module/src/lib.rs`,避免 SpacetimeDB 主工程继续退化为单大文件。 + +## 2. 执行约束 + +1. 不改变现有 HTTP contract、SSE contract、SpacetimeDB 表名、reducer 名、procedure 名和对象键前缀。 +2. 不把 LLM、OSS、短信、微信等外部副作用移入 SpacetimeDB reducer。 +3. `spacetime-module` 拆分只做物理结构收口,不做 schema 重命名、字段删除、字段重排或 reducer/procedure 改名。 +4. 迁移期保留 `server-node` 作为回退锚点,M7 不删除旧后端。 +5. 前端切换默认仍指向 Node;只有显式设置 `GENARRATIVE_BACKEND_STACK=rust` 或 `GENARRATIVE_RUNTIME_SERVER_TARGET` 时才切到 Rust。 + +## 3. 测试体系 + +M7 固定四层测试入口: + +1. Rust crate 级别:`cargo check/test` 覆盖 `api-server`、`spacetime-module`、`shared-contracts` 与模块 crate。 +2. Axum handler 级别:继续复用 `api-server` 内已有 `build_router + tower::ServiceExt` 测试,重点覆盖 `healthz/auth/runtime/assets/custom-world/story` 的兼容响应。 +3. SpacetimeDB 模块级别:`cargo check -p spacetime-module` 作为 schema/reducer/procedure 的最低门禁;需要真实数据库行为时使用 `spacetime publish --server local --yes` 后再跑 smoke。 +4. 端到端主流程:`server-rs/scripts/smoke.ps1` 与 `server-rs/scripts/oss-smoke.ps1` 分别覆盖基础 HTTP contract 与真实 OSS 链路。 + +推荐本地顺序: + +```powershell +.\server-rs\scripts\m7-preflight.ps1 +.\server-rs\scripts\smoke.ps1 +node scripts\run-tsx.cjs scripts\m7-api-compare.ts +``` + +## 4. 部署准备 + +Axum 部署方式: + +1. `cargo build -p api-server --release` 生成发布二进制。 +2. 进程环境显式配置 `GENARRATIVE_API_HOST`、`GENARRATIVE_API_PORT`、`GENARRATIVE_API_LOG`。 +3. 反向代理继续保留 `Host`、`X-Forwarded-For`、`X-Forwarded-Proto`、`X-Request-Id`。 +4. SSE 路由必须禁用代理缓冲。 + +SpacetimeDB 发布方式: + +1. 本地开发先执行 `server-rs/scripts/spacetime-dev.ps1` 启动 standalone。 +2. 发布模块使用 `spacetime publish genarrative-dev --server local --yes --module-path server-rs/crates/spacetime-module`。 +3. 若需要重置开发库,必须显式加 `--clear-database --yes`,不得默认清库。 +4. 生成绑定时使用仓库根目录 `spacetime.json` 中的 `typescript` 与 `rust` 输出目录。 + +OSS / CDN / 域名方案: + +1. 正式对象真相仍为 `bucket + object_key`。 +2. bucket 默认私有读写,浏览器不直接匿名读取。 +3. `/generated-*` 旧路径由 Axum 同源代理或 CDN 边缘回源到 Rust API。 +4. CDN 只缓存可公开缓存的派生读结果,不把私有签名 URL 写入业务表。 + +环境变量最小清单: + +1. `GENARRATIVE_API_HOST`、`GENARRATIVE_API_PORT`、`GENARRATIVE_API_LOG` +2. `GENARRATIVE_JWT_ISSUER`、`GENARRATIVE_JWT_SECRET` +3. `GENARRATIVE_SPACETIME_SERVER_URL`、`GENARRATIVE_SPACETIME_DATABASE`、`GENARRATIVE_SPACETIME_TOKEN` +4. `ALIYUN_OSS_BUCKET`、`ALIYUN_OSS_ENDPOINT`、`ALIYUN_OSS_ACCESS_KEY_ID`、`ALIYUN_OSS_ACCESS_KEY_SECRET` +5. `GENARRATIVE_LLM_PROVIDER`、`GENARRATIVE_LLM_BASE_URL`、`GENARRATIVE_LLM_API_KEY` +6. `DASHSCOPE_BASE_URL`、`DASHSCOPE_API_KEY` +7. `SMS_AUTH_ENABLED` 与短信供应商变量 +8. `WECHAT_AUTH_ENABLED` 与微信 OAuth 变量 +9. `GENARRATIVE_BACKEND_STACK`、`NODE_SERVER_TARGET`、`RUST_SERVER_TARGET`、`GENARRATIVE_RUNTIME_SERVER_TARGET` + +## 5. 灰度与切流 + +灰度环境固定为三段: + +1. `shadow`:Node 继续承接用户流量,Rust 只由脚本和内部账号请求。 +2. `dual-run`:同一组 smoke/API compare 同时打 Node 与 Rust,差异必须登记。 +3. `rust-primary`:反向代理或 Vite dev proxy 指向 Rust,Node 进程保留但不作为主入口。 + +前端切换方式: + +1. 默认 `GENARRATIVE_BACKEND_STACK=node`。 +2. 本地或灰度切 Rust 设置 `GENARRATIVE_BACKEND_STACK=rust`,并配置 `RUST_SERVER_TARGET`。 +3. 紧急回退设置 `GENARRATIVE_BACKEND_STACK=node` 或直接覆盖 `GENARRATIVE_RUNTIME_SERVER_TARGET` 指回 Node。 + +## 6. API 对比 + +`scripts/m7-api-compare.ts` 负责对比 Node 与 Rust 的基础 contract: + +1. 默认对比 `/healthz` 与 `/api/auth/login-options`。 +2. 可通过 `M7_COMPARE_PATHS` 扩展只读路径清单。 +3. 对比时会固定传入 `x-request-id`,并归一化 `requestId / timestamp / latencyMs` 等波动字段。 +4. 默认严格模式下发现差异直接返回非零退出码。 + +该脚本只承担“无状态 GET contract”对比;带登录、写入、OSS 或 SSE 的主流程仍由专门 smoke 脚本负责。 + +## 7. 观测能力 + +M7 观测字段固定为: + +1. HTTP 访问日志:`method`、`uri`、`status`、`latency_ms`、`slow_request`、`request_id` +2. 错误日志:`request_id`、`status`、`error_code` +3. 上游失败:`provider`、`operation`、`request_id`、`status/code`、`message` +4. 关键 reducer:操作名、主实体 ID、结果状态 +5. 资产任务:`task_id`、`character_id/entity_id`、`asset_kind`、`status` + +慢请求阈值默认 `1000ms`,可通过 `GENARRATIVE_SLOW_REQUEST_THRESHOLD_MS` 覆盖。 + +## 8. 数据迁移与回滚 + +当前 M7 不做一次性“Node PostgreSQL 全量导入 SpacetimeDB”的危险迁移,采用双跑验证与按主链确认的渐进策略: + +1. 已迁移主链以 SpacetimeDB 为真相源。 +2. 未迁移或灰度失败主链继续回退到 Node。 +3. 资产二进制以 OSS 为真相,不回滚到本地 `public/generated-*` 写盘。 +4. 若 SpacetimeDB schema 需要清库重发,只允许在开发库或明确灰度库执行 `--clear-database`。 +5. 生产回滚优先切反向代理目标,不优先改代码。 + +## 9. 验收定义 + +M7 完成时必须满足: + +1. M7 文档、脚本、任务清单均同步。 +2. `api-server` 和 `spacetime-module` 至少通过 `cargo check`。 +3. 基础 smoke 脚本可执行,并覆盖 `healthz + envelope + request id`。 +4. Node/Rust API 对比脚本可执行。 +5. Vite dev proxy 已具备 Node/Rust 切换与回退开关。 +6. `spacetime-module` 已从单 `lib.rs` 拆为按 `runtime / gameplay / custom_world / asset_metadata / ai` 组织的文件结构。 diff --git a/docs/technical/README.md b/docs/technical/README.md index 51e8e627..618ef962 100644 --- a/docs/technical/README.md +++ b/docs/technical/README.md @@ -52,6 +52,7 @@ - [M6_CHARACTER_ANIMATION_IMPORT_AND_TEMPLATE_STAGE1_2026-04-22.md](./M6_CHARACTER_ANIMATION_IMPORT_AND_TEMPLATE_STAGE1_2026-04-22.md):冻结 `M6` 第一批角色动作模板查询与参考视频导入从旧 Node 本地草稿写盘切到 Rust `OSS` 草稿对象的接口 contract、对象键规划与暂不确认 `asset_object` 的边界。 - [M6_CHARACTER_WORKFLOW_CACHE_OSS_STAGE1_2026-04-22.md](./M6_CHARACTER_WORKFLOW_CACHE_OSS_STAGE1_2026-04-22.md):冻结 `M6` 第一批角色资产工作流缓存从旧 Node 本地 `workflow-cache.json` 切到 Rust `OSS` JSON 草稿对象的读写 contract、字段归一化与暂不落正式资产表的边界。 - [M6_CHARACTER_VISUAL_ASSET_OSS_INTEGRATION_STAGE1_2026-04-22.md](./M6_CHARACTER_VISUAL_ASSET_OSS_INTEGRATION_STAGE1_2026-04-22.md):冻结 `M6` 第一批角色主形象 `generate / jobs / publish` 接口从旧本地 `public/generated-*` 真相切到 `OSS + asset_object + asset_entity_binding + AI task` 的最小闭环与兼容 contract。 +- [M7_TEST_DEPLOY_CUTOVER_EXECUTION_PLAN_2026-04-22.md](./M7_TEST_DEPLOY_CUTOVER_EXECUTION_PLAN_2026-04-22.md):冻结 `M7` 联调、回归、部署、观测、双跑对比、灰度切流、回滚和 `spacetime-module` 结构收口的可执行方案。 - [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/package.json b/package.json index fcda4d41..73cf3b76 100644 --- a/package.json +++ b/package.json @@ -17,6 +17,8 @@ "server-node:smoke": "npx tsx scripts/smoke-server-node.ts", "server-node:smoke:proxy": "npx tsx scripts/smoke-same-origin-stack.ts", "server-node:check:deploy": "npm run check:encoding && npm run server-node:test && npm run server-node:smoke && npm run server-node:build && npm run build && npm run server-node:smoke:proxy", + "server-rs:m7:preflight": "powershell -ExecutionPolicy Bypass -File server-rs/scripts/m7-preflight.ps1", + "m7:api-compare": "node scripts/run-tsx.cjs scripts/m7-api-compare.ts", "build": "node scripts/build-gate.mjs", "build:raw": "node scripts/vite-cli.mjs build", "preview": "node scripts/vite-cli.mjs preview", diff --git a/scripts/m7-api-compare.ts b/scripts/m7-api-compare.ts new file mode 100644 index 00000000..3a2c5244 --- /dev/null +++ b/scripts/m7-api-compare.ts @@ -0,0 +1,170 @@ +import assert from 'node:assert/strict'; + +type HttpMethod = 'GET'; + +interface CompareCase { + method: HttpMethod; + path: string; +} + +interface CompareResult { + path: string; + nodeStatus: number; + rustStatus: number; + matched: boolean; + reason?: string; +} + +const DEFAULT_NODE_BASE_URL = 'http://127.0.0.1:8081'; +const DEFAULT_RUST_BASE_URL = 'http://127.0.0.1:3000'; + +function readEnv(name: string, fallback: string): string { + const value = process.env[name]?.trim(); + return value ? value : fallback; +} + +function buildCases(): CompareCase[] { + const rawPaths = process.env.M7_COMPARE_PATHS?.trim(); + const paths = rawPaths + ? rawPaths.split(',').map((value) => value.trim()).filter(Boolean) + : ['/healthz', '/api/auth/login-options']; + + return paths.map((path) => ({ + method: 'GET', + path: path.startsWith('/') ? path : `/${path}`, + })); +} + +async function fetchJson(baseUrl: string, testCase: CompareCase, requestId: string) { + const url = new URL(testCase.path, baseUrl); + const response = await fetch(url, { + method: testCase.method, + headers: { + 'x-request-id': requestId, + 'x-genarrative-response-envelope': '1', + }, + }); + const text = await response.text(); + const json = text ? JSON.parse(text) : null; + + return { + status: response.status, + json: normalizeVolatileJson(json), + }; +} + +function normalizeVolatileJson(value: unknown): unknown { + if (Array.isArray(value)) { + return value.map(normalizeVolatileJson); + } + + if (!value || typeof value !== 'object') { + return value; + } + + const record = value as Record; + const normalized: Record = {}; + + for (const [key, child] of Object.entries(record)) { + if (['requestId', 'timestamp', 'latencyMs'].includes(key)) { + continue; + } + + normalized[key] = normalizeVolatileJson(child); + } + + return normalized; +} + +function stableStringify(value: unknown): string { + if (Array.isArray(value)) { + return `[${value.map(stableStringify).join(',')}]`; + } + + if (!value || typeof value !== 'object') { + return JSON.stringify(value); + } + + const entries = Object.entries(value as Record) + .sort(([left], [right]) => left.localeCompare(right)) + .map(([key, child]) => `${JSON.stringify(key)}:${stableStringify(child)}`); + + return `{${entries.join(',')}}`; +} + +async function compareCase( + nodeBaseUrl: string, + rustBaseUrl: string, + testCase: CompareCase, +): Promise { + const requestId = `m7-api-compare-${testCase.path.replaceAll('/', '-')}`; + const [nodeResponse, rustResponse] = await Promise.all([ + fetchJson(nodeBaseUrl, testCase, requestId), + fetchJson(rustBaseUrl, testCase, requestId), + ]); + + if (nodeResponse.status !== rustResponse.status) { + return { + path: testCase.path, + nodeStatus: nodeResponse.status, + rustStatus: rustResponse.status, + matched: false, + reason: 'status 不一致', + }; + } + + const nodeBody = stableStringify(nodeResponse.json); + const rustBody = stableStringify(rustResponse.json); + if (nodeBody !== rustBody) { + return { + path: testCase.path, + nodeStatus: nodeResponse.status, + rustStatus: rustResponse.status, + matched: false, + reason: `body 不一致\nnode=${nodeBody}\nrust=${rustBody}`, + }; + } + + return { + path: testCase.path, + nodeStatus: nodeResponse.status, + rustStatus: rustResponse.status, + matched: true, + }; +} + +async function main() { + const nodeBaseUrl = readEnv('M7_NODE_BASE_URL', DEFAULT_NODE_BASE_URL); + const rustBaseUrl = readEnv('M7_RUST_BASE_URL', DEFAULT_RUST_BASE_URL); + const strict = process.env.M7_COMPARE_STRICT?.trim() !== 'false'; + const cases = buildCases(); + + console.log(`[m7:api-compare] node=${nodeBaseUrl}`); + console.log(`[m7:api-compare] rust=${rustBaseUrl}`); + console.log(`[m7:api-compare] cases=${cases.map((item) => item.path).join(', ')}`); + + const results = await Promise.all( + cases.map((testCase) => compareCase(nodeBaseUrl, rustBaseUrl, testCase)), + ); + + for (const result of results) { + const label = result.matched ? 'OK' : 'DIFF'; + console.log( + `[m7:api-compare] ${label} ${result.path} node=${result.nodeStatus} rust=${result.rustStatus}`, + ); + if (result.reason) { + console.log(result.reason); + } + } + + const failures = results.filter((result) => !result.matched); + if (strict) { + assert.equal(failures.length, 0, '存在 Node/Rust API contract 差异'); + } +} + +main().catch((error) => { + console.error('[m7:api-compare] failed'); + console.error(error); + process.exitCode = 1; +}); diff --git a/server-rs/README.md b/server-rs/README.md index c67d619e..2876fd19 100644 --- a/server-rs/README.md +++ b/server-rs/README.md @@ -14,7 +14,7 @@ ## 2. 当前阶段说明 -当前目录已经完成以下三十五项初始化: +当前目录已经完成以下三十九项初始化: 1. 为新后端预留正式目录并把路径固定到仓库结构中。 2. 创建虚拟 workspace `Cargo.toml`,后续 crate 会逐项挂入。 @@ -52,6 +52,9 @@ 34. 创建 `scripts/spacetime-dev.ps1`,固定 Windows 本地 SpacetimeDB 启动入口。 35. 创建 `scripts/spacetime-dev.sh`,固定 Unix-like 本地 SpacetimeDB 启动入口。 36. 创建 `scripts/oss-smoke.ps1`,固定 Windows 本地阿里云 OSS 真实联调入口。 +37. 创建 `scripts/m7-preflight.ps1`,固定 M7 切流前 Rust 后端预检入口。 +38. 创建根目录 `scripts/m7-api-compare.ts`,固定旧 Node 与新 Rust 的无状态 API contract 对比入口。 +39. 固定 Vite dev proxy 的 `GENARRATIVE_BACKEND_STACK` / `GENARRATIVE_RUNTIME_SERVER_TARGET` 切流和回退开关。 后续任务会继续在本目录内按顺序补齐: diff --git a/server-rs/crates/api-server/src/app.rs b/server-rs/crates/api-server/src/app.rs index a41ecebb..faf915ff 100644 --- a/server-rs/crates/api-server/src/app.rs +++ b/server-rs/crates/api-server/src/app.rs @@ -6,8 +6,11 @@ use axum::{ middleware, routing::{get, post}, }; -use tower_http::trace::{DefaultOnFailure, DefaultOnRequest, DefaultOnResponse, TraceLayer}; -use tracing::{Level, info_span}; +use tower_http::{ + classify::ServerErrorsFailureClass, + trace::{DefaultOnRequest, TraceLayer}, +}; +use tracing::{Level, Span, error, info, info_span, warn}; use crate::{ ai_tasks::{ @@ -86,6 +89,8 @@ use crate::{ // 统一由这里构造 Axum 路由树,后续再逐项挂接中间件与业务路由。 pub fn build_router(state: AppState) -> Router { + let slow_request_threshold_ms = state.config.slow_request_threshold_ms; + Router::new() .route( "/healthz", @@ -688,8 +693,39 @@ pub fn build_router(state: AppState) -> Router { ) }) .on_request(DefaultOnRequest::new().level(Level::INFO)) - .on_response(DefaultOnResponse::new().level(Level::INFO)) - .on_failure(DefaultOnFailure::new().level(Level::ERROR)), + .on_response(move |response: &axum::response::Response, latency: std::time::Duration, span: &Span| { + let latency_ms = latency.as_millis().min(u64::MAX as u128) as u64; + let status = response.status().as_u16(); + let slow_request = latency_ms >= slow_request_threshold_ms; + span.record("status", status); + span.record("latency_ms", latency_ms); + if slow_request { + warn!( + parent: span, + status, + latency_ms, + slow_request = true, + "http request completed slowly" + ); + } else { + info!( + parent: span, + status, + latency_ms, + slow_request = false, + "http request completed" + ); + } + }) + .on_failure(|failure: ServerErrorsFailureClass, latency: std::time::Duration, span: &Span| { + let latency_ms = latency.as_millis().min(u64::MAX as u128) as u64; + error!( + parent: span, + latency_ms, + failure = %failure, + "http request failed" + ); + }), ) // request_id 中间件先进入请求链,确保后续 tracing、错误处理和响应头层都能复用同一份请求标识。 .layer(middleware::from_fn(attach_request_context)) diff --git a/server-rs/crates/api-server/src/config.rs b/server-rs/crates/api-server/src/config.rs index 577bc7a9..c287e2b0 100644 --- a/server-rs/crates/api-server/src/config.rs +++ b/server-rs/crates/api-server/src/config.rs @@ -54,6 +54,7 @@ pub struct AppConfig { pub llm_request_timeout_ms: u64, pub llm_max_retries: u32, pub llm_retry_backoff_ms: u64, + pub slow_request_threshold_ms: u64, } impl Default for AppConfig { @@ -104,6 +105,7 @@ impl Default for AppConfig { llm_request_timeout_ms: DEFAULT_REQUEST_TIMEOUT_MS, llm_max_retries: DEFAULT_MAX_RETRIES, llm_retry_backoff_ms: DEFAULT_RETRY_BACKOFF_MS, + slow_request_threshold_ms: 1_000, } } } @@ -305,6 +307,12 @@ impl AppConfig { config.llm_retry_backoff_ms = llm_retry_backoff_ms; } + if let Some(slow_request_threshold_ms) = + read_first_positive_u64_env(&["GENARRATIVE_SLOW_REQUEST_THRESHOLD_MS"]) + { + config.slow_request_threshold_ms = slow_request_threshold_ms; + } + config } diff --git a/server-rs/crates/spacetime-module/src/ai/mod.rs b/server-rs/crates/spacetime-module/src/ai/mod.rs new file mode 100644 index 00000000..627b3711 --- /dev/null +++ b/server-rs/crates/spacetime-module/src/ai/mod.rs @@ -0,0 +1,753 @@ +#[spacetimedb::table( + accessor = ai_task, + index(accessor = by_ai_task_owner_user_id, btree(columns = [owner_user_id])), + index(accessor = by_ai_task_status, btree(columns = [status])), + index(accessor = by_ai_task_kind, btree(columns = [task_kind])) +)] +pub struct AiTask { + #[primary_key] + task_id: String, + task_kind: AiTaskKind, + owner_user_id: String, + request_label: String, + source_module: String, + source_entity_id: Option, + request_payload_json: Option, + status: AiTaskStatus, + failure_message: Option, + latest_text_output: Option, + latest_structured_payload_json: Option, + version: u32, + created_at: Timestamp, + started_at: Option, + completed_at: Option, + updated_at: Timestamp, +} + +#[spacetimedb::table( + accessor = ai_task_stage, + index(accessor = by_ai_task_stage_task_id, btree(columns = [task_id])), + index(accessor = by_ai_task_stage_task_order, btree(columns = [task_id, stage_order])) +)] +pub struct AiTaskStage { + #[primary_key] + task_stage_id: String, + task_id: String, + stage_kind: AiTaskStageKind, + label: String, + detail: String, + stage_order: u32, + status: AiTaskStageStatus, + text_output: Option, + structured_payload_json: Option, + warning_messages: Vec, + started_at: Option, + completed_at: Option, +} + +#[spacetimedb::table( + accessor = ai_text_chunk, + index(accessor = by_ai_text_chunk_task_id, btree(columns = [task_id])), + index( + accessor = by_ai_text_chunk_task_stage_sequence, + btree(columns = [task_id, stage_kind, sequence]) + ) +)] +pub struct AiTextChunk { + #[primary_key] + text_chunk_row_id: String, + chunk_id: String, + task_id: String, + stage_kind: AiTaskStageKind, + sequence: u32, + delta_text: String, + created_at: Timestamp, +} + +#[spacetimedb::table( + accessor = ai_result_reference, + index(accessor = by_ai_result_reference_task_id, btree(columns = [task_id])) +)] +pub struct AiResultReference { + #[primary_key] + result_reference_row_id: String, + result_ref_id: String, + task_id: String, + reference_kind: AiResultReferenceKind, + reference_id: String, + label: Option, + created_at: Timestamp, +} + +// AI 任务当前先固定成 private 真相表,后续由 Axum / platform-llm 再往外包一层 HTTP 与 SSE 协议。 +#[spacetimedb::reducer] +pub fn create_ai_task(ctx: &ReducerContext, input: AiTaskCreateInput) -> Result<(), String> { + create_ai_task_tx(ctx, input).map(|_| ()) +} + +#[spacetimedb::procedure] +pub fn create_ai_task_and_return( + ctx: &mut ProcedureContext, + input: AiTaskCreateInput, +) -> AiTaskProcedureResult { + match ctx.try_with_tx(|tx| create_ai_task_tx(tx, input.clone())) { + Ok(task) => AiTaskProcedureResult { + ok: true, + task: Some(task), + text_chunk: None, + error_message: None, + }, + Err(message) => AiTaskProcedureResult { + ok: false, + task: None, + text_chunk: None, + error_message: Some(message), + }, + } +} + +#[spacetimedb::reducer] +pub fn start_ai_task(ctx: &ReducerContext, input: AiTaskStartInput) -> Result<(), String> { + start_ai_task_tx(ctx, input).map(|_| ()) +} + +#[spacetimedb::reducer] +pub fn start_ai_task_stage( + ctx: &ReducerContext, + input: AiTaskStageStartInput, +) -> Result<(), String> { + start_ai_task_stage_tx(ctx, input).map(|_| ()) +} + +// 流式增量写入需要同步返回 chunk 与聚合后的任务快照,方便后续 Axum facade 直接复用。 +#[spacetimedb::procedure] +pub fn append_ai_text_chunk_and_return( + ctx: &mut ProcedureContext, + input: AiTextChunkAppendInput, +) -> AiTaskProcedureResult { + match ctx.try_with_tx(|tx| append_ai_text_chunk_tx(tx, input.clone())) { + Ok((task, text_chunk)) => AiTaskProcedureResult { + ok: true, + task: Some(task), + text_chunk: Some(text_chunk), + error_message: None, + }, + Err(message) => AiTaskProcedureResult { + ok: false, + task: None, + text_chunk: None, + error_message: Some(message), + }, + } +} + +#[spacetimedb::procedure] +pub fn complete_ai_stage_and_return( + ctx: &mut ProcedureContext, + input: AiStageCompletionInput, +) -> AiTaskProcedureResult { + match ctx.try_with_tx(|tx| complete_ai_stage_tx(tx, input.clone())) { + Ok(task) => AiTaskProcedureResult { + ok: true, + task: Some(task), + text_chunk: None, + error_message: None, + }, + Err(message) => AiTaskProcedureResult { + ok: false, + task: None, + text_chunk: None, + error_message: Some(message), + }, + } +} + +#[spacetimedb::procedure] +pub fn attach_ai_result_reference_and_return( + ctx: &mut ProcedureContext, + input: AiResultReferenceInput, +) -> AiTaskProcedureResult { + match ctx.try_with_tx(|tx| attach_ai_result_reference_tx(tx, input.clone())) { + Ok(task) => AiTaskProcedureResult { + ok: true, + task: Some(task), + text_chunk: None, + error_message: None, + }, + Err(message) => AiTaskProcedureResult { + ok: false, + task: None, + text_chunk: None, + error_message: Some(message), + }, + } +} + +#[spacetimedb::procedure] +pub fn complete_ai_task_and_return( + ctx: &mut ProcedureContext, + input: AiTaskFinishInput, +) -> AiTaskProcedureResult { + match ctx.try_with_tx(|tx| complete_ai_task_tx(tx, input.clone())) { + Ok(task) => AiTaskProcedureResult { + ok: true, + task: Some(task), + text_chunk: None, + error_message: None, + }, + Err(message) => AiTaskProcedureResult { + ok: false, + task: None, + text_chunk: None, + error_message: Some(message), + }, + } +} + +#[spacetimedb::procedure] +pub fn fail_ai_task_and_return( + ctx: &mut ProcedureContext, + input: AiTaskFailureInput, +) -> AiTaskProcedureResult { + match ctx.try_with_tx(|tx| fail_ai_task_tx(tx, input.clone())) { + Ok(task) => AiTaskProcedureResult { + ok: true, + task: Some(task), + text_chunk: None, + error_message: None, + }, + Err(message) => AiTaskProcedureResult { + ok: false, + task: None, + text_chunk: None, + error_message: Some(message), + }, + } +} + +#[spacetimedb::procedure] +pub fn cancel_ai_task_and_return( + ctx: &mut ProcedureContext, + input: AiTaskCancelInput, +) -> AiTaskProcedureResult { + match ctx.try_with_tx(|tx| cancel_ai_task_tx(tx, input.clone())) { + Ok(task) => AiTaskProcedureResult { + ok: true, + task: Some(task), + text_chunk: None, + error_message: None, + }, + Err(message) => AiTaskProcedureResult { + ok: false, + task: None, + text_chunk: None, + error_message: Some(message), + }, + } +} +fn create_ai_task_tx( + ctx: &ReducerContext, + input: AiTaskCreateInput, +) -> Result { + validate_task_create_input(&input).map_err(|error| error.to_string())?; + + if ctx.db.ai_task().task_id().find(&input.task_id).is_some() { + return Err("ai_task.task_id 已存在".to_string()); + } + + let task_snapshot = build_ai_task_snapshot_from_create_input(&input); + ctx.db.ai_task().insert(build_ai_task_row(&task_snapshot)); + replace_ai_task_stages(ctx, &task_snapshot.task_id, &task_snapshot.stages); + + get_ai_task_snapshot_tx(ctx, &task_snapshot.task_id) +} + +fn start_ai_task_tx( + ctx: &ReducerContext, + input: AiTaskStartInput, +) -> Result { + let mut snapshot = get_ai_task_snapshot_tx(ctx, &input.task_id)?; + ensure_ai_task_can_transition(snapshot.status)?; + + snapshot.status = AiTaskStatus::Running; + if snapshot.started_at_micros.is_none() { + snapshot.started_at_micros = Some(input.started_at_micros); + } + snapshot.updated_at_micros = input.started_at_micros; + snapshot.version += 1; + + persist_ai_task_snapshot(ctx, &snapshot)?; + Ok(snapshot) +} + +fn start_ai_task_stage_tx( + ctx: &ReducerContext, + input: AiTaskStageStartInput, +) -> Result { + let mut snapshot = get_ai_task_snapshot_tx(ctx, &input.task_id)?; + ensure_ai_task_can_transition(snapshot.status)?; + + let stage = snapshot + .stages + .iter_mut() + .find(|stage| stage.stage_kind == input.stage_kind) + .ok_or_else(|| "ai_task.stage 不存在".to_string())?; + + snapshot.status = AiTaskStatus::Running; + if snapshot.started_at_micros.is_none() { + snapshot.started_at_micros = Some(input.started_at_micros); + } + stage.status = AiTaskStageStatus::Running; + if stage.started_at_micros.is_none() { + stage.started_at_micros = Some(input.started_at_micros); + } + snapshot.updated_at_micros = input.started_at_micros; + snapshot.version += 1; + + persist_ai_task_snapshot(ctx, &snapshot)?; + Ok(snapshot) +} + +fn append_ai_text_chunk_tx( + ctx: &ReducerContext, + input: AiTextChunkAppendInput, +) -> Result<(AiTaskSnapshot, AiTextChunkSnapshot), String> { + if input.delta_text.trim().is_empty() { + return Err("ai_text_chunk.delta_text 不能为空".to_string()); + } + if input.sequence == 0 { + return Err("ai_text_chunk.sequence 必须大于 0".to_string()); + } + + let mut snapshot = get_ai_task_snapshot_tx(ctx, &input.task_id)?; + ensure_ai_task_can_transition(snapshot.status)?; + + let stage = snapshot + .stages + .iter_mut() + .find(|stage| stage.stage_kind == input.stage_kind) + .ok_or_else(|| "ai_task.stage 不存在".to_string())?; + + let chunk = AiTextChunkSnapshot { + chunk_id: generate_ai_text_chunk_id(input.created_at_micros, input.sequence), + task_id: input.task_id.trim().to_string(), + stage_kind: input.stage_kind, + sequence: input.sequence, + delta_text: input.delta_text.trim().to_string(), + created_at_micros: input.created_at_micros, + }; + ctx.db + .ai_text_chunk() + .insert(build_ai_text_chunk_row(&chunk)); + + let aggregated_text = collect_ai_stage_text_output(ctx, &chunk.task_id, chunk.stage_kind); + + snapshot.status = AiTaskStatus::Running; + if snapshot.started_at_micros.is_none() { + snapshot.started_at_micros = Some(input.created_at_micros); + } + stage.status = AiTaskStageStatus::Running; + if stage.started_at_micros.is_none() { + stage.started_at_micros = Some(input.created_at_micros); + } + stage.text_output = aggregated_text.clone(); + snapshot.latest_text_output = aggregated_text; + snapshot.updated_at_micros = input.created_at_micros; + snapshot.version += 1; + + persist_ai_task_snapshot(ctx, &snapshot)?; + Ok((snapshot, chunk)) +} + +fn complete_ai_stage_tx( + ctx: &ReducerContext, + input: AiStageCompletionInput, +) -> Result { + let mut snapshot = get_ai_task_snapshot_tx(ctx, &input.task_id)?; + ensure_ai_task_can_transition(snapshot.status)?; + + let stage = snapshot + .stages + .iter_mut() + .find(|stage| stage.stage_kind == input.stage_kind) + .ok_or_else(|| "ai_task.stage 不存在".to_string())?; + + stage.status = AiTaskStageStatus::Completed; + stage.completed_at_micros = Some(input.completed_at_micros); + stage.text_output = normalize_optional_text(input.text_output.clone()); + stage.structured_payload_json = normalize_optional_text(input.structured_payload_json.clone()); + stage.warning_messages = normalize_string_list(input.warning_messages.clone()); + + snapshot.latest_text_output = stage.text_output.clone(); + snapshot.latest_structured_payload_json = stage.structured_payload_json.clone(); + snapshot.updated_at_micros = input.completed_at_micros; + snapshot.version += 1; + + persist_ai_task_snapshot(ctx, &snapshot)?; + Ok(snapshot) +} + +fn attach_ai_result_reference_tx( + ctx: &ReducerContext, + input: AiResultReferenceInput, +) -> Result { + let reference_id = input.reference_id.trim().to_string(); + if reference_id.is_empty() { + return Err("ai_result_reference.reference_id 不能为空".to_string()); + } + + let mut snapshot = get_ai_task_snapshot_tx(ctx, &input.task_id)?; + ensure_ai_task_can_transition(snapshot.status)?; + + let reference = AiResultReferenceSnapshot { + result_ref_id: generate_ai_result_ref_id(input.created_at_micros), + task_id: input.task_id.trim().to_string(), + reference_kind: input.reference_kind, + reference_id, + label: normalize_optional_text(input.label), + created_at_micros: input.created_at_micros, + }; + ctx.db + .ai_result_reference() + .insert(build_ai_result_reference_row(&reference)); + + snapshot.result_references.push(reference); + snapshot.updated_at_micros = input.created_at_micros; + snapshot.version += 1; + + persist_ai_task_snapshot(ctx, &snapshot)?; + Ok(snapshot) +} + +fn complete_ai_task_tx( + ctx: &ReducerContext, + input: AiTaskFinishInput, +) -> Result { + let mut snapshot = get_ai_task_snapshot_tx(ctx, &input.task_id)?; + ensure_ai_task_can_transition(snapshot.status)?; + + snapshot.status = AiTaskStatus::Completed; + snapshot.completed_at_micros = Some(input.completed_at_micros); + snapshot.updated_at_micros = input.completed_at_micros; + snapshot.version += 1; + + persist_ai_task_snapshot(ctx, &snapshot)?; + Ok(snapshot) +} + +fn fail_ai_task_tx( + ctx: &ReducerContext, + input: AiTaskFailureInput, +) -> Result { + let failure_message = input.failure_message.trim().to_string(); + if failure_message.is_empty() { + return Err("ai_task.failure_message 不能为空".to_string()); + } + + let mut snapshot = get_ai_task_snapshot_tx(ctx, &input.task_id)?; + ensure_ai_task_can_transition(snapshot.status)?; + + snapshot.status = AiTaskStatus::Failed; + snapshot.failure_message = Some(failure_message); + snapshot.completed_at_micros = Some(input.completed_at_micros); + snapshot.updated_at_micros = input.completed_at_micros; + snapshot.version += 1; + + persist_ai_task_snapshot(ctx, &snapshot)?; + Ok(snapshot) +} + +fn cancel_ai_task_tx( + ctx: &ReducerContext, + input: AiTaskCancelInput, +) -> Result { + let mut snapshot = get_ai_task_snapshot_tx(ctx, &input.task_id)?; + ensure_ai_task_can_transition(snapshot.status)?; + + snapshot.status = AiTaskStatus::Cancelled; + snapshot.completed_at_micros = Some(input.completed_at_micros); + snapshot.updated_at_micros = input.completed_at_micros; + snapshot.version += 1; + + persist_ai_task_snapshot(ctx, &snapshot)?; + Ok(snapshot) +} + +fn get_ai_task_snapshot_tx(ctx: &ReducerContext, task_id: &str) -> Result { + let row = ctx + .db + .ai_task() + .task_id() + .find(&task_id.trim().to_string()) + .ok_or_else(|| "ai_task 不存在".to_string())?; + + Ok(build_ai_task_snapshot_from_row(ctx, &row)) +} + +fn persist_ai_task_snapshot(ctx: &ReducerContext, snapshot: &AiTaskSnapshot) -> Result<(), String> { + ctx.db.ai_task().task_id().delete(&snapshot.task_id); + ctx.db.ai_task().insert(build_ai_task_row(snapshot)); + replace_ai_task_stages(ctx, &snapshot.task_id, &snapshot.stages); + Ok(()) +} + +fn replace_ai_task_stages(ctx: &ReducerContext, task_id: &str, stages: &[AiTaskStageSnapshot]) { + let stage_ids = ctx + .db + .ai_task_stage() + .iter() + .filter(|row| row.task_id == task_id) + .map(|row| row.task_stage_id.clone()) + .collect::>(); + for stage_id in stage_ids { + ctx.db.ai_task_stage().task_stage_id().delete(&stage_id); + } + + for stage in stages { + ctx.db + .ai_task_stage() + .insert(build_ai_task_stage_row(task_id, stage)); + } +} + +fn collect_ai_stage_text_output( + ctx: &ReducerContext, + task_id: &str, + stage_kind: AiTaskStageKind, +) -> Option { + let mut chunks = ctx + .db + .ai_text_chunk() + .iter() + .filter(|row| row.task_id == task_id && row.stage_kind == stage_kind) + .map(|row| build_ai_text_chunk_snapshot_from_row(&row)) + .collect::>(); + chunks.sort_by_key(|chunk| chunk.sequence); + + let aggregated = chunks + .into_iter() + .map(|chunk| chunk.delta_text) + .collect::>() + .join(""); + if aggregated.trim().is_empty() { + None + } else { + Some(aggregated) + } +} + +fn ensure_ai_task_can_transition(status: AiTaskStatus) -> Result<(), String> { + if matches!( + status, + AiTaskStatus::Completed | AiTaskStatus::Failed | AiTaskStatus::Cancelled + ) { + Err("当前 ai_task 状态不允许执行该操作".to_string()) + } else { + Ok(()) + } +} + +fn build_ai_task_snapshot_from_create_input(input: &AiTaskCreateInput) -> AiTaskSnapshot { + AiTaskSnapshot { + task_id: input.task_id.trim().to_string(), + task_kind: input.task_kind, + owner_user_id: input.owner_user_id.trim().to_string(), + request_label: input.request_label.trim().to_string(), + source_module: input.source_module.trim().to_string(), + source_entity_id: normalize_optional_text(input.source_entity_id.clone()), + request_payload_json: normalize_optional_text(input.request_payload_json.clone()), + status: AiTaskStatus::Pending, + failure_message: None, + stages: input + .stages + .iter() + .map(|stage| AiTaskStageSnapshot { + stage_kind: stage.stage_kind, + label: stage.label.trim().to_string(), + detail: stage.detail.trim().to_string(), + order: stage.order, + status: AiTaskStageStatus::Pending, + text_output: None, + structured_payload_json: None, + warning_messages: Vec::new(), + started_at_micros: None, + completed_at_micros: None, + }) + .collect(), + result_references: Vec::new(), + latest_text_output: None, + latest_structured_payload_json: None, + version: INITIAL_AI_TASK_VERSION, + created_at_micros: input.created_at_micros, + started_at_micros: None, + completed_at_micros: None, + updated_at_micros: input.created_at_micros, + } +} + +fn build_ai_task_row(snapshot: &AiTaskSnapshot) -> AiTask { + AiTask { + task_id: snapshot.task_id.clone(), + task_kind: snapshot.task_kind, + owner_user_id: snapshot.owner_user_id.clone(), + request_label: snapshot.request_label.clone(), + source_module: snapshot.source_module.clone(), + source_entity_id: snapshot.source_entity_id.clone(), + request_payload_json: snapshot.request_payload_json.clone(), + status: snapshot.status, + failure_message: snapshot.failure_message.clone(), + latest_text_output: snapshot.latest_text_output.clone(), + latest_structured_payload_json: snapshot.latest_structured_payload_json.clone(), + version: snapshot.version, + created_at: Timestamp::from_micros_since_unix_epoch(snapshot.created_at_micros), + started_at: snapshot + .started_at_micros + .map(Timestamp::from_micros_since_unix_epoch), + completed_at: snapshot + .completed_at_micros + .map(Timestamp::from_micros_since_unix_epoch), + updated_at: Timestamp::from_micros_since_unix_epoch(snapshot.updated_at_micros), + } +} + +fn build_ai_task_snapshot_from_row(ctx: &ReducerContext, row: &AiTask) -> AiTaskSnapshot { + let mut stages = ctx + .db + .ai_task_stage() + .iter() + .filter(|stage| stage.task_id == row.task_id) + .map(|stage| build_ai_task_stage_snapshot_from_row(&stage)) + .collect::>(); + stages.sort_by_key(|stage| stage.order); + + let mut result_references = ctx + .db + .ai_result_reference() + .iter() + .filter(|reference| reference.task_id == row.task_id) + .map(|reference| build_ai_result_reference_snapshot_from_row(&reference)) + .collect::>(); + result_references.sort_by_key(|reference| reference.created_at_micros); + + AiTaskSnapshot { + task_id: row.task_id.clone(), + task_kind: row.task_kind, + owner_user_id: row.owner_user_id.clone(), + request_label: row.request_label.clone(), + source_module: row.source_module.clone(), + source_entity_id: row.source_entity_id.clone(), + request_payload_json: row.request_payload_json.clone(), + status: row.status, + failure_message: row.failure_message.clone(), + stages, + result_references, + latest_text_output: row.latest_text_output.clone(), + latest_structured_payload_json: row.latest_structured_payload_json.clone(), + version: row.version, + created_at_micros: row.created_at.to_micros_since_unix_epoch(), + started_at_micros: row + .started_at + .map(|value| value.to_micros_since_unix_epoch()), + completed_at_micros: row + .completed_at + .map(|value| value.to_micros_since_unix_epoch()), + updated_at_micros: row.updated_at.to_micros_since_unix_epoch(), + } +} + +fn build_ai_task_stage_row(task_id: &str, snapshot: &AiTaskStageSnapshot) -> AiTaskStage { + AiTaskStage { + task_stage_id: generate_ai_task_stage_id(task_id, snapshot.stage_kind), + task_id: task_id.to_string(), + stage_kind: snapshot.stage_kind, + label: snapshot.label.clone(), + detail: snapshot.detail.clone(), + stage_order: snapshot.order, + status: snapshot.status, + text_output: snapshot.text_output.clone(), + structured_payload_json: snapshot.structured_payload_json.clone(), + warning_messages: snapshot.warning_messages.clone(), + started_at: snapshot + .started_at_micros + .map(Timestamp::from_micros_since_unix_epoch), + completed_at: snapshot + .completed_at_micros + .map(Timestamp::from_micros_since_unix_epoch), + } +} + +fn build_ai_task_stage_snapshot_from_row(row: &AiTaskStage) -> AiTaskStageSnapshot { + AiTaskStageSnapshot { + stage_kind: row.stage_kind, + label: row.label.clone(), + detail: row.detail.clone(), + order: row.stage_order, + status: row.status, + text_output: row.text_output.clone(), + structured_payload_json: row.structured_payload_json.clone(), + warning_messages: row.warning_messages.clone(), + started_at_micros: row + .started_at + .map(|value| value.to_micros_since_unix_epoch()), + completed_at_micros: row + .completed_at + .map(|value| value.to_micros_since_unix_epoch()), + } +} + +fn build_ai_text_chunk_row(snapshot: &AiTextChunkSnapshot) -> AiTextChunk { + AiTextChunk { + text_chunk_row_id: format!( + "{}{}_{}_{}", + AI_TEXT_CHUNK_ID_PREFIX, + snapshot.task_id, + snapshot.stage_kind.as_str(), + snapshot.sequence + ), + chunk_id: snapshot.chunk_id.clone(), + task_id: snapshot.task_id.clone(), + stage_kind: snapshot.stage_kind, + sequence: snapshot.sequence, + delta_text: snapshot.delta_text.clone(), + created_at: Timestamp::from_micros_since_unix_epoch(snapshot.created_at_micros), + } +} + +fn build_ai_text_chunk_snapshot_from_row(row: &AiTextChunk) -> AiTextChunkSnapshot { + AiTextChunkSnapshot { + chunk_id: row.chunk_id.clone(), + task_id: row.task_id.clone(), + stage_kind: row.stage_kind, + sequence: row.sequence, + delta_text: row.delta_text.clone(), + created_at_micros: row.created_at.to_micros_since_unix_epoch(), + } +} + +fn build_ai_result_reference_row(snapshot: &AiResultReferenceSnapshot) -> AiResultReference { + AiResultReference { + result_reference_row_id: format!( + "{}{}_{}", + AI_RESULT_REF_ID_PREFIX, snapshot.task_id, snapshot.result_ref_id + ), + result_ref_id: snapshot.result_ref_id.clone(), + task_id: snapshot.task_id.clone(), + reference_kind: snapshot.reference_kind, + reference_id: snapshot.reference_id.clone(), + label: snapshot.label.clone(), + created_at: Timestamp::from_micros_since_unix_epoch(snapshot.created_at_micros), + } +} + +fn build_ai_result_reference_snapshot_from_row( + row: &AiResultReference, +) -> AiResultReferenceSnapshot { + AiResultReferenceSnapshot { + result_ref_id: row.result_ref_id.clone(), + task_id: row.task_id.clone(), + reference_kind: row.reference_kind, + reference_id: row.reference_id.clone(), + label: row.label.clone(), + created_at_micros: row.created_at.to_micros_since_unix_epoch(), + } +} diff --git a/server-rs/crates/spacetime-module/src/asset_metadata/mod.rs b/server-rs/crates/spacetime-module/src/asset_metadata/mod.rs new file mode 100644 index 00000000..66353476 --- /dev/null +++ b/server-rs/crates/spacetime-module/src/asset_metadata/mod.rs @@ -0,0 +1,305 @@ +#[spacetimedb::table( + accessor = asset_object, + index(accessor = by_bucket_object_key, btree(columns = [bucket, object_key])) +)] +pub struct AssetObject { + #[primary_key] + asset_object_id: String, + // 正式对象定位固定拆成 bucket + object_key 两列,避免后续再从单字符串路径做 schema 拆分。 + bucket: String, + object_key: String, + access_policy: AssetObjectAccessPolicy, + content_type: Option, + content_length: u64, + content_hash: Option, + version: u32, + source_job_id: Option, + owner_user_id: Option, + profile_id: Option, + entity_id: Option, + #[index(btree)] + asset_kind: String, + created_at: Timestamp, + updated_at: Timestamp, +} + +#[spacetimedb::table( + accessor = asset_entity_binding, + index(accessor = by_entity_slot, btree(columns = [entity_kind, entity_id, slot])), + index(accessor = by_asset_object_id, btree(columns = [asset_object_id])) +)] +pub struct AssetEntityBinding { + #[primary_key] + binding_id: String, + asset_object_id: String, + entity_kind: String, + entity_id: String, + slot: String, + asset_kind: String, + owner_user_id: Option, + profile_id: Option, + created_at: Timestamp, + updated_at: Timestamp, +} + +// reducer 负责固定资产对象的正式写规则,供后续内部模块逻辑复用。 +#[spacetimedb::reducer] +pub fn confirm_asset_object( + ctx: &ReducerContext, + input: AssetObjectUpsertInput, +) -> Result<(), String> { + upsert_asset_object(ctx, input).map(|_| ()) +} + +// procedure 面向 Axum 同步确认接口,返回最终持久化后的对象记录,避免 HTTP 层再额外查询 private table。 +#[spacetimedb::procedure] +pub fn confirm_asset_object_and_return( + ctx: &mut ProcedureContext, + input: AssetObjectUpsertInput, +) -> AssetObjectProcedureResult { + match ctx.try_with_tx(|tx| upsert_asset_object(tx, input.clone())) { + Ok(record) => AssetObjectProcedureResult { + ok: true, + record: Some(record), + error_message: None, + }, + Err(message) => AssetObjectProcedureResult { + ok: false, + record: None, + error_message: Some(message), + }, + } +} + +// reducer 负责把已确认对象绑定到实体槽位,强业务资产表稳定前先用通用绑定表承接关系。 +#[spacetimedb::reducer] +pub fn bind_asset_object_to_entity( + ctx: &ReducerContext, + input: AssetEntityBindingInput, +) -> Result<(), String> { + upsert_asset_entity_binding(ctx, input).map(|_| ()) +} + +// procedure 面向 Axum 同步绑定接口,返回最终绑定快照,避免 HTTP 层读取 private table。 +#[spacetimedb::procedure] +pub fn bind_asset_object_to_entity_and_return( + ctx: &mut ProcedureContext, + input: AssetEntityBindingInput, +) -> AssetEntityBindingProcedureResult { + match ctx.try_with_tx(|tx| upsert_asset_entity_binding(tx, input.clone())) { + Ok(record) => AssetEntityBindingProcedureResult { + ok: true, + record: Some(record), + error_message: None, + }, + Err(message) => AssetEntityBindingProcedureResult { + ok: false, + record: None, + error_message: Some(message), + }, + } +} +fn upsert_asset_object( + ctx: &ReducerContext, + input: AssetObjectUpsertInput, +) -> Result { + validate_asset_object_fields( + &input.bucket, + &input.object_key, + &input.asset_kind, + input.version, + ) + .map_err(|error| error.to_string())?; + + let updated_at = Timestamp::from_micros_since_unix_epoch(input.updated_at_micros); + // 这里先保持最小可发布实现:查重语义已经冻结,后续再把实现优化回组合索引扫描。 + let current = ctx + .db + .asset_object() + .iter() + .find(|row| row.bucket == input.bucket && row.object_key == input.object_key); + + let snapshot = match current { + Some(existing) => { + ctx.db + .asset_object() + .asset_object_id() + .delete(&existing.asset_object_id); + let row = AssetObject { + asset_object_id: existing.asset_object_id.clone(), + bucket: input.bucket.clone(), + object_key: input.object_key.clone(), + access_policy: input.access_policy, + content_type: input.content_type.clone(), + content_length: input.content_length, + content_hash: input.content_hash.clone(), + version: input.version, + source_job_id: input.source_job_id.clone(), + owner_user_id: input.owner_user_id.clone(), + profile_id: input.profile_id.clone(), + entity_id: input.entity_id.clone(), + asset_kind: input.asset_kind.clone(), + created_at: existing.created_at, + updated_at, + }; + ctx.db.asset_object().insert(row); + + AssetObjectUpsertSnapshot { + asset_object_id: existing.asset_object_id, + bucket: input.bucket, + object_key: input.object_key, + access_policy: input.access_policy, + content_type: input.content_type, + content_length: input.content_length, + content_hash: input.content_hash, + version: input.version, + source_job_id: input.source_job_id, + owner_user_id: input.owner_user_id, + profile_id: input.profile_id, + entity_id: input.entity_id, + asset_kind: input.asset_kind, + created_at_micros: existing.created_at.to_micros_since_unix_epoch(), + updated_at_micros: input.updated_at_micros, + } + } + None => { + let created_at = updated_at; + let row = AssetObject { + asset_object_id: input.asset_object_id.clone(), + bucket: input.bucket.clone(), + object_key: input.object_key.clone(), + access_policy: input.access_policy, + content_type: input.content_type.clone(), + content_length: input.content_length, + content_hash: input.content_hash.clone(), + version: input.version, + source_job_id: input.source_job_id.clone(), + owner_user_id: input.owner_user_id.clone(), + profile_id: input.profile_id.clone(), + entity_id: input.entity_id.clone(), + asset_kind: input.asset_kind.clone(), + created_at, + updated_at, + }; + ctx.db.asset_object().insert(row); + + AssetObjectUpsertSnapshot { + asset_object_id: input.asset_object_id, + bucket: input.bucket, + object_key: input.object_key, + access_policy: input.access_policy, + content_type: input.content_type, + content_length: input.content_length, + content_hash: input.content_hash, + version: input.version, + source_job_id: input.source_job_id, + owner_user_id: input.owner_user_id, + profile_id: input.profile_id, + entity_id: input.entity_id, + asset_kind: input.asset_kind, + created_at_micros: input.updated_at_micros, + updated_at_micros: input.updated_at_micros, + } + } + }; + + Ok(snapshot) +} +fn upsert_asset_entity_binding( + ctx: &ReducerContext, + input: AssetEntityBindingInput, +) -> Result { + validate_asset_entity_binding_fields( + &input.binding_id, + &input.asset_object_id, + &input.entity_kind, + &input.entity_id, + &input.slot, + &input.asset_kind, + ) + .map_err(|error| error.to_string())?; + + if ctx + .db + .asset_object() + .asset_object_id() + .find(&input.asset_object_id) + .is_none() + { + return Err("asset_entity_binding.asset_object_id 对应的 asset_object 不存在".to_string()); + } + + let updated_at = Timestamp::from_micros_since_unix_epoch(input.updated_at_micros); + // 首版绑定按 entity_kind + entity_id + slot 幂等定位,后续访问量明确后再改为组合索引扫描。 + let current = ctx.db.asset_entity_binding().iter().find(|row| { + row.entity_kind == input.entity_kind + && row.entity_id == input.entity_id + && row.slot == input.slot + }); + + let snapshot = match current { + Some(existing) => { + ctx.db + .asset_entity_binding() + .binding_id() + .delete(&existing.binding_id); + let row = AssetEntityBinding { + binding_id: existing.binding_id.clone(), + asset_object_id: input.asset_object_id.clone(), + entity_kind: input.entity_kind.clone(), + entity_id: input.entity_id.clone(), + slot: input.slot.clone(), + asset_kind: input.asset_kind.clone(), + owner_user_id: input.owner_user_id.clone(), + profile_id: input.profile_id.clone(), + created_at: existing.created_at, + updated_at, + }; + ctx.db.asset_entity_binding().insert(row); + + AssetEntityBindingSnapshot { + binding_id: existing.binding_id, + asset_object_id: input.asset_object_id, + entity_kind: input.entity_kind, + entity_id: input.entity_id, + slot: input.slot, + asset_kind: input.asset_kind, + owner_user_id: input.owner_user_id, + profile_id: input.profile_id, + created_at_micros: existing.created_at.to_micros_since_unix_epoch(), + updated_at_micros: input.updated_at_micros, + } + } + None => { + let created_at = updated_at; + let row = AssetEntityBinding { + binding_id: input.binding_id.clone(), + asset_object_id: input.asset_object_id.clone(), + entity_kind: input.entity_kind.clone(), + entity_id: input.entity_id.clone(), + slot: input.slot.clone(), + asset_kind: input.asset_kind.clone(), + owner_user_id: input.owner_user_id.clone(), + profile_id: input.profile_id.clone(), + created_at, + updated_at, + }; + ctx.db.asset_entity_binding().insert(row); + + AssetEntityBindingSnapshot { + binding_id: input.binding_id, + asset_object_id: input.asset_object_id, + entity_kind: input.entity_kind, + entity_id: input.entity_id, + slot: input.slot, + asset_kind: input.asset_kind, + owner_user_id: input.owner_user_id, + profile_id: input.profile_id, + created_at_micros: input.updated_at_micros, + updated_at_micros: input.updated_at_micros, + } + } + }; + + Ok(snapshot) +} diff --git a/server-rs/crates/spacetime-module/src/custom_world/mod.rs b/server-rs/crates/spacetime-module/src/custom_world/mod.rs new file mode 100644 index 00000000..f3211780 --- /dev/null +++ b/server-rs/crates/spacetime-module/src/custom_world/mod.rs @@ -0,0 +1,3254 @@ +#[spacetimedb::table( + accessor = custom_world_profile, + index(accessor = by_custom_world_profile_owner_user_id, btree(columns = [owner_user_id])), + index( + accessor = by_custom_world_profile_publication_status, + btree(columns = [publication_status]) + ) +)] +pub struct CustomWorldProfile { + #[primary_key] + profile_id: String, + // 当前 profile 承接 library / publish / enter-world 的正式世界工件真相。 + owner_user_id: String, + source_agent_session_id: Option, + publication_status: CustomWorldPublicationStatus, + world_name: String, + subtitle: String, + summary_text: String, + theme_mode: CustomWorldThemeMode, + cover_image_src: Option, + profile_payload_json: String, + playable_npc_count: u32, + landmark_count: u32, + author_display_name: String, + published_at: Option, + created_at: Timestamp, + updated_at: Timestamp, +} + +#[spacetimedb::table( + accessor = custom_world_session, + index(accessor = by_custom_world_session_owner_user_id, btree(columns = [owner_user_id])) +)] +pub struct CustomWorldSession { + #[primary_key] + session_id: String, + // 这张表只承接旧 custom-world/sessions 传统问答流,不和 agent 会话混存。 + owner_user_id: String, + generation_mode: CustomWorldGenerationMode, + status: CustomWorldSessionStatus, + setting_text: String, + creator_intent_json: Option, + question_snapshot_json: String, + result_payload_json: Option, + last_error_message: Option, + created_at: Timestamp, + updated_at: Timestamp, +} + +#[spacetimedb::table( + accessor = custom_world_agent_session, + index( + accessor = by_custom_world_agent_session_owner_user_id, + btree(columns = [owner_user_id]) + ), + index(accessor = by_custom_world_agent_session_stage, btree(columns = [stage])) +)] +pub struct CustomWorldAgentSession { + #[primary_key] + session_id: String, + // Agent 会话只保留会话级聚合字段,消息、操作、卡片都拆到独立表。 + owner_user_id: String, + seed_text: String, + current_turn: u32, + progress_percent: u32, + stage: RpgAgentStage, + focus_card_id: Option, + anchor_content_json: String, + creator_intent_json: Option, + creator_intent_readiness_json: String, + 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: String, + quality_findings_json: String, + suggested_actions_json: String, + recommended_replies_json: String, + asset_coverage_json: String, + checkpoints_json: String, + created_at: Timestamp, + updated_at: Timestamp, +} + +#[spacetimedb::table( + accessor = custom_world_agent_message, + index(accessor = by_custom_world_agent_message_session_id, btree(columns = [session_id])) +)] +pub struct CustomWorldAgentMessage { + #[primary_key] + message_id: String, + // 消息流水单独成表,避免继续塞回 session 大 JSON。 + session_id: String, + role: RpgAgentMessageRole, + kind: RpgAgentMessageKind, + text: String, + related_operation_id: Option, + created_at: Timestamp, +} + +#[spacetimedb::table( + accessor = custom_world_agent_operation, + index(accessor = by_custom_world_agent_operation_session_id, btree(columns = [session_id])) +)] +pub struct CustomWorldAgentOperation { + #[primary_key] + operation_id: String, + // 异步操作单独建表,为 message stream / operation query 提供真相源。 + session_id: String, + operation_type: RpgAgentOperationType, + status: RpgAgentOperationStatus, + phase_label: String, + phase_detail: String, + progress: u32, + error_message: Option, + created_at: Timestamp, + updated_at: Timestamp, +} + +#[spacetimedb::table( + accessor = custom_world_draft_card, + index(accessor = by_custom_world_draft_card_session_id, btree(columns = [session_id])), + index(accessor = by_custom_world_draft_card_kind, btree(columns = [kind])) +)] +pub struct CustomWorldDraftCard { + #[primary_key] + card_id: String, + // 卡片实体从 agent session 拆出,后续 detail / update 都直接对这张表操作。 + session_id: String, + kind: RpgAgentDraftCardKind, + status: RpgAgentDraftCardStatus, + title: String, + subtitle: String, + summary: String, + linked_ids_json: String, + warning_count: u32, + asset_status: Option, + asset_status_label: Option, + detail_payload_json: Option, + created_at: Timestamp, + updated_at: Timestamp, +} + +#[spacetimedb::table( + accessor = custom_world_gallery_entry, + public, + index(accessor = by_custom_world_gallery_owner_user_id, btree(columns = [owner_user_id])), + index(accessor = by_custom_world_gallery_theme_mode, btree(columns = [theme_mode])) +)] +pub struct CustomWorldGalleryEntry { + #[primary_key] + profile_id: String, + // 画廊是公开订阅读模型,不再运行时从 profile 即席拼装。 + owner_user_id: String, + author_display_name: String, + world_name: String, + subtitle: String, + summary_text: String, + cover_image_src: Option, + theme_mode: CustomWorldThemeMode, + playable_npc_count: u32, + landmark_count: u32, + published_at: Timestamp, + updated_at: Timestamp, +} + +#[spacetimedb::procedure] +pub fn create_custom_world_agent_session( + ctx: &mut ProcedureContext, + input: CustomWorldAgentSessionCreateInput, +) -> CustomWorldAgentSessionProcedureResult { + match ctx.try_with_tx(|tx| create_custom_world_agent_session_tx(tx, input.clone())) { + Ok(session) => CustomWorldAgentSessionProcedureResult { + ok: true, + session: Some(session), + error_message: None, + }, + Err(message) => CustomWorldAgentSessionProcedureResult { + ok: false, + session: None, + error_message: Some(message), + }, + } +} + +// Stage 6 读取拆表后的最小 Agent session snapshot,供 Axum 兼容旧前端 contract。 +#[spacetimedb::procedure] +pub fn get_custom_world_agent_session( + ctx: &mut ProcedureContext, + input: CustomWorldAgentSessionGetInput, +) -> CustomWorldAgentSessionProcedureResult { + match ctx.try_with_tx(|tx| get_custom_world_agent_session_tx(tx, input.clone())) { + Ok(session) => CustomWorldAgentSessionProcedureResult { + ok: true, + session: Some(session), + error_message: None, + }, + Err(message) => CustomWorldAgentSessionProcedureResult { + ok: false, + session: None, + error_message: Some(message), + }, + } +} + +#[spacetimedb::procedure] +pub fn submit_custom_world_agent_message( + ctx: &mut ProcedureContext, + input: CustomWorldAgentMessageSubmitInput, +) -> CustomWorldAgentOperationProcedureResult { + match ctx.try_with_tx(|tx| submit_custom_world_agent_message_tx(tx, input.clone())) { + Ok(operation) => CustomWorldAgentOperationProcedureResult { + ok: true, + operation: Some(operation), + error_message: None, + }, + Err(message) => CustomWorldAgentOperationProcedureResult { + ok: false, + operation: None, + error_message: Some(message), + }, + } +} + +#[spacetimedb::procedure] +pub fn get_custom_world_agent_operation( + ctx: &mut ProcedureContext, + input: CustomWorldAgentOperationGetInput, +) -> CustomWorldAgentOperationProcedureResult { + match ctx.try_with_tx(|tx| get_custom_world_agent_operation_tx(tx, input.clone())) { + Ok(operation) => CustomWorldAgentOperationProcedureResult { + ok: true, + operation: Some(operation), + error_message: None, + }, + Err(message) => CustomWorldAgentOperationProcedureResult { + ok: false, + operation: None, + error_message: Some(message), + }, + } +} + +fn continue_story_tx( + ctx: &ReducerContext, + input: StoryContinueInput, +) -> Result<(StorySessionSnapshot, StoryEventSnapshot), String> { + validate_story_continue_input(&input).map_err(|error| error.to_string())?; + + let current = ctx + .db + .story_session() + .story_session_id() + .find(&input.story_session_id) + .ok_or_else(|| "story_session 不存在,无法继续推进".to_string())?; + + let current_snapshot = StorySessionSnapshot { + story_session_id: current.story_session_id.clone(), + runtime_session_id: current.runtime_session_id.clone(), + actor_user_id: current.actor_user_id.clone(), + world_profile_id: current.world_profile_id.clone(), + initial_prompt: current.initial_prompt.clone(), + opening_summary: current.opening_summary.clone(), + latest_narrative_text: current.latest_narrative_text.clone(), + latest_choice_function_id: current.latest_choice_function_id.clone(), + status: current.status, + version: current.version, + created_at_micros: current.created_at.to_micros_since_unix_epoch(), + updated_at_micros: current.updated_at.to_micros_since_unix_epoch(), + }; + + let (next_snapshot, event_snapshot) = + apply_story_continue(current_snapshot, input).map_err(|error| error.to_string())?; + + ctx.db + .story_session() + .story_session_id() + .delete(¤t.story_session_id); + + ctx.db.story_session().insert(StorySession { + story_session_id: next_snapshot.story_session_id.clone(), + runtime_session_id: next_snapshot.runtime_session_id.clone(), + actor_user_id: next_snapshot.actor_user_id.clone(), + world_profile_id: next_snapshot.world_profile_id.clone(), + initial_prompt: next_snapshot.initial_prompt.clone(), + opening_summary: next_snapshot.opening_summary.clone(), + latest_narrative_text: next_snapshot.latest_narrative_text.clone(), + latest_choice_function_id: next_snapshot.latest_choice_function_id.clone(), + status: next_snapshot.status, + version: next_snapshot.version, + created_at: Timestamp::from_micros_since_unix_epoch(next_snapshot.created_at_micros), + updated_at: Timestamp::from_micros_since_unix_epoch(next_snapshot.updated_at_micros), + }); + + ctx.db.story_event().insert(StoryEvent { + event_id: event_snapshot.event_id.clone(), + story_session_id: event_snapshot.story_session_id.clone(), + event_kind: event_snapshot.event_kind, + narrative_text: event_snapshot.narrative_text.clone(), + choice_function_id: event_snapshot.choice_function_id.clone(), + created_at: Timestamp::from_micros_since_unix_epoch(event_snapshot.created_at_micros), + }); + + Ok((next_snapshot, event_snapshot)) +} + +fn get_story_session_state_tx( + ctx: &ReducerContext, + input: StorySessionStateInput, +) -> Result<(StorySessionSnapshot, Vec), String> { + validate_story_session_state_input(&input).map_err(|error| error.to_string())?; + + let session = ctx + .db + .story_session() + .story_session_id() + .find(&input.story_session_id) + .ok_or_else(|| "story_session 不存在".to_string())?; + + let session_snapshot = build_story_session_snapshot_from_row(&session); + let mut events = ctx + .db + .story_event() + .iter() + .filter(|row| row.story_session_id == input.story_session_id) + .map(|row| build_story_event_snapshot_from_row(&row)) + .collect::>(); + events.sort_by_key(|event| (event.created_at_micros, event.event_id.clone())); + + Ok((session_snapshot, events)) +} + +fn create_custom_world_agent_session_tx( + ctx: &ReducerContext, + input: CustomWorldAgentSessionCreateInput, +) -> Result { + validate_custom_world_agent_session_create_input(&input).map_err(|error| error.to_string())?; + + if ctx + .db + .custom_world_agent_session() + .session_id() + .find(&input.session_id) + .is_some() + { + return Err("custom_world_agent_session.session_id 已存在".to_string()); + } + if ctx + .db + .custom_world_agent_message() + .message_id() + .find(&input.welcome_message_id) + .is_some() + { + return Err("custom_world_agent_message.message_id 已存在".to_string()); + } + + let created_at = Timestamp::from_micros_since_unix_epoch(input.created_at_micros); + ctx.db + .custom_world_agent_session() + .insert(CustomWorldAgentSession { + session_id: input.session_id.clone(), + owner_user_id: input.owner_user_id.clone(), + seed_text: input.seed_text.trim().to_string(), + current_turn: 0, + progress_percent: 0, + stage: RpgAgentStage::CollectingIntent, + focus_card_id: None, + anchor_content_json: input.anchor_content_json.clone(), + creator_intent_json: input.creator_intent_json.clone(), + creator_intent_readiness_json: input.creator_intent_readiness_json.clone(), + anchor_pack_json: input.anchor_pack_json.clone(), + lock_state_json: input.lock_state_json.clone(), + draft_profile_json: input.draft_profile_json.clone(), + last_assistant_reply: Some(input.welcome_message_text.trim().to_string()), + publish_gate_json: None, + result_preview_json: None, + pending_clarifications_json: input.pending_clarifications_json.clone(), + quality_findings_json: input.quality_findings_json.clone(), + suggested_actions_json: input.suggested_actions_json.clone(), + recommended_replies_json: input.recommended_replies_json.clone(), + asset_coverage_json: input.asset_coverage_json.clone(), + checkpoints_json: input.checkpoints_json.clone(), + created_at, + updated_at: created_at, + }); + ctx.db + .custom_world_agent_message() + .insert(CustomWorldAgentMessage { + message_id: input.welcome_message_id, + session_id: input.session_id.clone(), + role: RpgAgentMessageRole::Assistant, + kind: RpgAgentMessageKind::Chat, + text: input.welcome_message_text.trim().to_string(), + related_operation_id: None, + created_at, + }); + + get_custom_world_agent_session_tx( + ctx, + CustomWorldAgentSessionGetInput { + session_id: input.session_id, + owner_user_id: input.owner_user_id, + }, + ) +} + +fn get_custom_world_agent_session_tx( + ctx: &ReducerContext, + input: CustomWorldAgentSessionGetInput, +) -> Result { + validate_custom_world_agent_session_get_input(&input).map_err(|error| error.to_string())?; + + let session = ctx + .db + .custom_world_agent_session() + .session_id() + .find(&input.session_id) + .filter(|row| row.owner_user_id == input.owner_user_id) + .ok_or_else(|| "custom_world_agent_session 不存在".to_string())?; + + Ok(build_custom_world_agent_session_snapshot(ctx, &session)) +} + +fn submit_custom_world_agent_message_tx( + ctx: &ReducerContext, + input: CustomWorldAgentMessageSubmitInput, +) -> Result { + validate_custom_world_agent_message_submit_input(&input).map_err(|error| error.to_string())?; + + if input.user_message_text.contains("__phase1_force_fail__") { + return Err("forced failure".to_string()); + } + + let session = ctx + .db + .custom_world_agent_session() + .session_id() + .find(&input.session_id) + .filter(|row| row.owner_user_id == input.owner_user_id) + .ok_or_else(|| "custom_world_agent_session 不存在".to_string())?; + + if ctx + .db + .custom_world_agent_message() + .message_id() + .find(&input.user_message_id) + .is_some() + { + return Err("custom_world_agent_message.message_id 已存在".to_string()); + } + if ctx + .db + .custom_world_agent_operation() + .operation_id() + .find(&input.operation_id) + .is_some() + { + return Err("custom_world_agent_operation.operation_id 已存在".to_string()); + } + + let submitted_at = Timestamp::from_micros_since_unix_epoch(input.submitted_at_micros); + let user_message_text = input.user_message_text.trim().to_string(); + let assistant_message_id = format!("assistant-{}", input.operation_id); + if ctx + .db + .custom_world_agent_message() + .message_id() + .find(&assistant_message_id) + .is_some() + { + return Err("custom_world_agent_message.assistant_message_id 已存在".to_string()); + } + + ctx.db + .custom_world_agent_message() + .insert(CustomWorldAgentMessage { + message_id: input.user_message_id, + session_id: input.session_id.clone(), + role: RpgAgentMessageRole::User, + kind: RpgAgentMessageKind::Chat, + text: user_message_text, + related_operation_id: Some(input.operation_id.clone()), + created_at: submitted_at, + }); + + let user_message_count = ctx + .db + .custom_world_agent_message() + .iter() + .filter(|row| { + row.session_id == input.session_id && matches!(row.role, RpgAgentMessageRole::User) + }) + .count() as u32; + let next_turn = session.current_turn.saturating_add(1); + let (next_stage, next_progress_percent, next_readiness_json, next_pending_clarifications_json) = + if user_message_count >= 2 { + ( + RpgAgentStage::FoundationReview, + 100, + r#"{"isReady":true,"completedKeys":["seed_input"],"missingKeys":[]}"#.to_string(), + "[]".to_string(), + ) + } else { + ( + RpgAgentStage::Clarifying, + session.progress_percent.max(20), + session.creator_intent_readiness_json.clone(), + format!( + r#"[{{"id":"clarify-{next_turn}","label":"补充核心设定","question":"请继续补充这个世界的玩家身份、主题氛围或核心冲突。","targetKey":"core_conflict","priority":1}}]"# + ), + ) + }; + let assistant_reply = "已记录这条设定。我会先把它当作新的世界线索收进当前草稿,你可以继续补充玩家身份、主题氛围、核心冲突、关键关系或标志性元素。".to_string(); + + ctx.db + .custom_world_agent_operation() + .insert(CustomWorldAgentOperation { + operation_id: input.operation_id.clone(), + session_id: input.session_id.clone(), + operation_type: RpgAgentOperationType::ProcessMessage, + status: RpgAgentOperationStatus::Completed, + phase_label: "消息已处理".to_string(), + phase_detail: if next_stage == RpgAgentStage::FoundationReview { + "当前上下文已达到最小 foundation_review 门槛。".to_string() + } else { + "当前上下文已记录,继续收集世界关键锚点。".to_string() + }, + progress: 100, + error_message: None, + created_at: submitted_at, + updated_at: submitted_at, + }); + + ctx.db + .custom_world_agent_message() + .insert(CustomWorldAgentMessage { + message_id: assistant_message_id, + session_id: input.session_id.clone(), + role: RpgAgentMessageRole::Assistant, + kind: RpgAgentMessageKind::Chat, + text: assistant_reply.clone(), + related_operation_id: Some(input.operation_id.clone()), + created_at: submitted_at, + }); + + ctx.db + .custom_world_agent_session() + .session_id() + .update(CustomWorldAgentSession { + session_id: session.session_id.clone(), + owner_user_id: session.owner_user_id.clone(), + seed_text: session.seed_text.clone(), + current_turn: next_turn, + progress_percent: next_progress_percent, + stage: next_stage, + focus_card_id: session.focus_card_id.clone(), + anchor_content_json: session.anchor_content_json.clone(), + creator_intent_json: session.creator_intent_json.clone(), + creator_intent_readiness_json: next_readiness_json, + anchor_pack_json: session.anchor_pack_json.clone(), + lock_state_json: session.lock_state_json.clone(), + draft_profile_json: session.draft_profile_json.clone(), + last_assistant_reply: Some(assistant_reply), + publish_gate_json: session.publish_gate_json.clone(), + result_preview_json: session.result_preview_json.clone(), + pending_clarifications_json: next_pending_clarifications_json, + quality_findings_json: session.quality_findings_json.clone(), + suggested_actions_json: session.suggested_actions_json.clone(), + recommended_replies_json: session.recommended_replies_json.clone(), + asset_coverage_json: session.asset_coverage_json.clone(), + checkpoints_json: session.checkpoints_json.clone(), + created_at: session.created_at, + updated_at: submitted_at, + }); + + get_custom_world_agent_operation_tx( + ctx, + CustomWorldAgentOperationGetInput { + session_id: input.session_id, + owner_user_id: input.owner_user_id, + operation_id: input.operation_id, + }, + ) +} + +fn get_custom_world_agent_operation_tx( + ctx: &ReducerContext, + input: CustomWorldAgentOperationGetInput, +) -> Result { + validate_custom_world_agent_operation_get_input(&input).map_err(|error| error.to_string())?; + + ctx.db + .custom_world_agent_session() + .session_id() + .find(&input.session_id) + .filter(|row| row.owner_user_id == input.owner_user_id) + .ok_or_else(|| "custom_world_agent_session 不存在".to_string())?; + + let operation = ctx + .db + .custom_world_agent_operation() + .operation_id() + .find(&input.operation_id) + .filter(|row| row.session_id == input.session_id) + .ok_or_else(|| "custom_world_agent_operation 不存在".to_string())?; + + Ok(build_custom_world_agent_operation_snapshot(&operation)) +} + +// M5 Stage 2 先把 library profile upsert 固定成最小正式写入口;已发布作品在这里同步刷新 gallery 投影。 +#[spacetimedb::reducer] +pub fn upsert_custom_world_profile( + ctx: &ReducerContext, + input: CustomWorldProfileUpsertInput, +) -> Result<(), String> { + upsert_custom_world_profile_record(ctx, input).map(|_| ()) +} + +// procedure 面向 Axum 返回 profile 与可能同步出的 gallery 投影,避免 HTTP 层再二次查询私有表。 +#[spacetimedb::procedure] +pub fn upsert_custom_world_profile_and_return( + ctx: &mut ProcedureContext, + input: CustomWorldProfileUpsertInput, +) -> CustomWorldLibraryMutationResult { + match ctx.try_with_tx(|tx| upsert_custom_world_profile_record(tx, input.clone())) { + Ok((entry, gallery_entry)) => CustomWorldLibraryMutationResult { + ok: true, + entry: Some(entry), + gallery_entry, + error_message: None, + }, + Err(message) => CustomWorldLibraryMutationResult { + ok: false, + entry: None, + gallery_entry: None, + error_message: Some(message), + }, + } +} + +// publish 负责同时推进 profile 发布态与 gallery 公开投影,避免公开列表继续运行时拼装。 +#[spacetimedb::reducer] +pub fn publish_custom_world_profile( + ctx: &ReducerContext, + input: CustomWorldProfilePublishInput, +) -> Result<(), String> { + publish_custom_world_profile_record(ctx, input).map(|_| ()) +} + +#[spacetimedb::procedure] +pub fn publish_custom_world_profile_and_return( + ctx: &mut ProcedureContext, + input: CustomWorldProfilePublishInput, +) -> CustomWorldLibraryMutationResult { + match ctx.try_with_tx(|tx| publish_custom_world_profile_record(tx, input.clone())) { + Ok((entry, gallery_entry)) => CustomWorldLibraryMutationResult { + ok: true, + entry: Some(entry), + gallery_entry, + error_message: None, + }, + Err(message) => CustomWorldLibraryMutationResult { + ok: false, + entry: None, + gallery_entry: None, + error_message: Some(message), + }, + } +} + +// unpublish 负责撤掉 gallery 投影,并把 profile 恢复为 draft。 +#[spacetimedb::reducer] +pub fn unpublish_custom_world_profile( + ctx: &ReducerContext, + input: CustomWorldProfileUnpublishInput, +) -> Result<(), String> { + unpublish_custom_world_profile_record(ctx, input).map(|_| ()) +} + +#[spacetimedb::procedure] +pub fn unpublish_custom_world_profile_and_return( + ctx: &mut ProcedureContext, + input: CustomWorldProfileUnpublishInput, +) -> CustomWorldLibraryMutationResult { + match ctx.try_with_tx(|tx| unpublish_custom_world_profile_record(tx, input.clone())) { + Ok((entry, gallery_entry)) => CustomWorldLibraryMutationResult { + ok: true, + entry: Some(entry), + gallery_entry, + error_message: None, + }, + Err(message) => CustomWorldLibraryMutationResult { + ok: false, + entry: None, + gallery_entry: None, + error_message: Some(message), + }, + } +} + +#[spacetimedb::procedure] +pub fn list_custom_world_profiles( + ctx: &mut ProcedureContext, + input: CustomWorldProfileListInput, +) -> CustomWorldProfileListResult { + match ctx.try_with_tx(|tx| list_custom_world_profile_snapshots(tx, input.clone())) { + Ok(entries) => CustomWorldProfileListResult { + ok: true, + entries, + error_message: None, + }, + Err(message) => CustomWorldProfileListResult { + ok: false, + entries: Vec::new(), + error_message: Some(message), + }, + } +} + +#[spacetimedb::procedure] +pub fn list_custom_world_gallery_entries( + ctx: &mut ProcedureContext, +) -> CustomWorldGalleryListResult { + match ctx.try_with_tx(|tx| Ok::<_, String>(list_custom_world_gallery_snapshots(tx))) { + Ok(entries) => CustomWorldGalleryListResult { + ok: true, + entries, + error_message: None, + }, + Err(message) => CustomWorldGalleryListResult { + ok: false, + entries: Vec::new(), + error_message: Some(message), + }, + } +} + +#[spacetimedb::procedure] +pub fn get_custom_world_library_detail( + ctx: &mut ProcedureContext, + input: CustomWorldLibraryDetailInput, +) -> CustomWorldLibraryMutationResult { + match ctx.try_with_tx(|tx| get_custom_world_library_detail_record(tx, input.clone())) { + Ok((entry, gallery_entry)) => CustomWorldLibraryMutationResult { + ok: true, + entry, + gallery_entry, + error_message: None, + }, + Err(message) => CustomWorldLibraryMutationResult { + ok: false, + entry: None, + gallery_entry: None, + error_message: Some(message), + }, + } +} + +#[spacetimedb::procedure] +pub fn get_custom_world_gallery_detail( + ctx: &mut ProcedureContext, + input: CustomWorldGalleryDetailInput, +) -> CustomWorldLibraryMutationResult { + match ctx.try_with_tx(|tx| get_custom_world_gallery_detail_record(tx, input.clone())) { + Ok((entry, gallery_entry)) => CustomWorldLibraryMutationResult { + ok: true, + entry, + gallery_entry, + error_message: None, + }, + Err(message) => CustomWorldLibraryMutationResult { + ok: false, + entry: None, + gallery_entry: None, + error_message: Some(message), + }, + } +} + +#[spacetimedb::procedure] +pub fn list_custom_world_works( + ctx: &mut ProcedureContext, + input: CustomWorldWorksListInput, +) -> CustomWorldWorksListResult { + match ctx.try_with_tx(|tx| list_custom_world_work_snapshots(tx, input.clone())) { + Ok(items) => CustomWorldWorksListResult { + ok: true, + items, + error_message: None, + }, + Err(message) => CustomWorldWorksListResult { + ok: false, + items: Vec::new(), + error_message: Some(message), + }, + } +} + +#[spacetimedb::procedure] +pub fn get_custom_world_agent_card_detail( + ctx: &mut ProcedureContext, + input: CustomWorldAgentCardDetailGetInput, +) -> CustomWorldDraftCardDetailResult { + match ctx.try_with_tx(|tx| get_custom_world_agent_card_detail_tx(tx, input.clone())) { + Ok(card) => CustomWorldDraftCardDetailResult { + ok: true, + card: Some(card), + error_message: None, + }, + Err(message) => CustomWorldDraftCardDetailResult { + ok: false, + card: None, + error_message: Some(message), + }, + } +} + +#[spacetimedb::procedure] +pub fn execute_custom_world_agent_action( + ctx: &mut ProcedureContext, + input: CustomWorldAgentActionExecuteInput, +) -> CustomWorldAgentActionExecuteResult { + match ctx.try_with_tx(|tx| execute_custom_world_agent_action_tx(tx, input.clone())) { + Ok(operation) => CustomWorldAgentActionExecuteResult { + ok: true, + operation: Some(operation), + error_message: None, + }, + Err(message) => CustomWorldAgentActionExecuteResult { + ok: false, + operation: None, + error_message: Some(message), + }, + } +} + +// procedure 面向 Axum 同步拉取浏览历史,继续沿用旧 Node 的 visitedAt 倒序输出语义。 +#[spacetimedb::procedure] +pub fn list_platform_browse_history( + ctx: &mut ProcedureContext, + input: RuntimeBrowseHistoryListInput, +) -> RuntimeBrowseHistoryProcedureResult { + match ctx.try_with_tx(|tx| list_platform_browse_history_rows(tx, input.clone())) { + Ok(entries) => RuntimeBrowseHistoryProcedureResult { + ok: true, + entries, + error_message: None, + }, + Err(message) => RuntimeBrowseHistoryProcedureResult { + ok: false, + entries: Vec::new(), + error_message: Some(message), + }, + } +} + +// procedure 面向 Axum 承接 browse history 的单条/批量 POST,同步返回当前用户的完整列表。 +#[spacetimedb::procedure] +pub fn upsert_platform_browse_history_and_return( + ctx: &mut ProcedureContext, + input: RuntimeBrowseHistorySyncInput, +) -> RuntimeBrowseHistoryProcedureResult { + match ctx.try_with_tx(|tx| upsert_platform_browse_history_rows(tx, input.clone())) { + Ok(entries) => RuntimeBrowseHistoryProcedureResult { + ok: true, + entries, + error_message: None, + }, + Err(message) => RuntimeBrowseHistoryProcedureResult { + ok: false, + entries: Vec::new(), + error_message: Some(message), + }, + } +} + +// procedure 面向 Axum 清空当前用户浏览历史,并直接返回空列表响应。 +#[spacetimedb::procedure] +pub fn clear_platform_browse_history_and_return( + ctx: &mut ProcedureContext, + input: RuntimeBrowseHistoryClearInput, +) -> RuntimeBrowseHistoryProcedureResult { + match ctx.try_with_tx(|tx| clear_platform_browse_history_rows(tx, input.clone())) { + Ok(entries) => RuntimeBrowseHistoryProcedureResult { + ok: true, + entries, + error_message: None, + }, + Err(message) => RuntimeBrowseHistoryProcedureResult { + ok: false, + entries: Vec::new(), + error_message: Some(message), + }, + } +} + +// Stage 3 先把 published profile compile 作为独立 procedure 暴露,避免把编译逻辑和表写入、发布动作强耦合。 +#[spacetimedb::procedure] +pub fn compile_custom_world_published_profile( + _ctx: &mut ProcedureContext, + input: CustomWorldPublishedProfileCompileInput, +) -> CustomWorldPublishedProfileCompileResult { + match build_custom_world_published_profile_compile_snapshot(input) { + Ok(record) => CustomWorldPublishedProfileCompileResult { + ok: true, + record: Some(record), + error_message: None, + }, + Err(error) => CustomWorldPublishedProfileCompileResult { + ok: false, + record: None, + error_message: Some(error.to_string()), + }, + } +} + +// Stage 4 把 publish_world 串成单事务主链:compile -> profile upsert -> profile publish -> session.stage 推进。 +#[spacetimedb::procedure] +pub fn publish_custom_world_world( + ctx: &mut ProcedureContext, + input: CustomWorldPublishWorldInput, +) -> CustomWorldPublishWorldResult { + match ctx.try_with_tx(|tx| publish_custom_world_world_record(tx, input.clone())) { + Ok((compiled_record, entry, gallery_entry, session_stage)) => { + CustomWorldPublishWorldResult { + ok: true, + compiled_record: Some(compiled_record), + entry: Some(entry), + gallery_entry, + session_stage: Some(session_stage), + error_message: None, + } + } + Err(message) => CustomWorldPublishWorldResult { + ok: false, + compiled_record: None, + entry: None, + gallery_entry: None, + session_stage: None, + error_message: Some(message), + }, + } +} + +fn upsert_custom_world_profile_record( + ctx: &ReducerContext, + input: CustomWorldProfileUpsertInput, +) -> Result< + ( + CustomWorldProfileSnapshot, + Option, + ), + String, +> { + validate_custom_world_profile_upsert_input(&input).map_err(|error| error.to_string())?; + + let updated_at = Timestamp::from_micros_since_unix_epoch(input.updated_at_micros); + let current = ctx + .db + .custom_world_profile() + .profile_id() + .find(&input.profile_id) + .filter(|row| row.owner_user_id == input.owner_user_id); + + let next_row = match current { + Some(existing) => { + ctx.db + .custom_world_profile() + .profile_id() + .delete(&existing.profile_id); + CustomWorldProfile { + profile_id: existing.profile_id.clone(), + owner_user_id: existing.owner_user_id.clone(), + source_agent_session_id: input.source_agent_session_id.clone(), + publication_status: existing.publication_status, + world_name: input.world_name.clone(), + subtitle: input.subtitle.clone(), + summary_text: input.summary_text.clone(), + theme_mode: input.theme_mode, + cover_image_src: input.cover_image_src.clone(), + profile_payload_json: input.profile_payload_json.clone(), + playable_npc_count: input.playable_npc_count, + landmark_count: input.landmark_count, + author_display_name: input.author_display_name.clone(), + published_at: existing.published_at, + created_at: existing.created_at, + updated_at, + } + } + None => CustomWorldProfile { + profile_id: input.profile_id.clone(), + owner_user_id: input.owner_user_id.clone(), + source_agent_session_id: input.source_agent_session_id.clone(), + publication_status: CustomWorldPublicationStatus::Draft, + world_name: input.world_name.clone(), + subtitle: input.subtitle.clone(), + summary_text: input.summary_text.clone(), + theme_mode: input.theme_mode, + cover_image_src: input.cover_image_src.clone(), + profile_payload_json: input.profile_payload_json.clone(), + playable_npc_count: input.playable_npc_count, + landmark_count: input.landmark_count, + author_display_name: input.author_display_name.clone(), + published_at: None, + created_at: updated_at, + updated_at, + }, + }; + + let inserted = ctx.db.custom_world_profile().insert(next_row); + + let gallery_entry = if inserted.publication_status == CustomWorldPublicationStatus::Published { + Some(sync_custom_world_gallery_entry_from_profile( + ctx, &inserted, + )?) + } else { + ctx.db + .custom_world_gallery_entry() + .profile_id() + .delete(&inserted.profile_id); + None + }; + + Ok(( + build_custom_world_profile_snapshot(&inserted), + gallery_entry, + )) +} + +fn publish_custom_world_world_record( + ctx: &ReducerContext, + input: CustomWorldPublishWorldInput, +) -> Result< + ( + module_custom_world::CustomWorldPublishedProfileCompileSnapshot, + CustomWorldProfileSnapshot, + Option, + RpgAgentStage, + ), + String, +> { + validate_custom_world_publish_world_input(&input).map_err(|error| error.to_string())?; + + let compiled_record = build_custom_world_published_profile_compile_snapshot( + CustomWorldPublishedProfileCompileInput { + session_id: input.session_id.clone(), + profile_id: input.profile_id.clone(), + owner_user_id: input.owner_user_id.clone(), + draft_profile_json: input.draft_profile_json.clone(), + legacy_result_profile_json: input.legacy_result_profile_json.clone(), + setting_text: input.setting_text.clone(), + author_display_name: input.author_display_name.clone(), + updated_at_micros: input.published_at_micros, + }, + ) + .map_err(|error| error.to_string())?; + + let _ = upsert_custom_world_profile_record( + ctx, + CustomWorldProfileUpsertInput { + profile_id: compiled_record.profile_id.clone(), + owner_user_id: compiled_record.owner_user_id.clone(), + source_agent_session_id: Some(input.session_id.clone()), + world_name: compiled_record.world_name.clone(), + subtitle: compiled_record.subtitle.clone(), + summary_text: compiled_record.summary_text.clone(), + theme_mode: compiled_record.theme_mode, + cover_image_src: compiled_record.cover_image_src.clone(), + profile_payload_json: compiled_record.compiled_profile_payload_json.clone(), + playable_npc_count: compiled_record.playable_npc_count, + landmark_count: compiled_record.landmark_count, + author_display_name: compiled_record.author_display_name.clone(), + updated_at_micros: input.published_at_micros, + }, + )?; + + let (entry, gallery_entry) = publish_custom_world_profile_record( + ctx, + CustomWorldProfilePublishInput { + profile_id: compiled_record.profile_id.clone(), + owner_user_id: compiled_record.owner_user_id.clone(), + author_display_name: compiled_record.author_display_name.clone(), + published_at_micros: input.published_at_micros, + }, + )?; + + let session_stage = mark_custom_world_agent_session_published( + ctx, + &input.session_id, + &input.owner_user_id, + input.published_at_micros, + )?; + + Ok((compiled_record, entry, gallery_entry, session_stage)) +} + +fn publish_custom_world_profile_record( + ctx: &ReducerContext, + input: CustomWorldProfilePublishInput, +) -> Result< + ( + CustomWorldProfileSnapshot, + Option, + ), + String, +> { + validate_custom_world_profile_publish_input(&input).map_err(|error| error.to_string())?; + + let existing = ctx + .db + .custom_world_profile() + .profile_id() + .find(&input.profile_id) + .filter(|row| row.owner_user_id == input.owner_user_id) + .ok_or_else(|| "custom_world_profile 不存在,无法发布".to_string())?; + + let published_at = Timestamp::from_micros_since_unix_epoch(input.published_at_micros); + + ctx.db + .custom_world_profile() + .profile_id() + .delete(&existing.profile_id); + + let next_row = CustomWorldProfile { + profile_id: existing.profile_id.clone(), + owner_user_id: existing.owner_user_id.clone(), + source_agent_session_id: existing.source_agent_session_id.clone(), + publication_status: CustomWorldPublicationStatus::Published, + world_name: existing.world_name.clone(), + subtitle: existing.subtitle.clone(), + summary_text: existing.summary_text.clone(), + theme_mode: existing.theme_mode, + cover_image_src: existing.cover_image_src.clone(), + profile_payload_json: existing.profile_payload_json.clone(), + playable_npc_count: existing.playable_npc_count, + landmark_count: existing.landmark_count, + author_display_name: input.author_display_name.clone(), + published_at: Some(published_at), + created_at: existing.created_at, + updated_at: published_at, + }; + + let inserted = ctx.db.custom_world_profile().insert(next_row); + let gallery_entry = sync_custom_world_gallery_entry_from_profile(ctx, &inserted)?; + + Ok(( + build_custom_world_profile_snapshot(&inserted), + Some(gallery_entry), + )) +} + +fn unpublish_custom_world_profile_record( + ctx: &ReducerContext, + input: CustomWorldProfileUnpublishInput, +) -> Result< + ( + CustomWorldProfileSnapshot, + Option, + ), + String, +> { + validate_custom_world_profile_unpublish_input(&input).map_err(|error| error.to_string())?; + + let existing = ctx + .db + .custom_world_profile() + .profile_id() + .find(&input.profile_id) + .filter(|row| row.owner_user_id == input.owner_user_id) + .ok_or_else(|| "custom_world_profile 不存在,无法取消发布".to_string())?; + + let updated_at = Timestamp::from_micros_since_unix_epoch(input.updated_at_micros); + + ctx.db + .custom_world_profile() + .profile_id() + .delete(&existing.profile_id); + + ctx.db + .custom_world_gallery_entry() + .profile_id() + .delete(&existing.profile_id); + + let next_row = CustomWorldProfile { + profile_id: existing.profile_id.clone(), + owner_user_id: existing.owner_user_id.clone(), + source_agent_session_id: existing.source_agent_session_id.clone(), + publication_status: CustomWorldPublicationStatus::Draft, + world_name: existing.world_name.clone(), + subtitle: existing.subtitle.clone(), + summary_text: existing.summary_text.clone(), + theme_mode: existing.theme_mode, + cover_image_src: existing.cover_image_src.clone(), + profile_payload_json: existing.profile_payload_json.clone(), + playable_npc_count: existing.playable_npc_count, + landmark_count: existing.landmark_count, + author_display_name: input.author_display_name.clone(), + published_at: None, + created_at: existing.created_at, + updated_at, + }; + + let inserted = ctx.db.custom_world_profile().insert(next_row); + + Ok((build_custom_world_profile_snapshot(&inserted), None)) +} + +fn list_custom_world_profile_snapshots( + ctx: &ReducerContext, + input: CustomWorldProfileListInput, +) -> Result, String> { + validate_custom_world_profile_list_input(&input).map_err(|error| error.to_string())?; + + let mut entries = ctx + .db + .custom_world_profile() + .iter() + .filter(|row| row.owner_user_id == input.owner_user_id) + .map(|row| build_custom_world_profile_snapshot(&row)) + .collect::>(); + + entries.sort_by(|left, right| right.updated_at_micros.cmp(&left.updated_at_micros)); + + Ok(entries) +} + +fn list_custom_world_gallery_snapshots( + ctx: &ReducerContext, +) -> Vec { + let mut entries = ctx + .db + .custom_world_gallery_entry() + .iter() + .map(|row| build_custom_world_gallery_entry_snapshot(&row)) + .collect::>(); + + entries.sort_by(|left, right| { + right + .published_at_micros + .cmp(&left.published_at_micros) + .then(right.updated_at_micros.cmp(&left.updated_at_micros)) + }); + + entries +} + +fn get_custom_world_library_detail_record( + ctx: &ReducerContext, + input: CustomWorldLibraryDetailInput, +) -> Result< + ( + Option, + Option, + ), + String, +> { + validate_custom_world_library_detail_input(&input).map_err(|error| error.to_string())?; + + let profile = ctx + .db + .custom_world_profile() + .profile_id() + .find(&input.profile_id) + .filter(|row| row.owner_user_id == input.owner_user_id); + + let gallery_entry = profile + .as_ref() + .filter(|row| row.publication_status == CustomWorldPublicationStatus::Published) + .and_then(|row| { + ctx.db + .custom_world_gallery_entry() + .profile_id() + .find(&row.profile_id) + .filter(|gallery_row| gallery_row.owner_user_id == row.owner_user_id) + }); + + Ok(( + profile.as_ref().map(build_custom_world_profile_snapshot), + gallery_entry + .as_ref() + .map(build_custom_world_gallery_entry_snapshot), + )) +} + +fn get_custom_world_gallery_detail_record( + ctx: &ReducerContext, + input: CustomWorldGalleryDetailInput, +) -> Result< + ( + Option, + Option, + ), + String, +> { + validate_custom_world_gallery_detail_input(&input).map_err(|error| error.to_string())?; + + let profile = ctx + .db + .custom_world_profile() + .profile_id() + .find(&input.profile_id) + .filter(|row| { + row.owner_user_id == input.owner_user_id + && row.publication_status == CustomWorldPublicationStatus::Published + }); + + let gallery_entry = ctx + .db + .custom_world_gallery_entry() + .profile_id() + .find(&input.profile_id) + .filter(|row| row.owner_user_id == input.owner_user_id); + + Ok(( + profile.as_ref().map(build_custom_world_profile_snapshot), + gallery_entry + .as_ref() + .map(build_custom_world_gallery_entry_snapshot), + )) +} + +fn list_custom_world_work_snapshots( + ctx: &ReducerContext, + input: CustomWorldWorksListInput, +) -> Result, String> { + validate_custom_world_works_list_input(&input).map_err(|error| error.to_string())?; + + let mut items = Vec::new(); + + for session in ctx + .db + .custom_world_agent_session() + .iter() + .filter(|row| row.owner_user_id == input.owner_user_id && row.stage != RpgAgentStage::Published) + { + let gate = build_custom_world_publish_gate_from_session(&session); + let draft_profile = parse_optional_session_object(session.draft_profile_json.as_deref()); + let title = resolve_session_work_title(&session, draft_profile.as_ref()); + let summary = resolve_session_work_summary(&session, draft_profile.as_ref()); + 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(ctx, &session, draft_profile.as_ref()); + + items.push(CustomWorldWorkSummarySnapshot { + work_id: format!("draft:{}", session.session_id), + source_type: "agent_session".to_string(), + status: "draft".to_string(), + title, + subtitle, + summary, + cover_image_src: resolve_session_work_cover_image_src(draft_profile.as_ref()), + cover_render_mode: None, + cover_character_image_srcs_json: "[]".to_string(), + updated_at_micros: session.updated_at.to_micros_since_unix_epoch(), + published_at_micros: None, + stage: Some(session.stage), + stage_label, + playable_npc_count, + landmark_count, + role_visual_ready_count: None, + role_animation_ready_count: None, + role_asset_summary_label: None, + session_id: Some(session.session_id.clone()), + profile_id: None, + can_resume: true, + can_enter_world: gate.can_enter_world, + blocker_count: gate.blocker_count, + publish_ready: gate.publish_ready, + }); + } + + for profile in ctx + .db + .custom_world_profile() + .iter() + .filter(|row| row.owner_user_id == input.owner_user_id) + { + items.push(CustomWorldWorkSummarySnapshot { + work_id: format!("published:{}", profile.profile_id), + source_type: "published_profile".to_string(), + status: profile.publication_status.as_str().to_string(), + title: profile.world_name.clone(), + subtitle: profile.subtitle.clone(), + summary: profile.summary_text.clone(), + cover_image_src: profile.cover_image_src.clone(), + cover_render_mode: None, + cover_character_image_srcs_json: "[]".to_string(), + updated_at_micros: profile.updated_at.to_micros_since_unix_epoch(), + published_at_micros: profile + .published_at + .map(|value| value.to_micros_since_unix_epoch()), + stage: None, + stage_label: None, + playable_npc_count: profile.playable_npc_count, + landmark_count: profile.landmark_count, + role_visual_ready_count: None, + role_animation_ready_count: None, + role_asset_summary_label: None, + session_id: profile.source_agent_session_id.clone(), + profile_id: Some(profile.profile_id.clone()), + can_resume: false, + can_enter_world: profile.publication_status == CustomWorldPublicationStatus::Published, + blocker_count: 0, + publish_ready: true, + }); + } + + items.sort_by(|left, right| { + right + .updated_at_micros + .cmp(&left.updated_at_micros) + .then_with(|| { + let left_rank = if left.source_type == "agent_session" { 0 } else { 1 }; + let right_rank = if right.source_type == "agent_session" { 0 } else { 1 }; + left_rank.cmp(&right_rank) + }) + .then(left.work_id.cmp(&right.work_id)) + }); + + Ok(items) +} + +fn get_custom_world_agent_card_detail_tx( + ctx: &ReducerContext, + input: CustomWorldAgentCardDetailGetInput, +) -> Result { + validate_custom_world_agent_card_detail_get_input(&input).map_err(|error| error.to_string())?; + + ctx.db + .custom_world_agent_session() + .session_id() + .find(&input.session_id) + .filter(|row| row.owner_user_id == input.owner_user_id) + .ok_or_else(|| "custom_world_agent_session 不存在".to_string())?; + + let card = ctx + .db + .custom_world_draft_card() + .card_id() + .find(&input.card_id) + .filter(|row| row.session_id == input.session_id) + .ok_or_else(|| "custom_world_draft_card 不存在".to_string())?; + + build_custom_world_draft_card_detail_snapshot(&card) +} + +fn execute_custom_world_agent_action_tx( + ctx: &ReducerContext, + input: CustomWorldAgentActionExecuteInput, +) -> Result { + validate_custom_world_agent_action_execute_input(&input).map_err(|error| error.to_string())?; + + let session = ctx + .db + .custom_world_agent_session() + .session_id() + .find(&input.session_id) + .filter(|row| row.owner_user_id == input.owner_user_id) + .ok_or_else(|| "custom_world_agent_session 不存在".to_string())?; + + if ctx + .db + .custom_world_agent_operation() + .operation_id() + .find(&input.operation_id) + .is_some() + { + return Err("custom_world_agent_operation.operation_id 已存在".to_string()); + } + + let payload = parse_optional_session_object(input.payload_json.as_deref()).unwrap_or_default(); + match input.action.trim() { + "draft_foundation" => execute_draft_foundation_action(ctx, &session, &input, &payload), + "update_draft_card" => execute_update_draft_card_action(ctx, &session, &input, &payload), + "sync_result_profile" => execute_sync_result_profile_action(ctx, &session, &input, &payload), + "publish_world" => execute_publish_world_action(ctx, &session, &input, &payload), + "revert_checkpoint" => execute_revert_checkpoint_action(ctx, &session, &input, &payload), + "generate_characters" + | "generate_landmarks" + | "generate_role_assets" + | "sync_role_assets" + | "generate_scene_assets" + | "sync_scene_assets" + | "expand_long_tail" => execute_placeholder_custom_world_action(ctx, &session, &input), + other => Err(format!("custom world action `{other}` 当前尚未支持")), + } +} + +fn execute_draft_foundation_action( + ctx: &ReducerContext, + session: &CustomWorldAgentSession, + input: &CustomWorldAgentActionExecuteInput, + payload: &JsonMap, +) -> Result { + if session.progress_percent < 100 { + return Err("draft_foundation requires progressPercent >= 100".to_string()); + } + + let updated_at = input.submitted_at_micros; + let draft_profile = if let Some(profile) = payload.get("draftProfile").and_then(JsonValue::as_object) { + profile.clone() + } else if let Some(existing) = parse_optional_session_object(session.draft_profile_json.as_deref()) { + ensure_minimal_draft_profile(existing, &session.seed_text) + } else { + build_minimal_draft_profile_from_seed(&session.seed_text) + }; + + let draft_profile_json = + serde_json::to_string(&JsonValue::Object(draft_profile.clone())).map_err(|error| { + format!("draft_foundation 无法序列化 draft_profile_json: {error}") + })?; + let gate = summarize_publish_gate_from_json( + &input.session_id, + RpgAgentStage::ObjectRefining, + Some(&draft_profile), + &parse_json_array_or_empty(&session.quality_findings_json), + ); + let next_session = rebuild_custom_world_agent_session_row( + session, + CustomWorldAgentSessionPatch { + progress_percent: Some(100), + stage: Some(RpgAgentStage::ObjectRefining), + draft_profile_json: Some(Some(draft_profile_json.clone())), + last_assistant_reply: Some(Some("世界底稿已整理完成,接下来可以继续细化卡片和发布预览。".to_string())), + 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, + )?), + checkpoints_json: Some(append_checkpoint_json( + &session.checkpoints_json, + &build_session_checkpoint_value("foundation-ready", "底稿整理完成", session), + )?), + updated_at_micros: Some(updated_at), + ..CustomWorldAgentSessionPatch::default() + }, + )?; + replace_custom_world_agent_session(ctx, session, next_session); + + upsert_world_foundation_card(ctx, &session.session_id, &draft_profile, updated_at)?; + append_custom_world_action_result_message( + ctx, + &session.session_id, + &input.operation_id, + "已整理出第一版世界底稿,并同步生成世界基础卡片。", + updated_at, + ); + + let operation = build_and_insert_custom_world_operation( + ctx, + &input.operation_id, + &session.session_id, + RpgAgentOperationType::DraftFoundation, + "底稿已整理", + "第一版 foundation draft 已写入会话与世界卡。", + updated_at, + ); + + Ok(build_custom_world_agent_operation_snapshot(&operation)) +} + +fn execute_update_draft_card_action( + ctx: &ReducerContext, + session: &CustomWorldAgentSession, + input: &CustomWorldAgentActionExecuteInput, + payload: &JsonMap, +) -> Result { + ensure_refining_stage(session.stage, "update_draft_card")?; + + let card_id = read_required_payload_text(payload, "cardId", "update_draft_card requires cardId")?; + let card = ctx + .db + .custom_world_draft_card() + .card_id() + .find(&card_id) + .filter(|row| row.session_id == session.session_id) + .ok_or_else(|| "update_draft_card target card does not exist".to_string())?; + let sections = payload + .get("sections") + .and_then(JsonValue::as_array) + .ok_or_else(|| "update_draft_card requires sections".to_string())?; + if sections.is_empty() { + return Err("update_draft_card requires sections".to_string()); + } + + let mut detail_object = parse_optional_session_object(card.detail_payload_json.as_deref()).unwrap_or_default(); + let mut detail_sections = detail_object + .get("sections") + .and_then(JsonValue::as_array) + .cloned() + .unwrap_or_else(|| build_fallback_card_sections_json(&card)); + + for patch in sections { + let patch_object = patch + .as_object() + .ok_or_else(|| "update_draft_card.sections 必须是 object 数组".to_string())?; + let section_id = read_required_payload_text( + patch_object, + "sectionId", + "update_draft_card section.sectionId is required", + )?; + let value = patch_object + .get("value") + .and_then(JsonValue::as_str) + .unwrap_or_default() + .trim() + .to_string(); + + let mut updated = false; + for existing in &mut detail_sections { + if existing.get("id").and_then(JsonValue::as_str) == Some(section_id.as_str()) { + if let Some(object) = existing.as_object_mut() { + object.insert("value".to_string(), JsonValue::String(value.clone())); + } + updated = true; + break; + } + } + + if !updated { + detail_sections.push(json!({ + "id": section_id, + "label": section_id, + "value": value, + })); + } + } + + detail_object.insert("id".to_string(), JsonValue::String(card.card_id.clone())); + detail_object.insert("kind".to_string(), JsonValue::String(card.kind.as_str().to_string())); + detail_object.insert("title".to_string(), JsonValue::String(card.title.clone())); + detail_object.insert("sections".to_string(), JsonValue::Array(detail_sections.clone())); + detail_object.insert( + "linkedIds".to_string(), + serde_json::from_str::(&card.linked_ids_json).unwrap_or_else(|_| JsonValue::Array(Vec::new())), + ); + detail_object.insert("locked".to_string(), JsonValue::Bool(false)); + detail_object.insert("editable".to_string(), JsonValue::Bool(false)); + detail_object.insert("editableSectionIds".to_string(), JsonValue::Array(Vec::new())); + detail_object.insert("warningMessages".to_string(), JsonValue::Array(Vec::new())); + + let updated_title = extract_detail_section_value(&detail_sections, "title").unwrap_or_else(|| card.title.clone()); + let updated_subtitle = + extract_detail_section_value(&detail_sections, "subtitle").unwrap_or_else(|| card.subtitle.clone()); + let updated_summary = + extract_detail_section_value(&detail_sections, "summary").unwrap_or_else(|| card.summary.clone()); + let detail_payload_json = + serde_json::to_string(&JsonValue::Object(detail_object)).map_err(|error| { + format!("update_draft_card 无法序列化 detail_payload_json: {error}") + })?; + + replace_custom_world_draft_card( + ctx, + &card, + CustomWorldDraftCard { + card_id: card.card_id.clone(), + session_id: card.session_id.clone(), + kind: card.kind, + status: card.status, + title: updated_title.clone(), + subtitle: updated_subtitle.clone(), + summary: updated_summary.clone(), + linked_ids_json: card.linked_ids_json.clone(), + warning_count: card.warning_count, + asset_status: card.asset_status, + asset_status_label: card.asset_status_label.clone(), + detail_payload_json: Some(detail_payload_json), + created_at: card.created_at, + updated_at: Timestamp::from_micros_since_unix_epoch(input.submitted_at_micros), + }, + ); + + let next_session = sync_session_draft_profile_from_card_update(session, &card, &updated_title, &updated_subtitle, &updated_summary, input.submitted_at_micros)?; + replace_custom_world_agent_session(ctx, session, next_session); + + append_custom_world_action_result_message( + ctx, + &session.session_id, + &input.operation_id, + &format!("已更新卡片《{}》的草稿内容。", updated_title), + input.submitted_at_micros, + ); + + let operation = build_and_insert_custom_world_operation( + ctx, + &input.operation_id, + &session.session_id, + RpgAgentOperationType::UpdateDraftCard, + "卡片已更新", + &format!("卡片 {} 的 detail 与摘要字段已同步更新。", card_id), + input.submitted_at_micros, + ); + + Ok(build_custom_world_agent_operation_snapshot(&operation)) +} + +fn execute_sync_result_profile_action( + ctx: &ReducerContext, + session: &CustomWorldAgentSession, + input: &CustomWorldAgentActionExecuteInput, + payload: &JsonMap, +) -> Result { + ensure_refining_stage(session.stage, "sync_result_profile")?; + let profile = payload + .get("profile") + .and_then(JsonValue::as_object) + .cloned() + .ok_or_else(|| "sync_result_profile requires profile".to_string())?; + let draft_profile = ensure_minimal_draft_profile(profile, &session.seed_text); + let gate = summarize_publish_gate_from_json( + &session.session_id, + session.stage, + Some(&draft_profile), + &parse_json_array_or_empty(&session.quality_findings_json), + ); + + let next_session = rebuild_custom_world_agent_session_row( + session, + CustomWorldAgentSessionPatch { + draft_profile_json: Some(Some(serialize_json_value(&JsonValue::Object(draft_profile.clone()))?)), + last_assistant_reply: Some(Some("结果页草稿已同步回当前会话。".to_string())), + 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), + input.submitted_at_micros, + )?), + checkpoints_json: Some(append_checkpoint_json( + &session.checkpoints_json, + &build_session_checkpoint_value("sync-result-profile", "同步结果页草稿", session), + )?), + updated_at_micros: Some(input.submitted_at_micros), + ..CustomWorldAgentSessionPatch::default() + }, + )?; + replace_custom_world_agent_session(ctx, session, next_session); + + append_custom_world_action_result_message( + ctx, + &session.session_id, + &input.operation_id, + "结果页 profile 已回写当前会话,并重建预览。", + input.submitted_at_micros, + ); + + let operation = build_and_insert_custom_world_operation( + ctx, + &input.operation_id, + &session.session_id, + RpgAgentOperationType::SyncResultProfile, + "结果页已同步", + "draft_profile_json 与 result_preview 已更新。", + input.submitted_at_micros, + ); + + Ok(build_custom_world_agent_operation_snapshot(&operation)) +} + +fn execute_publish_world_action( + ctx: &ReducerContext, + session: &CustomWorldAgentSession, + input: &CustomWorldAgentActionExecuteInput, + payload: &JsonMap, +) -> Result { + ensure_publishable_stage(session.stage, "publish_world")?; + + let draft_profile = if let Some(explicit) = payload.get("draftProfile").and_then(JsonValue::as_object) { + explicit.clone() + } else { + parse_optional_session_object(session.draft_profile_json.as_deref()) + .ok_or_else(|| "publish_world requires draft_profile_json".to_string())? + }; + let gate = summarize_publish_gate_from_json( + &session.session_id, + session.stage, + Some(&draft_profile), + &parse_json_array_or_empty(&session.quality_findings_json), + ); + if !gate.publish_ready { + return Err(format!( + "当前世界仍有 {} 个 blocker,暂时不能发布", + gate.blocker_count + )); + } + + let profile_id = payload + .get("profileId") + .and_then(JsonValue::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(ToOwned::to_owned) + .unwrap_or_else(|| gate.profile_id.clone()); + let setting_text = payload + .get("settingText") + .and_then(JsonValue::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(ToOwned::to_owned) + .unwrap_or_else(|| session.seed_text.clone()); + let legacy_result_profile_json = payload + .get("legacyResultProfile") + .map(serialize_json_value) + .transpose()?; + let publish_result = publish_custom_world_world_record( + ctx, + CustomWorldPublishWorldInput { + session_id: session.session_id.clone(), + profile_id, + owner_user_id: session.owner_user_id.clone(), + draft_profile_json: serialize_json_value(&JsonValue::Object(draft_profile.clone()))?, + legacy_result_profile_json, + setting_text, + author_display_name: "创作者".to_string(), + published_at_micros: input.submitted_at_micros, + }, + )?; + + append_custom_world_action_result_message( + ctx, + &session.session_id, + &input.operation_id, + &format!("正式世界档案已发布:{}。", publish_result.1.profile_id), + input.submitted_at_micros, + ); + + let operation = build_and_insert_custom_world_operation( + ctx, + &input.operation_id, + &session.session_id, + RpgAgentOperationType::PublishWorld, + "世界已发布", + &format!("正式世界档案已写入作品库:{}。", publish_result.1.profile_id), + input.submitted_at_micros, + ); + + Ok(build_custom_world_agent_operation_snapshot(&operation)) +} + +fn execute_revert_checkpoint_action( + ctx: &ReducerContext, + session: &CustomWorldAgentSession, + input: &CustomWorldAgentActionExecuteInput, + payload: &JsonMap, +) -> Result { + ensure_long_tail_stage(session.stage, "revert_checkpoint")?; + let checkpoint_id = read_required_payload_text( + payload, + "checkpointId", + "revert_checkpoint requires checkpointId", + )?; + let checkpoint = parse_json_array_or_empty(&session.checkpoints_json) + .into_iter() + .find(|entry| { + entry + .get("checkpointId") + .and_then(JsonValue::as_str) + .map(str::trim) + == Some(checkpoint_id.as_str()) + }) + .ok_or_else(|| "revert_checkpoint target checkpoint does not exist".to_string())?; + let snapshot = checkpoint + .get("snapshot") + .and_then(JsonValue::as_object) + .cloned() + .ok_or_else(|| { + "revert_checkpoint target checkpoint does not contain a restorable snapshot".to_string() + })?; + + let restored_stage = snapshot + .get("stage") + .and_then(JsonValue::as_str) + .and_then(parse_rpg_agent_stage) + .unwrap_or(session.stage); + let restored_progress = snapshot + .get("progressPercent") + .and_then(JsonValue::as_u64) + .and_then(|value| u32::try_from(value).ok()) + .unwrap_or(session.progress_percent); + let restored_draft_profile = snapshot + .get("draftProfile") + .and_then(JsonValue::as_object) + .cloned(); + let restored_quality_findings = snapshot + .get("qualityFindings") + .and_then(JsonValue::as_array) + .cloned() + .unwrap_or_else(Vec::new); + let gate = summarize_publish_gate_from_json( + &session.session_id, + restored_stage, + restored_draft_profile.as_ref(), + &restored_quality_findings, + ); + + let next_session = rebuild_custom_world_agent_session_row( + session, + CustomWorldAgentSessionPatch { + progress_percent: Some(restored_progress), + stage: Some(restored_stage), + 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))?)), + result_preview_json: Some(build_result_preview_json( + restored_draft_profile.as_ref(), + &gate, + &parse_json_array_or_empty(&serialize_json_value(&JsonValue::Array( + snapshot + .get("qualityFindings") + .and_then(JsonValue::as_array) + .cloned() + .unwrap_or_else(Vec::new), + ))?), + input.submitted_at_micros, + )?), + updated_at_micros: Some(input.submitted_at_micros), + ..CustomWorldAgentSessionPatch::default() + }, + )?; + replace_custom_world_agent_session(ctx, session, next_session); + + append_custom_world_action_result_message( + ctx, + &session.session_id, + &input.operation_id, + "已恢复到所选 checkpoint。", + input.submitted_at_micros, + ); + + let operation = build_and_insert_custom_world_operation( + ctx, + &input.operation_id, + &session.session_id, + RpgAgentOperationType::RevertCheckpoint, + "已回滚 checkpoint", + &format!("会话已恢复到 checkpoint {}。", checkpoint_id), + input.submitted_at_micros, + ); + + Ok(build_custom_world_agent_operation_snapshot(&operation)) +} + +fn execute_placeholder_custom_world_action( + ctx: &ReducerContext, + session: &CustomWorldAgentSession, + input: &CustomWorldAgentActionExecuteInput, +) -> Result { + let operation_type = map_action_name_to_operation_type(input.action.as_str()) + .ok_or_else(|| format!("action {} 无法映射到 operation type", input.action))?; + append_custom_world_action_result_message( + ctx, + &session.session_id, + &input.operation_id, + &format!("动作 {} 已接入最小兼容占位,后续会继续补真实编排。", input.action), + input.submitted_at_micros, + ); + let operation = build_and_insert_custom_world_operation( + ctx, + &input.operation_id, + &session.session_id, + operation_type, + "动作已完成", + &format!("{} 当前已走最小兼容闭环。", input.action), + input.submitted_at_micros, + ); + 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, + owner_user_id: &str, + updated_at_micros: i64, +) -> Result { + let existing = ctx + .db + .custom_world_agent_session() + .session_id() + .find(&session_id.to_string()) + .filter(|row| row.owner_user_id == owner_user_id) + .ok_or_else(|| "custom_world_agent_session 不存在,无法推进到 published".to_string())?; + + ctx.db + .custom_world_agent_session() + .session_id() + .delete(&existing.session_id); + + let next_row = CustomWorldAgentSession { + session_id: existing.session_id.clone(), + owner_user_id: existing.owner_user_id.clone(), + seed_text: existing.seed_text.clone(), + current_turn: existing.current_turn, + progress_percent: existing.progress_percent, + stage: RpgAgentStage::Published, + focus_card_id: existing.focus_card_id.clone(), + anchor_content_json: existing.anchor_content_json.clone(), + creator_intent_json: existing.creator_intent_json.clone(), + creator_intent_readiness_json: existing.creator_intent_readiness_json.clone(), + anchor_pack_json: existing.anchor_pack_json.clone(), + lock_state_json: existing.lock_state_json.clone(), + draft_profile_json: existing.draft_profile_json.clone(), + last_assistant_reply: existing.last_assistant_reply.clone(), + publish_gate_json: existing.publish_gate_json.clone(), + result_preview_json: existing.result_preview_json.clone(), + pending_clarifications_json: existing.pending_clarifications_json.clone(), + quality_findings_json: existing.quality_findings_json.clone(), + suggested_actions_json: existing.suggested_actions_json.clone(), + recommended_replies_json: existing.recommended_replies_json.clone(), + asset_coverage_json: existing.asset_coverage_json.clone(), + checkpoints_json: existing.checkpoints_json.clone(), + created_at: existing.created_at, + updated_at: Timestamp::from_micros_since_unix_epoch(updated_at_micros), + }; + + ctx.db.custom_world_agent_session().insert(next_row); + + Ok(RpgAgentStage::Published) +} + +fn sync_custom_world_gallery_entry_from_profile( + ctx: &ReducerContext, + profile: &CustomWorldProfile, +) -> Result { + let published_at = profile + .published_at + .ok_or_else(|| "published profile 缺少 published_at,无法同步 gallery".to_string())?; + + ctx.db + .custom_world_gallery_entry() + .profile_id() + .delete(&profile.profile_id); + + let row = CustomWorldGalleryEntry { + profile_id: profile.profile_id.clone(), + owner_user_id: profile.owner_user_id.clone(), + author_display_name: profile.author_display_name.clone(), + world_name: profile.world_name.clone(), + subtitle: profile.subtitle.clone(), + summary_text: profile.summary_text.clone(), + cover_image_src: profile.cover_image_src.clone(), + theme_mode: profile.theme_mode, + playable_npc_count: profile.playable_npc_count, + landmark_count: profile.landmark_count, + published_at, + updated_at: profile.updated_at, + }; + + let inserted = ctx.db.custom_world_gallery_entry().insert(row); + + Ok(build_custom_world_gallery_entry_snapshot(&inserted)) +} + +fn build_custom_world_profile_snapshot(row: &CustomWorldProfile) -> CustomWorldProfileSnapshot { + CustomWorldProfileSnapshot { + profile_id: row.profile_id.clone(), + owner_user_id: row.owner_user_id.clone(), + source_agent_session_id: row.source_agent_session_id.clone(), + publication_status: row.publication_status, + world_name: row.world_name.clone(), + subtitle: row.subtitle.clone(), + summary_text: row.summary_text.clone(), + theme_mode: row.theme_mode, + cover_image_src: row.cover_image_src.clone(), + profile_payload_json: row.profile_payload_json.clone(), + playable_npc_count: row.playable_npc_count, + landmark_count: row.landmark_count, + author_display_name: row.author_display_name.clone(), + published_at_micros: row + .published_at + .map(|value| value.to_micros_since_unix_epoch()), + created_at_micros: row.created_at.to_micros_since_unix_epoch(), + updated_at_micros: row.updated_at.to_micros_since_unix_epoch(), + } +} + +fn build_custom_world_agent_session_snapshot( + ctx: &ReducerContext, + row: &CustomWorldAgentSession, +) -> CustomWorldAgentSessionSnapshot { + let mut messages = ctx + .db + .custom_world_agent_message() + .iter() + .filter(|message| message.session_id == row.session_id) + .map(|message| build_custom_world_agent_message_snapshot(&message)) + .collect::>(); + messages.sort_by_key(|message| (message.created_at_micros, message.message_id.clone())); + + let mut draft_cards = ctx + .db + .custom_world_draft_card() + .iter() + .filter(|card| card.session_id == row.session_id) + .map(|card| build_custom_world_draft_card_snapshot(&card)) + .collect::>(); + draft_cards.sort_by_key(|card| (card.created_at_micros, card.card_id.clone())); + + let mut operations = ctx + .db + .custom_world_agent_operation() + .iter() + .filter(|operation| operation.session_id == row.session_id) + .map(|operation| build_custom_world_agent_operation_snapshot(&operation)) + .collect::>(); + operations + .sort_by_key(|operation| (operation.created_at_micros, operation.operation_id.clone())); + + CustomWorldAgentSessionSnapshot { + session_id: row.session_id.clone(), + owner_user_id: row.owner_user_id.clone(), + seed_text: row.seed_text.clone(), + current_turn: row.current_turn, + progress_percent: row.progress_percent, + stage: row.stage, + focus_card_id: row.focus_card_id.clone(), + anchor_content_json: row.anchor_content_json.clone(), + creator_intent_json: row.creator_intent_json.clone(), + creator_intent_readiness_json: row.creator_intent_readiness_json.clone(), + anchor_pack_json: row.anchor_pack_json.clone(), + lock_state_json: row.lock_state_json.clone(), + draft_profile_json: row.draft_profile_json.clone(), + last_assistant_reply: row.last_assistant_reply.clone(), + publish_gate_json: row.publish_gate_json.clone(), + result_preview_json: row.result_preview_json.clone(), + pending_clarifications_json: row.pending_clarifications_json.clone(), + quality_findings_json: row.quality_findings_json.clone(), + suggested_actions_json: row.suggested_actions_json.clone(), + 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, + created_at_micros: row.created_at.to_micros_since_unix_epoch(), + updated_at_micros: row.updated_at.to_micros_since_unix_epoch(), + } +} + +fn build_custom_world_agent_message_snapshot( + row: &CustomWorldAgentMessage, +) -> CustomWorldAgentMessageSnapshot { + CustomWorldAgentMessageSnapshot { + message_id: row.message_id.clone(), + session_id: row.session_id.clone(), + role: row.role, + kind: row.kind, + text: row.text.clone(), + related_operation_id: row.related_operation_id.clone(), + created_at_micros: row.created_at.to_micros_since_unix_epoch(), + } +} + +fn build_custom_world_agent_operation_snapshot( + row: &CustomWorldAgentOperation, +) -> CustomWorldAgentOperationSnapshot { + CustomWorldAgentOperationSnapshot { + operation_id: row.operation_id.clone(), + session_id: row.session_id.clone(), + operation_type: row.operation_type, + status: row.status, + phase_label: row.phase_label.clone(), + phase_detail: row.phase_detail.clone(), + progress: row.progress, + error_message: row.error_message.clone(), + created_at_micros: row.created_at.to_micros_since_unix_epoch(), + updated_at_micros: row.updated_at.to_micros_since_unix_epoch(), + } +} + +fn build_custom_world_draft_card_snapshot( + row: &CustomWorldDraftCard, +) -> CustomWorldDraftCardSnapshot { + CustomWorldDraftCardSnapshot { + card_id: row.card_id.clone(), + session_id: row.session_id.clone(), + kind: row.kind, + status: row.status, + title: row.title.clone(), + subtitle: row.subtitle.clone(), + summary: row.summary.clone(), + linked_ids_json: row.linked_ids_json.clone(), + warning_count: row.warning_count, + asset_status: row.asset_status, + asset_status_label: row.asset_status_label.clone(), + detail_payload_json: row.detail_payload_json.clone(), + created_at_micros: row.created_at.to_micros_since_unix_epoch(), + updated_at_micros: row.updated_at.to_micros_since_unix_epoch(), + } +} + +fn build_custom_world_gallery_entry_snapshot( + row: &CustomWorldGalleryEntry, +) -> CustomWorldGalleryEntrySnapshot { + CustomWorldGalleryEntrySnapshot { + profile_id: row.profile_id.clone(), + owner_user_id: row.owner_user_id.clone(), + author_display_name: row.author_display_name.clone(), + world_name: row.world_name.clone(), + subtitle: row.subtitle.clone(), + summary_text: row.summary_text.clone(), + cover_image_src: row.cover_image_src.clone(), + theme_mode: row.theme_mode, + playable_npc_count: row.playable_npc_count, + landmark_count: row.landmark_count, + published_at_micros: row.published_at.to_micros_since_unix_epoch(), + updated_at_micros: row.updated_at.to_micros_since_unix_epoch(), + } +} diff --git a/server-rs/crates/spacetime-module/src/domain_types.rs b/server-rs/crates/spacetime-module/src/domain_types.rs new file mode 100644 index 00000000..27892a43 --- /dev/null +++ b/server-rs/crates/spacetime-module/src/domain_types.rs @@ -0,0 +1,29 @@ +#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)] +pub struct ResolveNpcBattleInteractionInput { + pub npc_interaction: ResolveNpcInteractionInput, + pub story_session_id: String, + pub actor_user_id: String, + pub battle_state_id: Option, + pub player_hp: i32, + pub player_max_hp: i32, + pub player_mana: i32, + pub player_max_mana: i32, + pub target_hp: i32, + pub target_max_hp: i32, + pub experience_reward: u32, + pub reward_items: Vec, +} + +// 输出同时返回 NPC 交互结果与 battle_state 快照,避免 Axum 再回头读取 private table。 +#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)] +pub struct NpcBattleInteractionResult { + pub interaction: module_npc::NpcInteractionResult, + pub battle_state: BattleStateSnapshot, +} + +#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)] +pub struct NpcBattleInteractionProcedureResult { + pub ok: bool, + pub result: Option, + pub error_message: Option, +} diff --git a/server-rs/crates/spacetime-module/src/entry.rs b/server-rs/crates/spacetime-module/src/entry.rs new file mode 100644 index 00000000..34a98165 --- /dev/null +++ b/server-rs/crates/spacetime-module/src/entry.rs @@ -0,0 +1,23 @@ +// 当前阶段先落可发布的最小模块入口,后续再补对象确认、业务绑定与任务编排 reducer。 +#[spacetimedb::reducer(init)] +pub fn init(_ctx: &ReducerContext) { + log::info!( + "spacetime-module 初始化完成,asset_object 已固定 bucket/object_key 双列主存储口径,runtime_setting 已固定默认音量={} 和默认主题={},battle_state 前缀={},战斗初始版本={},npc_state 前缀={},npc 招募阈值={},story_session 前缀={},story_event 前缀={},inventory_slot 前缀={},inventory_mutation 前缀={},quest_log 前缀={},treasure_record 前缀={},player_progression 与 chapter_progression 已接入成长真相表,M5 custom_world_profile/session/agent/gallery 首批表骨架已接入,默认对象 ID 前缀={},默认绑定 ID 前缀={},资产初始版本={},故事会话初始版本={}", + DEFAULT_MUSIC_VOLUME, + DEFAULT_PLATFORM_THEME.as_str(), + BATTLE_STATE_ID_PREFIX, + INITIAL_BATTLE_VERSION, + NPC_STATE_ID_PREFIX, + NPC_RECRUIT_AFFINITY_THRESHOLD, + STORY_SESSION_ID_PREFIX, + STORY_EVENT_ID_PREFIX, + INVENTORY_SLOT_ID_PREFIX, + INVENTORY_MUTATION_ID_PREFIX, + QUEST_LOG_ID_PREFIX, + TREASURE_RECORD_ID_PREFIX, + ASSET_OBJECT_ID_PREFIX, + ASSET_BINDING_ID_PREFIX, + INITIAL_ASSET_OBJECT_VERSION, + INITIAL_STORY_SESSION_VERSION + ); +} diff --git a/server-rs/crates/spacetime-module/src/gameplay/mod.rs b/server-rs/crates/spacetime-module/src/gameplay/mod.rs new file mode 100644 index 00000000..dfee8a93 --- /dev/null +++ b/server-rs/crates/spacetime-module/src/gameplay/mod.rs @@ -0,0 +1,2110 @@ +#[spacetimedb::table(accessor = player_progression)] +pub struct PlayerProgression { + #[primary_key] + user_id: String, + level: u32, + current_level_xp: u32, + total_xp: u32, + xp_to_next_level: u32, + pending_level_ups: u32, + last_granted_source: Option, + created_at: Timestamp, + updated_at: Timestamp, +} + +#[spacetimedb::table( + accessor = chapter_progression, + index(accessor = by_chapter_progression_user_id, btree(columns = [user_id])), + index(accessor = by_chapter_progression_chapter_id, btree(columns = [chapter_id])), + index(accessor = by_chapter_progression_user_chapter, btree(columns = [user_id, chapter_id])) +)] +pub struct ChapterProgression { + #[primary_key] + chapter_progression_id: String, + user_id: String, + chapter_id: String, + chapter_index: u32, + total_chapters: u32, + entry_pseudo_level_millis: u32, + exit_pseudo_level_millis: u32, + entry_level: u32, + exit_level: u32, + planned_total_xp: u32, + planned_quest_xp: u32, + planned_hostile_xp: u32, + actual_quest_xp: u32, + actual_hostile_xp: u32, + expected_hostile_defeat_count: u32, + actual_hostile_defeat_count: u32, + level_at_entry: u32, + level_at_exit: Option, + pace_band: ChapterPaceBand, + created_at: Timestamp, + updated_at: Timestamp, +} + +#[spacetimedb::table( + accessor = npc_state, + index(accessor = by_runtime_session_id, btree(columns = [runtime_session_id])), + index(accessor = by_npc_id, btree(columns = [npc_id])), + index(accessor = by_runtime_session_npc, btree(columns = [runtime_session_id, npc_id])) +)] +pub struct NpcState { + #[primary_key] + npc_state_id: String, + runtime_session_id: String, + npc_id: String, + npc_name: String, + affinity: i32, + relation_state: NpcRelationState, + help_used: bool, + chatted_count: u32, + gifts_given: u32, + recruited: bool, + trade_stock_signature: Option, + revealed_facts: Vec, + known_attribute_rumors: Vec, + first_meaningful_contact_resolved: bool, + seen_backstory_chapter_ids: Vec, + stance_profile: NpcStanceProfile, + created_at: Timestamp, + updated_at: Timestamp, +} + +#[spacetimedb::table( + accessor = story_session, + index(accessor = by_runtime_session_id, btree(columns = [runtime_session_id])), + index(accessor = by_actor_user_id, btree(columns = [actor_user_id])) +)] +pub struct StorySession { + #[primary_key] + story_session_id: String, + runtime_session_id: String, + actor_user_id: String, + world_profile_id: String, + initial_prompt: String, + opening_summary: Option, + latest_narrative_text: String, + latest_choice_function_id: Option, + status: StorySessionStatus, + version: u32, + created_at: Timestamp, + updated_at: Timestamp, +} + +#[spacetimedb::table( + accessor = story_event, + index(accessor = by_story_session_id, btree(columns = [story_session_id])) +)] +pub struct StoryEvent { + #[primary_key] + event_id: String, + story_session_id: String, + event_kind: StoryEventKind, + narrative_text: String, + choice_function_id: Option, + created_at: Timestamp, +} + +#[spacetimedb::table( + accessor = inventory_slot, + index(accessor = by_inventory_runtime_session_id, btree(columns = [runtime_session_id])), + index(accessor = by_inventory_actor_user_id, btree(columns = [actor_user_id])), + index(accessor = by_inventory_container_slot, btree(columns = [container_kind, slot_key])), + index(accessor = by_inventory_item_id, btree(columns = [item_id])) +)] +pub struct InventorySlot { + #[primary_key] + slot_id: String, + runtime_session_id: String, + story_session_id: Option, + actor_user_id: String, + container_kind: InventoryContainerKind, + slot_key: String, + item_id: String, + category: String, + name: String, + description: Option, + quantity: u32, + rarity: InventoryItemRarity, + tags: Vec, + stackable: bool, + stack_key: String, + equipment_slot_id: Option, + source_kind: InventoryItemSourceKind, + source_reference_id: Option, + created_at: Timestamp, + updated_at: Timestamp, +} + +#[spacetimedb::table( + accessor = battle_state, + index(accessor = by_battle_story_session_id, btree(columns = [story_session_id])), + index(accessor = by_battle_runtime_session_id, btree(columns = [runtime_session_id])), + index(accessor = by_battle_actor_user_id, btree(columns = [actor_user_id])) +)] +pub struct BattleState { + #[primary_key] + battle_state_id: String, + story_session_id: String, + runtime_session_id: String, + actor_user_id: String, + chapter_id: Option, + target_npc_id: String, + target_name: String, + battle_mode: BattleMode, + status: BattleStatus, + player_hp: i32, + player_max_hp: i32, + player_mana: i32, + player_max_mana: i32, + target_hp: i32, + target_max_hp: i32, + experience_reward: u32, + reward_items: Vec, + turn_index: u32, + last_action_function_id: Option, + last_action_text: Option, + last_result_text: Option, + last_damage_dealt: i32, + last_damage_taken: i32, + last_outcome: CombatOutcome, + version: u32, + created_at: Timestamp, + updated_at: Timestamp, +} + +#[spacetimedb::table( + accessor = treasure_record, + index(accessor = by_treasure_story_session_id, btree(columns = [story_session_id])), + index(accessor = by_treasure_runtime_session_id, btree(columns = [runtime_session_id])), + index(accessor = by_treasure_actor_user_id, btree(columns = [actor_user_id])), + index(accessor = by_treasure_encounter_id, btree(columns = [encounter_id])) +)] +pub struct TreasureRecord { + #[primary_key] + treasure_record_id: String, + runtime_session_id: String, + story_session_id: String, + actor_user_id: String, + encounter_id: String, + encounter_name: String, + scene_id: Option, + scene_name: Option, + action: TreasureInteractionAction, + reward_items: Vec, + reward_hp: u32, + reward_mana: u32, + reward_currency: u32, + story_hint: Option, + created_at: Timestamp, + updated_at: Timestamp, +} + +#[spacetimedb::table( + accessor = quest_record, + index(accessor = by_runtime_session_id, btree(columns = [runtime_session_id])), + index(accessor = by_actor_user_id, btree(columns = [actor_user_id])), + index(accessor = by_issuer_npc_id, btree(columns = [issuer_npc_id])) +)] +pub struct QuestRecord { + #[primary_key] + quest_id: String, + runtime_session_id: String, + story_session_id: Option, + actor_user_id: String, + issuer_npc_id: String, + issuer_npc_name: String, + scene_id: Option, + chapter_id: Option, + act_id: Option, + thread_id: Option, + contract_id: Option, + title: String, + description: String, + summary: String, + objective: QuestObjectiveSnapshot, + progress: u32, + status: QuestStatus, + completion_notified: bool, + reward: QuestRewardSnapshot, + reward_text: String, + narrative_binding: QuestNarrativeBindingSnapshot, + steps: Vec, + active_step_id: Option, + visible_stage: u32, + hidden_flags: Vec, + discovered_fact_ids: Vec, + related_carrier_ids: Vec, + consequence_ids: Vec, + created_at: Timestamp, + updated_at: Timestamp, + completed_at: Option, + turned_in_at: Option, +} + +#[spacetimedb::table( + accessor = quest_log, + index(accessor = by_quest_id, btree(columns = [quest_id])), + index(accessor = by_runtime_session_id, btree(columns = [runtime_session_id])), + index(accessor = by_actor_user_id, btree(columns = [actor_user_id])) +)] +pub struct QuestLog { + #[primary_key] + log_id: String, + quest_id: String, + runtime_session_id: String, + actor_user_id: String, + event_kind: QuestLogEventKind, + status_after: QuestStatus, + signal_kind: Option, + signal: Option, + step_id: Option, + step_progress: Option, + created_at: Timestamp, +} + +#[spacetimedb::procedure] +pub fn get_player_progression_or_default( + ctx: &mut ProcedureContext, + input: PlayerProgressionGetInput, +) -> PlayerProgressionProcedureResult { + match ctx.try_with_tx(|tx| get_player_progression_snapshot_tx(tx, input.clone())) { + Ok(record) => PlayerProgressionProcedureResult { + ok: true, + record: Some(record), + error_message: None, + }, + Err(message) => PlayerProgressionProcedureResult { + ok: false, + record: None, + error_message: Some(message), + }, + } +} + +// 经验发放统一走 progression reducer,避免任务和战斗各自直接写等级字段。 +#[spacetimedb::reducer] +pub fn grant_player_progression_experience( + ctx: &ReducerContext, + input: PlayerProgressionGrantInput, +) -> Result<(), String> { + upsert_player_progression_after_grant_tx(ctx, input).map(|_| ()) +} + +#[spacetimedb::procedure] +pub fn grant_player_progression_experience_and_return( + ctx: &mut ProcedureContext, + input: PlayerProgressionGrantInput, +) -> PlayerProgressionProcedureResult { + match ctx.try_with_tx(|tx| upsert_player_progression_after_grant_tx(tx, input.clone())) { + Ok(record) => PlayerProgressionProcedureResult { + ok: true, + record: Some(record), + error_message: None, + }, + Err(message) => PlayerProgressionProcedureResult { + ok: false, + record: None, + error_message: Some(message), + }, + } +} + +// 章节计划在进入章节或编译章节预算时写入;当前先用单表同时承接计划值与实际记账值。 +#[spacetimedb::reducer] +pub fn upsert_chapter_progression( + ctx: &ReducerContext, + input: ChapterProgressionInput, +) -> Result<(), String> { + upsert_chapter_progression_snapshot_tx(ctx, input).map(|_| ()) +} + +#[spacetimedb::procedure] +pub fn upsert_chapter_progression_and_return( + ctx: &mut ProcedureContext, + input: ChapterProgressionInput, +) -> ChapterProgressionProcedureResult { + match ctx.try_with_tx(|tx| upsert_chapter_progression_snapshot_tx(tx, input.clone())) { + Ok(record) => ChapterProgressionProcedureResult { + ok: true, + record: Some(record), + error_message: None, + }, + Err(message) => ChapterProgressionProcedureResult { + ok: false, + record: None, + error_message: Some(message), + }, + } +} + +// 章节实际经验与击杀记账后续由 quest/combat 联动调用,这一轮先把真相写入口固定下来。 +#[spacetimedb::reducer] +pub fn apply_chapter_progression_ledger_entry( + ctx: &ReducerContext, + input: ChapterProgressionLedgerInput, +) -> Result<(), String> { + update_chapter_progression_ledger_tx(ctx, input).map(|_| ()) +} + +#[spacetimedb::procedure] +pub fn apply_chapter_progression_ledger_entry_and_return( + ctx: &mut ProcedureContext, + input: ChapterProgressionLedgerInput, +) -> ChapterProgressionProcedureResult { + match ctx.try_with_tx(|tx| update_chapter_progression_ledger_tx(tx, input.clone())) { + Ok(record) => ChapterProgressionProcedureResult { + ok: true, + record: Some(record), + error_message: None, + }, + Err(message) => ChapterProgressionProcedureResult { + ok: false, + record: None, + error_message: Some(message), + }, + } +} + +#[spacetimedb::procedure] +pub fn get_chapter_progression( + ctx: &mut ProcedureContext, + input: ChapterProgressionGetInput, +) -> ChapterProgressionProcedureResult { + match ctx.try_with_tx(|tx| get_chapter_progression_snapshot_tx(tx, input.clone())) { + Ok(record) => ChapterProgressionProcedureResult { + ok: true, + record: Some(record), + error_message: None, + }, + Err(message) => ChapterProgressionProcedureResult { + ok: false, + record: None, + error_message: Some(message), + }, + } +} + +// 当前阶段先把 inventory_slot 立成显式背包真相表,避免继续由多个 service 各自改 runtime snapshot JSON。 +#[spacetimedb::reducer] +pub fn apply_inventory_mutation( + ctx: &ReducerContext, + input: InventoryMutationInput, +) -> Result<(), String> { + apply_inventory_mutation_tx(ctx, input) +} + +fn apply_inventory_mutation_tx( + ctx: &ReducerContext, + input: InventoryMutationInput, +) -> Result<(), String> { + let current_slots = ctx + .db + .inventory_slot() + .iter() + .filter(|slot| { + slot.runtime_session_id == input.runtime_session_id + && slot.actor_user_id == input.actor_user_id + }) + .map(|row| build_inventory_slot_snapshot_from_row(&row)) + .collect::>(); + + let outcome = + apply_inventory_slot_mutation(current_slots, input).map_err(|error| error.to_string())?; + + for removed_slot_id in outcome.removed_slot_ids { + ctx.db.inventory_slot().slot_id().delete(&removed_slot_id); + } + + for slot in outcome.next_slots { + ctx.db.inventory_slot().slot_id().delete(&slot.slot_id); + ctx.db + .inventory_slot() + .insert(build_inventory_slot_row(slot)); + } + + Ok(()) +} + +// procedure 面向 Axum 同步读取当前 runtime_session 下某个玩家的背包真相态。 +#[spacetimedb::procedure] +pub fn get_runtime_inventory_state( + ctx: &mut ProcedureContext, + input: RuntimeInventoryStateQueryInput, +) -> RuntimeInventoryStateProcedureResult { + match ctx.try_with_tx(|tx| get_runtime_inventory_state_tx(tx, input.clone())) { + Ok(snapshot) => RuntimeInventoryStateProcedureResult { + ok: true, + snapshot: Some(snapshot), + error_message: None, + }, + Err(message) => RuntimeInventoryStateProcedureResult { + ok: false, + snapshot: None, + error_message: Some(message), + }, + } +} + +// M4 首轮先把 battle_state 作为战斗真相源落到 SpacetimeDB,避免继续把战斗状态埋在 runtime JSON 里。 +#[spacetimedb::reducer] +pub fn create_battle_state(ctx: &ReducerContext, input: BattleStateInput) -> Result<(), String> { + create_battle_state_record(ctx, input).map(|_| ()) +} + +// procedure 面向 Axum 同步创建 battle_state,返回当前最新战斗快照,避免 HTTP 层再次读取 private table。 +#[spacetimedb::procedure] +pub fn create_battle_state_and_return( + ctx: &mut ProcedureContext, + input: BattleStateInput, +) -> BattleStateProcedureResult { + match ctx.try_with_tx(|tx| create_battle_state_record(tx, input.clone())) { + Ok(snapshot) => BattleStateProcedureResult { + ok: true, + snapshot: Some(snapshot), + error_message: None, + }, + Err(message) => BattleStateProcedureResult { + ok: false, + snapshot: None, + error_message: Some(message), + }, + } +} + +// procedure 面向 Axum 读取单个 battle_state 真相态,当前只返回最新战斗快照。 +#[spacetimedb::procedure] +pub fn get_battle_state( + ctx: &mut ProcedureContext, + input: BattleStateQueryInput, +) -> BattleStateProcedureResult { + match ctx.try_with_tx(|tx| get_battle_state_record(tx, input.clone())) { + Ok(snapshot) => BattleStateProcedureResult { + ok: true, + snapshot: Some(snapshot), + error_message: None, + }, + Err(message) => BattleStateProcedureResult { + ok: false, + snapshot: None, + error_message: Some(message), + }, + } +} + +// M4 首轮只承接单行为战斗推进,不提前把 inventory / progression / story AI 续写耦进 reducer。 +#[spacetimedb::reducer] +pub fn resolve_combat_action( + ctx: &ReducerContext, + input: ResolveCombatActionInput, +) -> Result<(), String> { + resolve_battle_state_record(ctx, input).map(|_| ()) +} + +// procedure 面向 Axum 同步推进单次战斗动作,返回本次结算结果与 battle_state 最新快照。 +#[spacetimedb::procedure] +pub fn resolve_combat_action_and_return( + ctx: &mut ProcedureContext, + input: ResolveCombatActionInput, +) -> ResolveCombatActionProcedureResult { + match ctx.try_with_tx(|tx| resolve_battle_state_record(tx, input.clone())) { + Ok(result) => ResolveCombatActionProcedureResult { + ok: true, + result: Some(result), + error_message: None, + }, + Err(message) => ResolveCombatActionProcedureResult { + ok: false, + result: None, + error_message: Some(message), + }, + } +} + +fn create_battle_state_record( + ctx: &ReducerContext, + input: BattleStateInput, +) -> Result { + validate_battle_state_input(&input).map_err(|error| error.to_string())?; + + if ctx + .db + .battle_state() + .battle_state_id() + .find(&input.battle_state_id) + .is_some() + { + return Err("battle_state.battle_state_id 已存在".to_string()); + } + + let snapshot = build_battle_state_snapshot(input); + ctx.db + .battle_state() + .insert(build_battle_state_row(snapshot.clone())); + + Ok(snapshot) +} + +fn get_battle_state_record( + ctx: &ReducerContext, + input: BattleStateQueryInput, +) -> Result { + validate_battle_state_query_input(&input).map_err(|error| error.to_string())?; + + let row = ctx + .db + .battle_state() + .battle_state_id() + .find(&input.battle_state_id) + .ok_or_else(|| "battle_state 不存在".to_string())?; + + Ok(build_battle_state_snapshot_from_row(&row)) +} + +fn get_runtime_inventory_state_tx( + ctx: &ReducerContext, + input: RuntimeInventoryStateQueryInput, +) -> Result { + let validated_input = + build_runtime_inventory_state_query_input(input.runtime_session_id, input.actor_user_id) + .map_err(|error| error.to_string())?; + + // 这层只返回 inventory_slot 真相表的最小切片,不混入 story、quest、battle 的额外投影。 + let slots = ctx + .db + .inventory_slot() + .iter() + .filter(|row| { + row.runtime_session_id == validated_input.runtime_session_id + && row.actor_user_id == validated_input.actor_user_id + }) + .map(|row| build_inventory_slot_snapshot_from_row(&row)) + .collect::>(); + + Ok(build_runtime_inventory_state_snapshot( + validated_input, + slots, + )) +} + +fn resolve_battle_state_record( + ctx: &ReducerContext, + input: ResolveCombatActionInput, +) -> Result { + let current = ctx + .db + .battle_state() + .battle_state_id() + .find(&input.battle_state_id) + .ok_or_else(|| "battle_state 不存在,无法执行战斗动作".to_string())?; + + let result = resolve_battle_state_action(build_battle_state_snapshot_from_row(¤t), input) + .map_err(|error| error.to_string())?; + + ctx.db + .battle_state() + .battle_state_id() + .delete(¤t.battle_state_id); + ctx.db + .battle_state() + .insert(build_battle_state_row(result.snapshot.clone())); + + if result.outcome == CombatOutcome::Victory { + grant_battle_reward_items(ctx, &result.snapshot)?; + + if result.snapshot.experience_reward > 0 { + let updated_player = upsert_player_progression_after_grant_tx( + ctx, + PlayerProgressionGrantInput { + user_id: result.snapshot.actor_user_id.clone(), + amount: result.snapshot.experience_reward, + source: PlayerProgressionGrantSource::HostileNpc, + updated_at_micros: result.snapshot.updated_at_micros, + }, + )?; + + // 章节计划可能尚未初始化;此时不能阻断战斗胜利结算,只跳过章节账本写入。 + try_update_chapter_progression_ledger_tx( + ctx, + result.snapshot.actor_user_id.clone(), + result.snapshot.chapter_id.clone(), + ChapterProgressionLedgerInput { + user_id: result.snapshot.actor_user_id.clone(), + chapter_id: result.snapshot.chapter_id.clone().unwrap_or_default(), + granted_quest_xp: 0, + granted_hostile_xp: result.snapshot.experience_reward, + hostile_defeat_increment: 1, + level_at_exit: Some(updated_player.level), + updated_at_micros: result.snapshot.updated_at_micros, + }, + )?; + } + } + + Ok(result) +} + +// 当前阶段先把 npc_state 立成显式真相表,避免继续把关系状态藏在运行时 JSON 快照里。 +#[spacetimedb::reducer] +pub fn upsert_npc_state(ctx: &ReducerContext, input: NpcStateUpsertInput) -> Result<(), String> { + upsert_npc_state_record(ctx, input).map(|_| ()) +} + +// procedure 面向 Axum 同步 upsert 接口,返回最新 NPC 状态快照。 +#[spacetimedb::procedure] +pub fn upsert_npc_state_and_return( + ctx: &mut ProcedureContext, + input: NpcStateUpsertInput, +) -> NpcStateProcedureResult { + match ctx.try_with_tx(|tx| upsert_npc_state_record(tx, input.clone())) { + Ok(record) => NpcStateProcedureResult { + ok: true, + record: Some(record), + error_message: None, + }, + Err(message) => NpcStateProcedureResult { + ok: false, + record: None, + error_message: Some(message), + }, + } +} + +// 当前阶段只承接 NPC 关系状态的最小社交动作,不提前把背包、战斗和队伍副作用也塞进来。 +#[spacetimedb::reducer] +pub fn resolve_npc_social_action( + ctx: &ReducerContext, + input: ResolveNpcSocialActionInput, +) -> Result<(), String> { + resolve_npc_social_action_record(ctx, input).map(|_| ()) +} + +// procedure 面向 Axum 同步社交动作接口,返回动作后的 NPC 状态快照。 +#[spacetimedb::procedure] +pub fn resolve_npc_social_action_and_return( + ctx: &mut ProcedureContext, + input: ResolveNpcSocialActionInput, +) -> NpcStateProcedureResult { + match ctx.try_with_tx(|tx| resolve_npc_social_action_record(tx, input.clone())) { + Ok(record) => NpcStateProcedureResult { + ok: true, + record: Some(record), + error_message: None, + }, + Err(message) => NpcStateProcedureResult { + ok: false, + record: None, + error_message: Some(message), + }, + } +} + +// 当前阶段先冻结 NPC 正式交互统一入口,不直接在这里扩出队伍、战斗、背包等跨子域副作用。 +#[spacetimedb::reducer] +pub fn resolve_npc_interaction( + ctx: &ReducerContext, + input: ResolveNpcInteractionInput, +) -> Result<(), String> { + resolve_npc_interaction_record(ctx, input).map(|_| ()) +} + +#[spacetimedb::procedure] +pub fn resolve_npc_interaction_and_return( + ctx: &mut ProcedureContext, + input: ResolveNpcInteractionInput, +) -> NpcInteractionProcedureResult { + match ctx.try_with_tx(|tx| resolve_npc_interaction_record(tx, input.clone())) { + Ok(result) => NpcInteractionProcedureResult { + ok: true, + result: Some(result), + error_message: None, + }, + Err(message) => NpcInteractionProcedureResult { + ok: false, + result: None, + error_message: Some(message), + }, + } +} + +// fight / spar 的 battle_state 初始化属于聚合层编排,不回灌到 module-npc 纯领域 crate。 +#[spacetimedb::procedure] +pub fn resolve_npc_battle_interaction_and_return( + ctx: &mut ProcedureContext, + input: ResolveNpcBattleInteractionInput, +) -> NpcBattleInteractionProcedureResult { + match ctx.try_with_tx(|tx| resolve_npc_battle_interaction_tx(tx, input.clone())) { + Ok(result) => NpcBattleInteractionProcedureResult { + ok: true, + result: Some(result), + error_message: None, + }, + Err(message) => NpcBattleInteractionProcedureResult { + ok: false, + result: None, + error_message: Some(message), + }, + } +} + +// M4 首轮先把 story_session / story_event 作为显式会话真相源落到 SpacetimeDB,避免后续继续依赖大 JSON 覆盖式写法。 +#[spacetimedb::reducer] +pub fn begin_story_session(ctx: &ReducerContext, input: StorySessionInput) -> Result<(), String> { + begin_story_session_tx(ctx, input).map(|_| ()) +} + +// procedure 面向 Axum 同步创建故事会话,返回最新会话快照与开场事件,避免 HTTP 层再读 private table。 +#[spacetimedb::procedure] +pub fn begin_story_session_and_return( + ctx: &mut ProcedureContext, + input: StorySessionInput, +) -> StorySessionProcedureResult { + match ctx.try_with_tx(|tx| begin_story_session_tx(tx, input.clone())) { + Ok((session, event)) => StorySessionProcedureResult { + ok: true, + session: Some(session), + event: Some(event), + error_message: None, + }, + Err(message) => StorySessionProcedureResult { + ok: false, + session: None, + event: None, + error_message: Some(message), + }, + } +} + +fn begin_story_session_tx( + ctx: &ReducerContext, + input: StorySessionInput, +) -> Result<(StorySessionSnapshot, StoryEventSnapshot), String> { + validate_story_session_input(&input).map_err(|error| error.to_string())?; + + if ctx + .db + .story_session() + .story_session_id() + .find(&input.story_session_id) + .is_some() + { + return Err("story_session.story_session_id 已存在".to_string()); + } + + let snapshot = build_story_session_snapshot(input); + let started_event = build_story_started_event(&snapshot); + let created_at = Timestamp::from_micros_since_unix_epoch(snapshot.created_at_micros); + let updated_at = Timestamp::from_micros_since_unix_epoch(snapshot.updated_at_micros); + + ctx.db.story_session().insert(StorySession { + story_session_id: snapshot.story_session_id.clone(), + runtime_session_id: snapshot.runtime_session_id.clone(), + actor_user_id: snapshot.actor_user_id.clone(), + world_profile_id: snapshot.world_profile_id.clone(), + initial_prompt: snapshot.initial_prompt.clone(), + opening_summary: snapshot.opening_summary.clone(), + latest_narrative_text: snapshot.latest_narrative_text.clone(), + latest_choice_function_id: snapshot.latest_choice_function_id.clone(), + status: snapshot.status, + version: snapshot.version, + created_at, + updated_at, + }); + + ctx.db.story_event().insert(StoryEvent { + event_id: started_event.event_id.clone(), + story_session_id: started_event.story_session_id.clone(), + event_kind: started_event.event_kind, + narrative_text: started_event.narrative_text.clone(), + choice_function_id: started_event.choice_function_id.clone(), + created_at, + }); + + Ok((snapshot, started_event)) +} + +// M4 首轮继续把“故事推进”固定为事件追加 + 会话版本递增,为后续 resolve_story_action 接线提供最小基座。 +#[spacetimedb::reducer] +pub fn continue_story(ctx: &ReducerContext, input: StoryContinueInput) -> Result<(), String> { + continue_story_tx(ctx, input).map(|_| ()) +} + +// procedure 面向 Axum 同步推进故事会话,返回最新会话快照与本次事件,避免 HTTP 层再读 private table。 +#[spacetimedb::procedure] +pub fn continue_story_and_return( + ctx: &mut ProcedureContext, + input: StoryContinueInput, +) -> StorySessionProcedureResult { + match ctx.try_with_tx(|tx| continue_story_tx(tx, input.clone())) { + Ok((session, event)) => StorySessionProcedureResult { + ok: true, + session: Some(session), + event: Some(event), + error_message: None, + }, + Err(message) => StorySessionProcedureResult { + ok: false, + session: None, + event: None, + error_message: Some(message), + }, + } +} + +// procedure 面向 Axum 读取指定 story session 的最小真实状态,当前只返回 session + event 列表。 +#[spacetimedb::procedure] +pub fn get_story_session_state( + ctx: &mut ProcedureContext, + input: StorySessionStateInput, +) -> StorySessionStateProcedureResult { + match ctx.try_with_tx(|tx| get_story_session_state_tx(tx, input.clone())) { + Ok((session, events)) => StorySessionStateProcedureResult { + ok: true, + session: Some(session), + events, + error_message: None, + }, + Err(message) => StorySessionStateProcedureResult { + ok: false, + session: None, + events: Vec::new(), + error_message: Some(message), + }, + } +} + + +// 当前阶段先把 quest_record / quest_log 立成最小任务真相源,后续再把奖励结算和 story action 总分发接进来。 +#[spacetimedb::reducer] +pub fn accept_quest(ctx: &ReducerContext, input: QuestRecordInput) -> Result<(), String> { + let snapshot = build_quest_record_snapshot(input).map_err(|error| error.to_string())?; + + if ctx + .db + .quest_record() + .quest_id() + .find(&snapshot.quest_id) + .is_some() + { + return Err("quest_record.quest_id 已存在".to_string()); + } + + ctx.db + .quest_record() + .insert(build_quest_record_row(snapshot.clone())); + append_quest_log( + ctx, + &snapshot, + QuestLogEventKind::Accepted, + None, + None, + None, + None, + snapshot.created_at_micros, + ); + + Ok(()) +} + +// 任务推进 reducer 只认 QuestProgressSignal,不直接掺入背包、成长和关系奖励发放。 +#[spacetimedb::reducer] +pub fn apply_quest_signal( + ctx: &ReducerContext, + input: QuestSignalApplyInput, +) -> Result<(), String> { + let signal = input.signal.clone(); + let current = ctx + .db + .quest_record() + .quest_id() + .find(&input.quest_id) + .ok_or_else(|| "quest_record 不存在,无法应用任务信号".to_string())?; + let outcome = apply_quest_record_signal(build_quest_record_snapshot_from_row(¤t), input) + .map_err(|error| error.to_string())?; + + if !outcome.changed { + return Ok(()); + } + + ctx.db.quest_record().quest_id().delete(¤t.quest_id); + ctx.db + .quest_record() + .insert(build_quest_record_row(outcome.next_record.clone())); + append_quest_log( + ctx, + &outcome.next_record, + if outcome.completed_now { + QuestLogEventKind::Completed + } else { + QuestLogEventKind::Progressed + }, + Some(outcome.signal_kind), + Some(signal), + outcome.changed_step_id, + outcome.changed_step_progress, + outcome.next_record.updated_at_micros, + ); + + Ok(()) +} + +#[spacetimedb::reducer] +pub fn acknowledge_quest_completion( + ctx: &ReducerContext, + input: QuestCompletionAckInput, +) -> Result<(), String> { + let current = ctx + .db + .quest_record() + .quest_id() + .find(&input.quest_id) + .ok_or_else(|| "quest_record 不存在,无法确认完成提示".to_string())?; + let outcome = + acknowledge_quest_record_completion(build_quest_record_snapshot_from_row(¤t), input) + .map_err(|error| error.to_string())?; + + if !outcome.changed { + return Ok(()); + } + + ctx.db.quest_record().quest_id().delete(¤t.quest_id); + ctx.db + .quest_record() + .insert(build_quest_record_row(outcome.next_record.clone())); + append_quest_log( + ctx, + &outcome.next_record, + QuestLogEventKind::CompletionAcknowledged, + None, + None, + None, + None, + outcome.next_record.updated_at_micros, + ); + + Ok(()) +} + +#[spacetimedb::reducer] +pub fn turn_in_quest(ctx: &ReducerContext, input: QuestTurnInInput) -> Result<(), String> { + let current = ctx + .db + .quest_record() + .quest_id() + .find(&input.quest_id) + .ok_or_else(|| "quest_record 不存在,无法交付任务".to_string())?; + let next = turn_in_quest_record(build_quest_record_snapshot_from_row(¤t), input) + .map_err(|error| error.to_string())?; + + ctx.db.quest_record().quest_id().delete(¤t.quest_id); + ctx.db + .quest_record() + .insert(build_quest_record_row(next.clone())); + append_quest_log( + ctx, + &next, + QuestLogEventKind::TurnedIn, + None, + None, + None, + None, + next.updated_at_micros, + ); + + let reward_experience = next.reward.experience.unwrap_or(0); + grant_quest_reward_items(ctx, &next)?; + if reward_experience > 0 { + let updated_player = upsert_player_progression_after_grant_tx( + ctx, + PlayerProgressionGrantInput { + user_id: next.actor_user_id.clone(), + amount: reward_experience, + source: PlayerProgressionGrantSource::Quest, + updated_at_micros: next.updated_at_micros, + }, + )?; + + // 章节计划缺失时先保持任务交付成功,避免成长联动反向阻断 quest 主链。 + try_update_chapter_progression_ledger_tx( + ctx, + next.actor_user_id.clone(), + next.chapter_id.clone(), + ChapterProgressionLedgerInput { + user_id: next.actor_user_id.clone(), + chapter_id: next.chapter_id.clone().unwrap_or_default(), + granted_quest_xp: reward_experience, + granted_hostile_xp: 0, + hostile_defeat_increment: 0, + level_at_exit: Some(updated_player.level), + updated_at_micros: next.updated_at_micros, + }, + )?; + } + + Ok(()) +} +// M4 首轮先把 treasure_record 固定成可审计的宝藏结算真相表,奖励写入与 story 归属关系由 reducer 显式校验。 +#[spacetimedb::reducer] +pub fn resolve_treasure_interaction( + ctx: &ReducerContext, + input: TreasureResolveInput, +) -> Result<(), String> { + upsert_treasure_record(ctx, input).map(|_| ()) +} + +// procedure 面向后续 Axum facade,同步返回最终 treasure_record 快照,避免 HTTP 层再额外读取私有表。 +#[spacetimedb::procedure] +pub fn resolve_treasure_interaction_and_return( + ctx: &mut ProcedureContext, + input: TreasureResolveInput, +) -> TreasureRecordProcedureResult { + match ctx.try_with_tx(|tx| upsert_treasure_record(tx, input.clone())) { + Ok(record) => TreasureRecordProcedureResult { + ok: true, + record: Some(record), + error_message: None, + }, + Err(message) => TreasureRecordProcedureResult { + ok: false, + record: None, + error_message: Some(message), + }, + } +} + +fn upsert_treasure_record( + ctx: &ReducerContext, + input: TreasureResolveInput, +) -> Result { + let snapshot = build_treasure_record_snapshot(input).map_err(|error| error.to_string())?; + let story_session = ctx + .db + .story_session() + .story_session_id() + .find(&snapshot.story_session_id) + .ok_or_else(|| { + "treasure_record.story_session_id 对应的 story_session 不存在".to_string() + })?; + + if story_session.runtime_session_id != snapshot.runtime_session_id { + return Err( + "treasure_record.runtime_session_id 必须与 story_session.runtime_session_id 一致" + .to_string(), + ); + } + + if story_session.actor_user_id != snapshot.actor_user_id { + return Err( + "treasure_record.actor_user_id 必须与 story_session.actor_user_id 一致".to_string(), + ); + } + + // treasure_record 首版按单次结算真相处理:同 id 重放直接返回已落库快照,避免记录更新和重复发奖脱节。 + if let Some(existing) = ctx + .db + .treasure_record() + .treasure_record_id() + .find(&snapshot.treasure_record_id) + { + return Ok(build_treasure_record_snapshot_from_row(&existing)); + } + + let updated_at = Timestamp::from_micros_since_unix_epoch(snapshot.updated_at_micros); + let created_at = Timestamp::from_micros_since_unix_epoch(snapshot.created_at_micros); + + ctx.db + .treasure_record() + .insert(build_treasure_record_row(&snapshot, created_at, updated_at)); + + grant_treasure_reward_items_to_inventory(ctx, &snapshot)?; + + Ok(snapshot) +} + +fn grant_treasure_reward_items_to_inventory( + ctx: &ReducerContext, + snapshot: &TreasureRecordSnapshot, +) -> Result<(), String> { + for (index, reward_item) in snapshot.reward_items.iter().cloned().enumerate() { + let inventory_item = build_inventory_item_snapshot_from_reward_item( + &snapshot.treasure_record_id, + reward_item, + ) + .map_err(|error| error.to_string())?; + let slot_id = build_treasure_inventory_slot_id(&snapshot.treasure_record_id, index); + let mutation_id = build_treasure_inventory_mutation_id(&snapshot.treasure_record_id, index); + + apply_inventory_mutation_tx( + ctx, + InventoryMutationInput { + mutation_id, + runtime_session_id: snapshot.runtime_session_id.clone(), + story_session_id: Some(snapshot.story_session_id.clone()), + actor_user_id: snapshot.actor_user_id.clone(), + mutation: InventoryMutation::GrantItem(module_inventory::GrantInventoryItemInput { + slot_id, + item: inventory_item, + }), + updated_at_micros: snapshot.updated_at_micros, + }, + )?; + } + + Ok(()) +} + +fn build_treasure_inventory_slot_id(treasure_record_id: &str, reward_index: usize) -> String { + format!( + "{}{}_{}", + INVENTORY_SLOT_ID_PREFIX, treasure_record_id, reward_index + ) +} + +fn build_treasure_inventory_mutation_id(treasure_record_id: &str, reward_index: usize) -> String { + format!( + "{}{}_{}", + INVENTORY_MUTATION_ID_PREFIX, treasure_record_id, reward_index + ) +} + +fn build_treasure_record_row( + snapshot: &TreasureRecordSnapshot, + created_at: Timestamp, + updated_at: Timestamp, +) -> TreasureRecord { + TreasureRecord { + treasure_record_id: snapshot.treasure_record_id.clone(), + runtime_session_id: snapshot.runtime_session_id.clone(), + story_session_id: snapshot.story_session_id.clone(), + actor_user_id: snapshot.actor_user_id.clone(), + encounter_id: snapshot.encounter_id.clone(), + encounter_name: snapshot.encounter_name.clone(), + scene_id: snapshot.scene_id.clone(), + scene_name: snapshot.scene_name.clone(), + action: snapshot.action, + reward_items: snapshot.reward_items.clone(), + reward_hp: snapshot.reward_hp, + reward_mana: snapshot.reward_mana, + reward_currency: snapshot.reward_currency, + story_hint: snapshot.story_hint.clone(), + created_at, + updated_at, + } +} +fn build_quest_record_row(snapshot: QuestRecordSnapshot) -> QuestRecord { + QuestRecord { + quest_id: snapshot.quest_id, + runtime_session_id: snapshot.runtime_session_id, + story_session_id: snapshot.story_session_id, + actor_user_id: snapshot.actor_user_id, + issuer_npc_id: snapshot.issuer_npc_id, + issuer_npc_name: snapshot.issuer_npc_name, + scene_id: snapshot.scene_id, + chapter_id: snapshot.chapter_id, + act_id: snapshot.act_id, + thread_id: snapshot.thread_id, + contract_id: snapshot.contract_id, + title: snapshot.title, + description: snapshot.description, + summary: snapshot.summary, + objective: snapshot.objective, + progress: snapshot.progress, + status: snapshot.status, + completion_notified: snapshot.completion_notified, + reward: snapshot.reward, + reward_text: snapshot.reward_text, + narrative_binding: snapshot.narrative_binding, + steps: snapshot.steps, + active_step_id: snapshot.active_step_id, + visible_stage: snapshot.visible_stage, + hidden_flags: snapshot.hidden_flags, + discovered_fact_ids: snapshot.discovered_fact_ids, + related_carrier_ids: snapshot.related_carrier_ids, + consequence_ids: snapshot.consequence_ids, + created_at: Timestamp::from_micros_since_unix_epoch(snapshot.created_at_micros), + updated_at: Timestamp::from_micros_since_unix_epoch(snapshot.updated_at_micros), + completed_at: snapshot + .completed_at_micros + .map(Timestamp::from_micros_since_unix_epoch), + turned_in_at: snapshot + .turned_in_at_micros + .map(Timestamp::from_micros_since_unix_epoch), + } +} + +fn build_player_progression_row(snapshot: PlayerProgressionSnapshot) -> PlayerProgression { + PlayerProgression { + user_id: snapshot.user_id, + level: snapshot.level, + current_level_xp: snapshot.current_level_xp, + total_xp: snapshot.total_xp, + xp_to_next_level: snapshot.xp_to_next_level, + pending_level_ups: snapshot.pending_level_ups, + last_granted_source: snapshot.last_granted_source, + created_at: Timestamp::from_micros_since_unix_epoch(snapshot.created_at_micros), + updated_at: Timestamp::from_micros_since_unix_epoch(snapshot.updated_at_micros), + } +} + +fn build_player_progression_snapshot_from_row( + row: &PlayerProgression, +) -> PlayerProgressionSnapshot { + PlayerProgressionSnapshot { + user_id: row.user_id.clone(), + level: row.level, + current_level_xp: row.current_level_xp, + total_xp: row.total_xp, + xp_to_next_level: row.xp_to_next_level, + pending_level_ups: row.pending_level_ups, + last_granted_source: row.last_granted_source, + created_at_micros: row.created_at.to_micros_since_unix_epoch(), + updated_at_micros: row.updated_at.to_micros_since_unix_epoch(), + } +} + +fn build_chapter_progression_id(user_id: &str, chapter_id: &str) -> String { + format!("chapprog_{}_{}", user_id.trim(), chapter_id.trim()) +} + +fn build_chapter_progression_row(snapshot: ChapterProgressionSnapshot) -> ChapterProgression { + ChapterProgression { + chapter_progression_id: build_chapter_progression_id( + &snapshot.user_id, + &snapshot.chapter_id, + ), + user_id: snapshot.user_id, + chapter_id: snapshot.chapter_id, + chapter_index: snapshot.chapter_index, + total_chapters: snapshot.total_chapters, + entry_pseudo_level_millis: snapshot.entry_pseudo_level_millis, + exit_pseudo_level_millis: snapshot.exit_pseudo_level_millis, + entry_level: snapshot.entry_level, + exit_level: snapshot.exit_level, + planned_total_xp: snapshot.planned_total_xp, + planned_quest_xp: snapshot.planned_quest_xp, + planned_hostile_xp: snapshot.planned_hostile_xp, + actual_quest_xp: snapshot.actual_quest_xp, + actual_hostile_xp: snapshot.actual_hostile_xp, + expected_hostile_defeat_count: snapshot.expected_hostile_defeat_count, + actual_hostile_defeat_count: snapshot.actual_hostile_defeat_count, + level_at_entry: snapshot.level_at_entry, + level_at_exit: snapshot.level_at_exit, + pace_band: snapshot.pace_band, + created_at: Timestamp::from_micros_since_unix_epoch(snapshot.created_at_micros), + updated_at: Timestamp::from_micros_since_unix_epoch(snapshot.updated_at_micros), + } +} + +fn build_chapter_progression_snapshot_from_row( + row: &ChapterProgression, +) -> ChapterProgressionSnapshot { + ChapterProgressionSnapshot { + user_id: row.user_id.clone(), + chapter_id: row.chapter_id.clone(), + chapter_index: row.chapter_index, + total_chapters: row.total_chapters, + entry_pseudo_level_millis: row.entry_pseudo_level_millis, + exit_pseudo_level_millis: row.exit_pseudo_level_millis, + entry_level: row.entry_level, + exit_level: row.exit_level, + planned_total_xp: row.planned_total_xp, + planned_quest_xp: row.planned_quest_xp, + planned_hostile_xp: row.planned_hostile_xp, + actual_quest_xp: row.actual_quest_xp, + actual_hostile_xp: row.actual_hostile_xp, + expected_hostile_defeat_count: row.expected_hostile_defeat_count, + actual_hostile_defeat_count: row.actual_hostile_defeat_count, + level_at_entry: row.level_at_entry, + level_at_exit: row.level_at_exit, + pace_band: row.pace_band, + created_at_micros: row.created_at.to_micros_since_unix_epoch(), + updated_at_micros: row.updated_at.to_micros_since_unix_epoch(), + } +} + +fn build_quest_record_snapshot_from_row(row: &QuestRecord) -> QuestRecordSnapshot { + QuestRecordSnapshot { + quest_id: row.quest_id.clone(), + runtime_session_id: row.runtime_session_id.clone(), + story_session_id: row.story_session_id.clone(), + actor_user_id: row.actor_user_id.clone(), + issuer_npc_id: row.issuer_npc_id.clone(), + issuer_npc_name: row.issuer_npc_name.clone(), + scene_id: row.scene_id.clone(), + chapter_id: row.chapter_id.clone(), + act_id: row.act_id.clone(), + thread_id: row.thread_id.clone(), + contract_id: row.contract_id.clone(), + title: row.title.clone(), + description: row.description.clone(), + summary: row.summary.clone(), + objective: row.objective.clone(), + progress: row.progress, + status: row.status, + completion_notified: row.completion_notified, + reward: row.reward.clone(), + reward_text: row.reward_text.clone(), + narrative_binding: row.narrative_binding.clone(), + steps: row.steps.clone(), + active_step_id: row.active_step_id.clone(), + visible_stage: row.visible_stage, + hidden_flags: row.hidden_flags.clone(), + discovered_fact_ids: row.discovered_fact_ids.clone(), + related_carrier_ids: row.related_carrier_ids.clone(), + consequence_ids: row.consequence_ids.clone(), + created_at_micros: row.created_at.to_micros_since_unix_epoch(), + updated_at_micros: row.updated_at.to_micros_since_unix_epoch(), + completed_at_micros: row + .completed_at + .map(|value| value.to_micros_since_unix_epoch()), + turned_in_at_micros: row + .turned_in_at + .map(|value| value.to_micros_since_unix_epoch()), + } +} + +fn build_inventory_slot_row(snapshot: InventorySlotSnapshot) -> InventorySlot { + InventorySlot { + slot_id: snapshot.slot_id, + runtime_session_id: snapshot.runtime_session_id, + story_session_id: snapshot.story_session_id, + actor_user_id: snapshot.actor_user_id, + container_kind: snapshot.container_kind, + slot_key: snapshot.slot_key, + item_id: snapshot.item_id, + category: snapshot.category, + name: snapshot.name, + description: snapshot.description, + quantity: snapshot.quantity, + rarity: snapshot.rarity, + tags: snapshot.tags, + stackable: snapshot.stackable, + stack_key: snapshot.stack_key, + equipment_slot_id: snapshot.equipment_slot_id, + source_kind: snapshot.source_kind, + source_reference_id: snapshot.source_reference_id, + created_at: Timestamp::from_micros_since_unix_epoch(snapshot.created_at_micros), + updated_at: Timestamp::from_micros_since_unix_epoch(snapshot.updated_at_micros), + } +} + +fn build_inventory_slot_snapshot_from_row(row: &InventorySlot) -> InventorySlotSnapshot { + InventorySlotSnapshot { + slot_id: row.slot_id.clone(), + runtime_session_id: row.runtime_session_id.clone(), + story_session_id: row.story_session_id.clone(), + actor_user_id: row.actor_user_id.clone(), + container_kind: row.container_kind, + slot_key: row.slot_key.clone(), + item_id: row.item_id.clone(), + category: row.category.clone(), + name: row.name.clone(), + description: row.description.clone(), + quantity: row.quantity, + rarity: row.rarity, + tags: row.tags.clone(), + stackable: row.stackable, + stack_key: row.stack_key.clone(), + equipment_slot_id: row.equipment_slot_id, + source_kind: row.source_kind, + source_reference_id: row.source_reference_id.clone(), + created_at_micros: row.created_at.to_micros_since_unix_epoch(), + updated_at_micros: row.updated_at.to_micros_since_unix_epoch(), + } +} + +fn grant_quest_reward_items( + ctx: &ReducerContext, + snapshot: &QuestRecordSnapshot, +) -> Result<(), String> { + if !ctx + .db + .inventory_slot() + .iter() + .filter(|row| { + row.runtime_session_id == snapshot.runtime_session_id + && row.actor_user_id == snapshot.actor_user_id + }) + .all(|row| row.source_reference_id.as_deref() != Some(snapshot.quest_id.as_str())) + { + return Ok(()); + } + + for (index, reward_item) in snapshot.reward.items.clone().into_iter().enumerate() { + let inventory_item = + build_inventory_item_snapshot_from_quest_reward_item(&snapshot.quest_id, reward_item); + grant_inventory_item_to_actor( + ctx, + &snapshot.runtime_session_id, + snapshot.story_session_id.clone(), + &snapshot.actor_user_id, + inventory_item, + build_reward_seed(snapshot.updated_at_micros, index), + snapshot.updated_at_micros, + )?; + } + + Ok(()) +} + +fn grant_battle_reward_items( + ctx: &ReducerContext, + snapshot: &BattleStateSnapshot, +) -> Result<(), String> { + if snapshot.reward_items.is_empty() { + return Ok(()); + } + + if !ctx + .db + .inventory_slot() + .iter() + .filter(|row| { + row.runtime_session_id == snapshot.runtime_session_id + && row.actor_user_id == snapshot.actor_user_id + }) + .all(|row| row.source_reference_id.as_deref() != Some(snapshot.battle_state_id.as_str())) + { + return Ok(()); + } + + for (index, reward_item) in snapshot.reward_items.clone().into_iter().enumerate() { + let inventory_item = build_inventory_item_snapshot_from_battle_reward_item( + &snapshot.battle_state_id, + reward_item, + ); + grant_inventory_item_to_actor( + ctx, + &snapshot.runtime_session_id, + Some(snapshot.story_session_id.clone()), + &snapshot.actor_user_id, + inventory_item, + build_reward_seed(snapshot.updated_at_micros, index), + snapshot.updated_at_micros, + )?; + } + + Ok(()) +} + +fn grant_inventory_item_to_actor( + ctx: &ReducerContext, + runtime_session_id: &str, + story_session_id: Option, + actor_user_id: &str, + item: InventoryItemSnapshot, + seed_micros: i64, + updated_at_micros: i64, +) -> Result<(), String> { + let current_slots = ctx + .db + .inventory_slot() + .iter() + .filter(|row| { + row.runtime_session_id == runtime_session_id && row.actor_user_id == actor_user_id + }) + .map(|row| build_inventory_slot_snapshot_from_row(&row)) + .collect::>(); + let slot_id = generate_inventory_slot_id(seed_micros); + let mutation_id = generate_inventory_mutation_id(seed_micros); + let outcome = apply_inventory_slot_mutation( + current_slots, + InventoryMutationInput { + mutation_id, + runtime_session_id: runtime_session_id.to_string(), + story_session_id, + actor_user_id: actor_user_id.to_string(), + mutation: InventoryMutation::GrantItem(GrantInventoryItemInput { slot_id, item }), + updated_at_micros, + }, + ) + .map_err(|error| error.to_string())?; + + for removed_slot_id in outcome.removed_slot_ids { + ctx.db.inventory_slot().slot_id().delete(&removed_slot_id); + } + for slot in outcome.next_slots { + ctx.db.inventory_slot().slot_id().delete(&slot.slot_id); + ctx.db + .inventory_slot() + .insert(build_inventory_slot_row(slot)); + } + + Ok(()) +} + +fn build_inventory_item_snapshot_from_battle_reward_item( + battle_state_id: &str, + reward_item: RuntimeItemRewardItemSnapshot, +) -> InventoryItemSnapshot { + InventoryItemSnapshot { + item_id: reward_item.item_id, + category: reward_item.category, + name: reward_item.item_name, + description: reward_item.description, + quantity: reward_item.quantity, + rarity: map_runtime_reward_item_rarity(reward_item.rarity), + tags: reward_item.tags, + stackable: reward_item.stackable, + stack_key: reward_item.stack_key, + equipment_slot_id: reward_item + .equipment_slot_id + .map(map_runtime_reward_equipment_slot), + source_kind: InventoryItemSourceKind::CombatDrop, + source_reference_id: Some(battle_state_id.to_string()), + } +} + +fn build_inventory_item_snapshot_from_quest_reward_item( + quest_id: &str, + reward_item: QuestRewardItem, +) -> InventoryItemSnapshot { + InventoryItemSnapshot { + item_id: reward_item.item_id, + category: reward_item.category, + name: reward_item.name, + description: reward_item.description, + quantity: reward_item.quantity, + rarity: map_quest_reward_item_rarity(reward_item.rarity), + tags: reward_item.tags, + stackable: reward_item.stackable, + stack_key: reward_item.stack_key, + equipment_slot_id: reward_item + .equipment_slot_id + .map(map_quest_reward_equipment_slot), + source_kind: InventoryItemSourceKind::QuestReward, + source_reference_id: Some(quest_id.to_string()), + } +} + +fn map_quest_reward_item_rarity(rarity: QuestRewardItemRarity) -> InventoryItemRarity { + match rarity { + QuestRewardItemRarity::Common => InventoryItemRarity::Common, + QuestRewardItemRarity::Uncommon => InventoryItemRarity::Uncommon, + QuestRewardItemRarity::Rare => InventoryItemRarity::Rare, + QuestRewardItemRarity::Epic => InventoryItemRarity::Epic, + QuestRewardItemRarity::Legendary => InventoryItemRarity::Legendary, + } +} + +fn map_runtime_reward_item_rarity( + rarity: module_runtime_item::RuntimeItemRewardItemRarity, +) -> InventoryItemRarity { + match rarity { + module_runtime_item::RuntimeItemRewardItemRarity::Common => InventoryItemRarity::Common, + module_runtime_item::RuntimeItemRewardItemRarity::Uncommon => InventoryItemRarity::Uncommon, + module_runtime_item::RuntimeItemRewardItemRarity::Rare => InventoryItemRarity::Rare, + module_runtime_item::RuntimeItemRewardItemRarity::Epic => InventoryItemRarity::Epic, + module_runtime_item::RuntimeItemRewardItemRarity::Legendary => { + InventoryItemRarity::Legendary + } + } +} + +fn map_quest_reward_equipment_slot(slot: QuestRewardEquipmentSlot) -> InventoryEquipmentSlot { + match slot { + QuestRewardEquipmentSlot::Weapon => InventoryEquipmentSlot::Weapon, + QuestRewardEquipmentSlot::Armor => InventoryEquipmentSlot::Armor, + QuestRewardEquipmentSlot::Relic => InventoryEquipmentSlot::Relic, + } +} + +fn map_runtime_reward_equipment_slot( + slot: module_runtime_item::RuntimeItemEquipmentSlot, +) -> InventoryEquipmentSlot { + match slot { + module_runtime_item::RuntimeItemEquipmentSlot::Weapon => InventoryEquipmentSlot::Weapon, + module_runtime_item::RuntimeItemEquipmentSlot::Armor => InventoryEquipmentSlot::Armor, + module_runtime_item::RuntimeItemEquipmentSlot::Relic => InventoryEquipmentSlot::Relic, + } +} + +fn build_reward_seed(updated_at_micros: i64, index: usize) -> i64 { + updated_at_micros.saturating_add(index as i64 + 1) +} + +fn build_story_session_snapshot_from_row(row: &StorySession) -> StorySessionSnapshot { + StorySessionSnapshot { + story_session_id: row.story_session_id.clone(), + runtime_session_id: row.runtime_session_id.clone(), + actor_user_id: row.actor_user_id.clone(), + world_profile_id: row.world_profile_id.clone(), + initial_prompt: row.initial_prompt.clone(), + opening_summary: row.opening_summary.clone(), + latest_narrative_text: row.latest_narrative_text.clone(), + latest_choice_function_id: row.latest_choice_function_id.clone(), + status: row.status, + version: row.version, + created_at_micros: row.created_at.to_micros_since_unix_epoch(), + updated_at_micros: row.updated_at.to_micros_since_unix_epoch(), + } +} + +fn build_story_event_snapshot_from_row(row: &StoryEvent) -> StoryEventSnapshot { + StoryEventSnapshot { + event_id: row.event_id.clone(), + story_session_id: row.story_session_id.clone(), + event_kind: row.event_kind, + narrative_text: row.narrative_text.clone(), + choice_function_id: row.choice_function_id.clone(), + created_at_micros: row.created_at.to_micros_since_unix_epoch(), + } +} + +fn build_treasure_record_snapshot_from_row(row: &TreasureRecord) -> TreasureRecordSnapshot { + TreasureRecordSnapshot { + treasure_record_id: row.treasure_record_id.clone(), + runtime_session_id: row.runtime_session_id.clone(), + story_session_id: row.story_session_id.clone(), + actor_user_id: row.actor_user_id.clone(), + encounter_id: row.encounter_id.clone(), + encounter_name: row.encounter_name.clone(), + scene_id: row.scene_id.clone(), + scene_name: row.scene_name.clone(), + action: row.action, + reward_items: row.reward_items.clone(), + reward_hp: row.reward_hp, + reward_mana: row.reward_mana, + reward_currency: row.reward_currency, + story_hint: row.story_hint.clone(), + created_at_micros: row.created_at.to_micros_since_unix_epoch(), + updated_at_micros: row.updated_at.to_micros_since_unix_epoch(), + } +} + +fn append_quest_log( + ctx: &ReducerContext, + snapshot: &QuestRecordSnapshot, + event_kind: QuestLogEventKind, + signal_kind: Option, + signal: Option, + step_id: Option, + step_progress: Option, + created_at_micros: i64, +) { + ctx.db.quest_log().insert(QuestLog { + log_id: generate_quest_log_id(&snapshot.quest_id, event_kind, created_at_micros), + quest_id: snapshot.quest_id.clone(), + runtime_session_id: snapshot.runtime_session_id.clone(), + actor_user_id: snapshot.actor_user_id.clone(), + event_kind, + status_after: snapshot.status, + signal_kind, + signal, + step_id, + step_progress, + created_at: Timestamp::from_micros_since_unix_epoch(created_at_micros), + }); +} + +fn get_player_progression_snapshot_tx( + ctx: &ReducerContext, + input: PlayerProgressionGetInput, +) -> Result { + let user_id = input.user_id.trim().to_string(); + if user_id.is_empty() { + return Err("player_progression.user_id 不能为空".to_string()); + } + + if let Some(existing) = ctx.db.player_progression().user_id().find(&user_id) { + return Ok(build_player_progression_snapshot_from_row(&existing)); + } + + create_initial_player_progression(user_id, 0).map_err(|error| error.to_string()) +} + +fn upsert_player_progression_after_grant_tx( + ctx: &ReducerContext, + input: PlayerProgressionGrantInput, +) -> Result { + let current = if let Some(existing) = ctx.db.player_progression().user_id().find(&input.user_id) + { + build_player_progression_snapshot_from_row(&existing) + } else { + create_initial_player_progression(input.user_id.clone(), input.updated_at_micros) + .map_err(|error| error.to_string())? + }; + + let next = grant_player_experience(current, input).map_err(|error| error.to_string())?; + if ctx + .db + .player_progression() + .user_id() + .find(&next.user_id) + .is_some() + { + ctx.db.player_progression().user_id().delete(&next.user_id); + } + ctx.db + .player_progression() + .insert(build_player_progression_row(next.clone())); + Ok(next) +} + +fn get_chapter_progression_snapshot_tx( + ctx: &ReducerContext, + input: ChapterProgressionGetInput, +) -> Result { + let user_id = input.user_id.trim().to_string(); + let chapter_id = input.chapter_id.trim().to_string(); + if user_id.is_empty() { + return Err("chapter_progression.user_id 不能为空".to_string()); + } + if chapter_id.is_empty() { + return Err("chapter_progression.chapter_id 不能为空".to_string()); + } + + let row_id = build_chapter_progression_id(&user_id, &chapter_id); + let existing = ctx + .db + .chapter_progression() + .chapter_progression_id() + .find(&row_id) + .ok_or_else(|| "chapter_progression 不存在".to_string())?; + + Ok(build_chapter_progression_snapshot_from_row(&existing)) +} + +fn upsert_chapter_progression_snapshot_tx( + ctx: &ReducerContext, + input: ChapterProgressionInput, +) -> Result { + let snapshot = build_chapter_progression_snapshot(input).map_err(|error| error.to_string())?; + let row_id = build_chapter_progression_id(&snapshot.user_id, &snapshot.chapter_id); + if ctx + .db + .chapter_progression() + .chapter_progression_id() + .find(&row_id) + .is_some() + { + ctx.db + .chapter_progression() + .chapter_progression_id() + .delete(&row_id); + } + ctx.db + .chapter_progression() + .insert(build_chapter_progression_row(snapshot.clone())); + Ok(snapshot) +} + +fn update_chapter_progression_ledger_tx( + ctx: &ReducerContext, + input: ChapterProgressionLedgerInput, +) -> Result { + let row_id = build_chapter_progression_id(&input.user_id, &input.chapter_id); + let current = ctx + .db + .chapter_progression() + .chapter_progression_id() + .find(&row_id) + .ok_or_else(|| "chapter_progression 不存在,无法记账".to_string())?; + let next = apply_chapter_progression_ledger( + build_chapter_progression_snapshot_from_row(¤t), + input, + ) + .map_err(|error| error.to_string())?; + + ctx.db + .chapter_progression() + .chapter_progression_id() + .delete(&row_id); + ctx.db + .chapter_progression() + .insert(build_chapter_progression_row(next.clone())); + Ok(next) +} + +fn try_update_chapter_progression_ledger_tx( + ctx: &ReducerContext, + user_id: String, + chapter_id: Option, + input: ChapterProgressionLedgerInput, +) -> Result, String> { + let Some(chapter_id) = chapter_id.map(|value| value.trim().to_string()) else { + return Ok(None); + }; + + if chapter_id.is_empty() || user_id.trim().is_empty() { + return Ok(None); + } + + let row_id = build_chapter_progression_id(user_id.trim(), &chapter_id); + if ctx + .db + .chapter_progression() + .chapter_progression_id() + .find(&row_id) + .is_none() + { + return Ok(None); + } + + update_chapter_progression_ledger_tx(ctx, input).map(Some) +} +fn build_battle_state_row(snapshot: BattleStateSnapshot) -> BattleState { + BattleState { + battle_state_id: snapshot.battle_state_id, + story_session_id: snapshot.story_session_id, + runtime_session_id: snapshot.runtime_session_id, + actor_user_id: snapshot.actor_user_id, + chapter_id: snapshot.chapter_id, + target_npc_id: snapshot.target_npc_id, + target_name: snapshot.target_name, + battle_mode: snapshot.battle_mode, + status: snapshot.status, + player_hp: snapshot.player_hp, + player_max_hp: snapshot.player_max_hp, + player_mana: snapshot.player_mana, + player_max_mana: snapshot.player_max_mana, + target_hp: snapshot.target_hp, + target_max_hp: snapshot.target_max_hp, + experience_reward: snapshot.experience_reward, + reward_items: snapshot.reward_items, + turn_index: snapshot.turn_index, + last_action_function_id: snapshot.last_action_function_id, + last_action_text: snapshot.last_action_text, + last_result_text: snapshot.last_result_text, + last_damage_dealt: snapshot.last_damage_dealt, + last_damage_taken: snapshot.last_damage_taken, + last_outcome: snapshot.last_outcome, + version: snapshot.version, + created_at: Timestamp::from_micros_since_unix_epoch(snapshot.created_at_micros), + updated_at: Timestamp::from_micros_since_unix_epoch(snapshot.updated_at_micros), + } +} + +fn build_battle_state_snapshot_from_row(row: &BattleState) -> BattleStateSnapshot { + BattleStateSnapshot { + battle_state_id: row.battle_state_id.clone(), + story_session_id: row.story_session_id.clone(), + runtime_session_id: row.runtime_session_id.clone(), + actor_user_id: row.actor_user_id.clone(), + chapter_id: row.chapter_id.clone(), + target_npc_id: row.target_npc_id.clone(), + target_name: row.target_name.clone(), + battle_mode: row.battle_mode, + status: row.status, + player_hp: row.player_hp, + player_max_hp: row.player_max_hp, + player_mana: row.player_mana, + player_max_mana: row.player_max_mana, + target_hp: row.target_hp, + target_max_hp: row.target_max_hp, + experience_reward: row.experience_reward, + reward_items: row.reward_items.clone(), + turn_index: row.turn_index, + last_action_function_id: row.last_action_function_id.clone(), + last_action_text: row.last_action_text.clone(), + last_result_text: row.last_result_text.clone(), + last_damage_dealt: row.last_damage_dealt, + last_damage_taken: row.last_damage_taken, + last_outcome: row.last_outcome, + version: row.version, + created_at_micros: row.created_at.to_micros_since_unix_epoch(), + updated_at_micros: row.updated_at.to_micros_since_unix_epoch(), + } +} + +fn upsert_npc_state_record( + ctx: &ReducerContext, + input: NpcStateUpsertInput, +) -> Result { + let npc_state_id = generate_npc_state_id(&input.runtime_session_id, &input.npc_id); + let existing = ctx.db.npc_state().npc_state_id().find(&npc_state_id); + let normalized = normalize_npc_state_snapshot( + input, + existing + .as_ref() + .map(|row| row.created_at.to_micros_since_unix_epoch()), + ) + .map_err(|error| error.to_string())?; + + if existing.is_some() { + ctx.db.npc_state().npc_state_id().delete(&npc_state_id); + } + ctx.db + .npc_state() + .insert(build_npc_state_row(normalized.clone())); + + Ok(normalized) +} + +fn resolve_npc_social_action_record( + ctx: &ReducerContext, + input: ResolveNpcSocialActionInput, +) -> Result { + let npc_state_id = generate_npc_state_id(&input.runtime_session_id, &input.npc_id); + let current = ctx + .db + .npc_state() + .npc_state_id() + .find(&npc_state_id) + .ok_or_else(|| "npc_state 不存在,无法执行社交动作".to_string())?; + let next = apply_npc_social_action(build_npc_state_snapshot_from_row(¤t), input) + .map_err(|error| error.to_string())?; + + ctx.db + .npc_state() + .npc_state_id() + .delete(¤t.npc_state_id); + ctx.db.npc_state().insert(build_npc_state_row(next.clone())); + + Ok(next) +} + +fn resolve_npc_interaction_record( + ctx: &ReducerContext, + input: ResolveNpcInteractionInput, +) -> Result { + let npc_state_id = generate_npc_state_id(&input.runtime_session_id, &input.npc_id); + let current = ctx + .db + .npc_state() + .npc_state_id() + .find(&npc_state_id) + .ok_or_else(|| "npc_state 不存在,无法执行交互".to_string())?; + let result = resolve_npc_interaction_domain(build_npc_state_snapshot_from_row(¤t), input) + .map_err(|error| error.to_string())?; + + ctx.db + .npc_state() + .npc_state_id() + .delete(¤t.npc_state_id); + ctx.db + .npc_state() + .insert(build_npc_state_row(result.npc_state.clone())); + + Ok(result) +} + +fn resolve_npc_battle_interaction_tx( + ctx: &ReducerContext, + input: ResolveNpcBattleInteractionInput, +) -> Result { + validate_npc_battle_interaction_input(&input)?; + + let interaction = resolve_npc_interaction_record(ctx, input.npc_interaction.clone())?; + let battle_mode = interaction + .battle_mode + .ok_or_else(|| "当前 NPC 交互没有产出 battle_mode,不能初始化 battle_state".to_string())?; + + let battle_state_id = input + .battle_state_id + .clone() + .unwrap_or_else(|| generate_battle_state_id(input.npc_interaction.updated_at_micros)); + if ctx + .db + .battle_state() + .battle_state_id() + .find(&battle_state_id) + .is_some() + { + return Err("battle_state.battle_state_id 已存在".to_string()); + } + + let battle_input = BattleStateInput { + battle_state_id, + story_session_id: input.story_session_id.trim().to_string(), + runtime_session_id: interaction.npc_state.runtime_session_id.clone(), + actor_user_id: input.actor_user_id.trim().to_string(), + chapter_id: None, + target_npc_id: interaction.npc_state.npc_id.clone(), + target_name: interaction.npc_state.npc_name.clone(), + battle_mode: map_npc_battle_mode(battle_mode), + player_hp: input.player_hp, + player_max_hp: input.player_max_hp, + player_mana: input.player_mana, + player_max_mana: input.player_max_mana, + target_hp: input.target_hp, + target_max_hp: input.target_max_hp, + experience_reward: input.experience_reward, + reward_items: input.reward_items.clone(), + created_at_micros: input.npc_interaction.updated_at_micros, + }; + validate_battle_state_input(&battle_input).map_err(|error| error.to_string())?; + + let battle_state = build_battle_state_snapshot(battle_input); + ctx.db + .battle_state() + .insert(build_battle_state_row(battle_state.clone())); + + Ok(NpcBattleInteractionResult { + interaction, + battle_state, + }) +} + +fn validate_npc_battle_interaction_input( + input: &ResolveNpcBattleInteractionInput, +) -> Result<(), String> { + if input.story_session_id.trim().is_empty() { + return Err("resolve_npc_battle_interaction.story_session_id 不能为空".to_string()); + } + if input.actor_user_id.trim().is_empty() { + return Err("resolve_npc_battle_interaction.actor_user_id 不能为空".to_string()); + } + if !matches!( + input.npc_interaction.interaction_function_id.trim(), + NPC_FIGHT_FUNCTION_ID | NPC_SPAR_FUNCTION_ID + ) { + return Err("resolve_npc_battle_interaction 只支持 npc_fight 或 npc_spar".to_string()); + } + + Ok(()) +} + +fn map_npc_battle_mode(mode: NpcInteractionBattleMode) -> BattleMode { + match mode { + NpcInteractionBattleMode::Fight => BattleMode::Fight, + NpcInteractionBattleMode::Spar => BattleMode::Spar, + } +} + +fn build_npc_state_row(snapshot: NpcStateSnapshot) -> NpcState { + NpcState { + npc_state_id: snapshot.npc_state_id, + runtime_session_id: snapshot.runtime_session_id, + npc_id: snapshot.npc_id, + npc_name: snapshot.npc_name, + affinity: snapshot.affinity, + relation_state: snapshot.relation_state, + help_used: snapshot.help_used, + chatted_count: snapshot.chatted_count, + gifts_given: snapshot.gifts_given, + recruited: snapshot.recruited, + trade_stock_signature: snapshot.trade_stock_signature, + revealed_facts: snapshot.revealed_facts, + known_attribute_rumors: snapshot.known_attribute_rumors, + first_meaningful_contact_resolved: snapshot.first_meaningful_contact_resolved, + seen_backstory_chapter_ids: snapshot.seen_backstory_chapter_ids, + stance_profile: snapshot.stance_profile, + created_at: Timestamp::from_micros_since_unix_epoch(snapshot.created_at_micros), + updated_at: Timestamp::from_micros_since_unix_epoch(snapshot.updated_at_micros), + } +} + +fn build_npc_state_snapshot_from_row(row: &NpcState) -> NpcStateSnapshot { + NpcStateSnapshot { + npc_state_id: row.npc_state_id.clone(), + runtime_session_id: row.runtime_session_id.clone(), + npc_id: row.npc_id.clone(), + npc_name: row.npc_name.clone(), + affinity: row.affinity, + relation_state: row.relation_state.clone(), + help_used: row.help_used, + chatted_count: row.chatted_count, + gifts_given: row.gifts_given, + recruited: row.recruited, + trade_stock_signature: row.trade_stock_signature.clone(), + revealed_facts: row.revealed_facts.clone(), + known_attribute_rumors: row.known_attribute_rumors.clone(), + first_meaningful_contact_resolved: row.first_meaningful_contact_resolved, + seen_backstory_chapter_ids: row.seen_backstory_chapter_ids.clone(), + stance_profile: row.stance_profile.clone(), + created_at_micros: row.created_at.to_micros_since_unix_epoch(), + updated_at_micros: row.updated_at.to_micros_since_unix_epoch(), + } +} diff --git a/server-rs/crates/spacetime-module/src/lib.rs b/server-rs/crates/spacetime-module/src/lib.rs index 99f787d2..73975d07 100644 --- a/server-rs/crates/spacetime-module/src/lib.rs +++ b/server-rs/crates/spacetime-module/src/lib.rs @@ -132,7779 +132,12 @@ use serde_json::{Map as JsonMap, Value as JsonValue, json}; use spacetimedb::{ProcedureContext, ReducerContext, SpacetimeType, Table, Timestamp}; // 这层输入只服务 NPC 开战编排;普通聊天、援手、招募继续走已有 resolve_npc_interaction 接口。 -#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)] -pub struct ResolveNpcBattleInteractionInput { - pub npc_interaction: ResolveNpcInteractionInput, - pub story_session_id: String, - pub actor_user_id: String, - pub battle_state_id: Option, - pub player_hp: i32, - pub player_max_hp: i32, - pub player_mana: i32, - pub player_max_mana: i32, - pub target_hp: i32, - pub target_max_hp: i32, - pub experience_reward: u32, - pub reward_items: Vec, -} -// 输出同时返回 NPC 交互结果与 battle_state 快照,避免 Axum 再回头读取 private table。 -#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)] -pub struct NpcBattleInteractionResult { - pub interaction: module_npc::NpcInteractionResult, - pub battle_state: BattleStateSnapshot, -} - -#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)] -pub struct NpcBattleInteractionProcedureResult { - pub ok: bool, - pub result: Option, - pub error_message: Option, -} - -#[spacetimedb::table( - accessor = asset_object, - index(accessor = by_bucket_object_key, btree(columns = [bucket, object_key])) -)] -pub struct AssetObject { - #[primary_key] - asset_object_id: String, - // 正式对象定位固定拆成 bucket + object_key 两列,避免后续再从单字符串路径做 schema 拆分。 - bucket: String, - object_key: String, - access_policy: AssetObjectAccessPolicy, - content_type: Option, - content_length: u64, - content_hash: Option, - version: u32, - source_job_id: Option, - owner_user_id: Option, - profile_id: Option, - entity_id: Option, - #[index(btree)] - asset_kind: String, - created_at: Timestamp, - updated_at: Timestamp, -} - -#[spacetimedb::table( - accessor = asset_entity_binding, - index(accessor = by_entity_slot, btree(columns = [entity_kind, entity_id, slot])), - index(accessor = by_asset_object_id, btree(columns = [asset_object_id])) -)] -pub struct AssetEntityBinding { - #[primary_key] - binding_id: String, - asset_object_id: String, - entity_kind: String, - entity_id: String, - slot: String, - asset_kind: String, - owner_user_id: Option, - profile_id: Option, - created_at: Timestamp, - updated_at: Timestamp, -} - -#[spacetimedb::table(accessor = runtime_setting)] -pub struct RuntimeSetting { - #[primary_key] - user_id: String, - music_volume: f32, - platform_theme: RuntimePlatformTheme, - created_at: Timestamp, - updated_at: Timestamp, -} - -#[spacetimedb::table(accessor = runtime_snapshot)] -pub struct RuntimeSnapshotRow { - #[primary_key] - user_id: String, - version: u32, - saved_at: Timestamp, - bottom_tab: String, - game_state_json: String, - current_story_json: Option, - created_at: Timestamp, - updated_at: Timestamp, -} - -#[spacetimedb::table( - accessor = user_browse_history, - index(accessor = by_browse_history_user_id, btree(columns = [user_id])), - index( - accessor = by_browse_history_user_owner_profile, - btree(columns = [user_id, owner_user_id, profile_id]) - ) -)] -pub struct UserBrowseHistory { - #[primary_key] - browse_history_id: String, - user_id: String, - owner_user_id: String, - profile_id: String, - world_name: String, - subtitle: String, - summary_text: String, - cover_image_src: Option, - theme_mode: RuntimeBrowseHistoryThemeMode, - author_display_name: String, - visited_at: Timestamp, - created_at: Timestamp, - updated_at: Timestamp, -} - -#[spacetimedb::table(accessor = profile_dashboard_state)] -pub struct ProfileDashboardState { - #[primary_key] - user_id: String, - wallet_balance: u64, - total_play_time_ms: u64, - created_at: Timestamp, - updated_at: Timestamp, -} - -#[spacetimedb::table( - accessor = profile_wallet_ledger, - index(accessor = by_profile_wallet_ledger_user_id, btree(columns = [user_id])), - index( - accessor = by_profile_wallet_ledger_user_created_at, - btree(columns = [user_id, created_at]) - ) -)] -pub struct ProfileWalletLedger { - #[primary_key] - wallet_ledger_id: String, - user_id: String, - amount_delta: i64, - balance_after: u64, - source_type: RuntimeProfileWalletLedgerSourceType, - created_at: Timestamp, -} - -#[spacetimedb::table( - accessor = profile_played_world, - index(accessor = by_profile_played_world_user_id, btree(columns = [user_id])), - index( - accessor = by_profile_played_world_user_world_key, - btree(columns = [user_id, world_key]) - ), - index( - accessor = by_profile_played_world_user_last_played_at, - btree(columns = [user_id, last_played_at]) - ) -)] -pub struct ProfilePlayedWorld { - #[primary_key] - played_world_id: String, - user_id: String, - world_key: String, - owner_user_id: Option, - profile_id: Option, - world_type: Option, - world_title: String, - world_subtitle: String, - first_played_at: Timestamp, - last_played_at: Timestamp, - last_observed_play_time_ms: u64, -} - -#[spacetimedb::table( - accessor = profile_save_archive, - index(accessor = by_profile_save_archive_user_id, btree(columns = [user_id])), - index( - accessor = by_profile_save_archive_user_world_key, - btree(columns = [user_id, world_key]) - ), - index( - accessor = by_profile_save_archive_user_saved_at, - btree(columns = [user_id, saved_at]) - ) -)] -pub struct ProfileSaveArchive { - #[primary_key] - archive_id: String, - user_id: String, - world_key: String, - owner_user_id: Option, - profile_id: Option, - world_type: Option, - world_name: String, - subtitle: String, - summary_text: String, - cover_image_src: Option, - saved_at: Timestamp, - bottom_tab: String, - game_state_json: String, - current_story_json: Option, - created_at: Timestamp, - updated_at: Timestamp, -} - -#[spacetimedb::table(accessor = player_progression)] -pub struct PlayerProgression { - #[primary_key] - user_id: String, - level: u32, - current_level_xp: u32, - total_xp: u32, - xp_to_next_level: u32, - pending_level_ups: u32, - last_granted_source: Option, - created_at: Timestamp, - updated_at: Timestamp, -} - -#[spacetimedb::table( - accessor = chapter_progression, - index(accessor = by_chapter_progression_user_id, btree(columns = [user_id])), - index(accessor = by_chapter_progression_chapter_id, btree(columns = [chapter_id])), - index(accessor = by_chapter_progression_user_chapter, btree(columns = [user_id, chapter_id])) -)] -pub struct ChapterProgression { - #[primary_key] - chapter_progression_id: String, - user_id: String, - chapter_id: String, - chapter_index: u32, - total_chapters: u32, - entry_pseudo_level_millis: u32, - exit_pseudo_level_millis: u32, - entry_level: u32, - exit_level: u32, - planned_total_xp: u32, - planned_quest_xp: u32, - planned_hostile_xp: u32, - actual_quest_xp: u32, - actual_hostile_xp: u32, - expected_hostile_defeat_count: u32, - actual_hostile_defeat_count: u32, - level_at_entry: u32, - level_at_exit: Option, - pace_band: ChapterPaceBand, - created_at: Timestamp, - updated_at: Timestamp, -} - -#[spacetimedb::table( - accessor = npc_state, - index(accessor = by_runtime_session_id, btree(columns = [runtime_session_id])), - index(accessor = by_npc_id, btree(columns = [npc_id])), - index(accessor = by_runtime_session_npc, btree(columns = [runtime_session_id, npc_id])) -)] -pub struct NpcState { - #[primary_key] - npc_state_id: String, - runtime_session_id: String, - npc_id: String, - npc_name: String, - affinity: i32, - relation_state: NpcRelationState, - help_used: bool, - chatted_count: u32, - gifts_given: u32, - recruited: bool, - trade_stock_signature: Option, - revealed_facts: Vec, - known_attribute_rumors: Vec, - first_meaningful_contact_resolved: bool, - seen_backstory_chapter_ids: Vec, - stance_profile: NpcStanceProfile, - created_at: Timestamp, - updated_at: Timestamp, -} - -#[spacetimedb::table( - accessor = story_session, - index(accessor = by_runtime_session_id, btree(columns = [runtime_session_id])), - index(accessor = by_actor_user_id, btree(columns = [actor_user_id])) -)] -pub struct StorySession { - #[primary_key] - story_session_id: String, - runtime_session_id: String, - actor_user_id: String, - world_profile_id: String, - initial_prompt: String, - opening_summary: Option, - latest_narrative_text: String, - latest_choice_function_id: Option, - status: StorySessionStatus, - version: u32, - created_at: Timestamp, - updated_at: Timestamp, -} - -#[spacetimedb::table( - accessor = story_event, - index(accessor = by_story_session_id, btree(columns = [story_session_id])) -)] -pub struct StoryEvent { - #[primary_key] - event_id: String, - story_session_id: String, - event_kind: StoryEventKind, - narrative_text: String, - choice_function_id: Option, - created_at: Timestamp, -} - -#[spacetimedb::table( - accessor = ai_task, - index(accessor = by_ai_task_owner_user_id, btree(columns = [owner_user_id])), - index(accessor = by_ai_task_status, btree(columns = [status])), - index(accessor = by_ai_task_kind, btree(columns = [task_kind])) -)] -pub struct AiTask { - #[primary_key] - task_id: String, - task_kind: AiTaskKind, - owner_user_id: String, - request_label: String, - source_module: String, - source_entity_id: Option, - request_payload_json: Option, - status: AiTaskStatus, - failure_message: Option, - latest_text_output: Option, - latest_structured_payload_json: Option, - version: u32, - created_at: Timestamp, - started_at: Option, - completed_at: Option, - updated_at: Timestamp, -} - -#[spacetimedb::table( - accessor = ai_task_stage, - index(accessor = by_ai_task_stage_task_id, btree(columns = [task_id])), - index(accessor = by_ai_task_stage_task_order, btree(columns = [task_id, stage_order])) -)] -pub struct AiTaskStage { - #[primary_key] - task_stage_id: String, - task_id: String, - stage_kind: AiTaskStageKind, - label: String, - detail: String, - stage_order: u32, - status: AiTaskStageStatus, - text_output: Option, - structured_payload_json: Option, - warning_messages: Vec, - started_at: Option, - completed_at: Option, -} - -#[spacetimedb::table( - accessor = ai_text_chunk, - index(accessor = by_ai_text_chunk_task_id, btree(columns = [task_id])), - index( - accessor = by_ai_text_chunk_task_stage_sequence, - btree(columns = [task_id, stage_kind, sequence]) - ) -)] -pub struct AiTextChunk { - #[primary_key] - text_chunk_row_id: String, - chunk_id: String, - task_id: String, - stage_kind: AiTaskStageKind, - sequence: u32, - delta_text: String, - created_at: Timestamp, -} - -#[spacetimedb::table( - accessor = ai_result_reference, - index(accessor = by_ai_result_reference_task_id, btree(columns = [task_id])) -)] -pub struct AiResultReference { - #[primary_key] - result_reference_row_id: String, - result_ref_id: String, - task_id: String, - reference_kind: AiResultReferenceKind, - reference_id: String, - label: Option, - created_at: Timestamp, -} - -#[spacetimedb::table( - accessor = inventory_slot, - index(accessor = by_inventory_runtime_session_id, btree(columns = [runtime_session_id])), - index(accessor = by_inventory_actor_user_id, btree(columns = [actor_user_id])), - index(accessor = by_inventory_container_slot, btree(columns = [container_kind, slot_key])), - index(accessor = by_inventory_item_id, btree(columns = [item_id])) -)] -pub struct InventorySlot { - #[primary_key] - slot_id: String, - runtime_session_id: String, - story_session_id: Option, - actor_user_id: String, - container_kind: InventoryContainerKind, - slot_key: String, - item_id: String, - category: String, - name: String, - description: Option, - quantity: u32, - rarity: InventoryItemRarity, - tags: Vec, - stackable: bool, - stack_key: String, - equipment_slot_id: Option, - source_kind: InventoryItemSourceKind, - source_reference_id: Option, - created_at: Timestamp, - updated_at: Timestamp, -} - -#[spacetimedb::table( - accessor = battle_state, - index(accessor = by_battle_story_session_id, btree(columns = [story_session_id])), - index(accessor = by_battle_runtime_session_id, btree(columns = [runtime_session_id])), - index(accessor = by_battle_actor_user_id, btree(columns = [actor_user_id])) -)] -pub struct BattleState { - #[primary_key] - battle_state_id: String, - story_session_id: String, - runtime_session_id: String, - actor_user_id: String, - chapter_id: Option, - target_npc_id: String, - target_name: String, - battle_mode: BattleMode, - status: BattleStatus, - player_hp: i32, - player_max_hp: i32, - player_mana: i32, - player_max_mana: i32, - target_hp: i32, - target_max_hp: i32, - experience_reward: u32, - reward_items: Vec, - turn_index: u32, - last_action_function_id: Option, - last_action_text: Option, - last_result_text: Option, - last_damage_dealt: i32, - last_damage_taken: i32, - last_outcome: CombatOutcome, - version: u32, - created_at: Timestamp, - updated_at: Timestamp, -} - -#[spacetimedb::table( - accessor = treasure_record, - index(accessor = by_treasure_story_session_id, btree(columns = [story_session_id])), - index(accessor = by_treasure_runtime_session_id, btree(columns = [runtime_session_id])), - index(accessor = by_treasure_actor_user_id, btree(columns = [actor_user_id])), - index(accessor = by_treasure_encounter_id, btree(columns = [encounter_id])) -)] -pub struct TreasureRecord { - #[primary_key] - treasure_record_id: String, - runtime_session_id: String, - story_session_id: String, - actor_user_id: String, - encounter_id: String, - encounter_name: String, - scene_id: Option, - scene_name: Option, - action: TreasureInteractionAction, - reward_items: Vec, - reward_hp: u32, - reward_mana: u32, - reward_currency: u32, - story_hint: Option, - created_at: Timestamp, - updated_at: Timestamp, -} - -#[spacetimedb::table( - accessor = quest_record, - index(accessor = by_runtime_session_id, btree(columns = [runtime_session_id])), - index(accessor = by_actor_user_id, btree(columns = [actor_user_id])), - index(accessor = by_issuer_npc_id, btree(columns = [issuer_npc_id])) -)] -pub struct QuestRecord { - #[primary_key] - quest_id: String, - runtime_session_id: String, - story_session_id: Option, - actor_user_id: String, - issuer_npc_id: String, - issuer_npc_name: String, - scene_id: Option, - chapter_id: Option, - act_id: Option, - thread_id: Option, - contract_id: Option, - title: String, - description: String, - summary: String, - objective: QuestObjectiveSnapshot, - progress: u32, - status: QuestStatus, - completion_notified: bool, - reward: QuestRewardSnapshot, - reward_text: String, - narrative_binding: QuestNarrativeBindingSnapshot, - steps: Vec, - active_step_id: Option, - visible_stage: u32, - hidden_flags: Vec, - discovered_fact_ids: Vec, - related_carrier_ids: Vec, - consequence_ids: Vec, - created_at: Timestamp, - updated_at: Timestamp, - completed_at: Option, - turned_in_at: Option, -} - -#[spacetimedb::table( - accessor = quest_log, - index(accessor = by_quest_id, btree(columns = [quest_id])), - index(accessor = by_runtime_session_id, btree(columns = [runtime_session_id])), - index(accessor = by_actor_user_id, btree(columns = [actor_user_id])) -)] -pub struct QuestLog { - #[primary_key] - log_id: String, - quest_id: String, - runtime_session_id: String, - actor_user_id: String, - event_kind: QuestLogEventKind, - status_after: QuestStatus, - signal_kind: Option, - signal: Option, - step_id: Option, - step_progress: Option, - created_at: Timestamp, -} - -#[spacetimedb::table( - accessor = custom_world_profile, - index(accessor = by_custom_world_profile_owner_user_id, btree(columns = [owner_user_id])), - index( - accessor = by_custom_world_profile_publication_status, - btree(columns = [publication_status]) - ) -)] -pub struct CustomWorldProfile { - #[primary_key] - profile_id: String, - // 当前 profile 承接 library / publish / enter-world 的正式世界工件真相。 - owner_user_id: String, - source_agent_session_id: Option, - publication_status: CustomWorldPublicationStatus, - world_name: String, - subtitle: String, - summary_text: String, - theme_mode: CustomWorldThemeMode, - cover_image_src: Option, - profile_payload_json: String, - playable_npc_count: u32, - landmark_count: u32, - author_display_name: String, - published_at: Option, - created_at: Timestamp, - updated_at: Timestamp, -} - -#[spacetimedb::table( - accessor = custom_world_session, - index(accessor = by_custom_world_session_owner_user_id, btree(columns = [owner_user_id])) -)] -pub struct CustomWorldSession { - #[primary_key] - session_id: String, - // 这张表只承接旧 custom-world/sessions 传统问答流,不和 agent 会话混存。 - owner_user_id: String, - generation_mode: CustomWorldGenerationMode, - status: CustomWorldSessionStatus, - setting_text: String, - creator_intent_json: Option, - question_snapshot_json: String, - result_payload_json: Option, - last_error_message: Option, - created_at: Timestamp, - updated_at: Timestamp, -} - -#[spacetimedb::table( - accessor = custom_world_agent_session, - index( - accessor = by_custom_world_agent_session_owner_user_id, - btree(columns = [owner_user_id]) - ), - index(accessor = by_custom_world_agent_session_stage, btree(columns = [stage])) -)] -pub struct CustomWorldAgentSession { - #[primary_key] - session_id: String, - // Agent 会话只保留会话级聚合字段,消息、操作、卡片都拆到独立表。 - owner_user_id: String, - seed_text: String, - current_turn: u32, - progress_percent: u32, - stage: RpgAgentStage, - focus_card_id: Option, - anchor_content_json: String, - creator_intent_json: Option, - creator_intent_readiness_json: String, - 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: String, - quality_findings_json: String, - suggested_actions_json: String, - recommended_replies_json: String, - asset_coverage_json: String, - checkpoints_json: String, - created_at: Timestamp, - updated_at: Timestamp, -} - -#[spacetimedb::table( - accessor = custom_world_agent_message, - index(accessor = by_custom_world_agent_message_session_id, btree(columns = [session_id])) -)] -pub struct CustomWorldAgentMessage { - #[primary_key] - message_id: String, - // 消息流水单独成表,避免继续塞回 session 大 JSON。 - session_id: String, - role: RpgAgentMessageRole, - kind: RpgAgentMessageKind, - text: String, - related_operation_id: Option, - created_at: Timestamp, -} - -#[spacetimedb::table( - accessor = custom_world_agent_operation, - index(accessor = by_custom_world_agent_operation_session_id, btree(columns = [session_id])) -)] -pub struct CustomWorldAgentOperation { - #[primary_key] - operation_id: String, - // 异步操作单独建表,为 message stream / operation query 提供真相源。 - session_id: String, - operation_type: RpgAgentOperationType, - status: RpgAgentOperationStatus, - phase_label: String, - phase_detail: String, - progress: u32, - error_message: Option, - created_at: Timestamp, - updated_at: Timestamp, -} - -#[spacetimedb::table( - accessor = custom_world_draft_card, - index(accessor = by_custom_world_draft_card_session_id, btree(columns = [session_id])), - index(accessor = by_custom_world_draft_card_kind, btree(columns = [kind])) -)] -pub struct CustomWorldDraftCard { - #[primary_key] - card_id: String, - // 卡片实体从 agent session 拆出,后续 detail / update 都直接对这张表操作。 - session_id: String, - kind: RpgAgentDraftCardKind, - status: RpgAgentDraftCardStatus, - title: String, - subtitle: String, - summary: String, - linked_ids_json: String, - warning_count: u32, - asset_status: Option, - asset_status_label: Option, - detail_payload_json: Option, - created_at: Timestamp, - updated_at: Timestamp, -} - -#[spacetimedb::table( - accessor = custom_world_gallery_entry, - public, - index(accessor = by_custom_world_gallery_owner_user_id, btree(columns = [owner_user_id])), - index(accessor = by_custom_world_gallery_theme_mode, btree(columns = [theme_mode])) -)] -pub struct CustomWorldGalleryEntry { - #[primary_key] - profile_id: String, - // 画廊是公开订阅读模型,不再运行时从 profile 即席拼装。 - owner_user_id: String, - author_display_name: String, - world_name: String, - subtitle: String, - summary_text: String, - cover_image_src: Option, - theme_mode: CustomWorldThemeMode, - playable_npc_count: u32, - landmark_count: u32, - published_at: Timestamp, - updated_at: Timestamp, -} - -// 当前阶段先落可发布的最小模块入口,后续再补对象确认、业务绑定与任务编排 reducer。 -#[spacetimedb::reducer(init)] -pub fn init(_ctx: &ReducerContext) { - log::info!( - "spacetime-module 初始化完成,asset_object 已固定 bucket/object_key 双列主存储口径,runtime_setting 已固定默认音量={} 和默认主题={},battle_state 前缀={},战斗初始版本={},npc_state 前缀={},npc 招募阈值={},story_session 前缀={},story_event 前缀={},inventory_slot 前缀={},inventory_mutation 前缀={},quest_log 前缀={},treasure_record 前缀={},player_progression 与 chapter_progression 已接入成长真相表,M5 custom_world_profile/session/agent/gallery 首批表骨架已接入,默认对象 ID 前缀={},默认绑定 ID 前缀={},资产初始版本={},故事会话初始版本={}", - DEFAULT_MUSIC_VOLUME, - DEFAULT_PLATFORM_THEME.as_str(), - BATTLE_STATE_ID_PREFIX, - INITIAL_BATTLE_VERSION, - NPC_STATE_ID_PREFIX, - NPC_RECRUIT_AFFINITY_THRESHOLD, - STORY_SESSION_ID_PREFIX, - STORY_EVENT_ID_PREFIX, - INVENTORY_SLOT_ID_PREFIX, - INVENTORY_MUTATION_ID_PREFIX, - QUEST_LOG_ID_PREFIX, - TREASURE_RECORD_ID_PREFIX, - ASSET_OBJECT_ID_PREFIX, - ASSET_BINDING_ID_PREFIX, - INITIAL_ASSET_OBJECT_VERSION, - INITIAL_STORY_SESSION_VERSION - ); -} - -// 成长状态默认按 user_id 单行持久化;若尚未存在记录则返回 Lv.1 / 0 XP 的兼容初始值。 -#[spacetimedb::procedure] -pub fn get_player_progression_or_default( - ctx: &mut ProcedureContext, - input: PlayerProgressionGetInput, -) -> PlayerProgressionProcedureResult { - match ctx.try_with_tx(|tx| get_player_progression_snapshot_tx(tx, input.clone())) { - Ok(record) => PlayerProgressionProcedureResult { - ok: true, - record: Some(record), - error_message: None, - }, - Err(message) => PlayerProgressionProcedureResult { - ok: false, - record: None, - error_message: Some(message), - }, - } -} - -// 经验发放统一走 progression reducer,避免任务和战斗各自直接写等级字段。 -#[spacetimedb::reducer] -pub fn grant_player_progression_experience( - ctx: &ReducerContext, - input: PlayerProgressionGrantInput, -) -> Result<(), String> { - upsert_player_progression_after_grant_tx(ctx, input).map(|_| ()) -} - -#[spacetimedb::procedure] -pub fn grant_player_progression_experience_and_return( - ctx: &mut ProcedureContext, - input: PlayerProgressionGrantInput, -) -> PlayerProgressionProcedureResult { - match ctx.try_with_tx(|tx| upsert_player_progression_after_grant_tx(tx, input.clone())) { - Ok(record) => PlayerProgressionProcedureResult { - ok: true, - record: Some(record), - error_message: None, - }, - Err(message) => PlayerProgressionProcedureResult { - ok: false, - record: None, - error_message: Some(message), - }, - } -} - -// 章节计划在进入章节或编译章节预算时写入;当前先用单表同时承接计划值与实际记账值。 -#[spacetimedb::reducer] -pub fn upsert_chapter_progression( - ctx: &ReducerContext, - input: ChapterProgressionInput, -) -> Result<(), String> { - upsert_chapter_progression_snapshot_tx(ctx, input).map(|_| ()) -} - -#[spacetimedb::procedure] -pub fn upsert_chapter_progression_and_return( - ctx: &mut ProcedureContext, - input: ChapterProgressionInput, -) -> ChapterProgressionProcedureResult { - match ctx.try_with_tx(|tx| upsert_chapter_progression_snapshot_tx(tx, input.clone())) { - Ok(record) => ChapterProgressionProcedureResult { - ok: true, - record: Some(record), - error_message: None, - }, - Err(message) => ChapterProgressionProcedureResult { - ok: false, - record: None, - error_message: Some(message), - }, - } -} - -// 章节实际经验与击杀记账后续由 quest/combat 联动调用,这一轮先把真相写入口固定下来。 -#[spacetimedb::reducer] -pub fn apply_chapter_progression_ledger_entry( - ctx: &ReducerContext, - input: ChapterProgressionLedgerInput, -) -> Result<(), String> { - update_chapter_progression_ledger_tx(ctx, input).map(|_| ()) -} - -#[spacetimedb::procedure] -pub fn apply_chapter_progression_ledger_entry_and_return( - ctx: &mut ProcedureContext, - input: ChapterProgressionLedgerInput, -) -> ChapterProgressionProcedureResult { - match ctx.try_with_tx(|tx| update_chapter_progression_ledger_tx(tx, input.clone())) { - Ok(record) => ChapterProgressionProcedureResult { - ok: true, - record: Some(record), - error_message: None, - }, - Err(message) => ChapterProgressionProcedureResult { - ok: false, - record: None, - error_message: Some(message), - }, - } -} - -#[spacetimedb::procedure] -pub fn get_chapter_progression( - ctx: &mut ProcedureContext, - input: ChapterProgressionGetInput, -) -> ChapterProgressionProcedureResult { - match ctx.try_with_tx(|tx| get_chapter_progression_snapshot_tx(tx, input.clone())) { - Ok(record) => ChapterProgressionProcedureResult { - ok: true, - record: Some(record), - error_message: None, - }, - Err(message) => ChapterProgressionProcedureResult { - ok: false, - record: None, - error_message: Some(message), - }, - } -} - -// 当前阶段先把 inventory_slot 立成显式背包真相表,避免继续由多个 service 各自改 runtime snapshot JSON。 -#[spacetimedb::reducer] -pub fn apply_inventory_mutation( - ctx: &ReducerContext, - input: InventoryMutationInput, -) -> Result<(), String> { - apply_inventory_mutation_tx(ctx, input) -} - -fn apply_inventory_mutation_tx( - ctx: &ReducerContext, - input: InventoryMutationInput, -) -> Result<(), String> { - let current_slots = ctx - .db - .inventory_slot() - .iter() - .filter(|slot| { - slot.runtime_session_id == input.runtime_session_id - && slot.actor_user_id == input.actor_user_id - }) - .map(|row| build_inventory_slot_snapshot_from_row(&row)) - .collect::>(); - - let outcome = - apply_inventory_slot_mutation(current_slots, input).map_err(|error| error.to_string())?; - - for removed_slot_id in outcome.removed_slot_ids { - ctx.db.inventory_slot().slot_id().delete(&removed_slot_id); - } - - for slot in outcome.next_slots { - ctx.db.inventory_slot().slot_id().delete(&slot.slot_id); - ctx.db - .inventory_slot() - .insert(build_inventory_slot_row(slot)); - } - - Ok(()) -} - -// procedure 面向 Axum 同步读取当前 runtime_session 下某个玩家的背包真相态。 -#[spacetimedb::procedure] -pub fn get_runtime_inventory_state( - ctx: &mut ProcedureContext, - input: RuntimeInventoryStateQueryInput, -) -> RuntimeInventoryStateProcedureResult { - match ctx.try_with_tx(|tx| get_runtime_inventory_state_tx(tx, input.clone())) { - Ok(snapshot) => RuntimeInventoryStateProcedureResult { - ok: true, - snapshot: Some(snapshot), - error_message: None, - }, - Err(message) => RuntimeInventoryStateProcedureResult { - ok: false, - snapshot: None, - error_message: Some(message), - }, - } -} - -// M4 首轮先把 battle_state 作为战斗真相源落到 SpacetimeDB,避免继续把战斗状态埋在 runtime JSON 里。 -#[spacetimedb::reducer] -pub fn create_battle_state(ctx: &ReducerContext, input: BattleStateInput) -> Result<(), String> { - create_battle_state_record(ctx, input).map(|_| ()) -} - -// procedure 面向 Axum 同步创建 battle_state,返回当前最新战斗快照,避免 HTTP 层再次读取 private table。 -#[spacetimedb::procedure] -pub fn create_battle_state_and_return( - ctx: &mut ProcedureContext, - input: BattleStateInput, -) -> BattleStateProcedureResult { - match ctx.try_with_tx(|tx| create_battle_state_record(tx, input.clone())) { - Ok(snapshot) => BattleStateProcedureResult { - ok: true, - snapshot: Some(snapshot), - error_message: None, - }, - Err(message) => BattleStateProcedureResult { - ok: false, - snapshot: None, - error_message: Some(message), - }, - } -} - -// procedure 面向 Axum 读取单个 battle_state 真相态,当前只返回最新战斗快照。 -#[spacetimedb::procedure] -pub fn get_battle_state( - ctx: &mut ProcedureContext, - input: BattleStateQueryInput, -) -> BattleStateProcedureResult { - match ctx.try_with_tx(|tx| get_battle_state_record(tx, input.clone())) { - Ok(snapshot) => BattleStateProcedureResult { - ok: true, - snapshot: Some(snapshot), - error_message: None, - }, - Err(message) => BattleStateProcedureResult { - ok: false, - snapshot: None, - error_message: Some(message), - }, - } -} - -// M4 首轮只承接单行为战斗推进,不提前把 inventory / progression / story AI 续写耦进 reducer。 -#[spacetimedb::reducer] -pub fn resolve_combat_action( - ctx: &ReducerContext, - input: ResolveCombatActionInput, -) -> Result<(), String> { - resolve_battle_state_record(ctx, input).map(|_| ()) -} - -// procedure 面向 Axum 同步推进单次战斗动作,返回本次结算结果与 battle_state 最新快照。 -#[spacetimedb::procedure] -pub fn resolve_combat_action_and_return( - ctx: &mut ProcedureContext, - input: ResolveCombatActionInput, -) -> ResolveCombatActionProcedureResult { - match ctx.try_with_tx(|tx| resolve_battle_state_record(tx, input.clone())) { - Ok(result) => ResolveCombatActionProcedureResult { - ok: true, - result: Some(result), - error_message: None, - }, - Err(message) => ResolveCombatActionProcedureResult { - ok: false, - result: None, - error_message: Some(message), - }, - } -} - -fn create_battle_state_record( - ctx: &ReducerContext, - input: BattleStateInput, -) -> Result { - validate_battle_state_input(&input).map_err(|error| error.to_string())?; - - if ctx - .db - .battle_state() - .battle_state_id() - .find(&input.battle_state_id) - .is_some() - { - return Err("battle_state.battle_state_id 已存在".to_string()); - } - - let snapshot = build_battle_state_snapshot(input); - ctx.db - .battle_state() - .insert(build_battle_state_row(snapshot.clone())); - - Ok(snapshot) -} - -fn get_battle_state_record( - ctx: &ReducerContext, - input: BattleStateQueryInput, -) -> Result { - validate_battle_state_query_input(&input).map_err(|error| error.to_string())?; - - let row = ctx - .db - .battle_state() - .battle_state_id() - .find(&input.battle_state_id) - .ok_or_else(|| "battle_state 不存在".to_string())?; - - Ok(build_battle_state_snapshot_from_row(&row)) -} - -fn get_runtime_inventory_state_tx( - ctx: &ReducerContext, - input: RuntimeInventoryStateQueryInput, -) -> Result { - let validated_input = - build_runtime_inventory_state_query_input(input.runtime_session_id, input.actor_user_id) - .map_err(|error| error.to_string())?; - - // 这层只返回 inventory_slot 真相表的最小切片,不混入 story、quest、battle 的额外投影。 - let slots = ctx - .db - .inventory_slot() - .iter() - .filter(|row| { - row.runtime_session_id == validated_input.runtime_session_id - && row.actor_user_id == validated_input.actor_user_id - }) - .map(|row| build_inventory_slot_snapshot_from_row(&row)) - .collect::>(); - - Ok(build_runtime_inventory_state_snapshot( - validated_input, - slots, - )) -} - -fn resolve_battle_state_record( - ctx: &ReducerContext, - input: ResolveCombatActionInput, -) -> Result { - let current = ctx - .db - .battle_state() - .battle_state_id() - .find(&input.battle_state_id) - .ok_or_else(|| "battle_state 不存在,无法执行战斗动作".to_string())?; - - let result = resolve_battle_state_action(build_battle_state_snapshot_from_row(¤t), input) - .map_err(|error| error.to_string())?; - - ctx.db - .battle_state() - .battle_state_id() - .delete(¤t.battle_state_id); - ctx.db - .battle_state() - .insert(build_battle_state_row(result.snapshot.clone())); - - if result.outcome == CombatOutcome::Victory { - grant_battle_reward_items(ctx, &result.snapshot)?; - - if result.snapshot.experience_reward > 0 { - let updated_player = upsert_player_progression_after_grant_tx( - ctx, - PlayerProgressionGrantInput { - user_id: result.snapshot.actor_user_id.clone(), - amount: result.snapshot.experience_reward, - source: PlayerProgressionGrantSource::HostileNpc, - updated_at_micros: result.snapshot.updated_at_micros, - }, - )?; - - // 章节计划可能尚未初始化;此时不能阻断战斗胜利结算,只跳过章节账本写入。 - try_update_chapter_progression_ledger_tx( - ctx, - result.snapshot.actor_user_id.clone(), - result.snapshot.chapter_id.clone(), - ChapterProgressionLedgerInput { - user_id: result.snapshot.actor_user_id.clone(), - chapter_id: result.snapshot.chapter_id.clone().unwrap_or_default(), - granted_quest_xp: 0, - granted_hostile_xp: result.snapshot.experience_reward, - hostile_defeat_increment: 1, - level_at_exit: Some(updated_player.level), - updated_at_micros: result.snapshot.updated_at_micros, - }, - )?; - } - } - - Ok(result) -} - -// 当前阶段先把 npc_state 立成显式真相表,避免继续把关系状态藏在运行时 JSON 快照里。 -#[spacetimedb::reducer] -pub fn upsert_npc_state(ctx: &ReducerContext, input: NpcStateUpsertInput) -> Result<(), String> { - upsert_npc_state_record(ctx, input).map(|_| ()) -} - -// procedure 面向 Axum 同步 upsert 接口,返回最新 NPC 状态快照。 -#[spacetimedb::procedure] -pub fn upsert_npc_state_and_return( - ctx: &mut ProcedureContext, - input: NpcStateUpsertInput, -) -> NpcStateProcedureResult { - match ctx.try_with_tx(|tx| upsert_npc_state_record(tx, input.clone())) { - Ok(record) => NpcStateProcedureResult { - ok: true, - record: Some(record), - error_message: None, - }, - Err(message) => NpcStateProcedureResult { - ok: false, - record: None, - error_message: Some(message), - }, - } -} - -// 当前阶段只承接 NPC 关系状态的最小社交动作,不提前把背包、战斗和队伍副作用也塞进来。 -#[spacetimedb::reducer] -pub fn resolve_npc_social_action( - ctx: &ReducerContext, - input: ResolveNpcSocialActionInput, -) -> Result<(), String> { - resolve_npc_social_action_record(ctx, input).map(|_| ()) -} - -// procedure 面向 Axum 同步社交动作接口,返回动作后的 NPC 状态快照。 -#[spacetimedb::procedure] -pub fn resolve_npc_social_action_and_return( - ctx: &mut ProcedureContext, - input: ResolveNpcSocialActionInput, -) -> NpcStateProcedureResult { - match ctx.try_with_tx(|tx| resolve_npc_social_action_record(tx, input.clone())) { - Ok(record) => NpcStateProcedureResult { - ok: true, - record: Some(record), - error_message: None, - }, - Err(message) => NpcStateProcedureResult { - ok: false, - record: None, - error_message: Some(message), - }, - } -} - -// 当前阶段先冻结 NPC 正式交互统一入口,不直接在这里扩出队伍、战斗、背包等跨子域副作用。 -#[spacetimedb::reducer] -pub fn resolve_npc_interaction( - ctx: &ReducerContext, - input: ResolveNpcInteractionInput, -) -> Result<(), String> { - resolve_npc_interaction_record(ctx, input).map(|_| ()) -} - -#[spacetimedb::procedure] -pub fn resolve_npc_interaction_and_return( - ctx: &mut ProcedureContext, - input: ResolveNpcInteractionInput, -) -> NpcInteractionProcedureResult { - match ctx.try_with_tx(|tx| resolve_npc_interaction_record(tx, input.clone())) { - Ok(result) => NpcInteractionProcedureResult { - ok: true, - result: Some(result), - error_message: None, - }, - Err(message) => NpcInteractionProcedureResult { - ok: false, - result: None, - error_message: Some(message), - }, - } -} - -// fight / spar 的 battle_state 初始化属于聚合层编排,不回灌到 module-npc 纯领域 crate。 -#[spacetimedb::procedure] -pub fn resolve_npc_battle_interaction_and_return( - ctx: &mut ProcedureContext, - input: ResolveNpcBattleInteractionInput, -) -> NpcBattleInteractionProcedureResult { - match ctx.try_with_tx(|tx| resolve_npc_battle_interaction_tx(tx, input.clone())) { - Ok(result) => NpcBattleInteractionProcedureResult { - ok: true, - result: Some(result), - error_message: None, - }, - Err(message) => NpcBattleInteractionProcedureResult { - ok: false, - result: None, - error_message: Some(message), - }, - } -} - -// M4 首轮先把 story_session / story_event 作为显式会话真相源落到 SpacetimeDB,避免后续继续依赖大 JSON 覆盖式写法。 -#[spacetimedb::reducer] -pub fn begin_story_session(ctx: &ReducerContext, input: StorySessionInput) -> Result<(), String> { - begin_story_session_tx(ctx, input).map(|_| ()) -} - -// procedure 面向 Axum 同步创建故事会话,返回最新会话快照与开场事件,避免 HTTP 层再读 private table。 -#[spacetimedb::procedure] -pub fn begin_story_session_and_return( - ctx: &mut ProcedureContext, - input: StorySessionInput, -) -> StorySessionProcedureResult { - match ctx.try_with_tx(|tx| begin_story_session_tx(tx, input.clone())) { - Ok((session, event)) => StorySessionProcedureResult { - ok: true, - session: Some(session), - event: Some(event), - error_message: None, - }, - Err(message) => StorySessionProcedureResult { - ok: false, - session: None, - event: None, - error_message: Some(message), - }, - } -} - -fn begin_story_session_tx( - ctx: &ReducerContext, - input: StorySessionInput, -) -> Result<(StorySessionSnapshot, StoryEventSnapshot), String> { - validate_story_session_input(&input).map_err(|error| error.to_string())?; - - if ctx - .db - .story_session() - .story_session_id() - .find(&input.story_session_id) - .is_some() - { - return Err("story_session.story_session_id 已存在".to_string()); - } - - let snapshot = build_story_session_snapshot(input); - let started_event = build_story_started_event(&snapshot); - let created_at = Timestamp::from_micros_since_unix_epoch(snapshot.created_at_micros); - let updated_at = Timestamp::from_micros_since_unix_epoch(snapshot.updated_at_micros); - - ctx.db.story_session().insert(StorySession { - story_session_id: snapshot.story_session_id.clone(), - runtime_session_id: snapshot.runtime_session_id.clone(), - actor_user_id: snapshot.actor_user_id.clone(), - world_profile_id: snapshot.world_profile_id.clone(), - initial_prompt: snapshot.initial_prompt.clone(), - opening_summary: snapshot.opening_summary.clone(), - latest_narrative_text: snapshot.latest_narrative_text.clone(), - latest_choice_function_id: snapshot.latest_choice_function_id.clone(), - status: snapshot.status, - version: snapshot.version, - created_at, - updated_at, - }); - - ctx.db.story_event().insert(StoryEvent { - event_id: started_event.event_id.clone(), - story_session_id: started_event.story_session_id.clone(), - event_kind: started_event.event_kind, - narrative_text: started_event.narrative_text.clone(), - choice_function_id: started_event.choice_function_id.clone(), - created_at, - }); - - Ok((snapshot, started_event)) -} - -// M4 首轮继续把“故事推进”固定为事件追加 + 会话版本递增,为后续 resolve_story_action 接线提供最小基座。 -#[spacetimedb::reducer] -pub fn continue_story(ctx: &ReducerContext, input: StoryContinueInput) -> Result<(), String> { - continue_story_tx(ctx, input).map(|_| ()) -} - -// procedure 面向 Axum 同步推进故事会话,返回最新会话快照与本次事件,避免 HTTP 层再读 private table。 -#[spacetimedb::procedure] -pub fn continue_story_and_return( - ctx: &mut ProcedureContext, - input: StoryContinueInput, -) -> StorySessionProcedureResult { - match ctx.try_with_tx(|tx| continue_story_tx(tx, input.clone())) { - Ok((session, event)) => StorySessionProcedureResult { - ok: true, - session: Some(session), - event: Some(event), - error_message: None, - }, - Err(message) => StorySessionProcedureResult { - ok: false, - session: None, - event: None, - error_message: Some(message), - }, - } -} - -// procedure 面向 Axum 读取指定 story session 的最小真实状态,当前只返回 session + event 列表。 -#[spacetimedb::procedure] -pub fn get_story_session_state( - ctx: &mut ProcedureContext, - input: StorySessionStateInput, -) -> StorySessionStateProcedureResult { - match ctx.try_with_tx(|tx| get_story_session_state_tx(tx, input.clone())) { - Ok((session, events)) => StorySessionStateProcedureResult { - ok: true, - session: Some(session), - events, - error_message: None, - }, - Err(message) => StorySessionStateProcedureResult { - ok: false, - session: None, - events: Vec::new(), - error_message: Some(message), - }, - } -} - -// Stage 6 先把 Agent 会话骨架写入 SpacetimeDB,LLM 采集与卡片生成后续再接入。 -#[spacetimedb::procedure] -pub fn create_custom_world_agent_session( - ctx: &mut ProcedureContext, - input: CustomWorldAgentSessionCreateInput, -) -> CustomWorldAgentSessionProcedureResult { - match ctx.try_with_tx(|tx| create_custom_world_agent_session_tx(tx, input.clone())) { - Ok(session) => CustomWorldAgentSessionProcedureResult { - ok: true, - session: Some(session), - error_message: None, - }, - Err(message) => CustomWorldAgentSessionProcedureResult { - ok: false, - session: None, - error_message: Some(message), - }, - } -} - -// Stage 6 读取拆表后的最小 Agent session snapshot,供 Axum 兼容旧前端 contract。 -#[spacetimedb::procedure] -pub fn get_custom_world_agent_session( - ctx: &mut ProcedureContext, - input: CustomWorldAgentSessionGetInput, -) -> CustomWorldAgentSessionProcedureResult { - match ctx.try_with_tx(|tx| get_custom_world_agent_session_tx(tx, input.clone())) { - Ok(session) => CustomWorldAgentSessionProcedureResult { - ok: true, - session: Some(session), - error_message: None, - }, - Err(message) => CustomWorldAgentSessionProcedureResult { - ok: false, - session: None, - error_message: Some(message), - }, - } -} - -#[spacetimedb::procedure] -pub fn submit_custom_world_agent_message( - ctx: &mut ProcedureContext, - input: CustomWorldAgentMessageSubmitInput, -) -> CustomWorldAgentOperationProcedureResult { - match ctx.try_with_tx(|tx| submit_custom_world_agent_message_tx(tx, input.clone())) { - Ok(operation) => CustomWorldAgentOperationProcedureResult { - ok: true, - operation: Some(operation), - error_message: None, - }, - Err(message) => CustomWorldAgentOperationProcedureResult { - ok: false, - operation: None, - error_message: Some(message), - }, - } -} - -#[spacetimedb::procedure] -pub fn get_custom_world_agent_operation( - ctx: &mut ProcedureContext, - input: CustomWorldAgentOperationGetInput, -) -> CustomWorldAgentOperationProcedureResult { - match ctx.try_with_tx(|tx| get_custom_world_agent_operation_tx(tx, input.clone())) { - Ok(operation) => CustomWorldAgentOperationProcedureResult { - ok: true, - operation: Some(operation), - error_message: None, - }, - Err(message) => CustomWorldAgentOperationProcedureResult { - ok: false, - operation: None, - error_message: Some(message), - }, - } -} - -fn continue_story_tx( - ctx: &ReducerContext, - input: StoryContinueInput, -) -> Result<(StorySessionSnapshot, StoryEventSnapshot), String> { - validate_story_continue_input(&input).map_err(|error| error.to_string())?; - - let current = ctx - .db - .story_session() - .story_session_id() - .find(&input.story_session_id) - .ok_or_else(|| "story_session 不存在,无法继续推进".to_string())?; - - let current_snapshot = StorySessionSnapshot { - story_session_id: current.story_session_id.clone(), - runtime_session_id: current.runtime_session_id.clone(), - actor_user_id: current.actor_user_id.clone(), - world_profile_id: current.world_profile_id.clone(), - initial_prompt: current.initial_prompt.clone(), - opening_summary: current.opening_summary.clone(), - latest_narrative_text: current.latest_narrative_text.clone(), - latest_choice_function_id: current.latest_choice_function_id.clone(), - status: current.status, - version: current.version, - created_at_micros: current.created_at.to_micros_since_unix_epoch(), - updated_at_micros: current.updated_at.to_micros_since_unix_epoch(), - }; - - let (next_snapshot, event_snapshot) = - apply_story_continue(current_snapshot, input).map_err(|error| error.to_string())?; - - ctx.db - .story_session() - .story_session_id() - .delete(¤t.story_session_id); - - ctx.db.story_session().insert(StorySession { - story_session_id: next_snapshot.story_session_id.clone(), - runtime_session_id: next_snapshot.runtime_session_id.clone(), - actor_user_id: next_snapshot.actor_user_id.clone(), - world_profile_id: next_snapshot.world_profile_id.clone(), - initial_prompt: next_snapshot.initial_prompt.clone(), - opening_summary: next_snapshot.opening_summary.clone(), - latest_narrative_text: next_snapshot.latest_narrative_text.clone(), - latest_choice_function_id: next_snapshot.latest_choice_function_id.clone(), - status: next_snapshot.status, - version: next_snapshot.version, - created_at: Timestamp::from_micros_since_unix_epoch(next_snapshot.created_at_micros), - updated_at: Timestamp::from_micros_since_unix_epoch(next_snapshot.updated_at_micros), - }); - - ctx.db.story_event().insert(StoryEvent { - event_id: event_snapshot.event_id.clone(), - story_session_id: event_snapshot.story_session_id.clone(), - event_kind: event_snapshot.event_kind, - narrative_text: event_snapshot.narrative_text.clone(), - choice_function_id: event_snapshot.choice_function_id.clone(), - created_at: Timestamp::from_micros_since_unix_epoch(event_snapshot.created_at_micros), - }); - - Ok((next_snapshot, event_snapshot)) -} - -fn get_story_session_state_tx( - ctx: &ReducerContext, - input: StorySessionStateInput, -) -> Result<(StorySessionSnapshot, Vec), String> { - validate_story_session_state_input(&input).map_err(|error| error.to_string())?; - - let session = ctx - .db - .story_session() - .story_session_id() - .find(&input.story_session_id) - .ok_or_else(|| "story_session 不存在".to_string())?; - - let session_snapshot = build_story_session_snapshot_from_row(&session); - let mut events = ctx - .db - .story_event() - .iter() - .filter(|row| row.story_session_id == input.story_session_id) - .map(|row| build_story_event_snapshot_from_row(&row)) - .collect::>(); - events.sort_by_key(|event| (event.created_at_micros, event.event_id.clone())); - - Ok((session_snapshot, events)) -} - -fn create_custom_world_agent_session_tx( - ctx: &ReducerContext, - input: CustomWorldAgentSessionCreateInput, -) -> Result { - validate_custom_world_agent_session_create_input(&input).map_err(|error| error.to_string())?; - - if ctx - .db - .custom_world_agent_session() - .session_id() - .find(&input.session_id) - .is_some() - { - return Err("custom_world_agent_session.session_id 已存在".to_string()); - } - if ctx - .db - .custom_world_agent_message() - .message_id() - .find(&input.welcome_message_id) - .is_some() - { - return Err("custom_world_agent_message.message_id 已存在".to_string()); - } - - let created_at = Timestamp::from_micros_since_unix_epoch(input.created_at_micros); - ctx.db - .custom_world_agent_session() - .insert(CustomWorldAgentSession { - session_id: input.session_id.clone(), - owner_user_id: input.owner_user_id.clone(), - seed_text: input.seed_text.trim().to_string(), - current_turn: 0, - progress_percent: 0, - stage: RpgAgentStage::CollectingIntent, - focus_card_id: None, - anchor_content_json: input.anchor_content_json.clone(), - creator_intent_json: input.creator_intent_json.clone(), - creator_intent_readiness_json: input.creator_intent_readiness_json.clone(), - anchor_pack_json: input.anchor_pack_json.clone(), - lock_state_json: input.lock_state_json.clone(), - draft_profile_json: input.draft_profile_json.clone(), - last_assistant_reply: Some(input.welcome_message_text.trim().to_string()), - publish_gate_json: None, - result_preview_json: None, - pending_clarifications_json: input.pending_clarifications_json.clone(), - quality_findings_json: input.quality_findings_json.clone(), - suggested_actions_json: input.suggested_actions_json.clone(), - recommended_replies_json: input.recommended_replies_json.clone(), - asset_coverage_json: input.asset_coverage_json.clone(), - checkpoints_json: input.checkpoints_json.clone(), - created_at, - updated_at: created_at, - }); - ctx.db - .custom_world_agent_message() - .insert(CustomWorldAgentMessage { - message_id: input.welcome_message_id, - session_id: input.session_id.clone(), - role: RpgAgentMessageRole::Assistant, - kind: RpgAgentMessageKind::Chat, - text: input.welcome_message_text.trim().to_string(), - related_operation_id: None, - created_at, - }); - - get_custom_world_agent_session_tx( - ctx, - CustomWorldAgentSessionGetInput { - session_id: input.session_id, - owner_user_id: input.owner_user_id, - }, - ) -} - -fn get_custom_world_agent_session_tx( - ctx: &ReducerContext, - input: CustomWorldAgentSessionGetInput, -) -> Result { - validate_custom_world_agent_session_get_input(&input).map_err(|error| error.to_string())?; - - let session = ctx - .db - .custom_world_agent_session() - .session_id() - .find(&input.session_id) - .filter(|row| row.owner_user_id == input.owner_user_id) - .ok_or_else(|| "custom_world_agent_session 不存在".to_string())?; - - Ok(build_custom_world_agent_session_snapshot(ctx, &session)) -} - -fn submit_custom_world_agent_message_tx( - ctx: &ReducerContext, - input: CustomWorldAgentMessageSubmitInput, -) -> Result { - validate_custom_world_agent_message_submit_input(&input).map_err(|error| error.to_string())?; - - if input.user_message_text.contains("__phase1_force_fail__") { - return Err("forced failure".to_string()); - } - - let session = ctx - .db - .custom_world_agent_session() - .session_id() - .find(&input.session_id) - .filter(|row| row.owner_user_id == input.owner_user_id) - .ok_or_else(|| "custom_world_agent_session 不存在".to_string())?; - - if ctx - .db - .custom_world_agent_message() - .message_id() - .find(&input.user_message_id) - .is_some() - { - return Err("custom_world_agent_message.message_id 已存在".to_string()); - } - if ctx - .db - .custom_world_agent_operation() - .operation_id() - .find(&input.operation_id) - .is_some() - { - return Err("custom_world_agent_operation.operation_id 已存在".to_string()); - } - - let submitted_at = Timestamp::from_micros_since_unix_epoch(input.submitted_at_micros); - let user_message_text = input.user_message_text.trim().to_string(); - let assistant_message_id = format!("assistant-{}", input.operation_id); - if ctx - .db - .custom_world_agent_message() - .message_id() - .find(&assistant_message_id) - .is_some() - { - return Err("custom_world_agent_message.assistant_message_id 已存在".to_string()); - } - - ctx.db - .custom_world_agent_message() - .insert(CustomWorldAgentMessage { - message_id: input.user_message_id, - session_id: input.session_id.clone(), - role: RpgAgentMessageRole::User, - kind: RpgAgentMessageKind::Chat, - text: user_message_text, - related_operation_id: Some(input.operation_id.clone()), - created_at: submitted_at, - }); - - let user_message_count = ctx - .db - .custom_world_agent_message() - .iter() - .filter(|row| { - row.session_id == input.session_id && matches!(row.role, RpgAgentMessageRole::User) - }) - .count() as u32; - let next_turn = session.current_turn.saturating_add(1); - let (next_stage, next_progress_percent, next_readiness_json, next_pending_clarifications_json) = - if user_message_count >= 2 { - ( - RpgAgentStage::FoundationReview, - 100, - r#"{"isReady":true,"completedKeys":["seed_input"],"missingKeys":[]}"#.to_string(), - "[]".to_string(), - ) - } else { - ( - RpgAgentStage::Clarifying, - session.progress_percent.max(20), - session.creator_intent_readiness_json.clone(), - format!( - r#"[{{"id":"clarify-{next_turn}","label":"补充核心设定","question":"请继续补充这个世界的玩家身份、主题氛围或核心冲突。","targetKey":"core_conflict","priority":1}}]"# - ), - ) - }; - let assistant_reply = "已记录这条设定。我会先把它当作新的世界线索收进当前草稿,你可以继续补充玩家身份、主题氛围、核心冲突、关键关系或标志性元素。".to_string(); - - ctx.db - .custom_world_agent_operation() - .insert(CustomWorldAgentOperation { - operation_id: input.operation_id.clone(), - session_id: input.session_id.clone(), - operation_type: RpgAgentOperationType::ProcessMessage, - status: RpgAgentOperationStatus::Completed, - phase_label: "消息已处理".to_string(), - phase_detail: if next_stage == RpgAgentStage::FoundationReview { - "当前上下文已达到最小 foundation_review 门槛。".to_string() - } else { - "当前上下文已记录,继续收集世界关键锚点。".to_string() - }, - progress: 100, - error_message: None, - created_at: submitted_at, - updated_at: submitted_at, - }); - - ctx.db - .custom_world_agent_message() - .insert(CustomWorldAgentMessage { - message_id: assistant_message_id, - session_id: input.session_id.clone(), - role: RpgAgentMessageRole::Assistant, - kind: RpgAgentMessageKind::Chat, - text: assistant_reply.clone(), - related_operation_id: Some(input.operation_id.clone()), - created_at: submitted_at, - }); - - ctx.db - .custom_world_agent_session() - .session_id() - .update(CustomWorldAgentSession { - session_id: session.session_id.clone(), - owner_user_id: session.owner_user_id.clone(), - seed_text: session.seed_text.clone(), - current_turn: next_turn, - progress_percent: next_progress_percent, - stage: next_stage, - focus_card_id: session.focus_card_id.clone(), - anchor_content_json: session.anchor_content_json.clone(), - creator_intent_json: session.creator_intent_json.clone(), - creator_intent_readiness_json: next_readiness_json, - anchor_pack_json: session.anchor_pack_json.clone(), - lock_state_json: session.lock_state_json.clone(), - draft_profile_json: session.draft_profile_json.clone(), - last_assistant_reply: Some(assistant_reply), - publish_gate_json: session.publish_gate_json.clone(), - result_preview_json: session.result_preview_json.clone(), - pending_clarifications_json: next_pending_clarifications_json, - quality_findings_json: session.quality_findings_json.clone(), - suggested_actions_json: session.suggested_actions_json.clone(), - recommended_replies_json: session.recommended_replies_json.clone(), - asset_coverage_json: session.asset_coverage_json.clone(), - checkpoints_json: session.checkpoints_json.clone(), - created_at: session.created_at, - updated_at: submitted_at, - }); - - get_custom_world_agent_operation_tx( - ctx, - CustomWorldAgentOperationGetInput { - session_id: input.session_id, - owner_user_id: input.owner_user_id, - operation_id: input.operation_id, - }, - ) -} - -fn get_custom_world_agent_operation_tx( - ctx: &ReducerContext, - input: CustomWorldAgentOperationGetInput, -) -> Result { - validate_custom_world_agent_operation_get_input(&input).map_err(|error| error.to_string())?; - - ctx.db - .custom_world_agent_session() - .session_id() - .find(&input.session_id) - .filter(|row| row.owner_user_id == input.owner_user_id) - .ok_or_else(|| "custom_world_agent_session 不存在".to_string())?; - - let operation = ctx - .db - .custom_world_agent_operation() - .operation_id() - .find(&input.operation_id) - .filter(|row| row.session_id == input.session_id) - .ok_or_else(|| "custom_world_agent_operation 不存在".to_string())?; - - Ok(build_custom_world_agent_operation_snapshot(&operation)) -} - -// AI 任务当前先固定成 private 真相表,后续由 Axum / platform-llm 再往外包一层 HTTP 与 SSE 协议。 -#[spacetimedb::reducer] -pub fn create_ai_task(ctx: &ReducerContext, input: AiTaskCreateInput) -> Result<(), String> { - create_ai_task_tx(ctx, input).map(|_| ()) -} - -#[spacetimedb::procedure] -pub fn create_ai_task_and_return( - ctx: &mut ProcedureContext, - input: AiTaskCreateInput, -) -> AiTaskProcedureResult { - match ctx.try_with_tx(|tx| create_ai_task_tx(tx, input.clone())) { - Ok(task) => AiTaskProcedureResult { - ok: true, - task: Some(task), - text_chunk: None, - error_message: None, - }, - Err(message) => AiTaskProcedureResult { - ok: false, - task: None, - text_chunk: None, - error_message: Some(message), - }, - } -} - -#[spacetimedb::reducer] -pub fn start_ai_task(ctx: &ReducerContext, input: AiTaskStartInput) -> Result<(), String> { - start_ai_task_tx(ctx, input).map(|_| ()) -} - -#[spacetimedb::reducer] -pub fn start_ai_task_stage( - ctx: &ReducerContext, - input: AiTaskStageStartInput, -) -> Result<(), String> { - start_ai_task_stage_tx(ctx, input).map(|_| ()) -} - -// 流式增量写入需要同步返回 chunk 与聚合后的任务快照,方便后续 Axum facade 直接复用。 -#[spacetimedb::procedure] -pub fn append_ai_text_chunk_and_return( - ctx: &mut ProcedureContext, - input: AiTextChunkAppendInput, -) -> AiTaskProcedureResult { - match ctx.try_with_tx(|tx| append_ai_text_chunk_tx(tx, input.clone())) { - Ok((task, text_chunk)) => AiTaskProcedureResult { - ok: true, - task: Some(task), - text_chunk: Some(text_chunk), - error_message: None, - }, - Err(message) => AiTaskProcedureResult { - ok: false, - task: None, - text_chunk: None, - error_message: Some(message), - }, - } -} - -#[spacetimedb::procedure] -pub fn complete_ai_stage_and_return( - ctx: &mut ProcedureContext, - input: AiStageCompletionInput, -) -> AiTaskProcedureResult { - match ctx.try_with_tx(|tx| complete_ai_stage_tx(tx, input.clone())) { - Ok(task) => AiTaskProcedureResult { - ok: true, - task: Some(task), - text_chunk: None, - error_message: None, - }, - Err(message) => AiTaskProcedureResult { - ok: false, - task: None, - text_chunk: None, - error_message: Some(message), - }, - } -} - -#[spacetimedb::procedure] -pub fn attach_ai_result_reference_and_return( - ctx: &mut ProcedureContext, - input: AiResultReferenceInput, -) -> AiTaskProcedureResult { - match ctx.try_with_tx(|tx| attach_ai_result_reference_tx(tx, input.clone())) { - Ok(task) => AiTaskProcedureResult { - ok: true, - task: Some(task), - text_chunk: None, - error_message: None, - }, - Err(message) => AiTaskProcedureResult { - ok: false, - task: None, - text_chunk: None, - error_message: Some(message), - }, - } -} - -#[spacetimedb::procedure] -pub fn complete_ai_task_and_return( - ctx: &mut ProcedureContext, - input: AiTaskFinishInput, -) -> AiTaskProcedureResult { - match ctx.try_with_tx(|tx| complete_ai_task_tx(tx, input.clone())) { - Ok(task) => AiTaskProcedureResult { - ok: true, - task: Some(task), - text_chunk: None, - error_message: None, - }, - Err(message) => AiTaskProcedureResult { - ok: false, - task: None, - text_chunk: None, - error_message: Some(message), - }, - } -} - -#[spacetimedb::procedure] -pub fn fail_ai_task_and_return( - ctx: &mut ProcedureContext, - input: AiTaskFailureInput, -) -> AiTaskProcedureResult { - match ctx.try_with_tx(|tx| fail_ai_task_tx(tx, input.clone())) { - Ok(task) => AiTaskProcedureResult { - ok: true, - task: Some(task), - text_chunk: None, - error_message: None, - }, - Err(message) => AiTaskProcedureResult { - ok: false, - task: None, - text_chunk: None, - error_message: Some(message), - }, - } -} - -#[spacetimedb::procedure] -pub fn cancel_ai_task_and_return( - ctx: &mut ProcedureContext, - input: AiTaskCancelInput, -) -> AiTaskProcedureResult { - match ctx.try_with_tx(|tx| cancel_ai_task_tx(tx, input.clone())) { - Ok(task) => AiTaskProcedureResult { - ok: true, - task: Some(task), - text_chunk: None, - error_message: None, - }, - Err(message) => AiTaskProcedureResult { - ok: false, - task: None, - text_chunk: None, - error_message: Some(message), - }, - } -} - -// 当前阶段先把 quest_record / quest_log 立成最小任务真相源,后续再把奖励结算和 story action 总分发接进来。 -#[spacetimedb::reducer] -pub fn accept_quest(ctx: &ReducerContext, input: QuestRecordInput) -> Result<(), String> { - let snapshot = build_quest_record_snapshot(input).map_err(|error| error.to_string())?; - - if ctx - .db - .quest_record() - .quest_id() - .find(&snapshot.quest_id) - .is_some() - { - return Err("quest_record.quest_id 已存在".to_string()); - } - - ctx.db - .quest_record() - .insert(build_quest_record_row(snapshot.clone())); - append_quest_log( - ctx, - &snapshot, - QuestLogEventKind::Accepted, - None, - None, - None, - None, - snapshot.created_at_micros, - ); - - Ok(()) -} - -// 任务推进 reducer 只认 QuestProgressSignal,不直接掺入背包、成长和关系奖励发放。 -#[spacetimedb::reducer] -pub fn apply_quest_signal( - ctx: &ReducerContext, - input: QuestSignalApplyInput, -) -> Result<(), String> { - let signal = input.signal.clone(); - let current = ctx - .db - .quest_record() - .quest_id() - .find(&input.quest_id) - .ok_or_else(|| "quest_record 不存在,无法应用任务信号".to_string())?; - let outcome = apply_quest_record_signal(build_quest_record_snapshot_from_row(¤t), input) - .map_err(|error| error.to_string())?; - - if !outcome.changed { - return Ok(()); - } - - ctx.db.quest_record().quest_id().delete(¤t.quest_id); - ctx.db - .quest_record() - .insert(build_quest_record_row(outcome.next_record.clone())); - append_quest_log( - ctx, - &outcome.next_record, - if outcome.completed_now { - QuestLogEventKind::Completed - } else { - QuestLogEventKind::Progressed - }, - Some(outcome.signal_kind), - Some(signal), - outcome.changed_step_id, - outcome.changed_step_progress, - outcome.next_record.updated_at_micros, - ); - - Ok(()) -} - -#[spacetimedb::reducer] -pub fn acknowledge_quest_completion( - ctx: &ReducerContext, - input: QuestCompletionAckInput, -) -> Result<(), String> { - let current = ctx - .db - .quest_record() - .quest_id() - .find(&input.quest_id) - .ok_or_else(|| "quest_record 不存在,无法确认完成提示".to_string())?; - let outcome = - acknowledge_quest_record_completion(build_quest_record_snapshot_from_row(¤t), input) - .map_err(|error| error.to_string())?; - - if !outcome.changed { - return Ok(()); - } - - ctx.db.quest_record().quest_id().delete(¤t.quest_id); - ctx.db - .quest_record() - .insert(build_quest_record_row(outcome.next_record.clone())); - append_quest_log( - ctx, - &outcome.next_record, - QuestLogEventKind::CompletionAcknowledged, - None, - None, - None, - None, - outcome.next_record.updated_at_micros, - ); - - Ok(()) -} - -#[spacetimedb::reducer] -pub fn turn_in_quest(ctx: &ReducerContext, input: QuestTurnInInput) -> Result<(), String> { - let current = ctx - .db - .quest_record() - .quest_id() - .find(&input.quest_id) - .ok_or_else(|| "quest_record 不存在,无法交付任务".to_string())?; - let next = turn_in_quest_record(build_quest_record_snapshot_from_row(¤t), input) - .map_err(|error| error.to_string())?; - - ctx.db.quest_record().quest_id().delete(¤t.quest_id); - ctx.db - .quest_record() - .insert(build_quest_record_row(next.clone())); - append_quest_log( - ctx, - &next, - QuestLogEventKind::TurnedIn, - None, - None, - None, - None, - next.updated_at_micros, - ); - - let reward_experience = next.reward.experience.unwrap_or(0); - grant_quest_reward_items(ctx, &next)?; - if reward_experience > 0 { - let updated_player = upsert_player_progression_after_grant_tx( - ctx, - PlayerProgressionGrantInput { - user_id: next.actor_user_id.clone(), - amount: reward_experience, - source: PlayerProgressionGrantSource::Quest, - updated_at_micros: next.updated_at_micros, - }, - )?; - - // 章节计划缺失时先保持任务交付成功,避免成长联动反向阻断 quest 主链。 - try_update_chapter_progression_ledger_tx( - ctx, - next.actor_user_id.clone(), - next.chapter_id.clone(), - ChapterProgressionLedgerInput { - user_id: next.actor_user_id.clone(), - chapter_id: next.chapter_id.clone().unwrap_or_default(), - granted_quest_xp: reward_experience, - granted_hostile_xp: 0, - hostile_defeat_increment: 0, - level_at_exit: Some(updated_player.level), - updated_at_micros: next.updated_at_micros, - }, - )?; - } - - Ok(()) -} - -// reducer 负责固定资产对象的正式写规则,供后续内部模块逻辑复用。 -#[spacetimedb::reducer] -pub fn confirm_asset_object( - ctx: &ReducerContext, - input: AssetObjectUpsertInput, -) -> Result<(), String> { - upsert_asset_object(ctx, input).map(|_| ()) -} - -// procedure 面向 Axum 同步确认接口,返回最终持久化后的对象记录,避免 HTTP 层再额外查询 private table。 -#[spacetimedb::procedure] -pub fn confirm_asset_object_and_return( - ctx: &mut ProcedureContext, - input: AssetObjectUpsertInput, -) -> AssetObjectProcedureResult { - match ctx.try_with_tx(|tx| upsert_asset_object(tx, input.clone())) { - Ok(record) => AssetObjectProcedureResult { - ok: true, - record: Some(record), - error_message: None, - }, - Err(message) => AssetObjectProcedureResult { - ok: false, - record: None, - error_message: Some(message), - }, - } -} - -// reducer 负责把已确认对象绑定到实体槽位,强业务资产表稳定前先用通用绑定表承接关系。 -#[spacetimedb::reducer] -pub fn bind_asset_object_to_entity( - ctx: &ReducerContext, - input: AssetEntityBindingInput, -) -> Result<(), String> { - upsert_asset_entity_binding(ctx, input).map(|_| ()) -} - -// procedure 面向 Axum 同步绑定接口,返回最终绑定快照,避免 HTTP 层读取 private table。 -#[spacetimedb::procedure] -pub fn bind_asset_object_to_entity_and_return( - ctx: &mut ProcedureContext, - input: AssetEntityBindingInput, -) -> AssetEntityBindingProcedureResult { - match ctx.try_with_tx(|tx| upsert_asset_entity_binding(tx, input.clone())) { - Ok(record) => AssetEntityBindingProcedureResult { - ok: true, - record: Some(record), - error_message: None, - }, - Err(message) => AssetEntityBindingProcedureResult { - ok: false, - record: None, - error_message: Some(message), - }, - } -} - -// procedure 面向 Axum 同步读取设置;若没有持久化记录则返回默认值快照,但不产生额外写入。 -#[spacetimedb::procedure] -pub fn get_runtime_setting_or_default( - ctx: &mut ProcedureContext, - input: RuntimeSettingGetInput, -) -> RuntimeSettingProcedureResult { - match ctx.try_with_tx(|tx| get_runtime_setting_snapshot(tx, input.clone())) { - Ok(record) => RuntimeSettingProcedureResult { - ok: true, - record: Some(record), - error_message: None, - }, - Err(message) => RuntimeSettingProcedureResult { - ok: false, - record: None, - error_message: Some(message), - }, - } -} - -// 当前快照读取保持旧 Node 语义:无快照时返回 ok=true + record=None,而不是默认空对象。 -#[spacetimedb::procedure] -pub fn get_runtime_snapshot( - ctx: &mut ProcedureContext, - input: RuntimeSnapshotGetInput, -) -> RuntimeSnapshotProcedureResult { - match ctx.try_with_tx(|tx| get_runtime_snapshot_record(tx, input.clone())) { - Ok(record) => RuntimeSnapshotProcedureResult { - ok: true, - record, - error_message: None, - }, - Err(message) => RuntimeSnapshotProcedureResult { - ok: false, - record: None, - error_message: Some(message), - }, - } -} - -// PUT snapshot 主链会同步刷新 dashboard / wallet / played_world / save_archive 四类 projection。 -#[spacetimedb::procedure] -pub fn upsert_runtime_snapshot_and_return( - ctx: &mut ProcedureContext, - input: RuntimeSnapshotUpsertInput, -) -> RuntimeSnapshotProcedureResult { - match ctx.try_with_tx(|tx| upsert_runtime_snapshot_record(tx, input.clone())) { - Ok(record) => RuntimeSnapshotProcedureResult { - ok: true, - record: Some(record), - error_message: None, - }, - Err(message) => RuntimeSnapshotProcedureResult { - ok: false, - record: None, - error_message: Some(message), - }, - } -} - -// 删除当前快照只影响 runtime_snapshot 主表,不联动清理 profile projection。 -#[spacetimedb::procedure] -pub fn delete_runtime_snapshot_and_return( - ctx: &mut ProcedureContext, - input: RuntimeSnapshotDeleteInput, -) -> RuntimeSnapshotProcedureResult { - match ctx.try_with_tx(|tx| delete_runtime_snapshot_record(tx, input.clone())) { - Ok(record) => RuntimeSnapshotProcedureResult { - ok: true, - record, - error_message: None, - }, - Err(message) => RuntimeSnapshotProcedureResult { - ok: false, - record: None, - error_message: Some(message), - }, - } -} - -// procedure 面向 Axum 同步写入设置,并返回最终归一化后的持久化结果。 -#[spacetimedb::procedure] -pub fn upsert_runtime_setting_and_return( - ctx: &mut ProcedureContext, - input: RuntimeSettingUpsertInput, -) -> RuntimeSettingProcedureResult { - match ctx.try_with_tx(|tx| upsert_runtime_setting(tx, input.clone())) { - Ok(record) => RuntimeSettingProcedureResult { - ok: true, - record: Some(record), - error_message: None, - }, - Err(message) => RuntimeSettingProcedureResult { - ok: false, - record: None, - error_message: Some(message), - }, - } -} - -// save archive 列表是按世界聚合后的最近一次快照视图,读取时只做排序,不再拼装默认值。 -#[spacetimedb::procedure] -pub fn list_profile_save_archives( - ctx: &mut ProcedureContext, - input: RuntimeProfileSaveArchiveListInput, -) -> RuntimeProfileSaveArchiveProcedureResult { - match ctx.try_with_tx(|tx| list_profile_save_archive_rows(tx, input.clone())) { - Ok(entries) => RuntimeProfileSaveArchiveProcedureResult { - ok: true, - entries, - record: None, - current_snapshot: None, - error_message: None, - }, - Err(message) => RuntimeProfileSaveArchiveProcedureResult { - ok: false, - entries: Vec::new(), - record: None, - current_snapshot: None, - error_message: Some(message), - }, - } -} - -// resume 会把指定 archive 回填到当前 snapshot,并同步返回 entry + 当前 snapshot。 -#[spacetimedb::procedure] -pub fn resume_profile_save_archive_and_return( - ctx: &mut ProcedureContext, - input: RuntimeProfileSaveArchiveResumeInput, -) -> RuntimeProfileSaveArchiveProcedureResult { - match ctx.try_with_tx(|tx| resume_profile_save_archive_record(tx, input.clone())) { - Ok((record, current_snapshot)) => RuntimeProfileSaveArchiveProcedureResult { - ok: true, - entries: Vec::new(), - record: Some(record), - current_snapshot: Some(current_snapshot), - error_message: None, - }, - Err(message) => RuntimeProfileSaveArchiveProcedureResult { - ok: false, - entries: Vec::new(), - record: None, - current_snapshot: None, - error_message: Some(message), - }, - } -} - -// profile dashboard 当前先作为 projection 读入口返回默认零值,等待 runtime_snapshot 写链补齐刷新。 -#[spacetimedb::procedure] -pub fn get_profile_dashboard( - ctx: &mut ProcedureContext, - input: RuntimeProfileDashboardGetInput, -) -> RuntimeProfileDashboardProcedureResult { - match ctx.try_with_tx(|tx| get_profile_dashboard_snapshot(tx, input.clone())) { - Ok(record) => RuntimeProfileDashboardProcedureResult { - ok: true, - record: Some(record), - error_message: None, - }, - Err(message) => RuntimeProfileDashboardProcedureResult { - ok: false, - record: None, - error_message: Some(message), - }, - } -} - -// 钱包流水当前只暴露最近 50 条只读视图,排序与截断逻辑在 procedure 内统一收口。 -#[spacetimedb::procedure] -pub fn list_profile_wallet_ledger( - ctx: &mut ProcedureContext, - input: RuntimeProfileWalletLedgerListInput, -) -> RuntimeProfileWalletLedgerProcedureResult { - match ctx.try_with_tx(|tx| list_profile_wallet_ledger_entries(tx, input.clone())) { - Ok(entries) => RuntimeProfileWalletLedgerProcedureResult { - ok: true, - entries, - error_message: None, - }, - Err(message) => RuntimeProfileWalletLedgerProcedureResult { - ok: false, - entries: Vec::new(), - error_message: Some(message), - }, - } -} - -// play stats 与 dashboard 共用 dashboard projection 的 total_play_time / updated_at,避免 Axum 侧拼装。 -#[spacetimedb::procedure] -pub fn get_profile_play_stats( - ctx: &mut ProcedureContext, - input: RuntimeProfilePlayStatsGetInput, -) -> RuntimeProfilePlayStatsProcedureResult { - match ctx.try_with_tx(|tx| get_profile_play_stats_snapshot(tx, input.clone())) { - Ok(record) => RuntimeProfilePlayStatsProcedureResult { - ok: true, - record: Some(record), - error_message: None, - }, - Err(message) => RuntimeProfilePlayStatsProcedureResult { - ok: false, - record: None, - error_message: Some(message), - }, - } -} - -// M5 Stage 2 先把 library profile upsert 固定成最小正式写入口;已发布作品在这里同步刷新 gallery 投影。 -#[spacetimedb::reducer] -pub fn upsert_custom_world_profile( - ctx: &ReducerContext, - input: CustomWorldProfileUpsertInput, -) -> Result<(), String> { - upsert_custom_world_profile_record(ctx, input).map(|_| ()) -} - -// procedure 面向 Axum 返回 profile 与可能同步出的 gallery 投影,避免 HTTP 层再二次查询私有表。 -#[spacetimedb::procedure] -pub fn upsert_custom_world_profile_and_return( - ctx: &mut ProcedureContext, - input: CustomWorldProfileUpsertInput, -) -> CustomWorldLibraryMutationResult { - match ctx.try_with_tx(|tx| upsert_custom_world_profile_record(tx, input.clone())) { - Ok((entry, gallery_entry)) => CustomWorldLibraryMutationResult { - ok: true, - entry: Some(entry), - gallery_entry, - error_message: None, - }, - Err(message) => CustomWorldLibraryMutationResult { - ok: false, - entry: None, - gallery_entry: None, - error_message: Some(message), - }, - } -} - -// publish 负责同时推进 profile 发布态与 gallery 公开投影,避免公开列表继续运行时拼装。 -#[spacetimedb::reducer] -pub fn publish_custom_world_profile( - ctx: &ReducerContext, - input: CustomWorldProfilePublishInput, -) -> Result<(), String> { - publish_custom_world_profile_record(ctx, input).map(|_| ()) -} - -#[spacetimedb::procedure] -pub fn publish_custom_world_profile_and_return( - ctx: &mut ProcedureContext, - input: CustomWorldProfilePublishInput, -) -> CustomWorldLibraryMutationResult { - match ctx.try_with_tx(|tx| publish_custom_world_profile_record(tx, input.clone())) { - Ok((entry, gallery_entry)) => CustomWorldLibraryMutationResult { - ok: true, - entry: Some(entry), - gallery_entry, - error_message: None, - }, - Err(message) => CustomWorldLibraryMutationResult { - ok: false, - entry: None, - gallery_entry: None, - error_message: Some(message), - }, - } -} - -// unpublish 负责撤掉 gallery 投影,并把 profile 恢复为 draft。 -#[spacetimedb::reducer] -pub fn unpublish_custom_world_profile( - ctx: &ReducerContext, - input: CustomWorldProfileUnpublishInput, -) -> Result<(), String> { - unpublish_custom_world_profile_record(ctx, input).map(|_| ()) -} - -#[spacetimedb::procedure] -pub fn unpublish_custom_world_profile_and_return( - ctx: &mut ProcedureContext, - input: CustomWorldProfileUnpublishInput, -) -> CustomWorldLibraryMutationResult { - match ctx.try_with_tx(|tx| unpublish_custom_world_profile_record(tx, input.clone())) { - Ok((entry, gallery_entry)) => CustomWorldLibraryMutationResult { - ok: true, - entry: Some(entry), - gallery_entry, - error_message: None, - }, - Err(message) => CustomWorldLibraryMutationResult { - ok: false, - entry: None, - gallery_entry: None, - error_message: Some(message), - }, - } -} - -#[spacetimedb::procedure] -pub fn list_custom_world_profiles( - ctx: &mut ProcedureContext, - input: CustomWorldProfileListInput, -) -> CustomWorldProfileListResult { - match ctx.try_with_tx(|tx| list_custom_world_profile_snapshots(tx, input.clone())) { - Ok(entries) => CustomWorldProfileListResult { - ok: true, - entries, - error_message: None, - }, - Err(message) => CustomWorldProfileListResult { - ok: false, - entries: Vec::new(), - error_message: Some(message), - }, - } -} - -#[spacetimedb::procedure] -pub fn list_custom_world_gallery_entries( - ctx: &mut ProcedureContext, -) -> CustomWorldGalleryListResult { - match ctx.try_with_tx(|tx| Ok::<_, String>(list_custom_world_gallery_snapshots(tx))) { - Ok(entries) => CustomWorldGalleryListResult { - ok: true, - entries, - error_message: None, - }, - Err(message) => CustomWorldGalleryListResult { - ok: false, - entries: Vec::new(), - error_message: Some(message), - }, - } -} - -#[spacetimedb::procedure] -pub fn get_custom_world_library_detail( - ctx: &mut ProcedureContext, - input: CustomWorldLibraryDetailInput, -) -> CustomWorldLibraryMutationResult { - match ctx.try_with_tx(|tx| get_custom_world_library_detail_record(tx, input.clone())) { - Ok((entry, gallery_entry)) => CustomWorldLibraryMutationResult { - ok: true, - entry, - gallery_entry, - error_message: None, - }, - Err(message) => CustomWorldLibraryMutationResult { - ok: false, - entry: None, - gallery_entry: None, - error_message: Some(message), - }, - } -} - -#[spacetimedb::procedure] -pub fn get_custom_world_gallery_detail( - ctx: &mut ProcedureContext, - input: CustomWorldGalleryDetailInput, -) -> CustomWorldLibraryMutationResult { - match ctx.try_with_tx(|tx| get_custom_world_gallery_detail_record(tx, input.clone())) { - Ok((entry, gallery_entry)) => CustomWorldLibraryMutationResult { - ok: true, - entry, - gallery_entry, - error_message: None, - }, - Err(message) => CustomWorldLibraryMutationResult { - ok: false, - entry: None, - gallery_entry: None, - error_message: Some(message), - }, - } -} - -#[spacetimedb::procedure] -pub fn list_custom_world_works( - ctx: &mut ProcedureContext, - input: CustomWorldWorksListInput, -) -> CustomWorldWorksListResult { - match ctx.try_with_tx(|tx| list_custom_world_work_snapshots(tx, input.clone())) { - Ok(items) => CustomWorldWorksListResult { - ok: true, - items, - error_message: None, - }, - Err(message) => CustomWorldWorksListResult { - ok: false, - items: Vec::new(), - error_message: Some(message), - }, - } -} - -#[spacetimedb::procedure] -pub fn get_custom_world_agent_card_detail( - ctx: &mut ProcedureContext, - input: CustomWorldAgentCardDetailGetInput, -) -> CustomWorldDraftCardDetailResult { - match ctx.try_with_tx(|tx| get_custom_world_agent_card_detail_tx(tx, input.clone())) { - Ok(card) => CustomWorldDraftCardDetailResult { - ok: true, - card: Some(card), - error_message: None, - }, - Err(message) => CustomWorldDraftCardDetailResult { - ok: false, - card: None, - error_message: Some(message), - }, - } -} - -#[spacetimedb::procedure] -pub fn execute_custom_world_agent_action( - ctx: &mut ProcedureContext, - input: CustomWorldAgentActionExecuteInput, -) -> CustomWorldAgentActionExecuteResult { - match ctx.try_with_tx(|tx| execute_custom_world_agent_action_tx(tx, input.clone())) { - Ok(operation) => CustomWorldAgentActionExecuteResult { - ok: true, - operation: Some(operation), - error_message: None, - }, - Err(message) => CustomWorldAgentActionExecuteResult { - ok: false, - operation: None, - error_message: Some(message), - }, - } -} - -// procedure 面向 Axum 同步拉取浏览历史,继续沿用旧 Node 的 visitedAt 倒序输出语义。 -#[spacetimedb::procedure] -pub fn list_platform_browse_history( - ctx: &mut ProcedureContext, - input: RuntimeBrowseHistoryListInput, -) -> RuntimeBrowseHistoryProcedureResult { - match ctx.try_with_tx(|tx| list_platform_browse_history_rows(tx, input.clone())) { - Ok(entries) => RuntimeBrowseHistoryProcedureResult { - ok: true, - entries, - error_message: None, - }, - Err(message) => RuntimeBrowseHistoryProcedureResult { - ok: false, - entries: Vec::new(), - error_message: Some(message), - }, - } -} - -// procedure 面向 Axum 承接 browse history 的单条/批量 POST,同步返回当前用户的完整列表。 -#[spacetimedb::procedure] -pub fn upsert_platform_browse_history_and_return( - ctx: &mut ProcedureContext, - input: RuntimeBrowseHistorySyncInput, -) -> RuntimeBrowseHistoryProcedureResult { - match ctx.try_with_tx(|tx| upsert_platform_browse_history_rows(tx, input.clone())) { - Ok(entries) => RuntimeBrowseHistoryProcedureResult { - ok: true, - entries, - error_message: None, - }, - Err(message) => RuntimeBrowseHistoryProcedureResult { - ok: false, - entries: Vec::new(), - error_message: Some(message), - }, - } -} - -// procedure 面向 Axum 清空当前用户浏览历史,并直接返回空列表响应。 -#[spacetimedb::procedure] -pub fn clear_platform_browse_history_and_return( - ctx: &mut ProcedureContext, - input: RuntimeBrowseHistoryClearInput, -) -> RuntimeBrowseHistoryProcedureResult { - match ctx.try_with_tx(|tx| clear_platform_browse_history_rows(tx, input.clone())) { - Ok(entries) => RuntimeBrowseHistoryProcedureResult { - ok: true, - entries, - error_message: None, - }, - Err(message) => RuntimeBrowseHistoryProcedureResult { - ok: false, - entries: Vec::new(), - error_message: Some(message), - }, - } -} - -// Stage 3 先把 published profile compile 作为独立 procedure 暴露,避免把编译逻辑和表写入、发布动作强耦合。 -#[spacetimedb::procedure] -pub fn compile_custom_world_published_profile( - _ctx: &mut ProcedureContext, - input: CustomWorldPublishedProfileCompileInput, -) -> CustomWorldPublishedProfileCompileResult { - match build_custom_world_published_profile_compile_snapshot(input) { - Ok(record) => CustomWorldPublishedProfileCompileResult { - ok: true, - record: Some(record), - error_message: None, - }, - Err(error) => CustomWorldPublishedProfileCompileResult { - ok: false, - record: None, - error_message: Some(error.to_string()), - }, - } -} - -// Stage 4 把 publish_world 串成单事务主链:compile -> profile upsert -> profile publish -> session.stage 推进。 -#[spacetimedb::procedure] -pub fn publish_custom_world_world( - ctx: &mut ProcedureContext, - input: CustomWorldPublishWorldInput, -) -> CustomWorldPublishWorldResult { - match ctx.try_with_tx(|tx| publish_custom_world_world_record(tx, input.clone())) { - Ok((compiled_record, entry, gallery_entry, session_stage)) => { - CustomWorldPublishWorldResult { - ok: true, - compiled_record: Some(compiled_record), - entry: Some(entry), - gallery_entry, - session_stage: Some(session_stage), - error_message: None, - } - } - Err(message) => CustomWorldPublishWorldResult { - ok: false, - compiled_record: None, - entry: None, - gallery_entry: None, - session_stage: None, - error_message: Some(message), - }, - } -} - -// M4 首轮先把 treasure_record 固定成可审计的宝藏结算真相表,奖励写入与 story 归属关系由 reducer 显式校验。 -#[spacetimedb::reducer] -pub fn resolve_treasure_interaction( - ctx: &ReducerContext, - input: TreasureResolveInput, -) -> Result<(), String> { - upsert_treasure_record(ctx, input).map(|_| ()) -} - -// procedure 面向后续 Axum facade,同步返回最终 treasure_record 快照,避免 HTTP 层再额外读取私有表。 -#[spacetimedb::procedure] -pub fn resolve_treasure_interaction_and_return( - ctx: &mut ProcedureContext, - input: TreasureResolveInput, -) -> TreasureRecordProcedureResult { - match ctx.try_with_tx(|tx| upsert_treasure_record(tx, input.clone())) { - Ok(record) => TreasureRecordProcedureResult { - ok: true, - record: Some(record), - error_message: None, - }, - Err(message) => TreasureRecordProcedureResult { - ok: false, - record: None, - error_message: Some(message), - }, - } -} - -fn upsert_treasure_record( - ctx: &ReducerContext, - input: TreasureResolveInput, -) -> Result { - let snapshot = build_treasure_record_snapshot(input).map_err(|error| error.to_string())?; - let story_session = ctx - .db - .story_session() - .story_session_id() - .find(&snapshot.story_session_id) - .ok_or_else(|| { - "treasure_record.story_session_id 对应的 story_session 不存在".to_string() - })?; - - if story_session.runtime_session_id != snapshot.runtime_session_id { - return Err( - "treasure_record.runtime_session_id 必须与 story_session.runtime_session_id 一致" - .to_string(), - ); - } - - if story_session.actor_user_id != snapshot.actor_user_id { - return Err( - "treasure_record.actor_user_id 必须与 story_session.actor_user_id 一致".to_string(), - ); - } - - // treasure_record 首版按单次结算真相处理:同 id 重放直接返回已落库快照,避免记录更新和重复发奖脱节。 - if let Some(existing) = ctx - .db - .treasure_record() - .treasure_record_id() - .find(&snapshot.treasure_record_id) - { - return Ok(build_treasure_record_snapshot_from_row(&existing)); - } - - let updated_at = Timestamp::from_micros_since_unix_epoch(snapshot.updated_at_micros); - let created_at = Timestamp::from_micros_since_unix_epoch(snapshot.created_at_micros); - - ctx.db - .treasure_record() - .insert(build_treasure_record_row(&snapshot, created_at, updated_at)); - - grant_treasure_reward_items_to_inventory(ctx, &snapshot)?; - - Ok(snapshot) -} - -fn grant_treasure_reward_items_to_inventory( - ctx: &ReducerContext, - snapshot: &TreasureRecordSnapshot, -) -> Result<(), String> { - for (index, reward_item) in snapshot.reward_items.iter().cloned().enumerate() { - let inventory_item = build_inventory_item_snapshot_from_reward_item( - &snapshot.treasure_record_id, - reward_item, - ) - .map_err(|error| error.to_string())?; - let slot_id = build_treasure_inventory_slot_id(&snapshot.treasure_record_id, index); - let mutation_id = build_treasure_inventory_mutation_id(&snapshot.treasure_record_id, index); - - apply_inventory_mutation_tx( - ctx, - InventoryMutationInput { - mutation_id, - runtime_session_id: snapshot.runtime_session_id.clone(), - story_session_id: Some(snapshot.story_session_id.clone()), - actor_user_id: snapshot.actor_user_id.clone(), - mutation: InventoryMutation::GrantItem(module_inventory::GrantInventoryItemInput { - slot_id, - item: inventory_item, - }), - updated_at_micros: snapshot.updated_at_micros, - }, - )?; - } - - Ok(()) -} - -fn build_treasure_inventory_slot_id(treasure_record_id: &str, reward_index: usize) -> String { - format!( - "{}{}_{}", - INVENTORY_SLOT_ID_PREFIX, treasure_record_id, reward_index - ) -} - -fn build_treasure_inventory_mutation_id(treasure_record_id: &str, reward_index: usize) -> String { - format!( - "{}{}_{}", - INVENTORY_MUTATION_ID_PREFIX, treasure_record_id, reward_index - ) -} - -fn build_treasure_record_row( - snapshot: &TreasureRecordSnapshot, - created_at: Timestamp, - updated_at: Timestamp, -) -> TreasureRecord { - TreasureRecord { - treasure_record_id: snapshot.treasure_record_id.clone(), - runtime_session_id: snapshot.runtime_session_id.clone(), - story_session_id: snapshot.story_session_id.clone(), - actor_user_id: snapshot.actor_user_id.clone(), - encounter_id: snapshot.encounter_id.clone(), - encounter_name: snapshot.encounter_name.clone(), - scene_id: snapshot.scene_id.clone(), - scene_name: snapshot.scene_name.clone(), - action: snapshot.action, - reward_items: snapshot.reward_items.clone(), - reward_hp: snapshot.reward_hp, - reward_mana: snapshot.reward_mana, - reward_currency: snapshot.reward_currency, - story_hint: snapshot.story_hint.clone(), - created_at, - updated_at, - } -} - -fn upsert_asset_object( - ctx: &ReducerContext, - input: AssetObjectUpsertInput, -) -> Result { - validate_asset_object_fields( - &input.bucket, - &input.object_key, - &input.asset_kind, - input.version, - ) - .map_err(|error| error.to_string())?; - - let updated_at = Timestamp::from_micros_since_unix_epoch(input.updated_at_micros); - // 这里先保持最小可发布实现:查重语义已经冻结,后续再把实现优化回组合索引扫描。 - let current = ctx - .db - .asset_object() - .iter() - .find(|row| row.bucket == input.bucket && row.object_key == input.object_key); - - let snapshot = match current { - Some(existing) => { - ctx.db - .asset_object() - .asset_object_id() - .delete(&existing.asset_object_id); - let row = AssetObject { - asset_object_id: existing.asset_object_id.clone(), - bucket: input.bucket.clone(), - object_key: input.object_key.clone(), - access_policy: input.access_policy, - content_type: input.content_type.clone(), - content_length: input.content_length, - content_hash: input.content_hash.clone(), - version: input.version, - source_job_id: input.source_job_id.clone(), - owner_user_id: input.owner_user_id.clone(), - profile_id: input.profile_id.clone(), - entity_id: input.entity_id.clone(), - asset_kind: input.asset_kind.clone(), - created_at: existing.created_at, - updated_at, - }; - ctx.db.asset_object().insert(row); - - AssetObjectUpsertSnapshot { - asset_object_id: existing.asset_object_id, - bucket: input.bucket, - object_key: input.object_key, - access_policy: input.access_policy, - content_type: input.content_type, - content_length: input.content_length, - content_hash: input.content_hash, - version: input.version, - source_job_id: input.source_job_id, - owner_user_id: input.owner_user_id, - profile_id: input.profile_id, - entity_id: input.entity_id, - asset_kind: input.asset_kind, - created_at_micros: existing.created_at.to_micros_since_unix_epoch(), - updated_at_micros: input.updated_at_micros, - } - } - None => { - let created_at = updated_at; - let row = AssetObject { - asset_object_id: input.asset_object_id.clone(), - bucket: input.bucket.clone(), - object_key: input.object_key.clone(), - access_policy: input.access_policy, - content_type: input.content_type.clone(), - content_length: input.content_length, - content_hash: input.content_hash.clone(), - version: input.version, - source_job_id: input.source_job_id.clone(), - owner_user_id: input.owner_user_id.clone(), - profile_id: input.profile_id.clone(), - entity_id: input.entity_id.clone(), - asset_kind: input.asset_kind.clone(), - created_at, - updated_at, - }; - ctx.db.asset_object().insert(row); - - AssetObjectUpsertSnapshot { - asset_object_id: input.asset_object_id, - bucket: input.bucket, - object_key: input.object_key, - access_policy: input.access_policy, - content_type: input.content_type, - content_length: input.content_length, - content_hash: input.content_hash, - version: input.version, - source_job_id: input.source_job_id, - owner_user_id: input.owner_user_id, - profile_id: input.profile_id, - entity_id: input.entity_id, - asset_kind: input.asset_kind, - created_at_micros: input.updated_at_micros, - updated_at_micros: input.updated_at_micros, - } - } - }; - - Ok(snapshot) -} - -fn upsert_custom_world_profile_record( - ctx: &ReducerContext, - input: CustomWorldProfileUpsertInput, -) -> Result< - ( - CustomWorldProfileSnapshot, - Option, - ), - String, -> { - validate_custom_world_profile_upsert_input(&input).map_err(|error| error.to_string())?; - - let updated_at = Timestamp::from_micros_since_unix_epoch(input.updated_at_micros); - let current = ctx - .db - .custom_world_profile() - .profile_id() - .find(&input.profile_id) - .filter(|row| row.owner_user_id == input.owner_user_id); - - let next_row = match current { - Some(existing) => { - ctx.db - .custom_world_profile() - .profile_id() - .delete(&existing.profile_id); - CustomWorldProfile { - profile_id: existing.profile_id.clone(), - owner_user_id: existing.owner_user_id.clone(), - source_agent_session_id: input.source_agent_session_id.clone(), - publication_status: existing.publication_status, - world_name: input.world_name.clone(), - subtitle: input.subtitle.clone(), - summary_text: input.summary_text.clone(), - theme_mode: input.theme_mode, - cover_image_src: input.cover_image_src.clone(), - profile_payload_json: input.profile_payload_json.clone(), - playable_npc_count: input.playable_npc_count, - landmark_count: input.landmark_count, - author_display_name: input.author_display_name.clone(), - published_at: existing.published_at, - created_at: existing.created_at, - updated_at, - } - } - None => CustomWorldProfile { - profile_id: input.profile_id.clone(), - owner_user_id: input.owner_user_id.clone(), - source_agent_session_id: input.source_agent_session_id.clone(), - publication_status: CustomWorldPublicationStatus::Draft, - world_name: input.world_name.clone(), - subtitle: input.subtitle.clone(), - summary_text: input.summary_text.clone(), - theme_mode: input.theme_mode, - cover_image_src: input.cover_image_src.clone(), - profile_payload_json: input.profile_payload_json.clone(), - playable_npc_count: input.playable_npc_count, - landmark_count: input.landmark_count, - author_display_name: input.author_display_name.clone(), - published_at: None, - created_at: updated_at, - updated_at, - }, - }; - - let inserted = ctx.db.custom_world_profile().insert(next_row); - - let gallery_entry = if inserted.publication_status == CustomWorldPublicationStatus::Published { - Some(sync_custom_world_gallery_entry_from_profile( - ctx, &inserted, - )?) - } else { - ctx.db - .custom_world_gallery_entry() - .profile_id() - .delete(&inserted.profile_id); - None - }; - - Ok(( - build_custom_world_profile_snapshot(&inserted), - gallery_entry, - )) -} - -fn publish_custom_world_world_record( - ctx: &ReducerContext, - input: CustomWorldPublishWorldInput, -) -> Result< - ( - module_custom_world::CustomWorldPublishedProfileCompileSnapshot, - CustomWorldProfileSnapshot, - Option, - RpgAgentStage, - ), - String, -> { - validate_custom_world_publish_world_input(&input).map_err(|error| error.to_string())?; - - let compiled_record = build_custom_world_published_profile_compile_snapshot( - CustomWorldPublishedProfileCompileInput { - session_id: input.session_id.clone(), - profile_id: input.profile_id.clone(), - owner_user_id: input.owner_user_id.clone(), - draft_profile_json: input.draft_profile_json.clone(), - legacy_result_profile_json: input.legacy_result_profile_json.clone(), - setting_text: input.setting_text.clone(), - author_display_name: input.author_display_name.clone(), - updated_at_micros: input.published_at_micros, - }, - ) - .map_err(|error| error.to_string())?; - - let _ = upsert_custom_world_profile_record( - ctx, - CustomWorldProfileUpsertInput { - profile_id: compiled_record.profile_id.clone(), - owner_user_id: compiled_record.owner_user_id.clone(), - source_agent_session_id: Some(input.session_id.clone()), - world_name: compiled_record.world_name.clone(), - subtitle: compiled_record.subtitle.clone(), - summary_text: compiled_record.summary_text.clone(), - theme_mode: compiled_record.theme_mode, - cover_image_src: compiled_record.cover_image_src.clone(), - profile_payload_json: compiled_record.compiled_profile_payload_json.clone(), - playable_npc_count: compiled_record.playable_npc_count, - landmark_count: compiled_record.landmark_count, - author_display_name: compiled_record.author_display_name.clone(), - updated_at_micros: input.published_at_micros, - }, - )?; - - let (entry, gallery_entry) = publish_custom_world_profile_record( - ctx, - CustomWorldProfilePublishInput { - profile_id: compiled_record.profile_id.clone(), - owner_user_id: compiled_record.owner_user_id.clone(), - author_display_name: compiled_record.author_display_name.clone(), - published_at_micros: input.published_at_micros, - }, - )?; - - let session_stage = mark_custom_world_agent_session_published( - ctx, - &input.session_id, - &input.owner_user_id, - input.published_at_micros, - )?; - - Ok((compiled_record, entry, gallery_entry, session_stage)) -} - -fn publish_custom_world_profile_record( - ctx: &ReducerContext, - input: CustomWorldProfilePublishInput, -) -> Result< - ( - CustomWorldProfileSnapshot, - Option, - ), - String, -> { - validate_custom_world_profile_publish_input(&input).map_err(|error| error.to_string())?; - - let existing = ctx - .db - .custom_world_profile() - .profile_id() - .find(&input.profile_id) - .filter(|row| row.owner_user_id == input.owner_user_id) - .ok_or_else(|| "custom_world_profile 不存在,无法发布".to_string())?; - - let published_at = Timestamp::from_micros_since_unix_epoch(input.published_at_micros); - - ctx.db - .custom_world_profile() - .profile_id() - .delete(&existing.profile_id); - - let next_row = CustomWorldProfile { - profile_id: existing.profile_id.clone(), - owner_user_id: existing.owner_user_id.clone(), - source_agent_session_id: existing.source_agent_session_id.clone(), - publication_status: CustomWorldPublicationStatus::Published, - world_name: existing.world_name.clone(), - subtitle: existing.subtitle.clone(), - summary_text: existing.summary_text.clone(), - theme_mode: existing.theme_mode, - cover_image_src: existing.cover_image_src.clone(), - profile_payload_json: existing.profile_payload_json.clone(), - playable_npc_count: existing.playable_npc_count, - landmark_count: existing.landmark_count, - author_display_name: input.author_display_name.clone(), - published_at: Some(published_at), - created_at: existing.created_at, - updated_at: published_at, - }; - - let inserted = ctx.db.custom_world_profile().insert(next_row); - let gallery_entry = sync_custom_world_gallery_entry_from_profile(ctx, &inserted)?; - - Ok(( - build_custom_world_profile_snapshot(&inserted), - Some(gallery_entry), - )) -} - -fn unpublish_custom_world_profile_record( - ctx: &ReducerContext, - input: CustomWorldProfileUnpublishInput, -) -> Result< - ( - CustomWorldProfileSnapshot, - Option, - ), - String, -> { - validate_custom_world_profile_unpublish_input(&input).map_err(|error| error.to_string())?; - - let existing = ctx - .db - .custom_world_profile() - .profile_id() - .find(&input.profile_id) - .filter(|row| row.owner_user_id == input.owner_user_id) - .ok_or_else(|| "custom_world_profile 不存在,无法取消发布".to_string())?; - - let updated_at = Timestamp::from_micros_since_unix_epoch(input.updated_at_micros); - - ctx.db - .custom_world_profile() - .profile_id() - .delete(&existing.profile_id); - - ctx.db - .custom_world_gallery_entry() - .profile_id() - .delete(&existing.profile_id); - - let next_row = CustomWorldProfile { - profile_id: existing.profile_id.clone(), - owner_user_id: existing.owner_user_id.clone(), - source_agent_session_id: existing.source_agent_session_id.clone(), - publication_status: CustomWorldPublicationStatus::Draft, - world_name: existing.world_name.clone(), - subtitle: existing.subtitle.clone(), - summary_text: existing.summary_text.clone(), - theme_mode: existing.theme_mode, - cover_image_src: existing.cover_image_src.clone(), - profile_payload_json: existing.profile_payload_json.clone(), - playable_npc_count: existing.playable_npc_count, - landmark_count: existing.landmark_count, - author_display_name: input.author_display_name.clone(), - published_at: None, - created_at: existing.created_at, - updated_at, - }; - - let inserted = ctx.db.custom_world_profile().insert(next_row); - - Ok((build_custom_world_profile_snapshot(&inserted), None)) -} - -fn list_custom_world_profile_snapshots( - ctx: &ReducerContext, - input: CustomWorldProfileListInput, -) -> Result, String> { - validate_custom_world_profile_list_input(&input).map_err(|error| error.to_string())?; - - let mut entries = ctx - .db - .custom_world_profile() - .iter() - .filter(|row| row.owner_user_id == input.owner_user_id) - .map(|row| build_custom_world_profile_snapshot(&row)) - .collect::>(); - - entries.sort_by(|left, right| right.updated_at_micros.cmp(&left.updated_at_micros)); - - Ok(entries) -} - -fn list_custom_world_gallery_snapshots( - ctx: &ReducerContext, -) -> Vec { - let mut entries = ctx - .db - .custom_world_gallery_entry() - .iter() - .map(|row| build_custom_world_gallery_entry_snapshot(&row)) - .collect::>(); - - entries.sort_by(|left, right| { - right - .published_at_micros - .cmp(&left.published_at_micros) - .then(right.updated_at_micros.cmp(&left.updated_at_micros)) - }); - - entries -} - -fn get_custom_world_library_detail_record( - ctx: &ReducerContext, - input: CustomWorldLibraryDetailInput, -) -> Result< - ( - Option, - Option, - ), - String, -> { - validate_custom_world_library_detail_input(&input).map_err(|error| error.to_string())?; - - let profile = ctx - .db - .custom_world_profile() - .profile_id() - .find(&input.profile_id) - .filter(|row| row.owner_user_id == input.owner_user_id); - - let gallery_entry = profile - .as_ref() - .filter(|row| row.publication_status == CustomWorldPublicationStatus::Published) - .and_then(|row| { - ctx.db - .custom_world_gallery_entry() - .profile_id() - .find(&row.profile_id) - .filter(|gallery_row| gallery_row.owner_user_id == row.owner_user_id) - }); - - Ok(( - profile.as_ref().map(build_custom_world_profile_snapshot), - gallery_entry - .as_ref() - .map(build_custom_world_gallery_entry_snapshot), - )) -} - -fn get_custom_world_gallery_detail_record( - ctx: &ReducerContext, - input: CustomWorldGalleryDetailInput, -) -> Result< - ( - Option, - Option, - ), - String, -> { - validate_custom_world_gallery_detail_input(&input).map_err(|error| error.to_string())?; - - let profile = ctx - .db - .custom_world_profile() - .profile_id() - .find(&input.profile_id) - .filter(|row| { - row.owner_user_id == input.owner_user_id - && row.publication_status == CustomWorldPublicationStatus::Published - }); - - let gallery_entry = ctx - .db - .custom_world_gallery_entry() - .profile_id() - .find(&input.profile_id) - .filter(|row| row.owner_user_id == input.owner_user_id); - - Ok(( - profile.as_ref().map(build_custom_world_profile_snapshot), - gallery_entry - .as_ref() - .map(build_custom_world_gallery_entry_snapshot), - )) -} - -fn list_custom_world_work_snapshots( - ctx: &ReducerContext, - input: CustomWorldWorksListInput, -) -> Result, String> { - validate_custom_world_works_list_input(&input).map_err(|error| error.to_string())?; - - let mut items = Vec::new(); - - for session in ctx - .db - .custom_world_agent_session() - .iter() - .filter(|row| row.owner_user_id == input.owner_user_id && row.stage != RpgAgentStage::Published) - { - let gate = build_custom_world_publish_gate_from_session(&session); - let draft_profile = parse_optional_session_object(session.draft_profile_json.as_deref()); - let title = resolve_session_work_title(&session, draft_profile.as_ref()); - let summary = resolve_session_work_summary(&session, draft_profile.as_ref()); - 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(ctx, &session, draft_profile.as_ref()); - - items.push(CustomWorldWorkSummarySnapshot { - work_id: format!("draft:{}", session.session_id), - source_type: "agent_session".to_string(), - status: "draft".to_string(), - title, - subtitle, - summary, - cover_image_src: resolve_session_work_cover_image_src(draft_profile.as_ref()), - cover_render_mode: None, - cover_character_image_srcs_json: "[]".to_string(), - updated_at_micros: session.updated_at.to_micros_since_unix_epoch(), - published_at_micros: None, - stage: Some(session.stage), - stage_label, - playable_npc_count, - landmark_count, - role_visual_ready_count: None, - role_animation_ready_count: None, - role_asset_summary_label: None, - session_id: Some(session.session_id.clone()), - profile_id: None, - can_resume: true, - can_enter_world: gate.can_enter_world, - blocker_count: gate.blocker_count, - publish_ready: gate.publish_ready, - }); - } - - for profile in ctx - .db - .custom_world_profile() - .iter() - .filter(|row| row.owner_user_id == input.owner_user_id) - { - items.push(CustomWorldWorkSummarySnapshot { - work_id: format!("published:{}", profile.profile_id), - source_type: "published_profile".to_string(), - status: profile.publication_status.as_str().to_string(), - title: profile.world_name.clone(), - subtitle: profile.subtitle.clone(), - summary: profile.summary_text.clone(), - cover_image_src: profile.cover_image_src.clone(), - cover_render_mode: None, - cover_character_image_srcs_json: "[]".to_string(), - updated_at_micros: profile.updated_at.to_micros_since_unix_epoch(), - published_at_micros: profile - .published_at - .map(|value| value.to_micros_since_unix_epoch()), - stage: None, - stage_label: None, - playable_npc_count: profile.playable_npc_count, - landmark_count: profile.landmark_count, - role_visual_ready_count: None, - role_animation_ready_count: None, - role_asset_summary_label: None, - session_id: profile.source_agent_session_id.clone(), - profile_id: Some(profile.profile_id.clone()), - can_resume: false, - can_enter_world: profile.publication_status == CustomWorldPublicationStatus::Published, - blocker_count: 0, - publish_ready: true, - }); - } - - items.sort_by(|left, right| { - right - .updated_at_micros - .cmp(&left.updated_at_micros) - .then_with(|| { - let left_rank = if left.source_type == "agent_session" { 0 } else { 1 }; - let right_rank = if right.source_type == "agent_session" { 0 } else { 1 }; - left_rank.cmp(&right_rank) - }) - .then(left.work_id.cmp(&right.work_id)) - }); - - Ok(items) -} - -fn get_custom_world_agent_card_detail_tx( - ctx: &ReducerContext, - input: CustomWorldAgentCardDetailGetInput, -) -> Result { - validate_custom_world_agent_card_detail_get_input(&input).map_err(|error| error.to_string())?; - - ctx.db - .custom_world_agent_session() - .session_id() - .find(&input.session_id) - .filter(|row| row.owner_user_id == input.owner_user_id) - .ok_or_else(|| "custom_world_agent_session 不存在".to_string())?; - - let card = ctx - .db - .custom_world_draft_card() - .card_id() - .find(&input.card_id) - .filter(|row| row.session_id == input.session_id) - .ok_or_else(|| "custom_world_draft_card 不存在".to_string())?; - - build_custom_world_draft_card_detail_snapshot(&card) -} - -fn execute_custom_world_agent_action_tx( - ctx: &ReducerContext, - input: CustomWorldAgentActionExecuteInput, -) -> Result { - validate_custom_world_agent_action_execute_input(&input).map_err(|error| error.to_string())?; - - let session = ctx - .db - .custom_world_agent_session() - .session_id() - .find(&input.session_id) - .filter(|row| row.owner_user_id == input.owner_user_id) - .ok_or_else(|| "custom_world_agent_session 不存在".to_string())?; - - if ctx - .db - .custom_world_agent_operation() - .operation_id() - .find(&input.operation_id) - .is_some() - { - return Err("custom_world_agent_operation.operation_id 已存在".to_string()); - } - - let payload = parse_optional_session_object(input.payload_json.as_deref()).unwrap_or_default(); - match input.action.trim() { - "draft_foundation" => execute_draft_foundation_action(ctx, &session, &input, &payload), - "update_draft_card" => execute_update_draft_card_action(ctx, &session, &input, &payload), - "sync_result_profile" => execute_sync_result_profile_action(ctx, &session, &input, &payload), - "publish_world" => execute_publish_world_action(ctx, &session, &input, &payload), - "revert_checkpoint" => execute_revert_checkpoint_action(ctx, &session, &input, &payload), - "generate_characters" - | "generate_landmarks" - | "generate_role_assets" - | "sync_role_assets" - | "generate_scene_assets" - | "sync_scene_assets" - | "expand_long_tail" => execute_placeholder_custom_world_action(ctx, &session, &input), - other => Err(format!("custom world action `{other}` 当前尚未支持")), - } -} - -fn execute_draft_foundation_action( - ctx: &ReducerContext, - session: &CustomWorldAgentSession, - input: &CustomWorldAgentActionExecuteInput, - payload: &JsonMap, -) -> Result { - if session.progress_percent < 100 { - return Err("draft_foundation requires progressPercent >= 100".to_string()); - } - - let updated_at = input.submitted_at_micros; - let draft_profile = if let Some(profile) = payload.get("draftProfile").and_then(JsonValue::as_object) { - profile.clone() - } else if let Some(existing) = parse_optional_session_object(session.draft_profile_json.as_deref()) { - ensure_minimal_draft_profile(existing, &session.seed_text) - } else { - build_minimal_draft_profile_from_seed(&session.seed_text) - }; - - let draft_profile_json = - serde_json::to_string(&JsonValue::Object(draft_profile.clone())).map_err(|error| { - format!("draft_foundation 无法序列化 draft_profile_json: {error}") - })?; - let gate = summarize_publish_gate_from_json( - &input.session_id, - RpgAgentStage::ObjectRefining, - Some(&draft_profile), - &parse_json_array_or_empty(&session.quality_findings_json), - ); - let next_session = rebuild_custom_world_agent_session_row( - session, - CustomWorldAgentSessionPatch { - progress_percent: Some(100), - stage: Some(RpgAgentStage::ObjectRefining), - draft_profile_json: Some(Some(draft_profile_json.clone())), - last_assistant_reply: Some(Some("世界底稿已整理完成,接下来可以继续细化卡片和发布预览。".to_string())), - 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, - )?), - checkpoints_json: Some(append_checkpoint_json( - &session.checkpoints_json, - &build_session_checkpoint_value("foundation-ready", "底稿整理完成", session), - )?), - updated_at_micros: Some(updated_at), - ..CustomWorldAgentSessionPatch::default() - }, - )?; - replace_custom_world_agent_session(ctx, session, next_session); - - upsert_world_foundation_card(ctx, &session.session_id, &draft_profile, updated_at)?; - append_custom_world_action_result_message( - ctx, - &session.session_id, - &input.operation_id, - "已整理出第一版世界底稿,并同步生成世界基础卡片。", - updated_at, - ); - - let operation = build_and_insert_custom_world_operation( - ctx, - &input.operation_id, - &session.session_id, - RpgAgentOperationType::DraftFoundation, - "底稿已整理", - "第一版 foundation draft 已写入会话与世界卡。", - updated_at, - ); - - Ok(build_custom_world_agent_operation_snapshot(&operation)) -} - -fn execute_update_draft_card_action( - ctx: &ReducerContext, - session: &CustomWorldAgentSession, - input: &CustomWorldAgentActionExecuteInput, - payload: &JsonMap, -) -> Result { - ensure_refining_stage(session.stage, "update_draft_card")?; - - let card_id = read_required_payload_text(payload, "cardId", "update_draft_card requires cardId")?; - let card = ctx - .db - .custom_world_draft_card() - .card_id() - .find(&card_id) - .filter(|row| row.session_id == session.session_id) - .ok_or_else(|| "update_draft_card target card does not exist".to_string())?; - let sections = payload - .get("sections") - .and_then(JsonValue::as_array) - .ok_or_else(|| "update_draft_card requires sections".to_string())?; - if sections.is_empty() { - return Err("update_draft_card requires sections".to_string()); - } - - let mut detail_object = parse_optional_session_object(card.detail_payload_json.as_deref()).unwrap_or_default(); - let mut detail_sections = detail_object - .get("sections") - .and_then(JsonValue::as_array) - .cloned() - .unwrap_or_else(|| build_fallback_card_sections_json(&card)); - - for patch in sections { - let patch_object = patch - .as_object() - .ok_or_else(|| "update_draft_card.sections 必须是 object 数组".to_string())?; - let section_id = read_required_payload_text( - patch_object, - "sectionId", - "update_draft_card section.sectionId is required", - )?; - let value = patch_object - .get("value") - .and_then(JsonValue::as_str) - .unwrap_or_default() - .trim() - .to_string(); - - let mut updated = false; - for existing in &mut detail_sections { - if existing.get("id").and_then(JsonValue::as_str) == Some(section_id.as_str()) { - if let Some(object) = existing.as_object_mut() { - object.insert("value".to_string(), JsonValue::String(value.clone())); - } - updated = true; - break; - } - } - - if !updated { - detail_sections.push(json!({ - "id": section_id, - "label": section_id, - "value": value, - })); - } - } - - detail_object.insert("id".to_string(), JsonValue::String(card.card_id.clone())); - detail_object.insert("kind".to_string(), JsonValue::String(card.kind.as_str().to_string())); - detail_object.insert("title".to_string(), JsonValue::String(card.title.clone())); - detail_object.insert("sections".to_string(), JsonValue::Array(detail_sections.clone())); - detail_object.insert( - "linkedIds".to_string(), - serde_json::from_str::(&card.linked_ids_json).unwrap_or_else(|_| JsonValue::Array(Vec::new())), - ); - detail_object.insert("locked".to_string(), JsonValue::Bool(false)); - detail_object.insert("editable".to_string(), JsonValue::Bool(false)); - detail_object.insert("editableSectionIds".to_string(), JsonValue::Array(Vec::new())); - detail_object.insert("warningMessages".to_string(), JsonValue::Array(Vec::new())); - - let updated_title = extract_detail_section_value(&detail_sections, "title").unwrap_or_else(|| card.title.clone()); - let updated_subtitle = - extract_detail_section_value(&detail_sections, "subtitle").unwrap_or_else(|| card.subtitle.clone()); - let updated_summary = - extract_detail_section_value(&detail_sections, "summary").unwrap_or_else(|| card.summary.clone()); - let detail_payload_json = - serde_json::to_string(&JsonValue::Object(detail_object)).map_err(|error| { - format!("update_draft_card 无法序列化 detail_payload_json: {error}") - })?; - - replace_custom_world_draft_card( - ctx, - &card, - CustomWorldDraftCard { - card_id: card.card_id.clone(), - session_id: card.session_id.clone(), - kind: card.kind, - status: card.status, - title: updated_title.clone(), - subtitle: updated_subtitle.clone(), - summary: updated_summary.clone(), - linked_ids_json: card.linked_ids_json.clone(), - warning_count: card.warning_count, - asset_status: card.asset_status, - asset_status_label: card.asset_status_label.clone(), - detail_payload_json: Some(detail_payload_json), - created_at: card.created_at, - updated_at: Timestamp::from_micros_since_unix_epoch(input.submitted_at_micros), - }, - ); - - let next_session = sync_session_draft_profile_from_card_update(session, &card, &updated_title, &updated_subtitle, &updated_summary, input.submitted_at_micros)?; - replace_custom_world_agent_session(ctx, session, next_session); - - append_custom_world_action_result_message( - ctx, - &session.session_id, - &input.operation_id, - &format!("已更新卡片《{}》的草稿内容。", updated_title), - input.submitted_at_micros, - ); - - let operation = build_and_insert_custom_world_operation( - ctx, - &input.operation_id, - &session.session_id, - RpgAgentOperationType::UpdateDraftCard, - "卡片已更新", - &format!("卡片 {} 的 detail 与摘要字段已同步更新。", card_id), - input.submitted_at_micros, - ); - - Ok(build_custom_world_agent_operation_snapshot(&operation)) -} - -fn execute_sync_result_profile_action( - ctx: &ReducerContext, - session: &CustomWorldAgentSession, - input: &CustomWorldAgentActionExecuteInput, - payload: &JsonMap, -) -> Result { - ensure_refining_stage(session.stage, "sync_result_profile")?; - let profile = payload - .get("profile") - .and_then(JsonValue::as_object) - .cloned() - .ok_or_else(|| "sync_result_profile requires profile".to_string())?; - let draft_profile = ensure_minimal_draft_profile(profile, &session.seed_text); - let gate = summarize_publish_gate_from_json( - &session.session_id, - session.stage, - Some(&draft_profile), - &parse_json_array_or_empty(&session.quality_findings_json), - ); - - let next_session = rebuild_custom_world_agent_session_row( - session, - CustomWorldAgentSessionPatch { - draft_profile_json: Some(Some(serialize_json_value(&JsonValue::Object(draft_profile.clone()))?)), - last_assistant_reply: Some(Some("结果页草稿已同步回当前会话。".to_string())), - 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), - input.submitted_at_micros, - )?), - checkpoints_json: Some(append_checkpoint_json( - &session.checkpoints_json, - &build_session_checkpoint_value("sync-result-profile", "同步结果页草稿", session), - )?), - updated_at_micros: Some(input.submitted_at_micros), - ..CustomWorldAgentSessionPatch::default() - }, - )?; - replace_custom_world_agent_session(ctx, session, next_session); - - append_custom_world_action_result_message( - ctx, - &session.session_id, - &input.operation_id, - "结果页 profile 已回写当前会话,并重建预览。", - input.submitted_at_micros, - ); - - let operation = build_and_insert_custom_world_operation( - ctx, - &input.operation_id, - &session.session_id, - RpgAgentOperationType::SyncResultProfile, - "结果页已同步", - "draft_profile_json 与 result_preview 已更新。", - input.submitted_at_micros, - ); - - Ok(build_custom_world_agent_operation_snapshot(&operation)) -} - -fn execute_publish_world_action( - ctx: &ReducerContext, - session: &CustomWorldAgentSession, - input: &CustomWorldAgentActionExecuteInput, - payload: &JsonMap, -) -> Result { - ensure_publishable_stage(session.stage, "publish_world")?; - - let draft_profile = if let Some(explicit) = payload.get("draftProfile").and_then(JsonValue::as_object) { - explicit.clone() - } else { - parse_optional_session_object(session.draft_profile_json.as_deref()) - .ok_or_else(|| "publish_world requires draft_profile_json".to_string())? - }; - let gate = summarize_publish_gate_from_json( - &session.session_id, - session.stage, - Some(&draft_profile), - &parse_json_array_or_empty(&session.quality_findings_json), - ); - if !gate.publish_ready { - return Err(format!( - "当前世界仍有 {} 个 blocker,暂时不能发布", - gate.blocker_count - )); - } - - let profile_id = payload - .get("profileId") - .and_then(JsonValue::as_str) - .map(str::trim) - .filter(|value| !value.is_empty()) - .map(ToOwned::to_owned) - .unwrap_or_else(|| gate.profile_id.clone()); - let setting_text = payload - .get("settingText") - .and_then(JsonValue::as_str) - .map(str::trim) - .filter(|value| !value.is_empty()) - .map(ToOwned::to_owned) - .unwrap_or_else(|| session.seed_text.clone()); - let legacy_result_profile_json = payload - .get("legacyResultProfile") - .map(serialize_json_value) - .transpose()?; - let publish_result = publish_custom_world_world_record( - ctx, - CustomWorldPublishWorldInput { - session_id: session.session_id.clone(), - profile_id, - owner_user_id: session.owner_user_id.clone(), - draft_profile_json: serialize_json_value(&JsonValue::Object(draft_profile.clone()))?, - legacy_result_profile_json, - setting_text, - author_display_name: "创作者".to_string(), - published_at_micros: input.submitted_at_micros, - }, - )?; - - append_custom_world_action_result_message( - ctx, - &session.session_id, - &input.operation_id, - &format!("正式世界档案已发布:{}。", publish_result.1.profile_id), - input.submitted_at_micros, - ); - - let operation = build_and_insert_custom_world_operation( - ctx, - &input.operation_id, - &session.session_id, - RpgAgentOperationType::PublishWorld, - "世界已发布", - &format!("正式世界档案已写入作品库:{}。", publish_result.1.profile_id), - input.submitted_at_micros, - ); - - Ok(build_custom_world_agent_operation_snapshot(&operation)) -} - -fn execute_revert_checkpoint_action( - ctx: &ReducerContext, - session: &CustomWorldAgentSession, - input: &CustomWorldAgentActionExecuteInput, - payload: &JsonMap, -) -> Result { - ensure_long_tail_stage(session.stage, "revert_checkpoint")?; - let checkpoint_id = read_required_payload_text( - payload, - "checkpointId", - "revert_checkpoint requires checkpointId", - )?; - let checkpoint = parse_json_array_or_empty(&session.checkpoints_json) - .into_iter() - .find(|entry| { - entry - .get("checkpointId") - .and_then(JsonValue::as_str) - .map(str::trim) - == Some(checkpoint_id.as_str()) - }) - .ok_or_else(|| "revert_checkpoint target checkpoint does not exist".to_string())?; - let snapshot = checkpoint - .get("snapshot") - .and_then(JsonValue::as_object) - .cloned() - .ok_or_else(|| { - "revert_checkpoint target checkpoint does not contain a restorable snapshot".to_string() - })?; - - let restored_stage = snapshot - .get("stage") - .and_then(JsonValue::as_str) - .and_then(parse_rpg_agent_stage) - .unwrap_or(session.stage); - let restored_progress = snapshot - .get("progressPercent") - .and_then(JsonValue::as_u64) - .and_then(|value| u32::try_from(value).ok()) - .unwrap_or(session.progress_percent); - let restored_draft_profile = snapshot - .get("draftProfile") - .and_then(JsonValue::as_object) - .cloned(); - let restored_quality_findings = snapshot - .get("qualityFindings") - .and_then(JsonValue::as_array) - .cloned() - .unwrap_or_else(Vec::new); - let gate = summarize_publish_gate_from_json( - &session.session_id, - restored_stage, - restored_draft_profile.as_ref(), - &restored_quality_findings, - ); - - let next_session = rebuild_custom_world_agent_session_row( - session, - CustomWorldAgentSessionPatch { - progress_percent: Some(restored_progress), - stage: Some(restored_stage), - 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))?)), - result_preview_json: Some(build_result_preview_json( - restored_draft_profile.as_ref(), - &gate, - &parse_json_array_or_empty(&serialize_json_value(&JsonValue::Array( - snapshot - .get("qualityFindings") - .and_then(JsonValue::as_array) - .cloned() - .unwrap_or_else(Vec::new), - ))?), - input.submitted_at_micros, - )?), - updated_at_micros: Some(input.submitted_at_micros), - ..CustomWorldAgentSessionPatch::default() - }, - )?; - replace_custom_world_agent_session(ctx, session, next_session); - - append_custom_world_action_result_message( - ctx, - &session.session_id, - &input.operation_id, - "已恢复到所选 checkpoint。", - input.submitted_at_micros, - ); - - let operation = build_and_insert_custom_world_operation( - ctx, - &input.operation_id, - &session.session_id, - RpgAgentOperationType::RevertCheckpoint, - "已回滚 checkpoint", - &format!("会话已恢复到 checkpoint {}。", checkpoint_id), - input.submitted_at_micros, - ); - - Ok(build_custom_world_agent_operation_snapshot(&operation)) -} - -fn execute_placeholder_custom_world_action( - ctx: &ReducerContext, - session: &CustomWorldAgentSession, - input: &CustomWorldAgentActionExecuteInput, -) -> Result { - let operation_type = map_action_name_to_operation_type(input.action.as_str()) - .ok_or_else(|| format!("action {} 无法映射到 operation type", input.action))?; - append_custom_world_action_result_message( - ctx, - &session.session_id, - &input.operation_id, - &format!("动作 {} 已接入最小兼容占位,后续会继续补真实编排。", input.action), - input.submitted_at_micros, - ); - let operation = build_and_insert_custom_world_operation( - ctx, - &input.operation_id, - &session.session_id, - operation_type, - "动作已完成", - &format!("{} 当前已走最小兼容闭环。", input.action), - input.submitted_at_micros, - ); - 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, - owner_user_id: &str, - updated_at_micros: i64, -) -> Result { - let existing = ctx - .db - .custom_world_agent_session() - .session_id() - .find(&session_id.to_string()) - .filter(|row| row.owner_user_id == owner_user_id) - .ok_or_else(|| "custom_world_agent_session 不存在,无法推进到 published".to_string())?; - - ctx.db - .custom_world_agent_session() - .session_id() - .delete(&existing.session_id); - - let next_row = CustomWorldAgentSession { - session_id: existing.session_id.clone(), - owner_user_id: existing.owner_user_id.clone(), - seed_text: existing.seed_text.clone(), - current_turn: existing.current_turn, - progress_percent: existing.progress_percent, - stage: RpgAgentStage::Published, - focus_card_id: existing.focus_card_id.clone(), - anchor_content_json: existing.anchor_content_json.clone(), - creator_intent_json: existing.creator_intent_json.clone(), - creator_intent_readiness_json: existing.creator_intent_readiness_json.clone(), - anchor_pack_json: existing.anchor_pack_json.clone(), - lock_state_json: existing.lock_state_json.clone(), - draft_profile_json: existing.draft_profile_json.clone(), - last_assistant_reply: existing.last_assistant_reply.clone(), - publish_gate_json: existing.publish_gate_json.clone(), - result_preview_json: existing.result_preview_json.clone(), - pending_clarifications_json: existing.pending_clarifications_json.clone(), - quality_findings_json: existing.quality_findings_json.clone(), - suggested_actions_json: existing.suggested_actions_json.clone(), - recommended_replies_json: existing.recommended_replies_json.clone(), - asset_coverage_json: existing.asset_coverage_json.clone(), - checkpoints_json: existing.checkpoints_json.clone(), - created_at: existing.created_at, - updated_at: Timestamp::from_micros_since_unix_epoch(updated_at_micros), - }; - - ctx.db.custom_world_agent_session().insert(next_row); - - Ok(RpgAgentStage::Published) -} - -fn sync_custom_world_gallery_entry_from_profile( - ctx: &ReducerContext, - profile: &CustomWorldProfile, -) -> Result { - let published_at = profile - .published_at - .ok_or_else(|| "published profile 缺少 published_at,无法同步 gallery".to_string())?; - - ctx.db - .custom_world_gallery_entry() - .profile_id() - .delete(&profile.profile_id); - - let row = CustomWorldGalleryEntry { - profile_id: profile.profile_id.clone(), - owner_user_id: profile.owner_user_id.clone(), - author_display_name: profile.author_display_name.clone(), - world_name: profile.world_name.clone(), - subtitle: profile.subtitle.clone(), - summary_text: profile.summary_text.clone(), - cover_image_src: profile.cover_image_src.clone(), - theme_mode: profile.theme_mode, - playable_npc_count: profile.playable_npc_count, - landmark_count: profile.landmark_count, - published_at, - updated_at: profile.updated_at, - }; - - let inserted = ctx.db.custom_world_gallery_entry().insert(row); - - Ok(build_custom_world_gallery_entry_snapshot(&inserted)) -} - -fn build_custom_world_profile_snapshot(row: &CustomWorldProfile) -> CustomWorldProfileSnapshot { - CustomWorldProfileSnapshot { - profile_id: row.profile_id.clone(), - owner_user_id: row.owner_user_id.clone(), - source_agent_session_id: row.source_agent_session_id.clone(), - publication_status: row.publication_status, - world_name: row.world_name.clone(), - subtitle: row.subtitle.clone(), - summary_text: row.summary_text.clone(), - theme_mode: row.theme_mode, - cover_image_src: row.cover_image_src.clone(), - profile_payload_json: row.profile_payload_json.clone(), - playable_npc_count: row.playable_npc_count, - landmark_count: row.landmark_count, - author_display_name: row.author_display_name.clone(), - published_at_micros: row - .published_at - .map(|value| value.to_micros_since_unix_epoch()), - created_at_micros: row.created_at.to_micros_since_unix_epoch(), - updated_at_micros: row.updated_at.to_micros_since_unix_epoch(), - } -} - -fn build_custom_world_agent_session_snapshot( - ctx: &ReducerContext, - row: &CustomWorldAgentSession, -) -> CustomWorldAgentSessionSnapshot { - let mut messages = ctx - .db - .custom_world_agent_message() - .iter() - .filter(|message| message.session_id == row.session_id) - .map(|message| build_custom_world_agent_message_snapshot(&message)) - .collect::>(); - messages.sort_by_key(|message| (message.created_at_micros, message.message_id.clone())); - - let mut draft_cards = ctx - .db - .custom_world_draft_card() - .iter() - .filter(|card| card.session_id == row.session_id) - .map(|card| build_custom_world_draft_card_snapshot(&card)) - .collect::>(); - draft_cards.sort_by_key(|card| (card.created_at_micros, card.card_id.clone())); - - let mut operations = ctx - .db - .custom_world_agent_operation() - .iter() - .filter(|operation| operation.session_id == row.session_id) - .map(|operation| build_custom_world_agent_operation_snapshot(&operation)) - .collect::>(); - operations - .sort_by_key(|operation| (operation.created_at_micros, operation.operation_id.clone())); - - CustomWorldAgentSessionSnapshot { - session_id: row.session_id.clone(), - owner_user_id: row.owner_user_id.clone(), - seed_text: row.seed_text.clone(), - current_turn: row.current_turn, - progress_percent: row.progress_percent, - stage: row.stage, - focus_card_id: row.focus_card_id.clone(), - anchor_content_json: row.anchor_content_json.clone(), - creator_intent_json: row.creator_intent_json.clone(), - creator_intent_readiness_json: row.creator_intent_readiness_json.clone(), - anchor_pack_json: row.anchor_pack_json.clone(), - lock_state_json: row.lock_state_json.clone(), - draft_profile_json: row.draft_profile_json.clone(), - last_assistant_reply: row.last_assistant_reply.clone(), - publish_gate_json: row.publish_gate_json.clone(), - result_preview_json: row.result_preview_json.clone(), - pending_clarifications_json: row.pending_clarifications_json.clone(), - quality_findings_json: row.quality_findings_json.clone(), - suggested_actions_json: row.suggested_actions_json.clone(), - 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, - created_at_micros: row.created_at.to_micros_since_unix_epoch(), - updated_at_micros: row.updated_at.to_micros_since_unix_epoch(), - } -} - -fn build_custom_world_agent_message_snapshot( - row: &CustomWorldAgentMessage, -) -> CustomWorldAgentMessageSnapshot { - CustomWorldAgentMessageSnapshot { - message_id: row.message_id.clone(), - session_id: row.session_id.clone(), - role: row.role, - kind: row.kind, - text: row.text.clone(), - related_operation_id: row.related_operation_id.clone(), - created_at_micros: row.created_at.to_micros_since_unix_epoch(), - } -} - -fn build_custom_world_agent_operation_snapshot( - row: &CustomWorldAgentOperation, -) -> CustomWorldAgentOperationSnapshot { - CustomWorldAgentOperationSnapshot { - operation_id: row.operation_id.clone(), - session_id: row.session_id.clone(), - operation_type: row.operation_type, - status: row.status, - phase_label: row.phase_label.clone(), - phase_detail: row.phase_detail.clone(), - progress: row.progress, - error_message: row.error_message.clone(), - created_at_micros: row.created_at.to_micros_since_unix_epoch(), - updated_at_micros: row.updated_at.to_micros_since_unix_epoch(), - } -} - -fn build_custom_world_draft_card_snapshot( - row: &CustomWorldDraftCard, -) -> CustomWorldDraftCardSnapshot { - CustomWorldDraftCardSnapshot { - card_id: row.card_id.clone(), - session_id: row.session_id.clone(), - kind: row.kind, - status: row.status, - title: row.title.clone(), - subtitle: row.subtitle.clone(), - summary: row.summary.clone(), - linked_ids_json: row.linked_ids_json.clone(), - warning_count: row.warning_count, - asset_status: row.asset_status, - asset_status_label: row.asset_status_label.clone(), - detail_payload_json: row.detail_payload_json.clone(), - created_at_micros: row.created_at.to_micros_since_unix_epoch(), - updated_at_micros: row.updated_at.to_micros_since_unix_epoch(), - } -} - -fn build_custom_world_gallery_entry_snapshot( - row: &CustomWorldGalleryEntry, -) -> CustomWorldGalleryEntrySnapshot { - CustomWorldGalleryEntrySnapshot { - profile_id: row.profile_id.clone(), - owner_user_id: row.owner_user_id.clone(), - author_display_name: row.author_display_name.clone(), - world_name: row.world_name.clone(), - subtitle: row.subtitle.clone(), - summary_text: row.summary_text.clone(), - cover_image_src: row.cover_image_src.clone(), - theme_mode: row.theme_mode, - playable_npc_count: row.playable_npc_count, - landmark_count: row.landmark_count, - published_at_micros: row.published_at.to_micros_since_unix_epoch(), - updated_at_micros: row.updated_at.to_micros_since_unix_epoch(), - } -} - -fn create_ai_task_tx( - ctx: &ReducerContext, - input: AiTaskCreateInput, -) -> Result { - validate_task_create_input(&input).map_err(|error| error.to_string())?; - - if ctx.db.ai_task().task_id().find(&input.task_id).is_some() { - return Err("ai_task.task_id 已存在".to_string()); - } - - let task_snapshot = build_ai_task_snapshot_from_create_input(&input); - ctx.db.ai_task().insert(build_ai_task_row(&task_snapshot)); - replace_ai_task_stages(ctx, &task_snapshot.task_id, &task_snapshot.stages); - - get_ai_task_snapshot_tx(ctx, &task_snapshot.task_id) -} - -fn start_ai_task_tx( - ctx: &ReducerContext, - input: AiTaskStartInput, -) -> Result { - let mut snapshot = get_ai_task_snapshot_tx(ctx, &input.task_id)?; - ensure_ai_task_can_transition(snapshot.status)?; - - snapshot.status = AiTaskStatus::Running; - if snapshot.started_at_micros.is_none() { - snapshot.started_at_micros = Some(input.started_at_micros); - } - snapshot.updated_at_micros = input.started_at_micros; - snapshot.version += 1; - - persist_ai_task_snapshot(ctx, &snapshot)?; - Ok(snapshot) -} - -fn start_ai_task_stage_tx( - ctx: &ReducerContext, - input: AiTaskStageStartInput, -) -> Result { - let mut snapshot = get_ai_task_snapshot_tx(ctx, &input.task_id)?; - ensure_ai_task_can_transition(snapshot.status)?; - - let stage = snapshot - .stages - .iter_mut() - .find(|stage| stage.stage_kind == input.stage_kind) - .ok_or_else(|| "ai_task.stage 不存在".to_string())?; - - snapshot.status = AiTaskStatus::Running; - if snapshot.started_at_micros.is_none() { - snapshot.started_at_micros = Some(input.started_at_micros); - } - stage.status = AiTaskStageStatus::Running; - if stage.started_at_micros.is_none() { - stage.started_at_micros = Some(input.started_at_micros); - } - snapshot.updated_at_micros = input.started_at_micros; - snapshot.version += 1; - - persist_ai_task_snapshot(ctx, &snapshot)?; - Ok(snapshot) -} - -fn append_ai_text_chunk_tx( - ctx: &ReducerContext, - input: AiTextChunkAppendInput, -) -> Result<(AiTaskSnapshot, AiTextChunkSnapshot), String> { - if input.delta_text.trim().is_empty() { - return Err("ai_text_chunk.delta_text 不能为空".to_string()); - } - if input.sequence == 0 { - return Err("ai_text_chunk.sequence 必须大于 0".to_string()); - } - - let mut snapshot = get_ai_task_snapshot_tx(ctx, &input.task_id)?; - ensure_ai_task_can_transition(snapshot.status)?; - - let stage = snapshot - .stages - .iter_mut() - .find(|stage| stage.stage_kind == input.stage_kind) - .ok_or_else(|| "ai_task.stage 不存在".to_string())?; - - let chunk = AiTextChunkSnapshot { - chunk_id: generate_ai_text_chunk_id(input.created_at_micros, input.sequence), - task_id: input.task_id.trim().to_string(), - stage_kind: input.stage_kind, - sequence: input.sequence, - delta_text: input.delta_text.trim().to_string(), - created_at_micros: input.created_at_micros, - }; - ctx.db - .ai_text_chunk() - .insert(build_ai_text_chunk_row(&chunk)); - - let aggregated_text = collect_ai_stage_text_output(ctx, &chunk.task_id, chunk.stage_kind); - - snapshot.status = AiTaskStatus::Running; - if snapshot.started_at_micros.is_none() { - snapshot.started_at_micros = Some(input.created_at_micros); - } - stage.status = AiTaskStageStatus::Running; - if stage.started_at_micros.is_none() { - stage.started_at_micros = Some(input.created_at_micros); - } - stage.text_output = aggregated_text.clone(); - snapshot.latest_text_output = aggregated_text; - snapshot.updated_at_micros = input.created_at_micros; - snapshot.version += 1; - - persist_ai_task_snapshot(ctx, &snapshot)?; - Ok((snapshot, chunk)) -} - -fn complete_ai_stage_tx( - ctx: &ReducerContext, - input: AiStageCompletionInput, -) -> Result { - let mut snapshot = get_ai_task_snapshot_tx(ctx, &input.task_id)?; - ensure_ai_task_can_transition(snapshot.status)?; - - let stage = snapshot - .stages - .iter_mut() - .find(|stage| stage.stage_kind == input.stage_kind) - .ok_or_else(|| "ai_task.stage 不存在".to_string())?; - - stage.status = AiTaskStageStatus::Completed; - stage.completed_at_micros = Some(input.completed_at_micros); - stage.text_output = normalize_optional_text(input.text_output.clone()); - stage.structured_payload_json = normalize_optional_text(input.structured_payload_json.clone()); - stage.warning_messages = normalize_string_list(input.warning_messages.clone()); - - snapshot.latest_text_output = stage.text_output.clone(); - snapshot.latest_structured_payload_json = stage.structured_payload_json.clone(); - snapshot.updated_at_micros = input.completed_at_micros; - snapshot.version += 1; - - persist_ai_task_snapshot(ctx, &snapshot)?; - Ok(snapshot) -} - -fn attach_ai_result_reference_tx( - ctx: &ReducerContext, - input: AiResultReferenceInput, -) -> Result { - let reference_id = input.reference_id.trim().to_string(); - if reference_id.is_empty() { - return Err("ai_result_reference.reference_id 不能为空".to_string()); - } - - let mut snapshot = get_ai_task_snapshot_tx(ctx, &input.task_id)?; - ensure_ai_task_can_transition(snapshot.status)?; - - let reference = AiResultReferenceSnapshot { - result_ref_id: generate_ai_result_ref_id(input.created_at_micros), - task_id: input.task_id.trim().to_string(), - reference_kind: input.reference_kind, - reference_id, - label: normalize_optional_text(input.label), - created_at_micros: input.created_at_micros, - }; - ctx.db - .ai_result_reference() - .insert(build_ai_result_reference_row(&reference)); - - snapshot.result_references.push(reference); - snapshot.updated_at_micros = input.created_at_micros; - snapshot.version += 1; - - persist_ai_task_snapshot(ctx, &snapshot)?; - Ok(snapshot) -} - -fn complete_ai_task_tx( - ctx: &ReducerContext, - input: AiTaskFinishInput, -) -> Result { - let mut snapshot = get_ai_task_snapshot_tx(ctx, &input.task_id)?; - ensure_ai_task_can_transition(snapshot.status)?; - - snapshot.status = AiTaskStatus::Completed; - snapshot.completed_at_micros = Some(input.completed_at_micros); - snapshot.updated_at_micros = input.completed_at_micros; - snapshot.version += 1; - - persist_ai_task_snapshot(ctx, &snapshot)?; - Ok(snapshot) -} - -fn fail_ai_task_tx( - ctx: &ReducerContext, - input: AiTaskFailureInput, -) -> Result { - let failure_message = input.failure_message.trim().to_string(); - if failure_message.is_empty() { - return Err("ai_task.failure_message 不能为空".to_string()); - } - - let mut snapshot = get_ai_task_snapshot_tx(ctx, &input.task_id)?; - ensure_ai_task_can_transition(snapshot.status)?; - - snapshot.status = AiTaskStatus::Failed; - snapshot.failure_message = Some(failure_message); - snapshot.completed_at_micros = Some(input.completed_at_micros); - snapshot.updated_at_micros = input.completed_at_micros; - snapshot.version += 1; - - persist_ai_task_snapshot(ctx, &snapshot)?; - Ok(snapshot) -} - -fn cancel_ai_task_tx( - ctx: &ReducerContext, - input: AiTaskCancelInput, -) -> Result { - let mut snapshot = get_ai_task_snapshot_tx(ctx, &input.task_id)?; - ensure_ai_task_can_transition(snapshot.status)?; - - snapshot.status = AiTaskStatus::Cancelled; - snapshot.completed_at_micros = Some(input.completed_at_micros); - snapshot.updated_at_micros = input.completed_at_micros; - snapshot.version += 1; - - persist_ai_task_snapshot(ctx, &snapshot)?; - Ok(snapshot) -} - -fn get_ai_task_snapshot_tx(ctx: &ReducerContext, task_id: &str) -> Result { - let row = ctx - .db - .ai_task() - .task_id() - .find(&task_id.trim().to_string()) - .ok_or_else(|| "ai_task 不存在".to_string())?; - - Ok(build_ai_task_snapshot_from_row(ctx, &row)) -} - -fn persist_ai_task_snapshot(ctx: &ReducerContext, snapshot: &AiTaskSnapshot) -> Result<(), String> { - ctx.db.ai_task().task_id().delete(&snapshot.task_id); - ctx.db.ai_task().insert(build_ai_task_row(snapshot)); - replace_ai_task_stages(ctx, &snapshot.task_id, &snapshot.stages); - Ok(()) -} - -fn replace_ai_task_stages(ctx: &ReducerContext, task_id: &str, stages: &[AiTaskStageSnapshot]) { - let stage_ids = ctx - .db - .ai_task_stage() - .iter() - .filter(|row| row.task_id == task_id) - .map(|row| row.task_stage_id.clone()) - .collect::>(); - for stage_id in stage_ids { - ctx.db.ai_task_stage().task_stage_id().delete(&stage_id); - } - - for stage in stages { - ctx.db - .ai_task_stage() - .insert(build_ai_task_stage_row(task_id, stage)); - } -} - -fn collect_ai_stage_text_output( - ctx: &ReducerContext, - task_id: &str, - stage_kind: AiTaskStageKind, -) -> Option { - let mut chunks = ctx - .db - .ai_text_chunk() - .iter() - .filter(|row| row.task_id == task_id && row.stage_kind == stage_kind) - .map(|row| build_ai_text_chunk_snapshot_from_row(&row)) - .collect::>(); - chunks.sort_by_key(|chunk| chunk.sequence); - - let aggregated = chunks - .into_iter() - .map(|chunk| chunk.delta_text) - .collect::>() - .join(""); - if aggregated.trim().is_empty() { - None - } else { - Some(aggregated) - } -} - -fn ensure_ai_task_can_transition(status: AiTaskStatus) -> Result<(), String> { - if matches!( - status, - AiTaskStatus::Completed | AiTaskStatus::Failed | AiTaskStatus::Cancelled - ) { - Err("当前 ai_task 状态不允许执行该操作".to_string()) - } else { - Ok(()) - } -} - -fn build_ai_task_snapshot_from_create_input(input: &AiTaskCreateInput) -> AiTaskSnapshot { - AiTaskSnapshot { - task_id: input.task_id.trim().to_string(), - task_kind: input.task_kind, - owner_user_id: input.owner_user_id.trim().to_string(), - request_label: input.request_label.trim().to_string(), - source_module: input.source_module.trim().to_string(), - source_entity_id: normalize_optional_text(input.source_entity_id.clone()), - request_payload_json: normalize_optional_text(input.request_payload_json.clone()), - status: AiTaskStatus::Pending, - failure_message: None, - stages: input - .stages - .iter() - .map(|stage| AiTaskStageSnapshot { - stage_kind: stage.stage_kind, - label: stage.label.trim().to_string(), - detail: stage.detail.trim().to_string(), - order: stage.order, - status: AiTaskStageStatus::Pending, - text_output: None, - structured_payload_json: None, - warning_messages: Vec::new(), - started_at_micros: None, - completed_at_micros: None, - }) - .collect(), - result_references: Vec::new(), - latest_text_output: None, - latest_structured_payload_json: None, - version: INITIAL_AI_TASK_VERSION, - created_at_micros: input.created_at_micros, - started_at_micros: None, - completed_at_micros: None, - updated_at_micros: input.created_at_micros, - } -} - -fn build_ai_task_row(snapshot: &AiTaskSnapshot) -> AiTask { - AiTask { - task_id: snapshot.task_id.clone(), - task_kind: snapshot.task_kind, - owner_user_id: snapshot.owner_user_id.clone(), - request_label: snapshot.request_label.clone(), - source_module: snapshot.source_module.clone(), - source_entity_id: snapshot.source_entity_id.clone(), - request_payload_json: snapshot.request_payload_json.clone(), - status: snapshot.status, - failure_message: snapshot.failure_message.clone(), - latest_text_output: snapshot.latest_text_output.clone(), - latest_structured_payload_json: snapshot.latest_structured_payload_json.clone(), - version: snapshot.version, - created_at: Timestamp::from_micros_since_unix_epoch(snapshot.created_at_micros), - started_at: snapshot - .started_at_micros - .map(Timestamp::from_micros_since_unix_epoch), - completed_at: snapshot - .completed_at_micros - .map(Timestamp::from_micros_since_unix_epoch), - updated_at: Timestamp::from_micros_since_unix_epoch(snapshot.updated_at_micros), - } -} - -fn build_ai_task_snapshot_from_row(ctx: &ReducerContext, row: &AiTask) -> AiTaskSnapshot { - let mut stages = ctx - .db - .ai_task_stage() - .iter() - .filter(|stage| stage.task_id == row.task_id) - .map(|stage| build_ai_task_stage_snapshot_from_row(&stage)) - .collect::>(); - stages.sort_by_key(|stage| stage.order); - - let mut result_references = ctx - .db - .ai_result_reference() - .iter() - .filter(|reference| reference.task_id == row.task_id) - .map(|reference| build_ai_result_reference_snapshot_from_row(&reference)) - .collect::>(); - result_references.sort_by_key(|reference| reference.created_at_micros); - - AiTaskSnapshot { - task_id: row.task_id.clone(), - task_kind: row.task_kind, - owner_user_id: row.owner_user_id.clone(), - request_label: row.request_label.clone(), - source_module: row.source_module.clone(), - source_entity_id: row.source_entity_id.clone(), - request_payload_json: row.request_payload_json.clone(), - status: row.status, - failure_message: row.failure_message.clone(), - stages, - result_references, - latest_text_output: row.latest_text_output.clone(), - latest_structured_payload_json: row.latest_structured_payload_json.clone(), - version: row.version, - created_at_micros: row.created_at.to_micros_since_unix_epoch(), - started_at_micros: row - .started_at - .map(|value| value.to_micros_since_unix_epoch()), - completed_at_micros: row - .completed_at - .map(|value| value.to_micros_since_unix_epoch()), - updated_at_micros: row.updated_at.to_micros_since_unix_epoch(), - } -} - -fn build_ai_task_stage_row(task_id: &str, snapshot: &AiTaskStageSnapshot) -> AiTaskStage { - AiTaskStage { - task_stage_id: generate_ai_task_stage_id(task_id, snapshot.stage_kind), - task_id: task_id.to_string(), - stage_kind: snapshot.stage_kind, - label: snapshot.label.clone(), - detail: snapshot.detail.clone(), - stage_order: snapshot.order, - status: snapshot.status, - text_output: snapshot.text_output.clone(), - structured_payload_json: snapshot.structured_payload_json.clone(), - warning_messages: snapshot.warning_messages.clone(), - started_at: snapshot - .started_at_micros - .map(Timestamp::from_micros_since_unix_epoch), - completed_at: snapshot - .completed_at_micros - .map(Timestamp::from_micros_since_unix_epoch), - } -} - -fn build_ai_task_stage_snapshot_from_row(row: &AiTaskStage) -> AiTaskStageSnapshot { - AiTaskStageSnapshot { - stage_kind: row.stage_kind, - label: row.label.clone(), - detail: row.detail.clone(), - order: row.stage_order, - status: row.status, - text_output: row.text_output.clone(), - structured_payload_json: row.structured_payload_json.clone(), - warning_messages: row.warning_messages.clone(), - started_at_micros: row - .started_at - .map(|value| value.to_micros_since_unix_epoch()), - completed_at_micros: row - .completed_at - .map(|value| value.to_micros_since_unix_epoch()), - } -} - -fn build_ai_text_chunk_row(snapshot: &AiTextChunkSnapshot) -> AiTextChunk { - AiTextChunk { - text_chunk_row_id: format!( - "{}{}_{}_{}", - AI_TEXT_CHUNK_ID_PREFIX, - snapshot.task_id, - snapshot.stage_kind.as_str(), - snapshot.sequence - ), - chunk_id: snapshot.chunk_id.clone(), - task_id: snapshot.task_id.clone(), - stage_kind: snapshot.stage_kind, - sequence: snapshot.sequence, - delta_text: snapshot.delta_text.clone(), - created_at: Timestamp::from_micros_since_unix_epoch(snapshot.created_at_micros), - } -} - -fn build_ai_text_chunk_snapshot_from_row(row: &AiTextChunk) -> AiTextChunkSnapshot { - AiTextChunkSnapshot { - chunk_id: row.chunk_id.clone(), - task_id: row.task_id.clone(), - stage_kind: row.stage_kind, - sequence: row.sequence, - delta_text: row.delta_text.clone(), - created_at_micros: row.created_at.to_micros_since_unix_epoch(), - } -} - -fn build_ai_result_reference_row(snapshot: &AiResultReferenceSnapshot) -> AiResultReference { - AiResultReference { - result_reference_row_id: format!( - "{}{}_{}", - AI_RESULT_REF_ID_PREFIX, snapshot.task_id, snapshot.result_ref_id - ), - result_ref_id: snapshot.result_ref_id.clone(), - task_id: snapshot.task_id.clone(), - reference_kind: snapshot.reference_kind, - reference_id: snapshot.reference_id.clone(), - label: snapshot.label.clone(), - created_at: Timestamp::from_micros_since_unix_epoch(snapshot.created_at_micros), - } -} - -fn build_ai_result_reference_snapshot_from_row( - row: &AiResultReference, -) -> AiResultReferenceSnapshot { - AiResultReferenceSnapshot { - result_ref_id: row.result_ref_id.clone(), - task_id: row.task_id.clone(), - reference_kind: row.reference_kind, - reference_id: row.reference_id.clone(), - label: row.label.clone(), - created_at_micros: row.created_at.to_micros_since_unix_epoch(), - } -} - -fn build_quest_record_row(snapshot: QuestRecordSnapshot) -> QuestRecord { - QuestRecord { - quest_id: snapshot.quest_id, - runtime_session_id: snapshot.runtime_session_id, - story_session_id: snapshot.story_session_id, - actor_user_id: snapshot.actor_user_id, - issuer_npc_id: snapshot.issuer_npc_id, - issuer_npc_name: snapshot.issuer_npc_name, - scene_id: snapshot.scene_id, - chapter_id: snapshot.chapter_id, - act_id: snapshot.act_id, - thread_id: snapshot.thread_id, - contract_id: snapshot.contract_id, - title: snapshot.title, - description: snapshot.description, - summary: snapshot.summary, - objective: snapshot.objective, - progress: snapshot.progress, - status: snapshot.status, - completion_notified: snapshot.completion_notified, - reward: snapshot.reward, - reward_text: snapshot.reward_text, - narrative_binding: snapshot.narrative_binding, - steps: snapshot.steps, - active_step_id: snapshot.active_step_id, - visible_stage: snapshot.visible_stage, - hidden_flags: snapshot.hidden_flags, - discovered_fact_ids: snapshot.discovered_fact_ids, - related_carrier_ids: snapshot.related_carrier_ids, - consequence_ids: snapshot.consequence_ids, - created_at: Timestamp::from_micros_since_unix_epoch(snapshot.created_at_micros), - updated_at: Timestamp::from_micros_since_unix_epoch(snapshot.updated_at_micros), - completed_at: snapshot - .completed_at_micros - .map(Timestamp::from_micros_since_unix_epoch), - turned_in_at: snapshot - .turned_in_at_micros - .map(Timestamp::from_micros_since_unix_epoch), - } -} - -fn build_player_progression_row(snapshot: PlayerProgressionSnapshot) -> PlayerProgression { - PlayerProgression { - user_id: snapshot.user_id, - level: snapshot.level, - current_level_xp: snapshot.current_level_xp, - total_xp: snapshot.total_xp, - xp_to_next_level: snapshot.xp_to_next_level, - pending_level_ups: snapshot.pending_level_ups, - last_granted_source: snapshot.last_granted_source, - created_at: Timestamp::from_micros_since_unix_epoch(snapshot.created_at_micros), - updated_at: Timestamp::from_micros_since_unix_epoch(snapshot.updated_at_micros), - } -} - -fn build_player_progression_snapshot_from_row( - row: &PlayerProgression, -) -> PlayerProgressionSnapshot { - PlayerProgressionSnapshot { - user_id: row.user_id.clone(), - level: row.level, - current_level_xp: row.current_level_xp, - total_xp: row.total_xp, - xp_to_next_level: row.xp_to_next_level, - pending_level_ups: row.pending_level_ups, - last_granted_source: row.last_granted_source, - created_at_micros: row.created_at.to_micros_since_unix_epoch(), - updated_at_micros: row.updated_at.to_micros_since_unix_epoch(), - } -} - -fn build_chapter_progression_id(user_id: &str, chapter_id: &str) -> String { - format!("chapprog_{}_{}", user_id.trim(), chapter_id.trim()) -} - -fn build_chapter_progression_row(snapshot: ChapterProgressionSnapshot) -> ChapterProgression { - ChapterProgression { - chapter_progression_id: build_chapter_progression_id( - &snapshot.user_id, - &snapshot.chapter_id, - ), - user_id: snapshot.user_id, - chapter_id: snapshot.chapter_id, - chapter_index: snapshot.chapter_index, - total_chapters: snapshot.total_chapters, - entry_pseudo_level_millis: snapshot.entry_pseudo_level_millis, - exit_pseudo_level_millis: snapshot.exit_pseudo_level_millis, - entry_level: snapshot.entry_level, - exit_level: snapshot.exit_level, - planned_total_xp: snapshot.planned_total_xp, - planned_quest_xp: snapshot.planned_quest_xp, - planned_hostile_xp: snapshot.planned_hostile_xp, - actual_quest_xp: snapshot.actual_quest_xp, - actual_hostile_xp: snapshot.actual_hostile_xp, - expected_hostile_defeat_count: snapshot.expected_hostile_defeat_count, - actual_hostile_defeat_count: snapshot.actual_hostile_defeat_count, - level_at_entry: snapshot.level_at_entry, - level_at_exit: snapshot.level_at_exit, - pace_band: snapshot.pace_band, - created_at: Timestamp::from_micros_since_unix_epoch(snapshot.created_at_micros), - updated_at: Timestamp::from_micros_since_unix_epoch(snapshot.updated_at_micros), - } -} - -fn build_chapter_progression_snapshot_from_row( - row: &ChapterProgression, -) -> ChapterProgressionSnapshot { - ChapterProgressionSnapshot { - user_id: row.user_id.clone(), - chapter_id: row.chapter_id.clone(), - chapter_index: row.chapter_index, - total_chapters: row.total_chapters, - entry_pseudo_level_millis: row.entry_pseudo_level_millis, - exit_pseudo_level_millis: row.exit_pseudo_level_millis, - entry_level: row.entry_level, - exit_level: row.exit_level, - planned_total_xp: row.planned_total_xp, - planned_quest_xp: row.planned_quest_xp, - planned_hostile_xp: row.planned_hostile_xp, - actual_quest_xp: row.actual_quest_xp, - actual_hostile_xp: row.actual_hostile_xp, - expected_hostile_defeat_count: row.expected_hostile_defeat_count, - actual_hostile_defeat_count: row.actual_hostile_defeat_count, - level_at_entry: row.level_at_entry, - level_at_exit: row.level_at_exit, - pace_band: row.pace_band, - created_at_micros: row.created_at.to_micros_since_unix_epoch(), - updated_at_micros: row.updated_at.to_micros_since_unix_epoch(), - } -} - -fn build_quest_record_snapshot_from_row(row: &QuestRecord) -> QuestRecordSnapshot { - QuestRecordSnapshot { - quest_id: row.quest_id.clone(), - runtime_session_id: row.runtime_session_id.clone(), - story_session_id: row.story_session_id.clone(), - actor_user_id: row.actor_user_id.clone(), - issuer_npc_id: row.issuer_npc_id.clone(), - issuer_npc_name: row.issuer_npc_name.clone(), - scene_id: row.scene_id.clone(), - chapter_id: row.chapter_id.clone(), - act_id: row.act_id.clone(), - thread_id: row.thread_id.clone(), - contract_id: row.contract_id.clone(), - title: row.title.clone(), - description: row.description.clone(), - summary: row.summary.clone(), - objective: row.objective.clone(), - progress: row.progress, - status: row.status, - completion_notified: row.completion_notified, - reward: row.reward.clone(), - reward_text: row.reward_text.clone(), - narrative_binding: row.narrative_binding.clone(), - steps: row.steps.clone(), - active_step_id: row.active_step_id.clone(), - visible_stage: row.visible_stage, - hidden_flags: row.hidden_flags.clone(), - discovered_fact_ids: row.discovered_fact_ids.clone(), - related_carrier_ids: row.related_carrier_ids.clone(), - consequence_ids: row.consequence_ids.clone(), - created_at_micros: row.created_at.to_micros_since_unix_epoch(), - updated_at_micros: row.updated_at.to_micros_since_unix_epoch(), - completed_at_micros: row - .completed_at - .map(|value| value.to_micros_since_unix_epoch()), - turned_in_at_micros: row - .turned_in_at - .map(|value| value.to_micros_since_unix_epoch()), - } -} - -fn build_inventory_slot_row(snapshot: InventorySlotSnapshot) -> InventorySlot { - InventorySlot { - slot_id: snapshot.slot_id, - runtime_session_id: snapshot.runtime_session_id, - story_session_id: snapshot.story_session_id, - actor_user_id: snapshot.actor_user_id, - container_kind: snapshot.container_kind, - slot_key: snapshot.slot_key, - item_id: snapshot.item_id, - category: snapshot.category, - name: snapshot.name, - description: snapshot.description, - quantity: snapshot.quantity, - rarity: snapshot.rarity, - tags: snapshot.tags, - stackable: snapshot.stackable, - stack_key: snapshot.stack_key, - equipment_slot_id: snapshot.equipment_slot_id, - source_kind: snapshot.source_kind, - source_reference_id: snapshot.source_reference_id, - created_at: Timestamp::from_micros_since_unix_epoch(snapshot.created_at_micros), - updated_at: Timestamp::from_micros_since_unix_epoch(snapshot.updated_at_micros), - } -} - -fn build_inventory_slot_snapshot_from_row(row: &InventorySlot) -> InventorySlotSnapshot { - InventorySlotSnapshot { - slot_id: row.slot_id.clone(), - runtime_session_id: row.runtime_session_id.clone(), - story_session_id: row.story_session_id.clone(), - actor_user_id: row.actor_user_id.clone(), - container_kind: row.container_kind, - slot_key: row.slot_key.clone(), - item_id: row.item_id.clone(), - category: row.category.clone(), - name: row.name.clone(), - description: row.description.clone(), - quantity: row.quantity, - rarity: row.rarity, - tags: row.tags.clone(), - stackable: row.stackable, - stack_key: row.stack_key.clone(), - equipment_slot_id: row.equipment_slot_id, - source_kind: row.source_kind, - source_reference_id: row.source_reference_id.clone(), - created_at_micros: row.created_at.to_micros_since_unix_epoch(), - updated_at_micros: row.updated_at.to_micros_since_unix_epoch(), - } -} - -fn grant_quest_reward_items( - ctx: &ReducerContext, - snapshot: &QuestRecordSnapshot, -) -> Result<(), String> { - if !ctx - .db - .inventory_slot() - .iter() - .filter(|row| { - row.runtime_session_id == snapshot.runtime_session_id - && row.actor_user_id == snapshot.actor_user_id - }) - .all(|row| row.source_reference_id.as_deref() != Some(snapshot.quest_id.as_str())) - { - return Ok(()); - } - - for (index, reward_item) in snapshot.reward.items.clone().into_iter().enumerate() { - let inventory_item = - build_inventory_item_snapshot_from_quest_reward_item(&snapshot.quest_id, reward_item); - grant_inventory_item_to_actor( - ctx, - &snapshot.runtime_session_id, - snapshot.story_session_id.clone(), - &snapshot.actor_user_id, - inventory_item, - build_reward_seed(snapshot.updated_at_micros, index), - snapshot.updated_at_micros, - )?; - } - - Ok(()) -} - -fn grant_battle_reward_items( - ctx: &ReducerContext, - snapshot: &BattleStateSnapshot, -) -> Result<(), String> { - if snapshot.reward_items.is_empty() { - return Ok(()); - } - - if !ctx - .db - .inventory_slot() - .iter() - .filter(|row| { - row.runtime_session_id == snapshot.runtime_session_id - && row.actor_user_id == snapshot.actor_user_id - }) - .all(|row| row.source_reference_id.as_deref() != Some(snapshot.battle_state_id.as_str())) - { - return Ok(()); - } - - for (index, reward_item) in snapshot.reward_items.clone().into_iter().enumerate() { - let inventory_item = build_inventory_item_snapshot_from_battle_reward_item( - &snapshot.battle_state_id, - reward_item, - ); - grant_inventory_item_to_actor( - ctx, - &snapshot.runtime_session_id, - Some(snapshot.story_session_id.clone()), - &snapshot.actor_user_id, - inventory_item, - build_reward_seed(snapshot.updated_at_micros, index), - snapshot.updated_at_micros, - )?; - } - - Ok(()) -} - -fn grant_inventory_item_to_actor( - ctx: &ReducerContext, - runtime_session_id: &str, - story_session_id: Option, - actor_user_id: &str, - item: InventoryItemSnapshot, - seed_micros: i64, - updated_at_micros: i64, -) -> Result<(), String> { - let current_slots = ctx - .db - .inventory_slot() - .iter() - .filter(|row| { - row.runtime_session_id == runtime_session_id && row.actor_user_id == actor_user_id - }) - .map(|row| build_inventory_slot_snapshot_from_row(&row)) - .collect::>(); - let slot_id = generate_inventory_slot_id(seed_micros); - let mutation_id = generate_inventory_mutation_id(seed_micros); - let outcome = apply_inventory_slot_mutation( - current_slots, - InventoryMutationInput { - mutation_id, - runtime_session_id: runtime_session_id.to_string(), - story_session_id, - actor_user_id: actor_user_id.to_string(), - mutation: InventoryMutation::GrantItem(GrantInventoryItemInput { slot_id, item }), - updated_at_micros, - }, - ) - .map_err(|error| error.to_string())?; - - for removed_slot_id in outcome.removed_slot_ids { - ctx.db.inventory_slot().slot_id().delete(&removed_slot_id); - } - for slot in outcome.next_slots { - ctx.db.inventory_slot().slot_id().delete(&slot.slot_id); - ctx.db - .inventory_slot() - .insert(build_inventory_slot_row(slot)); - } - - Ok(()) -} - -fn build_inventory_item_snapshot_from_battle_reward_item( - battle_state_id: &str, - reward_item: RuntimeItemRewardItemSnapshot, -) -> InventoryItemSnapshot { - InventoryItemSnapshot { - item_id: reward_item.item_id, - category: reward_item.category, - name: reward_item.item_name, - description: reward_item.description, - quantity: reward_item.quantity, - rarity: map_runtime_reward_item_rarity(reward_item.rarity), - tags: reward_item.tags, - stackable: reward_item.stackable, - stack_key: reward_item.stack_key, - equipment_slot_id: reward_item - .equipment_slot_id - .map(map_runtime_reward_equipment_slot), - source_kind: InventoryItemSourceKind::CombatDrop, - source_reference_id: Some(battle_state_id.to_string()), - } -} - -fn build_inventory_item_snapshot_from_quest_reward_item( - quest_id: &str, - reward_item: QuestRewardItem, -) -> InventoryItemSnapshot { - InventoryItemSnapshot { - item_id: reward_item.item_id, - category: reward_item.category, - name: reward_item.name, - description: reward_item.description, - quantity: reward_item.quantity, - rarity: map_quest_reward_item_rarity(reward_item.rarity), - tags: reward_item.tags, - stackable: reward_item.stackable, - stack_key: reward_item.stack_key, - equipment_slot_id: reward_item - .equipment_slot_id - .map(map_quest_reward_equipment_slot), - source_kind: InventoryItemSourceKind::QuestReward, - source_reference_id: Some(quest_id.to_string()), - } -} - -fn map_quest_reward_item_rarity(rarity: QuestRewardItemRarity) -> InventoryItemRarity { - match rarity { - QuestRewardItemRarity::Common => InventoryItemRarity::Common, - QuestRewardItemRarity::Uncommon => InventoryItemRarity::Uncommon, - QuestRewardItemRarity::Rare => InventoryItemRarity::Rare, - QuestRewardItemRarity::Epic => InventoryItemRarity::Epic, - QuestRewardItemRarity::Legendary => InventoryItemRarity::Legendary, - } -} - -fn map_runtime_reward_item_rarity( - rarity: module_runtime_item::RuntimeItemRewardItemRarity, -) -> InventoryItemRarity { - match rarity { - module_runtime_item::RuntimeItemRewardItemRarity::Common => InventoryItemRarity::Common, - module_runtime_item::RuntimeItemRewardItemRarity::Uncommon => InventoryItemRarity::Uncommon, - module_runtime_item::RuntimeItemRewardItemRarity::Rare => InventoryItemRarity::Rare, - module_runtime_item::RuntimeItemRewardItemRarity::Epic => InventoryItemRarity::Epic, - module_runtime_item::RuntimeItemRewardItemRarity::Legendary => { - InventoryItemRarity::Legendary - } - } -} - -fn map_quest_reward_equipment_slot(slot: QuestRewardEquipmentSlot) -> InventoryEquipmentSlot { - match slot { - QuestRewardEquipmentSlot::Weapon => InventoryEquipmentSlot::Weapon, - QuestRewardEquipmentSlot::Armor => InventoryEquipmentSlot::Armor, - QuestRewardEquipmentSlot::Relic => InventoryEquipmentSlot::Relic, - } -} - -fn map_runtime_reward_equipment_slot( - slot: module_runtime_item::RuntimeItemEquipmentSlot, -) -> InventoryEquipmentSlot { - match slot { - module_runtime_item::RuntimeItemEquipmentSlot::Weapon => InventoryEquipmentSlot::Weapon, - module_runtime_item::RuntimeItemEquipmentSlot::Armor => InventoryEquipmentSlot::Armor, - module_runtime_item::RuntimeItemEquipmentSlot::Relic => InventoryEquipmentSlot::Relic, - } -} - -fn build_reward_seed(updated_at_micros: i64, index: usize) -> i64 { - updated_at_micros.saturating_add(index as i64 + 1) -} - -fn build_story_session_snapshot_from_row(row: &StorySession) -> StorySessionSnapshot { - StorySessionSnapshot { - story_session_id: row.story_session_id.clone(), - runtime_session_id: row.runtime_session_id.clone(), - actor_user_id: row.actor_user_id.clone(), - world_profile_id: row.world_profile_id.clone(), - initial_prompt: row.initial_prompt.clone(), - opening_summary: row.opening_summary.clone(), - latest_narrative_text: row.latest_narrative_text.clone(), - latest_choice_function_id: row.latest_choice_function_id.clone(), - status: row.status, - version: row.version, - created_at_micros: row.created_at.to_micros_since_unix_epoch(), - updated_at_micros: row.updated_at.to_micros_since_unix_epoch(), - } -} - -fn build_story_event_snapshot_from_row(row: &StoryEvent) -> StoryEventSnapshot { - StoryEventSnapshot { - event_id: row.event_id.clone(), - story_session_id: row.story_session_id.clone(), - event_kind: row.event_kind, - narrative_text: row.narrative_text.clone(), - choice_function_id: row.choice_function_id.clone(), - created_at_micros: row.created_at.to_micros_since_unix_epoch(), - } -} - -fn build_treasure_record_snapshot_from_row(row: &TreasureRecord) -> TreasureRecordSnapshot { - TreasureRecordSnapshot { - treasure_record_id: row.treasure_record_id.clone(), - runtime_session_id: row.runtime_session_id.clone(), - story_session_id: row.story_session_id.clone(), - actor_user_id: row.actor_user_id.clone(), - encounter_id: row.encounter_id.clone(), - encounter_name: row.encounter_name.clone(), - scene_id: row.scene_id.clone(), - scene_name: row.scene_name.clone(), - action: row.action, - reward_items: row.reward_items.clone(), - reward_hp: row.reward_hp, - reward_mana: row.reward_mana, - reward_currency: row.reward_currency, - story_hint: row.story_hint.clone(), - created_at_micros: row.created_at.to_micros_since_unix_epoch(), - updated_at_micros: row.updated_at.to_micros_since_unix_epoch(), - } -} - -fn append_quest_log( - ctx: &ReducerContext, - snapshot: &QuestRecordSnapshot, - event_kind: QuestLogEventKind, - signal_kind: Option, - signal: Option, - step_id: Option, - step_progress: Option, - created_at_micros: i64, -) { - ctx.db.quest_log().insert(QuestLog { - log_id: generate_quest_log_id(&snapshot.quest_id, event_kind, created_at_micros), - quest_id: snapshot.quest_id.clone(), - runtime_session_id: snapshot.runtime_session_id.clone(), - actor_user_id: snapshot.actor_user_id.clone(), - event_kind, - status_after: snapshot.status, - signal_kind, - signal, - step_id, - step_progress, - created_at: Timestamp::from_micros_since_unix_epoch(created_at_micros), - }); -} - -fn get_player_progression_snapshot_tx( - ctx: &ReducerContext, - input: PlayerProgressionGetInput, -) -> Result { - let user_id = input.user_id.trim().to_string(); - if user_id.is_empty() { - return Err("player_progression.user_id 不能为空".to_string()); - } - - if let Some(existing) = ctx.db.player_progression().user_id().find(&user_id) { - return Ok(build_player_progression_snapshot_from_row(&existing)); - } - - create_initial_player_progression(user_id, 0).map_err(|error| error.to_string()) -} - -fn upsert_player_progression_after_grant_tx( - ctx: &ReducerContext, - input: PlayerProgressionGrantInput, -) -> Result { - let current = if let Some(existing) = ctx.db.player_progression().user_id().find(&input.user_id) - { - build_player_progression_snapshot_from_row(&existing) - } else { - create_initial_player_progression(input.user_id.clone(), input.updated_at_micros) - .map_err(|error| error.to_string())? - }; - - let next = grant_player_experience(current, input).map_err(|error| error.to_string())?; - if ctx - .db - .player_progression() - .user_id() - .find(&next.user_id) - .is_some() - { - ctx.db.player_progression().user_id().delete(&next.user_id); - } - ctx.db - .player_progression() - .insert(build_player_progression_row(next.clone())); - Ok(next) -} - -fn get_chapter_progression_snapshot_tx( - ctx: &ReducerContext, - input: ChapterProgressionGetInput, -) -> Result { - let user_id = input.user_id.trim().to_string(); - let chapter_id = input.chapter_id.trim().to_string(); - if user_id.is_empty() { - return Err("chapter_progression.user_id 不能为空".to_string()); - } - if chapter_id.is_empty() { - return Err("chapter_progression.chapter_id 不能为空".to_string()); - } - - let row_id = build_chapter_progression_id(&user_id, &chapter_id); - let existing = ctx - .db - .chapter_progression() - .chapter_progression_id() - .find(&row_id) - .ok_or_else(|| "chapter_progression 不存在".to_string())?; - - Ok(build_chapter_progression_snapshot_from_row(&existing)) -} - -fn upsert_chapter_progression_snapshot_tx( - ctx: &ReducerContext, - input: ChapterProgressionInput, -) -> Result { - let snapshot = build_chapter_progression_snapshot(input).map_err(|error| error.to_string())?; - let row_id = build_chapter_progression_id(&snapshot.user_id, &snapshot.chapter_id); - if ctx - .db - .chapter_progression() - .chapter_progression_id() - .find(&row_id) - .is_some() - { - ctx.db - .chapter_progression() - .chapter_progression_id() - .delete(&row_id); - } - ctx.db - .chapter_progression() - .insert(build_chapter_progression_row(snapshot.clone())); - Ok(snapshot) -} - -fn update_chapter_progression_ledger_tx( - ctx: &ReducerContext, - input: ChapterProgressionLedgerInput, -) -> Result { - let row_id = build_chapter_progression_id(&input.user_id, &input.chapter_id); - let current = ctx - .db - .chapter_progression() - .chapter_progression_id() - .find(&row_id) - .ok_or_else(|| "chapter_progression 不存在,无法记账".to_string())?; - let next = apply_chapter_progression_ledger( - build_chapter_progression_snapshot_from_row(¤t), - input, - ) - .map_err(|error| error.to_string())?; - - ctx.db - .chapter_progression() - .chapter_progression_id() - .delete(&row_id); - ctx.db - .chapter_progression() - .insert(build_chapter_progression_row(next.clone())); - Ok(next) -} - -fn try_update_chapter_progression_ledger_tx( - ctx: &ReducerContext, - user_id: String, - chapter_id: Option, - input: ChapterProgressionLedgerInput, -) -> Result, String> { - let Some(chapter_id) = chapter_id.map(|value| value.trim().to_string()) else { - return Ok(None); - }; - - if chapter_id.is_empty() || user_id.trim().is_empty() { - return Ok(None); - } - - let row_id = build_chapter_progression_id(user_id.trim(), &chapter_id); - if ctx - .db - .chapter_progression() - .chapter_progression_id() - .find(&row_id) - .is_none() - { - return Ok(None); - } - - update_chapter_progression_ledger_tx(ctx, input).map(Some) -} - -fn upsert_asset_entity_binding( - ctx: &ReducerContext, - input: AssetEntityBindingInput, -) -> Result { - validate_asset_entity_binding_fields( - &input.binding_id, - &input.asset_object_id, - &input.entity_kind, - &input.entity_id, - &input.slot, - &input.asset_kind, - ) - .map_err(|error| error.to_string())?; - - if ctx - .db - .asset_object() - .asset_object_id() - .find(&input.asset_object_id) - .is_none() - { - return Err("asset_entity_binding.asset_object_id 对应的 asset_object 不存在".to_string()); - } - - let updated_at = Timestamp::from_micros_since_unix_epoch(input.updated_at_micros); - // 首版绑定按 entity_kind + entity_id + slot 幂等定位,后续访问量明确后再改为组合索引扫描。 - let current = ctx.db.asset_entity_binding().iter().find(|row| { - row.entity_kind == input.entity_kind - && row.entity_id == input.entity_id - && row.slot == input.slot - }); - - let snapshot = match current { - Some(existing) => { - ctx.db - .asset_entity_binding() - .binding_id() - .delete(&existing.binding_id); - let row = AssetEntityBinding { - binding_id: existing.binding_id.clone(), - asset_object_id: input.asset_object_id.clone(), - entity_kind: input.entity_kind.clone(), - entity_id: input.entity_id.clone(), - slot: input.slot.clone(), - asset_kind: input.asset_kind.clone(), - owner_user_id: input.owner_user_id.clone(), - profile_id: input.profile_id.clone(), - created_at: existing.created_at, - updated_at, - }; - ctx.db.asset_entity_binding().insert(row); - - AssetEntityBindingSnapshot { - binding_id: existing.binding_id, - asset_object_id: input.asset_object_id, - entity_kind: input.entity_kind, - entity_id: input.entity_id, - slot: input.slot, - asset_kind: input.asset_kind, - owner_user_id: input.owner_user_id, - profile_id: input.profile_id, - created_at_micros: existing.created_at.to_micros_since_unix_epoch(), - updated_at_micros: input.updated_at_micros, - } - } - None => { - let created_at = updated_at; - let row = AssetEntityBinding { - binding_id: input.binding_id.clone(), - asset_object_id: input.asset_object_id.clone(), - entity_kind: input.entity_kind.clone(), - entity_id: input.entity_id.clone(), - slot: input.slot.clone(), - asset_kind: input.asset_kind.clone(), - owner_user_id: input.owner_user_id.clone(), - profile_id: input.profile_id.clone(), - created_at, - updated_at, - }; - ctx.db.asset_entity_binding().insert(row); - - AssetEntityBindingSnapshot { - binding_id: input.binding_id, - asset_object_id: input.asset_object_id, - entity_kind: input.entity_kind, - entity_id: input.entity_id, - slot: input.slot, - asset_kind: input.asset_kind, - owner_user_id: input.owner_user_id, - profile_id: input.profile_id, - created_at_micros: input.updated_at_micros, - updated_at_micros: input.updated_at_micros, - } - } - }; - - Ok(snapshot) -} - -fn get_runtime_setting_snapshot( - ctx: &ReducerContext, - input: RuntimeSettingGetInput, -) -> Result { - let validated_input = - build_runtime_setting_get_input(input.user_id).map_err(|error| error.to_string())?; - - if let Some(existing) = ctx - .db - .runtime_setting() - .user_id() - .find(&validated_input.user_id) - { - return Ok(RuntimeSettingSnapshot { - user_id: existing.user_id, - music_volume: existing.music_volume, - platform_theme: existing.platform_theme, - created_at_micros: existing.created_at.to_micros_since_unix_epoch(), - updated_at_micros: existing.updated_at.to_micros_since_unix_epoch(), - }); - } - - Ok(RuntimeSettingSnapshot { - user_id: validated_input.user_id, - music_volume: DEFAULT_MUSIC_VOLUME, - platform_theme: DEFAULT_PLATFORM_THEME, - created_at_micros: 0, - updated_at_micros: 0, - }) -} - -fn upsert_runtime_setting( - ctx: &ReducerContext, - input: RuntimeSettingUpsertInput, -) -> Result { - let validated_input = build_runtime_setting_upsert_input( - input.user_id, - input.music_volume, - input.platform_theme, - input.updated_at_micros, - ) - .map_err(|error| error.to_string())?; - let updated_at = Timestamp::from_micros_since_unix_epoch(validated_input.updated_at_micros); - - let snapshot = match ctx - .db - .runtime_setting() - .user_id() - .find(&validated_input.user_id) - { - Some(existing) => { - ctx.db.runtime_setting().user_id().delete(&existing.user_id); - ctx.db.runtime_setting().insert(RuntimeSetting { - user_id: existing.user_id.clone(), - music_volume: validated_input.music_volume, - platform_theme: validated_input.platform_theme, - created_at: existing.created_at, - updated_at, - }); - - RuntimeSettingSnapshot { - user_id: existing.user_id, - music_volume: validated_input.music_volume, - platform_theme: validated_input.platform_theme, - created_at_micros: existing.created_at.to_micros_since_unix_epoch(), - updated_at_micros: validated_input.updated_at_micros, - } - } - None => { - ctx.db.runtime_setting().insert(RuntimeSetting { - user_id: validated_input.user_id.clone(), - music_volume: validated_input.music_volume, - platform_theme: validated_input.platform_theme, - created_at: updated_at, - updated_at, - }); - - RuntimeSettingSnapshot { - user_id: validated_input.user_id, - music_volume: validated_input.music_volume, - platform_theme: validated_input.platform_theme, - created_at_micros: validated_input.updated_at_micros, - updated_at_micros: validated_input.updated_at_micros, - } - } - }; - - Ok(snapshot) -} - -fn get_runtime_snapshot_record( - ctx: &ReducerContext, - input: RuntimeSnapshotGetInput, -) -> Result, String> { - let validated_input = - build_runtime_snapshot_get_input(input.user_id).map_err(|error| error.to_string())?; - - Ok(ctx - .db - .runtime_snapshot() - .user_id() - .find(&validated_input.user_id) - .map(|row| build_runtime_snapshot_from_row(&row))) -} - -fn upsert_runtime_snapshot_record( - ctx: &ReducerContext, - input: RuntimeSnapshotUpsertInput, -) -> Result { - let current_story_value = parse_optional_json_str(input.current_story_json.as_deref())?; - let game_state = parse_json_str(&input.game_state_json)?; - let prepared = build_runtime_snapshot_upsert_input( - input.user_id, - input.saved_at_micros, - input.bottom_tab, - game_state.clone(), - current_story_value.clone(), - input.updated_at_micros, - ) - .map_err(|error| error.to_string())?; - let updated_at = Timestamp::from_micros_since_unix_epoch(prepared.updated_at_micros); - let saved_at = Timestamp::from_micros_since_unix_epoch(prepared.saved_at_micros); - - let snapshot = match ctx.db.runtime_snapshot().user_id().find(&prepared.user_id) { - Some(existing) => { - ctx.db.runtime_snapshot().user_id().delete(&existing.user_id); - ctx.db.runtime_snapshot().insert(RuntimeSnapshotRow { - user_id: existing.user_id.clone(), - version: SAVE_SNAPSHOT_VERSION, - saved_at, - bottom_tab: prepared.bottom_tab.clone(), - game_state_json: prepared.game_state_json.clone(), - current_story_json: prepared.current_story_json.clone(), - created_at: existing.created_at, - updated_at, - }); - - RuntimeSnapshot { - user_id: existing.user_id, - version: SAVE_SNAPSHOT_VERSION, - saved_at_micros: prepared.saved_at_micros, - bottom_tab: prepared.bottom_tab, - game_state_json: prepared.game_state_json, - current_story_json: prepared.current_story_json, - created_at_micros: existing.created_at.to_micros_since_unix_epoch(), - updated_at_micros: prepared.updated_at_micros, - } - } - None => { - ctx.db.runtime_snapshot().insert(RuntimeSnapshotRow { - user_id: prepared.user_id.clone(), - version: SAVE_SNAPSHOT_VERSION, - saved_at, - bottom_tab: prepared.bottom_tab.clone(), - game_state_json: prepared.game_state_json.clone(), - current_story_json: prepared.current_story_json.clone(), - created_at: updated_at, - updated_at, - }); - - RuntimeSnapshot { - user_id: prepared.user_id, - version: SAVE_SNAPSHOT_VERSION, - saved_at_micros: prepared.saved_at_micros, - bottom_tab: prepared.bottom_tab, - game_state_json: prepared.game_state_json, - current_story_json: prepared.current_story_json, - created_at_micros: prepared.updated_at_micros, - updated_at_micros: prepared.updated_at_micros, - } - } - }; - - sync_profile_projections_from_snapshot(ctx, &snapshot)?; - - Ok(snapshot) -} - -fn delete_runtime_snapshot_record( - ctx: &ReducerContext, - input: RuntimeSnapshotDeleteInput, -) -> Result, String> { - let validated_input = - build_runtime_snapshot_delete_input(input.user_id).map_err(|error| error.to_string())?; - - let existing = ctx - .db - .runtime_snapshot() - .user_id() - .find(&validated_input.user_id); - if let Some(existing) = existing { - let snapshot = build_runtime_snapshot_from_row(&existing); - ctx.db.runtime_snapshot().user_id().delete(&existing.user_id); - return Ok(Some(snapshot)); - } - - Ok(None) -} - -fn list_profile_save_archive_rows( - ctx: &ReducerContext, - input: RuntimeProfileSaveArchiveListInput, -) -> Result, String> { - let validated_input = - build_runtime_profile_save_archive_list_input(input.user_id).map_err(|error| error.to_string())?; - - let mut entries = ctx - .db - .profile_save_archive() - .iter() - .filter(|row| row.user_id == validated_input.user_id) - .map(|row| build_profile_save_archive_snapshot_from_row(&row)) - .collect::>(); - - entries.sort_by(|left, right| { - right - .saved_at_micros - .cmp(&left.saved_at_micros) - .then_with(|| left.archive_id.cmp(&right.archive_id)) - }); - - Ok(entries) -} - -fn resume_profile_save_archive_record( - ctx: &ReducerContext, - input: RuntimeProfileSaveArchiveResumeInput, -) -> Result<(RuntimeProfileSaveArchiveSnapshot, RuntimeSnapshot), String> { - let validated_input = build_runtime_profile_save_archive_resume_input(input.user_id, input.world_key) - .map_err(|error| error.to_string())?; - let archive = ctx - .db - .profile_save_archive() - .iter() - .find(|row| row.user_id == validated_input.user_id && row.world_key == validated_input.world_key) - .ok_or_else(|| "profile_save_archive 对应 world_key 不存在".to_string())?; - - let existing_snapshot = ctx - .db - .runtime_snapshot() - .user_id() - .find(&validated_input.user_id); - let created_at = existing_snapshot - .as_ref() - .map(|row| row.created_at) - .unwrap_or(archive.saved_at); - - if let Some(existing) = existing_snapshot { - ctx.db.runtime_snapshot().user_id().delete(&existing.user_id); - } - - ctx.db.runtime_snapshot().insert(RuntimeSnapshotRow { - user_id: archive.user_id.clone(), - version: SAVE_SNAPSHOT_VERSION, - saved_at: archive.saved_at, - bottom_tab: archive.bottom_tab.clone(), - game_state_json: archive.game_state_json.clone(), - current_story_json: archive.current_story_json.clone(), - created_at, - updated_at: archive.saved_at, - }); - - Ok(( - build_profile_save_archive_snapshot_from_row(&archive), - RuntimeSnapshot { - user_id: archive.user_id.clone(), - version: SAVE_SNAPSHOT_VERSION, - saved_at_micros: archive.saved_at.to_micros_since_unix_epoch(), - bottom_tab: archive.bottom_tab.clone(), - game_state_json: archive.game_state_json.clone(), - current_story_json: archive.current_story_json.clone(), - created_at_micros: created_at.to_micros_since_unix_epoch(), - updated_at_micros: archive.saved_at.to_micros_since_unix_epoch(), - }, - )) -} - -fn sync_profile_projections_from_snapshot( - ctx: &ReducerContext, - snapshot: &RuntimeSnapshot, -) -> Result<(), String> { - let game_state = parse_json_str(&snapshot.game_state_json)?; - let game_state_object = game_state.as_object(); - let saved_at = Timestamp::from_micros_since_unix_epoch(snapshot.saved_at_micros); - - sync_profile_dashboard_from_snapshot(ctx, snapshot, game_state_object, saved_at); - sync_profile_save_archive_from_snapshot(ctx, snapshot, &game_state, saved_at)?; - - Ok(()) -} - -fn sync_profile_dashboard_from_snapshot( - ctx: &ReducerContext, - snapshot: &RuntimeSnapshot, - game_state: Option<&serde_json::Map>, - saved_at: Timestamp, -) { - let current_state = ctx - .db - .profile_dashboard_state() - .user_id() - .find(&snapshot.user_id); - let previous_wallet_balance = current_state.as_ref().map(|row| row.wallet_balance).unwrap_or(0); - let previous_total_play_time_ms = current_state - .as_ref() - .map(|row| row.total_play_time_ms) - .unwrap_or(0); - let next_wallet_balance = read_non_negative_u64(game_state.and_then(|state| state.get("playerCurrency"))); - let mut next_total_play_time_ms = previous_total_play_time_ms; - - if next_wallet_balance != previous_wallet_balance { - ctx.db.profile_wallet_ledger().insert(ProfileWalletLedger { - wallet_ledger_id: format!( - "{}:{}:{}", - snapshot.user_id, - snapshot.saved_at_micros, - next_wallet_balance - ), - user_id: snapshot.user_id.clone(), - amount_delta: next_wallet_balance as i64 - previous_wallet_balance as i64, - balance_after: next_wallet_balance, - source_type: RuntimeProfileWalletLedgerSourceType::SnapshotSync, - created_at: saved_at, - }); - } - - if let Some(world_meta) = resolve_profile_world_snapshot_meta(game_state) { - let current_play_time_ms = read_non_negative_u64( - game_state - .and_then(|state| state.get("runtimeStats")) - .and_then(JsonValue::as_object) - .and_then(|stats| stats.get("playTimeMs")), - ); - let played_world_id = format!("{}:{}", snapshot.user_id, world_meta.world_key); - let existing = ctx.db.profile_played_world().played_world_id().find(&played_world_id); - let previous_observed_play_time_ms = existing - .as_ref() - .map(|row| row.last_observed_play_time_ms) - .unwrap_or(0); - let incremental_play_time_ms = current_play_time_ms.saturating_sub(previous_observed_play_time_ms); - next_total_play_time_ms = next_total_play_time_ms.saturating_add(incremental_play_time_ms); - - if let Some(existing) = existing { - ctx.db - .profile_played_world() - .played_world_id() - .delete(&existing.played_world_id); - ctx.db.profile_played_world().insert(ProfilePlayedWorld { - played_world_id, - user_id: snapshot.user_id.clone(), - world_key: world_meta.world_key, - owner_user_id: world_meta.owner_user_id, - profile_id: world_meta.profile_id, - world_type: world_meta.world_type, - world_title: world_meta.world_title, - world_subtitle: world_meta.world_subtitle, - first_played_at: existing.first_played_at, - last_played_at: saved_at, - last_observed_play_time_ms: current_play_time_ms.max(existing.last_observed_play_time_ms), - }); - } else { - ctx.db.profile_played_world().insert(ProfilePlayedWorld { - played_world_id, - user_id: snapshot.user_id.clone(), - world_key: world_meta.world_key, - owner_user_id: world_meta.owner_user_id, - profile_id: world_meta.profile_id, - world_type: world_meta.world_type, - world_title: world_meta.world_title, - world_subtitle: world_meta.world_subtitle, - first_played_at: saved_at, - last_played_at: saved_at, - last_observed_play_time_ms: current_play_time_ms, - }); - } - } - - if let Some(existing) = current_state { - ctx.db - .profile_dashboard_state() - .user_id() - .delete(&existing.user_id); - ctx.db.profile_dashboard_state().insert(ProfileDashboardState { - user_id: snapshot.user_id.clone(), - wallet_balance: next_wallet_balance, - total_play_time_ms: next_total_play_time_ms, - created_at: existing.created_at, - updated_at: saved_at, - }); - } else { - ctx.db.profile_dashboard_state().insert(ProfileDashboardState { - user_id: snapshot.user_id.clone(), - wallet_balance: next_wallet_balance, - total_play_time_ms: next_total_play_time_ms, - created_at: saved_at, - updated_at: saved_at, - }); - } -} - -fn sync_profile_save_archive_from_snapshot( - ctx: &ReducerContext, - snapshot: &RuntimeSnapshot, - game_state: &JsonValue, - saved_at: Timestamp, -) -> Result<(), String> { - let Some(archive_meta) = resolve_profile_save_archive_meta(game_state, snapshot.current_story_json.as_deref()) else { - return Ok(()); - }; - - let archive_id = format!("{}:{}", snapshot.user_id, archive_meta.world_key); - let existing = ctx - .db - .profile_save_archive() - .archive_id() - .find(&archive_id); - let created_at = existing.as_ref().map(|row| row.created_at).unwrap_or(saved_at); - - if let Some(existing) = existing { - ctx.db - .profile_save_archive() - .archive_id() - .delete(&existing.archive_id); - } - - ctx.db.profile_save_archive().insert(ProfileSaveArchive { - archive_id, - user_id: snapshot.user_id.clone(), - world_key: archive_meta.world_key, - owner_user_id: archive_meta.owner_user_id, - profile_id: archive_meta.profile_id, - world_type: archive_meta.world_type, - world_name: archive_meta.world_name, - subtitle: archive_meta.subtitle, - summary_text: archive_meta.summary_text, - cover_image_src: archive_meta.cover_image_src, - saved_at, - bottom_tab: snapshot.bottom_tab.clone(), - game_state_json: snapshot.game_state_json.clone(), - current_story_json: snapshot.current_story_json.clone(), - created_at, - updated_at: saved_at, - }); - - Ok(()) -} - -#[derive(Clone, Debug)] -struct ProfileWorldSnapshotMeta { - world_key: String, - owner_user_id: Option, - profile_id: Option, - world_type: Option, - world_title: String, - world_subtitle: String, -} - -#[derive(Clone, Debug)] -struct ProfileSaveArchiveMeta { - world_key: String, - owner_user_id: Option, - profile_id: Option, - world_type: Option, - world_name: String, - subtitle: String, - summary_text: String, - cover_image_src: Option, -} - -fn build_runtime_snapshot_from_row(row: &RuntimeSnapshotRow) -> RuntimeSnapshot { - RuntimeSnapshot { - user_id: row.user_id.clone(), - version: row.version, - saved_at_micros: row.saved_at.to_micros_since_unix_epoch(), - bottom_tab: row.bottom_tab.clone(), - game_state_json: row.game_state_json.clone(), - current_story_json: row.current_story_json.clone(), - created_at_micros: row.created_at.to_micros_since_unix_epoch(), - updated_at_micros: row.updated_at.to_micros_since_unix_epoch(), - } -} - -fn build_profile_save_archive_snapshot_from_row( - row: &ProfileSaveArchive, -) -> RuntimeProfileSaveArchiveSnapshot { - RuntimeProfileSaveArchiveSnapshot { - archive_id: row.archive_id.clone(), - user_id: row.user_id.clone(), - world_key: row.world_key.clone(), - owner_user_id: row.owner_user_id.clone(), - profile_id: row.profile_id.clone(), - world_type: row.world_type.clone(), - world_name: row.world_name.clone(), - subtitle: row.subtitle.clone(), - summary_text: row.summary_text.clone(), - cover_image_src: row.cover_image_src.clone(), - saved_at_micros: row.saved_at.to_micros_since_unix_epoch(), - bottom_tab: row.bottom_tab.clone(), - game_state_json: row.game_state_json.clone(), - current_story_json: row.current_story_json.clone(), - created_at_micros: row.created_at.to_micros_since_unix_epoch(), - updated_at_micros: row.updated_at.to_micros_since_unix_epoch(), - } -} - -fn parse_json_str(raw: &str) -> Result { - serde_json::from_str::(raw).map_err(|error| format!("game_state_json 解析失败: {error}")) -} - -fn parse_optional_json_str(raw: Option<&str>) -> Result, String> { - match raw.map(str::trim).filter(|value| !value.is_empty()) { - Some(value) => serde_json::from_str::(value) - .map(Some) - .map_err(|error| format!("current_story_json 解析失败: {error}")), - None => Ok(None), - } -} - -fn read_non_negative_u64(value: Option<&JsonValue>) -> u64 { - match value { - Some(JsonValue::Number(number)) => { - if let Some(raw) = number.as_u64() { - raw - } else if let Some(raw) = number.as_i64() { - raw.max(0) as u64 - } else if let Some(raw) = number.as_f64() { - if raw.is_finite() && raw > 0.0 { - raw.floor() as u64 - } else { - 0 - } - } else { - 0 - } - } - Some(JsonValue::String(raw)) => raw.trim().parse::().ok().unwrap_or(0), - _ => 0, - } -} - -fn read_string_from_json(value: Option<&JsonValue>) -> Option { - value - .and_then(JsonValue::as_str) - .map(str::trim) - .filter(|value| !value.is_empty()) - .map(ToString::to_string) -} - -fn resolve_profile_world_snapshot_meta( - game_state: Option<&serde_json::Map>, -) -> Option { - let game_state = game_state?; - let custom_world_profile = game_state.get("customWorldProfile").and_then(JsonValue::as_object); - - if let Some(custom_world_profile) = custom_world_profile { - let profile_id = read_string_from_json(custom_world_profile.get("id")); - let world_title = read_string_from_json(custom_world_profile.get("name")) - .or_else(|| read_string_from_json(custom_world_profile.get("title"))); - if profile_id.is_some() || world_title.is_some() { - let world_title = world_title.unwrap_or_else(|| "自定义世界".to_string()); - return Some(ProfileWorldSnapshotMeta { - world_key: profile_id - .as_ref() - .map(|profile_id| format!("custom:{profile_id}")) - .unwrap_or_else(|| format!("custom:{world_title}")), - owner_user_id: None, - profile_id, - world_type: Some("CUSTOM".to_string()), - world_title, - world_subtitle: read_string_from_json(custom_world_profile.get("summary")) - .or_else(|| read_string_from_json(custom_world_profile.get("settingText"))) - .unwrap_or_default(), - }); - } - } - - let world_type = read_string_from_json(game_state.get("worldType"))?; - let current_scene_preset = game_state.get("currentScenePreset").and_then(JsonValue::as_object); - - Some(ProfileWorldSnapshotMeta { - world_key: format!("builtin:{world_type}"), - owner_user_id: None, - profile_id: None, - world_type: Some(world_type.clone()), - world_title: current_scene_preset - .and_then(|preset| read_string_from_json(preset.get("name"))) - .unwrap_or_else(|| build_builtin_world_title(&world_type)), - world_subtitle: current_scene_preset - .and_then(|preset| { - read_string_from_json(preset.get("summary")) - .or_else(|| read_string_from_json(preset.get("description"))) - }) - .unwrap_or_default(), - }) -} - -fn resolve_profile_save_archive_meta( - game_state: &JsonValue, - current_story_json: Option<&str>, -) -> Option { - let game_state_object = game_state.as_object(); - let world_meta = resolve_profile_world_snapshot_meta(game_state_object)?; - let story_engine_memory = game_state_object - .and_then(|state| state.get("storyEngineMemory")) - .and_then(JsonValue::as_object); - let continue_game_digest = - story_engine_memory.and_then(|memory| read_string_from_json(memory.get("continueGameDigest"))); - let current_story_text = parse_optional_json_str(current_story_json) - .ok() - .flatten() - .and_then(|story| story.as_object().cloned()) - .and_then(|story| read_string_from_json(story.get("text"))); - let custom_world_profile = game_state_object - .and_then(|state| state.get("customWorldProfile")) - .and_then(JsonValue::as_object); - - if let Some(custom_world_profile) = custom_world_profile { - let world_name = read_string_from_json(custom_world_profile.get("name")) - .or_else(|| read_string_from_json(custom_world_profile.get("title"))) - .unwrap_or_else(|| world_meta.world_title.clone()); - let subtitle = read_string_from_json(custom_world_profile.get("summary")) - .or_else(|| read_string_from_json(custom_world_profile.get("settingText"))) - .unwrap_or_else(|| world_meta.world_subtitle.clone()); - let summary_text = continue_game_digest - .or(current_story_text) - .or_else(|| { - if subtitle.is_empty() { - None - } else { - Some(subtitle.clone()) - } - }) - .unwrap_or_else(|| DEFAULT_SAVE_ARCHIVE_SUMMARY_TEXT.to_string()); - - return Some(ProfileSaveArchiveMeta { - world_key: world_meta.world_key, - owner_user_id: world_meta.owner_user_id, - profile_id: world_meta.profile_id, - world_type: world_meta.world_type, - world_name, - subtitle, - summary_text, - cover_image_src: read_string_from_json(custom_world_profile.get("coverImageSrc")), - }); - } - - let summary_text = continue_game_digest - .or(current_story_text) - .or_else(|| { - if world_meta.world_subtitle.is_empty() { - None - } else { - Some(world_meta.world_subtitle.clone()) - } - }) - .unwrap_or_else(|| DEFAULT_SAVE_ARCHIVE_SUMMARY_TEXT.to_string()); - let current_scene_preset = game_state_object - .and_then(|state| state.get("currentScenePreset")) - .and_then(JsonValue::as_object); - - Some(ProfileSaveArchiveMeta { - world_key: world_meta.world_key, - owner_user_id: world_meta.owner_user_id, - profile_id: world_meta.profile_id, - world_type: world_meta.world_type, - world_name: world_meta.world_title, - subtitle: world_meta.world_subtitle.clone(), - summary_text, - cover_image_src: current_scene_preset - .and_then(|preset| read_string_from_json(preset.get("imageSrc"))), - }) -} - -fn build_builtin_world_title(world_type: &str) -> String { - match world_type { - "WUXIA" => "武侠世界".to_string(), - "XIANXIA" => "仙侠世界".to_string(), - _ => "叙事世界".to_string(), - } -} - -fn get_profile_dashboard_snapshot( - ctx: &ReducerContext, - input: RuntimeProfileDashboardGetInput, -) -> Result { - let validated_input = build_runtime_profile_dashboard_get_input(input.user_id) - .map_err(|error| error.to_string())?; - let state = ctx - .db - .profile_dashboard_state() - .user_id() - .find(&validated_input.user_id); - let played_world_count = ctx - .db - .profile_played_world() - .iter() - .filter(|row| row.user_id == validated_input.user_id) - .count() as u32; - - Ok(match state { - Some(existing) => RuntimeProfileDashboardSnapshot { - user_id: existing.user_id, - wallet_balance: existing.wallet_balance, - total_play_time_ms: existing.total_play_time_ms, - played_world_count, - updated_at_micros: Some(existing.updated_at.to_micros_since_unix_epoch()), - }, - None => RuntimeProfileDashboardSnapshot { - user_id: validated_input.user_id, - wallet_balance: 0, - total_play_time_ms: 0, - played_world_count, - updated_at_micros: None, - }, - }) -} - -fn list_profile_wallet_ledger_entries( - ctx: &ReducerContext, - input: RuntimeProfileWalletLedgerListInput, -) -> Result, String> { - let validated_input = build_runtime_profile_wallet_ledger_list_input(input.user_id) - .map_err(|error| error.to_string())?; - - let mut entries = ctx - .db - .profile_wallet_ledger() - .iter() - .filter(|row| row.user_id == validated_input.user_id) - .map(|row| build_profile_wallet_ledger_snapshot_from_row(&row)) - .collect::>(); - - entries.sort_by(|left, right| { - right - .created_at_micros - .cmp(&left.created_at_micros) - .then_with(|| left.wallet_ledger_id.cmp(&right.wallet_ledger_id)) - }); - entries.truncate(PROFILE_WALLET_LEDGER_LIST_LIMIT); - - Ok(entries) -} - -fn get_profile_play_stats_snapshot( - ctx: &ReducerContext, - input: RuntimeProfilePlayStatsGetInput, -) -> Result { - let validated_input = build_runtime_profile_play_stats_get_input(input.user_id) - .map_err(|error| error.to_string())?; - let dashboard_state = ctx - .db - .profile_dashboard_state() - .user_id() - .find(&validated_input.user_id); - let mut played_works = ctx - .db - .profile_played_world() - .iter() - .filter(|row| row.user_id == validated_input.user_id) - .map(|row| build_profile_played_world_snapshot_from_row(&row)) - .collect::>(); - - played_works.sort_by(|left, right| { - right - .last_played_at_micros - .cmp(&left.last_played_at_micros) - .then_with(|| left.played_world_id.cmp(&right.played_world_id)) - }); - - Ok(RuntimeProfilePlayStatsSnapshot { - user_id: validated_input.user_id, - total_play_time_ms: dashboard_state - .as_ref() - .map(|row| row.total_play_time_ms) - .unwrap_or(0), - played_works, - updated_at_micros: dashboard_state - .as_ref() - .map(|row| row.updated_at.to_micros_since_unix_epoch()), - }) -} - -fn list_platform_browse_history_rows( - ctx: &ReducerContext, - input: RuntimeBrowseHistoryListInput, -) -> Result, String> { - let validated_input = build_runtime_browse_history_list_input(input.user_id) - .map_err(|error| error.to_string())?; - - let mut entries = ctx - .db - .user_browse_history() - .iter() - .filter(|row| row.user_id == validated_input.user_id) - .map(|row| build_runtime_browse_history_snapshot_from_row(&row)) - .collect::>(); - - entries.sort_by(|left, right| { - right - .visited_at_micros - .cmp(&left.visited_at_micros) - .then_with(|| left.browse_history_id.cmp(&right.browse_history_id)) - }); - - Ok(entries) -} - -fn upsert_platform_browse_history_rows( - ctx: &ReducerContext, - input: RuntimeBrowseHistorySyncInput, -) -> Result, String> { - let user_id = input.user_id.clone(); - let prepared_entries = - prepare_runtime_browse_history_entries(input).map_err(|error| error.to_string())?; - - for prepared in prepared_entries { - let existing = ctx - .db - .user_browse_history() - .browse_history_id() - .find(&prepared.browse_history_id); - let created_at = existing - .as_ref() - .map(|row| row.created_at) - .unwrap_or_else(|| Timestamp::from_micros_since_unix_epoch(prepared.updated_at_micros)); - - if let Some(existing) = existing { - ctx.db - .user_browse_history() - .browse_history_id() - .delete(&existing.browse_history_id); - } - - ctx.db.user_browse_history().insert(UserBrowseHistory { - browse_history_id: prepared.browse_history_id, - user_id: prepared.user_id, - owner_user_id: prepared.owner_user_id, - profile_id: prepared.profile_id, - world_name: prepared.world_name, - subtitle: prepared.subtitle, - summary_text: prepared.summary_text, - cover_image_src: prepared.cover_image_src, - theme_mode: prepared.theme_mode, - author_display_name: prepared.author_display_name, - visited_at: Timestamp::from_micros_since_unix_epoch(prepared.visited_at_micros), - created_at, - updated_at: Timestamp::from_micros_since_unix_epoch(prepared.updated_at_micros), - }); - } - - list_platform_browse_history_rows(ctx, RuntimeBrowseHistoryListInput { user_id }) -} - -fn clear_platform_browse_history_rows( - ctx: &ReducerContext, - input: RuntimeBrowseHistoryClearInput, -) -> Result, String> { - let validated_input = build_runtime_browse_history_clear_input(input.user_id) - .map_err(|error| error.to_string())?; - let row_ids = ctx - .db - .user_browse_history() - .iter() - .filter(|row| row.user_id == validated_input.user_id) - .map(|row| row.browse_history_id.clone()) - .collect::>(); - - for row_id in row_ids { - ctx.db - .user_browse_history() - .browse_history_id() - .delete(&row_id); - } - - Ok(Vec::new()) -} - -fn build_runtime_browse_history_snapshot_from_row( - row: &UserBrowseHistory, -) -> RuntimeBrowseHistorySnapshot { - RuntimeBrowseHistorySnapshot { - browse_history_id: row.browse_history_id.clone(), - user_id: row.user_id.clone(), - owner_user_id: row.owner_user_id.clone(), - profile_id: row.profile_id.clone(), - world_name: row.world_name.clone(), - subtitle: row.subtitle.clone(), - summary_text: row.summary_text.clone(), - cover_image_src: row.cover_image_src.clone(), - theme_mode: row.theme_mode, - author_display_name: row.author_display_name.clone(), - visited_at_micros: row.visited_at.to_micros_since_unix_epoch(), - created_at_micros: row.created_at.to_micros_since_unix_epoch(), - updated_at_micros: row.updated_at.to_micros_since_unix_epoch(), - } -} - -fn build_profile_wallet_ledger_snapshot_from_row( - row: &ProfileWalletLedger, -) -> RuntimeProfileWalletLedgerEntrySnapshot { - RuntimeProfileWalletLedgerEntrySnapshot { - wallet_ledger_id: row.wallet_ledger_id.clone(), - user_id: row.user_id.clone(), - amount_delta: row.amount_delta, - balance_after: row.balance_after, - source_type: row.source_type, - created_at_micros: row.created_at.to_micros_since_unix_epoch(), - } -} - -fn build_profile_played_world_snapshot_from_row( - row: &ProfilePlayedWorld, -) -> RuntimeProfilePlayedWorldSnapshot { - RuntimeProfilePlayedWorldSnapshot { - played_world_id: row.played_world_id.clone(), - user_id: row.user_id.clone(), - world_key: row.world_key.clone(), - owner_user_id: row.owner_user_id.clone(), - profile_id: row.profile_id.clone(), - world_type: row.world_type.clone(), - world_title: row.world_title.clone(), - world_subtitle: row.world_subtitle.clone(), - first_played_at_micros: row.first_played_at.to_micros_since_unix_epoch(), - last_played_at_micros: row.last_played_at.to_micros_since_unix_epoch(), - last_observed_play_time_ms: row.last_observed_play_time_ms, - } -} - -#[allow(dead_code)] -fn build_runtime_browse_history_row(snapshot: RuntimeBrowseHistorySnapshot) -> UserBrowseHistory { - UserBrowseHistory { - browse_history_id: snapshot.browse_history_id, - user_id: snapshot.user_id, - owner_user_id: snapshot.owner_user_id, - profile_id: snapshot.profile_id, - world_name: snapshot.world_name, - subtitle: snapshot.subtitle, - summary_text: snapshot.summary_text, - cover_image_src: snapshot.cover_image_src, - theme_mode: snapshot.theme_mode, - author_display_name: snapshot.author_display_name, - visited_at: Timestamp::from_micros_since_unix_epoch(snapshot.visited_at_micros), - created_at: Timestamp::from_micros_since_unix_epoch(snapshot.created_at_micros), - updated_at: Timestamp::from_micros_since_unix_epoch(snapshot.updated_at_micros), - } -} - -fn build_battle_state_row(snapshot: BattleStateSnapshot) -> BattleState { - BattleState { - battle_state_id: snapshot.battle_state_id, - story_session_id: snapshot.story_session_id, - runtime_session_id: snapshot.runtime_session_id, - actor_user_id: snapshot.actor_user_id, - chapter_id: snapshot.chapter_id, - target_npc_id: snapshot.target_npc_id, - target_name: snapshot.target_name, - battle_mode: snapshot.battle_mode, - status: snapshot.status, - player_hp: snapshot.player_hp, - player_max_hp: snapshot.player_max_hp, - player_mana: snapshot.player_mana, - player_max_mana: snapshot.player_max_mana, - target_hp: snapshot.target_hp, - target_max_hp: snapshot.target_max_hp, - experience_reward: snapshot.experience_reward, - reward_items: snapshot.reward_items, - turn_index: snapshot.turn_index, - last_action_function_id: snapshot.last_action_function_id, - last_action_text: snapshot.last_action_text, - last_result_text: snapshot.last_result_text, - last_damage_dealt: snapshot.last_damage_dealt, - last_damage_taken: snapshot.last_damage_taken, - last_outcome: snapshot.last_outcome, - version: snapshot.version, - created_at: Timestamp::from_micros_since_unix_epoch(snapshot.created_at_micros), - updated_at: Timestamp::from_micros_since_unix_epoch(snapshot.updated_at_micros), - } -} - -fn build_battle_state_snapshot_from_row(row: &BattleState) -> BattleStateSnapshot { - BattleStateSnapshot { - battle_state_id: row.battle_state_id.clone(), - story_session_id: row.story_session_id.clone(), - runtime_session_id: row.runtime_session_id.clone(), - actor_user_id: row.actor_user_id.clone(), - chapter_id: row.chapter_id.clone(), - target_npc_id: row.target_npc_id.clone(), - target_name: row.target_name.clone(), - battle_mode: row.battle_mode, - status: row.status, - player_hp: row.player_hp, - player_max_hp: row.player_max_hp, - player_mana: row.player_mana, - player_max_mana: row.player_max_mana, - target_hp: row.target_hp, - target_max_hp: row.target_max_hp, - experience_reward: row.experience_reward, - reward_items: row.reward_items.clone(), - turn_index: row.turn_index, - last_action_function_id: row.last_action_function_id.clone(), - last_action_text: row.last_action_text.clone(), - last_result_text: row.last_result_text.clone(), - last_damage_dealt: row.last_damage_dealt, - last_damage_taken: row.last_damage_taken, - last_outcome: row.last_outcome, - version: row.version, - created_at_micros: row.created_at.to_micros_since_unix_epoch(), - updated_at_micros: row.updated_at.to_micros_since_unix_epoch(), - } -} - -fn upsert_npc_state_record( - ctx: &ReducerContext, - input: NpcStateUpsertInput, -) -> Result { - let npc_state_id = generate_npc_state_id(&input.runtime_session_id, &input.npc_id); - let existing = ctx.db.npc_state().npc_state_id().find(&npc_state_id); - let normalized = normalize_npc_state_snapshot( - input, - existing - .as_ref() - .map(|row| row.created_at.to_micros_since_unix_epoch()), - ) - .map_err(|error| error.to_string())?; - - if existing.is_some() { - ctx.db.npc_state().npc_state_id().delete(&npc_state_id); - } - ctx.db - .npc_state() - .insert(build_npc_state_row(normalized.clone())); - - Ok(normalized) -} - -fn resolve_npc_social_action_record( - ctx: &ReducerContext, - input: ResolveNpcSocialActionInput, -) -> Result { - let npc_state_id = generate_npc_state_id(&input.runtime_session_id, &input.npc_id); - let current = ctx - .db - .npc_state() - .npc_state_id() - .find(&npc_state_id) - .ok_or_else(|| "npc_state 不存在,无法执行社交动作".to_string())?; - let next = apply_npc_social_action(build_npc_state_snapshot_from_row(¤t), input) - .map_err(|error| error.to_string())?; - - ctx.db - .npc_state() - .npc_state_id() - .delete(¤t.npc_state_id); - ctx.db.npc_state().insert(build_npc_state_row(next.clone())); - - Ok(next) -} - -fn resolve_npc_interaction_record( - ctx: &ReducerContext, - input: ResolveNpcInteractionInput, -) -> Result { - let npc_state_id = generate_npc_state_id(&input.runtime_session_id, &input.npc_id); - let current = ctx - .db - .npc_state() - .npc_state_id() - .find(&npc_state_id) - .ok_or_else(|| "npc_state 不存在,无法执行交互".to_string())?; - let result = resolve_npc_interaction_domain(build_npc_state_snapshot_from_row(¤t), input) - .map_err(|error| error.to_string())?; - - ctx.db - .npc_state() - .npc_state_id() - .delete(¤t.npc_state_id); - ctx.db - .npc_state() - .insert(build_npc_state_row(result.npc_state.clone())); - - Ok(result) -} - -fn resolve_npc_battle_interaction_tx( - ctx: &ReducerContext, - input: ResolveNpcBattleInteractionInput, -) -> Result { - validate_npc_battle_interaction_input(&input)?; - - let interaction = resolve_npc_interaction_record(ctx, input.npc_interaction.clone())?; - let battle_mode = interaction - .battle_mode - .ok_or_else(|| "当前 NPC 交互没有产出 battle_mode,不能初始化 battle_state".to_string())?; - - let battle_state_id = input - .battle_state_id - .clone() - .unwrap_or_else(|| generate_battle_state_id(input.npc_interaction.updated_at_micros)); - if ctx - .db - .battle_state() - .battle_state_id() - .find(&battle_state_id) - .is_some() - { - return Err("battle_state.battle_state_id 已存在".to_string()); - } - - let battle_input = BattleStateInput { - battle_state_id, - story_session_id: input.story_session_id.trim().to_string(), - runtime_session_id: interaction.npc_state.runtime_session_id.clone(), - actor_user_id: input.actor_user_id.trim().to_string(), - chapter_id: None, - target_npc_id: interaction.npc_state.npc_id.clone(), - target_name: interaction.npc_state.npc_name.clone(), - battle_mode: map_npc_battle_mode(battle_mode), - player_hp: input.player_hp, - player_max_hp: input.player_max_hp, - player_mana: input.player_mana, - player_max_mana: input.player_max_mana, - target_hp: input.target_hp, - target_max_hp: input.target_max_hp, - experience_reward: input.experience_reward, - reward_items: input.reward_items.clone(), - created_at_micros: input.npc_interaction.updated_at_micros, - }; - validate_battle_state_input(&battle_input).map_err(|error| error.to_string())?; - - let battle_state = build_battle_state_snapshot(battle_input); - ctx.db - .battle_state() - .insert(build_battle_state_row(battle_state.clone())); - - Ok(NpcBattleInteractionResult { - interaction, - battle_state, - }) -} - -fn validate_npc_battle_interaction_input( - input: &ResolveNpcBattleInteractionInput, -) -> Result<(), String> { - if input.story_session_id.trim().is_empty() { - return Err("resolve_npc_battle_interaction.story_session_id 不能为空".to_string()); - } - if input.actor_user_id.trim().is_empty() { - return Err("resolve_npc_battle_interaction.actor_user_id 不能为空".to_string()); - } - if !matches!( - input.npc_interaction.interaction_function_id.trim(), - NPC_FIGHT_FUNCTION_ID | NPC_SPAR_FUNCTION_ID - ) { - return Err("resolve_npc_battle_interaction 只支持 npc_fight 或 npc_spar".to_string()); - } - - Ok(()) -} - -fn map_npc_battle_mode(mode: NpcInteractionBattleMode) -> BattleMode { - match mode { - NpcInteractionBattleMode::Fight => BattleMode::Fight, - NpcInteractionBattleMode::Spar => BattleMode::Spar, - } -} - -fn build_npc_state_row(snapshot: NpcStateSnapshot) -> NpcState { - NpcState { - npc_state_id: snapshot.npc_state_id, - runtime_session_id: snapshot.runtime_session_id, - npc_id: snapshot.npc_id, - npc_name: snapshot.npc_name, - affinity: snapshot.affinity, - relation_state: snapshot.relation_state, - help_used: snapshot.help_used, - chatted_count: snapshot.chatted_count, - gifts_given: snapshot.gifts_given, - recruited: snapshot.recruited, - trade_stock_signature: snapshot.trade_stock_signature, - revealed_facts: snapshot.revealed_facts, - known_attribute_rumors: snapshot.known_attribute_rumors, - first_meaningful_contact_resolved: snapshot.first_meaningful_contact_resolved, - seen_backstory_chapter_ids: snapshot.seen_backstory_chapter_ids, - stance_profile: snapshot.stance_profile, - created_at: Timestamp::from_micros_since_unix_epoch(snapshot.created_at_micros), - updated_at: Timestamp::from_micros_since_unix_epoch(snapshot.updated_at_micros), - } -} - -fn build_npc_state_snapshot_from_row(row: &NpcState) -> NpcStateSnapshot { - NpcStateSnapshot { - npc_state_id: row.npc_state_id.clone(), - runtime_session_id: row.runtime_session_id.clone(), - npc_id: row.npc_id.clone(), - npc_name: row.npc_name.clone(), - affinity: row.affinity, - relation_state: row.relation_state.clone(), - help_used: row.help_used, - chatted_count: row.chatted_count, - gifts_given: row.gifts_given, - recruited: row.recruited, - trade_stock_signature: row.trade_stock_signature.clone(), - revealed_facts: row.revealed_facts.clone(), - known_attribute_rumors: row.known_attribute_rumors.clone(), - first_meaningful_contact_resolved: row.first_meaningful_contact_resolved, - seen_backstory_chapter_ids: row.seen_backstory_chapter_ids.clone(), - stance_profile: row.stance_profile.clone(), - created_at_micros: row.created_at.to_micros_since_unix_epoch(), - updated_at_micros: row.updated_at.to_micros_since_unix_epoch(), - } -} +// SpacetimeDB 的 table/reducer/procedure 宏保持在 crate 顶层展开,避免拆分时改变对外导出名称。 +include!("domain_types.rs"); +include!("asset_metadata/mod.rs"); +include!("runtime/mod.rs"); +include!("gameplay/mod.rs"); +include!("custom_world/mod.rs"); +include!("ai/mod.rs"); +include!("entry.rs"); diff --git a/server-rs/crates/spacetime-module/src/runtime/mod.rs b/server-rs/crates/spacetime-module/src/runtime/mod.rs new file mode 100644 index 00000000..69ebd8ce --- /dev/null +++ b/server-rs/crates/spacetime-module/src/runtime/mod.rs @@ -0,0 +1,1288 @@ +#[spacetimedb::table(accessor = runtime_setting)] +pub struct RuntimeSetting { + #[primary_key] + user_id: String, + music_volume: f32, + platform_theme: RuntimePlatformTheme, + created_at: Timestamp, + updated_at: Timestamp, +} + +#[spacetimedb::table(accessor = runtime_snapshot)] +pub struct RuntimeSnapshotRow { + #[primary_key] + user_id: String, + version: u32, + saved_at: Timestamp, + bottom_tab: String, + game_state_json: String, + current_story_json: Option, + created_at: Timestamp, + updated_at: Timestamp, +} + +#[spacetimedb::table( + accessor = user_browse_history, + index(accessor = by_browse_history_user_id, btree(columns = [user_id])), + index( + accessor = by_browse_history_user_owner_profile, + btree(columns = [user_id, owner_user_id, profile_id]) + ) +)] +pub struct UserBrowseHistory { + #[primary_key] + browse_history_id: String, + user_id: String, + owner_user_id: String, + profile_id: String, + world_name: String, + subtitle: String, + summary_text: String, + cover_image_src: Option, + theme_mode: RuntimeBrowseHistoryThemeMode, + author_display_name: String, + visited_at: Timestamp, + created_at: Timestamp, + updated_at: Timestamp, +} + +#[spacetimedb::table(accessor = profile_dashboard_state)] +pub struct ProfileDashboardState { + #[primary_key] + user_id: String, + wallet_balance: u64, + total_play_time_ms: u64, + created_at: Timestamp, + updated_at: Timestamp, +} + +#[spacetimedb::table( + accessor = profile_wallet_ledger, + index(accessor = by_profile_wallet_ledger_user_id, btree(columns = [user_id])), + index( + accessor = by_profile_wallet_ledger_user_created_at, + btree(columns = [user_id, created_at]) + ) +)] +pub struct ProfileWalletLedger { + #[primary_key] + wallet_ledger_id: String, + user_id: String, + amount_delta: i64, + balance_after: u64, + source_type: RuntimeProfileWalletLedgerSourceType, + created_at: Timestamp, +} + +#[spacetimedb::table( + accessor = profile_played_world, + index(accessor = by_profile_played_world_user_id, btree(columns = [user_id])), + index( + accessor = by_profile_played_world_user_world_key, + btree(columns = [user_id, world_key]) + ), + index( + accessor = by_profile_played_world_user_last_played_at, + btree(columns = [user_id, last_played_at]) + ) +)] +pub struct ProfilePlayedWorld { + #[primary_key] + played_world_id: String, + user_id: String, + world_key: String, + owner_user_id: Option, + profile_id: Option, + world_type: Option, + world_title: String, + world_subtitle: String, + first_played_at: Timestamp, + last_played_at: Timestamp, + last_observed_play_time_ms: u64, +} + +#[spacetimedb::table( + accessor = profile_save_archive, + index(accessor = by_profile_save_archive_user_id, btree(columns = [user_id])), + index( + accessor = by_profile_save_archive_user_world_key, + btree(columns = [user_id, world_key]) + ), + index( + accessor = by_profile_save_archive_user_saved_at, + btree(columns = [user_id, saved_at]) + ) +)] +pub struct ProfileSaveArchive { + #[primary_key] + archive_id: String, + user_id: String, + world_key: String, + owner_user_id: Option, + profile_id: Option, + world_type: Option, + world_name: String, + subtitle: String, + summary_text: String, + cover_image_src: Option, + saved_at: Timestamp, + bottom_tab: String, + game_state_json: String, + current_story_json: Option, + created_at: Timestamp, + updated_at: Timestamp, +} + +// procedure 面向 Axum 同步读取设置;若没有持久化记录则返回默认值快照,但不产生额外写入。 +#[spacetimedb::procedure] +pub fn get_runtime_setting_or_default( + ctx: &mut ProcedureContext, + input: RuntimeSettingGetInput, +) -> RuntimeSettingProcedureResult { + match ctx.try_with_tx(|tx| get_runtime_setting_snapshot(tx, input.clone())) { + Ok(record) => RuntimeSettingProcedureResult { + ok: true, + record: Some(record), + error_message: None, + }, + Err(message) => RuntimeSettingProcedureResult { + ok: false, + record: None, + error_message: Some(message), + }, + } +} + +// 当前快照读取保持旧 Node 语义:无快照时返回 ok=true + record=None,而不是默认空对象。 +#[spacetimedb::procedure] +pub fn get_runtime_snapshot( + ctx: &mut ProcedureContext, + input: RuntimeSnapshotGetInput, +) -> RuntimeSnapshotProcedureResult { + match ctx.try_with_tx(|tx| get_runtime_snapshot_record(tx, input.clone())) { + Ok(record) => RuntimeSnapshotProcedureResult { + ok: true, + record, + error_message: None, + }, + Err(message) => RuntimeSnapshotProcedureResult { + ok: false, + record: None, + error_message: Some(message), + }, + } +} + +// PUT snapshot 主链会同步刷新 dashboard / wallet / played_world / save_archive 四类 projection。 +#[spacetimedb::procedure] +pub fn upsert_runtime_snapshot_and_return( + ctx: &mut ProcedureContext, + input: RuntimeSnapshotUpsertInput, +) -> RuntimeSnapshotProcedureResult { + match ctx.try_with_tx(|tx| upsert_runtime_snapshot_record(tx, input.clone())) { + Ok(record) => RuntimeSnapshotProcedureResult { + ok: true, + record: Some(record), + error_message: None, + }, + Err(message) => RuntimeSnapshotProcedureResult { + ok: false, + record: None, + error_message: Some(message), + }, + } +} + +// 删除当前快照只影响 runtime_snapshot 主表,不联动清理 profile projection。 +#[spacetimedb::procedure] +pub fn delete_runtime_snapshot_and_return( + ctx: &mut ProcedureContext, + input: RuntimeSnapshotDeleteInput, +) -> RuntimeSnapshotProcedureResult { + match ctx.try_with_tx(|tx| delete_runtime_snapshot_record(tx, input.clone())) { + Ok(record) => RuntimeSnapshotProcedureResult { + ok: true, + record, + error_message: None, + }, + Err(message) => RuntimeSnapshotProcedureResult { + ok: false, + record: None, + error_message: Some(message), + }, + } +} + +// procedure 面向 Axum 同步写入设置,并返回最终归一化后的持久化结果。 +#[spacetimedb::procedure] +pub fn upsert_runtime_setting_and_return( + ctx: &mut ProcedureContext, + input: RuntimeSettingUpsertInput, +) -> RuntimeSettingProcedureResult { + match ctx.try_with_tx(|tx| upsert_runtime_setting(tx, input.clone())) { + Ok(record) => RuntimeSettingProcedureResult { + ok: true, + record: Some(record), + error_message: None, + }, + Err(message) => RuntimeSettingProcedureResult { + ok: false, + record: None, + error_message: Some(message), + }, + } +} + +// save archive 列表是按世界聚合后的最近一次快照视图,读取时只做排序,不再拼装默认值。 +#[spacetimedb::procedure] +pub fn list_profile_save_archives( + ctx: &mut ProcedureContext, + input: RuntimeProfileSaveArchiveListInput, +) -> RuntimeProfileSaveArchiveProcedureResult { + match ctx.try_with_tx(|tx| list_profile_save_archive_rows(tx, input.clone())) { + Ok(entries) => RuntimeProfileSaveArchiveProcedureResult { + ok: true, + entries, + record: None, + current_snapshot: None, + error_message: None, + }, + Err(message) => RuntimeProfileSaveArchiveProcedureResult { + ok: false, + entries: Vec::new(), + record: None, + current_snapshot: None, + error_message: Some(message), + }, + } +} + +// resume 会把指定 archive 回填到当前 snapshot,并同步返回 entry + 当前 snapshot。 +#[spacetimedb::procedure] +pub fn resume_profile_save_archive_and_return( + ctx: &mut ProcedureContext, + input: RuntimeProfileSaveArchiveResumeInput, +) -> RuntimeProfileSaveArchiveProcedureResult { + match ctx.try_with_tx(|tx| resume_profile_save_archive_record(tx, input.clone())) { + Ok((record, current_snapshot)) => RuntimeProfileSaveArchiveProcedureResult { + ok: true, + entries: Vec::new(), + record: Some(record), + current_snapshot: Some(current_snapshot), + error_message: None, + }, + Err(message) => RuntimeProfileSaveArchiveProcedureResult { + ok: false, + entries: Vec::new(), + record: None, + current_snapshot: None, + error_message: Some(message), + }, + } +} + +// profile dashboard 当前先作为 projection 读入口返回默认零值,等待 runtime_snapshot 写链补齐刷新。 +#[spacetimedb::procedure] +pub fn get_profile_dashboard( + ctx: &mut ProcedureContext, + input: RuntimeProfileDashboardGetInput, +) -> RuntimeProfileDashboardProcedureResult { + match ctx.try_with_tx(|tx| get_profile_dashboard_snapshot(tx, input.clone())) { + Ok(record) => RuntimeProfileDashboardProcedureResult { + ok: true, + record: Some(record), + error_message: None, + }, + Err(message) => RuntimeProfileDashboardProcedureResult { + ok: false, + record: None, + error_message: Some(message), + }, + } +} + +// 钱包流水当前只暴露最近 50 条只读视图,排序与截断逻辑在 procedure 内统一收口。 +#[spacetimedb::procedure] +pub fn list_profile_wallet_ledger( + ctx: &mut ProcedureContext, + input: RuntimeProfileWalletLedgerListInput, +) -> RuntimeProfileWalletLedgerProcedureResult { + match ctx.try_with_tx(|tx| list_profile_wallet_ledger_entries(tx, input.clone())) { + Ok(entries) => RuntimeProfileWalletLedgerProcedureResult { + ok: true, + entries, + error_message: None, + }, + Err(message) => RuntimeProfileWalletLedgerProcedureResult { + ok: false, + entries: Vec::new(), + error_message: Some(message), + }, + } +} + +// play stats 与 dashboard 共用 dashboard projection 的 total_play_time / updated_at,避免 Axum 侧拼装。 +#[spacetimedb::procedure] +pub fn get_profile_play_stats( + ctx: &mut ProcedureContext, + input: RuntimeProfilePlayStatsGetInput, +) -> RuntimeProfilePlayStatsProcedureResult { + match ctx.try_with_tx(|tx| get_profile_play_stats_snapshot(tx, input.clone())) { + Ok(record) => RuntimeProfilePlayStatsProcedureResult { + ok: true, + record: Some(record), + error_message: None, + }, + Err(message) => RuntimeProfilePlayStatsProcedureResult { + ok: false, + record: None, + error_message: Some(message), + }, + } +} +fn get_runtime_setting_snapshot( + ctx: &ReducerContext, + input: RuntimeSettingGetInput, +) -> Result { + let validated_input = + build_runtime_setting_get_input(input.user_id).map_err(|error| error.to_string())?; + + if let Some(existing) = ctx + .db + .runtime_setting() + .user_id() + .find(&validated_input.user_id) + { + return Ok(RuntimeSettingSnapshot { + user_id: existing.user_id, + music_volume: existing.music_volume, + platform_theme: existing.platform_theme, + created_at_micros: existing.created_at.to_micros_since_unix_epoch(), + updated_at_micros: existing.updated_at.to_micros_since_unix_epoch(), + }); + } + + Ok(RuntimeSettingSnapshot { + user_id: validated_input.user_id, + music_volume: DEFAULT_MUSIC_VOLUME, + platform_theme: DEFAULT_PLATFORM_THEME, + created_at_micros: 0, + updated_at_micros: 0, + }) +} + +fn upsert_runtime_setting( + ctx: &ReducerContext, + input: RuntimeSettingUpsertInput, +) -> Result { + let validated_input = build_runtime_setting_upsert_input( + input.user_id, + input.music_volume, + input.platform_theme, + input.updated_at_micros, + ) + .map_err(|error| error.to_string())?; + let updated_at = Timestamp::from_micros_since_unix_epoch(validated_input.updated_at_micros); + + let snapshot = match ctx + .db + .runtime_setting() + .user_id() + .find(&validated_input.user_id) + { + Some(existing) => { + ctx.db.runtime_setting().user_id().delete(&existing.user_id); + ctx.db.runtime_setting().insert(RuntimeSetting { + user_id: existing.user_id.clone(), + music_volume: validated_input.music_volume, + platform_theme: validated_input.platform_theme, + created_at: existing.created_at, + updated_at, + }); + + RuntimeSettingSnapshot { + user_id: existing.user_id, + music_volume: validated_input.music_volume, + platform_theme: validated_input.platform_theme, + created_at_micros: existing.created_at.to_micros_since_unix_epoch(), + updated_at_micros: validated_input.updated_at_micros, + } + } + None => { + ctx.db.runtime_setting().insert(RuntimeSetting { + user_id: validated_input.user_id.clone(), + music_volume: validated_input.music_volume, + platform_theme: validated_input.platform_theme, + created_at: updated_at, + updated_at, + }); + + RuntimeSettingSnapshot { + user_id: validated_input.user_id, + music_volume: validated_input.music_volume, + platform_theme: validated_input.platform_theme, + created_at_micros: validated_input.updated_at_micros, + updated_at_micros: validated_input.updated_at_micros, + } + } + }; + + Ok(snapshot) +} + +fn get_runtime_snapshot_record( + ctx: &ReducerContext, + input: RuntimeSnapshotGetInput, +) -> Result, String> { + let validated_input = + build_runtime_snapshot_get_input(input.user_id).map_err(|error| error.to_string())?; + + Ok(ctx + .db + .runtime_snapshot() + .user_id() + .find(&validated_input.user_id) + .map(|row| build_runtime_snapshot_from_row(&row))) +} + +fn upsert_runtime_snapshot_record( + ctx: &ReducerContext, + input: RuntimeSnapshotUpsertInput, +) -> Result { + let current_story_value = parse_optional_json_str(input.current_story_json.as_deref())?; + let game_state = parse_json_str(&input.game_state_json)?; + let prepared = build_runtime_snapshot_upsert_input( + input.user_id, + input.saved_at_micros, + input.bottom_tab, + game_state.clone(), + current_story_value.clone(), + input.updated_at_micros, + ) + .map_err(|error| error.to_string())?; + let updated_at = Timestamp::from_micros_since_unix_epoch(prepared.updated_at_micros); + let saved_at = Timestamp::from_micros_since_unix_epoch(prepared.saved_at_micros); + + let snapshot = match ctx.db.runtime_snapshot().user_id().find(&prepared.user_id) { + Some(existing) => { + ctx.db.runtime_snapshot().user_id().delete(&existing.user_id); + ctx.db.runtime_snapshot().insert(RuntimeSnapshotRow { + user_id: existing.user_id.clone(), + version: SAVE_SNAPSHOT_VERSION, + saved_at, + bottom_tab: prepared.bottom_tab.clone(), + game_state_json: prepared.game_state_json.clone(), + current_story_json: prepared.current_story_json.clone(), + created_at: existing.created_at, + updated_at, + }); + + RuntimeSnapshot { + user_id: existing.user_id, + version: SAVE_SNAPSHOT_VERSION, + saved_at_micros: prepared.saved_at_micros, + bottom_tab: prepared.bottom_tab, + game_state_json: prepared.game_state_json, + current_story_json: prepared.current_story_json, + created_at_micros: existing.created_at.to_micros_since_unix_epoch(), + updated_at_micros: prepared.updated_at_micros, + } + } + None => { + ctx.db.runtime_snapshot().insert(RuntimeSnapshotRow { + user_id: prepared.user_id.clone(), + version: SAVE_SNAPSHOT_VERSION, + saved_at, + bottom_tab: prepared.bottom_tab.clone(), + game_state_json: prepared.game_state_json.clone(), + current_story_json: prepared.current_story_json.clone(), + created_at: updated_at, + updated_at, + }); + + RuntimeSnapshot { + user_id: prepared.user_id, + version: SAVE_SNAPSHOT_VERSION, + saved_at_micros: prepared.saved_at_micros, + bottom_tab: prepared.bottom_tab, + game_state_json: prepared.game_state_json, + current_story_json: prepared.current_story_json, + created_at_micros: prepared.updated_at_micros, + updated_at_micros: prepared.updated_at_micros, + } + } + }; + + sync_profile_projections_from_snapshot(ctx, &snapshot)?; + + Ok(snapshot) +} + +fn delete_runtime_snapshot_record( + ctx: &ReducerContext, + input: RuntimeSnapshotDeleteInput, +) -> Result, String> { + let validated_input = + build_runtime_snapshot_delete_input(input.user_id).map_err(|error| error.to_string())?; + + let existing = ctx + .db + .runtime_snapshot() + .user_id() + .find(&validated_input.user_id); + if let Some(existing) = existing { + let snapshot = build_runtime_snapshot_from_row(&existing); + ctx.db.runtime_snapshot().user_id().delete(&existing.user_id); + return Ok(Some(snapshot)); + } + + Ok(None) +} + +fn list_profile_save_archive_rows( + ctx: &ReducerContext, + input: RuntimeProfileSaveArchiveListInput, +) -> Result, String> { + let validated_input = + build_runtime_profile_save_archive_list_input(input.user_id).map_err(|error| error.to_string())?; + + let mut entries = ctx + .db + .profile_save_archive() + .iter() + .filter(|row| row.user_id == validated_input.user_id) + .map(|row| build_profile_save_archive_snapshot_from_row(&row)) + .collect::>(); + + entries.sort_by(|left, right| { + right + .saved_at_micros + .cmp(&left.saved_at_micros) + .then_with(|| left.archive_id.cmp(&right.archive_id)) + }); + + Ok(entries) +} + +fn resume_profile_save_archive_record( + ctx: &ReducerContext, + input: RuntimeProfileSaveArchiveResumeInput, +) -> Result<(RuntimeProfileSaveArchiveSnapshot, RuntimeSnapshot), String> { + let validated_input = build_runtime_profile_save_archive_resume_input(input.user_id, input.world_key) + .map_err(|error| error.to_string())?; + let archive = ctx + .db + .profile_save_archive() + .iter() + .find(|row| row.user_id == validated_input.user_id && row.world_key == validated_input.world_key) + .ok_or_else(|| "profile_save_archive 对应 world_key 不存在".to_string())?; + + let existing_snapshot = ctx + .db + .runtime_snapshot() + .user_id() + .find(&validated_input.user_id); + let created_at = existing_snapshot + .as_ref() + .map(|row| row.created_at) + .unwrap_or(archive.saved_at); + + if let Some(existing) = existing_snapshot { + ctx.db.runtime_snapshot().user_id().delete(&existing.user_id); + } + + ctx.db.runtime_snapshot().insert(RuntimeSnapshotRow { + user_id: archive.user_id.clone(), + version: SAVE_SNAPSHOT_VERSION, + saved_at: archive.saved_at, + bottom_tab: archive.bottom_tab.clone(), + game_state_json: archive.game_state_json.clone(), + current_story_json: archive.current_story_json.clone(), + created_at, + updated_at: archive.saved_at, + }); + + Ok(( + build_profile_save_archive_snapshot_from_row(&archive), + RuntimeSnapshot { + user_id: archive.user_id.clone(), + version: SAVE_SNAPSHOT_VERSION, + saved_at_micros: archive.saved_at.to_micros_since_unix_epoch(), + bottom_tab: archive.bottom_tab.clone(), + game_state_json: archive.game_state_json.clone(), + current_story_json: archive.current_story_json.clone(), + created_at_micros: created_at.to_micros_since_unix_epoch(), + updated_at_micros: archive.saved_at.to_micros_since_unix_epoch(), + }, + )) +} + +fn sync_profile_projections_from_snapshot( + ctx: &ReducerContext, + snapshot: &RuntimeSnapshot, +) -> Result<(), String> { + let game_state = parse_json_str(&snapshot.game_state_json)?; + let game_state_object = game_state.as_object(); + let saved_at = Timestamp::from_micros_since_unix_epoch(snapshot.saved_at_micros); + + sync_profile_dashboard_from_snapshot(ctx, snapshot, game_state_object, saved_at); + sync_profile_save_archive_from_snapshot(ctx, snapshot, &game_state, saved_at)?; + + Ok(()) +} + +fn sync_profile_dashboard_from_snapshot( + ctx: &ReducerContext, + snapshot: &RuntimeSnapshot, + game_state: Option<&serde_json::Map>, + saved_at: Timestamp, +) { + let current_state = ctx + .db + .profile_dashboard_state() + .user_id() + .find(&snapshot.user_id); + let previous_wallet_balance = current_state.as_ref().map(|row| row.wallet_balance).unwrap_or(0); + let previous_total_play_time_ms = current_state + .as_ref() + .map(|row| row.total_play_time_ms) + .unwrap_or(0); + let next_wallet_balance = read_non_negative_u64(game_state.and_then(|state| state.get("playerCurrency"))); + let mut next_total_play_time_ms = previous_total_play_time_ms; + + if next_wallet_balance != previous_wallet_balance { + ctx.db.profile_wallet_ledger().insert(ProfileWalletLedger { + wallet_ledger_id: format!( + "{}:{}:{}", + snapshot.user_id, + snapshot.saved_at_micros, + next_wallet_balance + ), + user_id: snapshot.user_id.clone(), + amount_delta: next_wallet_balance as i64 - previous_wallet_balance as i64, + balance_after: next_wallet_balance, + source_type: RuntimeProfileWalletLedgerSourceType::SnapshotSync, + created_at: saved_at, + }); + } + + if let Some(world_meta) = resolve_profile_world_snapshot_meta(game_state) { + let current_play_time_ms = read_non_negative_u64( + game_state + .and_then(|state| state.get("runtimeStats")) + .and_then(JsonValue::as_object) + .and_then(|stats| stats.get("playTimeMs")), + ); + let played_world_id = format!("{}:{}", snapshot.user_id, world_meta.world_key); + let existing = ctx.db.profile_played_world().played_world_id().find(&played_world_id); + let previous_observed_play_time_ms = existing + .as_ref() + .map(|row| row.last_observed_play_time_ms) + .unwrap_or(0); + let incremental_play_time_ms = current_play_time_ms.saturating_sub(previous_observed_play_time_ms); + next_total_play_time_ms = next_total_play_time_ms.saturating_add(incremental_play_time_ms); + + if let Some(existing) = existing { + ctx.db + .profile_played_world() + .played_world_id() + .delete(&existing.played_world_id); + ctx.db.profile_played_world().insert(ProfilePlayedWorld { + played_world_id, + user_id: snapshot.user_id.clone(), + world_key: world_meta.world_key, + owner_user_id: world_meta.owner_user_id, + profile_id: world_meta.profile_id, + world_type: world_meta.world_type, + world_title: world_meta.world_title, + world_subtitle: world_meta.world_subtitle, + first_played_at: existing.first_played_at, + last_played_at: saved_at, + last_observed_play_time_ms: current_play_time_ms.max(existing.last_observed_play_time_ms), + }); + } else { + ctx.db.profile_played_world().insert(ProfilePlayedWorld { + played_world_id, + user_id: snapshot.user_id.clone(), + world_key: world_meta.world_key, + owner_user_id: world_meta.owner_user_id, + profile_id: world_meta.profile_id, + world_type: world_meta.world_type, + world_title: world_meta.world_title, + world_subtitle: world_meta.world_subtitle, + first_played_at: saved_at, + last_played_at: saved_at, + last_observed_play_time_ms: current_play_time_ms, + }); + } + } + + if let Some(existing) = current_state { + ctx.db + .profile_dashboard_state() + .user_id() + .delete(&existing.user_id); + ctx.db.profile_dashboard_state().insert(ProfileDashboardState { + user_id: snapshot.user_id.clone(), + wallet_balance: next_wallet_balance, + total_play_time_ms: next_total_play_time_ms, + created_at: existing.created_at, + updated_at: saved_at, + }); + } else { + ctx.db.profile_dashboard_state().insert(ProfileDashboardState { + user_id: snapshot.user_id.clone(), + wallet_balance: next_wallet_balance, + total_play_time_ms: next_total_play_time_ms, + created_at: saved_at, + updated_at: saved_at, + }); + } +} + +fn sync_profile_save_archive_from_snapshot( + ctx: &ReducerContext, + snapshot: &RuntimeSnapshot, + game_state: &JsonValue, + saved_at: Timestamp, +) -> Result<(), String> { + let Some(archive_meta) = resolve_profile_save_archive_meta(game_state, snapshot.current_story_json.as_deref()) else { + return Ok(()); + }; + + let archive_id = format!("{}:{}", snapshot.user_id, archive_meta.world_key); + let existing = ctx + .db + .profile_save_archive() + .archive_id() + .find(&archive_id); + let created_at = existing.as_ref().map(|row| row.created_at).unwrap_or(saved_at); + + if let Some(existing) = existing { + ctx.db + .profile_save_archive() + .archive_id() + .delete(&existing.archive_id); + } + + ctx.db.profile_save_archive().insert(ProfileSaveArchive { + archive_id, + user_id: snapshot.user_id.clone(), + world_key: archive_meta.world_key, + owner_user_id: archive_meta.owner_user_id, + profile_id: archive_meta.profile_id, + world_type: archive_meta.world_type, + world_name: archive_meta.world_name, + subtitle: archive_meta.subtitle, + summary_text: archive_meta.summary_text, + cover_image_src: archive_meta.cover_image_src, + saved_at, + bottom_tab: snapshot.bottom_tab.clone(), + game_state_json: snapshot.game_state_json.clone(), + current_story_json: snapshot.current_story_json.clone(), + created_at, + updated_at: saved_at, + }); + + Ok(()) +} + +#[derive(Clone, Debug)] +struct ProfileWorldSnapshotMeta { + world_key: String, + owner_user_id: Option, + profile_id: Option, + world_type: Option, + world_title: String, + world_subtitle: String, +} + +#[derive(Clone, Debug)] +struct ProfileSaveArchiveMeta { + world_key: String, + owner_user_id: Option, + profile_id: Option, + world_type: Option, + world_name: String, + subtitle: String, + summary_text: String, + cover_image_src: Option, +} + +fn build_runtime_snapshot_from_row(row: &RuntimeSnapshotRow) -> RuntimeSnapshot { + RuntimeSnapshot { + user_id: row.user_id.clone(), + version: row.version, + saved_at_micros: row.saved_at.to_micros_since_unix_epoch(), + bottom_tab: row.bottom_tab.clone(), + game_state_json: row.game_state_json.clone(), + current_story_json: row.current_story_json.clone(), + created_at_micros: row.created_at.to_micros_since_unix_epoch(), + updated_at_micros: row.updated_at.to_micros_since_unix_epoch(), + } +} + +fn build_profile_save_archive_snapshot_from_row( + row: &ProfileSaveArchive, +) -> RuntimeProfileSaveArchiveSnapshot { + RuntimeProfileSaveArchiveSnapshot { + archive_id: row.archive_id.clone(), + user_id: row.user_id.clone(), + world_key: row.world_key.clone(), + owner_user_id: row.owner_user_id.clone(), + profile_id: row.profile_id.clone(), + world_type: row.world_type.clone(), + world_name: row.world_name.clone(), + subtitle: row.subtitle.clone(), + summary_text: row.summary_text.clone(), + cover_image_src: row.cover_image_src.clone(), + saved_at_micros: row.saved_at.to_micros_since_unix_epoch(), + bottom_tab: row.bottom_tab.clone(), + game_state_json: row.game_state_json.clone(), + current_story_json: row.current_story_json.clone(), + created_at_micros: row.created_at.to_micros_since_unix_epoch(), + updated_at_micros: row.updated_at.to_micros_since_unix_epoch(), + } +} + +fn parse_json_str(raw: &str) -> Result { + serde_json::from_str::(raw).map_err(|error| format!("game_state_json 解析失败: {error}")) +} + +fn parse_optional_json_str(raw: Option<&str>) -> Result, String> { + match raw.map(str::trim).filter(|value| !value.is_empty()) { + Some(value) => serde_json::from_str::(value) + .map(Some) + .map_err(|error| format!("current_story_json 解析失败: {error}")), + None => Ok(None), + } +} + +fn read_non_negative_u64(value: Option<&JsonValue>) -> u64 { + match value { + Some(JsonValue::Number(number)) => { + if let Some(raw) = number.as_u64() { + raw + } else if let Some(raw) = number.as_i64() { + raw.max(0) as u64 + } else if let Some(raw) = number.as_f64() { + if raw.is_finite() && raw > 0.0 { + raw.floor() as u64 + } else { + 0 + } + } else { + 0 + } + } + Some(JsonValue::String(raw)) => raw.trim().parse::().ok().unwrap_or(0), + _ => 0, + } +} + +fn read_string_from_json(value: Option<&JsonValue>) -> Option { + value + .and_then(JsonValue::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(ToString::to_string) +} + +fn resolve_profile_world_snapshot_meta( + game_state: Option<&serde_json::Map>, +) -> Option { + let game_state = game_state?; + let custom_world_profile = game_state.get("customWorldProfile").and_then(JsonValue::as_object); + + if let Some(custom_world_profile) = custom_world_profile { + let profile_id = read_string_from_json(custom_world_profile.get("id")); + let world_title = read_string_from_json(custom_world_profile.get("name")) + .or_else(|| read_string_from_json(custom_world_profile.get("title"))); + if profile_id.is_some() || world_title.is_some() { + let world_title = world_title.unwrap_or_else(|| "自定义世界".to_string()); + return Some(ProfileWorldSnapshotMeta { + world_key: profile_id + .as_ref() + .map(|profile_id| format!("custom:{profile_id}")) + .unwrap_or_else(|| format!("custom:{world_title}")), + owner_user_id: None, + profile_id, + world_type: Some("CUSTOM".to_string()), + world_title, + world_subtitle: read_string_from_json(custom_world_profile.get("summary")) + .or_else(|| read_string_from_json(custom_world_profile.get("settingText"))) + .unwrap_or_default(), + }); + } + } + + let world_type = read_string_from_json(game_state.get("worldType"))?; + let current_scene_preset = game_state.get("currentScenePreset").and_then(JsonValue::as_object); + + Some(ProfileWorldSnapshotMeta { + world_key: format!("builtin:{world_type}"), + owner_user_id: None, + profile_id: None, + world_type: Some(world_type.clone()), + world_title: current_scene_preset + .and_then(|preset| read_string_from_json(preset.get("name"))) + .unwrap_or_else(|| build_builtin_world_title(&world_type)), + world_subtitle: current_scene_preset + .and_then(|preset| { + read_string_from_json(preset.get("summary")) + .or_else(|| read_string_from_json(preset.get("description"))) + }) + .unwrap_or_default(), + }) +} + +fn resolve_profile_save_archive_meta( + game_state: &JsonValue, + current_story_json: Option<&str>, +) -> Option { + let game_state_object = game_state.as_object(); + let world_meta = resolve_profile_world_snapshot_meta(game_state_object)?; + let story_engine_memory = game_state_object + .and_then(|state| state.get("storyEngineMemory")) + .and_then(JsonValue::as_object); + let continue_game_digest = + story_engine_memory.and_then(|memory| read_string_from_json(memory.get("continueGameDigest"))); + let current_story_text = parse_optional_json_str(current_story_json) + .ok() + .flatten() + .and_then(|story| story.as_object().cloned()) + .and_then(|story| read_string_from_json(story.get("text"))); + let custom_world_profile = game_state_object + .and_then(|state| state.get("customWorldProfile")) + .and_then(JsonValue::as_object); + + if let Some(custom_world_profile) = custom_world_profile { + let world_name = read_string_from_json(custom_world_profile.get("name")) + .or_else(|| read_string_from_json(custom_world_profile.get("title"))) + .unwrap_or_else(|| world_meta.world_title.clone()); + let subtitle = read_string_from_json(custom_world_profile.get("summary")) + .or_else(|| read_string_from_json(custom_world_profile.get("settingText"))) + .unwrap_or_else(|| world_meta.world_subtitle.clone()); + let summary_text = continue_game_digest + .or(current_story_text) + .or_else(|| { + if subtitle.is_empty() { + None + } else { + Some(subtitle.clone()) + } + }) + .unwrap_or_else(|| DEFAULT_SAVE_ARCHIVE_SUMMARY_TEXT.to_string()); + + return Some(ProfileSaveArchiveMeta { + world_key: world_meta.world_key, + owner_user_id: world_meta.owner_user_id, + profile_id: world_meta.profile_id, + world_type: world_meta.world_type, + world_name, + subtitle, + summary_text, + cover_image_src: read_string_from_json(custom_world_profile.get("coverImageSrc")), + }); + } + + let summary_text = continue_game_digest + .or(current_story_text) + .or_else(|| { + if world_meta.world_subtitle.is_empty() { + None + } else { + Some(world_meta.world_subtitle.clone()) + } + }) + .unwrap_or_else(|| DEFAULT_SAVE_ARCHIVE_SUMMARY_TEXT.to_string()); + let current_scene_preset = game_state_object + .and_then(|state| state.get("currentScenePreset")) + .and_then(JsonValue::as_object); + + Some(ProfileSaveArchiveMeta { + world_key: world_meta.world_key, + owner_user_id: world_meta.owner_user_id, + profile_id: world_meta.profile_id, + world_type: world_meta.world_type, + world_name: world_meta.world_title, + subtitle: world_meta.world_subtitle.clone(), + summary_text, + cover_image_src: current_scene_preset + .and_then(|preset| read_string_from_json(preset.get("imageSrc"))), + }) +} + +fn build_builtin_world_title(world_type: &str) -> String { + match world_type { + "WUXIA" => "武侠世界".to_string(), + "XIANXIA" => "仙侠世界".to_string(), + _ => "叙事世界".to_string(), + } +} + +fn get_profile_dashboard_snapshot( + ctx: &ReducerContext, + input: RuntimeProfileDashboardGetInput, +) -> Result { + let validated_input = build_runtime_profile_dashboard_get_input(input.user_id) + .map_err(|error| error.to_string())?; + let state = ctx + .db + .profile_dashboard_state() + .user_id() + .find(&validated_input.user_id); + let played_world_count = ctx + .db + .profile_played_world() + .iter() + .filter(|row| row.user_id == validated_input.user_id) + .count() as u32; + + Ok(match state { + Some(existing) => RuntimeProfileDashboardSnapshot { + user_id: existing.user_id, + wallet_balance: existing.wallet_balance, + total_play_time_ms: existing.total_play_time_ms, + played_world_count, + updated_at_micros: Some(existing.updated_at.to_micros_since_unix_epoch()), + }, + None => RuntimeProfileDashboardSnapshot { + user_id: validated_input.user_id, + wallet_balance: 0, + total_play_time_ms: 0, + played_world_count, + updated_at_micros: None, + }, + }) +} + +fn list_profile_wallet_ledger_entries( + ctx: &ReducerContext, + input: RuntimeProfileWalletLedgerListInput, +) -> Result, String> { + let validated_input = build_runtime_profile_wallet_ledger_list_input(input.user_id) + .map_err(|error| error.to_string())?; + + let mut entries = ctx + .db + .profile_wallet_ledger() + .iter() + .filter(|row| row.user_id == validated_input.user_id) + .map(|row| build_profile_wallet_ledger_snapshot_from_row(&row)) + .collect::>(); + + entries.sort_by(|left, right| { + right + .created_at_micros + .cmp(&left.created_at_micros) + .then_with(|| left.wallet_ledger_id.cmp(&right.wallet_ledger_id)) + }); + entries.truncate(PROFILE_WALLET_LEDGER_LIST_LIMIT); + + Ok(entries) +} + +fn get_profile_play_stats_snapshot( + ctx: &ReducerContext, + input: RuntimeProfilePlayStatsGetInput, +) -> Result { + let validated_input = build_runtime_profile_play_stats_get_input(input.user_id) + .map_err(|error| error.to_string())?; + let dashboard_state = ctx + .db + .profile_dashboard_state() + .user_id() + .find(&validated_input.user_id); + let mut played_works = ctx + .db + .profile_played_world() + .iter() + .filter(|row| row.user_id == validated_input.user_id) + .map(|row| build_profile_played_world_snapshot_from_row(&row)) + .collect::>(); + + played_works.sort_by(|left, right| { + right + .last_played_at_micros + .cmp(&left.last_played_at_micros) + .then_with(|| left.played_world_id.cmp(&right.played_world_id)) + }); + + Ok(RuntimeProfilePlayStatsSnapshot { + user_id: validated_input.user_id, + total_play_time_ms: dashboard_state + .as_ref() + .map(|row| row.total_play_time_ms) + .unwrap_or(0), + played_works, + updated_at_micros: dashboard_state + .as_ref() + .map(|row| row.updated_at.to_micros_since_unix_epoch()), + }) +} + +fn list_platform_browse_history_rows( + ctx: &ReducerContext, + input: RuntimeBrowseHistoryListInput, +) -> Result, String> { + let validated_input = build_runtime_browse_history_list_input(input.user_id) + .map_err(|error| error.to_string())?; + + let mut entries = ctx + .db + .user_browse_history() + .iter() + .filter(|row| row.user_id == validated_input.user_id) + .map(|row| build_runtime_browse_history_snapshot_from_row(&row)) + .collect::>(); + + entries.sort_by(|left, right| { + right + .visited_at_micros + .cmp(&left.visited_at_micros) + .then_with(|| left.browse_history_id.cmp(&right.browse_history_id)) + }); + + Ok(entries) +} + +fn upsert_platform_browse_history_rows( + ctx: &ReducerContext, + input: RuntimeBrowseHistorySyncInput, +) -> Result, String> { + let user_id = input.user_id.clone(); + let prepared_entries = + prepare_runtime_browse_history_entries(input).map_err(|error| error.to_string())?; + + for prepared in prepared_entries { + let existing = ctx + .db + .user_browse_history() + .browse_history_id() + .find(&prepared.browse_history_id); + let created_at = existing + .as_ref() + .map(|row| row.created_at) + .unwrap_or_else(|| Timestamp::from_micros_since_unix_epoch(prepared.updated_at_micros)); + + if let Some(existing) = existing { + ctx.db + .user_browse_history() + .browse_history_id() + .delete(&existing.browse_history_id); + } + + ctx.db.user_browse_history().insert(UserBrowseHistory { + browse_history_id: prepared.browse_history_id, + user_id: prepared.user_id, + owner_user_id: prepared.owner_user_id, + profile_id: prepared.profile_id, + world_name: prepared.world_name, + subtitle: prepared.subtitle, + summary_text: prepared.summary_text, + cover_image_src: prepared.cover_image_src, + theme_mode: prepared.theme_mode, + author_display_name: prepared.author_display_name, + visited_at: Timestamp::from_micros_since_unix_epoch(prepared.visited_at_micros), + created_at, + updated_at: Timestamp::from_micros_since_unix_epoch(prepared.updated_at_micros), + }); + } + + list_platform_browse_history_rows(ctx, RuntimeBrowseHistoryListInput { user_id }) +} + +fn clear_platform_browse_history_rows( + ctx: &ReducerContext, + input: RuntimeBrowseHistoryClearInput, +) -> Result, String> { + let validated_input = build_runtime_browse_history_clear_input(input.user_id) + .map_err(|error| error.to_string())?; + let row_ids = ctx + .db + .user_browse_history() + .iter() + .filter(|row| row.user_id == validated_input.user_id) + .map(|row| row.browse_history_id.clone()) + .collect::>(); + + for row_id in row_ids { + ctx.db + .user_browse_history() + .browse_history_id() + .delete(&row_id); + } + + Ok(Vec::new()) +} + +fn build_runtime_browse_history_snapshot_from_row( + row: &UserBrowseHistory, +) -> RuntimeBrowseHistorySnapshot { + RuntimeBrowseHistorySnapshot { + browse_history_id: row.browse_history_id.clone(), + user_id: row.user_id.clone(), + owner_user_id: row.owner_user_id.clone(), + profile_id: row.profile_id.clone(), + world_name: row.world_name.clone(), + subtitle: row.subtitle.clone(), + summary_text: row.summary_text.clone(), + cover_image_src: row.cover_image_src.clone(), + theme_mode: row.theme_mode, + author_display_name: row.author_display_name.clone(), + visited_at_micros: row.visited_at.to_micros_since_unix_epoch(), + created_at_micros: row.created_at.to_micros_since_unix_epoch(), + updated_at_micros: row.updated_at.to_micros_since_unix_epoch(), + } +} + +fn build_profile_wallet_ledger_snapshot_from_row( + row: &ProfileWalletLedger, +) -> RuntimeProfileWalletLedgerEntrySnapshot { + RuntimeProfileWalletLedgerEntrySnapshot { + wallet_ledger_id: row.wallet_ledger_id.clone(), + user_id: row.user_id.clone(), + amount_delta: row.amount_delta, + balance_after: row.balance_after, + source_type: row.source_type, + created_at_micros: row.created_at.to_micros_since_unix_epoch(), + } +} + +fn build_profile_played_world_snapshot_from_row( + row: &ProfilePlayedWorld, +) -> RuntimeProfilePlayedWorldSnapshot { + RuntimeProfilePlayedWorldSnapshot { + played_world_id: row.played_world_id.clone(), + user_id: row.user_id.clone(), + world_key: row.world_key.clone(), + owner_user_id: row.owner_user_id.clone(), + profile_id: row.profile_id.clone(), + world_type: row.world_type.clone(), + world_title: row.world_title.clone(), + world_subtitle: row.world_subtitle.clone(), + first_played_at_micros: row.first_played_at.to_micros_since_unix_epoch(), + last_played_at_micros: row.last_played_at.to_micros_since_unix_epoch(), + last_observed_play_time_ms: row.last_observed_play_time_ms, + } +} + +#[allow(dead_code)] +fn build_runtime_browse_history_row(snapshot: RuntimeBrowseHistorySnapshot) -> UserBrowseHistory { + UserBrowseHistory { + browse_history_id: snapshot.browse_history_id, + user_id: snapshot.user_id, + owner_user_id: snapshot.owner_user_id, + profile_id: snapshot.profile_id, + world_name: snapshot.world_name, + subtitle: snapshot.subtitle, + summary_text: snapshot.summary_text, + cover_image_src: snapshot.cover_image_src, + theme_mode: snapshot.theme_mode, + author_display_name: snapshot.author_display_name, + visited_at: Timestamp::from_micros_since_unix_epoch(snapshot.visited_at_micros), + created_at: Timestamp::from_micros_since_unix_epoch(snapshot.created_at_micros), + updated_at: Timestamp::from_micros_since_unix_epoch(snapshot.updated_at_micros), + } +} diff --git a/server-rs/scripts/m7-preflight.ps1 b/server-rs/scripts/m7-preflight.ps1 new file mode 100644 index 00000000..053e982c --- /dev/null +++ b/server-rs/scripts/m7-preflight.ps1 @@ -0,0 +1,80 @@ +[CmdletBinding()] +param( + [Alias("h")] + [switch]$Help, + [switch]$RunSmoke, + [switch]$RunSpacetimeBuild +) + +$ErrorActionPreference = "Stop" + +function Write-Usage { + @( + 'Usage:', + ' ./server-rs/scripts/m7-preflight.ps1', + ' ./server-rs/scripts/m7-preflight.ps1 -RunSmoke', + ' ./server-rs/scripts/m7-preflight.ps1 -RunSpacetimeBuild', + '', + 'Notes:', + ' 1. Run M7 cutover preflight checks for Rust backend', + ' 2. Default checks are non-destructive and do not publish or clear SpacetimeDB data', + ' 3. -RunSmoke starts a temporary api-server and verifies /healthz contract', + ' 4. -RunSpacetimeBuild requires spacetime CLI and only builds the module' + ) -join [Environment]::NewLine +} + +if ($Help) { + Write-Usage + exit 0 +} + +$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path +$serverRsDir = Split-Path -Parent $scriptDir +$repoRoot = Split-Path -Parent $serverRsDir +$manifestPath = Join-Path $serverRsDir "Cargo.toml" +$modulePath = Join-Path $serverRsDir "crates\spacetime-module" + +if (-not (Test-Path $manifestPath)) { + throw "Missing server-rs/Cargo.toml, cannot start M7 preflight." +} + +Write-Host "[m7:preflight] repo root: $repoRoot" +Write-Host "[m7:preflight] server-rs: $serverRsDir" + +Push-Location $serverRsDir +try { + Write-Host "[m7:preflight] step: cargo check -p spacetime-module" + cargo check -p spacetime-module --manifest-path $manifestPath + + Write-Host "[m7:preflight] step: cargo check -p api-server" + cargo check -p api-server --manifest-path $manifestPath + + Write-Host "[m7:preflight] step: cargo test -p shared-contracts" + cargo test -p shared-contracts --manifest-path $manifestPath + + if ($RunSpacetimeBuild) { + $spacetimeCommand = Get-Command spacetime -ErrorAction SilentlyContinue + if ($null -eq $spacetimeCommand) { + throw "Missing spacetime CLI, cannot run spacetime build." + } + + Write-Host "[m7:preflight] step: spacetime build --debug" + Push-Location $modulePath + try { + & $spacetimeCommand.Source build --debug + } + finally { + Pop-Location + } + } +} +finally { + Pop-Location +} + +if ($RunSmoke) { + Write-Host "[m7:preflight] step: server-rs smoke" + & (Join-Path $serverRsDir "scripts\smoke.ps1") +} + +Write-Host "[m7:preflight] all checks passed" diff --git a/vite.config.ts b/vite.config.ts index 7efc9d22..3b9b9ef1 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -17,9 +17,16 @@ export default defineConfig(({mode}) => { '**/public/generated-custom-world-scenes/**', '**/public/generated-qwen-sprites/**', ]; - const runtimeServerTarget = + const backendStack = (env.GENARRATIVE_BACKEND_STACK || 'node').trim().toLowerCase(); + const nodeServerTarget = env.NODE_SERVER_TARGET || 'http://127.0.0.1:8081'; + const rustServerTarget = + env.RUST_SERVER_TARGET || + 'http://127.0.0.1:3000'; + const runtimeServerTarget = + env.GENARRATIVE_RUNTIME_SERVER_TARGET || + (backendStack === 'rust' ? rustServerTarget : nodeServerTarget); return { root: __dirname,