diff --git a/docs/technical/README.md b/docs/technical/README.md index 434febe4..9acd89c3 100644 --- a/docs/technical/README.md +++ b/docs/technical/README.md @@ -4,6 +4,10 @@ ## 文档列表 +- [SERVER_RS_DDD_WP_SC_SPACETIME_CLIENT_REFACTOR_2026-04-29.md](./SERVER_RS_DDD_WP_SC_SPACETIME_CLIENT_REFACTOR_2026-04-29.md):冻结 `WP-SC Spacetime Client` 本次基础设施重构边界,明确只收口 `spacetime-client` 的 typed facade、错误映射和 row snapshot mapper,不预判尚未由 `WP-ST` 稳定的表、reducer、procedure 或 row shape。 +- [SERVER_RS_DDD_G1_CONTRACT_AND_ROUTE_MATRIX_PROGRESS_2026-04-29.md](./SERVER_RS_DDD_G1_CONTRACT_AND_ROUTE_MATRIX_PROGRESS_2026-04-29.md):记录 `G1 契约与路由矩阵` 已完成的本地进度、验证结果、单 owner 边界和下一批并行任务入口。 +- [SERVER_RS_DDD_G1_CONTRACT_AND_ROUTE_MATRIX_2026-04-29.md](./SERVER_RS_DDD_G1_CONTRACT_AND_ROUTE_MATRIX_2026-04-29.md):冻结 `server-rs` DDD G1 契约与路由矩阵,明确新旧 HTTP 路由去留、DTO 删除/保留/重命名、页面到 query/result DTO 映射、breaking change、API 错误 envelope 和共享契约单 owner 边界。 +- [SERVER_RS_DDD_WP_API_BFF_START_2026-04-29.md](./SERVER_RS_DDD_WP_API_BFF_START_2026-04-29.md):记录 `WP-API api-server BFF` 启动切片,先收口旧 runtime story 兼容路由挂载、错误 envelope 回归和后续依赖,不越过 `spacetime-client` 接线边界。 - [SERVER_RS_DDD_PARALLEL_TASKLIST_2026-04-29.md](./SERVER_RS_DDD_PARALLEL_TASKLIST_2026-04-29.md):把 `server-rs` DDD 一次性重构拆成全局可并行工作包,覆盖 `module-*`、`spacetime-module`、`spacetime-client`、`api-server`、`platform-*`、共享契约和前端接入的依赖、边界与验收命令。 - [SERVER_RS_DDD_FULL_REFACTOR_2026-04-28.md](./SERVER_RS_DDD_FULL_REFACTOR_2026-04-28.md):冻结 `server-rs` 一次性 DDD 重构总纲,明确 crate 依赖方向、模块目录、上下文聚合/命令/事件/读模型、SpacetimeDB adapter 映射和表结构变更约束。 - [SPACETIMEDB_SCHEMA_CHANGE_CONSTRAINTS.md](./SPACETIMEDB_SCHEMA_CHANGE_CONSTRAINTS.md):冻结 SpacetimeDB 表结构变更约束、自动迁移可接受范围、冲突后的系统行为,以及保留旧数据的增量迁移流程;凡涉及 `spacetime publish`、表字段调整或 `migration.rs` 对齐时优先参考。 diff --git a/docs/technical/RUST_API_SERVER_ROUTE_INDEX_2026-04-22.md b/docs/technical/RUST_API_SERVER_ROUTE_INDEX_2026-04-22.md index de8fd3e1..a35a152a 100644 --- a/docs/technical/RUST_API_SERVER_ROUTE_INDEX_2026-04-22.md +++ b/docs/technical/RUST_API_SERVER_ROUTE_INDEX_2026-04-22.md @@ -2,6 +2,10 @@ 更新时间:`2026-04-23` +> 2026-04-29 补充:本文件保留为迁移期路由快照。DDD G1 后续并行工作的契约冻结口径以 [`SERVER_RS_DDD_G1_CONTRACT_AND_ROUTE_MATRIX_2026-04-29.md`](./SERVER_RS_DDD_G1_CONTRACT_AND_ROUTE_MATRIX_2026-04-29.md) 为准,尤其是新增的 Big Fish、Puzzle、profile、runtime chat、story facade 和兼容路由删除计划。 +> +> 2026-04-29 WP-RS 进度:旧 `/api/runtime/story/*` HTTP compat 路由已从 `api-server/src/app.rs` 取消挂载,并删除 `api-server/src/runtime_story*` 兼容实现。当前 Rust `api-server` 对外 story 主链只保留 `/api/story/*`、`/api/runtime/sessions/{runtime_session_id}/inventory` 与 runtime chat 相关路由。 + ## 1. 文档目标 本文件记录当前 `server-rs/crates/api-server/src/app.rs` 中已挂载的 Rust Axum 路由面,用于对照 Node 后端 `96` 条路由能力基线。 @@ -20,7 +24,7 @@ 6. custom world / agent 接口:`23` 条。 7. llm proxy 接口:`1` 条。 8. profile / runtime profile 接口:`12` 条。 -9. runtime story / story gameplay 接口:`15` 条。 +9. story gameplay / runtime inventory 接口:`10` 条。 10. legacy generated 静态路径兼容:`6` 条。 11. health check:`1` 条。 @@ -129,23 +133,18 @@ 11. `POST /api/profile/save-archives/{world_key}` 12. `POST /api/runtime/profile/save-archives/{world_key}` -### 3.9 Runtime Story / Gameplay +### 3.9 Story Gameplay / Runtime Inventory 1. `POST /api/runtime/save/snapshot` 2. `GET /api/runtime/settings` -3. `GET /api/runtime/story/state/{session_id}` -4. `POST /api/runtime/story/state/resolve` -5. `POST /api/runtime/story/actions/resolve` -6. `POST /api/runtime/story/initial` -7. `POST /api/runtime/story/continue` -8. `POST /api/story/sessions` -9. `POST /api/story/sessions/continue` -10. `GET /api/story/sessions/{story_session_id}/state` -11. `POST /api/story/battles` -12. `POST /api/story/battles/resolve` -13. `GET /api/story/battles/{battle_state_id}` -14. `POST /api/story/npc/battle` -15. `GET /api/runtime/sessions/{runtime_session_id}/inventory` +3. `POST /api/story/sessions` +4. `POST /api/story/sessions/continue` +5. `GET /api/story/sessions/{story_session_id}/state` +6. `POST /api/story/battles` +7. `POST /api/story/battles/resolve` +8. `GET /api/story/battles/{battle_state_id}` +9. `POST /api/story/npc/battle` +10. `GET /api/runtime/sessions/{runtime_session_id}/inventory` ### 3.10 Legacy Generated 路径 diff --git a/docs/technical/SERVER_RS_DDD_G1_CONTRACT_AND_ROUTE_MATRIX_2026-04-29.md b/docs/technical/SERVER_RS_DDD_G1_CONTRACT_AND_ROUTE_MATRIX_2026-04-29.md new file mode 100644 index 00000000..a174e36a --- /dev/null +++ b/docs/technical/SERVER_RS_DDD_G1_CONTRACT_AND_ROUTE_MATRIX_2026-04-29.md @@ -0,0 +1,176 @@ +# server-rs DDD G1 契约与路由矩阵冻结(2026-04-29) + +## 1. 冻结范围 + +本文是 `SERVER_RS_DDD_PARALLEL_TASKLIST_2026-04-29.md` 中 `G1 契约与路由矩阵` 的串行冻结结果。G1 只冻结契约、路由和后续并行任务边界,不实现业务逻辑,不迁移 reducer,不改前端页面。 + +G1 单 owner 文件范围: + +1. `server-rs/crates/shared-contracts/src/**` +2. `packages/shared/src/contracts/**` +3. `packages/shared/src/index.ts` +4. `docs/technical/RUST_API_SERVER_ROUTE_INDEX_2026-04-22.md` +5. 本文档 + +后续并行任务只能消费本文冻结的 route、DTO 和 error envelope。确实需要新增或调整 DTO shape 时,先在对应工作包交接中写清变更原因,再回到 G1 owner 文件集中改动,避免多个并行线同时抢改契约。 + +## 2. HTTP 路由矩阵 + +状态含义: + +1. `保留`:作为 DDD 改造后的主链路由继续存在。 +2. `重命名`:后续改成新的 route family,旧路径删除,不做兼容双主链。 +3. `删除`:兼容层或临时调试入口,前端迁移后物理删除。 +4. `收敛`:保留功能,但 route、DTO 或返回 envelope 需要归一到新的主链。 + +| 分组 | 当前路由 | G1 决议 | 新主链目标 | 所属后续任务 | +| --- | --- | --- | --- | --- | +| 健康检查 | `GET /healthz` | 保留 | 不变,统一 envelope 可例外保留轻量 health payload | WP-API | +| 管理后台页面 | `GET /admin` | 保留 | 不变 | WP-API | +| 管理后台 API | `POST /admin/api/login`、`GET /admin/api/me`、`GET /admin/api/overview`、`POST /admin/api/debug/http` | 保留 | `Admin*` DTO 继续由 `admin.rs` 管理 | WP-A、WP-API | +| 管理兑换码 | `POST /admin/api/profile/redeem-codes`、`POST /admin/api/profile/redeem-codes/disable` | 收敛 | 继续走 admin 路由,DTO 归入 profile/runtime 管理命令组 | WP-RT、WP-API | +| 内部鉴权调试 | `GET /_internal/auth/claims`、`GET /_internal/auth/refresh-cookie` | 删除 | 只允许本地诊断脚本或 admin debug 能力使用,不作为前端契约 | WP-DEL | +| 鉴权公开查询 | `GET /api/auth/login-options`、`GET /api/auth/public-users/by-code/{code}`、`GET /api/auth/public-users/by-id/{user_id}` | 保留 | `AuthLoginOptionsResponse`、`PublicUserSearchResponse` | WP-A | +| 鉴权会话 | `GET /api/auth/me`、`GET /api/auth/sessions`、`POST /api/auth/refresh`、`POST /api/auth/logout`、`POST /api/auth/logout-all` | 保留 | `AuthMeResponse`、`AuthSessionsResponse`、`RefreshSessionResponse`、`LogoutResponse`、`LogoutAllResponse` | WP-A | +| 鉴权登录 | `POST /api/auth/phone/send-code`、`POST /api/auth/phone/login`、`GET /api/auth/wechat/start`、`GET /api/auth/wechat/callback`、`POST /api/auth/wechat/bind-phone`、`POST /api/auth/entry`、`POST /api/auth/password/change`、`POST /api/auth/password/reset` | 保留 | TS 命名统一使用 `Auth*` 前缀,Rust 命名维持领域语义 | WP-A | +| 旧本地生成资产代理 | `GET /generated-character-drafts/{*path}`、`/generated-characters/{*path}`、`/generated-animations/{*path}`、`/generated-big-fish-assets/{*path}`、`/generated-puzzle-assets/{*path}`、`/generated-custom-world-scenes/{*path}`、`/generated-custom-world-covers/{*path}`、`/generated-qwen-sprites/{*path}` | 删除 | 正式读取统一改为 `GET /api/assets/read-url` 或 asset object projection;本地生成路径只允许迁移窗口内存在 | WP-AS、WP-FE、WP-DEL | +| LLM 代理 | `POST /api/llm/chat/completions` | 收敛 | 仅作为平台能力代理;玩法 prompt 不允许由前端直接传入 | WP-PF、WP-API | +| Runtime chat | `POST /api/runtime/chat/character/suggestions`、`/summary`、`/reply/stream`、`/npc/dialogue/stream`、`/npc/turn/stream`、`/npc/recruit/stream` | 重命名 | 收敛到 session scoped story/chat 命令;请求体不得携带前端拼装的世界真相 | WP-RS、WP-RPG、WP-FE | +| 文档输入 | `POST /api/runtime/creation-agent/document-inputs/parse` | 保留 | `ParseCreationAgentDocumentInputRequest/Response` | WP-CW、WP-BF、WP-PZ | +| AI task | `POST /api/ai/tasks`、`/{task_id}/start`、`/{task_id}/stages/{stage_kind}/start`、`/{task_id}/chunks`、`/{task_id}/stages/{stage_kind}/complete`、`/{task_id}/references`、`/{task_id}/complete`、`/{task_id}/fail`、`/{task_id}/cancel` | 保留 | `AiTask*` 命令/result DTO;后续接 module-ai 状态机 | WP-AI | +| Assets object | `POST /api/assets/direct-upload-tickets`、`POST /api/assets/sts-upload-credentials`、`POST /api/assets/objects/confirm`、`POST /api/assets/objects/bind`、`GET /api/assets/read-url`、`GET /api/assets/history` | 保留 | `CreateDirectUploadTicket*`、`ConfirmAssetObject*`、`BindAssetObject*`、`GetReadUrlQuery/Response`、`AssetHistory*` | WP-AS | +| 角色资产工作流 | `POST /api/assets/character-visual/generate`、`GET /api/assets/character-visual/jobs/{task_id}`、`POST /api/assets/character-visual/publish`、`POST /api/assets/character-animation/generate`、`GET /api/assets/character-animation/jobs/{task_id}`、`POST /api/assets/character-animation/publish`、`POST /api/assets/character-animation/import-video`、`GET /api/assets/character-animation/templates`、`POST /api/assets/character-workflow-cache`、`GET /api/assets/character-workflow-cache/{character_id}`、`POST/PUT /api/runtime/custom-world/asset-studio/role/{character_id}/workflow` | 收敛 | Asset object、AI task、role workflow 三组 DTO 拆清;workflow 不再把业务真相藏在 cache body | WP-AS、WP-CW、WP-API | +| Runtime settings/save | `GET/PUT /api/runtime/settings`、`GET/PUT/DELETE /api/runtime/save/snapshot` | 保留 | `RuntimeSettingsResponse`、`PutRuntimeSettingsRequest`、`SavedGameSnapshotResponse`、`PutSavedGameSnapshotRequest` | WP-RT | +| RPG 作品库 | `GET /api/runtime/custom-world-library`、`GET/PUT/DELETE /api/runtime/custom-world-library/{profile_id}`、`POST /publish`、`POST /unpublish`、`GET /api/runtime/custom-world-gallery`、`GET /api/runtime/custom-world-gallery/{owner_user_id}/{profile_id}`、`GET /api/runtime/custom-world-gallery/by-code/{code}` | 收敛 | 命名后续改为 RPG creation/work route family;删除 `custom-world` 旧泛名歧义 | WP-CW、WP-FE | +| RPG Agent | `POST /api/runtime/custom-world/agent/sessions`、`GET/DELETE /sessions/{session_id}`、`GET /result-view`、`GET /works`、`GET /cards/{card_id}`、`POST /messages`、`POST /messages/stream`、`POST /actions`、`GET /operations/{operation_id}` | 收敛 | DTO 重命名为 `RpgAgent*`,Rust 当前 `CustomWorldAgent*` 后续物理重命名 | WP-CW、WP-FE、WP-DEL | +| Big Fish Agent/Works | `POST /api/runtime/big-fish/agent/sessions`、`GET /sessions/{session_id}`、`POST /messages`、`POST /messages/stream`、`POST /actions`、`GET /works`、`DELETE /works/{session_id}`、`GET /gallery`、`POST /sessions/{session_id}/play`、`POST /works/{session_id}/play` | 保留 | `BigFish*` DTO,`sessions/{id}/play` 与 `works/{id}/play` 后续二选一保留 | WP-BF | +| Puzzle Agent/Works/Runtime | `POST /api/runtime/puzzle/agent/sessions`、`GET /sessions/{session_id}`、`POST /messages`、`POST /messages/stream`、`POST /actions`、`GET /works`、`GET/PUT/DELETE /works/{profile_id}`、`GET /gallery`、`GET /gallery/{profile_id}`、`POST /runs`、`POST /runs/local-next-level`、`GET /runs/{run_id}`、`POST /runs/{run_id}/swap`、`POST /runs/{run_id}/drag`、`POST /runs/{run_id}/next-level`、`POST /runs/{run_id}/leaderboard` | 保留 | `PuzzleAgent*`、`PuzzleWork*`、`PuzzleRun*` DTO | WP-PZ | +| RPG profile/asset generation | `POST /api/runtime/custom-world/profile`、`POST /api/custom-world/entity`、`POST /api/runtime/custom-world/entity`、`POST /api/custom-world/scene-npc`、`POST /api/runtime/custom-world/scene-npc`、`POST /api/custom-world/scene-image`、`POST /api/custom-world/cover-image`、`POST /api/runtime/custom-world/cover-image`、`POST /api/custom-world/cover-upload`、`POST /api/runtime/custom-world/cover-upload` | 重命名 | 去掉非 runtime 前缀旧入口;统一到 RPG creation asset/profile route family | WP-CW、WP-AS、WP-DEL | +| Profile | `GET/POST/DELETE /api/runtime/profile/browse-history`、`GET/POST/DELETE /api/profile/browse-history`、`GET /dashboard`、`GET /wallet-ledger`、`GET /recharge-center`、`POST /recharge/orders`、`GET /referrals/invite-center`、`POST /referrals/redeem-code`、`POST /redeem-codes/redeem`、`GET /play-stats`、`GET /save-archives`、`POST /save-archives/{world_key}` | 重命名 | 保留 `/api/runtime/profile/*` 主链,删除 `/api/profile/*` 镜像入口 | WP-RT、WP-FE、WP-DEL | +| Runtime inventory | `GET /api/runtime/sessions/{runtime_session_id}/inventory` | 保留 | `RuntimeInventoryStateResponse` | WP-RPG、WP-RT | +| Runtime story 旧层 | `POST /api/runtime/story/sessions`、`POST /api/runtime/story/state/resolve`、`GET /api/runtime/story/state/{session_id}`、`POST /api/runtime/story/actions/resolve`、`POST /api/runtime/story/initial`、`POST /api/runtime/story/continue` | 已删除 | 已从 `api-server` 取消挂载并删除 `api-server/src/runtime_story*` 兼容实现;后续前端迁移到 `GET/POST /api/story/*` 和 session scoped story/chat facade | WP-RS、WP-FE、WP-DEL | +| Story/Game facade | `POST /api/story/sessions`、`GET /api/story/sessions/{story_session_id}/state`、`POST /api/story/sessions/continue`、`POST /api/story/battles`、`GET /api/story/battles/{battle_state_id}`、`POST /api/story/npc/battle`、`POST /api/story/battles/resolve` | 保留 | `BeginStorySession*`、`ContinueStory*`、battle/npc command/result DTO 后续补齐到 `shared-contracts` | WP-RPG、WP-RS | + +## 3. DTO 冻结清单 + +### 3.1 保留 + +| 契约文件 | 保留 DTO | +| --- | --- | +| `shared-contracts/src/api.rs` | `ApiResponseMeta`、`ApiErrorPayload`、`ApiSuccessEnvelope`、`ApiErrorEnvelope` | +| `shared-contracts/src/admin.rs` | `AdminLoginRequest/Response`、`AdminSessionPayload`、`AdminMeResponse`、`AdminOverviewResponse`、`AdminDebugHttpRequest/Response` | +| `shared-contracts/src/auth.rs` | `AuthLoginOptionsResponse`、`AuthUserPayload`、`PublicUserSummaryPayload`、`PublicUserSearchResponse`、`PasswordEntry*`、`PasswordChange*`、`PasswordReset*`、`AuthMeResponse`、`AuthSessionsResponse`、`RefreshSessionResponse`、`Logout*`、`Phone*`、`Wechat*` | +| `shared-contracts/src/ai.rs` | `CreateAiTaskRequest`、`AppendAiTextChunkRequest`、`CompleteAiStageRequest`、`AttachAiResultReferenceRequest`、`FailAiTaskRequest`、`AiTask*Payload`、`AiTaskMutationResponse`、`AiTaskAcceptedResponse` | +| `shared-contracts/src/assets.rs` | Direct upload、read url、asset object、asset binding、asset history、character visual/animation、workflow cache、role asset workflow 相关 DTO | +| `shared-contracts/src/big_fish*.rs` | `CreateBigFishSessionRequest`、`SendBigFishMessageRequest`、`ExecuteBigFishActionRequest`、`RecordBigFishPlayRequest`、`BigFish*Response`、`BigFishWorksResponse` | +| `shared-contracts/src/puzzle_*.rs` | `CreatePuzzleAgentSessionRequest`、`SendPuzzleAgentMessageRequest`、`ExecutePuzzleAgentActionRequest`、`PuzzleAgent*Response`、`PuzzleWork*`、`PuzzleRun*`、`PuzzleGallery*` | +| `shared-contracts/src/runtime.rs` | runtime settings/save/profile/browse history/custom world library/agent/result view/inventory 现有 DTO 在迁移窗口保留 | +| `shared-contracts/src/story.rs` | `BeginStorySessionRequest`、`ContinueStoryRequest`、`StorySessionPayload`、`StoryEventPayload`、`StorySessionMutationResponse`、`StorySessionStateResponse` | +| `packages/shared/src/contracts/runtime.ts` | `RuntimeSettings`、`SavedGameSnapshot*`、profile、browse history、library/gallery DTO;迁移窗口继续作为前端消费主入口 | +| `packages/shared/src/contracts/rpgAgent*.ts` | RPG Agent、draft、anchors、result view、work summary DTO | +| `packages/shared/src/contracts/bigFish*.ts` | Big Fish Agent、runtime、本地作品列表 DTO | +| `packages/shared/src/contracts/puzzle*.ts` | Puzzle Agent、work、gallery、runtime DTO | + +### 3.2 重命名 + +| 当前 DTO | 新命名 | 说明 | +| --- | --- | --- | +| Rust `CustomWorldAgent*` | `RpgAgent*` | Rust 与 TS 命名对齐,`custom world` 只作为历史目录语义,不再作为 RPG 主链契约名。 | +| Rust `CustomWorldLibrary*` / `CustomWorldWorks*` | `RpgCreationWork*` / `RpgCreationLibrary*` | 前端已有 `RpgCreationWorkSummary`,后端后续对齐 RPG 创作域命名。 | +| Rust `GenerateCustomWorldProfile*` | `GenerateRpgCreationProfile*` | 去掉泛化 custom world 命名,明确 RPG 创作 profile。 | +| TS `AuthEntry*` | `PasswordEntry*` 或统一后端 `AuthPasswordEntry*` | 需要在 WP-A 中二选一收口,避免 entry 与 phone/wechat 登录语义混杂。 | +| `RuntimeStory*` view model | `RpgRuntimeStory*` 或拆到 `Story*`、`Battle*`、`Inventory*` | 旧聚合大 DTO 后续拆分为 story session、battle、inventory、npc interaction 投影。 | +| Profile 镜像 DTO | `RuntimeProfile*` | `/api/profile/*` 镜像删除后,契约命名跟随 `/api/runtime/profile/*`。 | + +### 3.3 删除 + +| DTO/文件 | 删除条件 | 替代 | +| --- | --- | --- | +| `LegacyApiErrorResponse` | 全部路由完成 envelope 归一后 | `ApiErrorEnvelope` | +| Rust `RuntimeStoryStateResolveRequest` | 前端切到 `GET /api/story/sessions/{story_session_id}/state` 后 | `StorySessionStateResponse` 加拆分投影 | +| Rust/TS `RuntimeStoryBootstrapRequest/Response` | `POST /api/runtime/story/initial` 删除后 | `BeginStorySessionRequest`、`StorySessionMutationResponse` | +| Rust/TS `RuntimeStoryAiRequest/Response` | `POST /api/runtime/story/continue` 删除后 | `ContinueStoryRequest`、`StorySessionMutationResponse` | +| Rust/TS `RuntimeStoryActionRequest/Response` 旧总入口形态 | `POST /api/runtime/story/actions/resolve` 删除后 | story/battle/npc/inventory 分命令 result DTO | +| TS `StoryRequestPayload`、`PlainTextPromptRequest`、`PlainTextResponse` | runtime chat 不再由前端传 prompt 后 | 后端 session scoped chat/story command | +| TS `CreateCustomWorldSessionRequest`、`AnswerCustomWorldSessionQuestionRequest`、`CustomWorldSessionRecord` 等旧问答生成 DTO | 确认无前端运行引用后 | RPG Agent session DTO | +| `/api/profile/*` 镜像 DTO 别名 | 前端全量迁到 `/api/runtime/profile/*` 后 | Runtime profile DTO | + +## 4. 页面/功能到 query/result DTO 映射 + +| 页面/功能 | Query DTO | Command DTO | Result DTO | +| --- | --- | --- | --- | +| 管理后台登录 | 无 | `AdminLoginRequest` | `AdminLoginResponse` | +| 管理后台概览 | 无 | 无 | `AdminMeResponse`、`AdminOverviewResponse` | +| 管理后台 API 调试 | 无 | `AdminDebugHttpRequest` | `AdminDebugHttpResponse` | +| 登录方式页 | 无 | 无 | `AuthLoginOptionsResponse` | +| 手机号登录 | 无 | `PhoneSendCodeRequest`、`PhoneLoginRequest` | `PhoneSendCodeResponse`、`PhoneLoginResponse` | +| 密码登录/改密/重置 | 无 | `PasswordEntryRequest`、`PasswordChangeRequest`、`PasswordResetRequest` | `PasswordEntryResponse`、`PasswordChangeResponse`、`PasswordResetResponse` | +| 会话中心 | refresh cookie / bearer token | `logout`、`logout-all` 无 body | `AuthMeResponse`、`AuthSessionsResponse`、`RefreshSessionResponse`、`LogoutResponse`、`LogoutAllResponse` | +| 公开用户卡片 | route param `code` 或 `user_id` | 无 | `PublicUserSearchResponse` | +| 创作中心 RPG 作品货架 | bearer token | 无 | `CustomWorldWorksResponse`,后续重命名 `RpgCreationWorksResponse` | +| RPG Agent 工作区 | route param `session_id` / `operation_id` | `CreateCustomWorldAgentSessionRequest`、`SendCustomWorldAgentMessageRequest`、`ExecuteCustomWorldAgentActionRequest` | `CustomWorldAgentSessionResponse`、`CustomWorldAgentOperationResponse`、`CustomWorldCreationResultViewResponse`,后续重命名 `RpgAgent*` | +| RPG 结果页 | route param `session_id` | section patch/action request | `CustomWorldCreationResultViewResponse`、`CustomWorldAgentCardDetailResponse` | +| RPG 资产工坊 | route param `character_id`、`GetReadUrlQuery` | `CharacterVisualGenerateRequest`、`CharacterAnimationGenerateRequest`、`CharacterWorkflowCacheSaveRequest`、`CharacterRoleAssetWorkflowResolveRequest` | `CharacterVisualGenerateResponse`、`CharacterAnimationGenerateResponse`、`CharacterWorkflowCacheGetResponse`、`CharacterRoleAssetWorkflowResponse` | +| Big Fish Agent | route param `session_id` | `CreateBigFishSessionRequest`、`SendBigFishMessageRequest`、`ExecuteBigFishActionRequest` | `BigFishSessionResponse`、`BigFishActionResponse` | +| Big Fish 广场/作品 | bearer token 或公开 gallery query | `RecordBigFishPlayRequest` | `BigFishWorksResponse`、`BigFishGalleryResponse`、`BigFishSessionResponse` | +| Puzzle Agent | route param `session_id` | `CreatePuzzleAgentSessionRequest`、`SendPuzzleAgentMessageRequest`、`ExecutePuzzleAgentActionRequest` | `PuzzleAgentSessionResponse`、`PuzzleAgentActionResponse` | +| Puzzle 作品/广场 | route param `profile_id` | `PutPuzzleWorkRequest` | `PuzzleWorksResponse`、`PuzzleWorkDetailResponse`、`PuzzleGalleryResponse`、`PuzzleGalleryDetailResponse` | +| Puzzle 运行态 | route param `run_id` | `StartPuzzleRunRequest`、`AdvanceLocalPuzzleNextLevelRequest`、`SwapPuzzlePiecesRequest`、`DragPuzzlePieceRequest`、`SubmitPuzzleLeaderboardRequest` | `PuzzleRunResponse` | +| Runtime 设置与存档 | bearer token | `PutRuntimeSettingsRequest`、`PutSavedGameSnapshotRequest`、`PutRuntimeSaveCheckpointRequest` | `RuntimeSettingsResponse`、`SavedGameSnapshotResponse`、`BasicOkResponse` | +| 个人中心 | bearer token | `CreateProfileRechargeOrderRequest`、`RedeemProfileReferralInviteCodeRequest`、`RedeemProfileRewardCodeRequest`、`PlatformBrowseHistoryUpsertRequest` | `ProfileDashboardSummaryResponse`、`ProfileWalletLedgerResponse`、`ProfileRechargeCenterResponse`、`ProfileReferralInviteCenterResponse`、`ProfilePlayStatsResponse`、`ProfileSaveArchiveListResponse`、`PlatformBrowseHistoryResponse` | +| RPG Story 运行态 | route param `story_session_id`、`battle_state_id` | `BeginStorySessionRequest`、`ContinueStoryRequest`,battle/npc 命令 DTO 后续补齐 | `StorySessionMutationResponse`、`StorySessionStateResponse`、`RuntimeInventoryStateResponse` | +| Runtime chat/NPC 私聊 | route param `runtime_session_id` 或 `story_session_id` | 后续新增 session scoped chat command | 后续新增 chat turn result;旧 `rpgRuntimeChat.ts` DTO 只作为迁移参考 | + +## 5. Breaking change 清单 + +1. 删除兼容层是本轮默认策略。旧 `/api/runtime/story/*`、`/_internal/auth/*`、`/generated-*` 和 `/api/profile/*` 镜像入口在对应前端迁移完成后物理删除。 +2. Runtime story/chat 不再接受前端拼装的 `worldType`、`character`、`monsters`、`history`、`context`、prompt 文本作为正式真相。正式命令必须以 `runtimeSessionId`、`storySessionId`、`battleStateId` 等后端 session id 为索引。 +3. `CustomWorld*` 作为 RPG 主链命名将被重命名为 `RpgCreation*` 或 `RpgAgent*`。前端可同步修改,不保留旧命名适配层。 +4. `/api/custom-world/*` 非 runtime 前缀旧入口删除,统一进入 RPG creation route family 或 asset route family。 +5. `/api/profile/*` 镜像入口删除,统一使用 `/api/runtime/profile/*`。 +6. 资产读取不再依赖 `/generated-*` 静态代理作为正式 contract,统一走 asset object、read url 或后端投影里的正式 URL 字段。 +7. LLM 代理不得作为玩法 prompt 透传入口。玩法 prompt 由 `api-server`/`platform-llm` 内部编排,前端只提交用户动作和展示态输入。 +8. API 错误体统一为 `ApiErrorEnvelope`。旧 `{ error, meta }` 只允许在已列入删除计划的旧接口中短期存在。 + +## 6. API 错误 envelope + +所有主链 HTTP JSON 响应统一使用: + +```json +{ + "ok": false, + "data": null, + "error": { + "code": "BAD_REQUEST", + "message": "请求参数不合法", + "details": { + "message": "具体中文错误说明" + } + }, + "meta": { + "apiVersion": "2026-04-08", + "requestId": "req-xxx", + "routeVersion": "2026-04-08", + "operation": "POST /api/example", + "latencyMs": 12, + "timestamp": "2026-04-29T00:00:00Z" + } +} +``` + +冻结规则: + +1. 成功响应使用 `ApiSuccessEnvelope`:`ok=true`、`data` 为 result DTO、`error=null`、`meta` 必填。 +2. 失败响应使用 `ApiErrorEnvelope`:`ok=false`、`data=null`、`error` 必填、`meta` 必填。 +3. `error.message` 是稳定的分类中文文案,`error.details.message` 是可展示给用户的具体中文错误。 +4. 前端展示业务错误时优先读取 `error.details.message`,再退回 `error.message`。 +5. `code` 使用稳定英文枚举值,禁止把中文错误全文塞进 `code`。 +6. `LegacyApiErrorResponse` 只服务迁移窗口,不能用于新增主链 route。 + +## 7. 后续并行任务交接 + +1. 第 1 批领域任务不得改 `shared-contracts` 和 `packages/shared/src/contracts/**`。需要新字段时先写入任务交接,等 G1 owner 合流。 +2. `WP-ST` 负责 SpacetimeDB 表、reducer/procedure 和 `migration.rs`,不得由玩法领域任务直接抢改。 +3. `WP-API` 负责 `api-server/src/app.rs` 和 route 挂载入口,领域任务只提供应用结果和错误模型。 +4. `WP-FE` 在后端新接口稳定后删除旧前端兼容层,不新增对旧 route 的二次适配。 +5. `WP-DEL` 只能在搜索确认无运行引用后删除旧 DTO、旧 route 和旧静态代理。 diff --git a/docs/technical/SERVER_RS_DDD_G1_CONTRACT_AND_ROUTE_MATRIX_PROGRESS_2026-04-29.md b/docs/technical/SERVER_RS_DDD_G1_CONTRACT_AND_ROUTE_MATRIX_PROGRESS_2026-04-29.md new file mode 100644 index 00000000..91c0e2f9 --- /dev/null +++ b/docs/technical/SERVER_RS_DDD_G1_CONTRACT_AND_ROUTE_MATRIX_PROGRESS_2026-04-29.md @@ -0,0 +1,57 @@ +# server-rs DDD G1 契约与路由矩阵进度记录(2026-04-29) + +## 1. 当前状态 + +`G1 契约与路由矩阵` 已完成串行冻结,当前可作为第 1 批领域规则并行任务的契约输入。 + +本次只落地文档与索引,不修改 Rust / TypeScript 契约源码,不改业务实现,不启动前端迁移。 + +## 2. 已完成内容 + +1. 新增 G1 冻结文档:[`SERVER_RS_DDD_G1_CONTRACT_AND_ROUTE_MATRIX_2026-04-29.md`](./SERVER_RS_DDD_G1_CONTRACT_AND_ROUTE_MATRIX_2026-04-29.md)。 +2. 冻结新旧 HTTP 路由清单,按 `保留`、`重命名`、`删除`、`收敛` 标记后续处理。 +3. 冻结 DTO 保留、删除、重命名清单。 +4. 冻结页面/功能到 query、command、result DTO 的映射。 +5. 冻结 breaking change 清单,明确本轮不保留旧兼容层作为约束。 +6. 冻结 API 错误 envelope,主链统一使用 `ApiSuccessEnvelope` / `ApiErrorEnvelope`。 +7. 在全局并行任务清单中补充 G1 冻结文档入口和单 owner 文件边界。 +8. 在旧 Rust API route index 中补充 2026-04-29 提示,避免继续把 2026-04-23 快照当作最新契约。 +9. 在技术 README 中补充 G1 冻结文档入口。 + +## 3. 单 owner 边界 + +G1 后续合流文件: + +1. `server-rs/crates/shared-contracts/src/**` +2. `packages/shared/src/contracts/**` +3. `packages/shared/src/index.ts` +4. `docs/technical/RUST_API_SERVER_ROUTE_INDEX_2026-04-22.md` +5. `docs/technical/SERVER_RS_DDD_G1_CONTRACT_AND_ROUTE_MATRIX_2026-04-29.md` + +第 1 批并行领域任务不得直接改这些文件。确实需要新增字段或调整 DTO shape 时,先在任务交接里记录变更原因,再由 G1 owner 文件集中合流。 + +## 4. 验证结果 + +已执行: + +```powershell +npm.cmd run check:encoding -- docs/technical/SERVER_RS_DDD_G1_CONTRACT_AND_ROUTE_MATRIX_2026-04-29.md docs/technical/SERVER_RS_DDD_PARALLEL_TASKLIST_2026-04-29.md docs/technical/README.md docs/technical/RUST_API_SERVER_ROUTE_INDEX_2026-04-22.md +``` + +结果:通过,4 个文档文件 UTF-8 编码检查正常。 + +## 5. 后续入口 + +下一步可以按全局清单进入第 1 批领域规则并行任务: + +1. `WP-A Auth` +2. `WP-AS Assets` +3. `WP-AI AI Task` +4. `WP-CW Custom World` +5. `WP-BF Big Fish` +6. `WP-PZ Puzzle` +7. `WP-RT Runtime/Profile/Save` +8. `WP-RPG Gameplay 域` +9. `WP-RS Runtime Story 去兼容层` + +进入下一批前,先以 G1 冻结文档确认 route、DTO、error envelope 和 breaking change,避免并行任务各自定义接口。 diff --git a/docs/technical/SERVER_RS_DDD_PARALLEL_TASKLIST_2026-04-29.md b/docs/technical/SERVER_RS_DDD_PARALLEL_TASKLIST_2026-04-29.md index 542b8bb8..e724c6b8 100644 --- a/docs/technical/SERVER_RS_DDD_PARALLEL_TASKLIST_2026-04-29.md +++ b/docs/technical/SERVER_RS_DDD_PARALLEL_TASKLIST_2026-04-29.md @@ -50,15 +50,7 @@ flowchart TD G1 --> RT G1 --> RPG G1 --> RS - G2 --> A - G2 --> AS - G2 --> AI - G2 --> CW - G2 --> BF - G2 --> PZ - G2 --> RT - G2 --> RPG - G2 --> RS + G2 --> V A --> ST AS --> ST AI --> ST @@ -80,17 +72,22 @@ flowchart TD ## 3. 并行分批 -### 3.1 第一批:冻结边界 +### 3.1 第 0 批:冻结边界与门禁 -只能串行完成,避免后续并行任务各自定义接口。 +先完成边界和基础门禁,避免后续并行任务各自定义接口或绕过 DDD 骨架。 1. `G0 文档、边界、冻结窗口` 2. `G1 契约与路由矩阵` 3. `G2 module-* DDD 骨架与边界检查` -### 3.2 第二批:领域纯规则并行迁移 +执行口径: -第二批互相并行,但每个任务只能改自己的 `module-*` 和对应文档。 +1. `G1` 完成后即可开启第 1 批领域规则并行泳道。 +2. `G2` 是贯穿后续工作的边界检查门禁;当前 DDD 骨架已具备,后续每批必须继续跑检查。 + +### 3.2 第 1 批:领域规则并行 + +`G1` 完成后,可以开启下面这些并行泳道。每个任务只能改自己的 `module-*` 和对应文档;需要跨域输出时以领域事件或应用结果表达。 1. `WP-A Auth` 2. `WP-AS Assets` @@ -102,22 +99,56 @@ flowchart TD 8. `WP-RPG Gameplay 域` 9. `WP-RS Runtime Story 去兼容层` -### 3.3 第三批:adapter 和 BFF 接线 +### 3.3 第 2 批:Adapter / BFF 接线 -领域任务有稳定应用结果后启动。 +领域输出稳定后再启动本批。本批允许并行准备,但必须分层推进,不能让 `api-server` 先复制领域规则或绕过 `spacetime-client` 直连实现。 -1. `WP-ST SpacetimeDB Adapter` -2. `WP-SC Spacetime Client` -3. `WP-PF platform side effects` -4. `WP-API api-server BFF` +#### 3.3.1 2A:`WP-ST SpacetimeDB Adapter` -### 3.4 第四批:前端与旧层删除 +按上下文接入已经稳定的领域函数,负责 table、reducer、procedure、row mapper、事务内查询和必要 event/projection table。 + +单 owner 文件: + +1. `server-rs/crates/spacetime-module/src/lib.rs` +2. `server-rs/crates/spacetime-module/src/migration.rs` +3. `docs/technical/SPACETIMEDB_TABLE_CATALOG.md` + +这些文件由 `WP-ST` 统一控制,其他工作包需要改动时先写交接说明,再由 `WP-ST` 合流。 + +#### 3.3.2 2B:`WP-SC Spacetime Client` + +等待对应 SpacetimeDB facade 稳定后再接 mapper / facade,不提前假设 reducer、procedure 或 row shape。 + +职责: + +1. 绑定类型到 BFF DTO 的 mapper。 +2. typed facade。 +3. SpacetimeDB 错误到 `api-server` 可消费错误的收口。 + +#### 3.3.3 2C:`WP-PF platform side effects` + +LLM、OSS、SMS、微信等外部副作用可以独立准备,不等待 `WP-SC`。但平台层只提供能力实现和错误模型,不承载玩法领域状态机。 + +#### 3.3.4 2D:`WP-API api-server BFF` + +等待 `WP-SC` facade 和 `WP-PF` 平台接口稳定后接 route。`api-server` 只能做 BFF 编排、鉴权、SSE、DTO 映射和平台调用。 + +单 owner 文件: + +1. `server-rs/crates/api-server/src/app.rs` +2. 各 route 模块的统一挂载入口。 + +`api-server/src/app.rs` 由 `WP-API` 统一控制,其他工作包不得直接抢改路由挂载。 + +### 3.4 第 3 批:前端迁移、旧层删除与验证 后端新接口可用后启动。 -1. `WP-FE Frontend Clients/UI` -2. `WP-DEL 删除旧层与命名收口` -3. `WP-V 全链验证与发布 smoke` +1. `WP-FE-S Frontend API client 迁移` +2. `WP-FE-H Frontend hooks 迁移` +3. `WP-FE-C Frontend components 接线` +4. `WP-DEL 删除旧层与命名收口` +5. `WP-V 全链验证与发布 smoke` ## 4. 工作包总表 @@ -126,20 +157,22 @@ flowchart TD | G0 文档、边界、冻结窗口 | 首个串行 | `PLAN.md`、`docs/technical/*DDD*`、`docs/planning/*` | 业务代码 | 全局任务清单、专项清单索引、阶段性交接模板 | 编码检查通过 | | G1 契约与路由矩阵 | G0 后 | `shared-contracts`、`packages/shared/src/contracts/*`、API 路由索引 | 领域实现 | DTO 分组、breaking change 清单、前后端路由矩阵 | shared contract 测试通过 | | G2 DDD 骨架与边界检查 | G0 后 | `module-*` 骨架、`scripts/check-server-rs-ddd-boundaries.mjs` | 业务重写 | 所有 `module-*` 具备 `domain/commands/application/events/errors`,检查脚本覆盖禁用依赖 | `npm.cmd run check:server-rs-ddd` | -| WP-A Auth | G1/G2 后 | `module-auth`、`spacetime-module/src/auth*`、`api-server/src/auth*`、`platform-auth` | 其他玩法域 | 账号、会话、验证码、微信绑定领域化;真实短信/微信在 platform | `cargo test -p module-auth`,auth API 测试 | -| WP-AS Assets | G1/G2 后 | `module-assets`、`spacetime-module/src/asset_metadata/*`、资产 API、OSS adapter | 玩法业务规则 | 资产对象与绑定规则纯化;OSS head/upload 移出领域核心 | `cargo test -p module-assets`,资产 facade 测试 | -| WP-AI AI Task | G1/G2 后 | `module-ai`、`spacetime-module/src/ai/*`、AI task API | LLM prompt 业务规则 | AI task/stage/chunk/result 状态机领域化 | `cargo test -p module-ai`,AI task reducer/procedure smoke | -| WP-CW Custom World | G1/G2 后 | `module-custom-world`、`spacetime-module/src/custom_world/*`、`api-server` custom world 路由、前端创作 client | Big Fish/Puzzle | profile、agent session、draft card、gallery、publish gate 领域化;LLM 留在 API/platform | `cargo test -p module-custom-world`,custom world 定向测试 | -| WP-BF Big Fish | G1/G2 后 | `module-big-fish`、`spacetime-module/src/big_fish/*`、Big Fish API、Big Fish 前端 client | Puzzle/RPG | 会话、草稿、素材槽、运行态纯规则;草稿校验下沉 | `cargo test -p module-big-fish`,Big Fish API 测试 | -| WP-PZ Puzzle | G1/G2 后 | `module-puzzle`、`spacetime-module/src/puzzle*`、Puzzle API、Puzzle 前端 client | Big Fish/RPG | Agent session、work profile、runtime run、排行榜规则领域化 | `cargo test -p module-puzzle`,Puzzle 定向测试 | -| WP-RT Runtime/Profile/Save | G1/G2 后 | `module-runtime`、`spacetime-module/src/runtime/*`、runtime/save/profile API | RPG story 规则 | runtime setting、snapshot、wallet、played world、save archive 领域化 | `cargo test -p module-runtime`,runtime API 测试 | -| WP-RPG Gameplay 域 | G1/G2 后 | `module-combat`、`module-inventory`、`module-npc`、`module-progression`、`module-quest`、`module-runtime-item`、`module-story` | 创作域 | 战斗、背包、NPC、成长、任务、宝箱、story session 纯规则与跨域事件 | 各 module 测试;跨域应用结果测试 | -| WP-RS Runtime Story 去兼容层 | G1/G2 后 | `module-runtime-story`、`api-server/src/runtime_story/*`、`src/hooks/rpg-runtime-story/*` | 非 RPG 创作域 | 删除 compat 层、session scoped 新接口、前端匹配新接口 | 按专项文档验收 | -| WP-ST SpacetimeDB Adapter | 领域任务输出稳定后 | `spacetime-module/src/**`、`migration.rs`、表目录 | `api-server` 业务逻辑 | table/reducer/procedure/mapper/queries 按上下文拆分;必要 event/projection table | `cargo check -p spacetime-module`,需要时 `spacetime build/generate` | -| WP-SC Spacetime Client | WP-ST 接口稳定后 | `spacetime-client/src/**`、绑定 mapper | 领域规则 | typed facade、错误映射、row snapshot mapper | `cargo check -p spacetime-client` | -| WP-PF platform side effects | 可与 WP-API 并行 | `platform-*`、`api-server` platform 接线 | 领域状态机 | LLM、OSS、SMS、微信等副作用统一 adapter | platform crate 测试或 API smoke | -| WP-API api-server BFF | WP-SC/PF 可用后 | `api-server/src/**` | SpacetimeDB table 定义、领域主规则 | 路由、鉴权、SSE、请求响应映射、平台编排收口 | `cargo test -p api-server`,`cargo check -p api-server` | -| WP-FE Frontend Clients/UI | G1 和 WP-API 接口稳定后 | `src/services/**`、`src/hooks/**`、`src/components/**` | 后端规则复刻 | API client、hooks、UI 流程对齐新 contract;删除前端正式规则 | vitest/ESLint 定向测试 | +| WP-A Auth | G1 后 | `module-auth`、`spacetime-module/src/auth*`、`api-server/src/auth*`、`platform-auth` | 其他玩法域 | 账号、会话、验证码、微信绑定领域化;真实短信/微信在 platform | `cargo test -p module-auth`,auth API 测试 | +| WP-AS Assets | G1 后 | `module-assets`、`spacetime-module/src/asset_metadata/*`、资产 API、OSS adapter | 玩法业务规则 | 资产对象与绑定规则纯化;OSS head/upload 移出领域核心 | `cargo test -p module-assets`,资产 facade 测试 | +| WP-AI AI Task | G1 后 | `module-ai`、`spacetime-module/src/ai/*`、AI task API | LLM prompt 业务规则 | AI task/stage/chunk/result 状态机领域化 | `cargo test -p module-ai`,AI task reducer/procedure smoke | +| WP-CW Custom World | G1 后 | `module-custom-world`、`spacetime-module/src/custom_world/*`、`api-server` custom world 路由、前端创作 client | Big Fish/Puzzle | profile、agent session、draft card、gallery、publish gate 领域化;LLM 留在 API/platform | `cargo test -p module-custom-world`,custom world 定向测试 | +| WP-BF Big Fish | G1 后 | `module-big-fish`、`spacetime-module/src/big_fish/*`、Big Fish API、Big Fish 前端 client | Puzzle/RPG | 会话、草稿、素材槽、运行态纯规则;草稿校验下沉 | `cargo test -p module-big-fish`,Big Fish API 测试 | +| WP-PZ Puzzle | G1 后 | `module-puzzle`、`spacetime-module/src/puzzle*`、Puzzle API、Puzzle 前端 client | Big Fish/RPG | Agent session、work profile、runtime run、排行榜规则领域化 | `cargo test -p module-puzzle`,Puzzle 定向测试 | +| WP-RT Runtime/Profile/Save | G1 后 | `module-runtime`、`spacetime-module/src/runtime/*`、runtime/save/profile API | RPG story 规则 | runtime setting、snapshot、wallet、played world、save archive 领域化 | `cargo test -p module-runtime`,runtime API 测试 | +| WP-RPG Gameplay 域 | G1 后 | `module-combat`、`module-inventory`、`module-npc`、`module-progression`、`module-quest`、`module-runtime-item`、`module-story` | 创作域 | 战斗、背包、NPC、成长、任务、宝箱、story session 纯规则与跨域事件 | 各 module 测试;跨域应用结果测试 | +| WP-RS Runtime Story 去兼容层 | G1 后 | `module-runtime-story`、`api-server/src/runtime_story/*`、`src/hooks/rpg-runtime-story/*` | 非 RPG 创作域 | 先将历史 `module-runtime-story-compat` 迁为新主链 crate,再删除 HTTP compat 层、接 session scoped 新接口、前端匹配新接口 | `cargo test -p module-runtime-story`,runtime story/API/前端定向测试 | +| WP-ST SpacetimeDB Adapter | 领域任务输出稳定后 | `spacetime-module/src/**`、`migration.rs`、表目录 | `api-server` 业务逻辑 | table/reducer/procedure/mapper/queries 按上下文接入领域函数;必要 event/projection table;`lib.rs/migration.rs/表目录` 单 owner 合流 | `cargo check -p spacetime-module`,需要时 `spacetime build/generate` | +| WP-SC Spacetime Client | 对应 WP-ST facade 稳定后 | `spacetime-client/src/**`、绑定 mapper | 领域规则、未稳定 facade 的预判接线 | typed facade、错误映射、row snapshot mapper | `cargo check -p spacetime-client` | +| WP-PF platform side effects | G1 后可独立准备;接入 API 前与 WP-API 对齐错误模型 | `platform-*`、`api-server` platform 接线 | 领域状态机 | LLM、OSS、SMS、微信等副作用统一 adapter | platform crate 测试或 API smoke | +| WP-API api-server BFF | WP-SC facade 和 WP-PF 接口稳定后 | `api-server/src/**`,其中 `app.rs` 单 owner | SpacetimeDB table 定义、领域主规则、绕过 spacetime-client 的直连实现 | 路由、鉴权、SSE、请求响应映射、平台编排收口 | `cargo test -p api-server`,`cargo check -p api-server` | +| WP-FE-S Frontend API client 迁移 | G1 和 WP-API 契约稳定后 | `src/services/**`、必要 contract type import | hooks / components 大改 | API client、路径常量、请求体与响应解析对齐新 contract | `npm.cmd run test -- src/services` | +| WP-FE-H Frontend hooks 迁移 | WP-FE-S 完成且后端接口可用后 | `src/hooks/**`、必要 hook 测试 | components 大面积 UI 改版 | hooks 改为调用新 client,只保留 loading/error/transition 和 UI 临时态 | `npm.cmd run test -- src/hooks` | +| WP-FE-C Frontend components 接线 | WP-FE-H 完成后 | `src/components/**`、组件测试 | services / hooks contract 改动 | 组件接入新 hooks 和 DTO;不新增规则说明文案 | 相关组件 vitest / 交互测试 | | WP-DEL 删除旧层与命名收口 | 新接口与前端迁移后 | 旧 compat、旧 facade、旧 contract、旧测试 | 新主链 | 物理删除旧入口、旧命名、旧 fixture 中非必要样本 | 搜索无运行代码引用旧层 | | WP-V 全链验证与发布 smoke | 最后 | 文档、测试脚本、README | 新功能扩展 | 全链命令、Maincloud smoke、文档交接 | 第 8 节命令通过或记录非本轮阻塞 | @@ -147,6 +180,8 @@ flowchart TD ### 5.1 G1 契约与路由矩阵 +冻结文档:[`SERVER_RS_DDD_G1_CONTRACT_AND_ROUTE_MATRIX_2026-04-29.md`](./SERVER_RS_DDD_G1_CONTRACT_AND_ROUTE_MATRIX_2026-04-29.md)。 + 必须先冻结: 1. 当前保留、重命名、删除的 HTTP 路由。 @@ -157,6 +192,13 @@ flowchart TD 禁止在 G1 中实现业务逻辑。 +G1 单 owner 文件: + +1. `server-rs/crates/shared-contracts/src/**` +2. `packages/shared/src/contracts/**` +3. `packages/shared/src/index.ts` +4. `docs/technical/RUST_API_SERVER_ROUTE_INDEX_2026-04-22.md` + ### 5.2 module-* 领域任务通用规则 每个 `module-*` 工作包必须输出: @@ -189,7 +231,7 @@ server-rs/crates/spacetime-module/src// └─ queries.rs ``` -当前已有模块可渐进对齐,但新增实现不得继续堆回 `lib.rs`。 +当前已有模块可渐进对齐,但新增实现不得继续堆回 `lib.rs`。`spacetime-module/src/lib.rs`、`migration.rs` 和 SpacetimeDB 表目录必须由 `WP-ST` 单 owner 控制,避免多个并行任务同时改 schema、根入口和文档目录。 SpacetimeDB 硬要求: @@ -210,14 +252,18 @@ SpacetimeDB 硬要求: 4. 调用 `spacetime-client`。 5. 调用 `platform-*`。 6. SSE stream。 +7. 在 `WP-SC` 和 `WP-PF` 稳定后接入 route。 禁止: 1. 大段领域分支。 2. SpacetimeDB table 定义。 3. 为旧接口继续保留双主链。 +4. 绕过 `spacetime-client` 直接拼 SpacetimeDB 访问。 -### 5.5 WP-FE Frontend Clients/UI +`api-server/src/app.rs` 和各 route 统一挂载入口由 `WP-API` 单 owner 控制。 + +### 5.5 WP-FE Frontend 迁移 前端只负责表现: @@ -233,12 +279,56 @@ SpacetimeDB 硬要求: 3. prompt 正式组装。 4. 绕过后端直接写真相。 +迁移顺序必须固定为: + +1. 先改 API client:`src/services/**`。 +2. 再改 hooks:`src/hooks/**`。 +3. 最后改组件接线:`src/components/**`。 + +前端不要抢在后端契约未稳定时大改 hooks。若后端 DTO 或路由仍在变动,`WP-FE-S` 只能先补 client adapter、路径常量、类型保护和 service 测试;`WP-FE-H` 必须等对应后端契约稳定后再启动。 + +允许按功能域并行,但每条功能域内部仍遵循 services → hooks → components: + +1. `FE-RPG`:RPG runtime、runtime story、NPC 聊天、背包/战斗/任务展示。 +2. `FE-CREATION`:RPG 创作链路、Custom World Agent、结果页、创作中心。 +3. `FE-BIG-FISH`:Big Fish Agent、草稿、素材生成、运行态。 +4. `FE-PUZZLE`:Puzzle Agent、草稿、运行态、排行榜。 +5. `FE-AUTH-PROFILE`:Auth、Profile、会员/钱包/存档/浏览历史。 + +功能域并行边界: + +1. 各功能域可以并行修改自己的 `src/services//**`、`src/hooks//**`、`src/components//**`。 +2. 共享文件如 `src/services/aiService.ts`、`src/services/apiClient.ts`、全局路由、全局 auth provider 只能由一个任务统一维护。 +3. 若共享 client 必须调整,先完成 service 层适配,再通知 hooks 任务接线。 +4. 组件层不得为了绕过 hooks 直接拼 API 请求。 + +### 5.6 WP-DEL / WP-V 最终串行收口 + +`WP-DEL 删除旧层与命名收口` 和 `WP-V 全链验证与发布 smoke` 必须串行执行,不再拆散并行。进入这一批前必须满足: + +1. `G1` 已冻结契约与路由矩阵。 +2. 对应 `module-*` 领域规则已完成并通过定向测试。 +3. 对应领域已完成 `spacetime-module -> spacetime-client -> api-server` 接线。 +4. 前端已按新接口完成 `services -> hooks -> components` 接入。 +5. 运行代码不再需要旧接口兜底。 + +`WP-DEL` 执行顺序: + +1. 扫描旧入口引用:`compat`、旧 facade、旧 route、旧 contract、旧前端 client/helper。 +2. 删除后端旧 route / module / re-export。 +3. 删除前端旧 client / hook fallback。 +4. 删除旧测试 fixture 中非必要样本。 +5. 清理文档里已过期的“兼容主链”说法。 +6. 收口命名:运行代码中不再出现 `compat`;`legacy` 只允许出现在历史文档或迁移说明中。 + +`WP-V` 紧跟 `WP-DEL` 执行,不允许中途插入新功能。验证命令以第 8 节为准,后端代码变更后必须执行 `npm.cmd run api-server:maincloud`,不能改用旧后端重启命令。 + ## 6. 关键依赖与防冲突边界 1. `shared-contracts` 由 G1 统一所有权,其他任务只消费,不私自改 DTO shape。 -2. `spacetime-module/src/lib.rs` 由 WP-ST 统一所有权,领域任务不直接改根入口。 +2. `spacetime-module/src/lib.rs`、`server-rs/crates/spacetime-module/src/migration.rs`、`docs/technical/SPACETIMEDB_TABLE_CATALOG.md` 由 WP-ST 统一所有权,领域任务不直接改根入口、迁移和表目录。 3. `api-server/src/app.rs` 路由挂载由 WP-API 统一所有权。 -4. `src/services/aiService.ts`、`src/services/rpg-runtime/*` 由 WP-FE 统一所有权。 +4. `src/services/aiService.ts`、`src/services/apiClient.ts`、`src/services/rpg-runtime/*` 由 `WP-FE-S` 统一所有权。 5. `module-runtime-story` 与 runtime story 新接口由 WP-RS 所有,不和 WP-RPG 混写。 6. 若某任务必须改别人的边界文件,先在交接记录中写明改动动机和待合流点。 @@ -308,3 +398,626 @@ spacetime describe --json 当前不再单独维护专项清单。`WP-RS Runtime Story 去兼容层` 已内联在本文第 4 节工作包总表中。 后续如果某个工作包仍存在编码级歧义,必须先在本文补齐边界;只有单个工作包过大且无法在本文清晰承载时,才新增对应专项清单。 + +## 10. 本地进度记录 + +### 2026-04-29 前置等待解除与 WP-BF 领域小步落地 + +已确认: + +1. `G1 契约与路由矩阵` 已有冻结文档,可作为第 1 批领域规则并行任务输入。 +2. `npm.cmd run check:server-rs-ddd` 通过,当前 DDD 骨架门禁满足第 1 批启动条件。 +3. 前端第 3 批仍不可启动:`WP-API` 新接口尚未完成,`WP-FE-S` 只能等待对应后端 BFF contract 稳定后再改 `src/services/**`。 +4. 工作区同时存在其他前置任务产物,尤其是 `module-runtime-story` 新目录和旧 `module-runtime-story-compat` 删除记录;本次未回退这些并行产物。 + +本次已执行: + +1. 启动第 1 批 `WP-BF Big Fish` 的纯领域落地。 +2. 在 `module-big-fish` 中新增发布门禁应用服务: + - `EvaluateBigFishPublishReadinessCommand` + - `BigFishPublishReadiness` + - `BigFishDomainEvent::PublishReadinessEvaluated` + - `BigFishApplicationError` + - `evaluate_publish_readiness` +3. 发布门禁只消费草稿和资产槽,返回可发布状态、阻塞原因和领域事件,不调用 HTTP、SpacetimeDB、OSS、图片生成或前端逻辑。 +4. 未修改 `spacetime-module`、`api-server`、`src/services/**`、`src/hooks/**`、`src/components/**`,保持第 2/3 批边界。 + +验证: + +```powershell +npm.cmd run check:server-rs-ddd +npm.cmd run check:encoding -- docs/technical/SERVER_RS_DDD_PARALLEL_TASKLIST_2026-04-29.md docs/technical/SERVER_RS_DDD_G1_CONTRACT_AND_ROUTE_MATRIX_2026-04-29.md docs/technical/RUST_API_SERVER_ROUTE_INDEX_2026-04-22.md docs/technical/README.md +cargo fmt -p module-big-fish --manifest-path server-rs/Cargo.toml --check +cargo test -p module-big-fish --manifest-path server-rs/Cargo.toml +``` + +结果:通过。 + +备注: + +1. `cargo fmt --all --manifest-path server-rs/Cargo.toml --check` 当前会被工作区中缺失的 `server-rs/crates/module-ai/src/domain.rs` 阻塞;该文件缺失不是本次 WP-BF 改动引入,需由对应并行任务或 G2 owner 合流处理。 +2. 后端服务未重启,因为本次未触碰 `api-server`、SpacetimeDB table/reducer/procedure 或运行时接线。 + +### 2026-04-29 G1 契约与路由矩阵冻结确认 + +已完成: + +1. 新增并冻结 `SERVER_RS_DDD_G1_CONTRACT_AND_ROUTE_MATRIX_2026-04-29.md`。 +2. G1 文档已覆盖当前 HTTP 路由的 `保留/重命名/删除/收敛` 决议。 +3. G1 文档已覆盖 DTO 的保留、重命名、删除清单。 +4. G1 文档已覆盖页面/功能到 query、command、result DTO 的映射。 +5. G1 文档已覆盖 breaking change、API 错误 envelope 和共享契约单 owner 边界。 +6. `RUST_API_SERVER_ROUTE_INDEX_2026-04-22.md` 已标记为迁移期快照,后续并行任务以 G1 文档为准。 +7. `docs/technical/README.md` 已加入 G1 文档索引。 + +当前结论: + +1. `WP-DEL` 和 `WP-V` 仍不可执行,必须等待第 1 批领域规则、第 2 批 Adapter/BFF、第 3 批前端迁移完成。 +2. `G1` 已满足第 1 批领域规则并行启动条件。 +3. 下一步推荐从 `WP-A`、`WP-AS`、`WP-AI`、`WP-CW`、`WP-BF`、`WP-PZ`、`WP-RT`、`WP-RPG`、`WP-RS` 中按 owner 边界并行领取。 + +### 2026-04-29 WP-AI 领域层拆分进度 + +已完成: + +1. 新增 `SERVER_RS_DDD_WP_AI_TASK_DOMAIN_REFACTOR_2026-04-29.md`,冻结 WP-AI 本次可执行范围。 +2. 将 `module-ai` 中集中在 `lib.rs` 的 AI task 领域代码拆到: + - `src/domain.rs` + - `src/commands.rs` + - `src/application.rs` + - `src/events.rs` + - `src/errors.rs` +3. 保持 `module_ai::*` 公开导出不变,避免影响现有 `spacetime-module` 引用。 +4. 保持 AI task 状态迁移、流式文本聚合、结果引用挂接、中文错误文案和既有测试语义不变。 +5. 更新 `server-rs/crates/module-ai/README.md`,补充 DDD 分层与本次方案文档入口。 +6. 修复前序记录中提到的 `module-ai/src/domain.rs` 缺失导致 `cargo fmt --all --check` 阻塞的问题。 + +验证: + +```powershell +cargo test -p module-ai --manifest-path server-rs/Cargo.toml +cargo check -p spacetime-module --manifest-path server-rs/Cargo.toml +cargo fmt --all --check --manifest-path server-rs/Cargo.toml +npm.cmd run check:server-rs-ddd +npm.cmd run check:encoding -- docs/technical/SERVER_RS_DDD_WP_AI_TASK_DOMAIN_REFACTOR_2026-04-29.md server-rs/crates/module-ai/src/lib.rs server-rs/crates/module-ai/src/domain.rs server-rs/crates/module-ai/src/commands.rs server-rs/crates/module-ai/src/application.rs server-rs/crates/module-ai/src/events.rs server-rs/crates/module-ai/src/errors.rs +npm.cmd run api-server:maincloud +``` + +结果: + +1. `cargo test -p module-ai --manifest-path server-rs/Cargo.toml` 通过,9 个测试全部通过。 +2. `cargo fmt --all --check --manifest-path server-rs/Cargo.toml` 通过。 +3. `npm.cmd run check:server-rs-ddd` 通过。 +4. `npm.cmd run check:encoding -- ...` 通过,9 个文件编码检查通过。 +5. `npm.cmd run api-server:maincloud` 为常驻启动命令,180 秒超时前已启动 `server-rs/target/debug/api-server.exe`,并监听 `127.0.0.1:3100`;`/health` 返回 404,当前未提供通用健康路由。 + +未执行: + +1. 未执行 SpacetimeDB 发布、绑定生成或 migration 更新,原因是本次未改 SpacetimeDB table/reducer/procedure。 + +### 2026-04-29 WP-RS 前置满足后启动执行 + +已完成: + +1. 重新执行 `npm.cmd run check:server-rs-ddd`,确认 `G2` DDD 骨架门禁通过。 +2. 重新执行本轮触碰文档的编码检查,确认 `G1` 契约与路由矩阵文档可作为后续并行入口。 +3. 确认当前实际 crate 是历史命名 `module-runtime-story-compat`,与本轮去兼容层目标冲突。 +4. 将该 crate 迁为新主链 `module-runtime-story`: + - `server-rs/crates/module-runtime-story-compat` 改为 `server-rs/crates/module-runtime-story`。 + - `server-rs/Cargo.toml` workspace member 改为 `crates/module-runtime-story`。 + - `server-rs/crates/api-server/Cargo.toml` 依赖改为 `module-runtime-story`。 + - `api-server` 中直接导入改为 `module_runtime_story`。 +5. 本次未修改 SpacetimeDB 表结构,未触碰 `migration.rs`。 + +下一步: + +1. 在 `WP-RS` 内继续删除 `api-server/src/runtime_story/compat*` 的 HTTP 兼容入口。 +2. 以 `GET/POST /api/story/*` 和后续 session scoped story/chat facade 作为新主链。 +3. 前端等待后端新 route/DTO 稳定后再按 `services -> hooks -> components` 接入。 + +验证: + +```powershell +npm.cmd run check:server-rs-ddd +npm.cmd run check:encoding -- docs/technical/SERVER_RS_DDD_G1_CONTRACT_AND_ROUTE_MATRIX_2026-04-29.md docs/technical/SERVER_RS_DDD_PARALLEL_TASKLIST_2026-04-29.md docs/technical/RUST_API_SERVER_ROUTE_INDEX_2026-04-22.md docs/technical/README.md +``` + +结果:通过。迁名后的 Rust 编译验证记录在下一条 WP-RS 进度中补齐。 + +### 2026-04-29 WP-RS 迁名验证与并行合流记录 + +已完成: + +1. 完成 `module-runtime-story-compat` 到 `module-runtime-story` 的代码级迁名后,执行 Rust 定向验证。 +2. `module-runtime-story` 自身测试通过,确认迁名后的领域 crate 可编译、可运行既有纯规则测试。 +3. `api-server` 已改为依赖 `module-runtime-story` 并通过编译检查。 +4. 执行过程中发现并行 `WP-AI` 正在改 `module-ai`,曾短暂造成 `module-ai` 缺少 `application.rs` / `lib.rs` 或导出错位;已按该工作包当前分层结果补齐入口与导出,避免阻塞全局门禁。 +5. 本次仍未修改 SpacetimeDB 表结构,未触碰 `migration.rs`。 + +验证: + +```powershell +cargo test -p module-runtime-story --manifest-path server-rs/Cargo.toml +cargo check -p module-runtime-story --manifest-path server-rs/Cargo.toml +cargo test -p module-ai --manifest-path server-rs/Cargo.toml +cargo check -p api-server --manifest-path server-rs/Cargo.toml +npm.cmd run check:server-rs-ddd +npm.cmd run check:encoding -- docs/technical/SERVER_RS_DDD_G1_CONTRACT_AND_ROUTE_MATRIX_2026-04-29.md docs/technical/SERVER_RS_DDD_PARALLEL_TASKLIST_2026-04-29.md docs/technical/RUST_API_SERVER_ROUTE_INDEX_2026-04-22.md docs/technical/README.md server-rs/crates/module-runtime-story/README.md +``` + +结果: + +1. `module-runtime-story` 测试通过:7 个测试全部通过。 +2. `module-runtime-story` 编译通过。 +3. `module-ai` 测试通过:9 个测试全部通过。 +4. `api-server` 编译通过。 +5. `check:server-rs-ddd` 通过。 +6. 编码检查通过。 + +后端启动记录: + +```powershell +npm.cmd run api-server:maincloud +``` + +结果:已按后端代码变更要求执行。命令未再出现编译错误,但在 45 秒观察窗口内超时退出,且本地未探测到 `127.0.0.1:3100/healthz` 可用;后续继续 WP-RS 接线前需要重新启动并确认 health。 + +### 2026-04-29 WP-RS 旧 HTTP compat 路由下线 + +已完成: + +1. 从 `server-rs/crates/api-server/src/app.rs` 删除旧 `/api/runtime/story/*` 六条路由挂载: + - `POST /api/runtime/story/sessions` + - `POST /api/runtime/story/state/resolve` + - `GET /api/runtime/story/state/{session_id}` + - `POST /api/runtime/story/actions/resolve` + - `POST /api/runtime/story/initial` + - `POST /api/runtime/story/continue` +2. 从 `server-rs/crates/api-server/src/main.rs` 移除 `mod runtime_story;`,旧 compat 模块不再进入运行编译树。 +3. 物理删除 `server-rs/crates/api-server/src/runtime_story.rs` 与 `server-rs/crates/api-server/src/runtime_story/compat/**`。 +4. 在 `app.rs` 新增 `runtime_story_legacy_routes_are_not_mounted` 测试,锁定旧路由返回 `404 NOT_FOUND`。 +5. 同步更新 `RUST_API_SERVER_ROUTE_INDEX_2026-04-22.md` 和 G1 契约矩阵,将 runtime story 旧层标记为已删除。 +6. 本次未修改 SpacetimeDB 表结构,未触碰 `migration.rs`。 + +当前剩余: + +1. 前端 `src/services/rpg-runtime/rpgRuntimeStoryClient.ts` 仍指向 `/api/runtime/story`,需要在 `WP-FE-S` 中迁到新 `/api/story/*` 和 session scoped facade。 +2. `packages/shared/src/contracts/rpgRuntimeStory*` 与 `shared-contracts/src/runtime_story*` 仍保留旧 DTO,需等前端迁移完成后由 `WP-DEL` 统一删除。 +3. runtime chat 仍有 `runtimeStory` 命名和 prompt helper,需另起 session scoped chat/story facade 任务继续收口。 + +验证: + +```powershell +cargo test -p api-server runtime_story_legacy_routes_are_not_mounted --manifest-path server-rs/Cargo.toml +npm.cmd run check:server-rs-ddd +``` + +结果:通过。`cargo check -p api-server --manifest-path server-rs/Cargo.toml` 曾与测试并行抢 Cargo 锁导致 120 秒超时,需在后续验证中单独重跑。 + +### 2026-04-29 前端第 3 批迁移顺序补充 + +已完成: + +1. 将第 3 批从泛化的 `WP-FE Frontend Clients/UI` 拆成 `WP-FE-S / WP-FE-H / WP-FE-C`。 +2. 冻结前端迁移顺序:先 `src/services/**`,再 `src/hooks/**`,最后 `src/components/**`。 +3. 明确后端契约未稳定前,前端不得抢先大改 hooks。 +4. 明确功能域可并行:`FE-RPG`、`FE-CREATION`、`FE-BIG-FISH`、`FE-PUZZLE`、`FE-AUTH-PROFILE`。 +5. 明确前端只做表现和调用,不复制正式业务规则。 + +验证: + +```powershell +npm.cmd run check:encoding -- docs/technical/SERVER_RS_DDD_PARALLEL_TASKLIST_2026-04-29.md +``` + +结果:通过。 + +### 2026-04-29 第 4 批最终串行收口口径确认 + +已完成: + +1. 确认最终收口批次不再并行拆散,固定为 `WP-DEL 删除旧层与命名收口` 后紧跟 `WP-V 全链验证与发布 smoke`。 +2. 明确进入 `WP-DEL` 前必须先完成: + - `G1` 契约与路由矩阵。 + - 对应 `module-*` 领域规则。 + - 对应领域 `spacetime-module -> spacetime-client -> api-server` 接线。 + - 前端 `services -> hooks -> components` 新接口接入。 +3. 明确 `WP-DEL` 的删除顺序: + - 先扫描 `compat`、旧 facade、旧 route、旧 contract、旧前端 client/helper。 + - 再删除后端旧 route / module / re-export。 + - 再删除前端旧 client / hook fallback。 + - 再删除旧测试 fixture 中非必要样本。 + - 最后清理文档中过期的“兼容主链”说法。 +4. 明确命名收口规则:运行代码中不再出现 `compat`;`legacy` 只允许出现在历史文档或迁移说明中。 +5. 明确 `WP-V` 必须紧跟 `WP-DEL` 执行,中途不插入新功能;后端代码变更后必须执行 `npm.cmd run api-server:maincloud`。 + +### 2026-04-29 第 2 批 Adapter / BFF 接线顺序校准 + +已完成: + +1. 将原“adapter 和 BFF 接线”批次改为“第 2 批:Adapter / BFF 接线”。 +2. 明确第 2 批必须在领域输出稳定后启动,且按 `2A -> 2B -> 2C/2D` 分层推进。 +3. 补齐 `WP-ST SpacetimeDB Adapter` 的单 owner 边界: + - `server-rs/crates/spacetime-module/src/lib.rs` + - `server-rs/crates/spacetime-module/src/migration.rs` + - `docs/technical/SPACETIMEDB_TABLE_CATALOG.md` +4. 补齐 `WP-SC Spacetime Client` 等对应 SpacetimeDB facade 稳定后再接 mapper / facade 的依赖。 +5. 补齐 `WP-PF platform side effects` 可独立准备 LLM、OSS、SMS、微信等外部副作用,但不得承载玩法领域状态机。 +6. 补齐 `WP-API api-server BFF` 必须等待 `spacetime-client` facade 和 platform 接口稳定后再接 route。 +7. 明确 `server-rs/crates/api-server/src/app.rs` 和各 route 统一挂载入口由 `WP-API` 单 owner 控制。 +8. 补充禁止 `api-server` 绕过 `spacetime-client` 直接拼 SpacetimeDB 访问。 + +已验证: + +```powershell +npm.cmd run check:encoding -- docs/technical/SERVER_RS_DDD_PARALLEL_TASKLIST_2026-04-29.md +``` + +结果:通过。 + +### 2026-04-29 WP-API BFF 启动切片 + +已完成: + +1. 新增 `SERVER_RS_DDD_WP_API_BFF_START_2026-04-29.md`,冻结本次 WP-API 可独立启动范围。 +2. 确认 `api-server/src/app.rs` 已不再挂载旧 `/api/runtime/story/*` 兼容入口。 +3. 保留新主链 `/api/story/*` route family,后续只通过 `spacetime-client` facade 访问 SpacetimeDB。 +4. 补充旧 runtime story 路由未挂载的回归测试,请求标准 envelope 时返回 `404 / ok=false / NOT_FOUND / 资源不存在`。 +5. 新增 `GET /api/story/sessions/{story_session_id}/runtime-projection` 占位路由,先锁定鉴权与 `501 NOT_IMPLEMENTED` envelope;真实投影等待 `WP-ST/WP-SC` facade 后接入。 +6. 本切片未修改 `spacetime-module`、`spacetime-client`、前端 services/hooks/components,也未修改 SpacetimeDB 表结构或 `migration.rs`。 + +当前边界: + +1. `WP-API` 完整业务接线仍需等待 `WP-ST` 与 `WP-SC` facade 稳定。 +2. 不允许 `api-server` 绕过 `spacetime-client` 直接拼 SpacetimeDB 访问。 +3. 前端仍不能启动第 3 批迁移,需等待后端 route/DTO 稳定后按 `services -> hooks -> components` 推进。 + +### 2026-04-29 批次口径调整 + +已完成: + +1. 将原“第一批冻结边界”调整为“第 0 批:冻结边界与门禁”。 +2. 将领域纯规则迁移明确为“第 1 批:领域规则并行”。 +3. 明确 `G1` 完成后即可开启第 1 批领域规则并行泳道。 +4. 明确 `G2` 是贯穿后续工作的边界检查门禁;当前 DDD 骨架已具备,后续每批继续运行 `npm.cmd run check:server-rs-ddd`。 +5. 第 1 批领域规则并行泳道固定为: + - `WP-A Auth` + - `WP-AS Assets` + - `WP-AI AI Task` + - `WP-CW Custom World` + - `WP-BF Big Fish` + - `WP-PZ Puzzle` + - `WP-RT Runtime/Profile/Save` + - `WP-RPG Gameplay 域` + - `WP-RS Runtime Story 去兼容层` +6. `WP-RS` 不再引用单独专项清单,验收口径收口为 `module-runtime-story`、runtime story/API 和前端定向测试。 + +验证: + +```powershell +npm.cmd run check:encoding -- docs/technical/SERVER_RS_DDD_PARALLEL_TASKLIST_2026-04-29.md +``` + +结果:通过。 + +### 2026-04-29 WP-SC Spacetime Client 基础设施收口启动 + +已完成: + +1. 新增 `SERVER_RS_DDD_WP_SC_SPACETIME_CLIENT_REFACTOR_2026-04-29.md`,冻结本次 `WP-SC` 可执行范围。 +2. 确认 `WP-ST` 尚未完成所有新 facade,本次不预判新 table、reducer、procedure 或 row shape。 +3. 在 `spacetime-client` 中新增统一错误 helper: + - `SpacetimeClientError::from_sdk_error` + - `SpacetimeClientError::procedure_failed` + - `SpacetimeClientError::missing_snapshot` +4. 先以 `AI task` 和 `Big Fish` 现有 facade 作为示范,统一 SDK 调用错误映射。 +5. 在 mapper 中先收口资产对象和 Big Fish procedure 结果的业务错误与缺快照错误表达。 +6. 更新 `spacetime-client/README.md`,明确其 DDD 边界:只做 typed facade、row snapshot mapper 和错误收口,不承载领域规则,不定义 SpacetimeDB schema。 + +本次未修改: + +1. `spacetime-module/src/**` +2. `migration.rs` +3. `shared-contracts` +4. `api-server` 路由挂载 +5. 前端 services / hooks / components +6. `spacetime-client/src/module_bindings/**` 生成绑定 + +后续: + +1. 等 `WP-ST` 稳定对应 SpacetimeDB facade 后,再逐个领域补 mapper / typed facade。 +2. `WP-API` 后续只能通过 `spacetime-client` 调 SpacetimeDB,不绕过本 crate 直接使用生成绑定。 + +### 2026-04-29 WP-SC 错误映射第二批收口 + +已完成: + +1. 继续在 `spacetime-client` 内扩展统一 SDK 错误映射使用范围。 +2. 本批已覆盖现有稳定 facade: + - `assets` + - `auth` + - `story` + - `combat` + - `inventory` + - `npc` +3. mapper 中同步收口以下 procedure 结果错误: + - 资产对象绑定与资产历史 + - 认证快照 + - AI task mutation + - story session / story event / story state + - runtime inventory state + - battle state / combat action + - NPC battle interaction +4. 本批仍未修改 `spacetime-module`、`shared-contracts`、`api-server`、前端和生成绑定。 + +待继续: + +1. `runtime`、`puzzle`、`custom_world` facade 仍有较多重复 SDK 错误映射,可继续按同一方式机械收口。 +2. mapper 中仍有部分历史 `Procedure(...)` 构造和旧兼容 JSON 容错逻辑,后续应结合对应工作包逐步替换,避免一次大改影响面过宽。 + +### 2026-04-29 WP-ST AI Task 事件 Adapter 切片 + +已完成: + +1. 新增 `SERVER_RS_DDD_WP_ST_AI_TASK_EVENT_ADAPTER_2026-04-29.md`,冻结本次 WP-ST 可独立落地范围。 +2. 在 `spacetime-module/src/ai/events.rs` 新增 `ai_task_event` public event table。 +3. `ai_task_event` 当前承接: + - `TaskCreated` + - `TaskStatusChanged` + - `StageStarted` + - `StageCompleted` + - `TextChunkAppended` + - `ResultReferenceAttached` +4. 在 AI task / stage / text chunk / result reference 成功写入后,同事务写入事件表。 +5. 本次不改变 `ai_task`、`ai_task_stage`、`ai_text_chunk`、`ai_result_reference` 真相表字段。 +6. 已同步 `migration.rs` 迁移白名单,加入 `ai_task_event`。 +7. 已同步 `SPACETIMEDB_TABLE_CATALOG.md` 的 AI 任务表目录和查询说明。 + +验证: + +```powershell +cargo check -p spacetime-module --manifest-path server-rs/Cargo.toml +cargo test -p module-ai --manifest-path server-rs/Cargo.toml +npm.cmd run check:server-rs-ddd +npm.cmd run check:encoding -- docs/technical/SERVER_RS_DDD_WP_ST_AI_TASK_EVENT_ADAPTER_2026-04-29.md docs/technical/SPACETIMEDB_TABLE_CATALOG.md server-rs/crates/spacetime-module/src/ai/events.rs server-rs/crates/spacetime-module/src/ai/mod.rs server-rs/crates/spacetime-module/src/ai/snapshots.rs server-rs/crates/spacetime-module/src/ai/stages.rs server-rs/crates/spacetime-module/src/ai/tasks.rs server-rs/crates/spacetime-module/src/migration.rs +``` + +结果:通过。其中 `spacetime-module` 仍存在 3 个既有 `ambiguous glob re-exports` warning,非本次事件表引入。 + +后端启动: + +```powershell +npm.cmd run api-server:maincloud +``` + +结果:已执行。`api-server` 编译完成后运行进程以 `0xffffffff` 退出,同时出现一次 Maincloud WebSocket `Close(None)`;这不是本次 Rust 编译错误,后续需要结合 Maincloud 连接与当前并行 WP-API 改动继续排查。 + +未执行: + +```powershell +spacetime build --project-path server-rs/crates/spacetime-module +``` + +原因:当前环境未找到 `spacetime` CLI,可执行文件不在 PATH 中。 + +### 2026-04-29 WP-RS 新 story runtime 投影契约切片 + +已完成: + +1. 确认前端旧 `rpgRuntimeStoryClient.ts` 仍依赖 `/api/runtime/story` 的 `snapshot / viewModel / presentation` 顶层响应,不能直接切到现有 `/api/story/*`。 +2. 明确 WP-RS 下一段必须先提供 session scoped 的后端投影契约,再由 `WP-SC -> WP-API -> WP-FE-S/H/C` 分层接入。 +3. 在 `shared-contracts/src/story.rs` 新增新主链 DTO: + - `StoryRuntimeProjectionRequest` + - `StoryRuntimeProjectionResponse` + - `StoryRuntimeActorProjection` + - `StoryRuntimeInventoryProjection` + - `StoryRuntimeOptionProjection` + - `StoryRuntimeStatusProjection` +4. 新 DTO 固定挂在 `story` contract 下,后续 route 建议使用 `GET/POST /api/story/sessions/{story_session_id}/runtime-projection`,不恢复 `/api/runtime/story/*`。 +5. 新投影响应不再复制旧顶层 `snapshot / viewModel / presentation` 形状;前端只消费后端提供的展示投影字段,不在 hooks/components 重建正式业务规则。 +6. 将 `module-runtime-story/src/application.rs` 的模块注释从“兼容应用编排过渡落位”收口为新主链“runtime story 应用编排落位”。 +7. 本次未修改 SpacetimeDB 表结构,未触碰 `migration.rs`。 + +后续接线边界: + +1. `WP-ST` 如需新增 projection table/event,必须由 `spacetime-module/src/lib.rs`、`migration.rs`、表目录单 owner 合流。 +2. `WP-SC` 在 SpacetimeDB facade 稳定后,负责读取 story session、story events、inventory/battle/npc 等快照并映射为 `StoryRuntimeProjectionResponse` 所需的 typed 中间结果。 +3. `WP-API` 只能通过 `spacetime-client` 组合响应,不得绕过 `spacetime-client` 直接访问 SpacetimeDB。 +4. `WP-FE-S` 等待 route/DTO 稳定后迁移 `src/services/rpg-runtime/rpgRuntimeStoryClient.ts`;`WP-FE-H` 和 `WP-FE-C` 继续等待 services 完成。 +5. `WP-DEL` 仍需等新接口和前端迁移完成后,再统一删除 `shared-contracts/src/runtime_story.rs`、`packages/shared/src/contracts/rpgRuntimeStory*` 和旧前端 helper。 + +验证: + +```powershell +cargo test -p shared-contracts story_runtime_projection_response_uses_new_story_runtime_contract --manifest-path server-rs/Cargo.toml +cargo test -p module-runtime-story --manifest-path server-rs/Cargo.toml +cargo check -p api-server --manifest-path server-rs/Cargo.toml +npm.cmd run check:server-rs-ddd +npm.cmd run check:encoding -- docs/technical/SERVER_RS_DDD_PARALLEL_TASKLIST_2026-04-29.md server-rs/crates/shared-contracts/src/story.rs server-rs/crates/module-runtime-story/src/application.rs +npm.cmd run api-server:maincloud +``` + +结果:待本切片执行后补齐。 + +已验证: + +```powershell +cargo test -p shared-contracts story_runtime_projection_response_uses_new_story_runtime_contract --manifest-path server-rs/Cargo.toml +cargo test -p module-runtime-story --manifest-path server-rs/Cargo.toml +cargo check -p api-server --manifest-path server-rs/Cargo.toml +npm.cmd run check:server-rs-ddd +npm.cmd run check:encoding -- docs/technical/SERVER_RS_DDD_PARALLEL_TASKLIST_2026-04-29.md server-rs/crates/shared-contracts/src/story.rs server-rs/crates/module-runtime-story/src/application.rs +``` + +结果:通过。`cargo check -p api-server` 仍有既有 `api-server/src/prompt/rpg/runtime_chat.rs` 未使用 prompt helper warning,非本次新增。 + +### 2026-04-29 WP-API runtime projection 接线 + +已完成: + +1. `GET /api/story/sessions/{story_session_id}/runtime-projection` 已从 `501 NOT_IMPLEMENTED` 占位改为真实 BFF 接线。 +2. route 只读取当前 bearer token 的 `user_id`,调用 `SpacetimeClient::get_story_runtime_projection_source(story_session_id, actor_user_id)`。 +3. route 将 facade 返回的 `StoryRuntimeProjectionSource` 交给 `module_runtime_story::build_story_runtime_projection`,输出 `StoryRuntimeProjectionResponse`。 +4. `api-server` 未绕过 `spacetime-client`,未直接访问 SpacetimeDB 生成绑定,未复制 actor、inventory、option、status 等领域投影规则。 +5. 原占位测试已改为 SpacetimeDB 未发布场景下的 `502 / provider=spacetimedb` 回归测试;未登录仍返回 `401`。 +6. 本次未修改 SpacetimeDB 表结构,未触碰 `migration.rs`。 + +当前边界: + +1. `WP-FE-S` 可以基于 `/api/story/sessions/{story_session_id}/runtime-projection` 与 `StoryRuntimeProjectionResponse` 启动 services 迁移。 +2. `WP-FE-H` 与 `WP-FE-C` 仍等待 services 迁移完成后再接入,不在 hooks/components 中重建正式业务规则。 +3. 如后续 `WP-ST` 把 runtime story 快照拆入更细 projection table,仍由 `WP-ST/WP-SC` 更新 facade,`WP-API` 保持同一 BFF 边界。 + +验证: + +```powershell +cargo test -p api-server get_story_runtime_projection --manifest-path server-rs/Cargo.toml +cargo test -p api-server runtime_story_legacy_routes_are_not_mounted --manifest-path server-rs/Cargo.toml +cargo check -p api-server --manifest-path server-rs/Cargo.toml +npm.cmd run check:server-rs-ddd +npm.cmd run check:encoding -- docs/technical/SERVER_RS_DDD_WP_API_BFF_START_2026-04-29.md docs/technical/SERVER_RS_DDD_PARALLEL_TASKLIST_2026-04-29.md server-rs/crates/api-server/src/story_sessions.rs +``` + +结果:通过。`cargo check -p api-server` 仍有既有 `api-server/src/prompt/rpg/runtime_chat.rs` 未使用 prompt helper warning,非本次新增。 + +后端启动: + +```powershell +npm.cmd run api-server:maincloud +``` + +结果:命令在 90 秒观察窗口内超时,因为 `cargo run` 前台常驻;随后确认 `127.0.0.1:3100` 已由本仓库 `server-rs/target/debug/api-server.exe` 监听。`GET /healthz` 返回 `200`,`GET /api/story/sessions/storysess_001/runtime-projection` 未登录返回 `401`,无效 bearer token 返回 `401`。 + +后端启动: + +```powershell +npm.cmd run api-server:maincloud +``` + +结果:编译通过,仅有既有 prompt helper warning;运行阶段因 `127.0.0.1:3100` 端口已被既有 `api-server` 进程占用而退出,错误为 `AddrInUse / 10048`。随后探测 `http://127.0.0.1:3100/healthz` 返回 `200`,确认本地已有服务在线。 + +后端启动: + +```powershell +npm.cmd run api-server:maincloud +``` + +结果:命令在 60 秒观察窗口内超时,但随后探测 `http://127.0.0.1:3100/healthz` 返回 `200`,本地存在新的 `api-server` 运行进程。本切片未触发新的 Rust 编译错误。 + +### 2026-04-29 WP-SC story runtime projection source 接线 + +已完成: + +1. 在 `spacetime-client` 中新增 `story_runtime` facade 模块。 +2. 新增 `SpacetimeClient::get_story_runtime_projection_source(story_session_id, actor_user_id)`。 +3. 该 facade 负责: + - 读取 `get_story_session_state`。 + - 校验 story session 属于当前用户。 + - 读取当前用户 `get_runtime_snapshot`。 + - 校验 `runtime snapshot.gameState.runtimeSessionId` 与 story session 的 `runtimeSessionId` 一致。 + - 从 `currentStory.options` 解析 `RuntimeStoryOptionView`。 + - 组装 `module-runtime-story::StoryRuntimeProjectionSource`。 +4. 该 facade 不直接输出 HTTP DTO,不复制投影规则;真正投影仍由 `module-runtime-story::build_story_runtime_projection` 完成。 +5. 本次未修改 SpacetimeDB 表结构,未触碰 `migration.rs`。 + +后续接线边界: + +1. `WP-API` 可在新 route 中调用 `get_story_runtime_projection_source`,再调用 `build_story_runtime_projection` 输出 `StoryRuntimeProjectionResponse`。 +2. `WP-FE-S` 仍等待 route 稳定后再迁移旧 `/api/runtime/story` client。 +3. `WP-ST` 如后续把 runtime story 相关快照从 save snapshot 拆入更细 projection table,需要由 `WP-ST` 单 owner 更新 `spacetime-module/src/lib.rs`、`migration.rs` 和表目录。 + +验证: + +```powershell +cargo test -p spacetime-client story_runtime --manifest-path server-rs/Cargo.toml +cargo check -p api-server --manifest-path server-rs/Cargo.toml +npm.cmd run check:server-rs-ddd +``` + +结果:通过。`cargo check -p api-server` 仍有既有 `api-server/src/prompt/rpg/runtime_chat.rs` 未使用 prompt helper warning,非本次新增。 + +后端启动: + +```powershell +npm.cmd run api-server:maincloud +``` + +结果:命令在 60 秒观察窗口内超时,但随后探测 `http://127.0.0.1:3100/healthz` 返回 `200`,本地存在 `api-server` 运行进程。本切片未触发新的 Rust 编译错误。 + +### 2026-04-29 WP-ST Big Fish 发布门禁 Adapter 切片 + +已完成: + +1. 新增 `SERVER_RS_DDD_WP_ST_BIG_FISH_READINESS_ADAPTER_2026-04-29.md`,冻结本次 WP-ST Big Fish 可独立落地范围。 +2. 在 `spacetime-module/src/big_fish/events.rs` 新增 `big_fish_event` public event table。 +3. `big_fish_event` 当前承接 `PublishReadinessEvaluated`,用于订阅端、BFF 或审计流程感知发布门禁评估事实。 +4. `compile_big_fish_draft_tx`、`generate_big_fish_asset_tx`、`publish_big_fish_game_tx` 已改为调用 `module_big_fish::evaluate_publish_readiness`。 +5. Adapter 只负责从 SpacetimeDB row 读取草稿和资产槽、调用领域应用服务、持久化 session readiness 和事件,不再在 Adapter 中直接决定发布门禁规则。 +6. 已同步 `migration.rs` 迁移白名单,加入 `big_fish_event`。 +7. 已同步 `SPACETIMEDB_TABLE_CATALOG.md` 的 Big Fish 表目录和查询说明。 + +边界说明: + +1. `big_fish_event` 不是作品真相表,正式作品状态仍以 `big_fish_creation_session` 和 `big_fish_asset_slot` 为准。 +2. SpacetimeDB 事务返回 `Err` 时会回滚,因此发布失败路径不会持久化事件;事件表只记录成功事务内完成的门禁评估事实。 +3. 本次未修改 `api-server`、`spacetime-client`、前端 services/hooks/components。 + +验证: + +```powershell +cargo check -p spacetime-module --manifest-path server-rs/Cargo.toml +cargo test -p module-big-fish --manifest-path server-rs/Cargo.toml +npm.cmd run check:server-rs-ddd +npm.cmd run check:encoding -- docs/technical/SERVER_RS_DDD_WP_ST_BIG_FISH_READINESS_ADAPTER_2026-04-29.md docs/technical/SPACETIMEDB_TABLE_CATALOG.md server-rs/crates/spacetime-module/src/big_fish/events.rs server-rs/crates/spacetime-module/src/big_fish/mod.rs server-rs/crates/spacetime-module/src/big_fish/assets.rs server-rs/crates/spacetime-module/src/big_fish/session.rs server-rs/crates/spacetime-module/src/migration.rs +``` + +结果:通过。其中 `spacetime-module` 仍存在 3 个既有 `ambiguous glob re-exports` warning,非本次 Big Fish event table 引入。 + +后端启动: + +```powershell +npm.cmd run api-server:maincloud +``` + +结果:已执行。命令在 60 秒观察窗口内超时,随后探测 `http://127.0.0.1:3100/healthz` 无法连接,本地未发现 `api-server` 进程;未观察到本次 Big Fish adapter 改动导致的 Rust 编译错误。 + +未执行: + +```powershell +spacetime build --project-path server-rs/crates/spacetime-module +``` + +原因:当前环境未找到 `spacetime` CLI,可执行文件不在 PATH 中。 + +### 2026-04-29 WP-RS 领域投影 builder 切片 + +已完成: + +1. 新增 `module-runtime-story/src/projection.rs`,提供纯领域函数 `build_story_runtime_projection`。 +2. 新增 `StoryRuntimeProjectionSource` 作为 BFF/SC 接线输入边界,输入只接收已取回的: + - `StorySessionPayload` + - `StoryEventPayload` + - `game_state` + - `RuntimeStoryOptionView` + - server version / narrative / toast 等展示上下文 +3. 投影 builder 复用既有 `build_runtime_story_inventory`、状态读取和 encounter 读取逻辑,输出上一切片新增的 `StoryRuntimeProjectionResponse`。 +4. 投影层不依赖 `spacetime-client`、不导入 SpacetimeDB 生成绑定、不挂 HTTP route,保持 `WP-RS` 领域边界。 +5. 新增 `projection_builds_frontend_ready_story_runtime_shape` 测试,覆盖 actor、inventory、option、status 和 toast 的投影结果。 +6. 本次未修改 SpacetimeDB 表结构,未触碰 `migration.rs`。 + +后续接线边界: + +1. `WP-SC` 可以在 story session 与 runtime inventory facade 稳定后,组合 `StoryRuntimeProjectionSource` 所需数据。 +2. `WP-API` 后续只负责调用 `spacetime-client` 并把中间结果传入 `build_story_runtime_projection`,不在 route 内复制领域投影规则。 +3. `WP-FE-S` 继续等待 `/api/story/sessions/{story_session_id}/runtime-projection` route 稳定后再迁移旧 client。 + +验证: + +```powershell +cargo test -p module-runtime-story --manifest-path server-rs/Cargo.toml +cargo test -p shared-contracts story_runtime_projection_response_uses_new_story_runtime_contract --manifest-path server-rs/Cargo.toml +cargo check -p api-server --manifest-path server-rs/Cargo.toml +npm.cmd run check:server-rs-ddd +``` + +结果:通过。`cargo check -p api-server` 仍有既有 `api-server/src/prompt/rpg/runtime_chat.rs` 未使用 prompt helper warning,非本次新增。 diff --git a/docs/technical/SERVER_RS_DDD_WP_AI_TASK_DOMAIN_REFACTOR_2026-04-29.md b/docs/technical/SERVER_RS_DDD_WP_AI_TASK_DOMAIN_REFACTOR_2026-04-29.md new file mode 100644 index 00000000..731573dd --- /dev/null +++ b/docs/technical/SERVER_RS_DDD_WP_AI_TASK_DOMAIN_REFACTOR_2026-04-29.md @@ -0,0 +1,64 @@ +# server-rs DDD WP-AI AI Task 领域层重构方案(2026-04-29) + +## 1. 背景 + +`G1 契约与路由矩阵` 已冻结,`WP-AI AI Task` 进入第 1 批领域规则并行泳道。当前 `module-ai` 已有 AI task 状态机、输入类型、错误和内存服务,但主要实现集中在 `src/lib.rs`,与全局 DDD 清单要求的 `domain / commands / application / events / errors` 分层不一致。 + +本次只整理 `module-ai` 纯领域层,不改 HTTP route,不改 SpacetimeDB table / reducer / procedure,不改前端。 + +## 2. 目标 + +1. 保持 `module_ai::*` 公开 API 兼容,让 `spacetime-module` 现有引用不需要跟随修改。 +2. 将 AI task 领域模型、命令、应用服务、事件、错误拆入对应文件。 +3. 保持 AI task 状态迁移规则不变: + - `Pending -> Running` + - `Running -> Completed / Failed / Cancelled` + - 终态不允许继续写入阶段、文本片段、结果引用或任务结束状态 +4. 保持流式文本片段按 `sequence` 聚合到阶段输出和任务 `latest_text_output`。 +5. 保持中文错误信息,便于 HTTP adapter 与 SpacetimeDB adapter 显式映射。 + +## 3. 文件边界 + +本次允许修改: + +1. `server-rs/crates/module-ai/src/lib.rs` +2. `server-rs/crates/module-ai/src/domain.rs` +3. `server-rs/crates/module-ai/src/commands.rs` +4. `server-rs/crates/module-ai/src/application.rs` +5. `server-rs/crates/module-ai/src/events.rs` +6. `server-rs/crates/module-ai/src/errors.rs` +7. `server-rs/crates/module-ai/README.md` +8. 本文档和全局任务清单进度记录 + +本次禁止修改: + +1. `server-rs/crates/spacetime-module/src/**` +2. `server-rs/crates/spacetime-client/src/**` +3. `server-rs/crates/api-server/src/**` +4. `server-rs/crates/shared-contracts/src/**` +5. `packages/shared/src/contracts/**` + +## 4. 分层落点 + +| 文件 | 职责 | +| --- | --- | +| `domain.rs` | AI task kind/status、stage kind/status、snapshot、ID helper、文本归一 helper | +| `commands.rs` | create/start/stage/chunk/result/fail/cancel 等写入输入,以及创建命令校验 | +| `application.rs` | `AiTaskService`、`InMemoryAiTaskStore` 和纯内存状态迁移 | +| `events.rs` | AI task 领域事件枚举,供后续 adapter / event table 映射 | +| `errors.rs` | `AiTaskFieldError`、`AiTaskServiceError` 与中文 Display | +| `lib.rs` | 模块声明、公开 re-export、既有行为测试 | + +## 5. 验收 + +必须执行: + +```powershell +cargo test -p module-ai --manifest-path server-rs/Cargo.toml +cargo fmt --all --check --manifest-path server-rs/Cargo.toml +npm.cmd run check:server-rs-ddd +npm.cmd run check:encoding -- docs/technical/SERVER_RS_DDD_WP_AI_TASK_DOMAIN_REFACTOR_2026-04-29.md docs/technical/SERVER_RS_DDD_PARALLEL_TASKLIST_2026-04-29.md server-rs/crates/module-ai/README.md server-rs/crates/module-ai/src/lib.rs server-rs/crates/module-ai/src/domain.rs server-rs/crates/module-ai/src/commands.rs server-rs/crates/module-ai/src/application.rs server-rs/crates/module-ai/src/events.rs server-rs/crates/module-ai/src/errors.rs +npm.cmd run api-server:maincloud +``` + +说明:本次不改 `api-server` route、SpacetimeDB table/reducer/procedure 或前端接线,但按仓库约束,后端 Rust 代码变更后仍执行 `npm.cmd run api-server:maincloud` 重新启动后端。 diff --git a/docs/technical/SERVER_RS_DDD_WP_API_BFF_START_2026-04-29.md b/docs/technical/SERVER_RS_DDD_WP_API_BFF_START_2026-04-29.md new file mode 100644 index 00000000..e1f716f6 --- /dev/null +++ b/docs/technical/SERVER_RS_DDD_WP_API_BFF_START_2026-04-29.md @@ -0,0 +1,98 @@ +# server-rs DDD WP-API BFF 启动切片(2026-04-29) + +## 1. 背景 + +`G1 契约与路由矩阵` 已冻结,`WP-RS` 已把旧 `module-runtime-story-compat` 迁为 `module-runtime-story`,当前可以启动 `WP-API api-server BFF` 的第一段边界收口。 + +`WP-ST SpacetimeDB Adapter`、`WP-SC Spacetime Client` 与 `WP-RS` 已提供 runtime projection 所需 facade 与领域投影 builder,因此本切片继续完成 BFF 层接线:路由挂载收口、错误 envelope 门禁、健康检查、runtime projection 新主链 route 接入,不新增领域规则,不改 SpacetimeDB table/reducer/procedure,不绕过 `spacetime-client`。 + +## 2. 本切片目标 + +1. 确认旧 `/api/runtime/story/*` 兼容路由不再挂载到 `api-server/src/app.rs`。 +2. 保留新主链 `/api/story/*` route family,后续只通过 `spacetime-client` facade 访问 SpacetimeDB。 +3. 对旧 runtime story 路由补充 404 + `ApiErrorEnvelope` 回归测试,避免后续重新接回兼容入口。 +4. 保持 `/healthz` 轻量健康检查可用,并在请求方声明 envelope 时返回标准成功 envelope。 +5. 接入 story runtime projection 主链路由,固定鉴权、错误 envelope 与 `StoryRuntimeProjectionResponse` 输出。 +6. 记录 WP-API 后续接线边界与前端迁移前置条件。 + +## 3. 文件边界 + +本切片允许修改: + +1. `server-rs/crates/api-server/src/app.rs` +2. `server-rs/crates/api-server/src/story_sessions.rs` +3. `docs/technical/SERVER_RS_DDD_WP_API_BFF_START_2026-04-29.md` +4. `docs/technical/SERVER_RS_DDD_PARALLEL_TASKLIST_2026-04-29.md` +5. `docs/technical/README.md` + +本切片不修改: + +1. `server-rs/crates/spacetime-module/src/**` +2. `server-rs/crates/spacetime-client/src/**` +3. `server-rs/crates/shared-contracts/src/**` +4. `packages/shared/src/contracts/**` +5. `src/services/**`、`src/hooks/**`、`src/components/**` + +## 4. 当前 API 边界 + +### 4.1 已收口 + +旧 runtime story 兼容入口不再挂载: + +1. `POST /api/runtime/story/sessions` +2. `POST /api/runtime/story/state/resolve` +3. `GET /api/runtime/story/state/{session_id}` +4. `POST /api/runtime/story/actions/resolve` +5. `POST /api/runtime/story/initial` +6. `POST /api/runtime/story/continue` + +这些路径请求 `x-genarrative-response-envelope: v1` 时应返回: + +1. HTTP status:`404` +2. `ok=false` +3. `error.code=NOT_FOUND` +4. `error.message=资源不存在` + +### 4.2 保留主链 + +当前保留的新主链入口: + +1. `POST /api/story/sessions` +2. `GET /api/story/sessions/{story_session_id}/state` +3. `GET /api/story/sessions/{story_session_id}/runtime-projection` +4. `POST /api/story/sessions/continue` +5. `POST /api/story/battles` +6. `GET /api/story/battles/{battle_state_id}` +7. `POST /api/story/npc/battle` +8. `POST /api/story/battles/resolve` + +这些 route 只能做鉴权、请求响应 DTO 映射、错误 envelope 和 `spacetime-client` 调用,不在 `api-server` 中复制 RPG 领域规则。 + +`runtime-projection` 已接入新主链:鉴权通过后,route 只读取当前用户身份,调用 `spacetime-client::get_story_runtime_projection_source`,再交给 `module-runtime-story::build_story_runtime_projection` 输出 `StoryRuntimeProjectionResponse`。API 层不得重新挂回旧 `/api/runtime/story/*` compat 总入口,也不得复制 actor、inventory、option、status 等领域投影规则。 + +## 5. 后续依赖 + +`WP-API` 后续继续接线前必须保持: + +1. runtime projection 只通过 `spacetime-client` facade 读取 SpacetimeDB。 +2. 投影规则只由 `module-runtime-story` 输出,`api-server` 只做 BFF 编排。 +3. `WP-PF` 稳定 LLM、OSS、SMS、微信等平台副作用错误模型。 +4. `G1` owner 合流必要 DTO shape 变更。 + +`runtime-projection` route/DTO 已可作为 `WP-FE-S` 迁移输入;前端仍需按 `services -> hooks -> components` 顺序推进,不在 hooks/components 中重建正式业务规则。 + +## 6. 验收 + +本切片验证命令: + +```powershell +cargo test -p api-server runtime_story_legacy_routes_are_not_mounted --manifest-path server-rs/Cargo.toml +cargo test -p api-server healthz_returns_standard_envelope_when_requested --manifest-path server-rs/Cargo.toml +cargo test -p api-server get_story_runtime_projection --manifest-path server-rs/Cargo.toml +cargo check -p api-server --manifest-path server-rs/Cargo.toml +npm.cmd run check:server-rs-ddd +npm.cmd run check:encoding -- docs/technical/SERVER_RS_DDD_WP_API_BFF_START_2026-04-29.md docs/technical/SERVER_RS_DDD_PARALLEL_TASKLIST_2026-04-29.md docs/technical/README.md server-rs/crates/api-server/src/app.rs server-rs/crates/api-server/src/story_sessions.rs +npm.cmd run api-server:maincloud +``` + +说明:本切片未修改 SpacetimeDB 表结构、reducer 或 procedure,因此不需要更新 `migration.rs`,也不执行绑定生成。 diff --git a/docs/technical/SERVER_RS_DDD_WP_SC_SPACETIME_CLIENT_REFACTOR_2026-04-29.md b/docs/technical/SERVER_RS_DDD_WP_SC_SPACETIME_CLIENT_REFACTOR_2026-04-29.md new file mode 100644 index 00000000..4b2d3883 --- /dev/null +++ b/docs/technical/SERVER_RS_DDD_WP_SC_SPACETIME_CLIENT_REFACTOR_2026-04-29.md @@ -0,0 +1,68 @@ +# server-rs DDD WP-SC Spacetime Client 重构方案(2026-04-29) + +## 1. 背景 + +`WP-SC Spacetime Client` 位于 `spacetime-module` 和 `api-server` 之间,只负责把 SpacetimeDB 生成绑定、procedure / reducer 调用、row snapshot 和错误语义收口成 BFF 可消费的 typed facade。 + +当前 `spacetime-client` 已经具备连接池、生成绑定、多个领域 facade 和 mapper,但错误映射仍散落在各 facade 中,且 README 仍停留在早期占位说明。由于 `WP-ST` 尚未完成所有新 facade,本次不预判新的 table、reducer、procedure 或 row shape,只先收口已存在调用层的基础设施。 + +## 2. 本次目标 + +1. 明确 `spacetime-client` 的 DDD 边界和后续接入顺序。 +2. 新增统一的 SDK 调用错误、业务 procedure 错误、缺失快照错误 helper。 +3. 用 AI task 与 Big Fish 现有 facade 作为第一批示范,减少重复的 `SpacetimeClientError::Procedure(error.to_string())`。 +4. 保持现有公开 facade 方法和返回 record 不变,不改 `api-server` 调用方。 +5. 不修改 `spacetime-module`、`shared-contracts`、`api-server` 路由挂载或前端。 + +## 3. 文件边界 + +本次允许修改: + +1. `server-rs/crates/spacetime-client/src/lib.rs` +2. `server-rs/crates/spacetime-client/src/ai.rs` +3. `server-rs/crates/spacetime-client/src/big_fish.rs` +4. `server-rs/crates/spacetime-client/src/mapper.rs` +5. `server-rs/crates/spacetime-client/README.md` +6. 本文档 +7. `docs/technical/README.md` +8. `docs/technical/SERVER_RS_DDD_PARALLEL_TASKLIST_2026-04-29.md` 的进度记录 + +本次禁止修改: + +1. `server-rs/crates/spacetime-module/src/**` +2. `server-rs/crates/shared-contracts/src/**` +3. `server-rs/crates/api-server/src/app.rs` +4. `server-rs/crates/api-server/src/**` 路由行为 +5. `src/services/**`、`src/hooks/**`、`src/components/**` +6. `server-rs/crates/spacetime-client/src/module_bindings/**` 生成绑定 + +## 4. 分层落点 + +| 层 | 职责 | 本次落点 | +| --- | --- | --- | +| 连接层 | 连接池、握手、超时、断线处理 | 保持现状,不改连接策略 | +| 调用层 | procedure / reducer then 回调、SDK 错误映射 | 新增统一错误 helper,并先接 AI / Big Fish | +| mapper 层 | 绑定类型到 BFF record / DTO 的转换 | 新增通用 procedure 失败与缺快照 helper,后续逐步替换重复代码 | +| facade 层 | 面向 `api-server` 的 typed 方法 | 方法签名保持不变 | + +## 5. 后续依赖 + +1. `WP-ST` 每稳定一个 SpacetimeDB facade 后,再由 `WP-SC` 接对应 mapper / facade。 +2. `WP-API` 只能通过 `spacetime-client` 调用 SpacetimeDB,不直接拼接生成绑定。 +3. 前端迁移必须等待 `WP-API` route 和 DTO 稳定后,再按 `services -> hooks -> components` 接入。 +4. 若后续改变 table / reducer / procedure,必须由 `WP-ST` 同步表目录和必要的绑定生成记录。 + +## 6. 验收 + +必须执行: + +```powershell +cargo fmt --all --check --manifest-path server-rs/Cargo.toml +cargo check -p spacetime-client --manifest-path server-rs/Cargo.toml +cargo check -p api-server --manifest-path server-rs/Cargo.toml +npm.cmd run check:server-rs-ddd +npm.cmd run check:encoding -- docs/technical/SERVER_RS_DDD_WP_SC_SPACETIME_CLIENT_REFACTOR_2026-04-29.md docs/technical/SERVER_RS_DDD_PARALLEL_TASKLIST_2026-04-29.md docs/technical/README.md server-rs/crates/spacetime-client/README.md server-rs/crates/spacetime-client/src/lib.rs server-rs/crates/spacetime-client/src/ai.rs server-rs/crates/spacetime-client/src/big_fish.rs server-rs/crates/spacetime-client/src/mapper.rs +npm.cmd run api-server:maincloud +``` + +说明:本次不改 SpacetimeDB 表、reducer、procedure,不刷新生成绑定,不同步 `migration.rs`。 diff --git a/docs/technical/SERVER_RS_DDD_WP_ST_AI_TASK_EVENT_ADAPTER_2026-04-29.md b/docs/technical/SERVER_RS_DDD_WP_ST_AI_TASK_EVENT_ADAPTER_2026-04-29.md new file mode 100644 index 00000000..a5797f1b --- /dev/null +++ b/docs/technical/SERVER_RS_DDD_WP_ST_AI_TASK_EVENT_ADAPTER_2026-04-29.md @@ -0,0 +1,78 @@ +# server-rs DDD WP-ST AI Task 事件 Adapter 落地记录(2026-04-29) + +## 1. 背景 + +`WP-AI AI Task` 已完成领域层拆分,`WP-ST SpacetimeDB Adapter` 可以开始把稳定领域状态变化接入 SpacetimeDB。当前 AI 任务已有真相表: + +1. `ai_task` +2. `ai_task_stage` +3. `ai_text_chunk` +4. `ai_result_reference` + +本次不改变这些真相表的字段,不改 HTTP/BFF,不改前端,只补齐 AI 任务状态变化的 SpacetimeDB 事件流。 + +## 2. 本次范围 + +允许修改: + +1. `server-rs/crates/spacetime-module/src/ai/**` +2. `server-rs/crates/spacetime-module/src/migration.rs` +3. `docs/technical/SPACETIMEDB_TABLE_CATALOG.md` +4. 本文档 + +禁止修改: + +1. `server-rs/crates/api-server/src/**` +2. `server-rs/crates/spacetime-client/src/**` +3. `src/services/**` +4. `src/hooks/**` +5. `src/components/**` + +## 3. 设计 + +新增 `ai_task_event` 为 `public event` 表,供订阅端和后续 BFF 增量消费 AI 任务变化。 + +事件类型: + +1. `TaskCreated` +2. `TaskStatusChanged` +3. `StageStarted` +4. `StageCompleted` +5. `TextChunkAppended` +6. `ResultReferenceAttached` + +事件字段只保存用于路由和定位的轻量信息: + +1. `task_id` +2. `owner_user_id` +3. `event_kind` +4. `task_status` +5. `stage_kind` +6. `text_chunk_row_id` +7. `result_reference_row_id` +8. `occurred_at` + +## 4. 边界说明 + +1. `ai_task_event` 不是业务真相表,不能替代 `ai_task` / `ai_task_stage` / `ai_text_chunk` / `ai_result_reference`。 +2. reducer 和 procedure 仍只在事务成功后写入事件。 +3. reducer 继续返回 `Result<(), String>`,procedure 继续返回现有 `AiTaskProcedureResult`。 +4. 本次没有引入网络、文件、外部随机数或全局可变状态。 +5. 本次新增表已同步 `migration.rs` 迁移白名单。 + +## 5. 验收命令 + +```powershell +cargo check -p spacetime-module --manifest-path server-rs/Cargo.toml +cargo test -p module-ai --manifest-path server-rs/Cargo.toml +npm.cmd run check:server-rs-ddd +npm.cmd run check:encoding -- docs/technical/SERVER_RS_DDD_WP_ST_AI_TASK_EVENT_ADAPTER_2026-04-29.md docs/technical/SPACETIMEDB_TABLE_CATALOG.md server-rs/crates/spacetime-module/src/ai/events.rs server-rs/crates/spacetime-module/src/ai/mod.rs server-rs/crates/spacetime-module/src/ai/snapshots.rs server-rs/crates/spacetime-module/src/ai/stages.rs server-rs/crates/spacetime-module/src/ai/tasks.rs server-rs/crates/spacetime-module/src/migration.rs +``` + +若后续生成前端绑定或发布数据库,需要继续执行: + +```powershell +spacetime build +spacetime generate --lang typescript --out-dir <前端绑定目录> --module-path server-rs/crates/spacetime-module +spacetime describe --json +``` diff --git a/docs/technical/SERVER_RS_DDD_WP_ST_BIG_FISH_READINESS_ADAPTER_2026-04-29.md b/docs/technical/SERVER_RS_DDD_WP_ST_BIG_FISH_READINESS_ADAPTER_2026-04-29.md new file mode 100644 index 00000000..41dfbab4 --- /dev/null +++ b/docs/technical/SERVER_RS_DDD_WP_ST_BIG_FISH_READINESS_ADAPTER_2026-04-29.md @@ -0,0 +1,69 @@ +# server-rs DDD WP-ST Big Fish 发布门禁 Adapter 落地记录(2026-04-29) + +## 1. 背景 + +`WP-BF Big Fish` 已在领域层新增 `evaluate_publish_readiness`,用于评估草稿和资产槽是否满足发布条件。`spacetime-module` 之前在 Big Fish adapter 内直接调用 `build_asset_coverage` 判断发布就绪,容易让门禁规则继续散落在 Adapter。 + +本次将 SpacetimeDB Adapter 的发布门禁收口到 `module-big-fish` 应用服务,并新增轻量事件表记录成功事务中的门禁评估事实。 + +## 2. 本次范围 + +允许修改: + +1. `server-rs/crates/spacetime-module/src/big_fish/**` +2. `server-rs/crates/spacetime-module/src/migration.rs` +3. `docs/technical/SPACETIMEDB_TABLE_CATALOG.md` +4. 本文档 + +禁止修改: + +1. `server-rs/crates/api-server/src/**` +2. `server-rs/crates/spacetime-client/src/**` +3. `src/services/**` +4. `src/hooks/**` +5. `src/components/**` + +## 3. 设计 + +新增 `big_fish_event` 为 `public event` 表,当前只承接 `PublishReadinessEvaluated`。 + +事件字段: + +1. `session_id` +2. `owner_user_id` +3. `event_kind` +4. `publish_ready` +5. `blockers_json` +6. `occurred_at` + +接入点: + +1. `compile_big_fish_draft_tx` +2. `generate_big_fish_asset_tx` +3. `publish_big_fish_game_tx` + +这些接入点先从 SpacetimeDB row 读取草稿和资产槽,再调用 `module_big_fish::evaluate_publish_readiness`,最后把 readiness 回写到 `big_fish_creation_session.publish_ready` 和 `asset_coverage_json`。 + +## 4. 边界说明 + +1. `big_fish_event` 不是作品真相表,不能替代 `big_fish_creation_session` 或 `big_fish_asset_slot`。 +2. 发布门禁规则由 `module-big-fish` 领域应用服务决定,SpacetimeDB Adapter 只负责 row 映射、持久化和事件落表。 +3. 由于 SpacetimeDB 事务在 `Err` 时回滚,发布失败路径中的事件不会持久化;事件表只记录成功事务内完成的门禁评估事实。 +4. 本次没有引入 HTTP、OSS、图片生成、文件系统或外部随机数。 +5. 本次新增表已同步 `migration.rs` 迁移白名单。 + +## 5. 验收命令 + +```powershell +cargo check -p spacetime-module --manifest-path server-rs/Cargo.toml +cargo test -p module-big-fish --manifest-path server-rs/Cargo.toml +npm.cmd run check:server-rs-ddd +npm.cmd run check:encoding -- docs/technical/SERVER_RS_DDD_WP_ST_BIG_FISH_READINESS_ADAPTER_2026-04-29.md docs/technical/SPACETIMEDB_TABLE_CATALOG.md server-rs/crates/spacetime-module/src/big_fish/events.rs server-rs/crates/spacetime-module/src/big_fish/mod.rs server-rs/crates/spacetime-module/src/big_fish/assets.rs server-rs/crates/spacetime-module/src/big_fish/session.rs server-rs/crates/spacetime-module/src/migration.rs +``` + +若后续具备 CLI 环境,需要继续执行: + +```powershell +spacetime build --project-path server-rs/crates/spacetime-module +spacetime generate --lang rust --out-dir server-rs/crates/spacetime-client/src/module_bindings --module-path server-rs/crates/spacetime-module +``` diff --git a/docs/technical/SPACETIMEDB_TABLE_CATALOG.md b/docs/technical/SPACETIMEDB_TABLE_CATALOG.md index edbb6008..9014e53c 100644 --- a/docs/technical/SPACETIMEDB_TABLE_CATALOG.md +++ b/docs/technical/SPACETIMEDB_TABLE_CATALOG.md @@ -27,9 +27,9 @@ spacetime sql "SELECT * FROM custom_world_gallery_entry" | RPG 运行时 | `story_session`, `story_event`, `npc_state`, `inventory_slot`, `battle_state`, `treasure_record`, `quest_record`, `quest_log`, `player_progression`, `chapter_progression` | | 世界创作 | `custom_world_profile`, `custom_world_session`, `custom_world_agent_session`, `custom_world_agent_message`, `custom_world_agent_operation`, `custom_world_draft_card`, `custom_world_gallery_entry` | | 拼图 | `puzzle_agent_session`, `puzzle_agent_message`, `puzzle_work_profile`, `puzzle_runtime_run` | -| 大鱼吃小鱼 | `big_fish_creation_session`, `big_fish_agent_message`, `big_fish_asset_slot`, `big_fish_runtime_run` | +| 大鱼吃小鱼 | `big_fish_creation_session`, `big_fish_agent_message`, `big_fish_asset_slot`, `big_fish_event`, `big_fish_runtime_run` | | 资产 | `asset_object`, `asset_entity_binding` | -| AI 任务 | `ai_task`, `ai_task_stage`, `ai_text_chunk`, `ai_result_reference` | +| AI 任务 | `ai_task`, `ai_task_stage`, `ai_text_chunk`, `ai_result_reference`, `ai_task_event` | ## 认证表 @@ -464,6 +464,18 @@ SELECT * FROM big_fish_asset_slot WHERE slot_id = ''; SELECT * FROM big_fish_asset_slot WHERE session_id = ''; ``` +### `big_fish_event` + +- 作用:大鱼吃小鱼创作事件表,目前记录发布门禁评估结果,供订阅端、BFF 或审计流程感知草稿是否达到发布条件;正式作品状态仍以 `big_fish_creation_session` 和 `big_fish_asset_slot` 为准。 +- 可见性:`public event`。 +- 结构:`event_id PK: String`, `session_id: String`, `owner_user_id: String`, `event_kind: BigFishEventKind`, `publish_ready: bool`, `blockers_json: String`, `occurred_at: Timestamp`。 +- 索引:`session_id`, `owner_user_id`。 + +```sql +SELECT * FROM big_fish_event WHERE session_id = '' ORDER BY occurred_at ASC; +SELECT * FROM big_fish_event WHERE owner_user_id = '' ORDER BY occurred_at DESC; +``` + ### `big_fish_runtime_run` - 作用:大鱼吃小鱼运行态表,保存当前 run 的快照、最后输入方向和 tick。 @@ -550,6 +562,18 @@ SELECT * FROM ai_result_reference WHERE result_reference_row_id = ''; SELECT * FROM ai_result_reference WHERE task_id = '' ORDER BY created_at ASC; ``` +### `ai_task_event` + +- 作用:AI 任务事件表,用于把任务创建、状态变化、阶段变化、流式文本和结果引用挂接广播给订阅端;任务真相仍以 `ai_task`、`ai_task_stage`、`ai_text_chunk` 和 `ai_result_reference` 为准。 +- 可见性:`public event`。 +- 结构:`event_id PK: String`, `task_id: String`, `owner_user_id: String`, `event_kind: AiTaskEventKind`, `task_status: Option`, `stage_kind: Option`, `text_chunk_row_id: Option`, `result_reference_row_id: Option`, `occurred_at: Timestamp`。 +- 索引:`task_id`, `owner_user_id`。 + +```sql +SELECT * FROM ai_task_event WHERE task_id = '' ORDER BY occurred_at ASC; +SELECT * FROM ai_task_event WHERE owner_user_id = '' ORDER BY occurred_at DESC; +``` + ## 当前维护风险 - `story_session`、`story_event`、`npc_state`、`inventory_slot`、`battle_state`、`treasure_record`、`quest_record`、`quest_log`、`player_progression`、`chapter_progression` 在 `src/lib.rs` 与 `src/gameplay/mod.rs` 都能看到表定义。当前编译入口以 `src/lib.rs` 为准;后续完成拆分时,需要删除重复定义或正式挂载子模块,并同步更新本文。 diff --git a/server-rs/Cargo.lock b/server-rs/Cargo.lock index d0b66e69..b802434e 100644 --- a/server-rs/Cargo.lock +++ b/server-rs/Cargo.lock @@ -89,7 +89,7 @@ dependencies = [ "module-puzzle", "module-runtime", "module-runtime-item", - "module-runtime-story-compat", + "module-runtime-story", "module-story", "platform-auth", "platform-llm", @@ -1624,7 +1624,7 @@ dependencies = [ ] [[package]] -name = "module-runtime-story-compat" +name = "module-runtime-story" version = "0.1.0" dependencies = [ "serde_json", @@ -2668,9 +2668,11 @@ dependencies = [ "module-puzzle", "module-runtime", "module-runtime-item", + "module-runtime-story", "module-story", "serde", "serde_json", + "shared-contracts", "shared-kernel", "spacetimedb-sdk", "tokio", diff --git a/server-rs/Cargo.toml b/server-rs/Cargo.toml index 821314f0..acb48fab 100644 --- a/server-rs/Cargo.toml +++ b/server-rs/Cargo.toml @@ -20,7 +20,7 @@ members = [ "crates/module-progression", "crates/module-quest", "crates/module-runtime", - "crates/module-runtime-story-compat", + "crates/module-runtime-story", "crates/module-runtime-item", "crates/module-story", "crates/platform-oss", diff --git a/server-rs/crates/api-server/Cargo.toml b/server-rs/crates/api-server/Cargo.toml index cc3ad307..3de40cef 100644 --- a/server-rs/crates/api-server/Cargo.toml +++ b/server-rs/crates/api-server/Cargo.toml @@ -23,7 +23,7 @@ module-inventory = { path = "../module-inventory" } module-npc = { path = "../module-npc" } module-puzzle = { path = "../module-puzzle" } module-runtime = { path = "../module-runtime" } -module-runtime-story-compat = { path = "../module-runtime-story-compat" } +module-runtime-story = { path = "../module-runtime-story" } module-runtime-item = { path = "../module-runtime-item" } module-story = { path = "../module-story" } platform-auth = { path = "../platform-auth" } diff --git a/server-rs/crates/api-server/src/app.rs b/server-rs/crates/api-server/src/app.rs index d450f9bd..33727ea3 100644 --- a/server-rs/crates/api-server/src/app.rs +++ b/server-rs/crates/api-server/src/app.rs @@ -111,16 +111,13 @@ use crate::{ put_runtime_snapshot, resume_profile_save_archive, }, runtime_settings::{get_runtime_settings, put_runtime_settings}, - runtime_story::{ - begin_runtime_story_session, generate_runtime_story_continue, - generate_runtime_story_initial, get_runtime_story_state, resolve_runtime_story_action, - resolve_runtime_story_state, - }, state::AppState, story_battles::{ create_story_battle, create_story_npc_battle, get_story_battle_state, resolve_story_battle, }, - story_sessions::{begin_story_session, continue_story, get_story_session_state}, + story_sessions::{ + begin_story_session, continue_story, get_story_runtime_projection, get_story_session_state, + }, wechat_auth::{bind_wechat_phone, handle_wechat_callback, start_wechat_login}, }; @@ -991,48 +988,6 @@ pub fn build_router(state: AppState) -> Router { require_bearer_auth, )), ) - .route( - "/api/runtime/story/sessions", - post(begin_runtime_story_session).route_layer(middleware::from_fn_with_state( - state.clone(), - require_bearer_auth, - )), - ) - .route( - "/api/runtime/story/state/resolve", - post(resolve_runtime_story_state).route_layer(middleware::from_fn_with_state( - state.clone(), - require_bearer_auth, - )), - ) - .route( - "/api/runtime/story/state/{session_id}", - get(get_runtime_story_state).route_layer(middleware::from_fn_with_state( - state.clone(), - require_bearer_auth, - )), - ) - .route( - "/api/runtime/story/actions/resolve", - post(resolve_runtime_story_action).route_layer(middleware::from_fn_with_state( - state.clone(), - require_bearer_auth, - )), - ) - .route( - "/api/runtime/story/initial", - post(generate_runtime_story_initial).route_layer(middleware::from_fn_with_state( - state.clone(), - require_bearer_auth, - )), - ) - .route( - "/api/runtime/story/continue", - post(generate_runtime_story_continue).route_layer(middleware::from_fn_with_state( - state.clone(), - require_bearer_auth, - )), - ) .route( "/api/profile/play-stats", get(get_profile_play_stats).route_layer(middleware::from_fn_with_state( @@ -1054,6 +1009,13 @@ pub fn build_router(state: AppState) -> Router { require_bearer_auth, )), ) + .route( + "/api/story/sessions/{story_session_id}/runtime-projection", + get(get_story_runtime_projection).route_layer(middleware::from_fn_with_state( + state.clone(), + require_bearer_auth, + )), + ) .route( "/api/story/sessions/continue", post(continue_story).route_layer(middleware::from_fn_with_state( @@ -1312,6 +1274,53 @@ mod tests { ); } + #[tokio::test] + async fn runtime_story_legacy_routes_are_not_mounted() { + let app = build_router(AppState::new(AppConfig::default()).expect("state should build")); + + for (method, uri) in [ + ("POST", "/api/runtime/story/sessions"), + ("POST", "/api/runtime/story/state/resolve"), + ("GET", "/api/runtime/story/state/runtime-main"), + ("POST", "/api/runtime/story/actions/resolve"), + ("POST", "/api/runtime/story/initial"), + ("POST", "/api/runtime/story/continue"), + ] { + let response = app + .clone() + .oneshot( + Request::builder() + .method(method) + .uri(uri) + .header("x-genarrative-response-envelope", "v1") + .body(Body::empty()) + .expect("legacy runtime story request should build"), + ) + .await + .expect("legacy runtime story request should be handled"); + + assert_eq!(response.status(), StatusCode::NOT_FOUND); + + let body = response + .into_body() + .collect() + .await + .expect("legacy runtime story body should collect") + .to_bytes(); + let payload: Value = + serde_json::from_slice(&body).expect("legacy runtime story body should be json"); + assert_eq!(payload["ok"], Value::Bool(false)); + assert_eq!( + payload["error"]["code"], + Value::String("NOT_FOUND".to_string()) + ); + assert_eq!( + payload["error"]["message"], + Value::String("资源不存在".to_string()) + ); + } + } + #[tokio::test] async fn internal_auth_claims_rejects_missing_bearer_token() { let app = build_router(AppState::new(AppConfig::default()).expect("state should build")); diff --git a/server-rs/crates/api-server/src/main.rs b/server-rs/crates/api-server/src/main.rs index 4a3c767a..64e3097f 100644 --- a/server-rs/crates/api-server/src/main.rs +++ b/server-rs/crates/api-server/src/main.rs @@ -55,7 +55,6 @@ mod runtime_inventory; mod runtime_profile; mod runtime_save; mod runtime_settings; -mod runtime_story; mod session_client; mod state; mod story_battles; diff --git a/server-rs/crates/api-server/src/runtime_chat.rs b/server-rs/crates/api-server/src/runtime_chat.rs index 8f713229..845a9dd0 100644 --- a/server-rs/crates/api-server/src/runtime_chat.rs +++ b/server-rs/crates/api-server/src/runtime_chat.rs @@ -12,7 +12,7 @@ use serde::Deserialize; use serde_json::{Value, json}; use std::convert::Infallible; -use module_runtime_story_compat::{ +use module_runtime_story::{ RuntimeStoryPromptContextExtras, build_runtime_story_prompt_context, current_world_type, normalize_required_string, read_array_field, read_field, read_i32_field, read_object_field, read_optional_string_field, read_runtime_session_id, diff --git a/server-rs/crates/api-server/src/runtime_chat_plain.rs b/server-rs/crates/api-server/src/runtime_chat_plain.rs index 622418f3..6d29cb88 100644 --- a/server-rs/crates/api-server/src/runtime_chat_plain.rs +++ b/server-rs/crates/api-server/src/runtime_chat_plain.rs @@ -16,7 +16,7 @@ use crate::{ api_response::json_success_body, auth::AuthenticatedAccessToken, http_error::AppError, prompt::runtime_chat::*, request_context::RequestContext, state::AppState, }; -use module_runtime_story_compat::{ +use module_runtime_story::{ RuntimeStoryPromptContextExtras, build_runtime_story_prompt_context, current_world_type, normalize_required_string, read_array_field, read_field, read_runtime_session_id, }; diff --git a/server-rs/crates/api-server/src/runtime_story.rs b/server-rs/crates/api-server/src/runtime_story.rs deleted file mode 100644 index b0bda7ce..00000000 --- a/server-rs/crates/api-server/src/runtime_story.rs +++ /dev/null @@ -1,6 +0,0 @@ -mod compat; - -pub use compat::{ - begin_runtime_story_session, generate_runtime_story_continue, generate_runtime_story_initial, - get_runtime_story_state, resolve_runtime_story_action, resolve_runtime_story_state, -}; diff --git a/server-rs/crates/api-server/src/runtime_story/compat.rs b/server-rs/crates/api-server/src/runtime_story/compat.rs deleted file mode 100644 index 0df019d7..00000000 --- a/server-rs/crates/api-server/src/runtime_story/compat.rs +++ /dev/null @@ -1,1470 +0,0 @@ -use axum::{ - Json, - extract::{Extension, Path, State}, - http::StatusCode, - response::Response, -}; -use module_npc::{ - NpcRelationStance, build_initial_stance_profile as build_module_npc_initial_stance_profile, - build_relation_state as build_module_npc_relation_state, -}; -use module_runtime::{RuntimeSnapshotRecord, SAVE_SNAPSHOT_VERSION, format_utc_micros}; -use module_runtime_story_compat::{ - CONTINUE_ADVENTURE_FUNCTION_ID, CurrentEncounterNpcQuestContext, GeneratedStoryPayload, - PendingQuestOfferContext, RuntimeStoryActionResponseParts, RuntimeStoryPromptContextExtras, - StoryResolution, add_player_currency, add_player_inventory_items, append_story_history, - apply_equipment_loadout_to_state, battle_mode_text, build_battle_runtime_story_options, - build_current_build_toast, build_disabled_runtime_story_option, build_npc_gift_result_text, - build_runtime_story_option_from_story_option, build_runtime_story_prompt_context, - build_runtime_story_view_model, build_static_runtime_story_option, build_status_patch, - build_story_option_from_runtime_option, clear_encounter_only, clear_encounter_state, - clone_inventory_item_with_quantity, current_encounter_id, current_encounter_name, - current_world_type, ensure_inventory_action_available, ensure_json_object, - equipment_slot_label, finalize_post_battle_resolution, find_player_inventory_entry, - format_currency_text, format_now_rfc3339, grant_player_progression_experience, - has_giftable_player_inventory, increment_runtime_stat, normalize_equipment_slot_id, - normalize_equipped_item, normalize_required_string, npc_buyback_price, npc_purchase_price, - project_story_engine_after_action, read_array_field, read_bool_field, read_field, - read_i32_field, read_inventory_item_name, read_object_field, read_optional_string_field, - read_player_equipment_item, read_required_string_field, read_runtime_session_id, - read_u32_field, recruit_companion_to_party, remove_player_inventory_item, resolve_action_text, - resolve_battle_action, resolve_current_encounter_npc_state, resolve_equipment_slot_for_item, - resolve_forge_craft_action, resolve_forge_dismantle_action, resolve_forge_reforge_action, - resolve_npc_gift_affinity_gain, resolve_post_battle_story_options, restore_player_resource, - simple_story_resolution, trade_quantity_suffix, write_bool_field, write_i32_field, - write_null_field, write_player_equipment_item, write_runtime_npc_interaction_view, - write_string_field, write_u32_field, -}; -use platform_llm::{LlmClient, LlmMessage, LlmTextRequest}; -use serde_json::{Map, Value, json}; -use shared_contracts::runtime_story::{ - RuntimeBattlePresentation, RuntimeStoryActionRequest, RuntimeStoryActionResponse, - RuntimeStoryAiRequest, RuntimeStoryAiResponse, RuntimeStoryBootstrapRequest, - RuntimeStoryBootstrapResponse, RuntimeStoryOptionInteraction, RuntimeStoryOptionView, - RuntimeStoryPatch, RuntimeStoryPresentation, RuntimeStorySnapshotPayload, - RuntimeStoryStateResolveRequest, -}; -use shared_kernel::{offset_datetime_to_unix_micros, parse_rfc3339}; -use spacetime_client::SpacetimeClientError; -use time::OffsetDateTime; - -use crate::{ - api_response::json_success_body, auth::AuthenticatedAccessToken, http_error::AppError, - request_context::RequestContext, state::AppState, -}; - -mod ai; -mod bootstrap; -mod equipment_actions; -mod game_state; -mod npc_actions; -mod presentation; -mod quest_actions; - -pub use self::bootstrap::begin_runtime_story_session; -use self::{ - ai::*, equipment_actions::*, game_state::*, npc_actions::*, presentation::*, quest_actions::*, -}; - -#[cfg(test)] -mod tests; - -pub async fn resolve_runtime_story_state( - State(state): State, - Extension(request_context): Extension, - Extension(authenticated): Extension, - Json(payload): Json, -) -> Result, Response> { - let session_id = normalize_required_string(payload.session_id.as_str()).ok_or_else(|| { - runtime_story_error_response( - &request_context, - AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ - "provider": "runtime-story", - "field": "sessionId", - "message": "sessionId 不能为空", - })), - ) - })?; - let snapshot = resolve_snapshot_for_request( - &state, - &request_context, - authenticated.claims().user_id().to_string(), - payload.snapshot, - ) - .await?; - - validate_client_version( - &request_context, - payload.client_version, - &snapshot.game_state, - "运行时版本已变化,请先同步最新快照后再读取状态", - )?; - - Ok(json_success_body( - Some(&request_context), - build_runtime_story_state_response(&session_id, payload.client_version, snapshot), - )) -} - -pub async fn get_runtime_story_state( - State(state): State, - Path(session_id): Path, - Extension(request_context): Extension, - Extension(authenticated): Extension, -) -> Result, Response> { - let session_id = normalize_required_string(session_id.as_str()).ok_or_else(|| { - runtime_story_error_response( - &request_context, - AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ - "provider": "runtime-story", - "field": "sessionId", - "message": "sessionId 不能为空", - })), - ) - })?; - let snapshot = resolve_snapshot_for_request( - &state, - &request_context, - authenticated.claims().user_id().to_string(), - None, - ) - .await?; - - Ok(json_success_body( - Some(&request_context), - build_runtime_story_state_response(&session_id, None, snapshot), - )) -} - -pub async fn resolve_runtime_story_action( - State(state): State, - Extension(request_context): Extension, - Extension(authenticated): Extension, - Json(payload): Json, -) -> Result, Response> { - let requested_session_id = - normalize_required_string(payload.session_id.as_str()).ok_or_else(|| { - runtime_story_error_response( - &request_context, - AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ - "provider": "runtime-story", - "field": "sessionId", - "message": "sessionId 不能为空", - })), - ) - })?; - let function_id = - normalize_required_string(payload.action.function_id.as_str()).ok_or_else(|| { - runtime_story_error_response( - &request_context, - AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ - "provider": "runtime-story", - "field": "action.functionId", - "message": "functionId 不能为空", - })), - ) - })?; - if payload.action.action_type.trim() != "story_choice" { - return Err(runtime_story_error_response( - &request_context, - AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ - "provider": "runtime-story", - "field": "action.type", - "message": "runtime story 当前只支持 story_choice 动作", - })), - )); - } - - let mut snapshot = resolve_snapshot_for_request( - &state, - &request_context, - authenticated.claims().user_id().to_string(), - payload.snapshot.clone(), - ) - .await?; - validate_client_version( - &request_context, - payload.client_version, - &snapshot.game_state, - "运行时版本已变化,请先同步最新快照后再提交动作", - )?; - - let previous_game_state = snapshot.game_state.clone(); - let current_story_before = snapshot.current_story.clone(); - let mut game_state = snapshot.game_state.clone(); - let mut resolution = resolve_runtime_story_choice_action( - &mut game_state, - current_story_before.as_ref(), - &payload, - &function_id, - ) - .map_err(|message| { - runtime_story_error_response( - &request_context, - AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ - "provider": "runtime-story", - "message": message, - })), - ) - })?; - - let server_version = read_u32_field(&game_state, "runtimeActionVersion") - .unwrap_or(0) - .saturating_add(1); - write_u32_field(&mut game_state, "runtimeActionVersion", server_version); - write_string_field( - &mut game_state, - "runtimeSessionId", - requested_session_id.as_str(), - ); - - let mut options = resolution - .presentation_options - .take() - .unwrap_or_else(|| build_fallback_runtime_story_options(&game_state)); - if options.is_empty() { - options = build_fallback_runtime_story_options(&game_state); - } - - let mut story_text = resolution - .story_text - .clone() - .unwrap_or_else(|| resolution.result_text.clone()); - let mut history_result_text = resolution.result_text.clone(); - let mut saved_current_story = resolution - .saved_current_story - .take() - .unwrap_or_else(|| build_legacy_current_story(story_text.as_str(), &options)); - let post_battle_finalized = finalize_runtime_story_resolution_for_response( - &mut game_state, - &mut story_text, - &mut history_result_text, - &mut options, - &mut saved_current_story, - resolution.battle.as_ref(), - ); - if !post_battle_finalized - && let Some(generated_payload) = generate_action_story_payload( - &state, - &game_state, - &payload, - &function_id, - resolution.action_text.as_str(), - resolution.result_text.as_str(), - &options, - resolution.battle.as_ref(), - ) - .await - { - story_text = generated_payload.story_text; - history_result_text = generated_payload.history_result_text; - options = generated_payload.presentation_options; - saved_current_story = generated_payload.saved_current_story; - } - append_story_history( - &mut game_state, - resolution.action_text.as_str(), - history_result_text.as_str(), - ); - project_story_engine_after_action( - &previous_game_state, - &mut game_state, - resolution.action_text.as_str(), - history_result_text.as_str(), - function_id.as_str(), - resolution - .battle - .as_ref() - .and_then(|battle| battle.outcome.as_deref()), - ); - - let mut patches = vec![RuntimeStoryPatch::StoryHistoryAppend { - action_text: resolution.action_text.clone(), - result_text: history_result_text, - }]; - patches.extend(resolution.patches); - - snapshot.saved_at = Some(format_now_rfc3339()); - snapshot.game_state = game_state; - snapshot.current_story = Some(saved_current_story); - let persisted = persist_runtime_story_snapshot( - &state, - &request_context, - authenticated.claims().user_id().to_string(), - snapshot, - ) - .await?; - let persisted_snapshot = runtime_snapshot_payload_from_record(&persisted); - - Ok(json_success_body( - Some(&request_context), - build_runtime_story_action_response(RuntimeStoryActionResponseParts { - requested_session_id, - server_version, - snapshot: persisted_snapshot, - action_text: resolution.action_text, - result_text: resolution.result_text, - story_text, - options, - patches, - toast: resolution.toast, - battle: resolution.battle, - }), - )) -} - -pub async fn generate_runtime_story_initial( - State(state): State, - Extension(request_context): Extension, - Extension(authenticated): Extension, - Json(payload): Json, -) -> Result, Response> { - let payload = hydrate_runtime_story_ai_request_from_session( - &state, - &request_context, - authenticated.claims().user_id().to_string(), - payload, - true, - ) - .await?; - - Ok(json_success_body( - Some(&request_context), - build_runtime_story_ai_response(&state, payload, true).await, - )) -} - -pub async fn generate_runtime_story_continue( - State(state): State, - Extension(request_context): Extension, - Extension(authenticated): Extension, - Json(payload): Json, -) -> Result, Response> { - let payload = hydrate_runtime_story_ai_request_from_session( - &state, - &request_context, - authenticated.claims().user_id().to_string(), - payload, - false, - ) - .await?; - - Ok(json_success_body( - Some(&request_context), - build_runtime_story_ai_response(&state, payload, false).await, - )) -} - -async fn hydrate_runtime_story_ai_request_from_session( - state: &AppState, - request_context: &RequestContext, - user_id: String, - mut payload: RuntimeStoryAiRequest, - initial: bool, -) -> Result { - let Some(session_id) = payload - .session_id - .as_deref() - .and_then(normalize_required_string) - else { - // 中文注释:旧测试或兼容入口可能仍传 worldType/character/context; - // 没有 sessionId 时只保留反序列化兼容,不作为新主链。 - return Ok(payload); - }; - - let snapshot = resolve_snapshot_for_request(state, request_context, user_id, None).await?; - validate_client_version( - request_context, - payload.client_version, - &snapshot.game_state, - "运行时版本已变化,请先同步最新快照后再生成剧情", - )?; - - let snapshot_session_id = - read_runtime_session_id(&snapshot.game_state).unwrap_or_else(|| session_id.clone()); - if snapshot_session_id != session_id { - return Err(runtime_story_error_response( - request_context, - AppError::from_status(StatusCode::CONFLICT).with_details(json!({ - "provider": "runtime-story", - "message": "请求的运行时会话与服务端快照不一致,请重新进入游戏", - "sessionId": session_id, - "snapshotSessionId": snapshot_session_id, - })), - )); - } - - let extras = RuntimeStoryPromptContextExtras { - pending_scene_encounter: false, - last_function_id: payload.last_function_id.clone(), - observe_signs_requested: payload.observe_signs_requested, - recent_action_result: payload.recent_action_result.clone(), - opening_camp_background: None, - opening_camp_dialogue: None, - }; - payload.world_type = current_world_type(&snapshot.game_state).unwrap_or_default(); - payload.character = read_field(&snapshot.game_state, "playerCharacter") - .cloned() - .unwrap_or(Value::Null); - payload.monsters = read_array_field(&snapshot.game_state, "sceneHostileNpcs") - .into_iter() - .cloned() - .collect(); - payload.history = if initial { - Vec::new() - } else { - read_array_field(&snapshot.game_state, "storyHistory") - .into_iter() - .rev() - .take(12) - .collect::>() - .into_iter() - .rev() - .cloned() - .collect() - }; - payload.context = build_runtime_story_prompt_context(&snapshot.game_state, extras); - - Ok(payload) -} - -async fn resolve_snapshot_for_request( - state: &AppState, - request_context: &RequestContext, - user_id: String, - snapshot: Option, -) -> Result { - if let Some(snapshot) = snapshot { - let record = - persist_runtime_story_snapshot(state, request_context, user_id, snapshot).await?; - return Ok(runtime_snapshot_payload_from_record(&record)); - } - - let record = state - .get_runtime_snapshot_record(user_id) - .await - .map_err(|error| { - runtime_story_error_response(request_context, map_runtime_story_client_error(error)) - })? - .ok_or_else(|| { - runtime_story_error_response( - request_context, - AppError::from_status(StatusCode::CONFLICT).with_details(json!({ - "provider": "runtime-story", - "message": "运行时快照不存在,请先初始化并保存一次游戏", - })), - ) - })?; - - Ok(runtime_snapshot_payload_from_record(&record)) -} - -async fn persist_runtime_story_snapshot( - state: &AppState, - request_context: &RequestContext, - user_id: String, - snapshot: RuntimeStorySnapshotPayload, -) -> Result { - validate_snapshot_payload(&snapshot).map_err(|message| { - runtime_story_error_response( - request_context, - AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ - "provider": "runtime-story", - "message": message, - })), - ) - })?; - - let now = OffsetDateTime::now_utc(); - let saved_at = snapshot - .saved_at - .as_deref() - .and_then(|value| normalize_required_string(value)) - .map(|value| parse_rfc3339(value.as_str())) - .transpose() - .map_err(|error| { - runtime_story_error_response( - request_context, - AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ - "provider": "runtime-story", - "field": "snapshot.savedAt", - "message": format!("savedAt 非法: {error}"), - })), - ) - })? - .unwrap_or(now); - let saved_at_micros = offset_datetime_to_unix_micros(saved_at); - let updated_at_micros = offset_datetime_to_unix_micros(now); - - if is_non_persistent_runtime_story_snapshot(&snapshot) { - let game_state = canonicalize_runtime_story_game_state_for_persistence(snapshot.game_state); - return Ok(build_transient_runtime_snapshot_record( - user_id, - saved_at_micros, - snapshot.bottom_tab, - game_state, - snapshot.current_story, - updated_at_micros, - )); - } - - let game_state = canonicalize_runtime_story_game_state_for_persistence(snapshot.game_state); - state - .put_runtime_snapshot_record( - user_id, - saved_at_micros, - snapshot.bottom_tab, - game_state, - snapshot.current_story, - updated_at_micros, - ) - .await - .map_err(|error| { - runtime_story_error_response(request_context, map_runtime_story_client_error(error)) - }) -} - -fn canonicalize_runtime_story_game_state_for_persistence(mut game_state: Value) -> Value { - if let Some(root) = game_state.as_object_mut() { - // 中文注释:NPC 交易/赠礼 view 是响应时派生的展示层数据,不能写回正式快照真相。 - root.remove("runtimeNpcInteraction"); - } - game_state -} - -fn finalize_runtime_story_resolution_for_response( - game_state: &mut Value, - story_text: &mut String, - history_result_text: &mut String, - options: &mut Vec, - saved_current_story: &mut Value, - battle: Option<&RuntimeBattlePresentation>, -) -> bool { - let battle_outcome = battle.and_then(|battle| battle.outcome.as_deref()); - let post_battle_options = resolve_post_battle_story_options(game_state); - if let Some(post_battle) = finalize_post_battle_resolution( - game_state, - story_text.as_str(), - battle_outcome, - post_battle_options, - ) { - *story_text = post_battle.story_text; - *history_result_text = story_text.clone(); - *options = post_battle.presentation_options; - *saved_current_story = post_battle.saved_current_story; - return true; - } - false -} - -fn build_transient_runtime_snapshot_record( - user_id: String, - saved_at_micros: i64, - bottom_tab: String, - game_state: Value, - current_story: Option, - updated_at_micros: i64, -) -> RuntimeSnapshotRecord { - // 中文注释:预览/测试只需要本次响应里的 hydrated snapshot,不能写入正式存档表。 - RuntimeSnapshotRecord { - user_id, - version: SAVE_SNAPSHOT_VERSION, - saved_at: format_utc_micros(saved_at_micros), - saved_at_micros, - bottom_tab, - game_state_json: game_state.to_string(), - current_story_json: current_story.as_ref().map(Value::to_string), - game_state, - current_story, - created_at_micros: updated_at_micros, - updated_at_micros, - } -} - -fn is_non_persistent_runtime_story_snapshot(snapshot: &RuntimeStorySnapshotPayload) -> bool { - let Some(game_state) = snapshot.game_state.as_object() else { - return false; - }; - - if game_state - .get("runtimePersistenceDisabled") - .and_then(Value::as_bool) - .unwrap_or(false) - { - return true; - } - - matches!( - game_state - .get("runtimeMode") - .and_then(Value::as_str) - .map(str::trim), - Some("preview") | Some("test") - ) -} - -fn validate_snapshot_payload(snapshot: &RuntimeStorySnapshotPayload) -> Result<(), String> { - if normalize_required_string(snapshot.bottom_tab.as_str()).is_none() { - return Err("snapshot.bottomTab 不能为空".to_string()); - } - if !snapshot.game_state.is_object() { - return Err("snapshot.gameState 必须是 JSON object".to_string()); - } - if snapshot - .current_story - .as_ref() - .is_some_and(|current_story| !current_story.is_object()) - { - return Err("snapshot.currentStory 必须是 JSON object 或 null".to_string()); - } - - Ok(()) -} - -fn runtime_snapshot_payload_from_record( - record: &RuntimeSnapshotRecord, -) -> RuntimeStorySnapshotPayload { - let mut game_state = record.game_state.clone(); - write_runtime_npc_interaction_view(&mut game_state); - - RuntimeStorySnapshotPayload { - saved_at: Some(record.saved_at.clone()), - bottom_tab: record.bottom_tab.clone(), - game_state, - current_story: record.current_story.clone(), - } -} - -fn validate_client_version( - request_context: &RequestContext, - client_version: Option, - game_state: &Value, - message: &str, -) -> Result<(), Response> { - let Some(client_version) = client_version else { - return Ok(()); - }; - let Some(server_version) = read_u32_field(game_state, "runtimeActionVersion") else { - return Ok(()); - }; - if client_version == server_version { - return Ok(()); - } - - Err(runtime_story_error_response( - request_context, - AppError::from_status(StatusCode::CONFLICT).with_details(json!({ - "provider": "runtime-story", - "message": message, - "clientVersion": client_version, - "serverVersion": server_version, - })), - )) -} - -fn resolve_runtime_story_choice_action( - game_state: &mut Value, - current_story: Option<&Value>, - request: &RuntimeStoryActionRequest, - function_id: &str, -) -> Result { - ensure_runtime_story_bridge_state(game_state); - match function_id { - CONTINUE_ADVENTURE_FUNCTION_ID => resolve_continue_adventure_action(current_story), - "story_opening_camp_dialogue" => resolve_npc_affinity_action( - game_state, - request, - "交换开场判断", - 2, - "你把眼前局势先讲清楚,对方终于愿意把第一轮判断说出口。", - ), - "camp_travel_home_scene" => resolve_camp_travel_home_scene_action(game_state, request), - "idle_call_out" => Ok(simple_story_resolution( - game_state, - resolve_action_text("主动出声试探", request), - "你的喊话打破了当前静场,周围潜着的动静也更难继续藏住。", - )), - "idle_explore_forward" => Ok(simple_story_resolution( - game_state, - resolve_action_text("继续向前探索", request), - "你没有停在原地,而是继续向前压,把下一段遭遇主动推到自己面前。", - )), - "idle_observe_signs" => Ok(simple_story_resolution( - game_state, - resolve_action_text("观察周围迹象", request), - "你先压住动作,把风向、脚印和气味这些细节重新读了一遍。", - )), - "idle_rest_focus" => { - restore_player_resource(game_state, 8, 6); - Ok(simple_story_resolution( - game_state, - resolve_action_text("原地调息", request), - "你把呼吸慢下来重新稳住节奏,生命和灵力都回上来一点。", - )) - } - "idle_travel_next_scene" => resolve_idle_travel_next_scene_action(game_state, request), - "npc_preview_talk" => resolve_npc_preview_action(game_state, request), - "npc_chat" => resolve_npc_chat_action(game_state, request), - "npc_help" => resolve_npc_help_action(game_state, request), - "npc_chat_quest_offer_view" => { - resolve_pending_quest_offer_view_action(game_state, current_story, request) - } - "npc_chat_quest_offer_replace" => { - resolve_pending_quest_offer_replace_action(game_state, current_story, request) - } - "npc_chat_quest_offer_abandon" => { - resolve_pending_quest_offer_abandon_action(game_state, current_story, request) - } - "npc_quest_accept" => { - resolve_pending_quest_accept_action(game_state, current_story, request) - } - "npc_quest_turn_in" => resolve_pending_quest_turn_in_action(game_state, request), - "npc_leave" => { - let npc_name = current_encounter_name(game_state); - clear_encounter_state(game_state); - Ok(StoryResolution { - action_text: resolve_action_text("离开当前角色", request), - result_text: format!("你结束了与 {npc_name} 的这一轮接触,把注意力重新放回旅途。"), - story_text: None, - presentation_options: None, - saved_current_story: None, - patches: vec![ - build_status_patch(game_state), - RuntimeStoryPatch::EncounterChanged { encounter_id: None }, - ], - battle: None, - toast: None, - }) - } - "npc_fight" | "npc_spar" => { - resolve_npc_battle_entry_action(game_state, request, function_id) - } - "npc_trade" => resolve_npc_trade_action(game_state, request), - "npc_gift" => resolve_npc_gift_action(game_state, request), - "npc_recruit" => resolve_npc_recruit_action(game_state, request), - "equipment_equip" => resolve_equipment_equip_action(game_state, request), - "equipment_unequip" => resolve_equipment_unequip_action(game_state, request), - "forge_craft" => resolve_forge_craft_action(game_state, request), - "forge_dismantle" => resolve_forge_dismantle_action(game_state, request), - "forge_reforge" => resolve_forge_reforge_action(game_state, request), - "battle_attack_basic" - | "battle_use_skill" - | "battle_all_in_crush" - | "battle_escape_breakout" - | "battle_feint_step" - | "battle_finisher_window" - | "battle_guard_break" - | "battle_probe_pressure" - | "battle_recover_breath" - | "inventory_use" => resolve_battle_action(game_state, request, function_id), - _ => Err(format!("暂不支持的 runtime action:{function_id}")), - } -} - -fn resolve_continue_adventure_action( - current_story: Option<&Value>, -) -> Result { - let deferred_options = current_story - .map(|story| { - read_array_field(story, "deferredOptions") - .into_iter() - .filter_map(build_runtime_story_option_from_story_option) - .collect::>() - }) - .unwrap_or_default(); - let options = (!deferred_options.is_empty()).then_some(deferred_options); - - Ok(StoryResolution { - action_text: "继续推进冒险".to_string(), - result_text: "你没有把节奏停下来,而是顺着当前局势继续向前推进了这一段故事。".to_string(), - story_text: None, - presentation_options: options, - saved_current_story: None, - patches: Vec::new(), - battle: None, - toast: None, - }) -} - -fn resolve_idle_travel_next_scene_action( - game_state: &mut Value, - request: &RuntimeStoryActionRequest, -) -> Result { - let previous_scene_name = read_object_field(game_state, "currentScenePreset") - .and_then(|scene| read_optional_string_field(scene, "name")) - .unwrap_or_else(|| "当前位置".to_string()); - let target_scene = resolve_next_scene_preset(game_state); - let target_scene_name = target_scene - .as_ref() - .and_then(|scene| read_optional_string_field(scene, "name")) - .unwrap_or_else(|| "相邻场景".to_string()); - - if let Some(scene) = target_scene { - ensure_json_object(game_state).insert("currentScenePreset".to_string(), scene); - } - clear_encounter_state(game_state); - increment_runtime_stat(game_state, "scenesTraveled", 1); - write_i32_field(game_state, "playerX", 0); - write_i32_field(game_state, "playerOffsetY", 0); - write_string_field(game_state, "playerFacing", "right"); - write_string_field(game_state, "animationState", "idle"); - write_string_field(game_state, "playerActionMode", "idle"); - write_bool_field(game_state, "scrollWorld", false); - write_null_field(game_state, "lastObserveSignsSceneId"); - write_null_field(game_state, "lastObserveSignsReport"); - write_null_field(game_state, "currentBattleNpcId"); - write_null_field(game_state, "currentNpcBattleOutcome"); - write_null_field(game_state, "sparReturnEncounter"); - write_null_field(game_state, "sparPlayerHpBefore"); - write_null_field(game_state, "sparPlayerMaxHpBefore"); - write_null_field(game_state, "sparStoryHistoryBefore"); - ensure_json_object(game_state).insert("activeCombatEffects".to_string(), Value::Array(vec![])); - ensure_scene_encounter_preview(game_state); - - Ok(StoryResolution { - action_text: resolve_action_text(&format!("前往{target_scene_name}"), request), - result_text: format!("你离开{previous_scene_name},前往{target_scene_name}。"), - story_text: None, - presentation_options: None, - saved_current_story: None, - patches: vec![ - build_status_patch(game_state), - RuntimeStoryPatch::EncounterChanged { - encounter_id: read_object_field(game_state, "currentEncounter") - .and_then(|encounter| read_optional_string_field(encounter, "id")), - }, - ], - battle: None, - toast: None, - }) -} - -fn resolve_camp_travel_home_scene_action( - game_state: &mut Value, - request: &RuntimeStoryActionRequest, -) -> Result { - let target_scene = resolve_camp_travel_target_scene(game_state, request) - .ok_or_else(|| "无法解析离营后的目标场景".to_string())?; - let target_scene_name = - read_optional_string_field(&target_scene, "name").unwrap_or_else(|| "前方场景".to_string()); - let companion_name = read_object_field(game_state, "currentEncounter") - .and_then(|encounter| { - read_optional_string_field(encounter, "npcName") - .or_else(|| read_optional_string_field(encounter, "name")) - }) - .unwrap_or_else(|| "同伴".to_string()); - - ensure_json_object(game_state).insert("currentScenePreset".to_string(), target_scene); - reset_scene_travel_runtime_state(game_state); - increment_runtime_stat(game_state, "scenesTraveled", 1); - ensure_scene_encounter_preview(game_state); - - let encounter_id = read_object_field(game_state, "currentEncounter") - .and_then(|encounter| read_optional_string_field(encounter, "id")); - Ok(StoryResolution { - action_text: resolve_action_text(&format!("前往{target_scene_name}"), request), - result_text: format!( - "你和{companion_name}离开营地,正式踏入{target_scene_name},把冒险推进到新的现场。" - ), - story_text: None, - presentation_options: None, - saved_current_story: None, - patches: vec![ - build_status_patch(game_state), - RuntimeStoryPatch::EncounterChanged { encounter_id }, - ], - battle: None, - toast: None, - }) -} - -fn resolve_camp_travel_target_scene( - game_state: &Value, - request: &RuntimeStoryActionRequest, -) -> Option { - resolve_payload_target_scene(game_state, request) - .or_else(|| resolve_character_home_scene(game_state)) - .or_else(|| resolve_current_scene_forward_scene(game_state)) - .or_else(|| resolve_default_first_adventure_scene(game_state)) -} - -fn resolve_payload_target_scene( - game_state: &Value, - request: &RuntimeStoryActionRequest, -) -> Option { - // 中文注释:旧前端如果补传 targetSceneId,后端可以接收; - // 但正式主链不依赖前端,缺省时仍由服务端自行解析目标场景。 - let target_scene_id = request - .action - .payload - .as_ref() - .and_then(|payload| read_optional_string_field(payload, "targetSceneId")) - .or_else(|| request.action.target_id.clone())?; - resolve_scene_preset_by_id(game_state, target_scene_id.as_str()) -} - -fn resolve_character_home_scene(game_state: &Value) -> Option { - let character_id = read_object_field(game_state, "playerCharacter") - .and_then(|character| read_optional_string_field(character, "id")); - let world_type = current_world_type(game_state); - let Some(character_id) = character_id else { - return None; - }; - if world_type.as_deref() == Some("CUSTOM") { - return resolve_custom_character_home_scene(game_state, character_id.as_str()); - } - - let scene_id = match (character_id.as_str(), world_type.as_deref()) { - ("sword-princess", Some("XIANXIA")) => "xianxia-celestial-corridor", - ("sword-princess", _) => "wuxia-palace-court", - ("archer-hero", Some("XIANXIA")) => "xianxia-star-vessel", - ("archer-hero", _) => "wuxia-border-camp", - ("girl-hero", Some("XIANXIA")) => "xianxia-waterfall-cliff", - ("girl-hero", _) => "wuxia-rain-street", - ("punch-hero", Some("XIANXIA")) => "xianxia-molten-realm", - ("punch-hero", _) => "wuxia-forge-works", - ("fighter-4", Some("XIANXIA")) => "xianxia-thunder-altar", - ("fighter-4", _) => "wuxia-mountain-gate", - _ => return None, - }; - - resolve_builtin_scene_preset(world_type.as_deref().unwrap_or("WUXIA"), scene_id) -} - -fn resolve_custom_character_home_scene(game_state: &Value, character_id: &str) -> Option { - let profile = read_object_field(game_state, "customWorldProfile")?; - let role_id = find_custom_world_role_id_by_reference(profile, character_id) - .or_else(|| { - read_object_field(game_state, "playerCharacter") - .and_then(|character| read_optional_string_field(character, "name")) - .and_then(|name| find_custom_world_role_id_by_reference(profile, name.as_str())) - }) - .unwrap_or_else(|| character_id.to_string()); - - read_array_field(profile, "landmarks") - .into_iter() - .enumerate() - .find_map(|(index, landmark)| { - read_array_field(landmark, "sceneNpcIds") - .into_iter() - .filter_map(Value::as_str) - .any(|npc_id| custom_role_references_equal(profile, npc_id, role_id.as_str())) - .then(|| { - bootstrap::build_custom_scene_preset( - profile, - format!("custom-scene-landmark-{}", index + 1).as_str(), - ) - }) - .flatten() - }) -} - -fn resolve_current_scene_forward_scene(game_state: &Value) -> Option { - let current_scene = read_object_field(game_state, "currentScenePreset")?; - let current_scene_id = read_optional_string_field(current_scene, "id"); - read_optional_string_field(current_scene, "forwardSceneId") - .or_else(|| { - read_array_field(current_scene, "connectedSceneIds") - .into_iter() - .filter_map(Value::as_str) - .find(|scene_id| Some(*scene_id) != current_scene_id.as_deref()) - .map(str::to_string) - }) - .or_else(|| { - read_array_field(current_scene, "connections") - .into_iter() - .find_map(|connection| { - read_optional_string_field(connection, "sceneId") - .filter(|scene_id| Some(scene_id.as_str()) != current_scene_id.as_deref()) - }) - }) - .and_then(|scene_id| resolve_scene_preset_by_id(game_state, scene_id.as_str())) -} - -fn resolve_default_first_adventure_scene(game_state: &Value) -> Option { - if current_world_type(game_state).as_deref() == Some("CUSTOM") { - let profile = read_object_field(game_state, "customWorldProfile")?; - if !read_array_field(profile, "landmarks").is_empty() { - return bootstrap::build_custom_scene_preset(profile, "custom-scene-landmark-1"); - } - return bootstrap::build_custom_scene_preset(profile, "custom-scene-camp"); - } - - resolve_builtin_scene_preset( - current_world_type(game_state).as_deref().unwrap_or("WUXIA"), - if current_world_type(game_state).as_deref() == Some("XIANXIA") { - "xianxia-cloud-gate" - } else { - "wuxia-bamboo-road" - }, - ) -} - -fn resolve_scene_preset_by_id(game_state: &Value, scene_id: &str) -> Option { - if current_world_type(game_state).as_deref() == Some("CUSTOM") { - return read_object_field(game_state, "customWorldProfile") - .and_then(|profile| bootstrap::build_custom_scene_preset(profile, scene_id)); - } - - resolve_builtin_scene_preset( - current_world_type(game_state).as_deref().unwrap_or("WUXIA"), - scene_id, - ) -} - -fn reset_scene_travel_runtime_state(game_state: &mut Value) { - clear_encounter_state(game_state); - write_i32_field(game_state, "playerX", 0); - write_i32_field(game_state, "playerOffsetY", 0); - write_string_field(game_state, "playerFacing", "right"); - write_string_field(game_state, "animationState", "idle"); - write_string_field(game_state, "playerActionMode", "idle"); - write_bool_field(game_state, "scrollWorld", false); - write_null_field(game_state, "lastObserveSignsSceneId"); - write_null_field(game_state, "lastObserveSignsReport"); - write_null_field(game_state, "currentBattleNpcId"); - write_null_field(game_state, "currentNpcBattleMode"); - write_null_field(game_state, "currentNpcBattleOutcome"); - write_null_field(game_state, "sparReturnEncounter"); - write_null_field(game_state, "sparPlayerHpBefore"); - write_null_field(game_state, "sparPlayerMaxHpBefore"); - write_null_field(game_state, "sparStoryHistoryBefore"); - ensure_json_object(game_state).insert("activeCombatEffects".to_string(), Value::Array(vec![])); -} - -fn resolve_builtin_scene_preset(world_type: &str, scene_id: &str) -> Option { - let scene = builtin_scene_definition(world_type, scene_id)?; - Some(build_builtin_scene_preset_from_definition( - world_type, scene, - )) -} - -fn build_builtin_scene_preset_from_definition( - world_type: &str, - scene: BuiltinSceneDefinition, -) -> Value { - let connections = - build_builtin_scene_connections(&scene.connected_scene_ids, scene.forward_scene_id); - let narrative_residues = scene - .treasure_hints - .iter() - .take(2) - .enumerate() - .map(|(index, hint)| { - json!({ - "id": format!("residue:{}:{}", scene.id, index + 1), - "title": format!("{}的残痕 {}", scene.name, index + 1), - "visibleClue": hint, - "linkedFactIds": [], - "linkedThreadIds": [] - }) - }) - .collect::>(); - json!({ - "id": scene.id, - "name": scene.name, - "description": scene.description, - "imageSrc": "", - "worldType": world_type, - "forwardSceneId": scene.forward_scene_id, - "connectedSceneIds": scene.connected_scene_ids, - "connections": connections, - "npcs": [build_builtin_scene_npc(scene.npc_id, scene.npc_name, scene.npc_role, scene.npc_avatar, scene.npc_description)], - "treasureHints": scene.treasure_hints, - "narrativeResidues": narrative_residues - }) -} - -fn build_builtin_scene_connections( - connected_scene_ids: &[&str], - forward_scene_id: &str, -) -> Vec { - connected_scene_ids - .iter() - .enumerate() - .map(|(index, scene_id)| { - let relative_position = if *scene_id == forward_scene_id { - "forward" - } else if index % 2 == 0 { - "left" - } else { - "right" - }; - json!({ - "sceneId": scene_id, - "relativePosition": relative_position, - "summary": if relative_position == "forward" { - "沿主路继续深入前方区域" - } else { - "这里分出一条支路" - } - }) - }) - .collect() -} - -fn build_builtin_scene_npc( - id: &str, - name: &str, - role: &str, - avatar: &str, - description: &str, -) -> Value { - json!({ - "id": id, - "name": name, - "description": description, - "avatar": avatar, - "role": role, - "gender": "unknown", - "initialAffinity": 18, - "hostile": false, - "functions": ["trade", "fight", "spar", "help", "chat", "recruit", "gift"] - }) -} - -struct BuiltinSceneDefinition { - id: &'static str, - name: &'static str, - description: &'static str, - connected_scene_ids: Vec<&'static str>, - forward_scene_id: &'static str, - treasure_hints: Vec<&'static str>, - npc_id: &'static str, - npc_name: &'static str, - npc_role: &'static str, - npc_avatar: &'static str, - npc_description: &'static str, -} - -fn builtin_scene_definition(world_type: &str, scene_id: &str) -> Option { - match (world_type, scene_id) { - (_, "wuxia-bamboo-road") => Some(BuiltinSceneDefinition { - id: "wuxia-bamboo-road", - name: "竹林古道", - description: "风过竹叶如刀鸣,窄道蜿蜒向深处,最适合藏伏毒物和游侠。", - connected_scene_ids: vec![ - "wuxia-mountain-gate", - "wuxia-mist-woods", - "wuxia-ferry-bridge", - ], - forward_scene_id: "wuxia-mountain-gate", - treasure_hints: vec!["竹根旁半埋的刀鞘", "倒竹间的旧药囊"], - npc_id: "wuxia-npc-bamboo-woodcutter", - npc_name: "樵夫老周", - npc_role: "樵夫", - npc_avatar: "樵", - npc_description: "常在竹海边缘砍柴,对附近路数和兽踪了如指掌。", - }), - (_, "wuxia-mountain-gate") => Some(BuiltinSceneDefinition { - id: "wuxia-mountain-gate", - name: "山门石阶", - description: "青石阶层层向上,旧山门半开半掩,守山人与伏兽都能藏得很稳。", - connected_scene_ids: vec![ - "wuxia-temple-forecourt", - "wuxia-border-camp", - "wuxia-bamboo-road", - ], - forward_scene_id: "wuxia-temple-forecourt", - treasure_hints: vec!["裂缝里的铜钥", "石狮座下遗落的令牌"], - npc_id: "wuxia-npc-gate-disciple", - npc_name: "守山弟子", - npc_role: "门派弟子", - npc_avatar: "守", - npc_description: "一直盯着石阶尽头的动静,像在等某位重要来客。", - }), - (_, "wuxia-rain-street") => Some(BuiltinSceneDefinition { - id: "wuxia-rain-street", - name: "雨夜长街", - description: "长街积水映灯,屋檐下尽是藏身空隙,最易碰见追踪者与夜行客。", - connected_scene_ids: vec![ - "wuxia-ferry-bridge", - "wuxia-palace-court", - "wuxia-ruined-village", - ], - forward_scene_id: "wuxia-ferry-bridge", - treasure_hints: vec!["灯檐下浸湿的布包", "排水沟边翻起的账册残页"], - npc_id: "wuxia-npc-night-vendor", - npc_name: "夜灯摊主", - npc_role: "摊主", - npc_avatar: "灯", - npc_description: "深夜仍在街口守着灯摊,见过太多不该见的人。", - }), - (_, "wuxia-border-camp") => Some(BuiltinSceneDefinition { - id: "wuxia-border-camp", - name: "边关营地", - description: "营火与旌旗都带着风沙味,士卒、斥候和异兽都可能在这里短暂停留。", - connected_scene_ids: vec![ - "wuxia-ferry-bridge", - "wuxia-mountain-gate", - "wuxia-ruined-village", - ], - forward_scene_id: "wuxia-rain-street", - treasure_hints: vec!["废营帐里的箭囊", "火盆旁埋着的军需匣"], - npc_id: "wuxia-npc-quartermaster", - npc_name: "军需官", - npc_role: "营地官", - npc_avatar: "营", - npc_description: "管着兵器和粮草,对各路来客始终保持戒心。", - }), - (_, "wuxia-forge-works") => Some(BuiltinSceneDefinition { - id: "wuxia-forge-works", - name: "铸坊工场", - description: "火星、铁水与重锤声混在一起,热浪里最容易引来重甲怪物与寻刀之人。", - connected_scene_ids: vec![ - "wuxia-mine-depths", - "wuxia-palace-court", - "wuxia-border-camp", - ], - forward_scene_id: "wuxia-palace-court", - treasure_hints: vec!["淬火池旁的铁匣", "风箱后压着的旧兵谱"], - npc_id: "wuxia-npc-blacksmith", - npc_name: "老铸匠", - npc_role: "铸匠", - npc_avatar: "铸", - npc_description: "看一眼兵器缺口就知道你刚从什么地方杀出来。", - }), - (_, "wuxia-palace-court") => Some(BuiltinSceneDefinition { - id: "wuxia-palace-court", - name: "宫苑内庭", - description: "回廊深处静得过分,花木修得齐整,却处处像埋着王庭旧案。", - connected_scene_ids: vec![ - "wuxia-forge-works", - "wuxia-rain-street", - "wuxia-crypt-passage", - ], - forward_scene_id: "wuxia-rain-street", - treasure_hints: vec!["回廊暗格里的香囊", "花圃石座下的旧金牌"], - npc_id: "wuxia-npc-maid", - npc_name: "旧宫侍女", - npc_role: "宫人", - npc_avatar: "侍", - npc_description: "嘴上说得少,却总知道哪条回廊最近不该过去。", - }), - ("XIANXIA", "xianxia-cloud-gate") => Some(BuiltinSceneDefinition { - id: "xianxia-cloud-gate", - name: "云海仙门", - description: "云阶在脚下翻涌,门阙后方灵光不断,来客与守门异物都极显眼。", - connected_scene_ids: vec![ - "xianxia-floating-isle", - "xianxia-celestial-corridor", - "xianxia-star-vessel", - ], - forward_scene_id: "xianxia-celestial-corridor", - treasure_hints: vec!["云阶尽头的灵符匣", "门阙阴影里的玉牌"], - npc_id: "xianxia-npc-gate-attendant", - npc_name: "守门灵官", - npc_role: "门官", - npc_avatar: "门", - npc_description: "站在门阙侧旁观来者,像在等一份迟迟未到的回报。", - }), - ("XIANXIA", "xianxia-celestial-corridor") => Some(BuiltinSceneDefinition { - id: "xianxia-celestial-corridor", - name: "天宫长廊", - description: "廊柱之间回响着空灵风声,禁制和书妖都喜欢寄在这类高处回廊里。", - connected_scene_ids: vec![ - "xianxia-cloud-gate", - "xianxia-thunder-altar", - "xianxia-ancient-ruins", - ], - forward_scene_id: "xianxia-thunder-altar", - treasure_hints: vec!["廊柱暗槽里的玉简", "风铃后藏着的封签"], - npc_id: "xianxia-npc-palace-page", - npc_name: "抄经侍者", - npc_role: "侍者", - npc_avatar: "卷", - npc_description: "抱着卷册在廊下快步穿行,像是在躲某种会翻页的东西。", - }), - ("XIANXIA", "xianxia-star-vessel") => Some(BuiltinSceneDefinition { - id: "xianxia-star-vessel", - name: "星舟甲板", - description: "甲板横在高天之上,风压和星光都很强,飞行异物最爱在这里盘旋。", - connected_scene_ids: vec![ - "xianxia-thunder-altar", - "xianxia-cloud-gate", - "xianxia-floating-isle", - ], - forward_scene_id: "xianxia-floating-isle", - treasure_hints: vec!["舵台后的星图匣", "甲板缝里卡着的灵罗盘"], - npc_id: "xianxia-npc-helmsman", - npc_name: "星舟舵手", - npc_role: "舵手", - npc_avatar: "舟", - npc_description: "守着老旧星舟的航线图,对高空中的异动异常敏感。", - }), - ("XIANXIA", "xianxia-waterfall-cliff") => Some(BuiltinSceneDefinition { - id: "xianxia-waterfall-cliff", - name: "飞瀑仙崖", - description: "瀑声压住一切杂音,崖边潮气浓重,飞蝠、水灵与章影都很容易现身。", - connected_scene_ids: vec![ - "xianxia-sacred-tree", - "xianxia-molten-realm", - "xianxia-floating-isle", - ], - forward_scene_id: "xianxia-cloud-gate", - treasure_hints: vec!["瀑幕后闪着光的石匣", "崖边藤上挂着的护身铃"], - npc_id: "xianxia-npc-cliff-scout", - npc_name: "崖巡女修", - npc_role: "巡修", - npc_avatar: "崖", - npc_description: "长期在飞瀑边巡看,脚步轻得像从不曾碰到过石面。", - }), - ("XIANXIA", "xianxia-molten-realm") => Some(BuiltinSceneDefinition { - id: "xianxia-molten-realm", - name: "熔岩秘境", - description: "热浪裹着赤光翻涌,附近的异章与泥灵都容易被灼气激得发狂。", - connected_scene_ids: vec![ - "xianxia-thunder-altar", - "xianxia-waterfall-cliff", - "xianxia-jade-cavern", - ], - forward_scene_id: "xianxia-waterfall-cliff", - treasure_hints: vec!["熔岩边冷却的矿匣", "焦岩后藏着的火纹石"], - npc_id: "xianxia-npc-fire-forger", - npc_name: "熔炉匠修", - npc_role: "炼匠", - npc_avatar: "炉", - npc_description: "在热浪里锻器不歇,见惯灵火失控的后果。", - }), - ("XIANXIA", "xianxia-thunder-altar") => Some(BuiltinSceneDefinition { - id: "xianxia-thunder-altar", - name: "雷殿祭坛", - description: "祭坛上方雷纹未散,灵书、飞蛾与雷意余波总会把来者围在中心。", - connected_scene_ids: vec![ - "xianxia-celestial-corridor", - "xianxia-molten-realm", - "xianxia-star-vessel", - ], - forward_scene_id: "xianxia-star-vessel", - treasure_hints: vec!["祭坛角落的雷纹匣", "断碑背面的青铜铃"], - npc_id: "xianxia-npc-thunder-keeper", - npc_name: "祭雷守使", - npc_role: "守使", - npc_avatar: "雷", - npc_description: "总站在祭坛边缘看天,像在确认下一道雷会落到哪里。", - }), - _ => None, - } -} - -fn find_custom_world_role_id_by_reference(profile: &Value, reference: &str) -> Option { - let normalized_reference = normalize_custom_role_reference(reference); - if normalized_reference.is_empty() { - return None; - } - - read_array_field(profile, "storyNpcs") - .into_iter() - .chain(read_array_field(profile, "playableNpcs")) - .find(|role| custom_role_aliases(role).contains(&normalized_reference)) - .and_then(|role| read_optional_string_field(role, "id")) -} - -fn custom_role_references_equal(profile: &Value, left: &str, right: &str) -> bool { - let left = find_custom_world_role_id_by_reference(profile, left) - .unwrap_or_else(|| left.trim().to_string()); - let right = find_custom_world_role_id_by_reference(profile, right) - .unwrap_or_else(|| right.trim().to_string()); - !left.trim().is_empty() && left == right -} - -fn custom_role_aliases(role: &Value) -> Vec { - [ - read_optional_string_field(role, "id"), - read_optional_string_field(role, "name"), - read_optional_string_field(role, "title"), - ] - .into_iter() - .flatten() - .map(|value| normalize_custom_role_reference(value.as_str())) - .filter(|value| !value.is_empty()) - .collect() -} - -fn normalize_custom_role_reference(value: &str) -> String { - value - .trim() - .to_lowercase() - .chars() - .filter(|ch| ch.is_alphanumeric()) - .collect() -} - -fn resolve_next_scene_preset(game_state: &Value) -> Option { - let current_scene = read_object_field(game_state, "currentScenePreset")?; - let current_scene_id = read_optional_string_field(current_scene, "id"); - let target_scene_id = - read_optional_string_field(current_scene, "forwardSceneId").or_else(|| { - read_array_field(current_scene, "connections") - .into_iter() - .find_map(|connection| { - read_optional_string_field(connection, "sceneId") - .filter(|scene_id| Some(scene_id) != current_scene_id.as_ref()) - }) - })?; - - find_scene_preset_in_runtime_profile(game_state, target_scene_id.as_str()).or_else(|| { - let mut scene = json!({ - "id": target_scene_id, - "name": "相邻场景", - "description": "你抵达了一处新的区域,周围的动静仍在继续变化。", - "imageSrc": "", - "connectedSceneIds": [current_scene_id.unwrap_or_else(|| "previous-scene".to_string())], - "connections": [], - "treasureHints": [], - "npcs": [] - }); - if let Some(world_type) = current_world_type(game_state) { - ensure_json_object(&mut scene) - .insert("worldType".to_string(), Value::String(world_type)); - } - Some(scene) - }) -} - -fn find_scene_preset_in_runtime_profile(game_state: &Value, scene_id: &str) -> Option { - let profile = read_object_field(game_state, "customWorldProfile")?; - bootstrap::build_custom_scene_preset( - profile, - bootstrap::resolve_custom_runtime_scene_id(profile, scene_id).as_str(), - ) -} - -fn ensure_scene_encounter_preview(game_state: &mut Value) { - if read_bool_field(game_state, "inBattle").unwrap_or(false) - || !read_array_field(game_state, "sceneHostileNpcs").is_empty() - || read_object_field(game_state, "currentEncounter").is_some() - { - return; - } - - let Some(scene) = read_object_field(game_state, "currentScenePreset") else { - return; - }; - let Some(npc) = read_array_field(scene, "npcs").into_iter().find(|npc| { - !read_bool_field(npc, "hostile").unwrap_or(false) - && read_optional_string_field(npc, "monsterPresetId").is_none() - }) else { - return; - }; - - let encounter = bootstrap::build_encounter_from_scene_npc(npc); - ensure_json_object(game_state).insert("currentEncounter".to_string(), encounter); - write_bool_field(game_state, "npcInteractionActive", false); -} - -fn map_runtime_story_client_error(error: SpacetimeClientError) -> AppError { - let (status, provider) = match error { - SpacetimeClientError::Runtime(_) => (StatusCode::BAD_REQUEST, "runtime-story"), - _ => (StatusCode::BAD_GATEWAY, "spacetimedb"), - }; - - AppError::from_status(status).with_details(json!({ - "provider": provider, - "message": error.to_string(), - })) -} - -fn runtime_story_error_response(request_context: &RequestContext, error: AppError) -> Response { - error.into_response_with_context(Some(request_context)) -} diff --git a/server-rs/crates/api-server/src/runtime_story/compat/ai.rs b/server-rs/crates/api-server/src/runtime_story/compat/ai.rs deleted file mode 100644 index 91694879..00000000 --- a/server-rs/crates/api-server/src/runtime_story/compat/ai.rs +++ /dev/null @@ -1,368 +0,0 @@ -use super::*; -use crate::prompt::runtime_chat::{ - RuntimeNpcDialoguePromptParams, RuntimeReasonedStoryPromptParams, RuntimeStoryTextPromptParams, - build_runtime_npc_dialogue_user_prompt, build_runtime_reasoned_story_user_prompt, - build_runtime_story_director_user_prompt, runtime_npc_dialogue_system_prompt, - runtime_reasoned_story_system_prompt, runtime_story_director_system_prompt, -}; - -pub(super) async fn build_runtime_story_ai_response( - state: &AppState, - payload: RuntimeStoryAiRequest, - initial: bool, -) -> RuntimeStoryAiResponse { - let options = build_ai_response_options(&payload); - let fallback = build_ai_fallback_story_text(&payload, initial); - let story_text = generate_ai_story_text(state, &payload, initial) - .await - .filter(|text| !text.trim().is_empty()) - .unwrap_or(fallback); - - RuntimeStoryAiResponse { - story_text, - options, - encounter: None, - } -} - -pub(super) async fn generate_ai_story_text( - state: &AppState, - payload: &RuntimeStoryAiRequest, - initial: bool, -) -> Option { - let llm_client = state.llm_client()?; - let system_prompt = runtime_story_director_system_prompt(initial); - let user_prompt = build_runtime_story_director_user_prompt(RuntimeStoryTextPromptParams { - world_type: payload.world_type.as_str(), - character: payload.character.clone(), - monsters: Value::Array(payload.monsters.clone()), - history: Value::Array(payload.history.clone()), - choice: Value::String(payload.choice.clone()), - context: payload.context.clone(), - available_options: Value::Array(payload.request_options.available_options.clone()), - }); - let mut request = LlmTextRequest::new(vec![ - LlmMessage::system(system_prompt), - LlmMessage::user(user_prompt), - ]); - request.max_tokens = Some(700); - apply_rpg_web_search(state, &mut request); - - llm_client - .request_text(request) - .await - .ok() - .map(|response| response.content.trim().to_string()) - .filter(|text| !text.is_empty()) -} - -pub(super) async fn generate_action_story_payload( - state: &AppState, - game_state: &Value, - request: &RuntimeStoryActionRequest, - function_id: &str, - action_text: &str, - result_text: &str, - options: &[RuntimeStoryOptionView], - battle: Option<&RuntimeBattlePresentation>, -) -> Option { - let llm_client = state.llm_client()?; - // 动作结算仍由确定性规则完成;LLM 只负责把已结算结果改写为可展示文本,失败时不影响主链。 - if function_id == "npc_chat" || function_id == "story_opening_camp_dialogue" { - return generate_npc_dialogue_payload( - llm_client, - state.config.rpg_llm_web_search_enabled, - game_state, - request, - action_text, - result_text, - options, - ) - .await; - } - - if should_generate_reasoned_combat_story(battle) { - return generate_reasoned_story_payload( - llm_client, - state.config.rpg_llm_web_search_enabled, - game_state, - request, - action_text, - result_text, - options, - battle, - ) - .await; - } - - None -} - -fn apply_rpg_web_search(state: &AppState, request: &mut LlmTextRequest) { - request.enable_web_search = state.config.rpg_llm_web_search_enabled; -} - -pub(super) async fn generate_npc_dialogue_payload( - llm_client: &LlmClient, - enable_web_search: bool, - game_state: &Value, - request: &RuntimeStoryActionRequest, - action_text: &str, - result_text: &str, - deferred_options: &[RuntimeStoryOptionView], -) -> Option { - let world_type = current_world_type(game_state)?; - let character = read_object_field(game_state, "playerCharacter")?.clone(); - let encounter = read_object_field(game_state, "currentEncounter")?; - if read_required_string_field(encounter, "kind").as_deref() != Some("npc") { - return None; - } - let npc_name = read_optional_string_field(encounter, "npcName") - .or_else(|| read_optional_string_field(encounter, "name")) - .unwrap_or_else(|| "对方".to_string()); - let user_prompt = build_runtime_npc_dialogue_user_prompt( - npc_name.as_str(), - RuntimeNpcDialoguePromptParams { - world_type: world_type.as_str(), - character: &character, - encounter, - monsters: read_array_field(game_state, "sceneHostileNpcs") - .into_iter() - .cloned() - .collect::>(), - history: build_action_story_history(game_state, action_text, result_text), - context: build_action_story_prompt_context(game_state, None), - topic: action_text, - result_summary: result_text, - requested_option: request.action.payload.clone().unwrap_or(Value::Null), - available_options: build_action_prompt_options(deferred_options), - }, - ); - let mut llm_request = LlmTextRequest::new(vec![ - LlmMessage::system(runtime_npc_dialogue_system_prompt()), - LlmMessage::user(user_prompt), - ]); - llm_request.max_tokens = Some(700); - llm_request.enable_web_search = enable_web_search; - - let dialogue_text = llm_client - .request_text(llm_request) - .await - .ok() - .map(|response| response.content.trim().to_string()) - .filter(|text| !text.is_empty())?; - let presentation_options = vec![build_continue_adventure_runtime_story_option()]; - let saved_current_story = - build_dialogue_current_story(npc_name.as_str(), dialogue_text.as_str(), deferred_options); - - Some(GeneratedStoryPayload { - story_text: dialogue_text.clone(), - history_result_text: dialogue_text, - presentation_options, - saved_current_story, - }) -} - -pub(super) async fn generate_reasoned_story_payload( - llm_client: &LlmClient, - enable_web_search: bool, - game_state: &Value, - request: &RuntimeStoryActionRequest, - action_text: &str, - result_text: &str, - options: &[RuntimeStoryOptionView], - battle: Option<&RuntimeBattlePresentation>, -) -> Option { - let world_type = current_world_type(game_state)?; - let character = read_object_field(game_state, "playerCharacter")?.clone(); - let user_prompt = build_runtime_reasoned_story_user_prompt(RuntimeReasonedStoryPromptParams { - world_type: world_type.as_str(), - character: &character, - monsters: read_array_field(game_state, "sceneHostileNpcs") - .into_iter() - .cloned() - .collect::>(), - history: build_action_story_history(game_state, action_text, result_text), - context: build_action_story_prompt_context(game_state, battle), - choice: action_text, - result_summary: result_text, - requested_option: request.action.payload.clone().unwrap_or(Value::Null), - available_options: build_action_prompt_options(options), - }); - let mut llm_request = LlmTextRequest::new(vec![ - LlmMessage::system(runtime_reasoned_story_system_prompt()), - LlmMessage::user(user_prompt), - ]); - llm_request.max_tokens = Some(700); - llm_request.enable_web_search = enable_web_search; - - let story_text = llm_client - .request_text(llm_request) - .await - .ok() - .map(|response| response.content.trim().to_string()) - .filter(|text| !text.is_empty())?; - - Some(GeneratedStoryPayload { - story_text: story_text.clone(), - history_result_text: story_text.clone(), - presentation_options: options.to_vec(), - saved_current_story: build_legacy_current_story(story_text.as_str(), options), - }) -} - -pub(super) fn should_generate_reasoned_combat_story( - _battle: Option<&RuntimeBattlePresentation>, -) -> bool { - // 战斗动作、逃跑、胜利、切磋结束与死亡都只走确定性结算,避免战斗链路再次触发剧情推理。 - false -} - -pub(super) fn build_action_story_history( - game_state: &Value, - action_text: &str, - result_text: &str, -) -> Vec { - let mut history = read_array_field(game_state, "storyHistory") - .into_iter() - .filter_map(|entry| { - let text = read_optional_string_field(entry, "text")?; - let history_role = read_optional_string_field(entry, "historyRole") - .unwrap_or_else(|| "result".to_string()); - Some(json!({ - "text": text, - "historyRole": history_role, - })) - }) - .collect::>(); - history.push(json!({ - "text": action_text, - "historyRole": "action", - })); - history.push(json!({ - "text": result_text, - "historyRole": "result", - })); - let keep_from = history.len().saturating_sub(12); - history.into_iter().skip(keep_from).collect() -} - -pub(super) fn build_action_story_prompt_context( - game_state: &Value, - battle: Option<&RuntimeBattlePresentation>, -) -> Value { - let scene_preset = read_object_field(game_state, "currentScenePreset"); - let battle_value = battle - .and_then(|presentation| serde_json::to_value(presentation).ok()) - .unwrap_or(Value::Null); - - json!({ - "sceneName": scene_preset - .and_then(|scene| read_optional_string_field(scene, "name")) - .or_else(|| read_optional_string_field(game_state, "currentScene")) - .unwrap_or_else(|| "当前区域".to_string()), - "sceneDescription": scene_preset - .and_then(|scene| read_optional_string_field(scene, "description")) - .or_else(|| read_optional_string_field(game_state, "sceneDescription")) - .unwrap_or_else(|| "周围气氛仍在继续变化。".to_string()), - "encounterName": read_object_field(game_state, "currentEncounter") - .and_then(|encounter| { - read_optional_string_field(encounter, "npcName") - .or_else(|| read_optional_string_field(encounter, "name")) - }), - "encounterId": current_encounter_id(game_state), - "playerHp": read_i32_field(game_state, "playerHp").unwrap_or(0), - "playerMaxHp": read_i32_field(game_state, "playerMaxHp").unwrap_or(1), - "playerMana": read_i32_field(game_state, "playerMana").unwrap_or(0), - "playerMaxMana": read_i32_field(game_state, "playerMaxMana").unwrap_or(1), - "inBattle": read_bool_field(game_state, "inBattle").unwrap_or(false), - "currentNpcBattleOutcome": read_optional_string_field(game_state, "currentNpcBattleOutcome"), - "battle": battle_value, - }) -} - -pub(super) fn build_action_prompt_options(options: &[RuntimeStoryOptionView]) -> Vec { - options - .iter() - .filter(|option| !option.disabled.unwrap_or(false)) - .map(|option| { - json!({ - "functionId": option.function_id, - "actionText": option.action_text, - "text": option.action_text, - }) - }) - .collect() -} - -pub(super) fn build_ai_response_options(payload: &RuntimeStoryAiRequest) -> Vec { - let source = if payload.request_options.available_options.is_empty() { - &payload.request_options.option_catalog - } else { - &payload.request_options.available_options - }; - let options = source - .iter() - .filter_map(normalize_ai_story_option) - .collect::>(); - if !options.is_empty() { - return options; - } - - vec![ - build_ai_story_option_value("idle_observe_signs", "观察周围迹象"), - build_ai_story_option_value("idle_explore_forward", "继续向前探索"), - build_ai_story_option_value("idle_rest_focus", "原地调息"), - ] -} - -pub(super) fn normalize_ai_story_option(value: &Value) -> Option { - let function_id = read_required_string_field(value, "functionId")?; - let action_text = read_required_string_field(value, "actionText") - .or_else(|| read_required_string_field(value, "text")) - .unwrap_or_else(|| function_id.clone()); - let mut option = value.as_object()?.clone(); - option.insert("functionId".to_string(), Value::String(function_id)); - option.insert("actionText".to_string(), Value::String(action_text.clone())); - option - .entry("text".to_string()) - .or_insert_with(|| Value::String(action_text)); - - Some(Value::Object(option)) -} - -pub(super) fn build_ai_story_option_value(function_id: &str, action_text: &str) -> Value { - json!({ - "functionId": function_id, - "actionText": action_text, - "text": action_text, - "visuals": { - "playerAnimation": "idle", - "playerMoveMeters": 0, - "playerOffsetY": 0, - "playerFacing": "right", - "scrollWorld": false, - "monsterChanges": [] - } - }) -} - -pub(super) fn build_ai_fallback_story_text( - payload: &RuntimeStoryAiRequest, - initial: bool, -) -> String { - let character_name = - read_optional_string_field(&payload.character, "name").unwrap_or_else(|| "你".to_string()); - let scene_name = read_optional_string_field(&payload.context, "sceneName") - .or_else(|| read_optional_string_field(&payload.context, "scene")) - .unwrap_or_else(|| "当前区域".to_string()); - if initial { - return format!( - "{character_name} 在 {scene_name} 稳住脚步,周围的气息正在变化,第一轮选择已经摆到眼前。" - ); - } - - let choice = normalize_required_string(payload.choice.as_str()) - .unwrap_or_else(|| "继续推进".to_string()); - format!("{character_name} 选择了「{choice}」,{scene_name} 的局势随之向下一步展开。") -} diff --git a/server-rs/crates/api-server/src/runtime_story/compat/bootstrap.rs b/server-rs/crates/api-server/src/runtime_story/compat/bootstrap.rs deleted file mode 100644 index c9f7cbb0..00000000 --- a/server-rs/crates/api-server/src/runtime_story/compat/bootstrap.rs +++ /dev/null @@ -1,1101 +0,0 @@ -use super::*; - -const PLAYER_BASE_MAX_HP: i32 = 180; -const DEFAULT_PLAYER_MAX_MANA: i32 = 999; -pub(super) const RESOLVED_ENTITY_X_METERS: f64 = 12.0; - -pub async fn begin_runtime_story_session( - State(state): State, - Extension(request_context): Extension, - Extension(authenticated): Extension, - Json(payload): Json, -) -> Result, Response> { - let actor_user_id = authenticated.claims().user_id().to_string(); - let now = OffsetDateTime::now_utc(); - let now_micros = offset_datetime_to_unix_micros(now); - let session_id = build_runtime_session_id( - actor_user_id.as_str(), - payload.custom_world_profile.as_ref(), - &payload.character, - now_micros, - ); - let game_state = - build_initial_runtime_game_state(&payload, session_id.as_str()).map_err(|message| { - runtime_story_error_response( - &request_context, - AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ - "provider": "runtime-story", - "message": message, - })), - ) - })?; - let snapshot = RuntimeStorySnapshotPayload { - saved_at: Some(format_now_rfc3339()), - bottom_tab: "adventure".to_string(), - game_state, - current_story: None, - }; - let persisted = - persist_runtime_story_snapshot(&state, &request_context, actor_user_id, snapshot).await?; - let persisted_snapshot = runtime_snapshot_payload_from_record(&persisted); - - Ok(json_success_body( - Some(&request_context), - RuntimeStoryBootstrapResponse { - session_id, - server_version: 1, - snapshot: persisted_snapshot, - }, - )) -} - -fn build_runtime_session_id( - actor_user_id: &str, - custom_world_profile: Option<&Value>, - character: &Value, - now_micros: i64, -) -> String { - let profile_id = custom_world_profile - .and_then(|profile| read_optional_string_field(profile, "id")) - .or_else(|| { - custom_world_profile.and_then(|profile| read_optional_string_field(profile, "name")) - }) - .unwrap_or_else(|| "builtin".to_string()); - let character_id = read_optional_string_field(character, "id") - .or_else(|| read_optional_string_field(character, "name")) - .unwrap_or_else(|| "character".to_string()); - - format!( - "runtime-{}-{}-{}-{now_micros}", - sanitize_id_segment(actor_user_id), - sanitize_id_segment(profile_id.as_str()), - sanitize_id_segment(character_id.as_str()) - ) -} - -fn sanitize_id_segment(value: &str) -> String { - let normalized = value - .trim() - .chars() - .filter(|ch| ch.is_ascii_alphanumeric() || *ch == '-' || *ch == '_') - .take(36) - .collect::(); - if normalized.is_empty() { - "unknown".to_string() - } else { - normalized - } -} - -fn build_initial_runtime_game_state( - payload: &RuntimeStoryBootstrapRequest, - session_id: &str, -) -> Result { - let world_type = normalize_required_string(payload.world_type.as_str()) - .ok_or_else(|| "worldType 不能为空".to_string())?; - if world_type == "CUSTOM" && payload.custom_world_profile.is_none() { - return Err("自定义世界开局必须提供 customWorldProfile".to_string()); - } - if !payload.character.is_object() { - return Err("character 必须是 JSON object".to_string()); - } - - let runtime_mode = normalize_runtime_mode(payload.runtime_mode.as_deref()); - let custom_world_profile = payload.custom_world_profile.clone().unwrap_or(Value::Null); - let character = payload.character.clone(); - let initial_scene_preset = - resolve_initial_scene_preset(world_type.as_str(), payload.custom_world_profile.as_ref()); - let initial_encounter = resolve_initial_encounter( - world_type.as_str(), - payload.custom_world_profile.as_ref(), - &character, - initial_scene_preset.as_ref(), - ); - let initial_npc_state = initial_encounter - .as_ref() - .map(build_initial_npc_state_value) - .unwrap_or(Value::Null); - let player_max_hp = resolve_character_max_hp(&character); - let player_max_mana = resolve_character_max_mana(&character); - let initial_inventory = build_initial_player_inventory( - world_type.as_str(), - payload.custom_world_profile.as_ref(), - &character, - ); - let initial_equipment = build_initial_player_equipment( - world_type.as_str(), - payload.custom_world_profile.as_ref(), - &character, - &initial_inventory, - ); - let equipment_bonuses = read_equipment_total_bonuses(&initial_equipment); - let player_max_hp_with_equipment = player_max_hp + equipment_bonuses.max_hp_bonus; - let story_engine_memory = build_opening_story_engine_memory( - payload.custom_world_profile.as_ref(), - &initial_scene_preset, - ); - - let mut npc_states = Map::new(); - if let (Some(encounter), Value::Object(npc_state)) = (&initial_encounter, initial_npc_state) { - let npc_id = read_optional_string_field(encounter, "id") - .unwrap_or_else(|| current_encounter_name(encounter)); - npc_states.insert(npc_id, Value::Object(npc_state)); - } - - let mut game_state = json!({ - "worldType": world_type, - "customWorldProfile": custom_world_profile, - "playerCharacter": character, - "runtimeSessionId": session_id, - "runtimeActionVersion": 1, - "runtimeMode": runtime_mode, - "runtimePersistenceDisabled": payload.disable_persistence.unwrap_or(false), - "runtimeStats": { - "playTimeMs": 0, - "lastPlayTickAt": Value::Null, - "hostileNpcsDefeated": 0, - "questsAccepted": 0, - "itemsUsed": 0, - "scenesTraveled": 0 - }, - "playerProgression": { - "level": 1, - "currentLevelXp": 0, - "totalXp": 0, - "xpToNextLevel": 100, - "pendingLevelUps": 0, - "lastGrantedSource": Value::Null - }, - "currentScene": "Story", - "storyHistory": [], - "storyEngineMemory": story_engine_memory, - "chapterState": Value::Null, - "campaignState": Value::Null, - "activeScenarioPackId": payload.custom_world_profile.as_ref().and_then(|profile| read_optional_string_field(profile, "scenarioPackId")), - "activeCampaignPackId": payload.custom_world_profile.as_ref().and_then(|profile| read_optional_string_field(profile, "campaignPackId")), - "characterChats": {}, - "lastObserveSignsSceneId": Value::Null, - "lastObserveSignsReport": Value::Null, - "animationState": "idle", - "currentEncounter": initial_encounter, - "npcInteractionActive": false, - "currentScenePreset": initial_scene_preset, - "sceneHostileNpcs": [], - "playerX": 0, - "playerOffsetY": 0, - "playerFacing": "right", - "playerActionMode": "idle", - "scrollWorld": false, - "inBattle": false, - "playerHp": player_max_hp_with_equipment, - "playerMaxHp": player_max_hp_with_equipment, - "playerMana": player_max_mana, - "playerMaxMana": player_max_mana, - "playerSkillCooldowns": {}, - "activeBuildBuffs": [], - "activeCombatEffects": [], - "playerCurrency": resolve_initial_player_currency(world_type.as_str(), payload.custom_world_profile.as_ref()), - "playerInventory": initial_inventory, - "playerEquipment": initial_equipment, - "npcStates": npc_states, - "quests": [], - "roster": [], - "companions": [], - "currentBattleNpcId": Value::Null, - "currentNpcBattleMode": Value::Null, - "currentNpcBattleOutcome": Value::Null, - "sparReturnEncounter": Value::Null, - "sparPlayerHpBefore": Value::Null, - "sparPlayerMaxHpBefore": Value::Null, - "sparStoryHistoryBefore": Value::Null - }); - ensure_json_object(&mut game_state).insert( - "playerSkillCooldowns".to_string(), - build_character_skill_cooldowns(&payload.character), - ); - Ok(game_state) -} - -fn normalize_runtime_mode(value: Option<&str>) -> &'static str { - match value.map(str::trim) { - Some("preview") => "preview", - Some("test") => "test", - _ => "play", - } -} - -fn resolve_initial_scene_preset(world_type: &str, profile: Option<&Value>) -> Option { - if world_type == "CUSTOM" { - let profile = profile?; - let scene_id = - resolve_opening_scene_id(profile).unwrap_or_else(|| "custom-scene-camp".to_string()); - return build_custom_scene_preset(profile, scene_id.as_str()); - } - - Some(build_builtin_camp_scene_preset(world_type)) -} - -fn resolve_opening_scene_id(profile: &Value) -> Option { - let opening_chapter = read_array_field(profile, "sceneChapterBlueprints") - .into_iter() - .next()?; - let opening_act = read_array_field(opening_chapter, "acts").into_iter().next(); - [ - opening_act.and_then(|act| read_optional_string_field(act, "sceneId")), - read_optional_string_field(opening_chapter, "sceneId"), - read_array_field(opening_chapter, "linkedLandmarkIds") - .into_iter() - .find_map(Value::as_str) - .map(str::to_string), - ] - .into_iter() - .flatten() - .map(|scene_id| resolve_custom_runtime_scene_id(profile, scene_id.as_str())) - .find(|scene_id| !scene_id.trim().is_empty()) -} - -pub(super) fn resolve_custom_runtime_scene_id(profile: &Value, scene_id: &str) -> String { - let normalized = scene_id.trim(); - if normalized.is_empty() - || normalized == "custom-scene-camp" - || read_object_field(profile, "camp") - .and_then(|camp| read_optional_string_field(camp, "id")) - .as_deref() - == Some(normalized) - { - return "custom-scene-camp".to_string(); - } - - for (index, landmark) in read_array_field(profile, "landmarks") - .into_iter() - .enumerate() - { - if read_optional_string_field(landmark, "id").as_deref() == Some(normalized) { - return format!("custom-scene-landmark-{}", index + 1); - } - } - - normalized.to_string() -} - -fn build_builtin_camp_scene_preset(world_type: &str) -> Value { - let is_xianxia = world_type == "XIANXIA"; - json!({ - "id": if is_xianxia { "xianxia-star-vessel" } else { "wuxia-border-camp" }, - "name": if is_xianxia { "星槎泊台" } else { "边城营地" }, - "description": if is_xianxia { "星槎停泊在云海边缘,远处灵潮微明。" } else { "边城营地炊烟未散,旧路与山影在前方交错。" }, - "imageSrc": "", - "worldType": world_type, - "forwardSceneId": Value::Null, - "connectedSceneIds": [], - "connections": [], - "npcs": [], - "treasureHints": [], - "narrativeResidues": [] - }) -} - -pub(super) fn build_custom_scene_preset(profile: &Value, scene_id: &str) -> Option { - if scene_id == "custom-scene-camp" { - let camp = read_object_field(profile, "camp"); - let name = camp - .and_then(|value| read_optional_string_field(value, "name")) - .unwrap_or_else(|| "开局归处".to_string()); - let description = camp - .and_then(|value| read_optional_string_field(value, "description")) - .unwrap_or_else(|| read_optional_string_field(profile, "summary").unwrap_or_default()); - let connected_scene_ids = read_array_field(profile, "landmarks") - .into_iter() - .take(3) - .enumerate() - .map(|(index, _)| Value::String(format!("custom-scene-landmark-{}", index + 1))) - .collect::>(); - let npcs = build_custom_scene_npcs(profile, scene_id); - return Some(json!({ - "id": "custom-scene-camp", - "name": name, - "description": description, - "imageSrc": camp.and_then(|value| read_optional_string_field(value, "imageSrc")).unwrap_or_default(), - "worldType": "CUSTOM", - "forwardSceneId": connected_scene_ids.first().cloned().unwrap_or(Value::Null), - "connectedSceneIds": connected_scene_ids, - "connections": [], - "npcs": npcs, - "treasureHints": [], - "narrativeResidues": camp.and_then(|value| read_field(value, "narrativeResidues")).cloned().unwrap_or(Value::Array(Vec::new())) - })); - } - - let landmark_index = scene_id - .strip_prefix("custom-scene-landmark-") - .and_then(|value| value.parse::().ok()) - .and_then(|value| value.checked_sub(1)) - .unwrap_or(0); - let landmark = *read_array_field(profile, "landmarks").get(landmark_index)?; - let npcs = build_custom_scene_npcs(profile, scene_id); - Some(json!({ - "id": scene_id, - "name": read_optional_string_field(landmark, "name").unwrap_or_else(|| format!("地标{}", landmark_index + 1)), - "description": read_optional_string_field(landmark, "description").unwrap_or_default(), - "imageSrc": read_optional_string_field(landmark, "imageSrc").unwrap_or_default(), - "worldType": "CUSTOM", - "forwardSceneId": Value::Null, - "connectedSceneIds": ["custom-scene-camp"], - "connections": [], - "npcs": npcs, - "treasureHints": [], - "narrativeResidues": read_field(landmark, "narrativeResidues").cloned().unwrap_or(Value::Array(Vec::new())) - })) -} - -pub(super) fn build_custom_scene_npcs(profile: &Value, scene_id: &str) -> Vec { - let mut npc_ids = Vec::new(); - if scene_id == "custom-scene-camp" { - read_object_field(profile, "camp") - .map(|camp| read_array_field(camp, "sceneNpcIds")) - .unwrap_or_default() - .into_iter() - .filter_map(Value::as_str) - .for_each(|id| push_unique_string(&mut npc_ids, id)); - } else if let Some(landmark_index) = scene_id - .strip_prefix("custom-scene-landmark-") - .and_then(|value| value.parse::().ok()) - .and_then(|value| value.checked_sub(1)) - { - if let Some(landmark) = read_array_field(profile, "landmarks").get(landmark_index) { - read_array_field(landmark, "sceneNpcIds") - .into_iter() - .filter_map(Value::as_str) - .for_each(|id| push_unique_string(&mut npc_ids, id)); - } - } - - collect_scene_act_npc_ids(profile, scene_id) - .into_iter() - .for_each(|id| push_unique_string(&mut npc_ids, id.as_str())); - - npc_ids - .into_iter() - .filter_map(|npc_id| find_custom_world_role_by_reference(profile, npc_id.as_str())) - .map(build_custom_scene_npc) - .collect() -} - -fn collect_scene_act_npc_ids(profile: &Value, scene_id: &str) -> Vec { - let aliases = custom_scene_aliases(profile, scene_id); - let mut npc_ids = Vec::new(); - for chapter in read_array_field(profile, "sceneChapterBlueprints") { - let chapter_scene_ids = [ - read_optional_string_field(chapter, "sceneId"), - Some( - read_array_field(chapter, "linkedLandmarkIds") - .into_iter() - .filter_map(Value::as_str) - .map(str::to_string) - .collect::>() - .join("|"), - ), - ]; - let mut matches_scene = chapter_scene_ids - .into_iter() - .flatten() - .flat_map(|entry| entry.split('|').map(str::to_string).collect::>()) - .any(|id| aliases.contains(&resolve_custom_runtime_scene_id(profile, id.as_str()))); - for act in read_array_field(chapter, "acts") { - if aliases.contains(&resolve_custom_runtime_scene_id( - profile, - read_optional_string_field(act, "sceneId") - .unwrap_or_default() - .as_str(), - )) { - matches_scene = true; - } - if matches_scene { - [ - read_optional_string_field(act, "primaryNpcId"), - read_optional_string_field(act, "oppositeNpcId"), - ] - .into_iter() - .flatten() - .for_each(|id| { - let resolved = resolve_custom_role_id_reference(profile, id.as_str()); - push_unique_string(&mut npc_ids, resolved.as_str()); - }); - read_array_field(act, "encounterNpcIds") - .into_iter() - .filter_map(Value::as_str) - .for_each(|id| { - let resolved = resolve_custom_role_id_reference(profile, id); - push_unique_string(&mut npc_ids, resolved.as_str()); - }); - } - } - } - npc_ids -} - -fn custom_scene_aliases(profile: &Value, scene_id: &str) -> Vec { - let runtime_id = resolve_custom_runtime_scene_id(profile, scene_id); - let mut aliases = vec![runtime_id.clone()]; - if runtime_id == "custom-scene-camp" { - if let Some(camp_id) = read_object_field(profile, "camp") - .and_then(|camp| read_optional_string_field(camp, "id")) - { - aliases.push(camp_id); - } - } - aliases -} - -fn push_unique_string(values: &mut Vec, value: &str) { - let normalized = value.trim(); - if !normalized.is_empty() && !values.iter().any(|entry| entry == normalized) { - values.push(normalized.to_string()); - } -} - -fn build_custom_scene_npc(role: Value) -> Value { - let role_id = read_optional_string_field(&role, "id").unwrap_or_default(); - let name = read_optional_string_field(&role, "name").unwrap_or_else(|| role_id.clone()); - let initial_affinity = read_i32_field(&role, "initialAffinity").unwrap_or(18); - let hostile = initial_affinity < 0; - json!({ - "id": role_id, - "characterId": read_optional_string_field(&role, "id"), - "name": name, - "title": read_optional_string_field(&role, "title"), - "role": read_optional_string_field(&role, "role").unwrap_or_default(), - "avatar": read_optional_string_field(&role, "imageSrc").unwrap_or_else(|| name.chars().next().map(|ch| ch.to_string()).unwrap_or_else(|| "?".to_string())), - "description": read_optional_string_field(&role, "description").unwrap_or_default(), - "gender": "unknown", - "initialAffinity": initial_affinity, - "hostile": hostile, - "recruitable": !hostile, - "functions": if hostile { json!(["fight"]) } else { json!(["trade", "fight", "spar", "help", "chat", "recruit", "gift"]) }, - "backstory": read_optional_string_field(&role, "backstory"), - "personality": read_optional_string_field(&role, "personality"), - "motivation": read_optional_string_field(&role, "motivation"), - "combatStyle": read_optional_string_field(&role, "combatStyle"), - "relationshipHooks": read_field(&role, "relationshipHooks").cloned().unwrap_or(Value::Array(Vec::new())), - "tags": read_field(&role, "tags").cloned().unwrap_or(Value::Array(Vec::new())), - "backstoryReveal": read_field(&role, "backstoryReveal").cloned(), - "skills": read_field(&role, "skills").cloned().unwrap_or(Value::Array(Vec::new())), - "initialItems": read_field(&role, "initialItems").cloned().unwrap_or(Value::Array(Vec::new())), - "imageSrc": read_optional_string_field(&role, "imageSrc"), - "visual": read_field(&role, "visual").cloned(), - "narrativeProfile": read_field(&role, "narrativeProfile").cloned(), - "attributeProfile": read_field(&role, "attributeProfile").cloned() - }) -} - -fn resolve_initial_encounter( - world_type: &str, - profile: Option<&Value>, - character: &Value, - scene_preset: Option<&Value>, -) -> Option { - if world_type == "CUSTOM" { - let profile = profile?; - if let Some(role_id) = resolve_opening_act_encounter_role_id(profile, character) { - if let Some(scene_npc) = scene_preset.and_then(|scene| { - read_array_field(scene, "npcs").into_iter().find(|npc| { - do_role_references_match( - profile, - read_optional_string_field(npc, "id").as_deref(), - Some(role_id.as_str()), - ) - }) - }) { - return Some(build_encounter_from_scene_npc(scene_npc)); - } - return find_custom_world_role_by_reference(profile, role_id.as_str()) - .map(build_opening_encounter_from_custom_role); - } - return None; - } - - scene_preset.and_then(|scene| { - read_array_field(scene, "npcs") - .into_iter() - .find(|npc| { - read_optional_string_field(npc, "characterId") - != read_optional_string_field(character, "id") - }) - .map(build_encounter_from_scene_npc) - }) -} - -fn resolve_opening_act_encounter_role_id(profile: &Value, character: &Value) -> Option { - let opening_chapter = read_array_field(profile, "sceneChapterBlueprints") - .into_iter() - .next()?; - let opening_act = read_array_field(opening_chapter, "acts") - .into_iter() - .next()?; - let references = [ - read_optional_string_field(opening_act, "oppositeNpcId"), - read_optional_string_field(opening_act, "primaryNpcId"), - ] - .into_iter() - .flatten() - .chain( - read_array_field(opening_act, "encounterNpcIds") - .into_iter() - .filter_map(Value::as_str) - .map(str::to_string), - ); - for reference in references { - let role_id = resolve_custom_role_id_reference(profile, reference.as_str()); - if do_role_references_match( - profile, - Some(role_id.as_str()), - read_optional_string_field(character, "id").as_deref(), - ) || do_role_references_match( - profile, - Some(role_id.as_str()), - read_optional_string_field(character, "name").as_deref(), - ) { - continue; - } - if !role_id.trim().is_empty() { - return Some(role_id); - } - } - None -} - -pub(super) fn build_encounter_from_scene_npc(npc: &Value) -> Value { - let name = read_optional_string_field(npc, "name").unwrap_or_else(|| "当前遭遇".to_string()); - json!({ - "id": read_optional_string_field(npc, "id"), - "kind": "npc", - "characterId": read_optional_string_field(npc, "characterId"), - "npcName": name, - "npcDescription": read_optional_string_field(npc, "description").unwrap_or_default(), - "npcAvatar": read_optional_string_field(npc, "avatar").unwrap_or_else(|| name.chars().next().map(|ch| ch.to_string()).unwrap_or_else(|| "?".to_string())), - "context": read_optional_string_field(npc, "role").unwrap_or_default(), - "gender": read_optional_string_field(npc, "gender").unwrap_or_else(|| "unknown".to_string()), - "xMeters": RESOLVED_ENTITY_X_METERS, - "initialAffinity": read_i32_field(npc, "initialAffinity"), - "hostile": read_bool_field(npc, "hostile").unwrap_or(false) || read_i32_field(npc, "initialAffinity").unwrap_or(0) < 0, - "title": read_optional_string_field(npc, "title"), - "backstory": read_optional_string_field(npc, "backstory"), - "personality": read_optional_string_field(npc, "personality"), - "motivation": read_optional_string_field(npc, "motivation"), - "combatStyle": read_optional_string_field(npc, "combatStyle"), - "relationshipHooks": read_field(npc, "relationshipHooks").cloned().unwrap_or(Value::Array(Vec::new())), - "tags": read_field(npc, "tags").cloned().unwrap_or(Value::Array(Vec::new())), - "backstoryReveal": read_field(npc, "backstoryReveal").cloned(), - "skills": read_field(npc, "skills").cloned().unwrap_or(Value::Array(Vec::new())), - "initialItems": read_field(npc, "initialItems").cloned().unwrap_or(Value::Array(Vec::new())), - "imageSrc": read_optional_string_field(npc, "imageSrc"), - "visual": read_field(npc, "visual").cloned(), - "narrativeProfile": read_field(npc, "narrativeProfile").cloned(), - "attributeProfile": read_field(npc, "attributeProfile").cloned() - }) -} - -fn build_opening_encounter_from_custom_role(role: Value) -> Value { - let scene_npc = build_custom_scene_npc(role); - build_encounter_from_scene_npc(&scene_npc) -} - -fn build_initial_npc_state_value(encounter: &Value) -> Value { - let affinity = read_i32_field(encounter, "initialAffinity").unwrap_or_else(|| { - if read_bool_field(encounter, "hostile").unwrap_or(false) { - -40 - } else { - 18 - } - }); - json!({ - "affinity": affinity, - "chattedCount": 0, - "helpUsed": false, - "giftsGiven": 0, - "inventory": [], - "recruited": false, - "relationState": build_runtime_story_relation_state_value(affinity), - "revealedFacts": [], - "knownAttributeRumors": [], - "firstMeaningfulContactResolved": false, - "seenBackstoryChapterIds": [], - "tradeStockSignature": Value::Null, - "stanceProfile": build_runtime_story_stance_profile_value( - affinity, - false, - read_bool_field(encounter, "hostile").unwrap_or(false), - read_optional_string_field(encounter, "context").as_deref(), - None, - ) - }) -} - -fn build_opening_story_engine_memory( - profile: Option<&Value>, - scene_preset: &Option, -) -> Value { - let current_scene_act_state = profile - .and_then(|profile| { - scene_preset.as_ref().and_then(|scene| { - read_optional_string_field(scene, "id").map(|scene_id| (profile, scene_id)) - }) - }) - .and_then(|(profile, scene_id)| { - build_initial_scene_act_runtime_state(profile, scene_id.as_str()) - }); - json!({ - "visibleFacts": [], - "hiddenFacts": [], - "threadStates": {}, - "companionMemory": {}, - "worldMutations": [], - "currentSceneActState": current_scene_act_state - }) -} - -fn build_initial_scene_act_runtime_state(profile: &Value, scene_id: &str) -> Option { - let aliases = custom_scene_aliases(profile, scene_id); - for chapter in read_array_field(profile, "sceneChapterBlueprints") { - let chapter_scene_id = read_optional_string_field(chapter, "sceneId") - .map(|id| resolve_custom_runtime_scene_id(profile, id.as_str())); - let chapter_matches = chapter_scene_id - .as_ref() - .is_some_and(|id| aliases.contains(id)) - || read_array_field(chapter, "linkedLandmarkIds") - .into_iter() - .filter_map(Value::as_str) - .any(|id| aliases.contains(&resolve_custom_runtime_scene_id(profile, id))); - if !chapter_matches { - continue; - } - let Some(first_act) = read_array_field(chapter, "acts").into_iter().next() else { - continue; - }; - return Some(json!({ - "sceneId": read_optional_string_field(chapter, "sceneId").unwrap_or_else(|| scene_id.to_string()), - "chapterId": read_optional_string_field(chapter, "id").unwrap_or_default(), - "currentActId": read_optional_string_field(first_act, "id").unwrap_or_default(), - "currentActIndex": 0, - "completedActIds": [], - "visitedActIds": [read_optional_string_field(first_act, "id").unwrap_or_default()] - })); - } - None -} - -fn build_character_skill_cooldowns(character: &Value) -> Value { - let mut cooldowns = Map::new(); - read_array_field(character, "skills") - .into_iter() - .filter_map(|skill| read_optional_string_field(skill, "id")) - .for_each(|skill_id| { - cooldowns.insert(skill_id, json!(0)); - }); - Value::Object(cooldowns) -} - -fn resolve_character_max_hp(character: &Value) -> i32 { - read_object_field(character, "resourceProfile") - .and_then(|profile| read_i32_field(profile, "maxHp")) - .unwrap_or_else(|| { - let strength = read_object_field(character, "attributes") - .and_then(|attributes| read_i32_field(attributes, "strength")) - .unwrap_or(6); - let spirit = read_object_field(character, "attributes") - .and_then(|attributes| read_i32_field(attributes, "spirit")) - .unwrap_or(4); - PLAYER_BASE_MAX_HP.max(90 + strength * 10 + spirit * 4) - }) -} - -fn resolve_character_max_mana(character: &Value) -> i32 { - read_object_field(character, "resourceProfile") - .and_then(|profile| read_i32_field(profile, "maxMana")) - .unwrap_or(DEFAULT_PLAYER_MAX_MANA) -} - -fn resolve_initial_player_currency(world_type: &str, profile: Option<&Value>) -> i32 { - profile - .and_then(|profile| read_object_field(profile, "ownedSettingLayers")) - .and_then(|layers| read_object_field(layers, "ruleProfile")) - .and_then(|rule| read_object_field(rule, "economyProfile")) - .and_then(|economy| read_i32_field(economy, "initialCurrency")) - .unwrap_or_else(|| if world_type == "XIANXIA" { 140 } else { 160 }) -} - -fn build_initial_player_inventory( - world_type: &str, - profile: Option<&Value>, - character: &Value, -) -> Vec { - let mut items = Vec::new(); - if world_type == "CUSTOM" { - if let Some(profile) = profile { - if let Some(role) = resolve_custom_character_role(profile, character) { - read_array_field(&role, "initialItems") - .into_iter() - .enumerate() - .map(|(index, item)| build_explicit_role_inventory_item(&role, item, index)) - .for_each(|item| merge_inventory_item(&mut items, item)); - } - } - } - - read_array_field(character, "inventory") - .into_iter() - .enumerate() - .map(|(index, item)| normalize_character_inventory_item(character, item, index)) - .for_each(|item| merge_inventory_item(&mut items, item)); - - if items.is_empty() { - items.push(json!({ - "id": format!("starter:{}:supply", read_optional_string_field(character, "id").unwrap_or_else(|| "character".to_string())), - "category": "消耗品", - "name": if world_type == "XIANXIA" { "回灵散" } else { "行囊补给" }, - "quantity": 2, - "rarity": "common", - "tags": ["healing", "supply"], - "description": "开局随身携带的基础补给。" - })); - } - - items -} - -fn build_initial_player_equipment( - world_type: &str, - profile: Option<&Value>, - character: &Value, - inventory: &[Value], -) -> Value { - let mut equipment = json!({ - "weapon": Value::Null, - "armor": Value::Null, - "relic": Value::Null - }); - - for item in inventory { - let Some(slot) = read_optional_string_field(item, "equipmentSlotId") else { - continue; - }; - if ["weapon", "armor", "relic"].contains(&slot.as_str()) - && read_field(&equipment, slot.as_str()).is_some_and(Value::is_null) - { - write_field(&mut equipment, slot.as_str(), item.clone()); - } - } - - for (slot, label) in [("weapon", "武器"), ("armor", "护甲"), ("relic", "饰品")] { - if read_field(&equipment, slot).is_some_and(Value::is_null) { - write_field( - &mut equipment, - slot, - build_fallback_equipment_item(world_type, profile, character, slot, label), - ); - } - } - - equipment -} - -fn build_fallback_equipment_item( - world_type: &str, - _profile: Option<&Value>, - character: &Value, - slot: &str, - label: &str, -) -> Value { - let character_id = - read_optional_string_field(character, "id").unwrap_or_else(|| "character".to_string()); - let character_name = - read_optional_string_field(character, "name").unwrap_or_else(|| "旅人".to_string()); - let name = match (world_type, slot) { - ("XIANXIA", "weapon") => format!("{character_name}的灵刃"), - ("XIANXIA", "armor") => format!("{character_name}的护行法衣"), - ("XIANXIA", "relic") => format!("{character_name}的行旅护符"), - (_, "weapon") => format!("{character_name}的短刃"), - (_, "armor") => format!("{character_name}的护行短甲"), - _ => format!("{character_name}的旧信物"), - }; - json!({ - "id": format!("starter:{character_id}:{slot}"), - "category": label, - "name": name, - "quantity": 1, - "rarity": "common", - "tags": [slot], - "equipmentSlotId": slot - }) -} - -fn normalize_character_inventory_item(character: &Value, item: &Value, index: usize) -> Value { - let category = - read_optional_string_field(item, "category").unwrap_or_else(|| "消耗品".to_string()); - json!({ - "id": read_optional_string_field(item, "id").unwrap_or_else(|| format!("starter:{}:inventory:{}", read_optional_string_field(character, "id").unwrap_or_else(|| "character".to_string()), index + 1)), - "category": category, - "name": read_optional_string_field(item, "name").or_else(|| read_optional_string_field(item, "item")).unwrap_or_else(|| "随身物品".to_string()), - "quantity": read_i32_field(item, "quantity").unwrap_or(1).max(1), - "rarity": read_optional_string_field(item, "rarity").unwrap_or_else(|| "common".to_string()), - "tags": read_field(item, "tags").cloned().unwrap_or(Value::Array(Vec::new())), - "description": read_optional_string_field(item, "description"), - "equipmentSlotId": infer_explicit_starter_slot(category.as_str()) - }) -} - -fn build_explicit_role_inventory_item(role: &Value, item: &Value, index: usize) -> Value { - let category = normalize_explicit_starter_category( - read_optional_string_field(item, "category") - .unwrap_or_else(|| "专属物品".to_string()) - .as_str(), - ); - let role_id = read_optional_string_field(role, "id").unwrap_or_else(|| "role".to_string()); - let role_name = read_optional_string_field(role, "name").unwrap_or_else(|| "角色".to_string()); - json!({ - "id": format!("custom-role-item:{role_id}:{}", index + 1), - "category": category, - "name": read_optional_string_field(item, "name").unwrap_or_else(|| "初始物品".to_string()), - "quantity": read_i32_field(item, "quantity").unwrap_or(1).max(1), - "rarity": read_optional_string_field(item, "rarity").unwrap_or_else(|| "common".to_string()), - "tags": read_field(item, "tags").cloned().unwrap_or(Value::Array(Vec::new())), - "description": read_optional_string_field(item, "description"), - "equipmentSlotId": infer_explicit_starter_slot(category.as_str()), - "runtimeMetadata": { - "origin": "ai_compiled", - "generationChannel": "discovery", - "seedKey": format!("{role_id}:{}", index + 1), - "relationAnchor": { - "type": "npc", - "npcId": role_id, - "npcName": role_name, - "roleText": read_optional_string_field(role, "role").unwrap_or_default() - }, - "sourceReason": format!("{role_name}在自定义世界开局时自带的初始物品。") - } - }) -} - -fn normalize_explicit_starter_category(category: &str) -> String { - let normalized = category.trim(); - if normalized == "专属物" { - "专属物品".to_string() - } else { - normalized.to_string() - } -} - -fn infer_explicit_starter_slot(category: &str) -> Value { - match normalize_explicit_starter_category(category).as_str() { - "武器" => json!("weapon"), - "护甲" => json!("armor"), - "饰品" | "稀有品" | "专属物品" => json!("relic"), - _ => Value::Null, - } -} - -fn merge_inventory_item(items: &mut Vec, item: Value) { - let key = format!( - "{}:{}", - read_optional_string_field(&item, "category").unwrap_or_default(), - read_optional_string_field(&item, "name").unwrap_or_default() - ); - if items.iter().any(|entry| { - format!( - "{}:{}", - read_optional_string_field(entry, "category").unwrap_or_default(), - read_optional_string_field(entry, "name").unwrap_or_default() - ) == key - }) { - return; - } - items.push(item); -} - -fn resolve_custom_character_role(profile: &Value, character: &Value) -> Option { - read_optional_string_field(character, "id") - .and_then(|id| find_custom_world_role_by_reference(profile, id.as_str())) - .or_else(|| { - read_optional_string_field(character, "name") - .and_then(|name| find_custom_world_role_by_reference(profile, name.as_str())) - }) -} - -fn find_custom_world_role_by_reference(profile: &Value, reference: &str) -> Option { - let normalized_reference = normalize_role_reference(reference); - if normalized_reference.is_empty() { - return None; - } - read_array_field(profile, "storyNpcs") - .into_iter() - .chain(read_array_field(profile, "playableNpcs")) - .find(|role| role_reference_aliases(role).contains(&normalized_reference)) - .cloned() -} - -fn resolve_custom_role_id_reference(profile: &Value, reference: &str) -> String { - find_custom_world_role_by_reference(profile, reference) - .and_then(|role| read_optional_string_field(&role, "id")) - .unwrap_or_else(|| reference.trim().to_string()) -} - -fn do_role_references_match(profile: &Value, left: Option<&str>, right: Option<&str>) -> bool { - let left = left.map(|value| resolve_custom_role_id_reference(profile, value)); - let right = right.map(|value| resolve_custom_role_id_reference(profile, value)); - matches!((left, right), (Some(left), Some(right)) if !left.is_empty() && left == right) -} - -fn role_reference_aliases(role: &Value) -> Vec { - let name = read_optional_string_field(role, "name").unwrap_or_default(); - let title = read_optional_string_field(role, "title").unwrap_or_default(); - let role_text = read_optional_string_field(role, "role").unwrap_or_default(); - [ - read_optional_string_field(role, "id").unwrap_or_default(), - name.clone(), - title.clone(), - format!("{name}{title}"), - format!("{title}{name}"), - format!("{role_text}{name}"), - format!("{name}{role_text}"), - ] - .into_iter() - .map(|value| normalize_role_reference(value.as_str())) - .filter(|value| !value.is_empty()) - .collect() -} - -fn normalize_role_reference(value: &str) -> String { - value - .trim() - .replace("character-npc-", "") - .replace("character-npc:", "") - .replace("playable-", "") - .replace("story-", "") - .replace("role-", "") - .replace("npc-", "") - .replace([' ', '(', ')', '(', ')'], "") -} - -fn read_equipment_total_bonuses(equipment: &Value) -> EquipmentBonusSummary { - let mut summary = EquipmentBonusSummary::default(); - for slot in ["weapon", "armor", "relic"] { - let Some(item) = read_field(equipment, slot) else { - continue; - }; - let Some(item_object) = item.as_object() else { - continue; - }; - if let Some(stat_profile) = item_object.get("statProfile") { - summary.max_hp_bonus += read_i32_field(stat_profile, "maxHpBonus").unwrap_or(0); - } else if slot == "armor" { - summary.max_hp_bonus += 14; - } - } - summary -} - -#[derive(Default)] -struct EquipmentBonusSummary { - max_hp_bonus: i32, -} - -fn write_field(target: &mut Value, key: &str, value: Value) { - let object = ensure_json_object(target); - object.insert(key.to_string(), value); -} - -#[cfg(test)] -mod bootstrap_tests { - use super::*; - - #[test] - fn custom_world_bootstrap_builds_opening_act_state_on_server() { - let payload = RuntimeStoryBootstrapRequest { - world_type: "CUSTOM".to_string(), - runtime_mode: Some("play".to_string()), - disable_persistence: Some(true), - character: json!({ - "id": "player-1", - "name": "沈砺", - "resourceProfile": { "maxHp": 188, "maxMana": 999 }, - "skills": [{ "id": "skill-1" }] - }), - custom_world_profile: Some(json!({ - "id": "profile-1", - "name": "回潮群岛", - "summary": "潮雾里有旧账。", - "camp": { - "id": "camp-1", - "name": "回潮暂栖所", - "description": "一间靠海的暂栖所。", - "sceneNpcIds": ["story-act-only"] - }, - "landmarks": [], - "playableNpcs": [{ - "id": "player-1", - "name": "沈砺", - "role": "主角", - "initialItems": [{ - "name": "旧潮短刃", - "category": "武器", - "quantity": 1, - "rarity": "rare", - "tags": ["weapon"], - "description": "旧账留下的短刃。" - }] - }], - "storyNpcs": [{ - "id": "story-act-only", - "name": "陆衡", - "title": "账房", - "role": "守账人", - "description": "守着账本的人。", - "backstory": "", - "personality": "", - "motivation": "", - "combatStyle": "", - "initialAffinity": 12, - "relationshipHooks": [], - "tags": [], - "initialItems": [], - "skills": [], - "backstoryReveal": { "publicSummary": "", "chapters": [] } - }], - "sceneChapterBlueprints": [{ - "id": "chapter-1", - "sceneId": "camp-1", - "title": "开局", - "linkedLandmarkIds": [], - "acts": [{ - "id": "act-1", - "sceneId": "camp-1", - "title": "对账", - "primaryNpcId": "story-primary-only", - "oppositeNpcId": "character-npc-story-act-only", - "encounterNpcIds": [] - }] - }] - })), - }; - - let state = build_initial_runtime_game_state(&payload, "runtime-test") - .expect("bootstrap should build state"); - - assert_eq!(state["runtimeSessionId"], json!("runtime-test")); - assert_eq!(state["currentScene"], json!("Story")); - assert_eq!( - state["currentScenePreset"]["id"], - json!("custom-scene-camp") - ); - assert_eq!( - state["storyEngineMemory"]["currentSceneActState"]["currentActId"], - json!("act-1") - ); - assert_eq!(state["currentEncounter"]["id"], json!("story-act-only")); - assert_eq!(state["playerInventory"][0]["name"], json!("旧潮短刃")); - assert_eq!( - state["playerEquipment"]["weapon"]["name"], - json!("旧潮短刃") - ); - } -} diff --git a/server-rs/crates/api-server/src/runtime_story/compat/equipment_actions.rs b/server-rs/crates/api-server/src/runtime_story/compat/equipment_actions.rs deleted file mode 100644 index 9af4b622..00000000 --- a/server-rs/crates/api-server/src/runtime_story/compat/equipment_actions.rs +++ /dev/null @@ -1,106 +0,0 @@ -use super::*; - -/// 对齐 Node 旧 inventory compat,先按装备位把物品从背包切到 playerEquipment, -/// 再把基础面板属性回算到快照上。 -pub(super) fn resolve_equipment_equip_action( - game_state: &mut Value, - request: &RuntimeStoryActionRequest, -) -> Result { - if read_field(game_state, "playerCharacter").is_none() { - return Err("缺少玩家角色,无法调整装备。".to_string()); - } - if read_bool_field(game_state, "inBattle").unwrap_or(false) { - return Err("战斗中无法调整装备。".to_string()); - } - let item_id = request - .action - .payload - .as_ref() - .and_then(|payload| read_optional_string_field(payload, "itemId")) - .or_else(|| request.action.target_id.clone()) - .ok_or_else(|| "equipment_equip 缺少 itemId".to_string())?; - let item = find_player_inventory_entry(game_state, item_id.as_str()) - .cloned() - .ok_or_else(|| "背包里没有这件装备。".to_string())?; - let slot_id = resolve_equipment_slot_for_item(&item) - .ok_or_else(|| format!("{} 不是可装备物品。", read_inventory_item_name(&item)))?; - let previous_equipment = read_player_equipment_item(game_state, slot_id); - let next_equipment_item = normalize_equipped_item(&item); - - remove_player_inventory_item(game_state, item_id.as_str(), 1); - if let Some(previous_equipment) = previous_equipment.as_ref() { - add_player_inventory_items(game_state, vec![previous_equipment.clone()]); - } - write_player_equipment_item(game_state, slot_id, Some(next_equipment_item)); - apply_equipment_loadout_to_state(game_state); - - let item_name = read_inventory_item_name(&item); - let result_text = if let Some(previous_equipment) = previous_equipment.as_ref() { - format!( - "你将{}从{}位上换下,改为装备{}。", - read_inventory_item_name(previous_equipment), - equipment_slot_label(slot_id), - item_name - ) - } else { - format!( - "你将{}装备在{}位上。", - item_name, - equipment_slot_label(slot_id) - ) - }; - - Ok(StoryResolution { - action_text: resolve_action_text(&format!("装备{}", item_name), request), - result_text, - story_text: None, - presentation_options: None, - saved_current_story: None, - patches: Vec::new(), - battle: None, - toast: Some(build_current_build_toast(game_state)), - }) -} - -pub(super) fn resolve_equipment_unequip_action( - game_state: &mut Value, - request: &RuntimeStoryActionRequest, -) -> Result { - ensure_inventory_action_available( - game_state, - "缺少玩家角色,无法卸下装备。", - "战斗中无法卸下装备。", - )?; - let slot_id = request - .action - .payload - .as_ref() - .and_then(|payload| read_optional_string_field(payload, "slotId")) - .or_else(|| request.action.target_id.clone()) - .ok_or_else(|| "equipment_unequip 缺少合法 slotId".to_string())?; - let slot_id = normalize_equipment_slot_id(slot_id.as_str()) - .ok_or_else(|| "equipment_unequip 缺少合法 slotId".to_string())?; - let equipped_item = read_player_equipment_item(game_state, slot_id) - .ok_or_else(|| format!("{}位当前没有装备。", equipment_slot_label(slot_id)))?; - - write_player_equipment_item(game_state, slot_id, None); - add_player_inventory_items(game_state, vec![equipped_item.clone()]); - apply_equipment_loadout_to_state(game_state); - - Ok(StoryResolution { - action_text: resolve_action_text( - &format!("卸下{}", read_inventory_item_name(&equipped_item)), - request, - ), - result_text: format!( - "你卸下了{},暂时收回背包。", - read_inventory_item_name(&equipped_item) - ), - story_text: None, - presentation_options: None, - saved_current_story: None, - patches: Vec::new(), - battle: None, - toast: Some(build_current_build_toast(game_state)), - }) -} diff --git a/server-rs/crates/api-server/src/runtime_story/compat/game_state.rs b/server-rs/crates/api-server/src/runtime_story/compat/game_state.rs deleted file mode 100644 index c65eafcc..00000000 --- a/server-rs/crates/api-server/src/runtime_story/compat/game_state.rs +++ /dev/null @@ -1,699 +0,0 @@ -use super::*; -use module_runtime_story_compat::{build_runtime_equipment_item, build_runtime_material_item}; - -pub(super) fn current_npc_trade_context(game_state: &Value) -> Result<(String, String), String> { - let encounter = read_object_field(game_state, "currentEncounter") - .ok_or_else(|| "当前不在可结算的 NPC 交互态,无法执行交易或赠礼。".to_string())?; - let kind = read_required_string_field(encounter, "kind") - .ok_or_else(|| "当前不在可结算的 NPC 交互态,无法执行交易或赠礼。".to_string())?; - if kind != "npc" { - return Err("当前不在可结算的 NPC 交互态,无法执行交易或赠礼。".to_string()); - } - let npc_name = current_encounter_name(game_state); - let npc_id = current_encounter_id(game_state).unwrap_or_else(|| npc_name.clone()); - if resolve_current_encounter_npc_state(game_state, npc_id.as_str(), npc_name.as_str()).is_none() - { - return Err("当前 NPC 状态不存在,无法继续结算。".to_string()); - } - Ok((npc_id, npc_name)) -} - -pub(super) fn current_npc_inventory_items<'a>(game_state: &'a Value) -> Vec<&'a Value> { - let Some(npc_id) = current_encounter_id(game_state) else { - return Vec::new(); - }; - let npc_name = current_encounter_name(game_state); - resolve_current_encounter_npc_state(game_state, npc_id.as_str(), npc_name.as_str()) - .map(|state| read_array_field(state, "inventory")) - .unwrap_or_default() -} - -/// 兼容桥沿用 Node 旧域的入口预处理:在读取选项或结算动作前, -/// 先确保当前 NPC 的持久状态最少可用,避免空快照直接打断交易/赠礼/委托主链。 -pub(super) fn ensure_runtime_story_bridge_state(game_state: &mut Value) { - ensure_current_encounter_npc_state_initialized(game_state); -} - -/// 这里不尝试一次性重建完整真相态,只补 compat bridge 当前确实依赖的字段, -/// 并为“纯商贩型 NPC”补一份确定性 trade stock,保证旧前端菜单不因空状态掉链子。 -pub(super) fn ensure_current_encounter_npc_state_initialized(game_state: &mut Value) { - let Some(encounter) = read_object_field(game_state, "currentEncounter").cloned() else { - return; - }; - if read_optional_string_field(&encounter, "kind").as_deref() != Some("npc") { - return; - } - - let npc_name = read_optional_string_field(&encounter, "npcName") - .or_else(|| read_optional_string_field(&encounter, "name")) - .unwrap_or_else(|| "当前遭遇".to_string()); - let npc_id = read_optional_string_field(&encounter, "id").unwrap_or_else(|| npc_name.clone()); - let storage_key = resolve_npc_state_storage_key(game_state, npc_id.as_str(), npc_name.as_str()); - let existing_state = read_field(game_state, "npcStates") - .and_then(|states| read_field(states, storage_key.as_str())) - .cloned(); - - let affinity = existing_state - .as_ref() - .and_then(|state| read_i32_field(state, "affinity")) - .unwrap_or_else(|| default_current_npc_affinity(&encounter)); - let recruited = existing_state - .as_ref() - .and_then(|state| read_bool_field(state, "recruited")) - .unwrap_or(false); - let chatted_count = existing_state - .as_ref() - .and_then(|state| read_i32_field(state, "chattedCount")) - .unwrap_or(0) - .max(0); - let gifts_given = existing_state - .as_ref() - .and_then(|state| read_i32_field(state, "giftsGiven")) - .unwrap_or(0) - .max(0); - let help_used = existing_state - .as_ref() - .and_then(|state| read_bool_field(state, "helpUsed")) - .unwrap_or(false); - let first_meaningful_contact_resolved = existing_state - .as_ref() - .and_then(|state| read_bool_field(state, "firstMeaningfulContactResolved")) - .unwrap_or(false); - let revealed_facts = existing_state - .as_ref() - .map(|state| read_string_list_field(state, "revealedFacts")) - .unwrap_or_default(); - let known_attribute_rumors = existing_state - .as_ref() - .map(|state| read_string_list_field(state, "knownAttributeRumors")) - .unwrap_or_default(); - let seen_backstory_chapter_ids = existing_state - .as_ref() - .map(|state| read_string_list_field(state, "seenBackstoryChapterIds")) - .unwrap_or_default(); - let existing_inventory = existing_state - .as_ref() - .map(|state| { - read_array_field(state, "inventory") - .into_iter() - .cloned() - .collect::>() - }) - .unwrap_or_default(); - let existing_trade_stock_signature = existing_state - .as_ref() - .and_then(|state| read_optional_string_field(state, "tradeStockSignature")); - let hostile = read_bool_field(&encounter, "hostile").unwrap_or(false) - || read_optional_string_field(&encounter, "monsterPresetId").is_some() - || affinity < 0; - let context_text = read_optional_string_field(&encounter, "context"); - - let (inventory, trade_stock_signature) = if is_trade_driven_role_npc(&encounter) { - let next_signature = build_current_npc_trade_stock_signature(game_state, npc_id.as_str()); - if existing_trade_stock_signature.as_deref() == Some(next_signature.as_str()) { - (existing_inventory, Some(next_signature)) - } else { - ( - sync_bootstrapped_trade_inventory( - game_state, - npc_id.as_str(), - npc_name.as_str(), - existing_inventory, - next_signature.as_str(), - ), - Some(next_signature), - ) - } - } else { - (existing_inventory, existing_trade_stock_signature) - }; - - let relation_state = build_runtime_story_relation_state_value(affinity); - let stance_profile = build_runtime_story_stance_profile_value( - affinity, - recruited, - hostile, - context_text.as_deref(), - existing_state - .as_ref() - .and_then(|state| read_field(state, "stanceProfile")) - .and_then(Value::as_object), - ); - let npc_state = json!({ - "affinity": affinity, - "chattedCount": chatted_count, - "helpUsed": help_used, - "giftsGiven": gifts_given, - "inventory": inventory, - "recruited": recruited, - "relationState": relation_state, - "revealedFacts": revealed_facts, - "knownAttributeRumors": known_attribute_rumors, - "firstMeaningfulContactResolved": first_meaningful_contact_resolved, - "seenBackstoryChapterIds": seen_backstory_chapter_ids, - "tradeStockSignature": trade_stock_signature, - "stanceProfile": stance_profile, - }); - - let root = ensure_json_object(game_state); - let npc_states = root - .entry("npcStates".to_string()) - .or_insert_with(|| Value::Object(Map::new())); - if !npc_states.is_object() { - *npc_states = Value::Object(Map::new()); - } - npc_states - .as_object_mut() - .expect("npcStates should be object") - .insert(storage_key, npc_state); -} - -pub(super) fn resolve_npc_state_storage_key( - game_state: &Value, - npc_id: &str, - npc_name: &str, -) -> String { - read_object_field(game_state, "npcStates") - .and_then(Value::as_object) - .and_then(|states| { - if states.contains_key(npc_id) { - Some(npc_id.to_string()) - } else if states.contains_key(npc_name) { - Some(npc_name.to_string()) - } else { - None - } - }) - .unwrap_or_else(|| npc_id.to_string()) -} - -pub(super) fn default_current_npc_affinity(encounter: &Value) -> i32 { - read_i32_field(encounter, "initialAffinity").unwrap_or_else(|| { - if read_optional_string_field(encounter, "monsterPresetId").is_some() { - -40 - } else if read_optional_string_field(encounter, "characterId").is_some() { - 18 - } else { - 6 - } - }) -} - -pub(super) fn read_string_list_field(value: &Value, key: &str) -> Vec { - let mut items = read_array_field(value, key) - .into_iter() - .filter_map(Value::as_str) - .map(str::trim) - .filter(|entry| !entry.is_empty()) - .map(str::to_string) - .collect::>(); - if items.len() > 3 { - items = items.split_off(items.len() - 3); - } - items -} - -pub(super) fn build_runtime_story_relation_state_value(affinity: i32) -> Value { - let relation_state = build_module_npc_relation_state(affinity); - json!({ - "affinity": relation_state.affinity, - "stance": npc_relation_stance_key(relation_state.stance), - }) -} - -pub(super) fn npc_relation_stance_key(value: NpcRelationStance) -> &'static str { - match value { - NpcRelationStance::Hostile => "hostile", - NpcRelationStance::Guarded => "guarded", - NpcRelationStance::Neutral => "neutral", - NpcRelationStance::Cooperative => "cooperative", - NpcRelationStance::Bonded => "bonded", - } -} - -pub(super) fn build_runtime_story_stance_profile_value( - affinity: i32, - recruited: bool, - hostile: bool, - role_text: Option<&str>, - existing_profile: Option<&Map>, -) -> Value { - let base = build_module_npc_initial_stance_profile(affinity, recruited, hostile, role_text); - let read_metric = |key: &str, fallback: u8| -> i32 { - existing_profile - .and_then(|profile| profile.get(key)) - .and_then(Value::as_i64) - .and_then(|value| i32::try_from(value).ok()) - .unwrap_or(i32::from(fallback)) - .clamp(0, 100) - }; - let recent_approvals = existing_profile - .and_then(|profile| profile.get("recentApprovals")) - .map(|value| read_string_list_field(value, "")) - .unwrap_or_else(|| base.recent_approvals.clone()); - let recent_disapprovals = existing_profile - .and_then(|profile| profile.get("recentDisapprovals")) - .map(|value| read_string_list_field(value, "")) - .unwrap_or_else(|| base.recent_disapprovals.clone()); - - json!({ - "trust": read_metric("trust", base.trust), - "warmth": read_metric("warmth", base.warmth), - "ideologicalFit": read_metric("ideologicalFit", base.ideological_fit), - "fearOrGuard": read_metric("fearOrGuard", base.fear_or_guard), - "loyalty": read_metric("loyalty", base.loyalty), - "currentConflictTag": existing_profile - .and_then(|profile| profile.get("currentConflictTag")) - .and_then(Value::as_str) - .map(str::to_string) - .or(base.current_conflict_tag), - "recentApprovals": recent_approvals, - "recentDisapprovals": recent_disapprovals, - }) -} - -pub(super) fn is_trade_driven_role_npc(encounter: &Value) -> bool { - read_optional_string_field(encounter, "characterId").is_none() - && read_optional_string_field(encounter, "monsterPresetId").is_none() -} - -pub(super) fn build_current_npc_trade_stock_signature(game_state: &Value, npc_id: &str) -> String { - let scene_key = read_object_field(game_state, "currentScenePreset") - .and_then(|preset| { - read_optional_string_field(preset, "id") - .or_else(|| read_optional_string_field(preset, "name")) - }) - .or_else(|| read_optional_string_field(game_state, "currentScene")) - .unwrap_or_else(|| "scene".to_string()); - let world_key = current_world_type(game_state).unwrap_or_else(|| "world".to_string()); - format!( - "{}:{}:{}", - sanitize_trade_stock_fragment(npc_id), - sanitize_trade_stock_fragment(scene_key.as_str()), - sanitize_trade_stock_fragment(world_key.as_str()) - ) -} - -pub(super) fn sanitize_trade_stock_fragment(value: &str) -> String { - let normalized = value - .trim() - .chars() - .map(|ch| match ch { - ':' | '/' | '\\' | ' ' => '-', - _ => ch, - }) - .collect::(); - if normalized.is_empty() { - "unknown".to_string() - } else { - normalized - } -} - -pub(super) fn sync_bootstrapped_trade_inventory( - game_state: &Value, - npc_id: &str, - npc_name: &str, - existing_inventory: Vec, - trade_stock_signature: &str, -) -> Vec { - let preserved_inventory = existing_inventory - .into_iter() - .filter(|item| { - read_field(item, "runtimeMetadata") - .and_then(|metadata| read_optional_string_field(metadata, "generationChannel")) - .as_deref() - != Some("npc_trade") - }) - .collect::>(); - let mut next_inventory = preserved_inventory; - next_inventory.extend(build_bootstrapped_trade_inventory( - game_state, - npc_id, - npc_name, - trade_stock_signature, - )); - next_inventory -} - -pub(super) fn build_bootstrapped_trade_inventory( - game_state: &Value, - npc_id: &str, - npc_name: &str, - trade_stock_signature: &str, -) -> Vec { - let world_type = current_world_type(game_state); - let consumable_name = if world_type.as_deref() == Some("XIANXIA") { - "回灵散" - } else { - "回气散" - }; - let material_name = if world_type.as_deref() == Some("XIANXIA") { - "凝光纱" - } else { - "工巧残材" - }; - let relic_name = if world_type.as_deref() == Some("XIANXIA") { - "行旅护符" - } else { - "结绳护符" - }; - let armor_name = if world_type.as_deref() == Some("XIANXIA") { - "护行法衣" - } else { - "护行短甲" - }; - let tonic_id = format!("npc-trade:{trade_stock_signature}:tonic"); - let material_id = format!("npc-trade:{trade_stock_signature}:material"); - let relic_id = format!("npc-trade:{trade_stock_signature}:relic"); - let armor_id = format!("npc-trade:{trade_stock_signature}:armor"); - - vec![ - build_bootstrapped_trade_consumable_item( - tonic_id.as_str(), - consumable_name, - npc_name, - world_type.as_deref(), - ), - attach_generated_trade_metadata( - build_runtime_material_item( - game_state, - material_name, - 2, - &["工巧", "补给"], - "uncommon", - ), - material_id.as_str(), - "npc_trade", - format!("{npc_id}:material").as_str(), - format!("{npc_name}整理出来的可交易工坊材料。").as_str(), - ), - attach_generated_trade_metadata( - build_runtime_equipment_item( - game_state, - relic_name, - "relic", - "rare", - "适合长途行路时稳住灵力与节奏的护符。", - "护持", - &["护持", "法力"], - &["护持", "法力"], - json!({ - "maxManaBonus": 12, - "outgoingDamageBonus": 0.05 - }), - ), - relic_id.as_str(), - "npc_trade", - format!("{npc_id}:relic").as_str(), - format!("{npc_name}随身携带的护身小物。").as_str(), - ), - attach_generated_trade_metadata( - build_runtime_equipment_item( - game_state, - armor_name, - "armor", - "rare", - "为行路与近身护体准备的轻装护具。", - "守御", - &["守御", "护体"], - &["守御", "护体"], - json!({ - "maxHpBonus": 18, - "incomingDamageMultiplier": 0.93 - }), - ), - armor_id.as_str(), - "npc_trade", - format!("{npc_id}:armor").as_str(), - format!("{npc_name}压箱底留下的一件护身装备。").as_str(), - ), - ] -} - -pub(super) fn build_bootstrapped_trade_consumable_item( - item_id: &str, - name: &str, - npc_name: &str, - world_type: Option<&str>, -) -> Value { - json!({ - "id": item_id, - "category": "消耗品", - "name": name, - "description": format!("{npc_name}常备的一份行路补给。"), - "quantity": 2, - "rarity": "uncommon", - "tags": if world_type == Some("XIANXIA") { - vec!["mana", "support", "trade"] - } else { - vec!["mana", "support", "trade"] - }, - "useProfile": { - "hpRestore": 0, - "manaRestore": 10, - "cooldownReduction": 0, - "buildBuffs": [] - }, - "runtimeMetadata": { - "origin": "procedural", - "generationChannel": "npc_trade", - "seedKey": format!("{item_id}:seed"), - "sourceReason": format!("{npc_name}把最常用的补给拿出来做成了交易库存。"), - "storyFingerprint": { - "relatedScarIds": [format!("scar:npc_trade:{item_id}")], - "relatedThreadIds": [], - "visibleClue": format!("{npc_name}随身药囊里最顺手的一味补给。"), - "witnessMark": "药包封口处还留着反复拆开的折痕。", - "unresolvedQuestion": "这份补给之前究竟替谁留着。" - } - } - }) -} - -pub(super) fn attach_generated_trade_metadata( - mut item: Value, - item_id: &str, - generation_channel: &str, - seed_key: &str, - source_reason: &str, -) -> Value { - let item_name = read_inventory_item_name(&item); - let entry = ensure_json_object(&mut item); - entry.insert("id".to_string(), Value::String(item_id.to_string())); - entry.insert( - "runtimeMetadata".to_string(), - json!({ - "origin": "procedural", - "generationChannel": generation_channel, - "seedKey": seed_key, - "sourceReason": source_reason, - "storyFingerprint": { - "relatedScarIds": [format!("scar:{generation_channel}:{seed_key}")], - "relatedThreadIds": [], - "visibleClue": format!("{item_name}上保留着反复流转留下的使用痕迹。"), - "witnessMark": "表面仍残留旧主人长期携带的磨损。", - "unresolvedQuestion": format!("{item_name}最初为什么会落到这名 NPC 手里。"), - } - }), - ); - item -} - -pub(super) fn read_current_npc_inventory_item<'a>( - game_state: &'a Value, - item_id: &str, -) -> Option<&'a Value> { - current_npc_inventory_items(game_state) - .into_iter() - .find(|item| read_optional_string_field(item, "id").as_deref() == Some(item_id)) -} - -pub(super) fn adjust_current_npc_affinity( - game_state: &mut Value, - delta: i32, -) -> Option<(String, i32, i32)> { - let npc_id = current_encounter_id(game_state)?; - let npc_name = current_encounter_name(game_state); - let state = ensure_npc_state_object(game_state, npc_id.as_str(), npc_name.as_str()); - let previous_affinity = state - .get("affinity") - .and_then(Value::as_i64) - .and_then(|value| i32::try_from(value).ok()) - .unwrap_or(0); - let next_affinity = (previous_affinity + delta).clamp(-100, 100); - state.insert("affinity".to_string(), json!(next_affinity)); - state - .entry("recruited".to_string()) - .or_insert(Value::Bool(false)); - - Some((npc_id, previous_affinity, next_affinity)) -} - -pub(super) fn read_current_npc_state_i32_field(game_state: &Value, key: &str) -> Option { - let npc_id = current_encounter_id(game_state)?; - let npc_name = current_encounter_name(game_state); - resolve_current_encounter_npc_state(game_state, npc_id.as_str(), npc_name.as_str()) - .and_then(|state| read_i32_field(state, key)) -} - -pub(super) fn read_current_npc_state_bool_field(game_state: &Value, key: &str) -> Option { - let npc_id = current_encounter_id(game_state)?; - let npc_name = current_encounter_name(game_state); - resolve_current_encounter_npc_state(game_state, npc_id.as_str(), npc_name.as_str()) - .and_then(|state| read_bool_field(state, key)) -} - -pub(super) fn write_current_npc_state_i32_field(game_state: &mut Value, key: &str, value: i32) { - let Some(npc_id) = current_encounter_id(game_state) else { - return; - }; - let npc_name = current_encounter_name(game_state); - let state = ensure_npc_state_object(game_state, npc_id.as_str(), npc_name.as_str()); - state.insert(key.to_string(), json!(value)); -} - -pub(super) fn write_current_npc_state_bool_field(game_state: &mut Value, key: &str, value: bool) { - let Some(npc_id) = current_encounter_id(game_state) else { - return; - }; - let npc_name = current_encounter_name(game_state); - let state = ensure_npc_state_object(game_state, npc_id.as_str(), npc_name.as_str()); - state.insert(key.to_string(), Value::Bool(value)); -} - -pub(super) fn set_current_npc_recruited( - game_state: &mut Value, - recruited: bool, -) -> Option<(i32, i32)> { - let npc_id = current_encounter_id(game_state)?; - let npc_name = current_encounter_name(game_state); - let state = ensure_npc_state_object(game_state, npc_id.as_str(), npc_name.as_str()); - let previous_affinity = state - .get("affinity") - .and_then(Value::as_i64) - .and_then(|value| i32::try_from(value).ok()) - .unwrap_or(0); - let next_affinity = previous_affinity.max(60); - state.insert("affinity".to_string(), json!(next_affinity)); - state.insert("recruited".to_string(), Value::Bool(recruited)); - - Some((previous_affinity, next_affinity)) -} - -pub(super) fn read_current_npc_affinity(game_state: &Value) -> i32 { - let Some(npc_id) = current_encounter_id(game_state) else { - return 0; - }; - let npc_name = current_encounter_name(game_state); - resolve_current_encounter_npc_state(game_state, npc_id.as_str(), npc_name.as_str()) - .and_then(|state| read_i32_field(state, "affinity")) - .unwrap_or(0) -} - -pub(super) fn ensure_npc_state_object<'a>( - game_state: &'a mut Value, - npc_id: &str, - npc_name: &str, -) -> &'a mut Map { - let root = ensure_json_object(game_state); - let npc_states = root - .entry("npcStates".to_string()) - .or_insert_with(|| Value::Object(Map::new())); - if !npc_states.is_object() { - *npc_states = Value::Object(Map::new()); - } - let states = npc_states - .as_object_mut() - .expect("npcStates should be object"); - let existing_key = if states.contains_key(npc_id) { - npc_id.to_string() - } else if states.contains_key(npc_name) { - npc_name.to_string() - } else { - npc_id.to_string() - }; - let state = states - .entry(existing_key) - .or_insert_with(|| Value::Object(Map::new())); - if !state.is_object() { - *state = Value::Object(Map::new()); - } - state.as_object_mut().expect("npc state should be object") -} - -pub(super) fn mark_current_npc_first_meaningful_contact_resolved(game_state: &mut Value) { - write_current_npc_state_bool_field(game_state, "firstMeaningfulContactResolved", true); -} - -pub(super) fn ensure_current_npc_inventory_array<'a>( - game_state: &'a mut Value, -) -> Option<&'a mut Vec> { - let npc_id = current_encounter_id(game_state)?; - let npc_name = current_encounter_name(game_state); - let state = ensure_npc_state_object(game_state, npc_id.as_str(), npc_name.as_str()); - let inventory = state - .entry("inventory".to_string()) - .or_insert_with(|| Value::Array(Vec::new())); - if !inventory.is_array() { - *inventory = Value::Array(Vec::new()); - } - inventory.as_array_mut() -} - -pub(super) fn add_current_npc_inventory_items(game_state: &mut Value, additions: Vec) { - if additions.is_empty() { - return; - } - let Some(items) = ensure_current_npc_inventory_array(game_state) else { - return; - }; - for addition in additions { - let Some(add_id) = read_optional_string_field(&addition, "id") else { - continue; - }; - let add_quantity = read_i32_field(&addition, "quantity").unwrap_or(1).max(1); - if let Some(existing) = items - .iter_mut() - .find(|item| read_optional_string_field(item, "id").as_deref() == Some(add_id.as_str())) - { - let next_quantity = - read_i32_field(existing, "quantity").unwrap_or(0).max(0) + add_quantity; - if let Some(existing_object) = existing.as_object_mut() { - existing_object.insert("quantity".to_string(), json!(next_quantity)); - } - continue; - } - items.push(addition); - } -} - -pub(super) fn remove_current_npc_inventory_item( - game_state: &mut Value, - item_id: &str, - quantity: i32, -) { - if quantity <= 0 { - return; - } - let Some(items) = ensure_current_npc_inventory_array(game_state) else { - return; - }; - let Some(index) = items - .iter() - .position(|entry| read_optional_string_field(entry, "id").as_deref() == Some(item_id)) - else { - return; - }; - let current_quantity = read_i32_field(&items[index], "quantity") - .unwrap_or(0) - .max(0); - let next_quantity = current_quantity - quantity; - if next_quantity <= 0 { - items.remove(index); - return; - } - if let Some(entry) = items[index].as_object_mut() { - entry.insert("quantity".to_string(), json!(next_quantity)); - } -} diff --git a/server-rs/crates/api-server/src/runtime_story/compat/npc_actions.rs b/server-rs/crates/api-server/src/runtime_story/compat/npc_actions.rs deleted file mode 100644 index d662054b..00000000 --- a/server-rs/crates/api-server/src/runtime_story/compat/npc_actions.rs +++ /dev/null @@ -1,523 +0,0 @@ -use super::*; - -pub(super) fn resolve_npc_preview_action( - game_state: &mut Value, - request: &RuntimeStoryActionRequest, -) -> Result { - let npc_name = current_encounter_name(game_state); - write_bool_field(game_state, "npcInteractionActive", true); - - Ok(StoryResolution { - action_text: resolve_action_text("转向眼前角色", request), - result_text: format!("{npc_name} 注意到了你的靠近,正在等你先把话说出来。"), - story_text: None, - presentation_options: None, - saved_current_story: None, - patches: vec![build_status_patch(game_state)], - battle: None, - toast: None, - }) -} - -pub(super) fn resolve_npc_affinity_action( - game_state: &mut Value, - request: &RuntimeStoryActionRequest, - default_action_text: &str, - affinity_delta: i32, - fallback_result_text: &str, -) -> Result { - write_bool_field(game_state, "npcInteractionActive", true); - let affinity_patch = adjust_current_npc_affinity(game_state, affinity_delta).map( - |(npc_id, previous_affinity, next_affinity)| RuntimeStoryPatch::NpcAffinityChanged { - npc_id, - previous_affinity, - next_affinity, - }, - ); - let mut patches = Vec::new(); - if let Some(patch) = affinity_patch { - patches.push(patch); - } - patches.push(build_status_patch(game_state)); - - Ok(StoryResolution { - action_text: resolve_action_text(default_action_text, request), - result_text: fallback_result_text.to_string(), - story_text: None, - presentation_options: None, - saved_current_story: None, - patches, - battle: None, - toast: None, - }) -} - -pub(super) fn resolve_npc_chat_action( - game_state: &mut Value, - request: &RuntimeStoryActionRequest, -) -> Result { - let chatted_count = read_current_npc_state_i32_field(game_state, "chattedCount").unwrap_or(0); - let affinity_gain = (6 - chatted_count).max(2); - let result_text = format!( - "{} 愿意把话接下去,态度比刚才明显松动了一些。当前关系推进了 {} 点。", - current_encounter_name(game_state), - affinity_gain - ); - let mut resolution = resolve_npc_affinity_action( - game_state, - request, - "继续交谈", - affinity_gain, - result_text.as_str(), - )?; - write_current_npc_state_i32_field(game_state, "chattedCount", chatted_count.saturating_add(1)); - write_current_npc_state_bool_field(game_state, "firstMeaningfulContactResolved", true); - resolution.action_text = format!("继续和{}交谈", current_encounter_name(game_state)); - Ok(resolution) -} - -pub(super) fn resolve_npc_help_action( - game_state: &mut Value, - request: &RuntimeStoryActionRequest, -) -> Result { - if read_current_npc_state_bool_field(game_state, "helpUsed").unwrap_or(false) { - return Err("当前 NPC 的一次性援手已经用完了".to_string()); - } - - restore_player_resource(game_state, 10, 8); - write_current_npc_state_bool_field(game_state, "helpUsed", true); - resolve_npc_affinity_action( - game_state, - request, - &format!("向{}请求援手", current_encounter_name(game_state)), - 4, - &format!( - "{} 给了你一次及时支援,你的状态暂时稳住了,关系也顺势拉近了一点。", - current_encounter_name(game_state) - ), - ) -} - -pub(super) fn resolve_npc_battle_entry_action( - game_state: &mut Value, - request: &RuntimeStoryActionRequest, - function_id: &str, -) -> Result { - let npc_id = current_encounter_id(game_state).unwrap_or_else(|| "npc_current".to_string()); - let npc_name = current_encounter_name(game_state); - let battle_mode = if function_id == "npc_spar" { - "spar" - } else { - "fight" - }; - let return_encounter = read_object_field(game_state, "currentEncounter").cloned(); - let resolved_formation = - resolve_npc_battle_formation(game_state, return_encounter.as_ref(), battle_mode); - - write_bool_field(game_state, "inBattle", true); - write_bool_field(game_state, "npcInteractionActive", false); - write_string_field(game_state, "currentBattleNpcId", npc_id.as_str()); - write_string_field(game_state, "currentNpcBattleMode", battle_mode); - write_null_field(game_state, "currentNpcBattleOutcome"); - write_null_field(game_state, "currentEncounter"); - ensure_json_object(game_state).insert( - "sceneHostileNpcs".to_string(), - Value::Array(resolved_formation), - ); - if let Some(return_encounter) = return_encounter { - ensure_json_object(game_state).insert("sparReturnEncounter".to_string(), return_encounter); - } - - Ok(StoryResolution { - action_text: resolve_action_text( - if battle_mode == "spar" { - "点到为止切磋" - } else { - "与对方战斗" - }, - request, - ), - result_text: format!( - "{npc_name} 已经进入{}节奏,下一步必须按战斗动作结算。", - battle_mode_text(battle_mode) - ), - story_text: None, - presentation_options: None, - saved_current_story: None, - patches: vec![build_status_patch(game_state)], - battle: Some(RuntimeBattlePresentation { - target_id: Some(npc_id), - target_name: Some(npc_name), - damage_dealt: None, - damage_taken: None, - outcome: Some("ongoing".to_string()), - }), - toast: None, - }) -} - -fn resolve_npc_battle_formation( - game_state: &Value, - encounter: Option<&Value>, - battle_mode: &str, -) -> Vec { - let visible_formation = read_array_field(game_state, "sceneHostileNpcs") - .into_iter() - .cloned() - .collect::>(); - if !visible_formation.is_empty() { - return visible_formation - .into_iter() - .map(|monster| normalize_npc_battle_monster(monster, battle_mode)) - .collect(); - } - - encounter - .map(|encounter| { - vec![build_npc_battle_monster_from_encounter( - game_state, - encounter, - battle_mode, - 3.2, - 0, - )] - }) - .unwrap_or_default() -} - -fn normalize_npc_battle_monster(mut monster: Value, battle_mode: &str) -> Value { - let Some(monster_object) = monster.as_object_mut() else { - return monster; - }; - monster_object - .entry("animation".to_string()) - .or_insert_with(|| Value::String("idle".to_string())); - monster_object - .entry("facing".to_string()) - .or_insert_with(|| Value::String("left".to_string())); - monster_object - .entry("renderKind".to_string()) - .or_insert_with(|| Value::String("npc".to_string())); - monster_object - .entry("attackRange".to_string()) - .or_insert_with(|| json!(1.8)); - monster_object - .entry("speed".to_string()) - .or_insert_with(|| json!(7)); - let max_hp = monster_object - .get("maxHp") - .and_then(Value::as_i64) - .unwrap_or_else(|| if battle_mode == "spar" { 10 } else { 80 }); - monster_object - .entry("hp".to_string()) - .or_insert_with(|| json!(max_hp)); - monster -} - -fn build_npc_battle_monster_from_encounter( - game_state: &Value, - encounter: &Value, - battle_mode: &str, - x_meters: f64, - y_offset: i32, -) -> Value { - let npc_id = read_optional_string_field(encounter, "id") - .unwrap_or_else(|| current_encounter_name(game_state)); - let npc_name = current_encounter_name(game_state); - let npc_state = - resolve_current_encounter_npc_state(game_state, npc_id.as_str(), npc_name.as_str()); - let affinity = npc_state - .and_then(|state| read_i32_field(state, "affinity")) - .or_else(|| read_i32_field(encounter, "initialAffinity")) - .unwrap_or(0); - let base_hp = if battle_mode == "spar" { - 10 - } else { - (80 + affinity).max(24) - }; - let monster_id = read_optional_string_field(encounter, "monsterPresetId") - .unwrap_or_else(|| format!("npc-opponent-{npc_id}")); - let mut battle_encounter = encounter.clone(); - if let Some(entry) = battle_encounter.as_object_mut() { - entry.insert("hostile".to_string(), Value::Bool(true)); - entry.insert("xMeters".to_string(), json!(x_meters)); - } - - json!({ - "id": monster_id, - "name": npc_name, - "action": if battle_mode == "spar" { - "抱拳行礼,准备点到为止地切磋武艺" - } else { - "摆开架势,随时准备出手" - }, - "description": read_optional_string_field(encounter, "npcDescription").unwrap_or_default(), - "animation": "idle", - "xMeters": x_meters, - "yOffset": y_offset, - "facing": "left", - "attackRange": 1.8, - "speed": 7, - "hp": base_hp, - "maxHp": base_hp, - "renderKind": "npc", - "levelProfile": read_field(encounter, "levelProfile").cloned(), - "experienceReward": read_i32_field(encounter, "experienceReward").unwrap_or(0), - "encounter": battle_encounter - }) -} - -pub(super) fn resolve_npc_recruit_action( - game_state: &mut Value, - request: &RuntimeStoryActionRequest, -) -> Result { - let npc_id = current_encounter_id(game_state).unwrap_or_else(|| "npc_current".to_string()); - let npc_name = current_encounter_name(game_state); - let current_affinity = read_current_npc_affinity(game_state); - if read_current_npc_state_bool_field(game_state, "recruited").unwrap_or(false) { - return Err("当前 NPC 已经处于已招募状态".to_string()); - } - if current_affinity < 60 { - return Err("当前关系还没达到招募阈值,暂时不能邀请入队".to_string()); - } - - let release_npc_id = request - .action - .payload - .as_ref() - .and_then(|payload| read_optional_string_field(payload, "releaseNpcId")); - let released_companion_name = recruit_companion_to_party( - game_state, - npc_id.as_str(), - current_affinity, - release_npc_id.as_deref(), - )?; - let affinity_patch = - set_current_npc_recruited(game_state, true).map(|(previous_affinity, next_affinity)| { - RuntimeStoryPatch::NpcAffinityChanged { - npc_id: npc_id.clone(), - previous_affinity, - next_affinity, - } - }); - write_current_npc_state_bool_field(game_state, "firstMeaningfulContactResolved", true); - write_bool_field(game_state, "npcInteractionActive", false); - clear_encounter_only(game_state); - write_null_field(game_state, "currentNpcBattleMode"); - write_null_field(game_state, "currentNpcBattleOutcome"); - write_bool_field(game_state, "inBattle", false); - - let mut patches = Vec::new(); - if let Some(patch) = affinity_patch { - patches.push(patch); - } - patches.push(build_status_patch(game_state)); - patches.push(RuntimeStoryPatch::EncounterChanged { encounter_id: None }); - - Ok(StoryResolution { - action_text: resolve_action_text(&format!("邀请{npc_name}加入队伍"), request), - result_text: match released_companion_name { - Some(released_name) => format!( - "{npc_name} 接受了你的邀请,你先让 {released_name} 暂时离队,把位置腾给了新的同行者。" - ), - None => format!("{npc_name} 接受了你的邀请,正式进入了同行队伍。"), - }, - story_text: None, - presentation_options: None, - saved_current_story: None, - patches, - battle: None, - toast: Some(format!("{npc_name} 已加入队伍")), - }) -} - -/// 先按 NPC 当前遭遇态结算简化版买卖逻辑,保持与 Node compat 一致的字段写回, -/// 后续再由真相态 inventory / runtime-item reducer 接管。 -pub(super) fn resolve_npc_trade_action( - game_state: &mut Value, - request: &RuntimeStoryActionRequest, -) -> Result { - let (_npc_id, npc_name) = current_npc_trade_context(game_state)?; - let payload = request.action.payload.as_ref(); - let mode = payload - .and_then(|value| read_optional_string_field(value, "mode")) - .ok_or_else(|| "npc_trade 缺少合法 mode,需为 buy 或 sell".to_string())?; - if mode != "buy" && mode != "sell" { - return Err("npc_trade 缺少合法 mode,需为 buy 或 sell".to_string()); - } - let item_id = payload - .and_then(|value| { - read_optional_string_field(value, "itemId") - .or_else(|| read_optional_string_field(value, "selectedNpcItemId")) - .or_else(|| read_optional_string_field(value, "selectedPlayerItemId")) - }) - .or_else(|| request.action.target_id.clone()) - .ok_or_else(|| "npc_trade 缺少 itemId".to_string())?; - let quantity = payload - .and_then(|value| read_i32_field(value, "quantity")) - .unwrap_or(1); - if quantity <= 0 { - return Err("npc_trade.quantity 必须大于 0".to_string()); - } - - if mode == "buy" { - let npc_item = read_current_npc_inventory_item(game_state, item_id.as_str()) - .cloned() - .ok_or_else(|| "目标商品不存在或库存不足。".to_string())?; - let available_quantity = read_i32_field(&npc_item, "quantity").unwrap_or(0).max(0); - if available_quantity < quantity { - return Err("目标商品不存在或库存不足。".to_string()); - } - let total_price = npc_purchase_price(&npc_item, read_current_npc_affinity(game_state)) - .saturating_mul(quantity); - let player_currency = read_i32_field(game_state, "playerCurrency").unwrap_or(0); - if player_currency < total_price { - return Err("当前钱币不足,无法完成购买。".to_string()); - } - - write_i32_field(game_state, "playerCurrency", player_currency - total_price); - add_player_inventory_items( - game_state, - vec![clone_inventory_item_with_quantity(&npc_item, quantity)], - ); - remove_current_npc_inventory_item(game_state, item_id.as_str(), quantity); - mark_current_npc_first_meaningful_contact_resolved(game_state); - - let item_name = read_inventory_item_name(&npc_item); - return Ok(StoryResolution { - action_text: resolve_action_text( - &format!( - "从{}手里买下{}{}", - npc_name, - item_name, - trade_quantity_suffix(quantity) - ), - request, - ), - result_text: format!( - "{}收下了{},把{}{}卖给了你。", - npc_name, - format_currency_text( - total_price, - read_optional_string_field(game_state, "worldType").as_deref() - ), - item_name, - trade_quantity_suffix(quantity) - ), - story_text: None, - presentation_options: None, - saved_current_story: None, - patches: Vec::new(), - battle: None, - toast: None, - }); - } - - let player_item = find_player_inventory_entry(game_state, item_id.as_str()) - .cloned() - .ok_or_else(|| "背包里没有足够数量的目标物品。".to_string())?; - let available_quantity = read_i32_field(&player_item, "quantity").unwrap_or(0).max(0); - if available_quantity < quantity { - return Err("背包里没有足够数量的目标物品。".to_string()); - } - let total_price = npc_buyback_price(&player_item, read_current_npc_affinity(game_state)) - .saturating_mul(quantity); - let player_currency = read_i32_field(game_state, "playerCurrency").unwrap_or(0); - write_i32_field( - game_state, - "playerCurrency", - player_currency.saturating_add(total_price), - ); - remove_player_inventory_item(game_state, item_id.as_str(), quantity); - add_current_npc_inventory_items( - game_state, - vec![clone_inventory_item_with_quantity(&player_item, quantity)], - ); - mark_current_npc_first_meaningful_contact_resolved(game_state); - - let item_name = read_inventory_item_name(&player_item); - Ok(StoryResolution { - action_text: resolve_action_text( - &format!( - "把{}{}卖给{}", - item_name, - trade_quantity_suffix(quantity), - npc_name - ), - request, - ), - result_text: format!( - "{}收下了{}{},付给你{}。", - npc_name, - item_name, - trade_quantity_suffix(quantity), - format_currency_text( - total_price, - read_optional_string_field(game_state, "worldType").as_deref() - ) - ), - story_text: None, - presentation_options: None, - saved_current_story: None, - patches: Vec::new(), - battle: None, - toast: None, - }) -} - -pub(super) fn resolve_npc_gift_action( - game_state: &mut Value, - request: &RuntimeStoryActionRequest, -) -> Result { - let (npc_id, npc_name) = current_npc_trade_context(game_state)?; - let item_id = request - .action - .payload - .as_ref() - .and_then(|payload| read_optional_string_field(payload, "itemId")) - .or_else(|| request.action.target_id.clone()) - .ok_or_else(|| "npc_gift 缺少 itemId".to_string())?; - let gift_item = find_player_inventory_entry(game_state, item_id.as_str()) - .cloned() - .ok_or_else(|| "背包里没有这件可赠送的物品。".to_string())?; - if read_i32_field(&gift_item, "quantity").unwrap_or(0) <= 0 { - return Err("背包里没有这件可赠送的物品。".to_string()); - } - - let previous_affinity = read_current_npc_affinity(game_state); - let affinity_gain = resolve_npc_gift_affinity_gain(&gift_item); - let next_affinity = (previous_affinity + affinity_gain).clamp(-100, 100); - remove_player_inventory_item(game_state, item_id.as_str(), 1); - add_current_npc_inventory_items( - game_state, - vec![clone_inventory_item_with_quantity(&gift_item, 1)], - ); - write_current_npc_state_i32_field(game_state, "affinity", next_affinity); - let next_gifts_given = - read_current_npc_state_i32_field(game_state, "giftsGiven").unwrap_or(0) + 1; - write_current_npc_state_i32_field(game_state, "giftsGiven", next_gifts_given); - mark_current_npc_first_meaningful_contact_resolved(game_state); - - Ok(StoryResolution { - action_text: resolve_action_text( - &format!("把{}赠给{}", read_inventory_item_name(&gift_item), npc_name), - request, - ), - result_text: build_npc_gift_result_text( - npc_name.as_str(), - &gift_item, - affinity_gain, - next_affinity, - ), - story_text: None, - presentation_options: None, - saved_current_story: None, - patches: vec![RuntimeStoryPatch::NpcAffinityChanged { - npc_id, - previous_affinity, - next_affinity, - }], - battle: None, - toast: None, - }) -} diff --git a/server-rs/crates/api-server/src/runtime_story/compat/presentation.rs b/server-rs/crates/api-server/src/runtime_story/compat/presentation.rs deleted file mode 100644 index 413c0bea..00000000 --- a/server-rs/crates/api-server/src/runtime_story/compat/presentation.rs +++ /dev/null @@ -1,735 +0,0 @@ -use super::*; - -pub(super) fn build_runtime_story_state_response( - requested_session_id: &str, - client_version: Option, - mut snapshot: RuntimeStorySnapshotPayload, -) -> RuntimeStoryActionResponse { - ensure_runtime_story_bridge_state(&mut snapshot.game_state); - write_runtime_npc_interaction_view(&mut snapshot.game_state); - let session_id = read_runtime_session_id(&snapshot.game_state) - .unwrap_or_else(|| requested_session_id.to_string()); - let options = - build_runtime_story_options(snapshot.current_story.as_ref(), &snapshot.game_state); - let story_text = read_story_text(snapshot.current_story.as_ref()) - .unwrap_or_else(|| build_fallback_story_text(&snapshot.game_state)); - let server_version = read_u32_field(&snapshot.game_state, "runtimeActionVersion") - .or(client_version) - .unwrap_or(0); - - build_runtime_story_action_response(RuntimeStoryActionResponseParts { - requested_session_id: session_id, - server_version, - snapshot, - action_text: String::new(), - result_text: String::new(), - story_text, - options, - patches: Vec::new(), - toast: None, - battle: None, - }) -} - -pub(super) fn build_runtime_story_action_response( - parts: RuntimeStoryActionResponseParts, -) -> RuntimeStoryActionResponse { - let session_id = read_runtime_session_id(&parts.snapshot.game_state) - .unwrap_or_else(|| parts.requested_session_id); - - RuntimeStoryActionResponse { - session_id, - server_version: parts.server_version, - view_model: build_runtime_story_view_model(&parts.snapshot.game_state, &parts.options), - presentation: RuntimeStoryPresentation { - action_text: parts.action_text, - result_text: parts.result_text, - story_text: parts.story_text, - options: parts.options, - toast: parts.toast, - battle: parts.battle, - }, - patches: parts.patches, - snapshot: parts.snapshot, - } -} - -pub(super) fn build_dialogue_current_story( - npc_name: &str, - text: &str, - deferred_options: &[RuntimeStoryOptionView], -) -> Value { - let continue_option = build_continue_adventure_runtime_story_option(); - // 对齐 Node 旧 currentStory:先展示单轮对话,只把真实下一步选项压到 deferredOptions。 - json!({ - "text": text, - "options": vec![build_story_option_from_runtime_option(&continue_option)], - "displayMode": "dialogue", - "dialogue": parse_dialogue_turns(text, npc_name), - "streaming": false, - "deferredOptions": deferred_options - .iter() - .map(build_story_option_from_runtime_option) - .collect::>(), - }) -} - -pub(super) fn build_continue_adventure_runtime_story_option() -> RuntimeStoryOptionView { - build_static_runtime_story_option(CONTINUE_ADVENTURE_FUNCTION_ID, "继续推进冒险", "story") -} - -pub(super) fn parse_dialogue_turns(text: &str, npc_name: &str) -> Vec { - let mut turns = Vec::new(); - for raw_line in text.lines() { - let line = raw_line.trim(); - if line.is_empty() { - continue; - } - if let Some(turn) = parse_dialogue_line(line, npc_name) { - turns.push(turn); - } - } - - if turns.is_empty() && !text.trim().is_empty() { - turns.push(json!({ - "speaker": "npc", - "speakerName": npc_name, - "text": text.trim(), - })); - } - - turns -} - -pub(super) fn parse_dialogue_line(line: &str, npc_name: &str) -> Option { - let delimiter_index = line.find(':').or_else(|| line.find(':'))?; - let speaker_name = line[..delimiter_index].trim(); - let content_start = delimiter_index + line[delimiter_index..].chars().next()?.len_utf8(); - let content = line[content_start..].trim(); - if content.is_empty() { - return None; - } - - if speaker_name == "你" { - return Some(json!({ - "speaker": "player", - "text": content, - })); - } - - if speaker_name == npc_name { - return Some(json!({ - "speaker": "npc", - "speakerName": npc_name, - "text": content, - })); - } - - Some(json!({ - "speaker": "companion", - "speakerName": speaker_name, - "text": content, - })) -} - -pub(super) fn build_runtime_story_options( - current_story: Option<&Value>, - game_state: &Value, -) -> Vec { - if let Some(story) = current_story { - let prefers_deferred = read_required_string_field(story, "displayMode") - .is_some_and(|value| value == "dialogue") - && !read_array_field(story, "deferredOptions").is_empty(); - - let source = if prefers_deferred { - read_array_field(story, "deferredOptions") - } else { - read_array_field(story, "options") - }; - - let compiled = source - .into_iter() - .filter_map(build_runtime_story_option_from_story_option) - .collect::>(); - - if !compiled.is_empty() { - return compiled; - } - } - - build_fallback_runtime_story_options(game_state) -} - -pub(super) fn build_fallback_runtime_story_options( - game_state: &Value, -) -> Vec { - if read_bool_field(game_state, "inBattle").unwrap_or(false) { - return build_battle_runtime_story_options(game_state); - } - - let encounter = read_object_field(game_state, "currentEncounter"); - if let Some(encounter) = encounter { - if matches!( - read_required_string_field(encounter, "kind").as_deref(), - Some("npc") - ) { - let interaction_active = - read_bool_field(game_state, "npcInteractionActive").unwrap_or(false); - let npc_id = read_required_string_field(encounter, "id") - .unwrap_or_else(|| "npc_current".to_string()); - if let Some(active_quest) = find_active_quest_for_issuer(game_state, npc_id.as_str()) { - if read_optional_string_field(active_quest, "status") - .is_some_and(|status| status == "completed") - { - return vec![ - build_npc_runtime_story_option_with_quest( - "npc_quest_turn_in", - &format!("向{}交付委托", current_encounter_name(game_state)), - &npc_id, - "quest_turn_in", - read_optional_string_field(active_quest, "id"), - ), - build_npc_runtime_story_option( - "npc_leave", - "离开当前角色", - &npc_id, - "leave", - ), - ]; - } - } - if interaction_active { - return build_active_npc_runtime_story_options(game_state, npc_id.as_str()); - } - - return vec![ - build_npc_runtime_story_option("npc_preview_talk", "转向眼前角色", &npc_id, "chat"), - build_npc_runtime_story_option("npc_fight", "与对方战斗", &npc_id, "fight"), - build_npc_runtime_story_option("npc_leave", "离开当前角色", &npc_id, "leave"), - ]; - } - } - - vec![ - build_static_runtime_story_option("idle_observe_signs", "观察周围迹象", "story"), - build_static_runtime_story_option("idle_call_out", "主动出声试探", "story"), - build_static_runtime_story_option("idle_rest_focus", "原地调息", "story"), - build_static_runtime_story_option("idle_explore_forward", "继续向前探索", "story"), - build_static_runtime_story_option("idle_travel_next_scene", "前往相邻场景", "story"), - build_static_runtime_story_option(CONTINUE_ADVENTURE_FUNCTION_ID, "继续推进冒险", "story"), - ] -} - -pub(super) fn build_npc_runtime_story_option( - function_id: &str, - action_text: &str, - npc_id: &str, - action: &str, -) -> RuntimeStoryOptionView { - RuntimeStoryOptionView { - interaction: Some(RuntimeStoryOptionInteraction::Npc { - npc_id: npc_id.to_string(), - action: action.to_string(), - quest_id: None, - }), - ..build_static_runtime_story_option(function_id, action_text, "npc") - } -} - -pub(super) fn build_npc_runtime_story_option_with_payload( - function_id: &str, - action_text: &str, - npc_id: &str, - action: &str, - payload: Value, -) -> RuntimeStoryOptionView { - RuntimeStoryOptionView { - payload: Some(payload), - ..build_npc_runtime_story_option(function_id, action_text, npc_id, action) - } -} - -pub(super) fn build_npc_runtime_story_option_with_quest( - function_id: &str, - action_text: &str, - npc_id: &str, - action: &str, - quest_id: Option, -) -> RuntimeStoryOptionView { - RuntimeStoryOptionView { - interaction: Some(RuntimeStoryOptionInteraction::Npc { - npc_id: npc_id.to_string(), - action: action.to_string(), - quest_id, - }), - ..build_static_runtime_story_option(function_id, action_text, "npc") - } -} - -/// 对齐 Node 旧 compat 入口顺序,在 NPC 交互态下统一补齐交易、赠礼、委托与招募入口。 -pub(super) fn build_active_npc_runtime_story_options( - game_state: &Value, - npc_id: &str, -) -> Vec { - let mut options = vec![ - build_npc_runtime_story_option("npc_chat", "继续交谈", npc_id, "chat"), - build_npc_help_runtime_story_option(game_state, npc_id), - build_npc_runtime_story_option("npc_spar", "点到为止切磋", npc_id, "spar"), - build_npc_runtime_story_option("npc_fight", "与对方战斗", npc_id, "fight"), - ]; - - if current_npc_inventory_items(game_state) - .iter() - .any(|item| read_i32_field(item, "quantity").unwrap_or(0) > 0) - { - options.push(build_npc_runtime_story_option( - "npc_trade", - "交易", - npc_id, - "trade", - )); - } - - if has_giftable_player_inventory(game_state) { - options.push(build_npc_runtime_story_option( - "npc_gift", - "赠送礼物", - npc_id, - "gift", - )); - } - - let active_quest = find_active_quest_for_issuer(game_state, npc_id); - if let Some(active_quest) = active_quest { - let can_turn_in = read_optional_string_field(active_quest, "status") - .is_some_and(|status| status == "completed" || status == "ready_to_turn_in"); - if can_turn_in { - options.push(build_npc_runtime_story_option_with_quest( - "npc_quest_turn_in", - &format!("向{}交付委托", current_encounter_name(game_state)), - npc_id, - "quest_turn_in", - read_optional_string_field(active_quest, "id"), - )); - } - } else { - options.push(build_npc_runtime_story_option( - "npc_quest_accept", - "接下委托", - npc_id, - "quest_accept", - )); - } - - if read_current_npc_affinity(game_state) >= 60 - && !read_current_npc_state_bool_field(game_state, "recruited").unwrap_or(false) - { - options.push(build_npc_runtime_story_option( - "npc_recruit", - "邀请同行", - npc_id, - "recruit", - )); - } - - options.push(build_npc_runtime_story_option( - "npc_leave", - "离开当前角色", - npc_id, - "leave", - )); - options -} - -pub(super) fn build_npc_help_runtime_story_option( - game_state: &Value, - npc_id: &str, -) -> RuntimeStoryOptionView { - if read_current_npc_state_bool_field(game_state, "helpUsed").unwrap_or(false) { - return build_disabled_runtime_story_option( - "npc_help", - "请求援手", - "npc", - None, - "当前 NPC 的一次性援手已经用完了。", - None, - ); - } - build_npc_runtime_story_option("npc_help", "请求援手", npc_id, "help") -} - -pub(super) fn current_encounter_npc_quest_context( - game_state: &Value, -) -> Result { - let encounter = read_object_field(game_state, "currentEncounter") - .ok_or_else(|| "当前不在可结算的 NPC 委托态。".to_string())?; - let kind = read_required_string_field(encounter, "kind") - .ok_or_else(|| "当前不在可结算的 NPC 委托态。".to_string())?; - if kind != "npc" { - return Err("当前不在可结算的 NPC 委托态。".to_string()); - } - - let npc_name = read_optional_string_field(encounter, "npcName") - .or_else(|| read_optional_string_field(encounter, "name")) - .unwrap_or_else(|| "当前角色".to_string()); - let npc_id = read_optional_string_field(encounter, "id").unwrap_or_else(|| npc_name.clone()); - - if resolve_current_encounter_npc_state(game_state, npc_id.as_str(), npc_name.as_str()).is_none() - { - return Err("当前 NPC 状态不存在,无法处理委托。".to_string()); - } - - Ok(CurrentEncounterNpcQuestContext { npc_id, npc_name }) -} - -pub(super) fn read_pending_quest_offer_context( - current_story: Option<&Value>, - npc_key: &str, -) -> Option { - let current_story = current_story?; - let npc_chat_state = read_object_field(current_story, "npcChatState")?; - let pending_offer = read_object_field(npc_chat_state, "pendingQuestOffer")?; - let quest = read_object_field(pending_offer, "quest")?.clone(); - let quest_id = read_optional_string_field(&quest, "id")?; - let pending_npc_id = read_optional_string_field(npc_chat_state, "npcId"); - let issuer_npc_id = read_optional_string_field(&quest, "issuerNpcId"); - if pending_npc_id - .as_deref() - .is_some_and(|value| value != npc_key) - { - return None; - } - if issuer_npc_id - .as_deref() - .is_some_and(|value| value != npc_key) - { - return None; - } - - Some(PendingQuestOfferContext { - dialogue: read_array_field(current_story, "dialogue") - .into_iter() - .cloned() - .collect(), - turn_count: read_i32_field(npc_chat_state, "turnCount").unwrap_or(0), - custom_input_placeholder: read_optional_string_field( - npc_chat_state, - "customInputPlaceholder", - ) - .unwrap_or_else(|| "输入你想对 TA 说的话".to_string()), - quest, - quest_id, - intro_text: read_optional_string_field(pending_offer, "introText"), - }) -} - -pub(super) fn build_quest_offer_dialogue_text(npc_name: &str, quest: &Value) -> String { - let summary_text = read_optional_string_field(quest, "summary") - .or_else(|| read_optional_string_field(quest, "description")) - .unwrap_or_default(); - if summary_text.is_empty() { - return format!( - "{npc_name}沉吟了片刻,像是终于把真正想托付的事说了出来。如果你愿意,我想把眼前这件事正式交给你。" - ); - } - format!( - "{npc_name}沉吟了片刻,像是终于把真正想托付的事说了出来。如果你愿意,我想把这件事正式交给你:{summary_text}" - ) -} - -pub(super) fn append_dialogue_turns(existing: &[Value], additions: Vec) -> Vec { - let mut dialogue = existing.to_vec(); - dialogue.extend(additions); - dialogue -} - -pub(super) fn build_pending_quest_offer_options(npc_id: &str) -> Vec { - vec![ - build_npc_runtime_story_option_with_payload( - "npc_chat_quest_offer_view", - "查看任务", - npc_id, - "quest_offer_view", - json!({ - "npcChatQuestOfferAction": "view" - }), - ), - build_npc_runtime_story_option_with_payload( - "npc_chat_quest_offer_replace", - "更换任务", - npc_id, - "quest_offer_replace", - json!({ - "npcChatQuestOfferAction": "replace" - }), - ), - build_npc_runtime_story_option_with_payload( - "npc_chat_quest_offer_abandon", - "放弃任务", - npc_id, - "quest_offer_abandon", - json!({ - "npcChatQuestOfferAction": "abandon" - }), - ), - ] -} - -pub(super) fn build_post_quest_offer_chat_options(npc_id: &str) -> Vec { - vec![ - build_npc_runtime_story_option( - "npc_chat", - "那先继续聊聊你刚才没说完的部分", - npc_id, - "chat", - ), - build_npc_runtime_story_option( - "npc_chat", - "除了委托,你对眼前局势还有什么判断", - npc_id, - "chat", - ), - build_npc_runtime_story_option( - "npc_chat", - "先把这附近真正危险的地方说清楚", - npc_id, - "chat", - ), - ] -} - -pub(super) fn build_post_quest_accept_chat_options(npc_id: &str) -> Vec { - vec![ - build_npc_runtime_story_option("npc_chat", "这件事里你最担心哪一步", npc_id, "chat"), - build_npc_runtime_story_option("npc_chat", "我回来时你最想先知道什么", npc_id, "chat"), - build_npc_runtime_story_option( - "npc_chat", - "除了这份委托,你还想提醒我什么", - npc_id, - "chat", - ), - ] -} - -pub(super) fn build_pending_quest_offer_story( - dialogue: Vec, - npc_id: &str, - npc_name: &str, - turn_count: i32, - custom_input_placeholder: &str, - pending_quest: Option, - options: &[RuntimeStoryOptionView], -) -> Value { - json!({ - "text": dialogue - .iter() - .filter_map(|entry| read_optional_string_field(entry, "text")) - .collect::>() - .join("\n"), - "options": options.iter().map(build_story_option_from_runtime_option).collect::>(), - "displayMode": "dialogue", - "dialogue": dialogue, - "streaming": false, - "npcChatState": { - "npcId": npc_id, - "npcName": npc_name, - "turnCount": turn_count, - "customInputPlaceholder": custom_input_placeholder, - "pendingQuestOffer": pending_quest.map(|quest| json!({ "quest": quest })), - } - }) -} - -pub(super) fn build_next_pending_quest_offer( - game_state: &Value, - npc_id: &str, - npc_name: &str, - previous_quest_id: Option<&str>, -) -> Value { - let next_id = if previous_quest_id.is_some_and(|id| id == "quest-bridge-offer") { - "quest-bridge-replaced" - } else { - "quest-generated-replaced" - }; - let title = if next_id == "quest-bridge-replaced" { - "断桥夜巡" - } else { - "新的临时委托" - }; - let scene_id = read_object_field(game_state, "currentScenePreset") - .and_then(|scene| read_optional_string_field(scene, "id")); - json!({ - "id": next_id, - "issuerNpcId": npc_id, - "issuerNpcName": npc_name, - "sceneId": scene_id, - "title": title, - "description": format!("{title}的详细说明。"), - "summary": format!("{title}的简要目标。"), - "objective": { - "kind": "talk_to_npc", - "requiredCount": 1 - }, - "progress": 0, - "status": "active", - "reward": { - "affinityBonus": 6, - "currency": 30, - "items": [] - }, - "rewardText": "完成后可以领取报酬。", - "steps": [{ - "id": format!("{next_id}-step-1"), - "title": "查清线索", - "kind": "talk_to_npc", - "requiredCount": 1, - "progress": 0, - "revealText": "先去断桥口附近把相关线索问清楚。", - "completeText": "关键线索已经问清。" - }], - "activeStepId": format!("{next_id}-step-1") - }) -} - -pub(super) fn find_active_quest_for_issuer<'a>( - game_state: &'a Value, - issuer_npc_id: &str, -) -> Option<&'a Value> { - read_array_field(game_state, "quests") - .into_iter() - .find(|quest| { - read_optional_string_field(quest, "issuerNpcId").as_deref() == Some(issuer_npc_id) - && read_optional_string_field(quest, "status") - .is_some_and(|status| status != "turned_in") - }) -} - -pub(super) fn push_quest_record(game_state: &mut Value, quest: &Value) { - let root = ensure_json_object(game_state); - let quests = root - .entry("quests".to_string()) - .or_insert_with(|| Value::Array(Vec::new())); - if !quests.is_array() { - *quests = Value::Array(Vec::new()); - } - quests - .as_array_mut() - .expect("quests should be array") - .push(quest.clone()); -} - -pub(super) fn first_quest_reveal_text(quest: &Value) -> Option { - read_array_field(quest, "steps") - .first() - .and_then(|step| read_optional_string_field(step, "revealText")) -} - -pub(super) fn build_quest_accept_result_text(quest: &Value) -> String { - let issuer_name = - read_optional_string_field(quest, "issuerNpcName").unwrap_or_else(|| "对方".to_string()); - let title = read_optional_string_field(quest, "title").unwrap_or_else(|| "委托".to_string()); - format!("你正式接下了 {issuer_name} 的委托「{title}」,接下来可以开始推进任务目标。") -} - -pub(super) fn turn_in_quest_record( - game_state: &mut Value, - issuer_npc_id: &str, - quest_id: &str, -) -> Result { - let root = ensure_json_object(game_state); - let quests = root - .entry("quests".to_string()) - .or_insert_with(|| Value::Array(Vec::new())); - if !quests.is_array() { - *quests = Value::Array(Vec::new()); - } - let quests = quests.as_array_mut().expect("quests should be array"); - let Some(index) = quests.iter().position(|quest| { - read_optional_string_field(quest, "id").as_deref() == Some(quest_id) - && read_optional_string_field(quest, "issuerNpcId").as_deref() == Some(issuer_npc_id) - }) else { - return Err("当前没有可交付的委托。".to_string()); - }; - - let mut turned_in = quests[index].clone(); - if read_optional_string_field(&turned_in, "status").as_deref() != Some("completed") { - return Err("这份委托还没有达到可交付状态。".to_string()); - } - if let Some(object) = turned_in.as_object_mut() { - object.insert("status".to_string(), Value::String("turned_in".to_string())); - object.insert("completionNotified".to_string(), Value::Bool(true)); - if let Some(steps) = object.get_mut("steps").and_then(Value::as_array_mut) { - for step in steps.iter_mut() { - let required_count = read_i32_field(step, "requiredCount").unwrap_or(0); - if let Some(step_object) = step.as_object_mut() { - step_object.insert("progress".to_string(), json!(required_count.max(0))); - } - } - } - } - quests[index] = turned_in.clone(); - Ok(turned_in) -} - -pub(super) fn build_quest_turn_in_result_text(quest: &Value) -> String { - let title = read_optional_string_field(quest, "title").unwrap_or_else(|| "委托".to_string()); - let reward_text = read_optional_string_field(quest, "rewardText") - .unwrap_or_else(|| "报酬已经结清。".to_string()); - format!("你已经完成并交付了「{title}」。{reward_text}") -} - -pub(super) fn apply_quest_turn_in_rewards(game_state: &mut Value, quest: &Value) { - let Some(reward) = read_field(quest, "reward") else { - return; - }; - - let currency = read_i32_field(reward, "currency").unwrap_or(0).max(0); - if currency > 0 { - add_player_currency(game_state, currency); - } - - let reward_items = read_array_field(reward, "items") - .into_iter() - .cloned() - .collect::>(); - if !reward_items.is_empty() { - add_player_inventory_items(game_state, reward_items); - } - - let experience = read_i32_field(reward, "experience").unwrap_or(0).max(0); - if experience > 0 { - grant_player_progression_experience(game_state, experience, "quest"); - } -} - -pub(super) fn build_legacy_current_story( - story_text: &str, - options: &[RuntimeStoryOptionView], -) -> Value { - json!({ - "text": story_text, - "options": options.iter().map(build_story_option_from_runtime_option).collect::>(), - "streaming": false - }) -} - -pub(super) fn read_story_text(current_story: Option<&Value>) -> Option { - current_story.and_then(|story| read_optional_string_field(story, "text")) -} - -pub(super) fn build_fallback_story_text(game_state: &Value) -> String { - if read_bool_field(game_state, "inBattle").unwrap_or(false) { - let encounter_name = read_object_field(game_state, "currentEncounter") - .and_then(|encounter| read_optional_string_field(encounter, "npcName")) - .unwrap_or_else(|| "眼前的敌人".to_string()); - return format!("战斗还没有结束,{encounter_name} 仍在逼你立刻做出下一步判断。"); - } - - if let Some(encounter) = read_object_field(game_state, "currentEncounter") - && let Some(npc_name) = read_optional_string_field(encounter, "npcName") - { - return format!("{npc_name} 正在等你表态,当前局势已经可以继续推进。"); - } - - "当前故事状态已经同步到兼容状态桥,可以继续推进这一轮运行时动作。".to_string() -} diff --git a/server-rs/crates/api-server/src/runtime_story/compat/quest_actions.rs b/server-rs/crates/api-server/src/runtime_story/compat/quest_actions.rs deleted file mode 100644 index f5ced359..00000000 --- a/server-rs/crates/api-server/src/runtime_story/compat/quest_actions.rs +++ /dev/null @@ -1,234 +0,0 @@ -use super::*; - -pub(super) fn resolve_pending_quest_offer_view_action( - game_state: &mut Value, - current_story: Option<&Value>, - request: &RuntimeStoryActionRequest, -) -> Result { - let encounter = current_encounter_npc_quest_context(game_state)?; - let pending_offer = read_pending_quest_offer_context(current_story, encounter.npc_id.as_str()) - .ok_or_else(|| "当前没有待处理的委托可查看。".to_string())?; - Ok(StoryResolution { - action_text: resolve_action_text(&format!("查看{}提出的委托", encounter.npc_name), request), - result_text: pending_offer.intro_text.clone().unwrap_or_else(|| { - build_quest_offer_dialogue_text(encounter.npc_name.as_str(), &pending_offer.quest) - }), - story_text: None, - presentation_options: None, - saved_current_story: None, - patches: vec![], - battle: None, - toast: None, - }) -} - -pub(super) fn resolve_pending_quest_offer_replace_action( - game_state: &mut Value, - current_story: Option<&Value>, - request: &RuntimeStoryActionRequest, -) -> Result { - let encounter = current_encounter_npc_quest_context(game_state)?; - let pending_offer = read_pending_quest_offer_context(current_story, encounter.npc_id.as_str()) - .ok_or_else(|| "当前没有待处理的委托可更换。".to_string())?; - let next_quest = build_next_pending_quest_offer( - game_state, - encounter.npc_id.as_str(), - encounter.npc_name.as_str(), - Some(pending_offer.quest_id.as_str()), - ); - let quest_text = build_quest_offer_dialogue_text(encounter.npc_name.as_str(), &next_quest); - let dialogue = append_dialogue_turns( - pending_offer.dialogue.as_slice(), - vec![ - json!({ - "speaker": "player", - "text": "能不能换一份更适合眼下局势的委托?" - }), - json!({ - "speaker": "npc", - "speakerName": encounter.npc_name, - "text": quest_text, - }), - ], - ); - let options = build_pending_quest_offer_options(encounter.npc_id.as_str()); - let saved_current_story = build_pending_quest_offer_story( - dialogue, - encounter.npc_id.as_str(), - encounter.npc_name.as_str(), - pending_offer.turn_count, - pending_offer.custom_input_placeholder.as_str(), - Some(next_quest.clone()), - options.as_slice(), - ); - - Ok(StoryResolution { - action_text: resolve_action_text(&format!("请{}更换委托", encounter.npc_name), request), - result_text: quest_text.clone(), - story_text: Some(quest_text), - presentation_options: Some(options), - saved_current_story: Some(saved_current_story), - patches: vec![], - battle: None, - toast: None, - }) -} - -pub(super) fn resolve_pending_quest_offer_abandon_action( - game_state: &mut Value, - current_story: Option<&Value>, - request: &RuntimeStoryActionRequest, -) -> Result { - let encounter = current_encounter_npc_quest_context(game_state)?; - let pending_offer = read_pending_quest_offer_context(current_story, encounter.npc_id.as_str()) - .ok_or_else(|| "当前没有待处理的委托可放弃。".to_string())?; - let npc_reply = format!( - "{}点了点头,没有继续强求,只把这份委托暂时收了回去。", - encounter.npc_name - ); - let dialogue = append_dialogue_turns( - pending_offer.dialogue.as_slice(), - vec![ - json!({ - "speaker": "player", - "text": "这件事我先不接,咱们还是先聊别的。" - }), - json!({ - "speaker": "npc", - "speakerName": encounter.npc_name, - "text": npc_reply, - }), - ], - ); - let options = build_post_quest_offer_chat_options(encounter.npc_id.as_str()); - let saved_current_story = build_pending_quest_offer_story( - dialogue, - encounter.npc_id.as_str(), - encounter.npc_name.as_str(), - pending_offer.turn_count, - pending_offer.custom_input_placeholder.as_str(), - None, - options.as_slice(), - ); - - Ok(StoryResolution { - action_text: resolve_action_text(&format!("暂不接受{}的委托", encounter.npc_name), request), - result_text: npc_reply.clone(), - story_text: Some(npc_reply), - presentation_options: Some(options), - saved_current_story: Some(saved_current_story), - patches: vec![], - battle: None, - toast: None, - }) -} - -pub(super) fn resolve_pending_quest_accept_action( - game_state: &mut Value, - current_story: Option<&Value>, - request: &RuntimeStoryActionRequest, -) -> Result { - let encounter = current_encounter_npc_quest_context(game_state)?; - let pending_offer = read_pending_quest_offer_context(current_story, encounter.npc_id.as_str()) - .ok_or_else(|| "当前没有待处理的委托可接下。".to_string())?; - if find_active_quest_for_issuer(game_state, encounter.npc_id.as_str()).is_some() { - return Err("当前角色已经有未结清的委托。".to_string()); - } - - let quest = pending_offer.quest.clone(); - push_quest_record(game_state, &quest); - increment_runtime_stat(game_state, "questsAccepted", 1); - write_current_npc_state_bool_field(game_state, "firstMeaningfulContactResolved", true); - - let reply_text = first_quest_reveal_text(&quest) - .map(|text| format!("那就拜托你了。{text}")) - .unwrap_or_else(|| { - format!( - "那就拜托你了。{}", - read_optional_string_field(&quest, "summary") - .unwrap_or_else(|| "这份委托的关键要点我已经交给你。".to_string()) - ) - }); - let dialogue = append_dialogue_turns( - pending_offer.dialogue.as_slice(), - vec![ - json!({ - "speaker": "player", - "text": "这件事我愿意接下,你把关键要点交给我。" - }), - json!({ - "speaker": "npc", - "speakerName": encounter.npc_name, - "text": reply_text, - }), - ], - ); - let options = build_post_quest_accept_chat_options(encounter.npc_id.as_str()); - let saved_current_story = build_pending_quest_offer_story( - dialogue, - encounter.npc_id.as_str(), - encounter.npc_name.as_str(), - pending_offer.turn_count, - pending_offer.custom_input_placeholder.as_str(), - None, - options.as_slice(), - ); - - Ok(StoryResolution { - action_text: resolve_action_text(&format!("接下{}的委托", encounter.npc_name), request), - result_text: build_quest_accept_result_text(&quest), - story_text: Some( - saved_current_story["text"] - .as_str() - .unwrap_or_default() - .to_string(), - ), - presentation_options: Some(options), - saved_current_story: Some(saved_current_story), - patches: vec![], - battle: None, - toast: None, - }) -} - -pub(super) fn resolve_pending_quest_turn_in_action( - game_state: &mut Value, - request: &RuntimeStoryActionRequest, -) -> Result { - let encounter = current_encounter_npc_quest_context(game_state)?; - let quest_id = request - .action - .payload - .as_ref() - .and_then(|payload| read_optional_string_field(payload, "questId")) - .or_else(|| request.action.target_id.clone()) - .or_else(|| { - find_active_quest_for_issuer(game_state, encounter.npc_id.as_str()) - .and_then(|quest| read_optional_string_field(quest, "id")) - }) - .ok_or_else(|| "当前没有可交付的委托。".to_string())?; - let turned_in = turn_in_quest_record(game_state, encounter.npc_id.as_str(), quest_id.as_str())?; - let previous_affinity = read_current_npc_affinity(game_state); - let affinity_bonus = read_field(&turned_in, "reward") - .and_then(|reward| read_i32_field(reward, "affinityBonus")) - .unwrap_or(0); - let next_affinity = previous_affinity.saturating_add(affinity_bonus); - write_current_npc_state_i32_field(game_state, "affinity", next_affinity); - write_current_npc_state_bool_field(game_state, "firstMeaningfulContactResolved", true); - apply_quest_turn_in_rewards(game_state, &turned_in); - - Ok(StoryResolution { - action_text: resolve_action_text(&format!("向{}交付委托", encounter.npc_name), request), - result_text: build_quest_turn_in_result_text(&turned_in), - story_text: None, - presentation_options: None, - saved_current_story: None, - patches: vec![RuntimeStoryPatch::NpcAffinityChanged { - npc_id: encounter.npc_id, - previous_affinity, - next_affinity, - }], - battle: None, - toast: None, - }) -} diff --git a/server-rs/crates/api-server/src/runtime_story/compat/tests.rs b/server-rs/crates/api-server/src/runtime_story/compat/tests.rs deleted file mode 100644 index 2b7b60ae..00000000 --- a/server-rs/crates/api-server/src/runtime_story/compat/tests.rs +++ /dev/null @@ -1,3235 +0,0 @@ -use axum::{ - body::Body, - http::{Request, StatusCode}, -}; -use http_body_util::BodyExt; -use platform_auth::{ - AccessTokenClaims, AccessTokenClaimsInput, AuthProvider, BindingStatus, sign_access_token, -}; -use serde_json::{Value, json}; -use time::OffsetDateTime; -use tower::ServiceExt; - -use super::*; -use crate::{app::build_router, config::AppConfig, state::AppState}; - -#[tokio::test] -async fn runtime_story_state_resolve_requires_authentication() { - let app = build_router(AppState::new(AppConfig::default()).expect("state should build")); - - let response = app - .oneshot( - Request::builder() - .method("POST") - .uri("/api/runtime/story/state/resolve") - .header("content-type", "application/json") - .body(Body::from( - json!({ - "sessionId": "runtime-main", - "snapshot": { - "bottomTab": "adventure", - "gameState": { - "runtimeSessionId": "runtime-main" - }, - "currentStory": null - } - }) - .to_string(), - )) - .expect("request should build"), - ) - .await - .expect("request should succeed"); - - assert_eq!(response.status(), StatusCode::UNAUTHORIZED); -} - -#[tokio::test] -async fn runtime_story_state_get_requires_authentication() { - let app = build_router(AppState::new(AppConfig::default()).expect("state should build")); - - let response = app - .oneshot( - Request::builder() - .method("GET") - .uri("/api/runtime/story/state/runtime-main") - .body(Body::empty()) - .expect("request should build"), - ) - .await - .expect("request should succeed"); - - assert_eq!(response.status(), StatusCode::UNAUTHORIZED); -} - -#[tokio::test] -async fn runtime_story_action_resolve_requires_authentication() { - let app = build_router(AppState::new(AppConfig::default()).expect("state should build")); - - let response = app - .oneshot( - Request::builder() - .method("POST") - .uri("/api/runtime/story/actions/resolve") - .header("content-type", "application/json") - .body(Body::from( - json!({ - "sessionId": "runtime-main", - "action": { - "type": "story_choice", - "functionId": "idle_rest_focus" - } - }) - .to_string(), - )) - .expect("request should build"), - ) - .await - .expect("request should succeed"); - - assert_eq!(response.status(), StatusCode::UNAUTHORIZED); -} - -#[tokio::test] -async fn runtime_story_routes_resolve_through_rust_route_boundary() { - let state = seed_authenticated_state().await; - let token = issue_access_token(&state); - seed_runtime_story_snapshot( - &state, - build_runtime_story_boundary_game_state_fixture(), - Some(json!({ - "text": "巡路人看着你,像在等一句开口。", - "options": [] - })), - ) - .await; - let app = build_router(state); - - let state_response = app - .clone() - .oneshot( - Request::builder() - .method("GET") - .uri("/api/runtime/story/state/runtime-main") - .header("authorization", format!("Bearer {token}")) - .header("x-genarrative-response-envelope", "v1") - .body(Body::empty()) - .expect("request should build"), - ) - .await - .expect("request should succeed"); - assert_eq!(state_response.status(), StatusCode::OK); - let state_payload: Value = serde_json::from_slice( - &state_response - .into_body() - .collect() - .await - .expect("body should collect") - .to_bytes(), - ) - .expect("response should be json"); - assert!( - state_payload["data"]["viewModel"]["availableOptions"] - .as_array() - .is_some_and(|options| options - .iter() - .any(|option| { option["functionId"] == json!("npc_chat") })) - ); - - let action_response = app - .oneshot( - Request::builder() - .method("POST") - .uri("/api/runtime/story/actions/resolve") - .header("authorization", format!("Bearer {token}")) - .header("content-type", "application/json") - .header("x-genarrative-response-envelope", "v1") - .body(Body::from( - json!({ - "sessionId": "runtime-main", - "clientVersion": 0, - "action": { - "type": "story_choice", - "functionId": "npc_chat" - } - }) - .to_string(), - )) - .expect("request should build"), - ) - .await - .expect("request should succeed"); - assert_eq!(action_response.status(), StatusCode::OK); - let action_payload: Value = serde_json::from_slice( - &action_response - .into_body() - .collect() - .await - .expect("body should collect") - .to_bytes(), - ) - .expect("response should be json"); - assert_eq!(action_payload["data"]["serverVersion"], json!(1)); - assert_eq!( - action_payload["data"]["viewModel"]["encounter"]["affinity"], - json!(52) - ); -} - -#[tokio::test] -async fn runtime_story_preview_snapshot_returns_transient_response_without_overwriting_save() { - let state = seed_authenticated_state().await; - let token = issue_access_token(&state); - seed_runtime_story_snapshot( - &state, - json!({ - "runtimeSessionId": "runtime-main", - "runtimeActionVersion": 1, - "worldType": "WUXIA", - "playerCharacter": { "id": "hero" }, - "currentScene": "Story", - "runtimeStats": { "playTimeMs": 0 }, - "storyHistory": [] - }), - Some(json!({ - "text": "正式存档里的故事。", - "options": [] - })), - ) - .await; - let app = build_router(state); - - let preview_response = app - .clone() - .oneshot( - Request::builder() - .method("POST") - .uri("/api/runtime/story/actions/resolve") - .header("authorization", format!("Bearer {token}")) - .header("content-type", "application/json") - .header("x-genarrative-response-envelope", "v1") - .body(Body::from( - json!({ - "sessionId": "runtime-main", - "clientVersion": 3, - "action": { - "type": "story_choice", - "functionId": "idle_rest_focus" - }, - "snapshot": { - "bottomTab": "adventure", - "gameState": { - "runtimeSessionId": "runtime-main", - "runtimeActionVersion": 3, - "runtimeMode": "preview", - "runtimePersistenceDisabled": true, - "playerHp": 10, - "playerMaxHp": 30, - "playerMana": 2, - "playerMaxMana": 12, - "storyHistory": [] - }, - "currentStory": { - "text": "幕预览里的临时故事。", - "options": [] - } - } - }) - .to_string(), - )) - .expect("request should build"), - ) - .await - .expect("request should succeed"); - assert_eq!(preview_response.status(), StatusCode::OK); - let preview_payload: Value = serde_json::from_slice( - &preview_response - .into_body() - .collect() - .await - .expect("body should collect") - .to_bytes(), - ) - .expect("response should be json"); - assert_eq!( - preview_payload["data"]["snapshot"]["gameState"]["runtimeMode"], - json!("preview") - ); - - let saved_response = app - .oneshot( - Request::builder() - .method("GET") - .uri("/api/runtime/save/snapshot") - .header("authorization", format!("Bearer {token}")) - .header("x-genarrative-response-envelope", "v1") - .body(Body::empty()) - .expect("request should build"), - ) - .await - .expect("request should succeed"); - assert_eq!(saved_response.status(), StatusCode::OK); - let saved_payload: Value = serde_json::from_slice( - &saved_response - .into_body() - .collect() - .await - .expect("body should collect") - .to_bytes(), - ) - .expect("response should be json"); - - assert_eq!( - saved_payload["data"]["currentStory"]["text"], - json!("正式存档里的故事。") - ); - assert!(saved_payload["data"]["gameState"]["runtimeMode"].is_null()); -} - -#[tokio::test] -async fn runtime_story_action_resolve_rejects_client_version_conflict() { - let state = seed_authenticated_state().await; - let token = issue_access_token(&state); - seed_runtime_story_snapshot( - &state, - json!({ - "runtimeSessionId": "runtime-main", - "runtimeActionVersion": 5, - "playerHp": 20, - "playerMaxHp": 30, - "playerMana": 4, - "playerMaxMana": 12, - "storyHistory": [] - }), - Some(json!({ - "text": "旧局势仍然悬着。", - "options": [] - })), - ) - .await; - let app = build_router(state); - - let response = app - .oneshot( - Request::builder() - .method("POST") - .uri("/api/runtime/story/actions/resolve") - .header("authorization", format!("Bearer {token}")) - .header("content-type", "application/json") - .header("x-genarrative-response-envelope", "v1") - .body(Body::from( - json!({ - "sessionId": "runtime-main", - "clientVersion": 4, - "action": { - "type": "story_choice", - "functionId": "idle_rest_focus" - } - }) - .to_string(), - )) - .expect("request should build"), - ) - .await - .expect("request should succeed"); - - assert_eq!(response.status(), StatusCode::CONFLICT); - let payload: Value = serde_json::from_slice( - &response - .into_body() - .collect() - .await - .expect("body should collect") - .to_bytes(), - ) - .expect("response should be json"); - assert_eq!(payload["error"]["details"]["clientVersion"], json!(4)); - assert_eq!(payload["error"]["details"]["serverVersion"], json!(5)); -} - -#[tokio::test] -async fn runtime_story_initial_returns_fallback_without_llm() { - let state = seed_authenticated_state().await; - let token = issue_access_token(&state); - let app = build_router(state); - - let response = app - .oneshot( - Request::builder() - .method("POST") - .uri("/api/runtime/story/initial") - .header("authorization", format!("Bearer {token}")) - .header("content-type", "application/json") - .header("x-genarrative-response-envelope", "v1") - .body(Body::from( - json!({ - "worldType": "martial", - "character": { "name": "林迟" }, - "monsters": [], - "context": { "sceneName": "旧驿道" }, - "requestOptions": { - "availableOptions": [{ - "functionId": "idle_observe_signs", - "actionText": "观察周围迹象" - }] - } - }) - .to_string(), - )) - .expect("request should build"), - ) - .await - .expect("request should succeed"); - - assert_eq!(response.status(), StatusCode::OK); - - let body = response - .into_body() - .collect() - .await - .expect("body should collect") - .to_bytes(); - let payload: Value = serde_json::from_slice(&body).expect("response body should be valid json"); - - assert_eq!(payload["ok"], Value::Bool(true)); - assert_eq!( - payload["data"]["options"][0]["functionId"], - json!("idle_observe_signs") - ); - assert!( - payload["data"]["storyText"] - .as_str() - .is_some_and(|text| text.contains("林迟")) - ); -} - -#[tokio::test] -async fn runtime_story_initial_uses_server_snapshot_prompt_context_when_session_id_present() { - let state = seed_authenticated_state().await; - let token = issue_access_token(&state); - let mut game_state = build_runtime_story_boundary_game_state_fixture(); - ensure_json_object(&mut game_state).insert( - "playerCharacter".to_string(), - json!({ - "id": "hero-story", - "name": "后端角色", - "title": "试剑客", - "description": "站在桥口的人。", - "personality": "谨慎", - "skills": [] - }), - ); - ensure_json_object(&mut game_state).insert( - "currentScenePreset".to_string(), - json!({ - "id": "server-scene", - "name": "后端场景", - "description": "这段描述只存在于服务端快照。", - "mutationStateText": "风里有新近留下的脚印。", - "currentPressureLevel": "high", - "npcs": [], - "treasureHints": [] - }), - ); - seed_runtime_story_snapshot(&state, game_state, None).await; - let app = build_router(state); - - let response = app - .oneshot( - Request::builder() - .method("POST") - .uri("/api/runtime/story/initial") - .header("authorization", format!("Bearer {token}")) - .header("content-type", "application/json") - .header("x-genarrative-response-envelope", "v1") - .body(Body::from( - json!({ - "sessionId": "runtime-main", - "clientVersion": 0, - "worldType": "browser-world", - "character": { "name": "浏览器角色" }, - "context": { "sceneName": "浏览器场景" }, - "requestOptions": { - "availableOptions": [{ - "functionId": "idle_observe_signs", - "actionText": "观察周围迹象" - }] - } - }) - .to_string(), - )) - .expect("request should build"), - ) - .await - .expect("request should succeed"); - - assert_eq!(response.status(), StatusCode::OK); - let body = response - .into_body() - .collect() - .await - .expect("body should collect") - .to_bytes(); - let payload: Value = serde_json::from_slice(&body).expect("response body should be valid json"); - - assert!( - payload["data"]["storyText"] - .as_str() - .is_some_and(|text| text.contains("后端角色") && text.contains("后端场景")) - ); - assert!( - payload["data"]["storyText"] - .as_str() - .is_some_and(|text| !text.contains("浏览器角色") && !text.contains("浏览器场景")) - ); - assert_eq!( - payload["data"]["options"][0]["functionId"], - json!("idle_observe_signs") - ); -} - -#[test] -fn runtime_story_state_compiler_prefers_dialogue_deferred_options() { - let response = build_runtime_story_state_response( - "runtime-main", - Some(7), - RuntimeStorySnapshotPayload { - saved_at: None, - bottom_tab: "adventure".to_string(), - game_state: json!({ - "runtimeSessionId": "runtime-main", - "runtimeActionVersion": 7, - "playerHp": 32, - "playerMaxHp": 40, - "playerMana": 18, - "playerMaxMana": 20, - "inBattle": false, - "npcInteractionActive": true, - "currentEncounter": { - "id": "npc_camp_firekeeper", - "kind": "npc", - "npcName": "守火人", - "hostile": false - }, - "npcStates": { - "npc_camp_firekeeper": { - "affinity": 12, - "recruited": false - } - }, - "companions": [{ - "npcId": "npc_companion_001", - "characterId": "char_companion_001", - "joinedAtAffinity": 64 - }] - }), - current_story: Some(json!({ - "text": "守火人抬眼看了你一瞬,示意你把想问的话继续说完。", - "displayMode": "dialogue", - "options": [{ - "functionId": "story_continue_adventure", - "actionText": "继续冒险" - }], - "deferredOptions": [{ - "functionId": "npc_chat", - "actionText": "继续交谈", - "detailText": "围绕当前话题继续推进关系判断。", - "interaction": { - "kind": "npc", - "npcId": "npc_camp_firekeeper", - "action": "chat" - }, - "runtimePayload": { - "note": "server-runtime-test" - } - }] - })), - }, - ); - - assert_eq!(response.session_id, "runtime-main"); - assert_eq!(response.server_version, 7); - assert_eq!( - response - .view_model - .encounter - .as_ref() - .expect("encounter should exist") - .npc_name, - "守火人" - ); - assert_eq!( - response.view_model.available_options[0].function_id, - "npc_chat" - ); - assert!(matches!( - response.presentation.options[0].interaction, - Some(RuntimeStoryOptionInteraction::Npc { .. }) - )); -} - -#[test] -fn runtime_story_action_resolution_updates_version_and_history() { - let request = RuntimeStoryActionRequest { - session_id: "runtime-main".to_string(), - client_version: Some(3), - action: shared_contracts::runtime_story::RuntimeStoryChoiceAction { - action_type: "story_choice".to_string(), - function_id: "idle_rest_focus".to_string(), - target_id: None, - payload: Some(json!({ "optionText": "原地调息" })), - }, - snapshot: None, - }; - let mut game_state = json!({ - "runtimeSessionId": "runtime-main", - "runtimeActionVersion": 3, - "playerHp": 10, - "playerMaxHp": 30, - "playerMana": 2, - "playerMaxMana": 12, - "storyHistory": [] - }); - - let resolution = - resolve_runtime_story_choice_action(&mut game_state, None, &request, "idle_rest_focus") - .expect("action should resolve"); - let next_version = read_u32_field(&game_state, "runtimeActionVersion") - .unwrap_or(3) - .saturating_add(1); - write_u32_field(&mut game_state, "runtimeActionVersion", next_version); - append_story_history( - &mut game_state, - resolution.action_text.as_str(), - resolution.result_text.as_str(), - ); - - assert_eq!(read_i32_field(&game_state, "playerHp"), Some(18)); - assert_eq!(read_i32_field(&game_state, "playerMana"), Some(8)); - assert_eq!(read_u32_field(&game_state, "runtimeActionVersion"), Some(4)); - assert_eq!( - read_array_field(&game_state, "storyHistory") - .first() - .and_then(|entry| read_optional_string_field(entry, "historyRole")), - Some("action".to_string()) - ); -} - -#[test] -fn runtime_story_state_compiler_builds_combat_options_with_skill_and_item_metadata() { - let response = build_runtime_story_state_response( - "runtime-main", - Some(0), - RuntimeStorySnapshotPayload { - saved_at: None, - bottom_tab: "adventure".to_string(), - game_state: json!({ - "runtimeSessionId": "runtime-main", - "runtimeActionVersion": 0, - "inBattle": true, - "npcInteractionActive": false, - "playerHp": 20, - "playerMaxHp": 40, - "playerMana": 4, - "playerMaxMana": 16, - "playerSkillCooldowns": { - "slash": 2 - }, - "playerCharacter": { - "attributes": { - "strength": 8, - "agility": 6 - }, - "skills": [ - { - "id": "slash", - "name": "试锋斩", - "damage": 18, - "manaCost": 4, - "cooldownTurns": 2 - }, - { - "id": "wind-step", - "name": "断风步", - "damage": 12, - "manaCost": 3, - "cooldownTurns": 1 - } - ] - }, - "playerInventory": [{ - "id": "focus-tonic", - "name": "凝神灵液", - "quantity": 1, - "useProfile": { - "hpRestore": 12, - "manaRestore": 6, - "cooldownReduction": 1 - } - }], - "currentEncounter": { - "kind": "npc", - "id": "npc_bandit_01", - "npcName": "断桥匪首", - "hostile": true - }, - "sceneHostileNpcs": [{ - "id": "npc_bandit_01", - "name": "断桥匪首", - "hp": 80, - "maxHp": 80 - }] - }), - current_story: None, - }, - ); - - let function_ids = response - .view_model - .available_options - .iter() - .map(|option| option.function_id.as_str()) - .collect::>(); - assert_eq!( - function_ids, - vec![ - "battle_attack_basic", - "battle_recover_breath", - "inventory_use", - "battle_use_skill", - "battle_use_skill", - "battle_escape_breakout" - ] - ); - - let inventory_option = &response.view_model.available_options[2]; - assert_eq!( - inventory_option.payload, - Some(json!({ "itemId": "focus-tonic" })) - ); - assert_eq!(inventory_option.disabled, None); - - let slash_option = &response.view_model.available_options[3]; - assert_eq!(slash_option.action_text, "试锋斩"); - assert_eq!(slash_option.payload, Some(json!({ "skillId": "slash" }))); - assert_eq!(slash_option.disabled, Some(true)); - assert_eq!(slash_option.reason.as_deref(), Some("冷却中,还需 2 回合")); - - let wind_step_option = &response.view_model.available_options[4]; - assert_eq!(wind_step_option.action_text, "断风步"); - assert_eq!( - wind_step_option.payload, - Some(json!({ "skillId": "wind-step" })) - ); - assert_eq!(wind_step_option.disabled, None); -} - -#[test] -fn runtime_story_battle_use_skill_writes_cooldown_and_build_buff() { - let request = RuntimeStoryActionRequest { - session_id: "runtime-main".to_string(), - client_version: Some(0), - action: shared_contracts::runtime_story::RuntimeStoryChoiceAction { - action_type: "story_choice".to_string(), - function_id: "battle_use_skill".to_string(), - target_id: None, - payload: Some(json!({ - "optionText": "试锋斩", - "skillId": "slash" - })), - }, - snapshot: None, - }; - let mut game_state = build_runtime_story_boundary_game_state_fixture(); - write_bool_field(&mut game_state, "inBattle", true); - write_bool_field(&mut game_state, "npcInteractionActive", false); - write_string_field(&mut game_state, "currentNpcBattleMode", "fight"); - write_i32_field(&mut game_state, "playerMana", 9); - let root = ensure_json_object(&mut game_state); - root.insert( - "currentEncounter".to_string(), - json!({ - "kind": "npc", - "id": "npc_bandit_01", - "npcName": "断桥匪首", - "hostile": true - }), - ); - root.insert( - "sceneHostileNpcs".to_string(), - json!([{ - "id": "npc_bandit_01", - "name": "断桥匪首", - "hp": 80, - "maxHp": 80 - }]), - ); - root.insert( - "playerCharacter".to_string(), - json!({ - "attributes": { - "strength": 8, - "agility": 6 - }, - "skills": [{ - "id": "slash", - "name": "试锋斩", - "damage": 18, - "manaCost": 4, - "cooldownTurns": 2, - "buildBuffs": [{ - "id": "slash:buff", - "sourceType": "skill", - "sourceId": "slash", - "name": "试锋余势", - "tags": ["快剑"], - "durationTurns": 2 - }] - }] - }), - ); - - let resolution = - resolve_runtime_story_choice_action(&mut game_state, None, &request, "battle_use_skill") - .expect("battle use skill should resolve"); - - assert_eq!(read_i32_field(&game_state, "playerMana"), Some(5)); - assert_eq!( - read_field(&game_state, "playerSkillCooldowns") - .and_then(|cooldowns| read_i32_field(cooldowns, "slash")), - Some(2) - ); - assert_eq!( - read_array_field(&game_state, "activeBuildBuffs") - .first() - .and_then(|buff| read_optional_string_field(buff, "id")), - Some("slash:buff".to_string()) - ); - assert!(matches!( - resolution.patches.first(), - Some(RuntimeStoryPatch::BattleResolved { - function_id, - outcome, - .. - }) if function_id == "battle_use_skill" && outcome == "ongoing" - )); -} - -#[test] -fn runtime_story_inventory_use_writes_item_consumption_and_victory_rewards() { - let request = RuntimeStoryActionRequest { - session_id: "runtime-main".to_string(), - client_version: Some(0), - action: shared_contracts::runtime_story::RuntimeStoryChoiceAction { - action_type: "story_choice".to_string(), - function_id: "inventory_use".to_string(), - target_id: None, - payload: Some(json!({ - "optionText": "使用凝神灵液", - "itemId": "focus-tonic" - })), - }, - snapshot: None, - }; - let mut game_state = build_runtime_story_boundary_game_state_fixture(); - write_bool_field(&mut game_state, "inBattle", true); - write_bool_field(&mut game_state, "npcInteractionActive", false); - write_string_field(&mut game_state, "currentNpcBattleMode", "fight"); - write_i32_field(&mut game_state, "playerHp", 20); - write_i32_field(&mut game_state, "playerMana", 4); - let root = ensure_json_object(&mut game_state); - root.insert( - "playerSkillCooldowns".to_string(), - json!({ - "slash": 2 - }), - ); - root.insert( - "playerInventory".to_string(), - json!([{ - "id": "focus-tonic", - "name": "凝神灵液", - "quantity": 1, - "useProfile": { - "hpRestore": 12, - "manaRestore": 6, - "cooldownReduction": 1, - "buildBuffs": [{ - "id": "focus-tonic:buff", - "sourceType": "item", - "sourceId": "focus-tonic", - "name": "凝神增益", - "tags": ["快剑"], - "durationTurns": 2 - }] - } - }]), - ); - root.insert( - "currentEncounter".to_string(), - json!({ - "kind": "npc", - "id": "npc_bandit_01", - "npcName": "断桥匪首", - "hostile": true, - "experienceReward": 24 - }), - ); - root.insert( - "sceneHostileNpcs".to_string(), - json!([{ - "id": "npc_bandit_01", - "name": "断桥匪首", - "hp": 0, - "maxHp": 80, - "experienceReward": 24 - }]), - ); - - let resolution = - resolve_runtime_story_choice_action(&mut game_state, None, &request, "inventory_use") - .expect("inventory use should resolve"); - - assert_eq!(read_i32_field(&game_state, "playerHp"), Some(24)); - assert_eq!(read_i32_field(&game_state, "playerMana"), Some(10)); - assert_eq!(read_array_field(&game_state, "playerInventory").len(), 0); - assert_eq!( - read_field(&game_state, "runtimeStats") - .and_then(|stats| read_i32_field(stats, "itemsUsed")), - Some(1) - ); - assert_eq!( - read_field(&game_state, "runtimeStats") - .and_then(|stats| read_i32_field(stats, "hostileNpcsDefeated")), - Some(1) - ); - assert_eq!( - read_field(&game_state, "playerProgression") - .and_then(|progression| read_i32_field(progression, "totalXp")), - Some(24) - ); - assert_eq!( - read_field(&game_state, "playerProgression") - .and_then(|progression| read_optional_string_field(progression, "lastGrantedSource")), - Some("hostile_npc".to_string()) - ); - assert_eq!( - resolution.toast.as_deref(), - Some("Build 增益已写回当前快照") - ); -} - -#[test] -fn runtime_story_post_battle_finalizer_builds_server_victory_story_and_act_state() { - let request = RuntimeStoryActionRequest { - session_id: "runtime-main".to_string(), - client_version: Some(0), - action: shared_contracts::runtime_story::RuntimeStoryChoiceAction { - action_type: "story_choice".to_string(), - function_id: "battle_attack_basic".to_string(), - target_id: None, - payload: Some(json!({ "optionText": "普通攻击" })), - }, - snapshot: None, - }; - let mut game_state = build_runtime_story_post_battle_custom_state_fixture(); - write_bool_field(&mut game_state, "inBattle", true); - write_bool_field(&mut game_state, "npcInteractionActive", false); - write_string_field(&mut game_state, "currentNpcBattleMode", "fight"); - let root = ensure_json_object(&mut game_state); - root.insert( - "currentEncounter".to_string(), - json!({ - "kind": "npc", - "id": "npc-rival", - "npcName": "潮线看守", - "hostile": true, - "hp": 6, - "maxHp": 30 - }), - ); - root.insert( - "sceneHostileNpcs".to_string(), - json!([{ - "id": "npc-rival", - "name": "潮线看守", - "hp": 6, - "maxHp": 30 - }]), - ); - - let mut resolution = - resolve_runtime_story_choice_action(&mut game_state, None, &request, "battle_attack_basic") - .expect("battle should resolve"); - let mut options = resolution - .presentation_options - .take() - .unwrap_or_else(|| build_fallback_runtime_story_options(&game_state)); - let mut story_text = resolution.result_text.clone(); - let mut history_result_text = resolution.result_text.clone(); - let mut saved_current_story = build_legacy_current_story(story_text.as_str(), &options); - - let finalized = finalize_runtime_story_resolution_for_response( - &mut game_state, - &mut story_text, - &mut history_result_text, - &mut options, - &mut saved_current_story, - resolution.battle.as_ref(), - ); - - assert!(finalized); - assert_eq!( - read_field(&game_state, "currentEncounter"), - Some(&Value::Null) - ); - assert_eq!(read_bool_field(&game_state, "inBattle"), Some(false)); - assert_eq!( - read_field(&game_state, "storyEngineMemory") - .and_then(|memory| read_field(memory, "currentSceneActState")) - .and_then(|act| read_optional_string_field(act, "currentActId")), - Some("act-2".to_string()) - ); - assert_eq!(options[0].function_id, "story_continue_adventure"); - assert_eq!( - read_array_field(&saved_current_story, "deferredOptions") - .first() - .and_then(|option| read_optional_string_field(option, "functionId")), - Some("idle_travel_next_scene".to_string()) - ); -} - -#[test] -fn runtime_story_post_battle_finalizer_revives_player_on_server() { - let mut game_state = build_runtime_story_post_battle_custom_state_fixture(); - write_bool_field(&mut game_state, "inBattle", false); - write_i32_field(&mut game_state, "playerHp", 0); - write_i32_field(&mut game_state, "playerMana", 0); - write_string_field(&mut game_state, "currentNpcBattleOutcome", "fight_defeat"); - let mut story_text = "你在与潮线看守的交锋中被压制倒下。".to_string(); - let mut history_result_text = story_text.clone(); - let mut options = build_fallback_runtime_story_options(&game_state); - let mut saved_current_story = build_legacy_current_story(story_text.as_str(), &options); - let battle = RuntimeBattlePresentation { - target_id: Some("npc-rival".to_string()), - target_name: Some("潮线看守".to_string()), - damage_dealt: Some(8), - damage_taken: Some(30), - outcome: Some("defeat".to_string()), - }; - - let finalized = finalize_runtime_story_resolution_for_response( - &mut game_state, - &mut story_text, - &mut history_result_text, - &mut options, - &mut saved_current_story, - Some(&battle), - ); - - assert!(finalized); - assert_eq!(read_i32_field(&game_state, "playerHp"), Some(60)); - assert_eq!(read_i32_field(&game_state, "playerMana"), Some(20)); - assert_eq!( - read_object_field(&game_state, "currentScenePreset") - .and_then(|scene| read_optional_string_field(scene, "id")), - Some("custom-scene-camp".to_string()) - ); - assert_eq!( - read_object_field(&game_state, "currentEncounter") - .and_then(|encounter| read_optional_string_field(encounter, "id")), - Some("npc-rival".to_string()) - ); - assert_eq!(options[0].function_id, "story_continue_adventure"); - assert!( - read_optional_string_field(&saved_current_story, "text") - .is_some_and(|text| text.contains("重新醒来")) - ); - assert_eq!( - read_field(&game_state, "storyEngineMemory") - .and_then(|memory| read_field(memory, "currentSceneActState")) - .and_then(|act| read_optional_string_field(act, "currentActId")), - Some("act-1".to_string()) - ); -} - -#[test] -fn runtime_story_state_compiler_builds_active_npc_options_with_trade_gift_and_help_lock() { - let mut game_state = build_runtime_story_boundary_game_state_fixture(); - write_bool_field(&mut game_state, "npcInteractionActive", true); - write_current_npc_state_bool_field(&mut game_state, "helpUsed", true); - let root = ensure_json_object(&mut game_state); - root.insert( - "playerInventory".to_string(), - json!([{ - "id": "gift-herb", - "category": "材料", - "name": "暖息草", - "quantity": 1, - "rarity": "rare", - "tags": ["material", "mana"] - }]), - ); - let npc_states = root - .get_mut("npcStates") - .and_then(Value::as_object_mut) - .expect("npcStates should be object"); - let merchant_state = npc_states - .get_mut("npc_merchant_01") - .and_then(Value::as_object_mut) - .expect("merchant state should exist"); - merchant_state.insert( - "inventory".to_string(), - json!([{ - "id": "merchant-essence", - "category": "消耗品", - "name": "回气散", - "quantity": 3, - "rarity": "uncommon", - "tags": ["mana"] - }]), - ); - - let response = build_runtime_story_state_response( - "runtime-main", - Some(0), - RuntimeStorySnapshotPayload { - saved_at: None, - bottom_tab: "adventure".to_string(), - game_state, - current_story: None, - }, - ); - - let function_ids = response - .view_model - .available_options - .iter() - .map(|option| option.function_id.as_str()) - .collect::>(); - assert_eq!( - function_ids, - vec![ - "npc_chat", - "npc_help", - "npc_spar", - "npc_fight", - "npc_trade", - "npc_gift", - "npc_quest_accept", - "npc_leave" - ] - ); - assert_eq!( - response.view_model.available_options[1].disabled, - Some(true) - ); - assert_eq!( - response.view_model.available_options[1].reason.as_deref(), - Some("当前 NPC 的一次性援手已经用完了。") - ); - assert!(matches!( - response.view_model.available_options[4].interaction, - Some(RuntimeStoryOptionInteraction::Npc { ref action, .. }) if action == "trade" - )); - assert!(matches!( - response.view_model.available_options[5].interaction, - Some(RuntimeStoryOptionInteraction::Npc { ref action, .. }) if action == "gift" - )); - let npc_interaction = response - .view_model - .npc_interaction - .as_ref() - .expect("active npc interaction view should compile"); - assert_eq!(npc_interaction.npc_id, "npc_merchant_01"); - assert_eq!(npc_interaction.currency_name, "铜钱"); - assert_eq!(npc_interaction.trade.buy_items[0].unit_price, 29); - assert_eq!(npc_interaction.trade.buy_items[0].max_quantity, 3); - assert!(npc_interaction.trade.buy_items[0].can_submit); - assert_eq!(npc_interaction.trade.sell_items[0].unit_price, 28); - assert_eq!(npc_interaction.gift.items[0].affinity_gain, 16); - assert_eq!( - response.snapshot.game_state["runtimeNpcInteraction"]["trade"]["buyItems"][0]["unitPrice"], - json!(29) - ); -} - -#[test] -fn runtime_story_equipment_equip_updates_loadout_and_build_toast() { - let request = RuntimeStoryActionRequest { - session_id: "runtime-main".to_string(), - client_version: Some(0), - action: shared_contracts::runtime_story::RuntimeStoryChoiceAction { - action_type: "story_choice".to_string(), - function_id: "equipment_equip".to_string(), - target_id: None, - payload: Some(json!({ - "itemId": "ward-mail" - })), - }, - snapshot: None, - }; - let mut game_state = build_runtime_story_boundary_game_state_fixture(); - let root = ensure_json_object(&mut game_state); - root.insert( - "playerInventory".to_string(), - json!([{ - "id": "ward-mail", - "category": "护甲", - "name": "镇岳甲", - "quantity": 1, - "rarity": "rare", - "tags": ["armor", "守御", "护体"], - "equipmentSlotId": "armor", - "statProfile": { - "maxHpBonus": 24, - "outgoingDamageBonus": 0.04, - "incomingDamageMultiplier": 0.92 - }, - "buildProfile": { - "role": "守御", - "tags": ["守御", "护体"], - "synergy": ["守御", "护体"], - "forgeRank": 0 - } - }]), - ); - - let resolution = - resolve_runtime_story_choice_action(&mut game_state, None, &request, "equipment_equip") - .expect("equipment equip should resolve"); - - assert_eq!(read_array_field(&game_state, "playerInventory").len(), 0); - assert_eq!( - read_field(&game_state, "playerEquipment") - .and_then(|equipment| read_field(equipment, "armor")) - .and_then(|armor| read_optional_string_field(armor, "id")), - Some("ward-mail".to_string()) - ); - assert_eq!(read_i32_field(&game_state, "playerMaxHp"), Some(64)); - assert_eq!(resolution.toast.as_deref(), Some("当前 Build 倍率 x1.04")); - assert!(resolution.result_text.contains("镇岳甲")); -} - -#[test] -fn runtime_story_equipment_unequip_returns_item_to_inventory_and_resets_loadout() { - let request = RuntimeStoryActionRequest { - session_id: "runtime-main".to_string(), - client_version: Some(0), - action: shared_contracts::runtime_story::RuntimeStoryChoiceAction { - action_type: "story_choice".to_string(), - function_id: "equipment_unequip".to_string(), - target_id: Some("armor".to_string()), - payload: Some(json!({ - "slotId": "护甲" - })), - }, - snapshot: None, - }; - let mut game_state = build_runtime_story_boundary_game_state_fixture(); - let root = ensure_json_object(&mut game_state); - root.insert( - "playerEquipment".to_string(), - json!({ - "weapon": null, - "armor": { - "id": "ward-mail", - "category": "护甲", - "name": "镇岳甲", - "quantity": 1, - "rarity": "rare", - "tags": ["armor", "守御", "护体"], - "equipmentSlotId": "armor", - "statProfile": { - "maxHpBonus": 24, - "outgoingDamageBonus": 0.04, - "incomingDamageMultiplier": 0.92 - }, - "buildProfile": { - "role": "守御", - "tags": ["守御", "护体"], - "synergy": ["守御", "护体"], - "forgeRank": 0 - } - }, - "relic": null - }), - ); - apply_equipment_loadout_to_state(&mut game_state); - - let resolution = - resolve_runtime_story_choice_action(&mut game_state, None, &request, "equipment_unequip") - .expect("equipment unequip should resolve"); - - assert_eq!( - read_field(&game_state, "playerEquipment") - .and_then(|equipment| read_field(equipment, "armor")) - .cloned(), - Some(Value::Null) - ); - assert_eq!(read_array_field(&game_state, "playerInventory").len(), 1); - assert_eq!( - read_array_field(&game_state, "playerInventory") - .first() - .and_then(|item| read_optional_string_field(item, "id")), - Some("ward-mail".to_string()) - ); - assert_eq!(read_i32_field(&game_state, "playerMaxHp"), Some(40)); - assert_eq!(resolution.toast.as_deref(), Some("当前 Build 倍率 x1.00")); - assert!(resolution.result_text.contains("镇岳甲")); -} - -#[test] -fn runtime_story_forge_craft_consumes_materials_and_currency() { - let request = RuntimeStoryActionRequest { - session_id: "runtime-main".to_string(), - client_version: Some(0), - action: shared_contracts::runtime_story::RuntimeStoryChoiceAction { - action_type: "story_choice".to_string(), - function_id: "forge_craft".to_string(), - target_id: None, - payload: Some(json!({ - "recipeId": "synthesis-refined-ingot" - })), - }, - snapshot: None, - }; - let mut game_state = build_runtime_story_boundary_game_state_fixture(); - let root = ensure_json_object(&mut game_state); - root.insert( - "playerInventory".to_string(), - json!([ - { - "id": "scrap-a", - "category": "材料", - "name": "旧铜片", - "quantity": 2, - "rarity": "common", - "tags": ["material", "工巧"] - }, - { - "id": "scrap-b", - "category": "材料", - "name": "风化铁片", - "quantity": 1, - "rarity": "common", - "tags": ["material", "守御"] - } - ]), - ); - - let resolution = - resolve_runtime_story_choice_action(&mut game_state, None, &request, "forge_craft") - .expect("forge craft should resolve"); - - assert_eq!(read_i32_field(&game_state, "playerCurrency"), Some(72)); - let inventory = read_array_field(&game_state, "playerInventory"); - assert_eq!(inventory.len(), 1); - let created_item = inventory.first().expect("crafted item should exist"); - assert_eq!( - read_optional_string_field(created_item, "name"), - Some("精炼锭材".to_string()) - ); - assert_eq!(read_i32_field(created_item, "quantity"), Some(1)); - assert!(resolution.result_text.contains("压炼锭材")); - assert!(resolution.result_text.contains("18 铜钱")); -} - -#[test] -fn runtime_story_forge_dismantle_replaces_item_with_material_outputs() { - let request = RuntimeStoryActionRequest { - session_id: "runtime-main".to_string(), - client_version: Some(0), - action: shared_contracts::runtime_story::RuntimeStoryChoiceAction { - action_type: "story_choice".to_string(), - function_id: "forge_dismantle".to_string(), - target_id: None, - payload: Some(json!({ - "itemId": "duelist-blade" - })), - }, - snapshot: None, - }; - let mut game_state = build_runtime_story_boundary_game_state_fixture(); - let root = ensure_json_object(&mut game_state); - root.insert( - "playerInventory".to_string(), - json!([{ - "id": "duelist-blade", - "category": "武器", - "name": "百炼追风剑", - "quantity": 1, - "rarity": "epic", - "tags": ["weapon", "快剑", "突进", "追击"], - "equipmentSlotId": "weapon", - "statProfile": { - "maxManaBonus": 10, - "outgoingDamageBonus": 0.2 - }, - "buildProfile": { - "role": "快剑", - "tags": ["快剑", "突进", "追击"], - "synergy": ["快剑", "突进", "追击"], - "forgeRank": 1 - } - }]), - ); - - let resolution = - resolve_runtime_story_choice_action(&mut game_state, None, &request, "forge_dismantle") - .expect("forge dismantle should resolve"); - - let inventory = read_array_field(&game_state, "playerInventory"); - assert_eq!(inventory.len(), 3); - assert_eq!( - inventory - .iter() - .find(|item| read_optional_string_field(item, "name").as_deref() == Some("武器残片")) - .and_then(|item| read_i32_field(item, "quantity")), - Some(4) - ); - assert!(inventory.iter().any(|item| { - read_optional_string_field(item, "name").as_deref() == Some("快剑精粹") - })); - assert!(inventory.iter().any(|item| { - read_optional_string_field(item, "name").as_deref() == Some("突进精粹") - })); - assert!(resolution.result_text.contains("百炼追风剑")); - assert!(resolution.result_text.contains("武器残片")); -} - -#[test] -fn runtime_story_forge_reforge_upgrades_item_and_consumes_cost() { - let request = RuntimeStoryActionRequest { - session_id: "runtime-main".to_string(), - client_version: Some(0), - action: shared_contracts::runtime_story::RuntimeStoryChoiceAction { - action_type: "story_choice".to_string(), - function_id: "forge_reforge".to_string(), - target_id: None, - payload: Some(json!({ - "itemId": "duelist-blade" - })), - }, - snapshot: None, - }; - let mut game_state = build_runtime_story_boundary_game_state_fixture(); - let root = ensure_json_object(&mut game_state); - root.insert( - "playerInventory".to_string(), - json!([ - { - "id": "duelist-blade", - "category": "武器", - "name": "百炼追风剑", - "quantity": 1, - "rarity": "epic", - "tags": ["weapon", "快剑", "突进", "追击"], - "equipmentSlotId": "weapon", - "description": "为快剑与追身构筑准备的锻造兵刃。", - "statProfile": { - "maxManaBonus": 10, - "outgoingDamageBonus": 0.2 - }, - "buildProfile": { - "role": "快剑", - "tags": ["快剑", "突进"], - "synergy": ["快剑", "突进"], - "forgeRank": 1 - } - }, - { - "id": "refined-ingot", - "category": "材料", - "name": "精炼锭材", - "quantity": 1, - "rarity": "rare", - "tags": ["material", "工巧", "守御"] - } - ]), - ); - - let resolution = - resolve_runtime_story_choice_action(&mut game_state, None, &request, "forge_reforge") - .expect("forge reforge should resolve"); - - assert_eq!(read_i32_field(&game_state, "playerCurrency"), Some(44)); - let inventory = read_array_field(&game_state, "playerInventory"); - assert_eq!(inventory.len(), 1); - let reforged_item = inventory.first().expect("reforged item should exist"); - assert!( - read_optional_string_field(reforged_item, "name").is_some_and(|name| name.contains("重铸")) - ); - assert_eq!( - read_field(reforged_item, "buildProfile") - .and_then(|profile| read_i32_field(profile, "forgeRank")), - Some(2) - ); - assert_eq!( - read_field(reforged_item, "statProfile") - .and_then(|profile| read_i32_field(profile, "maxManaBonus")), - Some(14) - ); - assert_eq!( - read_field(reforged_item, "statProfile") - .and_then(|profile| read_field(profile, "outgoingDamageBonus")) - .and_then(Value::as_f64), - Some(0.23) - ); - assert!(resolution.result_text.contains("46 铜钱")); - assert!(resolution.result_text.contains("百炼追风剑")); -} - -#[test] -fn runtime_story_npc_trade_buy_updates_currency_inventory_and_stock() { - let request = RuntimeStoryActionRequest { - session_id: "runtime-main".to_string(), - client_version: Some(0), - action: shared_contracts::runtime_story::RuntimeStoryChoiceAction { - action_type: "story_choice".to_string(), - function_id: "npc_trade".to_string(), - target_id: None, - payload: Some(json!({ - "mode": "buy", - "itemId": "merchant-essence", - "quantity": 2 - })), - }, - snapshot: None, - }; - let mut game_state = build_runtime_story_boundary_game_state_fixture(); - write_bool_field(&mut game_state, "npcInteractionActive", true); - write_i32_field(&mut game_state, "playerCurrency", 90); - let root = ensure_json_object(&mut game_state); - root.insert( - "currentEncounter".to_string(), - json!({ - "kind": "npc", - "id": "npc_merchant_02", - "npcName": "梁伯", - "npcDescription": "携带杂货箱的老人", - "context": "沿街商贩", - "characterId": "merchant-test" - }), - ); - root.insert( - "npcStates".to_string(), - json!({ - "npc_merchant_02": { - "affinity": 58, - "chattedCount": 1, - "helpUsed": false, - "giftsGiven": 0, - "inventory": [{ - "id": "merchant-essence", - "category": "消耗品", - "name": "回气散", - "quantity": 3, - "rarity": "uncommon", - "tags": ["mana"] - }], - "recruited": false - } - }), - ); - - let resolution = - resolve_runtime_story_choice_action(&mut game_state, None, &request, "npc_trade") - .expect("npc trade should resolve"); - - assert_eq!(read_i32_field(&game_state, "playerCurrency"), Some(32)); - assert_eq!( - read_array_field(&game_state, "playerInventory") - .first() - .and_then(|item| read_optional_string_field(item, "name")), - Some("回气散".to_string()) - ); - assert_eq!( - read_array_field(&game_state, "playerInventory") - .first() - .and_then(|item| read_i32_field(item, "quantity")), - Some(2) - ); - assert_eq!( - read_field(&game_state, "npcStates") - .and_then(|states| read_field(states, "npc_merchant_02")) - .map(|state| read_array_field(state, "inventory")) - .and_then(|items| items.first().copied()) - .and_then(|item| read_i32_field(item, "quantity")), - Some(1) - ); - assert!(resolution.result_text.contains("回气散")); -} - -#[test] -fn runtime_story_state_compiler_bootstraps_trade_inventory_for_role_npc() { - let mut game_state = build_runtime_story_boundary_game_state_fixture(); - let root = ensure_json_object(&mut game_state); - root.insert( - "currentEncounter".to_string(), - json!({ - "kind": "npc", - "id": "npc_merchant_bootstrap", - "npcName": "柳叔", - "npcDescription": "守在路边摊前的老商贩", - "context": "路边摊贩" - }), - ); - root.insert("npcStates".to_string(), json!({})); - - let response = build_runtime_story_state_response( - "runtime-main", - Some(0), - RuntimeStorySnapshotPayload { - saved_at: None, - bottom_tab: "adventure".to_string(), - game_state, - current_story: None, - }, - ); - - let function_ids = response - .view_model - .available_options - .iter() - .map(|option| option.function_id.as_str()) - .collect::>(); - assert!(function_ids.contains(&"npc_trade")); - assert_eq!( - read_field(&response.snapshot.game_state, "npcStates") - .and_then(|states| read_field(states, "npc_merchant_bootstrap")) - .and_then(|state| read_array_field(state, "inventory").first().copied()) - .and_then(|item| read_field(item, "runtimeMetadata")) - .and_then(|metadata| read_optional_string_field(metadata, "generationChannel")), - Some("npc_trade".to_string()) - ); - assert_eq!( - read_field(&response.snapshot.game_state, "npcStates") - .and_then(|states| read_field(states, "npc_merchant_bootstrap")) - .and_then(|state| read_field(state, "tradeStockSignature")) - .and_then(Value::as_str), - Some("npc_merchant_bootstrap:test-scene:WUXIA") - ); -} - -#[test] -fn runtime_story_npc_trade_buy_bootstraps_missing_npc_state() { - let request = RuntimeStoryActionRequest { - session_id: "runtime-main".to_string(), - client_version: Some(0), - action: shared_contracts::runtime_story::RuntimeStoryChoiceAction { - action_type: "story_choice".to_string(), - function_id: "npc_trade".to_string(), - target_id: None, - payload: Some(json!({ - "mode": "buy", - "itemId": "npc-trade:npc_merchant_bootstrap:test-scene:WUXIA:tonic", - "quantity": 1 - })), - }, - snapshot: None, - }; - let mut game_state = build_runtime_story_boundary_game_state_fixture(); - write_bool_field(&mut game_state, "npcInteractionActive", true); - write_i32_field(&mut game_state, "playerCurrency", 90); - let root = ensure_json_object(&mut game_state); - root.insert( - "currentEncounter".to_string(), - json!({ - "kind": "npc", - "id": "npc_merchant_bootstrap", - "npcName": "柳叔", - "npcDescription": "守在路边摊前的老商贩", - "context": "路边摊贩" - }), - ); - root.insert("npcStates".to_string(), json!({})); - - let resolution = - resolve_runtime_story_choice_action(&mut game_state, None, &request, "npc_trade") - .expect("npc trade should bootstrap and resolve"); - - assert_eq!(read_i32_field(&game_state, "playerCurrency"), Some(58)); - assert_eq!( - read_array_field(&game_state, "playerInventory") - .first() - .and_then(|item| read_optional_string_field(item, "name")), - Some("回气散".to_string()) - ); - assert_eq!( - read_field(&game_state, "npcStates") - .and_then(|states| read_field(states, "npc_merchant_bootstrap")) - .and_then(|state| read_array_field(state, "inventory").first().copied()) - .and_then(|item| read_i32_field(item, "quantity")), - Some(1) - ); - assert_eq!( - read_field(&game_state, "npcStates") - .and_then(|states| read_field(states, "npc_merchant_bootstrap")) - .and_then(|state| read_bool_field(state, "firstMeaningfulContactResolved")), - Some(true) - ); - assert!(resolution.result_text.contains("回气散")); -} - -#[test] -fn runtime_story_npc_trade_buy_rejects_stock_currency_and_invalid_quantity() { - let build_request = |quantity: i32| RuntimeStoryActionRequest { - session_id: "runtime-main".to_string(), - client_version: Some(0), - action: shared_contracts::runtime_story::RuntimeStoryChoiceAction { - action_type: "story_choice".to_string(), - function_id: "npc_trade".to_string(), - target_id: None, - payload: Some(json!({ - "mode": "buy", - "itemId": "merchant-essence", - "quantity": quantity - })), - }, - snapshot: None, - }; - let build_state = |player_currency: i32| { - let mut game_state = build_runtime_story_boundary_game_state_fixture(); - write_bool_field(&mut game_state, "npcInteractionActive", true); - write_i32_field(&mut game_state, "playerCurrency", player_currency); - let root = ensure_json_object(&mut game_state); - root.insert( - "npcStates".to_string(), - json!({ - "npc_merchant_01": { - "affinity": 58, - "chattedCount": 0, - "helpUsed": false, - "giftsGiven": 0, - "inventory": [{ - "id": "merchant-essence", - "category": "消耗品", - "name": "回气散", - "quantity": 3, - "rarity": "uncommon", - "tags": ["mana"] - }], - "recruited": false - } - }), - ); - game_state - }; - - let mut stock_state = build_state(120); - let stock_error = - resolve_runtime_story_choice_action(&mut stock_state, None, &build_request(4), "npc_trade"); - assert_eq!( - assert_runtime_story_error(stock_error, "stock shortage should be rejected"), - "目标商品不存在或库存不足。" - ); - assert_eq!(read_i32_field(&stock_state, "playerCurrency"), Some(120)); - - let mut currency_state = build_state(28); - let currency_error = resolve_runtime_story_choice_action( - &mut currency_state, - None, - &build_request(1), - "npc_trade", - ); - assert_eq!( - assert_runtime_story_error(currency_error, "currency shortage should be rejected"), - "当前钱币不足,无法完成购买。" - ); - assert_eq!(read_i32_field(¤cy_state, "playerCurrency"), Some(28)); - - let mut invalid_quantity_state = build_state(120); - let quantity_error = resolve_runtime_story_choice_action( - &mut invalid_quantity_state, - None, - &build_request(0), - "npc_trade", - ); - assert_eq!( - assert_runtime_story_error(quantity_error, "zero quantity should be rejected"), - "npc_trade.quantity 必须大于 0" - ); -} - -#[test] -fn runtime_story_npc_trade_sell_updates_currency_inventory_and_rejects_shortage() { - let request = RuntimeStoryActionRequest { - session_id: "runtime-main".to_string(), - client_version: Some(0), - action: shared_contracts::runtime_story::RuntimeStoryChoiceAction { - action_type: "story_choice".to_string(), - function_id: "npc_trade".to_string(), - target_id: None, - payload: Some(json!({ - "mode": "sell", - "itemId": "player-ingot", - "quantity": 2 - })), - }, - snapshot: None, - }; - let mut game_state = build_runtime_story_boundary_game_state_fixture(); - write_bool_field(&mut game_state, "npcInteractionActive", true); - write_i32_field(&mut game_state, "playerCurrency", 90); - ensure_json_object(&mut game_state).insert( - "playerInventory".to_string(), - json!([{ - "id": "player-ingot", - "category": "材料", - "name": "精炼锭材", - "quantity": 3, - "rarity": "rare", - "tags": ["material"], - "value": 50 - }]), - ); - - let resolution = - resolve_runtime_story_choice_action(&mut game_state, None, &request, "npc_trade") - .expect("sell trade should resolve"); - - assert_eq!(read_i32_field(&game_state, "playerCurrency"), Some(136)); - assert_eq!( - read_array_field(&game_state, "playerInventory") - .first() - .and_then(|item| read_i32_field(item, "quantity")), - Some(1) - ); - assert_eq!( - read_field(&game_state, "npcStates") - .and_then(|states| read_field(states, "npc_merchant_01")) - .map(|state| read_array_field(state, "inventory")) - .and_then(|items| items.first().copied()) - .and_then(|item| read_i32_field(item, "quantity")), - Some(2) - ); - assert!(resolution.result_text.contains("精炼锭材 x2")); - - let mut shortage_state = game_state; - let shortage_error = - resolve_runtime_story_choice_action(&mut shortage_state, None, &request, "npc_trade"); - assert_eq!( - assert_runtime_story_error(shortage_error, "selling more than owned should be rejected"), - "背包里没有足够数量的目标物品。" - ); -} - -#[test] -fn runtime_story_npc_gift_updates_affinity_inventory_and_patch() { - let request = RuntimeStoryActionRequest { - session_id: "runtime-main".to_string(), - client_version: Some(0), - action: shared_contracts::runtime_story::RuntimeStoryChoiceAction { - action_type: "story_choice".to_string(), - function_id: "npc_gift".to_string(), - target_id: None, - payload: Some(json!({ - "itemId": "gift-herb" - })), - }, - snapshot: None, - }; - let mut game_state = build_runtime_story_boundary_game_state_fixture(); - write_bool_field(&mut game_state, "npcInteractionActive", true); - let root = ensure_json_object(&mut game_state); - root.insert( - "currentEncounter".to_string(), - json!({ - "kind": "npc", - "id": "npc_merchant_03", - "npcName": "沈娘", - "npcDescription": "对药性很敏感的行脚商", - "context": "药商", - "characterId": "merchant-gift" - }), - ); - root.insert( - "playerInventory".to_string(), - json!([{ - "id": "gift-herb", - "category": "材料", - "name": "暖息草", - "quantity": 1, - "rarity": "rare", - "tags": ["material", "mana"] - }]), - ); - root.insert( - "npcStates".to_string(), - json!({ - "npc_merchant_03": { - "affinity": 22, - "chattedCount": 0, - "helpUsed": false, - "giftsGiven": 0, - "inventory": [], - "recruited": false - } - }), - ); - - let resolution = - resolve_runtime_story_choice_action(&mut game_state, None, &request, "npc_gift") - .expect("npc gift should resolve"); - - assert_eq!(read_array_field(&game_state, "playerInventory").len(), 0); - assert_eq!( - read_field(&game_state, "npcStates") - .and_then(|states| read_field(states, "npc_merchant_03")) - .and_then(|state| read_i32_field(state, "affinity")), - Some(38) - ); - assert_eq!( - read_field(&game_state, "npcStates") - .and_then(|states| read_field(states, "npc_merchant_03")) - .and_then(|state| read_i32_field(state, "giftsGiven")), - Some(1) - ); - assert!(matches!( - resolution.patches.first(), - Some(RuntimeStoryPatch::NpcAffinityChanged { - previous_affinity: 22, - next_affinity: 38, - .. - }) - )); -} - -#[test] -fn runtime_story_npc_gift_rejects_missing_item() { - let request = RuntimeStoryActionRequest { - session_id: "runtime-main".to_string(), - client_version: Some(0), - action: shared_contracts::runtime_story::RuntimeStoryChoiceAction { - action_type: "story_choice".to_string(), - function_id: "npc_gift".to_string(), - target_id: None, - payload: Some(json!({ - "itemId": "missing-gift" - })), - }, - snapshot: None, - }; - let mut game_state = build_runtime_story_boundary_game_state_fixture(); - write_bool_field(&mut game_state, "npcInteractionActive", true); - - let error = resolve_runtime_story_choice_action(&mut game_state, None, &request, "npc_gift"); - assert_eq!( - assert_runtime_story_error(error, "missing gift should be rejected"), - "背包里没有这件可赠送的物品。" - ); - assert_eq!( - read_field(&game_state, "npcStates") - .and_then(|states| read_field(states, "npc_merchant_01")) - .and_then(|state| read_i32_field(state, "affinity")), - Some(46) - ); -} - -#[tokio::test] -async fn runtime_story_route_boundary_persists_equipment_equip_snapshot_updates() { - let state = seed_authenticated_state().await; - let token = issue_access_token(&state); - let mut game_state = build_runtime_story_boundary_game_state_fixture(); - let root = ensure_json_object(&mut game_state); - root.insert( - "playerInventory".to_string(), - json!([{ - "id": "ward-mail", - "category": "护甲", - "name": "镇岳甲", - "quantity": 1, - "rarity": "rare", - "tags": ["armor", "守御", "护体"], - "equipmentSlotId": "armor", - "statProfile": { - "maxHpBonus": 24, - "outgoingDamageBonus": 0.04, - "incomingDamageMultiplier": 0.92 - } - }]), - ); - seed_runtime_story_snapshot( - &state, - game_state, - Some(json!({ - "text": "你低头检查身上的旧甲。", - "options": [] - })), - ) - .await; - let app = build_router(state); - - let action_response = app - .clone() - .oneshot( - Request::builder() - .method("POST") - .uri("/api/runtime/story/actions/resolve") - .header("authorization", format!("Bearer {token}")) - .header("content-type", "application/json") - .header("x-genarrative-response-envelope", "v1") - .body(Body::from( - json!({ - "sessionId": "runtime-main", - "clientVersion": 0, - "action": { - "type": "story_choice", - "functionId": "equipment_equip", - "payload": { - "itemId": "ward-mail" - } - } - }) - .to_string(), - )) - .expect("request should build"), - ) - .await - .expect("request should succeed"); - assert_eq!(action_response.status(), StatusCode::OK); - let action_payload: Value = serde_json::from_slice( - &action_response - .into_body() - .collect() - .await - .expect("body should collect") - .to_bytes(), - ) - .expect("response should be json"); - assert_eq!( - action_payload["data"]["snapshot"]["gameState"]["playerEquipment"]["armor"]["id"], - json!("ward-mail") - ); - assert_eq!( - action_payload["data"]["viewModel"]["player"]["maxHp"], - json!(64) - ); - - let state_response = app - .oneshot( - Request::builder() - .method("GET") - .uri("/api/runtime/story/state/runtime-main") - .header("authorization", format!("Bearer {token}")) - .header("x-genarrative-response-envelope", "v1") - .body(Body::empty()) - .expect("request should build"), - ) - .await - .expect("request should succeed"); - assert_eq!(state_response.status(), StatusCode::OK); - let state_payload: Value = serde_json::from_slice( - &state_response - .into_body() - .collect() - .await - .expect("body should collect") - .to_bytes(), - ) - .expect("response should be json"); - assert_eq!( - state_payload["data"]["snapshot"]["gameState"]["playerEquipment"]["armor"]["id"], - json!("ward-mail") - ); - assert_eq!( - state_payload["data"]["viewModel"]["player"]["maxHp"], - json!(64) - ); -} - -#[tokio::test] -async fn runtime_story_route_boundary_projects_story_engine_state() { - let state = seed_authenticated_state().await; - let token = issue_access_token(&state); - let mut game_state = build_runtime_story_boundary_game_state_fixture(); - let root = ensure_json_object(&mut game_state); - root.insert("currentScene".to_string(), json!("Story")); - root.insert("currentEncounter".to_string(), Value::Null); - root.insert( - "currentScenePreset".to_string(), - json!({ - "id": "scene-bridge", - "name": "断桥口", - "description": "风从桥下吹上来。", - "imageSrc": "", - "npcs": [{ - "id": "npc_merchant_01", - "name": "沈七", - "description": "腰间挂着药囊的行商", - "hostile": false - }] - }), - ); - root.insert( - "storyEngineMemory".to_string(), - json!({ - "activeThreadIds": ["thread-bridge"] - }), - ); - seed_runtime_story_snapshot( - &state, - game_state, - Some(json!({ - "text": "断桥口的风还没有停。", - "options": [] - })), - ) - .await; - let app = build_router(state); - - let action_response = app - .oneshot( - Request::builder() - .method("POST") - .uri("/api/runtime/story/actions/resolve") - .header("authorization", format!("Bearer {token}")) - .header("content-type", "application/json") - .header("x-genarrative-response-envelope", "v1") - .body(Body::from( - json!({ - "sessionId": "runtime-main", - "clientVersion": 0, - "action": { - "type": "story_choice", - "functionId": "idle_observe_signs", - "payload": { - "optionText": "观察周围迹象" - } - } - }) - .to_string(), - )) - .expect("request should build"), - ) - .await - .expect("request should succeed"); - assert_eq!(action_response.status(), StatusCode::OK); - let action_payload: Value = serde_json::from_slice( - &action_response - .into_body() - .collect() - .await - .expect("body should collect") - .to_bytes(), - ) - .expect("response should be json"); - let projected_state = &action_payload["data"]["snapshot"]["gameState"]; - - assert_eq!( - projected_state["chapterState"]["id"], - json!("chapter:scene:scene-bridge") - ); - assert_eq!( - projected_state["storyEngineMemory"]["currentChapter"]["id"], - json!("chapter:scene:scene-bridge") - ); - assert_eq!( - projected_state["quests"][0]["chapterId"], - json!("chapter:scene:scene-bridge") - ); - assert!( - projected_state["currentScenePreset"]["mutationStateText"] - .as_str() - .is_some_and(|text| text.contains("断桥口")) - ); - assert!( - projected_state["storyEngineMemory"]["worldMutations"] - .as_array() - .is_some_and(|items| !items.is_empty()) - ); -} - -#[tokio::test] -async fn runtime_story_route_boundary_camp_travel_home_scene_is_server_owned() { - let state = seed_authenticated_state().await; - let token = issue_access_token(&state); - let mut game_state = build_runtime_story_boundary_game_state_fixture(); - let root = ensure_json_object(&mut game_state); - root.insert("worldType".to_string(), json!("WUXIA")); - root.insert( - "playerCharacter".to_string(), - json!({ - "id": "sword-princess", - "name": "青璃", - "title": "试剑客", - "description": "准备离营的角色。", - "personality": "谨慎", - "attributes": { - "strength": 8, - "spirit": 6 - }, - "skills": [] - }), - ); - root.insert( - "currentScenePreset".to_string(), - json!({ - "id": "wuxia-border-camp", - "name": "边关营地", - "description": "营火未熄。", - "imageSrc": "", - "connectedSceneIds": ["wuxia-palace-court"], - "connections": [{ - "sceneId": "wuxia-palace-court", - "relativePosition": "forward", - "summary": "沿旧宫线索离营" - }], - "forwardSceneId": "wuxia-palace-court", - "treasureHints": [], - "npcs": [] - }), - ); - root.insert( - "currentEncounter".to_string(), - json!({ - "kind": "npc", - "id": "npc-camp-companion", - "npcName": "营地同伴", - "npcDescription": "准备一起出发的同伴", - "npcAvatar": "伴", - "context": "营地", - "hostile": false - }), - ); - root.insert( - "runtimeStats".to_string(), - json!({ - "playTimeMs": 0, - "lastPlayTickAt": null, - "hostileNpcsDefeated": 0, - "questsAccepted": 0, - "itemsUsed": 0, - "scenesTraveled": 2 - }), - ); - seed_runtime_story_snapshot( - &state, - game_state, - Some(json!({ - "text": "营地对话已经结束。", - "options": [] - })), - ) - .await; - let app = build_router(state); - - let action_response = app - .clone() - .oneshot( - Request::builder() - .method("POST") - .uri("/api/runtime/story/actions/resolve") - .header("authorization", format!("Bearer {token}")) - .header("content-type", "application/json") - .header("x-genarrative-response-envelope", "v1") - .body(Body::from( - json!({ - "sessionId": "runtime-main", - "clientVersion": 0, - "action": { - "type": "story_choice", - "functionId": "camp_travel_home_scene", - "payload": { - "optionText": "前往宫苑内庭" - } - } - }) - .to_string(), - )) - .expect("request should build"), - ) - .await - .expect("request should succeed"); - assert_eq!(action_response.status(), StatusCode::OK); - let action_payload: Value = serde_json::from_slice( - &action_response - .into_body() - .collect() - .await - .expect("body should collect") - .to_bytes(), - ) - .expect("response should be json"); - let action_state = &action_payload["data"]["snapshot"]["gameState"]; - - assert_eq!( - action_state["currentScenePreset"]["id"], - json!("wuxia-palace-court") - ); - assert_eq!(action_state["runtimeStats"]["scenesTraveled"], json!(3)); - assert_eq!(action_state["inBattle"], json!(false)); - assert_eq!(action_state["npcInteractionActive"], json!(false)); - assert_eq!(action_state["sceneHostileNpcs"], json!([])); - assert_eq!( - action_state["currentEncounter"]["id"], - json!("wuxia-npc-maid") - ); - assert_eq!( - action_state["storyHistory"] - .as_array() - .expect("story history should be array") - .len(), - 2 - ); - assert!( - action_payload["data"]["presentation"]["resultText"] - .as_str() - .is_some_and(|text| text.contains("宫苑内庭")) - ); - - let state_response = app - .oneshot( - Request::builder() - .method("GET") - .uri("/api/runtime/story/state/runtime-main") - .header("authorization", format!("Bearer {token}")) - .header("x-genarrative-response-envelope", "v1") - .body(Body::empty()) - .expect("request should build"), - ) - .await - .expect("request should succeed"); - assert_eq!(state_response.status(), StatusCode::OK); - let state_payload: Value = serde_json::from_slice( - &state_response - .into_body() - .collect() - .await - .expect("body should collect") - .to_bytes(), - ) - .expect("response should be json"); - assert_eq!( - state_payload["data"]["snapshot"]["gameState"]["currentScenePreset"]["id"], - json!("wuxia-palace-court") - ); - assert_eq!( - state_payload["data"]["snapshot"]["gameState"]["currentEncounter"]["id"], - json!("wuxia-npc-maid") - ); -} - -#[test] -fn runtime_story_npc_help_is_one_shot_and_restores_resources() { - let request = RuntimeStoryActionRequest { - session_id: "runtime-main".to_string(), - client_version: Some(0), - action: shared_contracts::runtime_story::RuntimeStoryChoiceAction { - action_type: "story_choice".to_string(), - function_id: "npc_help".to_string(), - target_id: None, - payload: Some(json!({ "optionText": "请求援手" })), - }, - snapshot: None, - }; - let mut game_state = build_runtime_story_boundary_game_state_fixture(); - write_i32_field(&mut game_state, "playerHp", 20); - write_i32_field(&mut game_state, "playerMana", 4); - - let first = resolve_runtime_story_choice_action(&mut game_state, None, &request, "npc_help") - .expect("first help should resolve"); - - assert!(first.result_text.contains("及时支援")); - assert_eq!(read_i32_field(&game_state, "playerHp"), Some(30)); - assert_eq!(read_i32_field(&game_state, "playerMana"), Some(12)); - assert_eq!( - read_current_npc_state_bool_field(&game_state, "helpUsed"), - Some(true) - ); - - let second = resolve_runtime_story_choice_action(&mut game_state, None, &request, "npc_help"); - match second { - Ok(_) => panic!("second help should be rejected"), - Err(error) => assert_eq!(error, "当前 NPC 的一次性援手已经用完了"), - } -} - -#[test] -fn runtime_story_idle_travel_next_scene_resolves_backend_snapshot_fields() { - let request = RuntimeStoryActionRequest { - session_id: "runtime-main".to_string(), - client_version: Some(0), - action: shared_contracts::runtime_story::RuntimeStoryChoiceAction { - action_type: "story_choice".to_string(), - function_id: "idle_travel_next_scene".to_string(), - target_id: None, - payload: Some(json!({ "optionText": "前往相邻场景" })), - }, - snapshot: None, - }; - let mut game_state = build_runtime_story_boundary_game_state_fixture(); - let root = ensure_json_object(&mut game_state); - root.insert( - "currentScenePreset".to_string(), - json!({ - "id": "wuxia-bamboo-road", - "name": "竹林古道", - "forwardSceneId": "wuxia-rain-street", - "connections": [{ - "sceneId": "wuxia-rain-street", - "relativePosition": "forward", - "summary": "沿石板路继续前行" - }], - "npcs": [] - }), - ); - root.insert( - "currentEncounter".to_string(), - json!({ - "kind": "npc", - "id": "npc_merchant_01", - "npcName": "沈七", - "hostile": false - }), - ); - root.insert( - "sceneHostileNpcs".to_string(), - json!([{ - "id": "old-hostile", - "name": "旧敌人", - "hp": 1, - "maxHp": 1 - }]), - ); - write_bool_field(&mut game_state, "inBattle", true); - write_bool_field(&mut game_state, "npcInteractionActive", true); - write_string_field(&mut game_state, "currentBattleNpcId", "npc_merchant_01"); - write_string_field(&mut game_state, "currentNpcBattleMode", "fight"); - write_string_field(&mut game_state, "currentNpcBattleOutcome", "ongoing"); - - let resolution = resolve_runtime_story_choice_action( - &mut game_state, - None, - &request, - "idle_travel_next_scene", - ) - .expect("travel action should resolve"); - - assert!(resolution.result_text.contains("竹林古道")); - assert_eq!( - read_object_field(&game_state, "currentScenePreset") - .and_then(|scene| read_optional_string_field(scene, "id")), - Some("wuxia-rain-street".to_string()) - ); - assert_eq!( - read_object_field(&game_state, "currentScenePreset") - .and_then(|scene| read_optional_string_field(scene, "name")), - Some("相邻场景".to_string()) - ); - assert_eq!( - read_object_field(&game_state, "runtimeStats") - .and_then(|stats| read_i32_field(stats, "scenesTraveled")), - Some(1) - ); - assert_eq!(read_bool_field(&game_state, "inBattle"), Some(false)); - assert_eq!( - read_bool_field(&game_state, "npcInteractionActive"), - Some(false) - ); - assert_eq!( - read_field(&game_state, "currentEncounter"), - Some(&Value::Null) - ); - assert!(read_array_field(&game_state, "sceneHostileNpcs").is_empty()); - assert_eq!( - read_field(&game_state, "currentBattleNpcId"), - Some(&Value::Null) - ); - assert_eq!( - read_field(&game_state, "currentNpcBattleMode"), - Some(&Value::Null) - ); - assert_eq!( - read_field(&game_state, "currentNpcBattleOutcome"), - Some(&Value::Null) - ); -} - -#[test] -fn runtime_story_npc_fight_resolves_battle_snapshot_without_frontend_bridge() { - let request = RuntimeStoryActionRequest { - session_id: "runtime-main".to_string(), - client_version: Some(0), - action: shared_contracts::runtime_story::RuntimeStoryChoiceAction { - action_type: "story_choice".to_string(), - function_id: "npc_fight".to_string(), - target_id: None, - payload: Some(json!({ "optionText": "直接开战" })), - }, - snapshot: None, - }; - let mut game_state = build_runtime_story_boundary_game_state_fixture(); - - let resolution = - resolve_runtime_story_choice_action(&mut game_state, None, &request, "npc_fight") - .expect("npc fight should resolve"); - - assert!(resolution.result_text.contains("战斗节奏")); - assert_eq!(read_bool_field(&game_state, "inBattle"), Some(true)); - assert_eq!( - read_bool_field(&game_state, "npcInteractionActive"), - Some(false) - ); - assert_eq!( - read_optional_string_field(&game_state, "currentBattleNpcId"), - Some("npc_merchant_01".to_string()) - ); - assert_eq!( - read_optional_string_field(&game_state, "currentNpcBattleMode"), - Some("fight".to_string()) - ); - assert_eq!( - read_field(&game_state, "currentEncounter"), - Some(&Value::Null) - ); - assert_eq!( - read_object_field(&game_state, "sparReturnEncounter") - .and_then(|encounter| read_optional_string_field(encounter, "id")), - Some("npc_merchant_01".to_string()) - ); - let formation = read_array_field(&game_state, "sceneHostileNpcs"); - assert_eq!(formation.len(), 1); - assert_eq!( - read_optional_string_field(formation[0], "renderKind"), - Some("npc".to_string()) - ); - assert_eq!( - read_object_field(formation[0], "encounter") - .and_then(|encounter| read_optional_string_field(encounter, "id")), - Some("npc_merchant_01".to_string()) - ); -} - -#[test] -fn runtime_story_npc_spar_resolves_lightweight_battle_snapshot() { - let request = RuntimeStoryActionRequest { - session_id: "runtime-main".to_string(), - client_version: Some(0), - action: shared_contracts::runtime_story::RuntimeStoryChoiceAction { - action_type: "story_choice".to_string(), - function_id: "npc_spar".to_string(), - target_id: None, - payload: Some(json!({ "optionText": "点到为止切磋" })), - }, - snapshot: None, - }; - let mut game_state = build_runtime_story_boundary_game_state_fixture(); - - resolve_runtime_story_choice_action(&mut game_state, None, &request, "npc_spar") - .expect("npc spar should resolve"); - - assert_eq!(read_bool_field(&game_state, "inBattle"), Some(true)); - assert_eq!( - read_optional_string_field(&game_state, "currentNpcBattleMode"), - Some("spar".to_string()) - ); - assert_eq!( - read_field(&game_state, "currentEncounter"), - Some(&Value::Null) - ); - let formation = read_array_field(&game_state, "sceneHostileNpcs"); - assert_eq!(formation.len(), 1); - assert_eq!(read_i32_field(formation[0], "maxHp"), Some(10)); - assert_eq!( - read_object_field(&game_state, "sparReturnEncounter") - .and_then(|encounter| read_optional_string_field(encounter, "id")), - Some("npc_merchant_01".to_string()) - ); -} - -#[test] -fn runtime_story_npc_recruit_requires_threshold_and_release_target_when_party_full() { - let request = RuntimeStoryActionRequest { - session_id: "runtime-main".to_string(), - client_version: Some(0), - action: shared_contracts::runtime_story::RuntimeStoryChoiceAction { - action_type: "story_choice".to_string(), - function_id: "npc_recruit".to_string(), - target_id: None, - payload: Some(json!({ "optionText": "邀请同行" })), - }, - snapshot: None, - }; - - let mut low_affinity_state = build_runtime_story_boundary_game_state_fixture(); - let error = - resolve_runtime_story_choice_action(&mut low_affinity_state, None, &request, "npc_recruit"); - match error { - Ok(_) => panic!("low affinity recruit should be rejected"), - Err(message) => assert_eq!(message, "当前关系还没达到招募阈值,暂时不能邀请入队"), - } - - let mut full_party_state = build_runtime_story_boundary_game_state_fixture(); - write_current_npc_state_i32_field(&mut full_party_state, "affinity", 60); - let root = ensure_json_object(&mut full_party_state); - root.insert( - "companions".to_string(), - json!([ - { - "npcId": "npc-ally-1", - "characterId": "char-ally-1", - "joinedAtAffinity": 64, - "npcName": "旧同伴甲" - }, - { - "npcId": "npc-ally-2", - "characterId": "char-ally-2", - "joinedAtAffinity": 61, - "npcName": "旧同伴乙" - } - ]), - ); - - let full_party_error = - resolve_runtime_story_choice_action(&mut full_party_state, None, &request, "npc_recruit"); - match full_party_error { - Ok(_) => panic!("full party recruit should require release target"), - Err(message) => assert_eq!(message, "队伍已满时必须明确指定一名离队同伴"), - } - - let request_with_release = RuntimeStoryActionRequest { - action: shared_contracts::runtime_story::RuntimeStoryChoiceAction { - payload: Some(json!({ - "optionText": "邀请同行", - "releaseNpcId": "npc-ally-1" - })), - ..request.action.clone() - }, - ..request - }; - let resolution = resolve_runtime_story_choice_action( - &mut full_party_state, - None, - &request_with_release, - "npc_recruit", - ) - .expect("recruit with release target should resolve"); - - assert!(resolution.result_text.contains("旧同伴甲")); - assert_eq!(read_array_field(&full_party_state, "companions").len(), 2); - assert!( - read_array_field(&full_party_state, "companions") - .iter() - .any(|entry| { - read_optional_string_field(entry, "npcId").as_deref() == Some("npc_merchant_01") - }) - ); - assert_eq!( - read_field(&full_party_state, "currentEncounter"), - Some(&Value::Null) - ); -} - -#[test] -fn runtime_story_quest_offer_replace_updates_pending_offer_and_payload() { - let request = RuntimeStoryActionRequest { - session_id: "runtime-main".to_string(), - client_version: Some(0), - action: shared_contracts::runtime_story::RuntimeStoryChoiceAction { - action_type: "story_choice".to_string(), - function_id: "npc_chat_quest_offer_replace".to_string(), - target_id: None, - payload: Some(json!({ - "optionText": "更换任务" - })), - }, - snapshot: None, - }; - let mut game_state = build_runtime_story_boundary_game_state_fixture(); - let current_story = build_runtime_story_pending_quest_offer_fixture( - build_runtime_story_boundary_quest_fixture("quest-bridge-offer", "断桥口的密信"), - ); - - let resolution = resolve_runtime_story_choice_action( - &mut game_state, - Some(¤t_story), - &request, - "npc_chat_quest_offer_replace", - ) - .expect("quest replace should resolve"); - - let saved_current_story = resolution - .saved_current_story - .expect("quest replace should save current story"); - let pending_quest = read_field(&saved_current_story, "npcChatState") - .and_then(|state| read_field(state, "pendingQuestOffer")) - .and_then(|offer| read_field(offer, "quest")) - .expect("pending quest should exist after replace"); - assert_eq!( - read_optional_string_field(pending_quest, "id"), - Some("quest-bridge-replaced".to_string()) - ); - - let options = resolution - .presentation_options - .expect("quest replace should expose options"); - assert_eq!(options.len(), 3); - assert_eq!( - options[1] - .payload - .as_ref() - .and_then(|payload| { read_optional_string_field(payload, "npcChatQuestOfferAction") }), - Some("replace".to_string()) - ); -} - -#[test] -fn runtime_story_quest_offer_abandon_clears_pending_offer_and_restores_chat_options() { - let request = RuntimeStoryActionRequest { - session_id: "runtime-main".to_string(), - client_version: Some(0), - action: shared_contracts::runtime_story::RuntimeStoryChoiceAction { - action_type: "story_choice".to_string(), - function_id: "npc_chat_quest_offer_abandon".to_string(), - target_id: None, - payload: Some(json!({ - "optionText": "放弃任务" - })), - }, - snapshot: None, - }; - let mut game_state = build_runtime_story_boundary_game_state_fixture(); - let current_story = build_runtime_story_pending_quest_offer_fixture( - build_runtime_story_boundary_quest_fixture("quest-bridge-offer", "断桥口的密信"), - ); - - let resolution = resolve_runtime_story_choice_action( - &mut game_state, - Some(¤t_story), - &request, - "npc_chat_quest_offer_abandon", - ) - .expect("quest abandon should resolve"); - - let saved_current_story = resolution - .saved_current_story - .expect("quest abandon should save current story"); - assert_eq!( - read_field(&saved_current_story, "npcChatState") - .and_then(|state| read_field(state, "pendingQuestOffer")), - Some(&Value::Null) - ); - let options = resolution - .presentation_options - .expect("quest abandon should expose follow-up chat options"); - assert_eq!(options.len(), 3); - assert!( - options - .iter() - .all(|option| option.function_id == "npc_chat") - ); - assert_eq!(options[0].action_text, "那先继续聊聊你刚才没说完的部分"); -} - -#[test] -fn runtime_story_quest_accept_writes_quest_runtime_stats_and_followup_story() { - let request = RuntimeStoryActionRequest { - session_id: "runtime-main".to_string(), - client_version: Some(0), - action: shared_contracts::runtime_story::RuntimeStoryChoiceAction { - action_type: "story_choice".to_string(), - function_id: "npc_quest_accept".to_string(), - target_id: None, - payload: Some(json!({ - "optionText": "接受任务" - })), - }, - snapshot: None, - }; - let mut game_state = build_runtime_story_boundary_game_state_fixture(); - let pending_quest = - build_runtime_story_boundary_quest_fixture("quest-bridge-offer", "断桥口的密信"); - let current_story = build_runtime_story_pending_quest_offer_fixture(pending_quest.clone()); - - let resolution = resolve_runtime_story_choice_action( - &mut game_state, - Some(¤t_story), - &request, - "npc_quest_accept", - ) - .expect("quest accept should resolve"); - - let quests = read_array_field(&game_state, "quests"); - assert_eq!(quests.len(), 1); - assert_eq!( - read_optional_string_field(quests[0], "id"), - read_optional_string_field(&pending_quest, "id") - ); - assert_eq!( - read_field(&game_state, "runtimeStats") - .and_then(|stats| read_i32_field(stats, "questsAccepted")), - Some(1) - ); - let saved_current_story = resolution - .saved_current_story - .expect("quest accept should save current story"); - assert_eq!( - read_field(&saved_current_story, "npcChatState") - .and_then(|state| read_field(state, "pendingQuestOffer")), - Some(&Value::Null) - ); - assert_eq!( - resolution - .presentation_options - .expect("quest accept should expose follow-up options") - .len(), - 3 - ); -} - -#[test] -fn runtime_story_quest_turn_in_marks_quest_rewards_and_affinity() { - let request = RuntimeStoryActionRequest { - session_id: "runtime-main".to_string(), - client_version: Some(0), - action: shared_contracts::runtime_story::RuntimeStoryChoiceAction { - action_type: "story_choice".to_string(), - function_id: "npc_quest_turn_in".to_string(), - target_id: None, - payload: Some(json!({ - "optionText": "交付任务", - "questId": "quest-bridge-complete" - })), - }, - snapshot: None, - }; - let mut game_state = build_runtime_story_boundary_game_state_fixture(); - let mut completed_quest = - build_runtime_story_boundary_quest_fixture("quest-bridge-complete", "断桥夜巡"); - if let Some(quest) = completed_quest.as_object_mut() { - quest.insert("status".to_string(), Value::String("completed".to_string())); - quest.insert( - "reward".to_string(), - json!({ - "affinityBonus": 6, - "currency": 30, - "experience": 24, - "items": [{ - "id": "reward-med-1", - "category": "补给", - "name": "回气散", - "quantity": 1, - "tags": [] - }] - }), - ); - } - push_quest_record(&mut game_state, &completed_quest); - - let resolution = - resolve_runtime_story_choice_action(&mut game_state, None, &request, "npc_quest_turn_in") - .expect("quest turn in should resolve"); - - let quests = read_array_field(&game_state, "quests"); - assert_eq!(quests.len(), 1); - assert_eq!( - read_optional_string_field(quests[0], "status"), - Some("turned_in".to_string()) - ); - assert_eq!(read_i32_field(&game_state, "playerCurrency"), Some(120)); - assert_eq!(read_array_field(&game_state, "playerInventory").len(), 1); - assert_eq!( - read_field(&game_state, "playerProgression") - .and_then(|progression| read_i32_field(progression, "totalXp")), - Some(24) - ); - assert_eq!( - read_current_npc_state_i32_field(&game_state, "affinity"), - Some(52) - ); - assert!(resolution.patches.iter().any(|patch| matches!( - patch, - RuntimeStoryPatch::NpcAffinityChanged { - previous_affinity: 46, - next_affinity: 52, - .. - } - ))); -} - -#[test] -fn runtime_story_reasoned_combat_story_guard_blocks_all_battle_outcomes() { - assert!(!should_generate_reasoned_combat_story(None)); - assert!(!should_generate_reasoned_combat_story(Some( - &RuntimeBattlePresentation { - target_id: None, - target_name: None, - damage_dealt: Some(4), - damage_taken: Some(2), - outcome: Some("ongoing".to_string()), - } - ))); - assert!(!should_generate_reasoned_combat_story(Some( - &RuntimeBattlePresentation { - target_id: Some("npc_merchant_01".to_string()), - target_name: Some("沈七".to_string()), - damage_dealt: Some(18), - damage_taken: Some(0), - outcome: Some("victory".to_string()), - } - ))); - assert!(!should_generate_reasoned_combat_story(Some( - &RuntimeBattlePresentation { - target_id: Some("npc_merchant_01".to_string()), - target_name: Some("沈七".to_string()), - damage_dealt: Some(0), - damage_taken: Some(0), - outcome: Some("escaped".to_string()), - } - ))); -} - -#[test] -fn runtime_story_dialogue_current_story_keeps_continue_and_deferred_options() { - let deferred_options = vec![ - build_npc_runtime_story_option("npc_help", "请求援手", "npc_merchant_01", "help"), - build_npc_runtime_story_option("npc_trade", "查看货物", "npc_merchant_01", "trade"), - ]; - - let current_story = build_dialogue_current_story( - "沈七", - "你:这一路还撑得住吗?\n沈七:还行,先把桥口这阵风躲过去。", - deferred_options.as_slice(), - ); - - assert_eq!( - read_required_string_field(¤t_story, "displayMode").as_deref(), - Some("dialogue") - ); - assert_eq!(read_array_field(¤t_story, "options").len(), 1); - assert_eq!(read_array_field(¤t_story, "deferredOptions").len(), 2); - assert_eq!( - read_array_field(¤t_story, "dialogue") - .into_iter() - .filter_map(|entry| read_optional_string_field(entry, "speaker")) - .collect::>(), - vec!["player".to_string(), "npc".to_string()] - ); - assert_eq!( - read_required_string_field( - read_array_field(¤t_story, "options") - .first() - .copied() - .expect("continue option should exist"), - "functionId" - ) - .as_deref(), - Some(CONTINUE_ADVENTURE_FUNCTION_ID) - ); -} - -async fn seed_authenticated_state() -> AppState { - let state = AppState::new(AppConfig::default()).expect("state should build"); - state - .seed_test_phone_user_with_password("13800138109", "secret123") - .await - .id; - state -} - -async fn seed_runtime_story_snapshot( - state: &AppState, - game_state: Value, - current_story: Option, -) { - let now = OffsetDateTime::now_utc(); - let micros = offset_datetime_to_unix_micros(now); - state - .put_runtime_snapshot_record( - "user_00000001".to_string(), - micros, - "adventure".to_string(), - game_state, - current_story, - micros, - ) - .await - .expect("runtime story snapshot should seed"); -} - -fn assert_runtime_story_error( - result: Result, - expectation: &str, -) -> String { - match result { - Ok(_) => panic!("{expectation}"), - Err(message) => message, - } -} - -fn issue_access_token(state: &AppState) -> String { - let claims = AccessTokenClaims::from_input( - AccessTokenClaimsInput { - user_id: "user_00000001".to_string(), - session_id: "sess_runtime_story_state".to_string(), - provider: AuthProvider::Password, - roles: vec!["user".to_string()], - token_version: 2, - phone_verified: true, - binding_status: BindingStatus::Active, - display_name: Some("运行时剧情状态用户".to_string()), - }, - state.auth_jwt_config(), - OffsetDateTime::now_utc(), - ) - .expect("claims should build"); - - sign_access_token(&claims, state.auth_jwt_config()).expect("token should sign") -} - -fn build_runtime_story_boundary_game_state_fixture() -> Value { - serde_json::from_str( - r#"{ - "worldType": "WUXIA", - "runtimeSessionId": "runtime-main", - "runtimeActionVersion": 0, - "playerCharacter": { - "id": "hero-story", - "title": "试剑客", - "description": "站在桥口的人。", - "personality": "谨慎", - "attributes": { - "strength": 8, - "spirit": 6 - }, - "skills": [] - }, - "runtimeStats": { - "playTimeMs": 0, - "lastPlayTickAt": null, - "hostileNpcsDefeated": 0, - "questsAccepted": 0, - "itemsUsed": 0, - "scenesTraveled": 0 - }, - "currentScene": "test-scene", - "storyHistory": [], - "characterChats": {}, - "animationState": "idle", - "currentEncounter": { - "kind": "npc", - "id": "npc_merchant_01", - "npcName": "沈七", - "npcDescription": "腰间挂着药囊的行商", - "context": "受伤行商", - "hostile": false - }, - "npcInteractionActive": true, - "currentScenePreset": null, - "sceneHostileNpcs": [], - "playerX": 0, - "playerOffsetY": 0, - "playerFacing": "right", - "playerActionMode": "idle", - "scrollWorld": false, - "inBattle": false, - "playerHp": 31, - "playerMaxHp": 40, - "playerMana": 9, - "playerMaxMana": 16, - "playerSkillCooldowns": {}, - "activeBuildBuffs": [], - "activeCombatEffects": [], - "playerCurrency": 90, - "playerInventory": [], - "playerEquipment": { - "weapon": null, - "armor": null, - "relic": null - }, - "npcStates": { - "npc_merchant_01": { - "affinity": 46, - "chattedCount": 0, - "helpUsed": false, - "giftsGiven": 0, - "inventory": [], - "recruited": false - } - }, - "quests": [], - "roster": [], - "companions": [], - "currentNpcBattleMode": null, - "currentNpcBattleOutcome": null, - "sparReturnEncounter": null, - "sparPlayerHpBefore": null, - "sparPlayerMaxHpBefore": null, - "sparStoryHistoryBefore": null, - "playerProgression": { - "level": 1, - "currentLevelXp": 0, - "totalXp": 0, - "xpToNextLevel": 60, - "pendingLevelUps": 0, - "lastGrantedSource": null - } - }"#, - ) - .expect("runtime story boundary game state fixture should parse") -} - -fn build_runtime_story_post_battle_custom_state_fixture() -> Value { - let mut game_state = build_runtime_story_boundary_game_state_fixture(); - let root = ensure_json_object(&mut game_state); - root.insert("worldType".to_string(), json!("CUSTOM")); - root.insert( - "currentScenePreset".to_string(), - json!({ - "id": "custom-scene-camp", - "name": "回潮营地", - "description": "潮雾里暂时安全的营地。", - "imageSrc": "", - "connectedSceneIds": ["custom-scene-landmark-1"], - "connections": [{ - "sceneId": "custom-scene-landmark-1", - "relativePosition": "forward", - "summary": "沿着潮线继续前进" - }], - "forwardSceneId": "custom-scene-landmark-1", - "treasureHints": [], - "npcs": [] - }), - ); - root.insert( - "customWorldProfile".to_string(), - json!({ - "id": "profile-post-battle", - "name": "回潮群岛", - "summary": "潮雾里有旧账。", - "camp": { - "id": "camp-1", - "name": "回潮营地", - "description": "潮雾里暂时安全的营地。", - "connections": [{ - "targetLandmarkId": "landmark-1", - "relativePosition": "forward", - "summary": "沿着潮线继续前进" - }], - "sceneNpcIds": ["npc-rival"] - }, - "landmarks": [{ - "id": "landmark-1", - "name": "潮线码头", - "description": "旧码头仍有看守巡行。", - "connections": [], - "sceneNpcIds": [] - }], - "playableNpcs": [], - "storyNpcs": [{ - "id": "npc-rival", - "name": "潮线看守", - "title": "巡潮者", - "role": "守住码头的对手", - "description": "盯着每一个靠近旧码头的人。", - "backstory": "", - "personality": "", - "motivation": "", - "combatStyle": "", - "initialAffinity": -20, - "relationshipHooks": [], - "tags": [], - "initialItems": [], - "skills": [], - "backstoryReveal": { "publicSummary": "", "chapters": [] } - }], - "sceneChapterBlueprints": [{ - "id": "chapter-1", - "sceneId": "camp-1", - "title": "回潮开局", - "linkedLandmarkIds": ["landmark-1"], - "acts": [ - { - "id": "act-1", - "sceneId": "camp-1", - "title": "营地复苏", - "primaryNpcId": "npc-rival", - "oppositeNpcId": "npc-rival", - "encounterNpcIds": ["npc-rival"] - }, - { - "id": "act-2", - "sceneId": "landmark-1", - "title": "码头追索", - "primaryNpcId": "npc-rival", - "oppositeNpcId": "npc-rival", - "encounterNpcIds": ["npc-rival"] - } - ] - }] - }), - ); - root.insert( - "storyEngineMemory".to_string(), - json!({ - "currentSceneActState": { - "chapterId": "chapter-1", - "currentActId": "act-1", - "sceneId": "camp-1", - "completedActIds": [], - "enteredAtStoryIndex": 0 - } - }), - ); - root.insert("playerMaxHp".to_string(), json!(60)); - root.insert("playerMaxMana".to_string(), json!(20)); - game_state -} - -fn build_runtime_story_boundary_quest_fixture(quest_id: &str, title: &str) -> Value { - json!({ - "id": quest_id, - "issuerNpcId": "npc_merchant_01", - "issuerNpcName": "沈七", - "sceneId": "scene-bridge", - "title": title, - "description": format!("{title}的详细说明。"), - "summary": format!("{title}的简要目标。"), - "objective": { - "kind": "talk_to_npc", - "requiredCount": 1 - }, - "progress": 0, - "status": "active", - "reward": { - "affinityBonus": 6, - "currency": 30, - "items": [] - }, - "rewardText": "完成后可以领取报酬。", - "steps": [{ - "id": format!("{quest_id}-step-1"), - "title": "查清线索", - "kind": "talk_to_npc", - "requiredCount": 1, - "progress": 0, - "revealText": "先去断桥口附近把相关线索问清楚。", - "completeText": "关键线索已经问清。" - }], - "activeStepId": format!("{quest_id}-step-1") - }) -} - -fn build_runtime_story_pending_quest_offer_fixture(quest: Value) -> Value { - json!({ - "text": "沈七终于把真正的委托说了出来。", - "options": [], - "displayMode": "dialogue", - "dialogue": [{ - "speaker": "npc", - "speakerName": "沈七", - "text": "这件事我只想托给你。" - }], - "npcChatState": { - "npcId": "npc_merchant_01", - "npcName": "沈七", - "turnCount": 2, - "customInputPlaceholder": "输入你想对 TA 说的话", - "pendingQuestOffer": { - "quest": quest - } - } - }) -} diff --git a/server-rs/crates/api-server/src/story_sessions.rs b/server-rs/crates/api-server/src/story_sessions.rs index 6b3caa8a..e2c9ca56 100644 --- a/server-rs/crates/api-server/src/story_sessions.rs +++ b/server-rs/crates/api-server/src/story_sessions.rs @@ -166,6 +166,27 @@ pub async fn get_story_session_state( )) } +pub async fn get_story_runtime_projection( + State(state): State, + Path(story_session_id): Path, + Extension(request_context): Extension, + Extension(authenticated): Extension, +) -> Result, Response> { + let actor_user_id = authenticated.claims().user_id().to_string(); + let source = state + .spacetime_client() + .get_story_runtime_projection_source(story_session_id, actor_user_id) + .await + .map_err(|error| { + story_sessions_error_response(&request_context, map_story_session_client_error(error)) + })?; + + Ok(json_success_body( + Some(&request_context), + module_runtime_story::build_story_runtime_projection(source), + )) +} + fn map_story_session_client_error(error: SpacetimeClientError) -> AppError { let status = match &error { SpacetimeClientError::Runtime(_) => StatusCode::BAD_REQUEST, @@ -381,6 +402,61 @@ mod tests { ); } + #[tokio::test] + async fn get_story_runtime_projection_requires_authentication() { + let app = build_router(AppState::new(AppConfig::default()).expect("state should build")); + + let response = app + .oneshot( + Request::builder() + .method("GET") + .uri("/api/story/sessions/storysess_001/runtime-projection") + .body(Body::empty()) + .expect("request should build"), + ) + .await + .expect("request should succeed"); + + assert_eq!(response.status(), StatusCode::UNAUTHORIZED); + } + + #[tokio::test] + async fn get_story_runtime_projection_returns_bad_gateway_when_spacetime_not_published() { + let state = seed_authenticated_state().await; + let token = issue_access_token(&state); + let app = build_router(state); + + let response = app + .oneshot( + Request::builder() + .method("GET") + .uri("/api/story/sessions/storysess_001/runtime-projection") + .header("authorization", format!("Bearer {token}")) + .header("x-genarrative-response-envelope", "v1") + .body(Body::empty()) + .expect("request should build"), + ) + .await + .expect("request should succeed"); + + assert_eq!(response.status(), StatusCode::BAD_GATEWAY); + + let body = response + .into_body() + .collect() + .await + .expect("body should collect") + .to_bytes(); + let payload: Value = + serde_json::from_slice(&body).expect("response body should be valid json"); + + assert_eq!(payload["ok"], Value::Bool(false)); + assert_eq!( + payload["error"]["details"]["provider"], + Value::String("spacetimedb".to_string()) + ); + } + async fn seed_authenticated_state() -> AppState { let state = AppState::new(AppConfig::default()).expect("state should build"); state diff --git a/server-rs/crates/module-ai/README.md b/server-rs/crates/module-ai/README.md index b4c4ba46..8f722c85 100644 --- a/server-rs/crates/module-ai/README.md +++ b/server-rs/crates/module-ai/README.md @@ -16,17 +16,23 @@ 当前提交已完成: 1. `module-ai` 的 `Cargo.toml` -2. 首版核心类型: +2. DDD 分层文件: + - `src/domain.rs` + - `src/commands.rs` + - `src/application.rs` + - `src/events.rs` + - `src/errors.rs` +3. 首版核心类型: - `AiTaskKind` - `AiTaskStatus` - `AiTaskStageKind` - `AiTaskSnapshot` - `AiTextChunkSnapshot` - `AiResultReferenceSnapshot` -3. 默认阶段蓝图与 ID 前缀 -4. `InMemoryAiTaskStore` -5. `AiTaskService` -6. 面向 `SpacetimeDB` 的输入类型与 ID helper: +4. 默认阶段蓝图与 ID 前缀 +5. `InMemoryAiTaskStore` +6. `AiTaskService` +7. 面向 `SpacetimeDB` 的输入类型与 ID helper: - `AiTaskStartInput` - `AiTaskStageStartInput` - `AiTextChunkAppendInput` @@ -34,13 +40,14 @@ - `AiTaskFinishInput` - `AiTaskCancelInput` - `AiTaskFailureInput` -7. 基础单元测试 +8. 基础单元测试 首版详细设计见: 1. [../../../docs/technical/M4_MODULE_AI_BASELINE_DESIGN_2026-04-21.md](../../../docs/technical/M4_MODULE_AI_BASELINE_DESIGN_2026-04-21.md) 2. [../../../docs/technical/M4_MODULE_AI_SPACETIMEDB_BASELINE_2026-04-21.md](../../../docs/technical/M4_MODULE_AI_SPACETIMEDB_BASELINE_2026-04-21.md) 3. [../../../docs/technical/M4_MODULE_AI_AXUM_FACADE_DESIGN_2026-04-22.md](../../../docs/technical/M4_MODULE_AI_AXUM_FACADE_DESIGN_2026-04-22.md) +4. [../../../docs/technical/SERVER_RS_DDD_WP_AI_TASK_DOMAIN_REFACTOR_2026-04-29.md](../../../docs/technical/SERVER_RS_DDD_WP_AI_TASK_DOMAIN_REFACTOR_2026-04-29.md) ## 3. 当前仍未进入的范围 diff --git a/server-rs/crates/module-ai/src/application.rs b/server-rs/crates/module-ai/src/application.rs index 0d7b7129..950a7be9 100644 --- a/server-rs/crates/module-ai/src/application.rs +++ b/server-rs/crates/module-ai/src/application.rs @@ -1,4 +1,400 @@ -//! AI 应用编排过渡落位。 -//! -//! 这里仅返回纯应用结果或领域事件;真实 LLM 调用继续留在 `platform-llm` -//! 与 `api-server` 编排层。 +use std::{ + collections::HashMap, + sync::{Arc, Mutex}, +}; + +use shared_kernel::normalize_required_string; + +use crate::commands::validate_task_create_input; +use crate::{ + AiResultReferenceKind, AiResultReferenceSnapshot, AiStageCompletionInput, AiTaskCreateInput, + AiTaskFieldError, AiTaskServiceError, AiTaskSnapshot, AiTaskStageSnapshot, AiTaskStageStatus, + AiTaskStatus, AiTextChunkSnapshot, INITIAL_AI_TASK_VERSION, generate_ai_result_ref_id, + generate_ai_text_chunk_id, normalize_optional_text, normalize_string_list, +}; + +use serde::{Deserialize, Serialize}; +#[cfg(feature = "spacetime-types")] +use spacetimedb::SpacetimeType; + +#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct AiTaskProcedureResult { + pub ok: bool, + pub task: Option, + pub text_chunk: Option, + pub error_message: Option, +} + +#[derive(Clone, Debug, Default)] +pub struct InMemoryAiTaskStore { + inner: Arc>, +} + +#[derive(Debug, Default)] +struct InMemoryAiTaskStoreState { + tasks: HashMap, + text_chunks: HashMap>, +} + +#[derive(Clone, Debug)] +pub struct AiTaskService { + store: InMemoryAiTaskStore, +} + +impl AiTaskService { + pub fn new(store: InMemoryAiTaskStore) -> Self { + Self { store } + } + + pub fn create_task( + &self, + input: AiTaskCreateInput, + ) -> Result { + validate_task_create_input(&input).map_err(AiTaskServiceError::Field)?; + + let snapshot = AiTaskSnapshot { + task_id: input.task_id.clone(), + task_kind: input.task_kind, + owner_user_id: normalize_required_string(input.owner_user_id).unwrap_or_default(), + request_label: normalize_required_string(input.request_label).unwrap_or_default(), + source_module: normalize_required_string(input.source_module).unwrap_or_default(), + source_entity_id: normalize_optional_text(input.source_entity_id), + request_payload_json: normalize_optional_text(input.request_payload_json), + status: AiTaskStatus::Pending, + failure_message: None, + stages: input + .stages + .into_iter() + .map(|stage| AiTaskStageSnapshot { + stage_kind: stage.stage_kind, + label: normalize_required_string(stage.label).unwrap_or_default(), + detail: normalize_required_string(stage.detail).unwrap_or_default(), + 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, + }; + + self.store.insert_task(snapshot) + } + + pub fn start_task( + &self, + task_id: &str, + started_at_micros: i64, + ) -> Result { + self.store.update_task(task_id, |task| { + ensure_task_is_not_terminal(task.status)?; + task.status = AiTaskStatus::Running; + task.started_at_micros.get_or_insert(started_at_micros); + task.updated_at_micros = started_at_micros; + task.version += 1; + Ok(()) + }) + } + + pub fn start_stage( + &self, + task_id: &str, + stage_kind: crate::AiTaskStageKind, + started_at_micros: i64, + ) -> Result { + self.store.update_task(task_id, |task| { + ensure_task_is_not_terminal(task.status)?; + task.status = AiTaskStatus::Running; + task.started_at_micros.get_or_insert(started_at_micros); + let stage = task + .stages + .iter_mut() + .find(|stage| stage.stage_kind == stage_kind) + .ok_or(AiTaskServiceError::StageNotFound)?; + stage.status = AiTaskStageStatus::Running; + stage.started_at_micros.get_or_insert(started_at_micros); + task.updated_at_micros = started_at_micros; + task.version += 1; + Ok(()) + }) + } + + pub fn append_text_chunk( + &self, + task_id: &str, + stage_kind: crate::AiTaskStageKind, + sequence: u32, + delta_text: String, + created_at_micros: i64, + ) -> Result<(AiTaskSnapshot, AiTextChunkSnapshot), AiTaskServiceError> { + if delta_text.trim().is_empty() { + return Err(AiTaskServiceError::Field( + AiTaskFieldError::MissingChunkText, + )); + } + if sequence == 0 { + return Err(AiTaskServiceError::Field(AiTaskFieldError::InvalidSequence)); + } + + let chunk = AiTextChunkSnapshot { + chunk_id: generate_ai_text_chunk_id(created_at_micros, sequence), + task_id: normalize_required_string(task_id).unwrap_or_default(), + stage_kind, + sequence, + delta_text: normalize_required_string(delta_text).unwrap_or_default(), + created_at_micros, + }; + + let task = self.store.append_text_chunk(chunk.clone())?; + Ok((task, chunk)) + } + + pub fn complete_stage( + &self, + input: AiStageCompletionInput, + ) -> Result { + self.store.update_task(&input.task_id, |task| { + ensure_task_is_not_terminal(task.status)?; + + let stage = task + .stages + .iter_mut() + .find(|stage| stage.stage_kind == input.stage_kind) + .ok_or(AiTaskServiceError::StageNotFound)?; + 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()); + + task.latest_text_output = stage.text_output.clone(); + task.latest_structured_payload_json = stage.structured_payload_json.clone(); + task.updated_at_micros = input.completed_at_micros; + task.version += 1; + Ok(()) + }) + } + + pub fn attach_result_reference( + &self, + task_id: &str, + reference_kind: AiResultReferenceKind, + reference_id: String, + label: Option, + created_at_micros: i64, + ) -> Result { + let Some(reference_id) = normalize_required_string(reference_id) else { + return Err(AiTaskServiceError::Field( + AiTaskFieldError::MissingReferenceId, + )); + }; + + self.store.update_task(task_id, |task| { + ensure_task_is_not_terminal(task.status)?; + task.result_references.push(AiResultReferenceSnapshot { + result_ref_id: generate_ai_result_ref_id(created_at_micros), + task_id: task.task_id.clone(), + reference_kind, + reference_id: reference_id.clone(), + label: normalize_optional_text(label.clone()), + created_at_micros, + }); + task.updated_at_micros = created_at_micros; + task.version += 1; + Ok(()) + }) + } + + pub fn complete_task( + &self, + task_id: &str, + completed_at_micros: i64, + ) -> Result { + self.store.update_task(task_id, |task| { + ensure_task_is_not_terminal(task.status)?; + task.status = AiTaskStatus::Completed; + task.completed_at_micros = Some(completed_at_micros); + task.updated_at_micros = completed_at_micros; + task.version += 1; + Ok(()) + }) + } + + pub fn fail_task( + &self, + task_id: &str, + failure_message: String, + completed_at_micros: i64, + ) -> Result { + let Some(failure_message) = normalize_required_string(failure_message) else { + return Err(AiTaskServiceError::Field( + AiTaskFieldError::MissingFailureMessage, + )); + }; + + self.store.update_task(task_id, |task| { + ensure_task_is_not_terminal(task.status)?; + task.status = AiTaskStatus::Failed; + task.failure_message = Some(failure_message.clone()); + task.completed_at_micros = Some(completed_at_micros); + task.updated_at_micros = completed_at_micros; + task.version += 1; + Ok(()) + }) + } + + pub fn cancel_task( + &self, + task_id: &str, + completed_at_micros: i64, + ) -> Result { + self.store.update_task(task_id, |task| { + ensure_task_is_not_terminal(task.status)?; + task.status = AiTaskStatus::Cancelled; + task.completed_at_micros = Some(completed_at_micros); + task.updated_at_micros = completed_at_micros; + task.version += 1; + Ok(()) + }) + } + + pub fn get_task(&self, task_id: &str) -> Result { + self.store.get_task(task_id) + } +} + +impl InMemoryAiTaskStore { + fn insert_task(&self, task: AiTaskSnapshot) -> Result { + let mut state = self + .inner + .lock() + .map_err(|_| AiTaskServiceError::Store("AI 任务仓储锁已中毒".to_string()))?; + + if state.tasks.contains_key(&task.task_id) { + return Err(AiTaskServiceError::TaskAlreadyExists); + } + + state.text_chunks.insert(task.task_id.clone(), Vec::new()); + state.tasks.insert(task.task_id.clone(), task.clone()); + Ok(task) + } + + fn update_task( + &self, + task_id: &str, + mut apply: F, + ) -> Result + where + F: FnMut(&mut AiTaskSnapshot) -> Result<(), AiTaskServiceError>, + { + let mut state = self + .inner + .lock() + .map_err(|_| AiTaskServiceError::Store("AI 任务仓储锁已中毒".to_string()))?; + let task = state + .tasks + .get_mut(task_id.trim()) + .ok_or(AiTaskServiceError::TaskNotFound)?; + apply(task)?; + Ok(task.clone()) + } + + fn append_text_chunk( + &self, + chunk: AiTextChunkSnapshot, + ) -> Result { + let mut state = self + .inner + .lock() + .map_err(|_| AiTaskServiceError::Store("AI 任务仓储锁已中毒".to_string()))?; + { + let task = state + .tasks + .get_mut(&chunk.task_id) + .ok_or(AiTaskServiceError::TaskNotFound)?; + ensure_task_is_not_terminal(task.status)?; + + let stage = task + .stages + .iter_mut() + .find(|stage| stage.stage_kind == chunk.stage_kind) + .ok_or(AiTaskServiceError::StageNotFound)?; + if stage.status == AiTaskStageStatus::Pending { + stage.status = AiTaskStageStatus::Running; + stage.started_at_micros = Some(chunk.created_at_micros); + } + + task.status = AiTaskStatus::Running; + task.started_at_micros + .get_or_insert(chunk.created_at_micros); + } + + let chunks = state + .text_chunks + .get_mut(&chunk.task_id) + .ok_or(AiTaskServiceError::TaskNotFound)?; + chunks.push(chunk.clone()); + chunks.sort_by_key(|value| value.sequence); + + let aggregated_text = chunks + .iter() + .filter(|value| value.stage_kind == chunk.stage_kind) + .map(|value| value.delta_text.as_str()) + .collect::>() + .join(""); + let normalized_output = if aggregated_text.trim().is_empty() { + None + } else { + Some(aggregated_text) + }; + + let task = state + .tasks + .get_mut(&chunk.task_id) + .ok_or(AiTaskServiceError::TaskNotFound)?; + let stage = task + .stages + .iter_mut() + .find(|stage| stage.stage_kind == chunk.stage_kind) + .ok_or(AiTaskServiceError::StageNotFound)?; + stage.text_output = normalized_output.clone(); + task.latest_text_output = normalized_output; + task.updated_at_micros = chunk.created_at_micros; + task.version += 1; + Ok(task.clone()) + } + + fn get_task(&self, task_id: &str) -> Result { + let state = self + .inner + .lock() + .map_err(|_| AiTaskServiceError::Store("AI 任务仓储锁已中毒".to_string()))?; + state + .tasks + .get(task_id.trim()) + .cloned() + .ok_or(AiTaskServiceError::TaskNotFound) + } +} + +fn ensure_task_is_not_terminal(status: AiTaskStatus) -> Result<(), AiTaskServiceError> { + if status.is_terminal() { + Err(AiTaskServiceError::Field( + AiTaskFieldError::InvalidTaskState, + )) + } else { + Ok(()) + } +} diff --git a/server-rs/crates/module-ai/src/commands.rs b/server-rs/crates/module-ai/src/commands.rs index 7c013929..88ab8101 100644 --- a/server-rs/crates/module-ai/src/commands.rs +++ b/server-rs/crates/module-ai/src/commands.rs @@ -1,4 +1,125 @@ -//! AI 写入命令过渡落位。 -//! -//! 只描述创建任务、推进阶段、追加文本片段和挂接结果引用等用例输入, -//! 不承载外部模型请求或持久化细节。 +use std::collections::HashMap; + +use serde::{Deserialize, Serialize}; +use shared_kernel::normalize_required_string; +#[cfg(feature = "spacetime-types")] +use spacetimedb::SpacetimeType; + +use crate::{ + AiResultReferenceKind, AiTaskFieldError, AiTaskKind, AiTaskStageBlueprint, AiTaskStageKind, +}; + +#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct AiTaskCreateInput { + pub task_id: String, + pub task_kind: AiTaskKind, + pub owner_user_id: String, + pub request_label: String, + pub source_module: String, + pub source_entity_id: Option, + pub request_payload_json: Option, + pub stages: Vec, + pub created_at_micros: i64, +} + +#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct AiTaskStartInput { + pub task_id: String, + pub started_at_micros: i64, +} + +#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct AiTaskStageStartInput { + pub task_id: String, + pub stage_kind: AiTaskStageKind, + pub started_at_micros: i64, +} + +#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct AiTextChunkAppendInput { + pub task_id: String, + pub stage_kind: AiTaskStageKind, + pub sequence: u32, + pub delta_text: String, + pub created_at_micros: i64, +} + +#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct AiStageCompletionInput { + pub task_id: String, + pub stage_kind: AiTaskStageKind, + pub text_output: Option, + pub structured_payload_json: Option, + pub warning_messages: Vec, + pub completed_at_micros: i64, +} + +#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct AiResultReferenceInput { + pub task_id: String, + pub reference_kind: AiResultReferenceKind, + pub reference_id: String, + pub label: Option, + pub created_at_micros: i64, +} + +#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct AiTaskFinishInput { + pub task_id: String, + pub completed_at_micros: i64, +} + +#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct AiTaskCancelInput { + pub task_id: String, + pub completed_at_micros: i64, +} + +#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct AiTaskFailureInput { + pub task_id: String, + pub failure_message: String, + pub completed_at_micros: i64, +} + +pub fn validate_task_create_input(input: &AiTaskCreateInput) -> Result<(), AiTaskFieldError> { + if normalize_required_string(&input.task_id).is_none() { + return Err(AiTaskFieldError::MissingTaskId); + } + if normalize_required_string(&input.owner_user_id).is_none() { + return Err(AiTaskFieldError::MissingOwnerUserId); + } + if normalize_required_string(&input.request_label).is_none() { + return Err(AiTaskFieldError::MissingRequestLabel); + } + if normalize_required_string(&input.source_module).is_none() { + return Err(AiTaskFieldError::MissingSourceModule); + } + if input.stages.is_empty() { + return Err(AiTaskFieldError::MissingStageBlueprints); + } + + let mut seen = HashMap::new(); + for stage in &input.stages { + if normalize_required_string(&stage.label).is_none() + || normalize_required_string(&stage.detail).is_none() + { + return Err(AiTaskFieldError::MissingStageBlueprints); + } + + if seen.insert(stage.stage_kind, true).is_some() { + return Err(AiTaskFieldError::DuplicateStageBlueprint); + } + } + + Ok(()) +} diff --git a/server-rs/crates/module-ai/src/domain.rs b/server-rs/crates/module-ai/src/domain.rs index aa3ab37d..eac99270 100644 --- a/server-rs/crates/module-ai/src/domain.rs +++ b/server-rs/crates/module-ai/src/domain.rs @@ -1,4 +1,239 @@ -//! AI 领域模型过渡落位。 -//! -//! 当前历史实现仍在 `lib.rs`。后续迁移 `AiTask`、阶段、流式片段和结果引用时, -//! 只能放入纯领域类型与状态迁移,不能引入 LLM、HTTP 或 SpacetimeDB adapter。 +use serde::{Deserialize, Serialize}; +use shared_kernel::{ + build_prefixed_seed_id, normalize_optional_string as normalize_shared_optional_string, + normalize_string_list as normalize_shared_string_list, +}; +#[cfg(feature = "spacetime-types")] +use spacetimedb::SpacetimeType; + +pub const AI_TASK_ID_PREFIX: &str = "aitask_"; +pub const AI_TASK_STAGE_ID_PREFIX: &str = "aistage_"; +pub const AI_RESULT_REF_ID_PREFIX: &str = "aires_"; +pub const AI_TEXT_CHUNK_ID_PREFIX: &str = "aichunk_"; +pub const INITIAL_AI_TASK_VERSION: u32 = 1; + +// AI 编排类型与当前正式运行时主链保持一致,具体 prompt 策略留给上层模块。 +#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub enum AiTaskKind { + StoryGeneration, + CharacterChat, + NpcChat, + CustomWorldGeneration, + QuestIntent, + RuntimeItemIntent, +} + +#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub enum AiTaskStatus { + Pending, + Running, + Completed, + Failed, + Cancelled, +} + +#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub enum AiTaskStageKind { + PreparePrompt, + RequestModel, + RepairResponse, + NormalizeResult, + PersistResult, +} + +#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub enum AiTaskStageStatus { + Pending, + Running, + Completed, + Skipped, +} + +#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub enum AiResultReferenceKind { + StorySession, + StoryEvent, + CustomWorldProfile, + QuestRecord, + RuntimeItemRecord, + AssetObject, +} + +#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct AiTaskStageBlueprint { + pub stage_kind: AiTaskStageKind, + pub label: String, + pub detail: String, + pub order: u32, +} + +#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct AiTaskStageSnapshot { + pub stage_kind: AiTaskStageKind, + pub label: String, + pub detail: String, + pub order: u32, + pub status: AiTaskStageStatus, + pub text_output: Option, + pub structured_payload_json: Option, + pub warning_messages: Vec, + pub started_at_micros: Option, + pub completed_at_micros: Option, +} + +#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct AiTaskSnapshot { + pub task_id: String, + pub task_kind: AiTaskKind, + pub owner_user_id: String, + pub request_label: String, + pub source_module: String, + pub source_entity_id: Option, + pub request_payload_json: Option, + pub status: AiTaskStatus, + pub failure_message: Option, + pub stages: Vec, + pub result_references: Vec, + pub latest_text_output: Option, + pub latest_structured_payload_json: Option, + pub version: u32, + pub created_at_micros: i64, + pub started_at_micros: Option, + pub completed_at_micros: Option, + pub updated_at_micros: i64, +} + +#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct AiTextChunkSnapshot { + pub chunk_id: String, + pub task_id: String, + pub stage_kind: AiTaskStageKind, + pub sequence: u32, + pub delta_text: String, + pub created_at_micros: i64, +} + +#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct AiResultReferenceSnapshot { + pub result_ref_id: String, + pub task_id: String, + pub reference_kind: AiResultReferenceKind, + pub reference_id: String, + pub label: Option, + pub created_at_micros: i64, +} + +impl AiTaskKind { + pub fn default_stage_blueprints(self) -> Vec { + let ordered_kinds = match self { + Self::StoryGeneration => vec![ + AiTaskStageKind::PreparePrompt, + AiTaskStageKind::RequestModel, + AiTaskStageKind::RepairResponse, + AiTaskStageKind::NormalizeResult, + ], + Self::CharacterChat | Self::NpcChat | Self::QuestIntent | Self::RuntimeItemIntent => { + vec![ + AiTaskStageKind::PreparePrompt, + AiTaskStageKind::RequestModel, + AiTaskStageKind::NormalizeResult, + ] + } + Self::CustomWorldGeneration => vec![ + AiTaskStageKind::PreparePrompt, + AiTaskStageKind::RequestModel, + AiTaskStageKind::RepairResponse, + AiTaskStageKind::NormalizeResult, + AiTaskStageKind::PersistResult, + ], + }; + + ordered_kinds + .into_iter() + .enumerate() + .map(|(index, stage_kind)| AiTaskStageBlueprint { + stage_kind, + label: stage_kind.default_label().to_string(), + detail: stage_kind.default_detail().to_string(), + order: index as u32, + }) + .collect() + } +} + +impl AiTaskStageKind { + pub fn as_str(self) -> &'static str { + match self { + Self::PreparePrompt => "prepare_prompt", + Self::RequestModel => "request_model", + Self::RepairResponse => "repair_response", + Self::NormalizeResult => "normalize_result", + Self::PersistResult => "persist_result", + } + } + + pub fn default_label(self) -> &'static str { + match self { + Self::PreparePrompt => "整理提示词", + Self::RequestModel => "请求模型", + Self::RepairResponse => "修复响应", + Self::NormalizeResult => "归一结果", + Self::PersistResult => "回写结果", + } + } + + pub fn default_detail(self) -> &'static str { + match self { + Self::PreparePrompt => "整理输入上下文并构建本轮提示词。", + Self::RequestModel => "向上游模型发起正式推理请求。", + Self::RepairResponse => "对非严格输出做补救修复或二次编排。", + Self::NormalizeResult => "把模型输出归一成模块可消费结构。", + Self::PersistResult => "把结果引用或聚合状态回写到下游模块。", + } + } +} + +impl AiTaskStatus { + pub fn is_terminal(self) -> bool { + matches!(self, Self::Completed | Self::Failed | Self::Cancelled) + } +} + +pub fn generate_ai_task_id(seed_micros: i64) -> String { + build_prefixed_seed_id(AI_TASK_ID_PREFIX, seed_micros) +} + +pub fn generate_ai_task_stage_id(task_id: &str, stage_kind: AiTaskStageKind) -> String { + format!( + "{}{}_{}", + AI_TASK_STAGE_ID_PREFIX, + task_id.trim(), + stage_kind.as_str() + ) +} + +pub fn generate_ai_result_ref_id(seed_micros: i64) -> String { + build_prefixed_seed_id(AI_RESULT_REF_ID_PREFIX, seed_micros) +} + +pub fn generate_ai_text_chunk_id(seed_micros: i64, sequence: u32) -> String { + format!("{}{seed_micros:x}_{sequence:x}", AI_TEXT_CHUNK_ID_PREFIX) +} + +pub fn normalize_optional_text(value: Option) -> Option { + normalize_shared_optional_string(value) +} + +pub fn normalize_string_list(values: Vec) -> Vec { + normalize_shared_string_list(values) +} diff --git a/server-rs/crates/module-ai/src/errors.rs b/server-rs/crates/module-ai/src/errors.rs index 84689dd7..3270426b 100644 --- a/server-rs/crates/module-ai/src/errors.rs +++ b/server-rs/crates/module-ai/src/errors.rs @@ -1,3 +1,61 @@ -//! AI 领域错误过渡落位。 -//! -//! 错误必须可被 HTTP adapter 和 SpacetimeDB adapter 显式映射,不能直接绑定状态码。 +use std::{error::Error, fmt}; + +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum AiTaskFieldError { + MissingTaskId, + MissingOwnerUserId, + MissingRequestLabel, + MissingSourceModule, + MissingStageBlueprints, + DuplicateStageBlueprint, + MissingReferenceId, + MissingChunkText, + InvalidSequence, + MissingFailureMessage, + MissingStage, + InvalidTaskState, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum AiTaskServiceError { + Field(AiTaskFieldError), + TaskAlreadyExists, + TaskNotFound, + StageNotFound, + Store(String), +} + +impl fmt::Display for AiTaskFieldError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::MissingTaskId => f.write_str("ai_task.task_id 不能为空"), + Self::MissingOwnerUserId => f.write_str("ai_task.owner_user_id 不能为空"), + Self::MissingRequestLabel => f.write_str("ai_task.request_label 不能为空"), + Self::MissingSourceModule => f.write_str("ai_task.source_module 不能为空"), + Self::MissingStageBlueprints => f.write_str("ai_task.stages 至少需要一个有效阶段"), + Self::DuplicateStageBlueprint => f.write_str("ai_task.stages 不能包含重复阶段"), + Self::MissingReferenceId => f.write_str("ai_result_reference.reference_id 不能为空"), + Self::MissingChunkText => f.write_str("ai_text_chunk.delta_text 不能为空"), + Self::InvalidSequence => f.write_str("ai_text_chunk.sequence 必须大于 0"), + Self::MissingFailureMessage => f.write_str("ai_task.failure_message 不能为空"), + Self::MissingStage => f.write_str("ai_task.stage 不存在"), + Self::InvalidTaskState => f.write_str("当前 ai_task 状态不允许执行该操作"), + } + } +} + +impl Error for AiTaskFieldError {} + +impl fmt::Display for AiTaskServiceError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Field(error) => write!(f, "{error}"), + Self::TaskAlreadyExists => f.write_str("ai_task 已存在,不能重复创建"), + Self::TaskNotFound => f.write_str("ai_task 不存在"), + Self::StageNotFound => f.write_str("ai_task.stage 不存在"), + Self::Store(message) => f.write_str(message), + } + } +} + +impl Error for AiTaskServiceError {} diff --git a/server-rs/crates/module-ai/src/events.rs b/server-rs/crates/module-ai/src/events.rs index 289edb08..83d7c8d9 100644 --- a/server-rs/crates/module-ai/src/events.rs +++ b/server-rs/crates/module-ai/src/events.rs @@ -1,3 +1,32 @@ -//! AI 领域事件过渡落位。 -//! -//! 用于表达任务开始、阶段完成、任务失败和结果引用挂接等跨上下文事实。 +use crate::{ + AiResultReferenceKind, AiTaskKind, AiTaskStageKind, AiTaskStatus, AiTextChunkSnapshot, +}; + +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum AiTaskDomainEvent { + TaskCreated { + task_id: String, + task_kind: AiTaskKind, + owner_user_id: String, + }, + TaskStatusChanged { + task_id: String, + status: AiTaskStatus, + }, + StageStarted { + task_id: String, + stage_kind: AiTaskStageKind, + }, + StageCompleted { + task_id: String, + stage_kind: AiTaskStageKind, + }, + TextChunkAppended { + chunk: AiTextChunkSnapshot, + }, + ResultReferenceAttached { + task_id: String, + reference_kind: AiResultReferenceKind, + reference_id: String, + }, +} diff --git a/server-rs/crates/module-ai/src/lib.rs b/server-rs/crates/module-ai/src/lib.rs index fc414ea2..b69211ac 100644 --- a/server-rs/crates/module-ai/src/lib.rs +++ b/server-rs/crates/module-ai/src/lib.rs @@ -4,832 +4,22 @@ mod domain; mod errors; mod events; -use std::{ - collections::HashMap, - error::Error, - fmt, - sync::{Arc, Mutex}, +pub use application::{AiTaskProcedureResult, AiTaskService, InMemoryAiTaskStore}; +pub use commands::{ + AiResultReferenceInput, AiStageCompletionInput, AiTaskCancelInput, AiTaskCreateInput, + AiTaskFailureInput, AiTaskFinishInput, AiTaskStageStartInput, AiTaskStartInput, + AiTextChunkAppendInput, validate_task_create_input, }; - -use serde::{Deserialize, Serialize}; -use shared_kernel::{ - build_prefixed_seed_id, normalize_optional_string as normalize_shared_optional_string, - normalize_required_string, normalize_string_list as normalize_shared_string_list, +pub use domain::{ + AI_RESULT_REF_ID_PREFIX, AI_TASK_ID_PREFIX, AI_TASK_STAGE_ID_PREFIX, AI_TEXT_CHUNK_ID_PREFIX, + AiResultReferenceKind, AiResultReferenceSnapshot, AiTaskKind, AiTaskSnapshot, + AiTaskStageBlueprint, AiTaskStageKind, AiTaskStageSnapshot, AiTaskStageStatus, AiTaskStatus, + AiTextChunkSnapshot, INITIAL_AI_TASK_VERSION, generate_ai_result_ref_id, generate_ai_task_id, + generate_ai_task_stage_id, generate_ai_text_chunk_id, normalize_optional_text, + normalize_string_list, }; -#[cfg(feature = "spacetime-types")] -use spacetimedb::SpacetimeType; - -pub const AI_TASK_ID_PREFIX: &str = "aitask_"; -pub const AI_TASK_STAGE_ID_PREFIX: &str = "aistage_"; -pub const AI_RESULT_REF_ID_PREFIX: &str = "aires_"; -pub const AI_TEXT_CHUNK_ID_PREFIX: &str = "aichunk_"; -pub const INITIAL_AI_TASK_VERSION: u32 = 1; - -// AI 编排类型与当前 Node 正式运行时主链保持一致,避免后续接线时重新发明命名。 -#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] -#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] -pub enum AiTaskKind { - StoryGeneration, - CharacterChat, - NpcChat, - CustomWorldGeneration, - QuestIntent, - RuntimeItemIntent, -} - -#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] -#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] -pub enum AiTaskStatus { - Pending, - Running, - Completed, - Failed, - Cancelled, -} - -#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] -#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)] -pub enum AiTaskStageKind { - PreparePrompt, - RequestModel, - RepairResponse, - NormalizeResult, - PersistResult, -} - -#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] -#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] -pub enum AiTaskStageStatus { - Pending, - Running, - Completed, - Skipped, -} - -#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] -#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] -pub enum AiResultReferenceKind { - StorySession, - StoryEvent, - CustomWorldProfile, - QuestRecord, - RuntimeItemRecord, - AssetObject, -} - -#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] -#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] -pub struct AiTaskStageBlueprint { - pub stage_kind: AiTaskStageKind, - pub label: String, - pub detail: String, - pub order: u32, -} - -#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] -#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] -pub struct AiTaskStageSnapshot { - pub stage_kind: AiTaskStageKind, - pub label: String, - pub detail: String, - pub order: u32, - pub status: AiTaskStageStatus, - pub text_output: Option, - pub structured_payload_json: Option, - pub warning_messages: Vec, - pub started_at_micros: Option, - pub completed_at_micros: Option, -} - -#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] -#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] -pub struct AiTaskCreateInput { - pub task_id: String, - pub task_kind: AiTaskKind, - pub owner_user_id: String, - pub request_label: String, - pub source_module: String, - pub source_entity_id: Option, - pub request_payload_json: Option, - pub stages: Vec, - pub created_at_micros: i64, -} - -#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] -#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] -pub struct AiTaskStartInput { - pub task_id: String, - pub started_at_micros: i64, -} - -#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] -#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] -pub struct AiTaskStageStartInput { - pub task_id: String, - pub stage_kind: AiTaskStageKind, - pub started_at_micros: i64, -} - -#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] -#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] -pub struct AiTaskSnapshot { - pub task_id: String, - pub task_kind: AiTaskKind, - pub owner_user_id: String, - pub request_label: String, - pub source_module: String, - pub source_entity_id: Option, - pub request_payload_json: Option, - pub status: AiTaskStatus, - pub failure_message: Option, - pub stages: Vec, - pub result_references: Vec, - pub latest_text_output: Option, - pub latest_structured_payload_json: Option, - pub version: u32, - pub created_at_micros: i64, - pub started_at_micros: Option, - pub completed_at_micros: Option, - pub updated_at_micros: i64, -} - -#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] -#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] -pub struct AiTextChunkSnapshot { - pub chunk_id: String, - pub task_id: String, - pub stage_kind: AiTaskStageKind, - pub sequence: u32, - pub delta_text: String, - pub created_at_micros: i64, -} - -#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] -#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] -pub struct AiTextChunkAppendInput { - pub task_id: String, - pub stage_kind: AiTaskStageKind, - pub sequence: u32, - pub delta_text: String, - pub created_at_micros: i64, -} - -#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] -#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] -pub struct AiStageCompletionInput { - pub task_id: String, - pub stage_kind: AiTaskStageKind, - pub text_output: Option, - pub structured_payload_json: Option, - pub warning_messages: Vec, - pub completed_at_micros: i64, -} - -#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] -#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] -pub struct AiResultReferenceInput { - pub task_id: String, - pub reference_kind: AiResultReferenceKind, - pub reference_id: String, - pub label: Option, - pub created_at_micros: i64, -} - -#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] -#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] -pub struct AiResultReferenceSnapshot { - pub result_ref_id: String, - pub task_id: String, - pub reference_kind: AiResultReferenceKind, - pub reference_id: String, - pub label: Option, - pub created_at_micros: i64, -} - -#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] -#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] -pub struct AiTaskFinishInput { - pub task_id: String, - pub completed_at_micros: i64, -} - -#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] -#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] -pub struct AiTaskCancelInput { - pub task_id: String, - pub completed_at_micros: i64, -} - -#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] -#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] -pub struct AiTaskFailureInput { - pub task_id: String, - pub failure_message: String, - pub completed_at_micros: i64, -} - -#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] -#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] -pub struct AiTaskProcedureResult { - pub ok: bool, - pub task: Option, - pub text_chunk: Option, - pub error_message: Option, -} - -#[derive(Clone, Debug, PartialEq, Eq)] -pub enum AiTaskFieldError { - MissingTaskId, - MissingOwnerUserId, - MissingRequestLabel, - MissingSourceModule, - MissingStageBlueprints, - DuplicateStageBlueprint, - MissingReferenceId, - MissingChunkText, - InvalidSequence, - MissingFailureMessage, - MissingStage, - InvalidTaskState, -} - -#[derive(Clone, Debug, PartialEq, Eq)] -pub enum AiTaskServiceError { - Field(AiTaskFieldError), - TaskAlreadyExists, - TaskNotFound, - StageNotFound, - Store(String), -} - -#[derive(Clone, Debug, Default)] -pub struct InMemoryAiTaskStore { - inner: Arc>, -} - -#[derive(Debug, Default)] -struct InMemoryAiTaskStoreState { - tasks: HashMap, - text_chunks: HashMap>, -} - -#[derive(Clone, Debug)] -pub struct AiTaskService { - store: InMemoryAiTaskStore, -} - -impl AiTaskKind { - // 默认阶段蓝图只冻结通用语义,具体 prompt 内容与供应商策略仍由上层模块决定。 - pub fn default_stage_blueprints(self) -> Vec { - let ordered_kinds = match self { - Self::StoryGeneration => vec![ - AiTaskStageKind::PreparePrompt, - AiTaskStageKind::RequestModel, - AiTaskStageKind::RepairResponse, - AiTaskStageKind::NormalizeResult, - ], - Self::CharacterChat | Self::NpcChat | Self::QuestIntent | Self::RuntimeItemIntent => { - vec![ - AiTaskStageKind::PreparePrompt, - AiTaskStageKind::RequestModel, - AiTaskStageKind::NormalizeResult, - ] - } - Self::CustomWorldGeneration => vec![ - AiTaskStageKind::PreparePrompt, - AiTaskStageKind::RequestModel, - AiTaskStageKind::RepairResponse, - AiTaskStageKind::NormalizeResult, - AiTaskStageKind::PersistResult, - ], - }; - - ordered_kinds - .into_iter() - .enumerate() - .map(|(index, stage_kind)| AiTaskStageBlueprint { - stage_kind, - label: stage_kind.default_label().to_string(), - detail: stage_kind.default_detail().to_string(), - order: index as u32, - }) - .collect() - } -} - -impl AiTaskStageKind { - pub fn as_str(self) -> &'static str { - match self { - Self::PreparePrompt => "prepare_prompt", - Self::RequestModel => "request_model", - Self::RepairResponse => "repair_response", - Self::NormalizeResult => "normalize_result", - Self::PersistResult => "persist_result", - } - } - - pub fn default_label(self) -> &'static str { - match self { - Self::PreparePrompt => "整理提示词", - Self::RequestModel => "请求模型", - Self::RepairResponse => "修复响应", - Self::NormalizeResult => "归一结果", - Self::PersistResult => "回写结果", - } - } - - pub fn default_detail(self) -> &'static str { - match self { - Self::PreparePrompt => "整理输入上下文并构建本轮提示词。", - Self::RequestModel => "向上游模型发起正式推理请求。", - Self::RepairResponse => "对非严格输出做补救修复或二次编排。", - Self::NormalizeResult => "把模型输出归一成模块可消费结构。", - Self::PersistResult => "把结果引用或聚合状态回写到下游模块。", - } - } -} - -impl AiTaskStatus { - fn is_terminal(self) -> bool { - matches!(self, Self::Completed | Self::Failed | Self::Cancelled) - } -} - -impl AiTaskService { - pub fn new(store: InMemoryAiTaskStore) -> Self { - Self { store } - } - - pub fn create_task( - &self, - input: AiTaskCreateInput, - ) -> Result { - validate_task_create_input(&input).map_err(AiTaskServiceError::Field)?; - - let snapshot = AiTaskSnapshot { - task_id: input.task_id.clone(), - task_kind: input.task_kind, - owner_user_id: normalize_required_string(input.owner_user_id).unwrap_or_default(), - request_label: normalize_required_string(input.request_label).unwrap_or_default(), - source_module: normalize_required_string(input.source_module).unwrap_or_default(), - source_entity_id: normalize_optional_text(input.source_entity_id), - request_payload_json: normalize_optional_text(input.request_payload_json), - status: AiTaskStatus::Pending, - failure_message: None, - stages: input - .stages - .into_iter() - .map(|stage| AiTaskStageSnapshot { - stage_kind: stage.stage_kind, - label: normalize_required_string(stage.label).unwrap_or_default(), - detail: normalize_required_string(stage.detail).unwrap_or_default(), - 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, - }; - - self.store.insert_task(snapshot) - } - - pub fn start_task( - &self, - task_id: &str, - started_at_micros: i64, - ) -> Result { - self.store.update_task(task_id, |task| { - if task.status.is_terminal() { - return Err(AiTaskServiceError::Field( - AiTaskFieldError::InvalidTaskState, - )); - } - - task.status = AiTaskStatus::Running; - task.started_at_micros.get_or_insert(started_at_micros); - task.updated_at_micros = started_at_micros; - task.version += 1; - Ok(()) - }) - } - - pub fn start_stage( - &self, - task_id: &str, - stage_kind: AiTaskStageKind, - started_at_micros: i64, - ) -> Result { - self.store.update_task(task_id, |task| { - if task.status.is_terminal() { - return Err(AiTaskServiceError::Field( - AiTaskFieldError::InvalidTaskState, - )); - } - - task.status = AiTaskStatus::Running; - task.started_at_micros.get_or_insert(started_at_micros); - let stage = task - .stages - .iter_mut() - .find(|stage| stage.stage_kind == stage_kind) - .ok_or(AiTaskServiceError::StageNotFound)?; - stage.status = AiTaskStageStatus::Running; - stage.started_at_micros.get_or_insert(started_at_micros); - task.updated_at_micros = started_at_micros; - task.version += 1; - Ok(()) - }) - } - - pub fn append_text_chunk( - &self, - task_id: &str, - stage_kind: AiTaskStageKind, - sequence: u32, - delta_text: String, - created_at_micros: i64, - ) -> Result<(AiTaskSnapshot, AiTextChunkSnapshot), AiTaskServiceError> { - if delta_text.trim().is_empty() { - return Err(AiTaskServiceError::Field( - AiTaskFieldError::MissingChunkText, - )); - } - if sequence == 0 { - return Err(AiTaskServiceError::Field(AiTaskFieldError::InvalidSequence)); - } - - let chunk = AiTextChunkSnapshot { - chunk_id: generate_ai_text_chunk_id(created_at_micros, sequence), - task_id: normalize_required_string(task_id).unwrap_or_default(), - stage_kind, - sequence, - delta_text: normalize_required_string(delta_text).unwrap_or_default(), - created_at_micros, - }; - - let task = self.store.append_text_chunk(chunk.clone())?; - Ok((task, chunk)) - } - - pub fn complete_stage( - &self, - input: AiStageCompletionInput, - ) -> Result { - self.store.update_task(&input.task_id, |task| { - if task.status.is_terminal() { - return Err(AiTaskServiceError::Field( - AiTaskFieldError::InvalidTaskState, - )); - } - - let stage = task - .stages - .iter_mut() - .find(|stage| stage.stage_kind == input.stage_kind) - .ok_or(AiTaskServiceError::StageNotFound)?; - 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()); - - task.latest_text_output = stage.text_output.clone(); - task.latest_structured_payload_json = stage.structured_payload_json.clone(); - task.updated_at_micros = input.completed_at_micros; - task.version += 1; - Ok(()) - }) - } - - pub fn attach_result_reference( - &self, - task_id: &str, - reference_kind: AiResultReferenceKind, - reference_id: String, - label: Option, - created_at_micros: i64, - ) -> Result { - let Some(reference_id) = normalize_required_string(reference_id) else { - return Err(AiTaskServiceError::Field( - AiTaskFieldError::MissingReferenceId, - )); - }; - - self.store.update_task(task_id, |task| { - task.result_references.push(AiResultReferenceSnapshot { - result_ref_id: generate_ai_result_ref_id(created_at_micros), - task_id: task.task_id.clone(), - reference_kind, - reference_id: reference_id.clone(), - label: normalize_optional_text(label.clone()), - created_at_micros, - }); - task.updated_at_micros = created_at_micros; - task.version += 1; - Ok(()) - }) - } - - pub fn complete_task( - &self, - task_id: &str, - completed_at_micros: i64, - ) -> Result { - self.store.update_task(task_id, |task| { - if task.status.is_terminal() { - return Err(AiTaskServiceError::Field( - AiTaskFieldError::InvalidTaskState, - )); - } - - task.status = AiTaskStatus::Completed; - task.completed_at_micros = Some(completed_at_micros); - task.updated_at_micros = completed_at_micros; - task.version += 1; - Ok(()) - }) - } - - pub fn fail_task( - &self, - task_id: &str, - failure_message: String, - completed_at_micros: i64, - ) -> Result { - let Some(failure_message) = normalize_required_string(failure_message) else { - return Err(AiTaskServiceError::Field( - AiTaskFieldError::MissingFailureMessage, - )); - }; - - self.store.update_task(task_id, |task| { - if task.status.is_terminal() { - return Err(AiTaskServiceError::Field( - AiTaskFieldError::InvalidTaskState, - )); - } - - task.status = AiTaskStatus::Failed; - task.failure_message = Some(failure_message.clone()); - task.completed_at_micros = Some(completed_at_micros); - task.updated_at_micros = completed_at_micros; - task.version += 1; - Ok(()) - }) - } - - pub fn cancel_task( - &self, - task_id: &str, - completed_at_micros: i64, - ) -> Result { - self.store.update_task(task_id, |task| { - if task.status.is_terminal() { - return Err(AiTaskServiceError::Field( - AiTaskFieldError::InvalidTaskState, - )); - } - - task.status = AiTaskStatus::Cancelled; - task.completed_at_micros = Some(completed_at_micros); - task.updated_at_micros = completed_at_micros; - task.version += 1; - Ok(()) - }) - } - - pub fn get_task(&self, task_id: &str) -> Result { - self.store.get_task(task_id) - } -} - -impl InMemoryAiTaskStore { - fn insert_task(&self, task: AiTaskSnapshot) -> Result { - let mut state = self - .inner - .lock() - .map_err(|_| AiTaskServiceError::Store("AI 任务仓储锁已中毒".to_string()))?; - - if state.tasks.contains_key(&task.task_id) { - return Err(AiTaskServiceError::TaskAlreadyExists); - } - - state.text_chunks.insert(task.task_id.clone(), Vec::new()); - state.tasks.insert(task.task_id.clone(), task.clone()); - Ok(task) - } - - fn update_task( - &self, - task_id: &str, - mut apply: F, - ) -> Result - where - F: FnMut(&mut AiTaskSnapshot) -> Result<(), AiTaskServiceError>, - { - let mut state = self - .inner - .lock() - .map_err(|_| AiTaskServiceError::Store("AI 任务仓储锁已中毒".to_string()))?; - let task = state - .tasks - .get_mut(task_id.trim()) - .ok_or(AiTaskServiceError::TaskNotFound)?; - apply(task)?; - Ok(task.clone()) - } - - fn append_text_chunk( - &self, - chunk: AiTextChunkSnapshot, - ) -> Result { - let mut state = self - .inner - .lock() - .map_err(|_| AiTaskServiceError::Store("AI 任务仓储锁已中毒".to_string()))?; - { - let task = state - .tasks - .get_mut(&chunk.task_id) - .ok_or(AiTaskServiceError::TaskNotFound)?; - if task.status.is_terminal() { - return Err(AiTaskServiceError::Field( - AiTaskFieldError::InvalidTaskState, - )); - } - - let stage = task - .stages - .iter_mut() - .find(|stage| stage.stage_kind == chunk.stage_kind) - .ok_or(AiTaskServiceError::StageNotFound)?; - if stage.status == AiTaskStageStatus::Pending { - stage.status = AiTaskStageStatus::Running; - stage.started_at_micros = Some(chunk.created_at_micros); - } - - task.status = AiTaskStatus::Running; - task.started_at_micros - .get_or_insert(chunk.created_at_micros); - } - - let chunks = state - .text_chunks - .get_mut(&chunk.task_id) - .ok_or(AiTaskServiceError::TaskNotFound)?; - chunks.push(chunk.clone()); - chunks.sort_by_key(|value| value.sequence); - - let aggregated_text = chunks - .iter() - .filter(|value| value.stage_kind == chunk.stage_kind) - .map(|value| value.delta_text.as_str()) - .collect::>() - .join(""); - let normalized_output = if aggregated_text.trim().is_empty() { - None - } else { - Some(aggregated_text) - }; - - let task = state - .tasks - .get_mut(&chunk.task_id) - .ok_or(AiTaskServiceError::TaskNotFound)?; - let stage = task - .stages - .iter_mut() - .find(|stage| stage.stage_kind == chunk.stage_kind) - .ok_or(AiTaskServiceError::StageNotFound)?; - stage.text_output = normalized_output.clone(); - task.latest_text_output = normalized_output; - task.updated_at_micros = chunk.created_at_micros; - task.version += 1; - Ok(task.clone()) - } - - fn get_task(&self, task_id: &str) -> Result { - let state = self - .inner - .lock() - .map_err(|_| AiTaskServiceError::Store("AI 任务仓储锁已中毒".to_string()))?; - state - .tasks - .get(task_id.trim()) - .cloned() - .ok_or(AiTaskServiceError::TaskNotFound) - } -} - -pub fn validate_task_create_input(input: &AiTaskCreateInput) -> Result<(), AiTaskFieldError> { - if normalize_required_string(&input.task_id).is_none() { - return Err(AiTaskFieldError::MissingTaskId); - } - if normalize_required_string(&input.owner_user_id).is_none() { - return Err(AiTaskFieldError::MissingOwnerUserId); - } - if normalize_required_string(&input.request_label).is_none() { - return Err(AiTaskFieldError::MissingRequestLabel); - } - if normalize_required_string(&input.source_module).is_none() { - return Err(AiTaskFieldError::MissingSourceModule); - } - if input.stages.is_empty() { - return Err(AiTaskFieldError::MissingStageBlueprints); - } - - let mut seen = HashMap::new(); - for stage in &input.stages { - if normalize_required_string(&stage.label).is_none() - || normalize_required_string(&stage.detail).is_none() - { - return Err(AiTaskFieldError::MissingStageBlueprints); - } - - if seen.insert(stage.stage_kind, true).is_some() { - return Err(AiTaskFieldError::DuplicateStageBlueprint); - } - } - - Ok(()) -} - -pub fn generate_ai_task_id(seed_micros: i64) -> String { - build_prefixed_seed_id(AI_TASK_ID_PREFIX, seed_micros) -} - -pub fn generate_ai_task_stage_id(task_id: &str, stage_kind: AiTaskStageKind) -> String { - format!( - "{}{}_{}", - AI_TASK_STAGE_ID_PREFIX, - task_id.trim(), - stage_kind.as_str() - ) -} - -pub fn generate_ai_result_ref_id(seed_micros: i64) -> String { - build_prefixed_seed_id(AI_RESULT_REF_ID_PREFIX, seed_micros) -} - -pub fn generate_ai_text_chunk_id(seed_micros: i64, sequence: u32) -> String { - format!("{}{seed_micros:x}_{sequence:x}", AI_TEXT_CHUNK_ID_PREFIX) -} - -pub fn normalize_optional_text(value: Option) -> Option { - normalize_shared_optional_string(value) -} - -pub fn normalize_string_list(values: Vec) -> Vec { - normalize_shared_string_list(values) -} - -impl fmt::Display for AiTaskFieldError { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - match self { - Self::MissingTaskId => f.write_str("ai_task.task_id 不能为空"), - Self::MissingOwnerUserId => f.write_str("ai_task.owner_user_id 不能为空"), - Self::MissingRequestLabel => f.write_str("ai_task.request_label 不能为空"), - Self::MissingSourceModule => f.write_str("ai_task.source_module 不能为空"), - Self::MissingStageBlueprints => f.write_str("ai_task.stages 至少需要一个有效阶段"), - Self::DuplicateStageBlueprint => f.write_str("ai_task.stages 不能包含重复阶段"), - Self::MissingReferenceId => f.write_str("ai_result_reference.reference_id 不能为空"), - Self::MissingChunkText => f.write_str("ai_text_chunk.delta_text 不能为空"), - Self::InvalidSequence => f.write_str("ai_text_chunk.sequence 必须大于 0"), - Self::MissingFailureMessage => f.write_str("ai_task.failure_message 不能为空"), - Self::MissingStage => f.write_str("ai_task.stage 不存在"), - Self::InvalidTaskState => f.write_str("当前 ai_task 状态不允许执行该操作"), - } - } -} - -impl Error for AiTaskFieldError {} - -impl fmt::Display for AiTaskServiceError { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - match self { - Self::Field(error) => write!(f, "{error}"), - Self::TaskAlreadyExists => f.write_str("ai_task 已存在,不能重复创建"), - Self::TaskNotFound => f.write_str("ai_task 不存在"), - Self::StageNotFound => f.write_str("ai_task.stage 不存在"), - Self::Store(message) => f.write_str(message), - } - } -} - -impl Error for AiTaskServiceError {} +pub use errors::{AiTaskFieldError, AiTaskServiceError}; +pub use events::AiTaskDomainEvent; #[cfg(test)] mod tests { diff --git a/server-rs/crates/module-big-fish/src/application.rs b/server-rs/crates/module-big-fish/src/application.rs index 51eb1590..fad5c0ed 100644 --- a/server-rs/crates/module-big-fish/src/application.rs +++ b/server-rs/crates/module-big-fish/src/application.rs @@ -1,3 +1,136 @@ //! 大鱼吃小鱼应用编排过渡落位。 //! //! 这里只组合领域规则并返回结果或事件,不直接调用外部图片、视频或存储服务。 + +use shared_kernel::normalize_required_string; + +use crate::{ + BigFishAssetSlotSnapshot, build_asset_coverage, + commands::EvaluateBigFishPublishReadinessCommand, domain::BigFishPublishReadiness, + errors::BigFishApplicationError, events::BigFishDomainEvent, +}; + +/// 发布门禁应用结果,供 adapter 持久化快照或转换成 API DTO。 +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct EvaluateBigFishPublishReadinessResult { + pub readiness: BigFishPublishReadiness, + pub events: Vec, +} + +/// 评估 Big Fish 作品是否具备发布条件。 +/// +/// 规则只依赖草稿和资产槽:草稿必须存在,等级主图、基础动作和背景图 +/// 必须满足 `build_asset_coverage` 的统一口径。 +pub fn evaluate_publish_readiness( + command: EvaluateBigFishPublishReadinessCommand, + asset_slots: &[BigFishAssetSlotSnapshot], +) -> Result { + let session_id = normalize_required_string(command.session_id) + .ok_or(BigFishApplicationError::MissingSessionId)?; + let owner_user_id = normalize_required_string(command.owner_user_id) + .ok_or(BigFishApplicationError::MissingOwnerUserId)?; + let coverage = build_asset_coverage(command.draft.as_ref(), asset_slots); + let readiness = BigFishPublishReadiness { + session_id: session_id.clone(), + owner_user_id: owner_user_id.clone(), + publish_ready: coverage.publish_ready, + blockers: coverage.blockers.clone(), + evaluated_at_micros: command.evaluated_at_micros, + }; + let event = BigFishDomainEvent::PublishReadinessEvaluated { + session_id, + owner_user_id, + publish_ready: readiness.publish_ready, + blockers: readiness.blockers.clone(), + occurred_at_micros: readiness.evaluated_at_micros, + }; + + Ok(EvaluateBigFishPublishReadinessResult { + readiness, + events: vec![event], + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{ + BigFishAssetKind, build_generated_asset_slot, compile_default_draft, infer_anchor_pack, + }; + + fn build_command() -> EvaluateBigFishPublishReadinessCommand { + EvaluateBigFishPublishReadinessCommand { + session_id: "big-fish-session-1".to_string(), + owner_user_id: "user-1".to_string(), + draft: Some(compile_default_draft(&infer_anchor_pack("机械深海", None))), + evaluated_at_micros: 1_713_680_000_000_000, + } + } + + #[test] + fn evaluate_publish_readiness_reports_blockers_when_assets_missing() { + let result = evaluate_publish_readiness(build_command(), &[]).expect("result"); + + assert!(!result.readiness.publish_ready); + assert!( + result + .readiness + .blockers + .iter() + .any(|item| item.contains("等级主图")) + ); + assert_eq!(result.events.len(), 1); + } + + #[test] + fn evaluate_publish_readiness_accepts_complete_assets() { + let command = build_command(); + let draft = command.draft.clone().expect("draft"); + let mut slots = Vec::new(); + for level in 1..=draft.runtime_params.level_count { + slots.push( + build_generated_asset_slot( + &command.session_id, + &draft, + BigFishAssetKind::LevelMainImage, + Some(level), + None, + Some(format!("/assets/level-{level}.png")), + command.evaluated_at_micros + level as i64, + ) + .expect("main image slot"), + ); + for motion_key in ["idle_float", "move_swim"] { + slots.push( + build_generated_asset_slot( + &command.session_id, + &draft, + BigFishAssetKind::LevelMotion, + Some(level), + Some(motion_key.to_string()), + Some(format!("/assets/level-{level}-{motion_key}.webm")), + command.evaluated_at_micros + 100 + level as i64, + ) + .expect("motion slot"), + ); + } + } + slots.push( + build_generated_asset_slot( + &command.session_id, + &draft, + BigFishAssetKind::StageBackground, + None, + None, + Some("/assets/bg.png".to_string()), + command.evaluated_at_micros + 1_000, + ) + .expect("background slot"), + ); + + let result = evaluate_publish_readiness(command, &slots).expect("result"); + + assert!(result.readiness.publish_ready); + assert!(result.readiness.blockers.is_empty()); + } +} diff --git a/server-rs/crates/module-big-fish/src/commands.rs b/server-rs/crates/module-big-fish/src/commands.rs index c53f4d56..694c3c1a 100644 --- a/server-rs/crates/module-big-fish/src/commands.rs +++ b/server-rs/crates/module-big-fish/src/commands.rs @@ -1,3 +1,17 @@ //! 大鱼吃小鱼写入命令过渡落位。 //! //! 用于表达创建会话、写入消息、更新资产槽和推进运行态等输入。 + +use crate::BigFishGameDraft; + +/// 评估作品是否可以发布的纯领域命令。 +/// +/// adapter 负责把 SpacetimeDB row 或 HTTP DTO 映射成这里的输入; +/// 命令本身只关心草稿与资产槽这些领域事实。 +#[derive(Clone, Debug, PartialEq)] +pub struct EvaluateBigFishPublishReadinessCommand { + pub session_id: String, + pub owner_user_id: String, + pub draft: Option, + pub evaluated_at_micros: i64, +} diff --git a/server-rs/crates/module-big-fish/src/domain.rs b/server-rs/crates/module-big-fish/src/domain.rs index 8057d81f..60a6b429 100644 --- a/server-rs/crates/module-big-fish/src/domain.rs +++ b/server-rs/crates/module-big-fish/src/domain.rs @@ -2,3 +2,15 @@ //! //! 后续迁移创作会话、资产槽和运行态聚合时,只保留玩法状态与规则; //! 图片生成、OSS 与 HTTP handler 均留在 adapter 层。 + +/// 发布门禁的领域判定结果。 +/// +/// 这里不保存外部任务状态,只表达当前聚合快照是否满足发布条件。 +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct BigFishPublishReadiness { + pub session_id: String, + pub owner_user_id: String, + pub publish_ready: bool, + pub blockers: Vec, + pub evaluated_at_micros: i64, +} diff --git a/server-rs/crates/module-big-fish/src/errors.rs b/server-rs/crates/module-big-fish/src/errors.rs index aff78b03..ec920f89 100644 --- a/server-rs/crates/module-big-fish/src/errors.rs +++ b/server-rs/crates/module-big-fish/src/errors.rs @@ -1,3 +1,25 @@ //! 大鱼吃小鱼领域错误过渡落位。 //! //! 错误只表达玩法规则失败,由 HTTP 和 SpacetimeDB adapter 分别映射展示。 + +use std::{error::Error, fmt}; + +/// 大鱼吃小鱼应用服务错误。 +/// +/// 这里不携带 HTTP status 或 SpacetimeDB 字符串错误,避免领域层泄漏 adapter 语义。 +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum BigFishApplicationError { + MissingSessionId, + MissingOwnerUserId, +} + +impl fmt::Display for BigFishApplicationError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::MissingSessionId => f.write_str("big_fish.session_id 不能为空"), + Self::MissingOwnerUserId => f.write_str("big_fish.owner_user_id 不能为空"), + } + } +} + +impl Error for BigFishApplicationError {} diff --git a/server-rs/crates/module-big-fish/src/events.rs b/server-rs/crates/module-big-fish/src/events.rs index 07522de8..ba6201d3 100644 --- a/server-rs/crates/module-big-fish/src/events.rs +++ b/server-rs/crates/module-big-fish/src/events.rs @@ -1,3 +1,18 @@ //! 大鱼吃小鱼领域事件过渡落位。 //! //! 用于表达草稿变化、资产槽变化和运行态 tick 等事实。 + +/// 大鱼吃小鱼领域事件。 +/// +/// 事件只描述已经发生的领域事实,后续由 SpacetimeDB adapter 或 BFF +/// 决定是否持久化、投影或通知前端。 +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum BigFishDomainEvent { + PublishReadinessEvaluated { + session_id: String, + owner_user_id: String, + publish_ready: bool, + blockers: Vec, + occurred_at_micros: i64, + }, +} diff --git a/server-rs/crates/module-big-fish/src/lib.rs b/server-rs/crates/module-big-fish/src/lib.rs index bab85a70..1b358831 100644 --- a/server-rs/crates/module-big-fish/src/lib.rs +++ b/server-rs/crates/module-big-fish/src/lib.rs @@ -4,6 +4,12 @@ mod domain; mod errors; mod events; +pub use application::{EvaluateBigFishPublishReadinessResult, evaluate_publish_readiness}; +pub use commands::EvaluateBigFishPublishReadinessCommand; +pub use domain::BigFishPublishReadiness; +pub use errors::BigFishApplicationError; +pub use events::BigFishDomainEvent; + use std::{error::Error, fmt}; use serde::{Deserialize, Serialize}; diff --git a/server-rs/crates/module-runtime-story-compat/README.md b/server-rs/crates/module-runtime-story-compat/README.md deleted file mode 100644 index a2258613..00000000 --- a/server-rs/crates/module-runtime-story-compat/README.md +++ /dev/null @@ -1,13 +0,0 @@ -# module-runtime-story-compat - -`module-runtime-story-compat` 承接旧 `/api/runtime/story/*` 兼容桥中不依赖 HTTP / `AppState` 的核心类型与纯 helper。 - -当前首批迁入范围保持克制: - -1. action 结算结果结构。 -2. action response 组装参数结构。 -3. NPC 委托上下文结构。 -4. functionId / 队伍上限常量。 -5. 少量只依赖 `serde_json::Value` 与 `shared-contracts` 的纯 helper。 - -后续再按 battle / forge / NPC / quest / presentation 的顺序,把已经拆好的 `api-server` 内部模块逐步迁入本 crate。 diff --git a/server-rs/crates/module-runtime-story-compat/src/application.rs b/server-rs/crates/module-runtime-story-compat/src/application.rs deleted file mode 100644 index e7e15af2..00000000 --- a/server-rs/crates/module-runtime-story-compat/src/application.rs +++ /dev/null @@ -1,3 +0,0 @@ -//! runtime story 兼容应用编排过渡落位。 -//! -//! 这里只组合旧规则并返回兼容结果;真实保存、SSE 和模型调用由外层完成。 diff --git a/server-rs/crates/module-runtime-story-compat/Cargo.toml b/server-rs/crates/module-runtime-story/Cargo.toml similarity index 87% rename from server-rs/crates/module-runtime-story-compat/Cargo.toml rename to server-rs/crates/module-runtime-story/Cargo.toml index a0f9d735..8242922f 100644 --- a/server-rs/crates/module-runtime-story-compat/Cargo.toml +++ b/server-rs/crates/module-runtime-story/Cargo.toml @@ -1,5 +1,5 @@ [package] -name = "module-runtime-story-compat" +name = "module-runtime-story" edition.workspace = true version.workspace = true license.workspace = true diff --git a/server-rs/crates/module-runtime-story/README.md b/server-rs/crates/module-runtime-story/README.md new file mode 100644 index 00000000..6a286a30 --- /dev/null +++ b/server-rs/crates/module-runtime-story/README.md @@ -0,0 +1,13 @@ +# module-runtime-story + +`module-runtime-story` 承接 RPG runtime story 的纯领域规则、应用用例、事件和错误模型,不依赖 HTTP / `AppState` / SpacetimeDB。 + +当前已经迁入的历史兼容纯逻辑会继续收口为 session scoped 新主链: + +1. action 结算结果结构。 +2. action response 组装参数结构。 +3. NPC 委托上下文结构。 +4. functionId / 队伍上限常量。 +5. 少量只依赖 `serde_json::Value` 与 `shared-contracts` 的纯 helper。 + +后续 WP-RS 继续按 battle / forge / NPC / quest / presentation 的顺序,把旧 `/api/runtime/story/*` 兼容桥中剩余纯规则迁入本 crate,并删除运行代码中的 compat 命名。 diff --git a/server-rs/crates/module-runtime-story/src/application.rs b/server-rs/crates/module-runtime-story/src/application.rs new file mode 100644 index 00000000..53dc09bd --- /dev/null +++ b/server-rs/crates/module-runtime-story/src/application.rs @@ -0,0 +1,3 @@ +//! runtime story 应用编排落位。 +//! +//! 这里组合纯领域规则并返回后端投影;真实保存、SSE 和模型调用由外层完成。 diff --git a/server-rs/crates/module-runtime-story-compat/src/battle.rs b/server-rs/crates/module-runtime-story/src/battle.rs similarity index 100% rename from server-rs/crates/module-runtime-story-compat/src/battle.rs rename to server-rs/crates/module-runtime-story/src/battle.rs diff --git a/server-rs/crates/module-runtime-story-compat/src/battle_tests.rs b/server-rs/crates/module-runtime-story/src/battle_tests.rs similarity index 100% rename from server-rs/crates/module-runtime-story-compat/src/battle_tests.rs rename to server-rs/crates/module-runtime-story/src/battle_tests.rs diff --git a/server-rs/crates/module-runtime-story-compat/src/commands.rs b/server-rs/crates/module-runtime-story/src/commands.rs similarity index 100% rename from server-rs/crates/module-runtime-story-compat/src/commands.rs rename to server-rs/crates/module-runtime-story/src/commands.rs diff --git a/server-rs/crates/module-runtime-story-compat/src/core.rs b/server-rs/crates/module-runtime-story/src/core.rs similarity index 100% rename from server-rs/crates/module-runtime-story-compat/src/core.rs rename to server-rs/crates/module-runtime-story/src/core.rs diff --git a/server-rs/crates/module-runtime-story-compat/src/domain.rs b/server-rs/crates/module-runtime-story/src/domain.rs similarity index 100% rename from server-rs/crates/module-runtime-story-compat/src/domain.rs rename to server-rs/crates/module-runtime-story/src/domain.rs diff --git a/server-rs/crates/module-runtime-story-compat/src/errors.rs b/server-rs/crates/module-runtime-story/src/errors.rs similarity index 100% rename from server-rs/crates/module-runtime-story-compat/src/errors.rs rename to server-rs/crates/module-runtime-story/src/errors.rs diff --git a/server-rs/crates/module-runtime-story-compat/src/events.rs b/server-rs/crates/module-runtime-story/src/events.rs similarity index 100% rename from server-rs/crates/module-runtime-story-compat/src/events.rs rename to server-rs/crates/module-runtime-story/src/events.rs diff --git a/server-rs/crates/module-runtime-story-compat/src/forge.rs b/server-rs/crates/module-runtime-story/src/forge.rs similarity index 100% rename from server-rs/crates/module-runtime-story-compat/src/forge.rs rename to server-rs/crates/module-runtime-story/src/forge.rs diff --git a/server-rs/crates/module-runtime-story-compat/src/forge_actions.rs b/server-rs/crates/module-runtime-story/src/forge_actions.rs similarity index 100% rename from server-rs/crates/module-runtime-story-compat/src/forge_actions.rs rename to server-rs/crates/module-runtime-story/src/forge_actions.rs diff --git a/server-rs/crates/module-runtime-story-compat/src/game_state.rs b/server-rs/crates/module-runtime-story/src/game_state.rs similarity index 100% rename from server-rs/crates/module-runtime-story-compat/src/game_state.rs rename to server-rs/crates/module-runtime-story/src/game_state.rs diff --git a/server-rs/crates/module-runtime-story-compat/src/lib.rs b/server-rs/crates/module-runtime-story/src/lib.rs similarity index 98% rename from server-rs/crates/module-runtime-story-compat/src/lib.rs rename to server-rs/crates/module-runtime-story/src/lib.rs index 01752679..d49999c6 100644 --- a/server-rs/crates/module-runtime-story-compat/src/lib.rs +++ b/server-rs/crates/module-runtime-story/src/lib.rs @@ -20,6 +20,7 @@ pub mod game_state; pub mod npc_support; pub mod options; pub mod post_battle; +pub mod projection; pub mod prompt_context; pub mod story_engine; pub mod view_model; @@ -69,6 +70,7 @@ pub use options::{ pub use post_battle::{ finalize_post_battle_resolution, is_terminal_battle_outcome, resolve_post_battle_story_options, }; +pub use projection::{StoryRuntimeProjectionSource, build_story_runtime_projection}; pub use prompt_context::{RuntimeStoryPromptContextExtras, build_runtime_story_prompt_context}; pub use story_engine::project_story_engine_after_action; pub use view_model::{ diff --git a/server-rs/crates/module-runtime-story-compat/src/npc_support.rs b/server-rs/crates/module-runtime-story/src/npc_support.rs similarity index 100% rename from server-rs/crates/module-runtime-story-compat/src/npc_support.rs rename to server-rs/crates/module-runtime-story/src/npc_support.rs diff --git a/server-rs/crates/module-runtime-story-compat/src/options.rs b/server-rs/crates/module-runtime-story/src/options.rs similarity index 100% rename from server-rs/crates/module-runtime-story-compat/src/options.rs rename to server-rs/crates/module-runtime-story/src/options.rs diff --git a/server-rs/crates/module-runtime-story-compat/src/post_battle.rs b/server-rs/crates/module-runtime-story/src/post_battle.rs similarity index 100% rename from server-rs/crates/module-runtime-story-compat/src/post_battle.rs rename to server-rs/crates/module-runtime-story/src/post_battle.rs diff --git a/server-rs/crates/module-runtime-story/src/projection.rs b/server-rs/crates/module-runtime-story/src/projection.rs new file mode 100644 index 00000000..ccb5e340 --- /dev/null +++ b/server-rs/crates/module-runtime-story/src/projection.rs @@ -0,0 +1,188 @@ +use serde_json::{Value, to_value}; + +use shared_contracts::{ + runtime_story::RuntimeStoryOptionView, + story::{ + StoryEventPayload, StoryRuntimeActorProjection, StoryRuntimeInventoryProjection, + StoryRuntimeOptionProjection, StoryRuntimeProjectionResponse, StoryRuntimeStatusProjection, + StorySessionPayload, + }, +}; + +use crate::{ + current_encounter_id, read_bool_field, read_i32_field, read_optional_string_field, + view_model::build_runtime_story_inventory, +}; + +pub struct StoryRuntimeProjectionSource { + pub story_session: StorySessionPayload, + pub story_events: Vec, + pub game_state: Value, + pub options: Vec, + pub server_version: u32, + pub current_narrative_text: Option, + pub action_result_text: Option, + pub toast: Option, +} + +/// 将领域快照折成前端可直接消费的新 story runtime 投影。 +pub fn build_story_runtime_projection( + source: StoryRuntimeProjectionSource, +) -> StoryRuntimeProjectionResponse { + let inventory = build_runtime_story_inventory(&source.game_state); + + StoryRuntimeProjectionResponse { + story_session: source.story_session, + story_events: source.story_events, + server_version: source.server_version, + actor: StoryRuntimeActorProjection { + hp: read_i32_field(&source.game_state, "playerHp").unwrap_or(0), + max_hp: read_i32_field(&source.game_state, "playerMaxHp").unwrap_or(1), + mana: read_i32_field(&source.game_state, "playerMana").unwrap_or(0), + max_mana: read_i32_field(&source.game_state, "playerMaxMana").unwrap_or(1), + currency: inventory.player_currency, + currency_text: inventory.currency_text.clone(), + }, + inventory: StoryRuntimeInventoryProjection { + backpack_items: inventory + .backpack_items + .into_iter() + .map(|item| to_value(item).expect("runtime inventory item should serialize")) + .collect(), + equipment_slots: inventory + .equipment_slots + .into_iter() + .map(|slot| to_value(slot).expect("runtime equipment slot should serialize")) + .collect(), + forge_recipes: inventory + .forge_recipes + .into_iter() + .map(|recipe| to_value(recipe).expect("runtime forge recipe should serialize")) + .collect(), + }, + options: source + .options + .into_iter() + .map(build_story_runtime_option_projection) + .collect(), + status: StoryRuntimeStatusProjection { + in_battle: read_bool_field(&source.game_state, "inBattle").unwrap_or(false), + npc_interaction_active: read_bool_field(&source.game_state, "npcInteractionActive") + .unwrap_or(false), + current_encounter_id: current_encounter_id(&source.game_state), + current_npc_battle_mode: read_optional_string_field( + &source.game_state, + "currentNpcBattleMode", + ), + current_npc_battle_outcome: read_optional_string_field( + &source.game_state, + "currentNpcBattleOutcome", + ), + }, + current_narrative_text: source.current_narrative_text, + action_result_text: source.action_result_text, + toast: source.toast, + } +} + +fn build_story_runtime_option_projection( + option: RuntimeStoryOptionView, +) -> StoryRuntimeOptionProjection { + let disabled = option.disabled.unwrap_or(false); + + StoryRuntimeOptionProjection { + function_id: option.function_id, + action_text: option.action_text, + detail_text: option.detail_text, + scope: option.scope, + payload: option.payload, + enabled: !disabled, + reason: option.reason, + } +} + +#[cfg(test)] +mod tests { + use serde_json::json; + + use super::*; + + fn story_session() -> StorySessionPayload { + StorySessionPayload { + story_session_id: "storysess_1".to_string(), + runtime_session_id: "runtime_1".to_string(), + actor_user_id: "user_1".to_string(), + world_profile_id: "profile_1".to_string(), + initial_prompt: "进入营地".to_string(), + opening_summary: Some("营地开场".to_string()), + latest_narrative_text: "篝火仍然亮着。".to_string(), + latest_choice_function_id: Some("npc_chat".to_string()), + status: "active".to_string(), + version: 3, + created_at: "1.000000Z".to_string(), + updated_at: "3.000000Z".to_string(), + } + } + + #[test] + fn projection_builds_frontend_ready_story_runtime_shape() { + let projection = build_story_runtime_projection(StoryRuntimeProjectionSource { + story_session: story_session(), + story_events: vec![StoryEventPayload { + event_id: "storyevt_1".to_string(), + story_session_id: "storysess_1".to_string(), + event_kind: "story_continued".to_string(), + narrative_text: "篝火仍然亮着。".to_string(), + choice_function_id: Some("npc_chat".to_string()), + created_at: "3.000000Z".to_string(), + }], + game_state: json!({ + "worldType": "WUXIA", + "playerCharacter": { "id": "hero-1", "name": "沈砺" }, + "playerHp": 28, + "playerMaxHp": 40, + "playerMana": 12, + "playerMaxMana": 20, + "playerCurrency": 80, + "playerInventory": [{ + "id": "potion-1", + "category": "消耗品", + "name": "疗伤药", + "quantity": 2, + "rarity": "common", + "tags": ["healing"] + }], + "playerEquipment": { "weapon": null, "armor": null, "relic": null }, + "currentEncounter": { "id": "npc_firekeeper", "npcName": "守火人" }, + "inBattle": false, + "npcInteractionActive": true + }), + options: vec![RuntimeStoryOptionView { + function_id: "npc_chat".to_string(), + action_text: "继续交谈".to_string(), + detail_text: Some("围绕当前话题继续推进关系判断。".to_string()), + scope: "npc".to_string(), + interaction: None, + payload: Some(json!({ "npcId": "npc_firekeeper" })), + disabled: None, + reason: None, + }], + server_version: 3, + current_narrative_text: Some("守火人示意你继续说。".to_string()), + action_result_text: None, + toast: Some("关系有所变化。".to_string()), + }); + + assert_eq!(projection.story_session.story_session_id, "storysess_1"); + assert_eq!(projection.actor.hp, 28); + assert_eq!(projection.actor.currency_text, "80 铜钱"); + assert_eq!(projection.inventory.backpack_items.len(), 1); + assert_eq!(projection.options[0].function_id, "npc_chat"); + assert!(projection.options[0].enabled); + assert_eq!( + projection.status.current_encounter_id.as_deref(), + Some("npc_firekeeper") + ); + assert_eq!(projection.toast.as_deref(), Some("关系有所变化。")); + } +} diff --git a/server-rs/crates/module-runtime-story-compat/src/prompt_context.rs b/server-rs/crates/module-runtime-story/src/prompt_context.rs similarity index 100% rename from server-rs/crates/module-runtime-story-compat/src/prompt_context.rs rename to server-rs/crates/module-runtime-story/src/prompt_context.rs diff --git a/server-rs/crates/module-runtime-story-compat/src/story_engine.rs b/server-rs/crates/module-runtime-story/src/story_engine.rs similarity index 100% rename from server-rs/crates/module-runtime-story-compat/src/story_engine.rs rename to server-rs/crates/module-runtime-story/src/story_engine.rs diff --git a/server-rs/crates/module-runtime-story-compat/src/view_model.rs b/server-rs/crates/module-runtime-story/src/view_model.rs similarity index 100% rename from server-rs/crates/module-runtime-story-compat/src/view_model.rs rename to server-rs/crates/module-runtime-story/src/view_model.rs diff --git a/server-rs/crates/shared-contracts/src/story.rs b/server-rs/crates/shared-contracts/src/story.rs index 34344c22..128f8cf7 100644 --- a/server-rs/crates/shared-contracts/src/story.rs +++ b/server-rs/crates/shared-contracts/src/story.rs @@ -1,4 +1,5 @@ use serde::{Deserialize, Serialize}; +use serde_json::Value; #[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "camelCase")] @@ -64,6 +65,79 @@ pub struct StorySessionStateResponse { pub story_events: Vec, } +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct StoryRuntimeProjectionRequest { + pub story_session_id: String, + #[serde(default)] + pub client_version: Option, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct StoryRuntimeActorProjection { + pub hp: i32, + pub max_hp: i32, + pub mana: i32, + pub max_mana: i32, + pub currency: i32, + pub currency_text: String, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct StoryRuntimeInventoryProjection { + pub backpack_items: Vec, + pub equipment_slots: Vec, + pub forge_recipes: Vec, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct StoryRuntimeOptionProjection { + pub function_id: String, + pub action_text: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub detail_text: Option, + pub scope: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub payload: Option, + pub enabled: bool, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub reason: Option, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct StoryRuntimeStatusProjection { + pub in_battle: bool, + pub npc_interaction_active: bool, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub current_encounter_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub current_npc_battle_mode: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub current_npc_battle_outcome: Option, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct StoryRuntimeProjectionResponse { + pub story_session: StorySessionPayload, + pub story_events: Vec, + pub server_version: u32, + pub actor: StoryRuntimeActorProjection, + pub inventory: StoryRuntimeInventoryProjection, + pub options: Vec, + pub status: StoryRuntimeStatusProjection, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub current_narrative_text: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub action_result_text: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub toast: Option, +} + #[cfg(test)] mod tests { use super::*; @@ -161,4 +235,81 @@ mod tests { json!("story_continued") ); } + + #[test] + fn story_runtime_projection_response_uses_new_story_runtime_contract() { + let payload = serde_json::to_value(StoryRuntimeProjectionResponse { + story_session: StorySessionPayload { + story_session_id: "storysess_1".to_string(), + runtime_session_id: "runtime_1".to_string(), + actor_user_id: "user_1".to_string(), + world_profile_id: "profile_1".to_string(), + initial_prompt: "进入营地".to_string(), + opening_summary: Some("营地开场".to_string()), + latest_narrative_text: "你看见篝火边有人招手。".to_string(), + latest_choice_function_id: Some("talk_to_npc".to_string()), + status: "active".to_string(), + version: 2, + created_at: "1.000000Z".to_string(), + updated_at: "2.000000Z".to_string(), + }, + story_events: vec![StoryEventPayload { + event_id: "storyevt_2".to_string(), + story_session_id: "storysess_1".to_string(), + event_kind: "story_continued".to_string(), + narrative_text: "你看见篝火边有人招手。".to_string(), + choice_function_id: Some("talk_to_npc".to_string()), + created_at: "2.000000Z".to_string(), + }], + server_version: 2, + actor: StoryRuntimeActorProjection { + hp: 32, + max_hp: 40, + mana: 18, + max_mana: 20, + currency: 80, + currency_text: "80 铜钱".to_string(), + }, + inventory: StoryRuntimeInventoryProjection { + backpack_items: vec![json!({ "id": "potion-1", "name": "疗伤药" })], + equipment_slots: vec![json!({ "slotId": "weapon", "label": "武器" })], + forge_recipes: Vec::new(), + }, + options: vec![StoryRuntimeOptionProjection { + function_id: "npc_chat".to_string(), + action_text: "继续交谈".to_string(), + detail_text: Some("围绕当前话题继续推进关系判断。".to_string()), + scope: "npc".to_string(), + payload: Some(json!({ "npcId": "npc_camp_firekeeper" })), + enabled: true, + reason: None, + }], + status: StoryRuntimeStatusProjection { + in_battle: false, + npc_interaction_active: true, + current_encounter_id: Some("npc_camp_firekeeper".to_string()), + current_npc_battle_mode: None, + current_npc_battle_outcome: None, + }, + current_narrative_text: Some("守火人示意你继续说。".to_string()), + action_result_text: None, + toast: None, + }) + .expect("payload should serialize"); + + assert_eq!( + payload["storySession"]["storySessionId"], + json!("storysess_1") + ); + assert_eq!(payload["serverVersion"], json!(2)); + assert_eq!(payload["actor"]["maxHp"], json!(40)); + assert_eq!( + payload["inventory"]["backpackItems"][0]["name"], + json!("疗伤药") + ); + assert_eq!(payload["options"][0]["functionId"], json!("npc_chat")); + assert!(payload.get("snapshot").is_none()); + assert!(payload.get("viewModel").is_none()); + assert!(payload.get("presentation").is_none()); + } } diff --git a/server-rs/crates/spacetime-client/Cargo.toml b/server-rs/crates/spacetime-client/Cargo.toml index f3233690..4b173265 100644 --- a/server-rs/crates/spacetime-client/Cargo.toml +++ b/server-rs/crates/spacetime-client/Cargo.toml @@ -14,10 +14,12 @@ module-inventory = { path = "../module-inventory" } module-npc = { path = "../module-npc" } module-puzzle = { path = "../module-puzzle" } module-runtime = { path = "../module-runtime" } +module-runtime-story = { path = "../module-runtime-story" } module-runtime-item = { path = "../module-runtime-item" } module-story = { path = "../module-story" } serde = { version = "1", features = ["derive"] } serde_json = "1" +shared-contracts = { path = "../shared-contracts" } shared-kernel = { path = "../shared-kernel" } spacetimedb-sdk = "2.1.0" tokio = { version = "1", features = ["rt", "sync", "time"] } diff --git a/server-rs/crates/spacetime-client/README.md b/server-rs/crates/spacetime-client/README.md index 406999c0..27e6f966 100644 --- a/server-rs/crates/spacetime-client/README.md +++ b/server-rs/crates/spacetime-client/README.md @@ -1,4 +1,4 @@ -# spacetime-client 共享 package 占位说明 +# spacetime-client 共享 package 说明 日期:`2026-04-20` @@ -10,6 +10,15 @@ 2. Axum 与各模块对 reducer、view、订阅的调用适配 3. 身份透传、连接配置与基础错误处理适配 +在 DDD 重构中,本 package 只承接 `WP-SC Spacetime Client`: + +1. 把 SpacetimeDB 生成绑定转换成 `api-server` 可消费的 typed facade。 +2. 把 row snapshot / procedure result 转换成 BFF record。 +3. 统一 SDK 调用错误、业务 procedure 错误、缺失快照错误和超时错误。 +4. 不承载领域规则,不直接定义 table / reducer / procedure,不替代 `spacetime-module`。 + +本轮方案见 [`SERVER_RS_DDD_WP_SC_SPACETIME_CLIENT_REFACTOR_2026-04-29.md`](../../../docs/technical/SERVER_RS_DDD_WP_SC_SPACETIME_CLIENT_REFACTOR_2026-04-29.md)。 + ## 2. 当前阶段说明 当前目录已不再只是占位,当前阶段已经落下: @@ -76,3 +85,5 @@ cargo check -p spacetime-client --manifest-path server-rs\Cargo.toml 1. `spacetime-client` 只承接 SpacetimeDB 客户端访问适配,不承接具体业务模块的规则实现。 2. 业务状态真相仍由 `apps/spacetime-module` 管理,业务编排由各模块 package 与 `apps/api-server` 承担。 3. 不允许把 reducer、view、订阅调用细节重新散落到多个业务模块里各自实现。 +4. 新增 facade 必须等待对应 `spacetime-module` facade 稳定后再接,不提前假设 row shape。 +5. `src/module_bindings/**` 是生成产物,只能通过 SpacetimeDB CLI 生成流程刷新。 diff --git a/server-rs/crates/spacetime-client/src/ai.rs b/server-rs/crates/spacetime-client/src/ai.rs index 4c931a4a..0601258a 100644 --- a/server-rs/crates/spacetime-client/src/ai.rs +++ b/server-rs/crates/spacetime-client/src/ai.rs @@ -13,7 +13,7 @@ impl SpacetimeClient { procedure_input, move |_, result| { let mapped = result - .map_err(|error| SpacetimeClientError::Procedure(error.to_string())) + .map_err(SpacetimeClientError::from_sdk_error) .and_then(map_ai_task_procedure_result); send_once(&sender, mapped); }, @@ -35,15 +35,12 @@ impl SpacetimeClient { .reducers .start_ai_task_then(reducer_input, move |_, result| { let mapped = result - .map_err(|error| SpacetimeClientError::Procedure(error.to_string())) + .map_err(SpacetimeClientError::from_sdk_error) .and_then(|inner| inner.map_err(SpacetimeClientError::Runtime)); send_reducer_once(&callback_sender, mapped); }) { - send_reducer_once( - &sender, - Err(SpacetimeClientError::Procedure(error.to_string())), - ); + send_reducer_once(&sender, Err(SpacetimeClientError::from_sdk_error(error))); } }) .await @@ -62,15 +59,12 @@ impl SpacetimeClient { .reducers .start_ai_task_stage_then(reducer_input, move |_, result| { let mapped = result - .map_err(|error| SpacetimeClientError::Procedure(error.to_string())) + .map_err(SpacetimeClientError::from_sdk_error) .and_then(|inner| inner.map_err(SpacetimeClientError::Runtime)); send_reducer_once(&callback_sender, mapped); }) { - send_reducer_once( - &sender, - Err(SpacetimeClientError::Procedure(error.to_string())), - ); + send_reducer_once(&sender, Err(SpacetimeClientError::from_sdk_error(error))); } }) .await @@ -87,7 +81,7 @@ impl SpacetimeClient { .procedures() .append_ai_text_chunk_and_return_then(procedure_input, move |_, result| { let mapped = result - .map_err(|error| SpacetimeClientError::Procedure(error.to_string())) + .map_err(SpacetimeClientError::from_sdk_error) .and_then(map_ai_task_procedure_result); send_once(&sender, mapped); }); @@ -106,7 +100,7 @@ impl SpacetimeClient { procedure_input, move |_, result| { let mapped = result - .map_err(|error| SpacetimeClientError::Procedure(error.to_string())) + .map_err(SpacetimeClientError::from_sdk_error) .and_then(map_ai_task_procedure_result); send_once(&sender, mapped); }, @@ -126,7 +120,7 @@ impl SpacetimeClient { .procedures() .attach_ai_result_reference_and_return_then(procedure_input, move |_, result| { let mapped = result - .map_err(|error| SpacetimeClientError::Procedure(error.to_string())) + .map_err(SpacetimeClientError::from_sdk_error) .and_then(map_ai_task_procedure_result); send_once(&sender, mapped); }); @@ -145,7 +139,7 @@ impl SpacetimeClient { procedure_input, move |_, result| { let mapped = result - .map_err(|error| SpacetimeClientError::Procedure(error.to_string())) + .map_err(SpacetimeClientError::from_sdk_error) .and_then(map_ai_task_procedure_result); send_once(&sender, mapped); }, @@ -165,7 +159,7 @@ impl SpacetimeClient { procedure_input, move |_, result| { let mapped = result - .map_err(|error| SpacetimeClientError::Procedure(error.to_string())) + .map_err(SpacetimeClientError::from_sdk_error) .and_then(map_ai_task_procedure_result); send_once(&sender, mapped); }, @@ -185,7 +179,7 @@ impl SpacetimeClient { procedure_input, move |_, result| { let mapped = result - .map_err(|error| SpacetimeClientError::Procedure(error.to_string())) + .map_err(SpacetimeClientError::from_sdk_error) .and_then(map_ai_task_procedure_result); send_once(&sender, mapped); }, diff --git a/server-rs/crates/spacetime-client/src/assets.rs b/server-rs/crates/spacetime-client/src/assets.rs index 1745d936..ef0a910e 100644 --- a/server-rs/crates/spacetime-client/src/assets.rs +++ b/server-rs/crates/spacetime-client/src/assets.rs @@ -12,7 +12,7 @@ impl SpacetimeClient { procedure_input, move |_, result| { let mapped = result - .map_err(|error| SpacetimeClientError::Procedure(error.to_string())) + .map_err(SpacetimeClientError::from_sdk_error) .and_then(map_asset_history_list_result); send_once(&sender, mapped); }, @@ -32,7 +32,7 @@ impl SpacetimeClient { .procedures() .confirm_asset_object_and_return_then(procedure_input, move |_, result| { let mapped = result - .map_err(|error| SpacetimeClientError::Procedure(error.to_string())) + .map_err(SpacetimeClientError::from_sdk_error) .and_then(map_procedure_result); send_once(&sender, mapped); }); @@ -51,7 +51,7 @@ impl SpacetimeClient { .procedures() .bind_asset_object_to_entity_and_return_then(procedure_input, move |_, result| { let mapped = result - .map_err(|error| SpacetimeClientError::Procedure(error.to_string())) + .map_err(SpacetimeClientError::from_sdk_error) .and_then(map_entity_binding_procedure_result); send_once(&sender, mapped); }); diff --git a/server-rs/crates/spacetime-client/src/auth.rs b/server-rs/crates/spacetime-client/src/auth.rs index 67f068eb..5b380948 100644 --- a/server-rs/crates/spacetime-client/src/auth.rs +++ b/server-rs/crates/spacetime-client/src/auth.rs @@ -9,7 +9,7 @@ impl SpacetimeClient { .procedures() .export_auth_store_snapshot_from_tables_then(move |_, result| { let mapped = result - .map_err(|error| SpacetimeClientError::Procedure(error.to_string())) + .map_err(SpacetimeClientError::from_sdk_error) .and_then(map_auth_store_snapshot_procedure_result); send_once(&sender, mapped); }); @@ -25,7 +25,7 @@ impl SpacetimeClient { .procedures() .get_auth_store_snapshot_then(move |_, result| { let mapped = result - .map_err(|error| SpacetimeClientError::Procedure(error.to_string())) + .map_err(SpacetimeClientError::from_sdk_error) .and_then(map_auth_store_snapshot_procedure_result); send_once(&sender, mapped); }); @@ -48,7 +48,7 @@ impl SpacetimeClient { procedure_input, move |_, result| { let mapped = result - .map_err(|error| SpacetimeClientError::Procedure(error.to_string())) + .map_err(SpacetimeClientError::from_sdk_error) .and_then(map_auth_store_snapshot_procedure_result); send_once(&sender, mapped); }, @@ -65,7 +65,7 @@ impl SpacetimeClient { .procedures() .import_auth_store_snapshot_then(move |_, result| { let mapped = result - .map_err(|error| SpacetimeClientError::Procedure(error.to_string())) + .map_err(SpacetimeClientError::from_sdk_error) .and_then(map_auth_store_snapshot_import_procedure_result); send_once(&sender, mapped); }); diff --git a/server-rs/crates/spacetime-client/src/big_fish.rs b/server-rs/crates/spacetime-client/src/big_fish.rs index 626a7d92..5d4106d4 100644 --- a/server-rs/crates/spacetime-client/src/big_fish.rs +++ b/server-rs/crates/spacetime-client/src/big_fish.rs @@ -22,7 +22,7 @@ impl SpacetimeClient { procedure_input, move |_, result| { let mapped = result - .map_err(|error| SpacetimeClientError::Procedure(error.to_string())) + .map_err(SpacetimeClientError::from_sdk_error) .and_then(map_big_fish_session_procedure_result); send_once(&sender, mapped); }, @@ -46,7 +46,7 @@ impl SpacetimeClient { .procedures() .get_big_fish_session_then(procedure_input, move |_, result| { let mapped = result - .map_err(|error| SpacetimeClientError::Procedure(error.to_string())) + .map_err(SpacetimeClientError::from_sdk_error) .and_then(map_big_fish_session_procedure_result); send_once(&sender, mapped); }); @@ -90,7 +90,7 @@ impl SpacetimeClient { .procedures() .list_big_fish_works_then(procedure_input, move |_, result| { let mapped = result - .map_err(|error| SpacetimeClientError::Procedure(error.to_string())) + .map_err(SpacetimeClientError::from_sdk_error) .and_then(|result| { map_big_fish_works_procedure_result( result, @@ -119,7 +119,7 @@ impl SpacetimeClient { .procedures() .delete_big_fish_work_then(procedure_input, move |_, result| { let mapped = result - .map_err(|error| SpacetimeClientError::Procedure(error.to_string())) + .map_err(SpacetimeClientError::from_sdk_error) .and_then(|result| { map_big_fish_works_procedure_result( result, @@ -150,7 +150,7 @@ impl SpacetimeClient { procedure_input, move |_, result| { let mapped = result - .map_err(|error| SpacetimeClientError::Procedure(error.to_string())) + .map_err(SpacetimeClientError::from_sdk_error) .and_then(map_big_fish_session_procedure_result); send_once(&sender, mapped); }, @@ -180,7 +180,7 @@ impl SpacetimeClient { .procedures() .finalize_big_fish_agent_message_turn_then(procedure_input, move |_, result| { let mapped = result - .map_err(|error| SpacetimeClientError::Procedure(error.to_string())) + .map_err(SpacetimeClientError::from_sdk_error) .and_then(map_big_fish_session_procedure_result); send_once(&sender, mapped); }); @@ -204,7 +204,7 @@ impl SpacetimeClient { procedure_input, move |_, result| { let mapped = result - .map_err(|error| SpacetimeClientError::Procedure(error.to_string())) + .map_err(SpacetimeClientError::from_sdk_error) .and_then(map_big_fish_session_procedure_result); send_once(&sender, mapped); }, @@ -232,7 +232,7 @@ impl SpacetimeClient { procedure_input, move |_, result| { let mapped = result - .map_err(|error| SpacetimeClientError::Procedure(error.to_string())) + .map_err(SpacetimeClientError::from_sdk_error) .and_then(map_big_fish_session_procedure_result); send_once(&sender, mapped); }, @@ -258,7 +258,7 @@ impl SpacetimeClient { procedure_input, move |_, result| { let mapped = result - .map_err(|error| SpacetimeClientError::Procedure(error.to_string())) + .map_err(SpacetimeClientError::from_sdk_error) .and_then(map_big_fish_session_procedure_result); send_once(&sender, mapped); }, @@ -283,7 +283,7 @@ impl SpacetimeClient { .procedures() .record_big_fish_play_then(procedure_input, move |_, result| { let mapped = result - .map_err(|error| SpacetimeClientError::Procedure(error.to_string())) + .map_err(SpacetimeClientError::from_sdk_error) .and_then(|result| map_big_fish_works_procedure_result(result, None)); send_once(&sender, mapped); }); diff --git a/server-rs/crates/spacetime-client/src/combat.rs b/server-rs/crates/spacetime-client/src/combat.rs index c683de08..689379d6 100644 --- a/server-rs/crates/spacetime-client/src/combat.rs +++ b/server-rs/crates/spacetime-client/src/combat.rs @@ -15,7 +15,7 @@ impl SpacetimeClient { procedure_input, move |_, result| { let mapped = result - .map_err(|error| SpacetimeClientError::Procedure(error.to_string())) + .map_err(SpacetimeClientError::from_sdk_error) .and_then(map_battle_state_procedure_result); send_once(&sender, mapped); }, @@ -37,7 +37,7 @@ impl SpacetimeClient { .procedures() .get_battle_state_then(procedure_input, move |_, result| { let mapped = result - .map_err(|error| SpacetimeClientError::Procedure(error.to_string())) + .map_err(SpacetimeClientError::from_sdk_error) .and_then(map_battle_state_procedure_result); send_once(&sender, mapped); }); @@ -58,7 +58,7 @@ impl SpacetimeClient { .procedures() .resolve_combat_action_and_return_then(procedure_input, move |_, result| { let mapped = result - .map_err(|error| SpacetimeClientError::Procedure(error.to_string())) + .map_err(SpacetimeClientError::from_sdk_error) .and_then(map_resolve_combat_action_procedure_result); send_once(&sender, mapped); }); diff --git a/server-rs/crates/spacetime-client/src/inventory.rs b/server-rs/crates/spacetime-client/src/inventory.rs index c3e66ef2..512da854 100644 --- a/server-rs/crates/spacetime-client/src/inventory.rs +++ b/server-rs/crates/spacetime-client/src/inventory.rs @@ -16,7 +16,7 @@ impl SpacetimeClient { procedure_input, move |_, result| { let mapped = result - .map_err(|error| SpacetimeClientError::Procedure(error.to_string())) + .map_err(SpacetimeClientError::from_sdk_error) .and_then(map_runtime_inventory_state_procedure_result); send_once(&sender, mapped); }, diff --git a/server-rs/crates/spacetime-client/src/lib.rs b/server-rs/crates/spacetime-client/src/lib.rs index b164406e..b7d56ff7 100644 --- a/server-rs/crates/spacetime-client/src/lib.rs +++ b/server-rs/crates/spacetime-client/src/lib.rs @@ -50,6 +50,7 @@ pub mod npc; pub mod puzzle; pub mod runtime; pub mod story; +pub mod story_runtime; use std::{ error::Error, @@ -424,6 +425,20 @@ impl SpacetimeClient { } } +impl SpacetimeClientError { + pub(crate) fn from_sdk_error(error: impl fmt::Display) -> Self { + Self::Procedure(error.to_string()) + } + + pub(crate) fn procedure_failed(message: Option) -> Self { + Self::Procedure(message.unwrap_or_else(|| "SpacetimeDB procedure 返回未知错误".to_string())) + } + + pub(crate) fn missing_snapshot(label: &'static str) -> Self { + Self::Procedure(format!("SpacetimeDB procedure 未返回{label}")) + } +} + impl PooledConnection { fn is_broken(&self) -> bool { self.broken.load(Ordering::SeqCst) diff --git a/server-rs/crates/spacetime-client/src/mapper.rs b/server-rs/crates/spacetime-client/src/mapper.rs index 96e09a5f..5d52ff7b 100644 --- a/server-rs/crates/spacetime-client/src/mapper.rs +++ b/server-rs/crates/spacetime-client/src/mapper.rs @@ -530,16 +530,12 @@ pub(crate) fn map_procedure_result( result: AssetObjectProcedureResult, ) -> Result { if !result.ok { - return Err(SpacetimeClientError::Procedure( - result - .error_message - .unwrap_or_else(|| "SpacetimeDB procedure 返回未知错误".to_string()), - )); + return Err(SpacetimeClientError::procedure_failed(result.error_message)); } - let snapshot = result.record.ok_or_else(|| { - SpacetimeClientError::Procedure("SpacetimeDB procedure 未返回对象快照".to_string()) - })?; + let snapshot = result + .record + .ok_or_else(|| SpacetimeClientError::missing_snapshot("对象快照"))?; Ok(build_asset_object_record(map_snapshot(snapshot))) } @@ -548,16 +544,12 @@ pub(crate) fn map_entity_binding_procedure_result( result: AssetEntityBindingProcedureResult, ) -> Result { if !result.ok { - return Err(SpacetimeClientError::Procedure( - result - .error_message - .unwrap_or_else(|| "SpacetimeDB procedure 返回未知错误".to_string()), - )); + return Err(SpacetimeClientError::procedure_failed(result.error_message)); } - let snapshot = result.record.ok_or_else(|| { - SpacetimeClientError::Procedure("SpacetimeDB procedure 未返回绑定快照".to_string()) - })?; + let snapshot = result + .record + .ok_or_else(|| SpacetimeClientError::missing_snapshot("绑定快照"))?; Ok(build_asset_entity_binding_record( map_entity_binding_snapshot(snapshot), @@ -568,11 +560,7 @@ pub(crate) fn map_asset_history_list_result( result: AssetHistoryListResult, ) -> Result, SpacetimeClientError> { if !result.ok { - return Err(SpacetimeClientError::Procedure( - result - .error_message - .unwrap_or_else(|| "SpacetimeDB procedure 返回未知错误".to_string()), - )); + return Err(SpacetimeClientError::procedure_failed(result.error_message)); } Ok(result @@ -609,16 +597,12 @@ pub(crate) fn map_auth_store_snapshot_procedure_result( result: AuthStoreSnapshotProcedureResult, ) -> Result { if !result.ok { - return Err(SpacetimeClientError::Procedure( - result - .error_message - .unwrap_or_else(|| "SpacetimeDB procedure 返回未知错误".to_string()), - )); + return Err(SpacetimeClientError::procedure_failed(result.error_message)); } - let record = result.record.ok_or_else(|| { - SpacetimeClientError::Procedure("SpacetimeDB procedure 未返回认证快照".to_string()) - })?; + let record = result + .record + .ok_or_else(|| SpacetimeClientError::missing_snapshot("认证快照"))?; Ok(map_auth_store_snapshot_record(record)) } @@ -1003,16 +987,12 @@ pub(crate) fn map_ai_task_procedure_result( result: AiTaskProcedureResult, ) -> Result { if !result.ok { - return Err(SpacetimeClientError::Runtime( - result - .error_message - .unwrap_or_else(|| "SpacetimeDB procedure 返回未知错误".to_string()), - )); + return Err(SpacetimeClientError::procedure_failed(result.error_message)); } - let task = result.task.ok_or_else(|| { - SpacetimeClientError::Procedure("SpacetimeDB procedure 未返回 ai_task 快照".to_string()) - })?; + let task = result + .task + .ok_or_else(|| SpacetimeClientError::missing_snapshot("ai_task 快照"))?; Ok(AiTaskMutationRecord { task: map_ai_task_snapshot(task), @@ -1344,18 +1324,12 @@ pub(crate) fn map_big_fish_session_procedure_result( result: BigFishSessionProcedureResult, ) -> Result { if !result.ok { - return Err(SpacetimeClientError::Procedure( - result - .error_message - .unwrap_or_else(|| "SpacetimeDB procedure 返回未知错误".to_string()), - )); + return Err(SpacetimeClientError::procedure_failed(result.error_message)); } - let session = result.session.ok_or_else(|| { - SpacetimeClientError::Procedure( - "SpacetimeDB procedure 未返回 big fish session 快照".to_string(), - ) - })?; + let session = result + .session + .ok_or_else(|| SpacetimeClientError::missing_snapshot("big fish session 快照"))?; Ok(map_big_fish_session_snapshot(session)) } @@ -1365,18 +1339,12 @@ pub(crate) fn map_big_fish_works_procedure_result( fallback_owner_user_id: Option<&str>, ) -> Result, SpacetimeClientError> { if !result.ok { - return Err(SpacetimeClientError::Procedure( - result - .error_message - .unwrap_or_else(|| "SpacetimeDB procedure 返回未知错误".to_string()), - )); + return Err(SpacetimeClientError::procedure_failed(result.error_message)); } - let items_json = result.items_json.ok_or_else(|| { - SpacetimeClientError::Procedure( - "SpacetimeDB procedure 未返回 big fish works 快照".to_string(), - ) - })?; + let items_json = result + .items_json + .ok_or_else(|| SpacetimeClientError::missing_snapshot("big fish works 快照"))?; let items = serde_json::from_str::>(&items_json) .map_err(|error| { SpacetimeClientError::Runtime(format!("big fish works items_json 非法: {error}")) @@ -1392,21 +1360,15 @@ pub(crate) fn map_story_session_procedure_result( result: StorySessionProcedureResult, ) -> Result { if !result.ok { - return Err(SpacetimeClientError::Procedure( - result - .error_message - .unwrap_or_else(|| "SpacetimeDB procedure 返回未知错误".to_string()), - )); + return Err(SpacetimeClientError::procedure_failed(result.error_message)); } - let session = result.session.ok_or_else(|| { - SpacetimeClientError::Procedure( - "SpacetimeDB procedure 未返回 story session 快照".to_string(), - ) - })?; - let event = result.event.ok_or_else(|| { - SpacetimeClientError::Procedure("SpacetimeDB procedure 未返回 story event 快照".to_string()) - })?; + let session = result + .session + .ok_or_else(|| SpacetimeClientError::missing_snapshot("story session 快照"))?; + let event = result + .event + .ok_or_else(|| SpacetimeClientError::missing_snapshot("story event 快照"))?; Ok(StorySessionResultRecord { session: map_story_session_snapshot(session), @@ -1418,18 +1380,12 @@ pub(crate) fn map_story_session_state_procedure_result( result: StorySessionStateProcedureResult, ) -> Result { if !result.ok { - return Err(SpacetimeClientError::Procedure( - result - .error_message - .unwrap_or_else(|| "SpacetimeDB procedure 返回未知错误".to_string()), - )); + return Err(SpacetimeClientError::procedure_failed(result.error_message)); } - let session = result.session.ok_or_else(|| { - SpacetimeClientError::Procedure( - "SpacetimeDB procedure 未返回 story session state 快照".to_string(), - ) - })?; + let session = result + .session + .ok_or_else(|| SpacetimeClientError::missing_snapshot("story session state 快照"))?; Ok(StorySessionStateRecord { session: map_story_session_snapshot(session), @@ -1445,18 +1401,12 @@ pub(crate) fn map_runtime_inventory_state_procedure_result( result: RuntimeInventoryStateProcedureResult, ) -> Result { if !result.ok { - return Err(SpacetimeClientError::Procedure( - result - .error_message - .unwrap_or_else(|| "SpacetimeDB procedure 返回未知错误".to_string()), - )); + return Err(SpacetimeClientError::procedure_failed(result.error_message)); } - let snapshot = result.snapshot.ok_or_else(|| { - SpacetimeClientError::Procedure( - "SpacetimeDB procedure 未返回 runtime inventory state 快照".to_string(), - ) - })?; + let snapshot = result + .snapshot + .ok_or_else(|| SpacetimeClientError::missing_snapshot("runtime inventory state 快照"))?; Ok(build_runtime_inventory_state_record( map_runtime_inventory_state_snapshot(snapshot), @@ -1467,18 +1417,12 @@ pub(crate) fn map_battle_state_procedure_result( result: BattleStateProcedureResult, ) -> Result { if !result.ok { - return Err(SpacetimeClientError::Procedure( - result - .error_message - .unwrap_or_else(|| "SpacetimeDB procedure 返回未知错误".to_string()), - )); + return Err(SpacetimeClientError::procedure_failed(result.error_message)); } - let snapshot = result.snapshot.ok_or_else(|| { - SpacetimeClientError::Procedure( - "SpacetimeDB procedure 未返回 battle_state 快照".to_string(), - ) - })?; + let snapshot = result + .snapshot + .ok_or_else(|| SpacetimeClientError::missing_snapshot("battle_state 快照"))?; Ok(build_battle_state_record(map_battle_state_snapshot( snapshot, @@ -1489,16 +1433,12 @@ pub(crate) fn map_resolve_combat_action_procedure_result( result: ResolveCombatActionProcedureResult, ) -> Result { if !result.ok { - return Err(SpacetimeClientError::Procedure( - result - .error_message - .unwrap_or_else(|| "SpacetimeDB procedure 返回未知错误".to_string()), - )); + return Err(SpacetimeClientError::procedure_failed(result.error_message)); } - let action_result = result.result.ok_or_else(|| { - SpacetimeClientError::Procedure("SpacetimeDB procedure 未返回战斗结算结果".to_string()) - })?; + let action_result = result + .result + .ok_or_else(|| SpacetimeClientError::missing_snapshot("战斗结算结果"))?; Ok(build_resolve_combat_action_record( map_resolve_combat_action_result(action_result), @@ -1509,16 +1449,12 @@ pub(crate) fn map_npc_battle_interaction_procedure_result( result: NpcBattleInteractionProcedureResult, ) -> Result { if !result.ok { - return Err(SpacetimeClientError::Procedure( - result - .error_message - .unwrap_or_else(|| "SpacetimeDB procedure 返回未知错误".to_string()), - )); + return Err(SpacetimeClientError::procedure_failed(result.error_message)); } - let interaction_result = result.result.ok_or_else(|| { - SpacetimeClientError::Procedure("SpacetimeDB procedure 未返回 NPC 开战结果".to_string()) - })?; + let interaction_result = result + .result + .ok_or_else(|| SpacetimeClientError::missing_snapshot("NPC 开战结果"))?; Ok(build_npc_battle_interaction_record( map_npc_battle_interaction_result(interaction_result), diff --git a/server-rs/crates/spacetime-client/src/npc.rs b/server-rs/crates/spacetime-client/src/npc.rs index 8abaab4e..77635c7b 100644 --- a/server-rs/crates/spacetime-client/src/npc.rs +++ b/server-rs/crates/spacetime-client/src/npc.rs @@ -16,7 +16,7 @@ impl SpacetimeClient { procedure_input, move |_, result| { let mapped = result - .map_err(|error| SpacetimeClientError::Procedure(error.to_string())) + .map_err(SpacetimeClientError::from_sdk_error) .and_then(map_npc_battle_interaction_procedure_result); send_once(&sender, mapped); }, diff --git a/server-rs/crates/spacetime-client/src/story.rs b/server-rs/crates/spacetime-client/src/story.rs index 4633629d..b724f384 100644 --- a/server-rs/crates/spacetime-client/src/story.rs +++ b/server-rs/crates/spacetime-client/src/story.rs @@ -28,7 +28,7 @@ impl SpacetimeClient { procedure_input, move |_, result| { let mapped = result - .map_err(|error| SpacetimeClientError::Procedure(error.to_string())) + .map_err(SpacetimeClientError::from_sdk_error) .and_then(map_story_session_procedure_result); send_once(&sender, mapped); }, @@ -60,7 +60,7 @@ impl SpacetimeClient { procedure_input, move |_, result| { let mapped = result - .map_err(|error| SpacetimeClientError::Procedure(error.to_string())) + .map_err(SpacetimeClientError::from_sdk_error) .and_then(map_story_session_procedure_result); send_once(&sender, mapped); }, @@ -82,7 +82,7 @@ impl SpacetimeClient { procedure_input, move |_, result| { let mapped = result - .map_err(|error| SpacetimeClientError::Procedure(error.to_string())) + .map_err(SpacetimeClientError::from_sdk_error) .and_then(map_story_session_state_procedure_result); send_once(&sender, mapped); }, diff --git a/server-rs/crates/spacetime-client/src/story_runtime.rs b/server-rs/crates/spacetime-client/src/story_runtime.rs new file mode 100644 index 00000000..d08af363 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/story_runtime.rs @@ -0,0 +1,227 @@ +use module_runtime_story::StoryRuntimeProjectionSource; +use serde_json::Value; +use shared_contracts::{ + runtime_story::RuntimeStoryOptionView, + story::{StoryEventPayload, StorySessionPayload}, +}; + +use super::*; + +impl SpacetimeClient { + pub async fn get_story_runtime_projection_source( + &self, + story_session_id: String, + actor_user_id: String, + ) -> Result { + let story_state = self.get_story_session_state(story_session_id).await?; + if story_state.session.actor_user_id != actor_user_id { + return Err(SpacetimeClientError::Runtime( + "story session 不属于当前用户".to_string(), + )); + } + + let runtime_snapshot = + self.get_runtime_snapshot(actor_user_id) + .await? + .ok_or_else(|| { + SpacetimeClientError::Runtime("当前用户缺少 runtime snapshot".to_string()) + })?; + assert_runtime_snapshot_matches_story_session(&story_state.session, &runtime_snapshot)?; + + let current_story = runtime_snapshot.current_story.as_ref(); + let latest_narrative_text = story_state.session.latest_narrative_text.clone(); + let server_version = runtime_snapshot.version.max(story_state.session.version); + + Ok(StoryRuntimeProjectionSource { + story_session: build_story_session_payload(story_state.session), + story_events: story_state + .events + .into_iter() + .map(build_story_event_payload) + .collect(), + game_state: runtime_snapshot.game_state, + options: read_runtime_story_options(current_story)?, + server_version, + current_narrative_text: read_current_story_text(current_story) + .or(Some(latest_narrative_text)), + action_result_text: read_current_story_string(current_story, "resultText"), + toast: read_current_story_string(current_story, "toast"), + }) + } +} + +fn assert_runtime_snapshot_matches_story_session( + session: &StorySessionRecord, + snapshot: &RuntimeSnapshotRecord, +) -> Result<(), SpacetimeClientError> { + let Some(runtime_session_id) = snapshot + .game_state + .as_object() + .and_then(|state| state.get("runtimeSessionId")) + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + else { + return Err(SpacetimeClientError::Runtime( + "runtime snapshot 缺少 runtimeSessionId".to_string(), + )); + }; + + if runtime_session_id != session.runtime_session_id { + return Err(SpacetimeClientError::Runtime( + "runtime snapshot 与 story session 不匹配".to_string(), + )); + } + + Ok(()) +} + +fn build_story_session_payload(record: StorySessionRecord) -> StorySessionPayload { + StorySessionPayload { + story_session_id: record.story_session_id, + runtime_session_id: record.runtime_session_id, + actor_user_id: record.actor_user_id, + world_profile_id: record.world_profile_id, + initial_prompt: record.initial_prompt, + opening_summary: record.opening_summary, + latest_narrative_text: record.latest_narrative_text, + latest_choice_function_id: record.latest_choice_function_id, + status: record.status, + version: record.version, + created_at: record.created_at, + updated_at: record.updated_at, + } +} + +fn build_story_event_payload(record: StoryEventRecord) -> StoryEventPayload { + StoryEventPayload { + event_id: record.event_id, + story_session_id: record.story_session_id, + event_kind: record.event_kind, + narrative_text: record.narrative_text, + choice_function_id: record.choice_function_id, + created_at: record.created_at, + } +} + +fn read_runtime_story_options( + current_story: Option<&Value>, +) -> Result, SpacetimeClientError> { + let Some(options) = current_story.and_then(|story| story.get("options")) else { + return Ok(Vec::new()); + }; + + serde_json::from_value::>(options.clone()).map_err(|error| { + SpacetimeClientError::Runtime(format!( + "currentStory.options 无法映射为后端选项投影: {error}" + )) + }) +} + +fn read_current_story_text(current_story: Option<&Value>) -> Option { + read_current_story_string(current_story, "text") + .or_else(|| read_current_story_string(current_story, "storyText")) +} + +fn read_current_story_string(current_story: Option<&Value>, field: &str) -> Option { + current_story? + .as_object()? + .get(field)? + .as_str() + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(ToOwned::to_owned) +} + +#[cfg(test)] +mod tests { + use serde_json::json; + + use super::*; + + #[test] + fn runtime_snapshot_session_guard_accepts_matching_runtime_session() { + let session = story_session_record(); + let snapshot = runtime_snapshot_record(json!({ "runtimeSessionId": "runtime_1" }), None); + + assert!(assert_runtime_snapshot_matches_story_session(&session, &snapshot).is_ok()); + } + + #[test] + fn runtime_snapshot_session_guard_rejects_mismatched_runtime_session() { + let session = story_session_record(); + let snapshot = + runtime_snapshot_record(json!({ "runtimeSessionId": "runtime_other" }), None); + + let error = assert_runtime_snapshot_matches_story_session(&session, &snapshot) + .expect_err("mismatched runtime session should fail"); + + assert!(error.to_string().contains("不匹配")); + } + + #[test] + fn current_story_options_parse_runtime_story_options() { + let options = read_runtime_story_options(Some(&json!({ + "text": "守火人抬眼看着你。", + "options": [{ + "functionId": "npc_chat", + "actionText": "继续交谈", + "scope": "npc" + }] + }))) + .expect("options should parse"); + + assert_eq!(options[0].function_id, "npc_chat"); + assert_eq!(options[0].action_text, "继续交谈"); + assert_eq!(options[0].scope, "npc"); + } + + #[test] + fn current_story_text_prefers_text_then_story_text() { + assert_eq!( + read_current_story_text(Some(&json!({ "text": "正文", "storyText": "备用" }))) + .as_deref(), + Some("正文") + ); + assert_eq!( + read_current_story_text(Some(&json!({ "storyText": "备用" }))).as_deref(), + Some("备用") + ); + } + + fn story_session_record() -> StorySessionRecord { + StorySessionRecord { + story_session_id: "storysess_1".to_string(), + runtime_session_id: "runtime_1".to_string(), + actor_user_id: "user_1".to_string(), + world_profile_id: "profile_1".to_string(), + initial_prompt: "进入营地".to_string(), + opening_summary: Some("营地开场".to_string()), + latest_narrative_text: "篝火仍然亮着。".to_string(), + latest_choice_function_id: Some("npc_chat".to_string()), + status: "active".to_string(), + version: 3, + created_at: "1.000000Z".to_string(), + updated_at: "3.000000Z".to_string(), + } + } + + fn runtime_snapshot_record( + game_state: Value, + current_story: Option, + ) -> RuntimeSnapshotRecord { + RuntimeSnapshotRecord { + user_id: "user_1".to_string(), + version: 2, + saved_at: "3.000000Z".to_string(), + saved_at_micros: 3, + bottom_tab: "adventure".to_string(), + game_state, + current_story, + game_state_json: "{}".to_string(), + current_story_json: None, + created_at_micros: 1, + updated_at_micros: 3, + } + } +} diff --git a/server-rs/crates/spacetime-module/src/ai/events.rs b/server-rs/crates/spacetime-module/src/ai/events.rs new file mode 100644 index 00000000..840b92ff --- /dev/null +++ b/server-rs/crates/spacetime-module/src/ai/events.rs @@ -0,0 +1,100 @@ +use crate::*; + +/// AI 任务事件类型。 +/// +/// 事件表用于给订阅端和 BFF 增量消费状态变化;正式任务真相仍以 +/// `ai_task`、`ai_task_stage`、`ai_text_chunk` 和 `ai_result_reference` 为准。 +#[derive(Clone, Copy, Debug, PartialEq, Eq, SpacetimeType)] +pub enum AiTaskEventKind { + TaskCreated, + TaskStatusChanged, + StageStarted, + StageCompleted, + TextChunkAppended, + ResultReferenceAttached, +} + +#[spacetimedb::table( + accessor = ai_task_event, + public, + event, + index(accessor = by_ai_task_event_task_id, btree(columns = [task_id])), + index(accessor = by_ai_task_event_owner_user_id, btree(columns = [owner_user_id])) +)] +pub struct AiTaskEvent { + #[primary_key] + pub(crate) event_id: String, + pub(crate) task_id: String, + pub(crate) owner_user_id: String, + pub(crate) event_kind: AiTaskEventKind, + pub(crate) task_status: Option, + pub(crate) stage_kind: Option, + pub(crate) text_chunk_row_id: Option, + pub(crate) result_reference_row_id: Option, + pub(crate) occurred_at: Timestamp, +} + +pub(crate) fn emit_ai_task_event( + ctx: &ReducerContext, + task: &AiTaskSnapshot, + event_kind: AiTaskEventKind, + stage_kind: Option, + text_chunk_row_id: Option, + result_reference_row_id: Option, + occurred_at_micros: i64, +) { + let suffix = match event_kind { + AiTaskEventKind::TaskCreated => "created".to_string(), + AiTaskEventKind::TaskStatusChanged => format!("status_{}", task.status.as_event_slug()), + AiTaskEventKind::StageStarted => { + format!("stage_started_{}", stage_kind_slug(stage_kind)) + } + AiTaskEventKind::StageCompleted => { + format!("stage_completed_{}", stage_kind_slug(stage_kind)) + } + AiTaskEventKind::TextChunkAppended => { + format!( + "chunk_{}", + text_chunk_row_id.as_deref().unwrap_or("unknown") + ) + } + AiTaskEventKind::ResultReferenceAttached => { + format!( + "result_{}", + result_reference_row_id.as_deref().unwrap_or("unknown") + ) + } + }; + + ctx.db.ai_task_event().insert(AiTaskEvent { + event_id: format!("aievt_{}_{}_{}", task.task_id, occurred_at_micros, suffix), + task_id: task.task_id.clone(), + owner_user_id: task.owner_user_id.clone(), + event_kind, + task_status: Some(task.status), + stage_kind, + text_chunk_row_id, + result_reference_row_id, + occurred_at: Timestamp::from_micros_since_unix_epoch(occurred_at_micros), + }); +} + +fn stage_kind_slug(stage_kind: Option) -> &'static str { + stage_kind.map(AiTaskStageKind::as_str).unwrap_or("unknown") +} + +trait AiTaskStatusEventSlug { + fn as_event_slug(self) -> &'static str; +} + +impl AiTaskStatusEventSlug for AiTaskStatus { + fn as_event_slug(self) -> &'static str { + match self { + Self::Pending => "pending", + Self::Running => "running", + Self::Completed => "completed", + Self::Failed => "failed", + Self::Cancelled => "cancelled", + } + } +} diff --git a/server-rs/crates/spacetime-module/src/ai/mod.rs b/server-rs/crates/spacetime-module/src/ai/mod.rs index 8b2abdcf..82fd0e85 100644 --- a/server-rs/crates/spacetime-module/src/ai/mod.rs +++ b/server-rs/crates/spacetime-module/src/ai/mod.rs @@ -1,7 +1,9 @@ +mod events; mod snapshots; mod stages; mod tasks; +pub(crate) use events::*; pub(crate) use snapshots::*; pub use stages::*; pub use tasks::*; diff --git a/server-rs/crates/spacetime-module/src/ai/snapshots.rs b/server-rs/crates/spacetime-module/src/ai/snapshots.rs index e5381405..f6a9284c 100644 --- a/server-rs/crates/spacetime-module/src/ai/snapshots.rs +++ b/server-rs/crates/spacetime-module/src/ai/snapshots.rs @@ -119,13 +119,7 @@ pub(crate) fn build_ai_task_stage_snapshot_from_row(row: &AiTaskStage) -> AiTask pub(crate) 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 - ), + text_chunk_row_id: build_ai_text_chunk_row_id(snapshot), chunk_id: snapshot.chunk_id.clone(), task_id: snapshot.task_id.clone(), stage_kind: snapshot.stage_kind, @@ -135,6 +129,16 @@ pub(crate) fn build_ai_text_chunk_row(snapshot: &AiTextChunkSnapshot) -> AiTextC } } +pub(crate) fn build_ai_text_chunk_row_id(snapshot: &AiTextChunkSnapshot) -> String { + format!( + "{}{}_{}_{}", + AI_TEXT_CHUNK_ID_PREFIX, + snapshot.task_id, + snapshot.stage_kind.as_str(), + snapshot.sequence + ) +} + pub(crate) fn build_ai_text_chunk_snapshot_from_row(row: &AiTextChunk) -> AiTextChunkSnapshot { AiTextChunkSnapshot { chunk_id: row.chunk_id.clone(), @@ -150,10 +154,7 @@ pub(crate) 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_reference_row_id: build_ai_result_reference_row_id(snapshot), result_ref_id: snapshot.result_ref_id.clone(), task_id: snapshot.task_id.clone(), reference_kind: snapshot.reference_kind, @@ -163,6 +164,13 @@ pub(crate) fn build_ai_result_reference_row( } } +pub(crate) fn build_ai_result_reference_row_id(snapshot: &AiResultReferenceSnapshot) -> String { + format!( + "{}{}_{}", + AI_RESULT_REF_ID_PREFIX, snapshot.task_id, snapshot.result_ref_id + ) +} + pub(crate) fn build_ai_result_reference_snapshot_from_row( row: &AiResultReference, ) -> AiResultReferenceSnapshot { diff --git a/server-rs/crates/spacetime-module/src/ai/stages.rs b/server-rs/crates/spacetime-module/src/ai/stages.rs index cb56ef77..ed908daf 100644 --- a/server-rs/crates/spacetime-module/src/ai/stages.rs +++ b/server-rs/crates/spacetime-module/src/ai/stages.rs @@ -156,6 +156,15 @@ pub(crate) fn start_ai_task_stage_tx( snapshot.version += 1; persist_ai_task_snapshot(ctx, &snapshot)?; + emit_ai_task_event( + ctx, + &snapshot, + AiTaskEventKind::StageStarted, + Some(input.stage_kind), + None, + None, + input.started_at_micros, + ); Ok(snapshot) } @@ -207,6 +216,15 @@ pub(crate) fn append_ai_text_chunk_tx( snapshot.version += 1; persist_ai_task_snapshot(ctx, &snapshot)?; + emit_ai_task_event( + ctx, + &snapshot, + AiTaskEventKind::TextChunkAppended, + Some(chunk.stage_kind), + Some(build_ai_text_chunk_row_id(&chunk)), + None, + chunk.created_at_micros, + ); Ok((snapshot, chunk)) } @@ -235,6 +253,15 @@ pub(crate) fn complete_ai_stage_tx( snapshot.version += 1; persist_ai_task_snapshot(ctx, &snapshot)?; + emit_ai_task_event( + ctx, + &snapshot, + AiTaskEventKind::StageCompleted, + Some(input.stage_kind), + None, + None, + input.completed_at_micros, + ); Ok(snapshot) } @@ -267,6 +294,19 @@ pub(crate) fn attach_ai_result_reference_tx( snapshot.version += 1; persist_ai_task_snapshot(ctx, &snapshot)?; + let reference = snapshot + .result_references + .last() + .ok_or_else(|| "ai_result_reference 写入后缺少快照".to_string())?; + emit_ai_task_event( + ctx, + &snapshot, + AiTaskEventKind::ResultReferenceAttached, + None, + None, + Some(build_ai_result_reference_row_id(reference)), + input.created_at_micros, + ); Ok(snapshot) } diff --git a/server-rs/crates/spacetime-module/src/ai/tasks.rs b/server-rs/crates/spacetime-module/src/ai/tasks.rs index 6d279e14..66c0909d 100644 --- a/server-rs/crates/spacetime-module/src/ai/tasks.rs +++ b/server-rs/crates/spacetime-module/src/ai/tasks.rs @@ -135,6 +135,15 @@ fn create_ai_task_tx( 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); + emit_ai_task_event( + ctx, + &task_snapshot, + AiTaskEventKind::TaskCreated, + None, + None, + None, + task_snapshot.created_at_micros, + ); get_ai_task_snapshot_tx(ctx, &task_snapshot.task_id) } @@ -154,6 +163,15 @@ fn start_ai_task_tx( snapshot.version += 1; persist_ai_task_snapshot(ctx, &snapshot)?; + emit_ai_task_event( + ctx, + &snapshot, + AiTaskEventKind::TaskStatusChanged, + None, + None, + None, + input.started_at_micros, + ); Ok(snapshot) } @@ -170,6 +188,15 @@ fn complete_ai_task_tx( snapshot.version += 1; persist_ai_task_snapshot(ctx, &snapshot)?; + emit_ai_task_event( + ctx, + &snapshot, + AiTaskEventKind::TaskStatusChanged, + None, + None, + None, + input.completed_at_micros, + ); Ok(snapshot) } @@ -192,6 +219,15 @@ fn fail_ai_task_tx( snapshot.version += 1; persist_ai_task_snapshot(ctx, &snapshot)?; + emit_ai_task_event( + ctx, + &snapshot, + AiTaskEventKind::TaskStatusChanged, + None, + None, + None, + input.completed_at_micros, + ); Ok(snapshot) } @@ -208,6 +244,15 @@ fn cancel_ai_task_tx( snapshot.version += 1; persist_ai_task_snapshot(ctx, &snapshot)?; + emit_ai_task_event( + ctx, + &snapshot, + AiTaskEventKind::TaskStatusChanged, + None, + None, + None, + input.completed_at_micros, + ); Ok(snapshot) } diff --git a/server-rs/crates/spacetime-module/src/big_fish/assets.rs b/server-rs/crates/spacetime-module/src/big_fish/assets.rs index ed97fd62..b99e984c 100644 --- a/server-rs/crates/spacetime-module/src/big_fish/assets.rs +++ b/server-rs/crates/spacetime-module/src/big_fish/assets.rs @@ -1,5 +1,6 @@ use crate::big_fish::tables::{big_fish_asset_slot, big_fish_creation_session}; use crate::*; +use module_big_fish::{EvaluateBigFishPublishReadinessCommand, evaluate_publish_readiness}; #[spacetimedb::procedure] pub fn generate_big_fish_asset( @@ -70,6 +71,16 @@ pub(crate) fn generate_big_fish_asset_tx( upsert_big_fish_asset_slot(ctx, slot); let asset_slots = list_big_fish_asset_slots(ctx, &session.session_id); + let readiness = evaluate_publish_readiness( + EvaluateBigFishPublishReadinessCommand { + session_id: session.session_id.clone(), + owner_user_id: session.owner_user_id.clone(), + draft: Some(draft.clone()), + evaluated_at_micros: input.generated_at_micros, + }, + &asset_slots, + ) + .map_err(|error| error.to_string())?; let coverage = build_asset_coverage(Some(&draft), &asset_slots); let updated_at = Timestamp::from_micros_since_unix_epoch(input.generated_at_micros); let uses_placeholder = input @@ -90,7 +101,7 @@ pub(crate) fn generate_big_fish_asset_tx( } } .to_string(); - let next_stage = if coverage.publish_ready { + let next_stage = if readiness.readiness.publish_ready { BigFishCreationStage::ReadyToPublish } else { BigFishCreationStage::AssetRefining @@ -100,19 +111,26 @@ pub(crate) fn generate_big_fish_asset_tx( owner_user_id: session.owner_user_id.clone(), seed_text: session.seed_text.clone(), current_turn: session.current_turn, - progress_percent: if coverage.publish_ready { 96 } else { 88 }, + progress_percent: if readiness.readiness.publish_ready { + 96 + } else { + 88 + }, stage: next_stage, anchor_pack_json: session.anchor_pack_json.clone(), draft_json: session.draft_json.clone(), asset_coverage_json: serialize_asset_coverage(&coverage) .map_err(|error| error.to_string())?, last_assistant_reply: Some(reply.clone()), - publish_ready: coverage.publish_ready, + publish_ready: readiness.readiness.publish_ready, play_count: session.play_count, created_at: session.created_at, updated_at, }; replace_big_fish_session(ctx, &session, next_session); + for event in readiness.events { + emit_big_fish_publish_readiness_event(ctx, event)?; + } get_big_fish_session_tx( ctx, @@ -140,14 +158,22 @@ pub(crate) fn publish_big_fish_game_tx( .as_deref() .ok_or_else(|| "big_fish.draft 尚未编译".to_string()) .and_then(|value| deserialize_draft(value).map_err(|error| error.to_string()))?; - let coverage = build_asset_coverage( - Some(&draft), - &list_big_fish_asset_slots(ctx, &session.session_id), - ); - if !coverage.publish_ready { + let asset_slots = list_big_fish_asset_slots(ctx, &session.session_id); + let readiness = evaluate_publish_readiness( + EvaluateBigFishPublishReadinessCommand { + session_id: session.session_id.clone(), + owner_user_id: session.owner_user_id.clone(), + draft: Some(draft.clone()), + evaluated_at_micros: input.published_at_micros, + }, + &asset_slots, + ) + .map_err(|error| error.to_string())?; + let coverage = build_asset_coverage(Some(&draft), &asset_slots); + if !readiness.readiness.publish_ready { return Err(format!( "big_fish 发布校验未通过:{}", - coverage.blockers.join(";") + readiness.readiness.blockers.join(";") )); } @@ -170,6 +196,9 @@ pub(crate) fn publish_big_fish_game_tx( updated_at: published_at, }; replace_big_fish_session(ctx, &session, next_session); + for event in readiness.events { + emit_big_fish_publish_readiness_event(ctx, event)?; + } get_big_fish_session_tx( ctx, diff --git a/server-rs/crates/spacetime-module/src/big_fish/events.rs b/server-rs/crates/spacetime-module/src/big_fish/events.rs new file mode 100644 index 00000000..1ff52b09 --- /dev/null +++ b/server-rs/crates/spacetime-module/src/big_fish/events.rs @@ -0,0 +1,56 @@ +use crate::*; + +/// Big Fish 创作事件类型。 +/// +/// 事件表只承接跨层订阅和审计所需的轻量事实,正式作品状态仍以 +/// `big_fish_creation_session` 和 `big_fish_asset_slot` 为准。 +#[derive(Clone, Copy, Debug, PartialEq, Eq, SpacetimeType)] +pub enum BigFishEventKind { + PublishReadinessEvaluated, +} + +#[spacetimedb::table( + accessor = big_fish_event, + public, + event, + index(accessor = by_big_fish_event_session_id, btree(columns = [session_id])), + index(accessor = by_big_fish_event_owner_user_id, btree(columns = [owner_user_id])) +)] +pub struct BigFishEvent { + #[primary_key] + pub(crate) event_id: String, + pub(crate) session_id: String, + pub(crate) owner_user_id: String, + pub(crate) event_kind: BigFishEventKind, + pub(crate) publish_ready: bool, + pub(crate) blockers_json: String, + pub(crate) occurred_at: Timestamp, +} + +pub(crate) fn emit_big_fish_publish_readiness_event( + ctx: &ReducerContext, + event: BigFishDomainEvent, +) -> Result<(), String> { + let BigFishDomainEvent::PublishReadinessEvaluated { + session_id, + owner_user_id, + publish_ready, + blockers, + occurred_at_micros, + } = event; + + let blockers_json = serde_json::to_string(&blockers) + .map_err(|error| format!("big_fish.publish_readiness.blockers 序列化失败: {error}"))?; + let state_slug = if publish_ready { "ready" } else { "blocked" }; + ctx.db.big_fish_event().insert(BigFishEvent { + event_id: format!("bfevt_{session_id}_{occurred_at_micros}_{state_slug}"), + session_id, + owner_user_id, + event_kind: BigFishEventKind::PublishReadinessEvaluated, + publish_ready, + blockers_json, + occurred_at: Timestamp::from_micros_since_unix_epoch(occurred_at_micros), + }); + + Ok(()) +} diff --git a/server-rs/crates/spacetime-module/src/big_fish/mod.rs b/server-rs/crates/spacetime-module/src/big_fish/mod.rs index 8478f897..8b7f8ec8 100644 --- a/server-rs/crates/spacetime-module/src/big_fish/mod.rs +++ b/server-rs/crates/spacetime-module/src/big_fish/mod.rs @@ -1,7 +1,9 @@ mod assets; +mod events; mod session; mod tables; pub use assets::*; +pub(crate) use events::*; pub use session::*; pub use tables::*; diff --git a/server-rs/crates/spacetime-module/src/big_fish/session.rs b/server-rs/crates/spacetime-module/src/big_fish/session.rs index 00d5fb20..fbef0200 100644 --- a/server-rs/crates/spacetime-module/src/big_fish/session.rs +++ b/server-rs/crates/spacetime-module/src/big_fish/session.rs @@ -3,6 +3,7 @@ use crate::runtime::{ ProfilePlayedWorkUpsertInput, add_profile_observed_play_time, upsert_profile_played_work, }; use crate::*; +use module_big_fish::{EvaluateBigFishPublishReadinessCommand, evaluate_publish_readiness}; const INITIAL_BIG_FISH_CREATION_PROGRESS_PERCENT: u32 = 0; @@ -552,6 +553,16 @@ pub(crate) fn compile_big_fish_draft_tx( .map_err(|error| format!("big_fish.draft_json 非法: {error}"))? .unwrap_or_else(|| compile_default_draft(&anchor_pack)); let asset_slots = list_big_fish_asset_slots(ctx, &session.session_id); + let readiness = evaluate_publish_readiness( + EvaluateBigFishPublishReadinessCommand { + session_id: session.session_id.clone(), + owner_user_id: session.owner_user_id.clone(), + draft: Some(draft.clone()), + evaluated_at_micros: input.compiled_at_micros, + }, + &asset_slots, + ) + .map_err(|error| error.to_string())?; let coverage = build_asset_coverage(Some(&draft), &asset_slots); let compiled_at = Timestamp::from_micros_since_unix_epoch(input.compiled_at_micros); let reply = "第一版玩法草稿已编译完成,可以在结果页逐级生成主图、动作和场地背景。".to_string(); @@ -568,12 +579,15 @@ pub(crate) fn compile_big_fish_draft_tx( asset_coverage_json: serialize_asset_coverage(&coverage) .map_err(|error| error.to_string())?, last_assistant_reply: Some(reply.clone()), - publish_ready: coverage.publish_ready, + publish_ready: readiness.readiness.publish_ready, play_count: session.play_count, created_at: session.created_at, updated_at: compiled_at, }; replace_big_fish_session(ctx, &session, next_session); + for event in readiness.events { + emit_big_fish_publish_readiness_event(ctx, event)?; + } get_big_fish_session_tx( ctx, diff --git a/server-rs/crates/spacetime-module/src/migration.rs b/server-rs/crates/spacetime-module/src/migration.rs index 0ba811af..f71775bf 100644 --- a/server-rs/crates/spacetime-module/src/migration.rs +++ b/server-rs/crates/spacetime-module/src/migration.rs @@ -104,6 +104,7 @@ macro_rules! migration_tables { ai_task_stage, ai_text_chunk, ai_result_reference, + ai_task_event, runtime_snapshot, runtime_setting, user_browse_history, @@ -142,7 +143,8 @@ macro_rules! migration_tables { puzzle_runtime_run, big_fish_creation_session, big_fish_agent_message, - big_fish_asset_slot + big_fish_asset_slot, + big_fish_event } }; }