master #14
@@ -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` 对齐时优先参考。
|
||||
|
||||
@@ -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 路径
|
||||
|
||||
|
||||
@@ -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<T>`、`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<T>`:`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 和旧静态代理。
|
||||
@@ -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<T>` / `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,避免并行任务各自定义接口。
|
||||
@@ -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/<context>/
|
||||
└─ 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/<domain>/**`、`src/hooks/<domain>/**`、`src/components/<domain>/**`。
|
||||
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 <database> --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,非本次新增。
|
||||
|
||||
@@ -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` 重新启动后端。
|
||||
98
docs/technical/SERVER_RS_DDD_WP_API_BFF_START_2026-04-29.md
Normal file
98
docs/technical/SERVER_RS_DDD_WP_API_BFF_START_2026-04-29.md
Normal file
@@ -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`,也不执行绑定生成。
|
||||
@@ -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`。
|
||||
@@ -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 <database> --json
|
||||
```
|
||||
@@ -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
|
||||
```
|
||||
@@ -27,9 +27,9 @@ spacetime sql <db> "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 = '<slot_id>';
|
||||
SELECT * FROM big_fish_asset_slot WHERE session_id = '<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 = '<session_id>' ORDER BY occurred_at ASC;
|
||||
SELECT * FROM big_fish_event WHERE owner_user_id = '<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 = '<row_id>';
|
||||
SELECT * FROM ai_result_reference WHERE task_id = '<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<AiTaskStatus>`, `stage_kind: Option<AiTaskStageKind>`, `text_chunk_row_id: Option<String>`, `result_reference_row_id: Option<String>`, `occurred_at: Timestamp`。
|
||||
- 索引:`task_id`, `owner_user_id`。
|
||||
|
||||
```sql
|
||||
SELECT * FROM ai_task_event WHERE task_id = '<task_id>' ORDER BY occurred_at ASC;
|
||||
SELECT * FROM ai_task_event WHERE owner_user_id = '<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` 为准;后续完成拆分时,需要删除重复定义或正式挂载子模块,并同步更新本文。
|
||||
|
||||
6
server-rs/Cargo.lock
generated
6
server-rs/Cargo.lock
generated
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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" }
|
||||
|
||||
@@ -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"));
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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<String> {
|
||||
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<GeneratedStoryPayload> {
|
||||
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<GeneratedStoryPayload> {
|
||||
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::<Vec<_>>(),
|
||||
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<GeneratedStoryPayload> {
|
||||
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::<Vec<_>>(),
|
||||
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<Value> {
|
||||
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::<Vec<_>>();
|
||||
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<Value> {
|
||||
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<Value> {
|
||||
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::<Vec<_>>();
|
||||
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<Value> {
|
||||
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} 的局势随之向下一步展开。")
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,106 +0,0 @@
|
||||
use super::*;
|
||||
|
||||
/// 对齐 Node 旧 inventory compat,先按装备位把物品从背包切到 playerEquipment,
|
||||
/// 再把基础面板属性回算到快照上。
|
||||
pub(super) fn resolve_equipment_equip_action(
|
||||
game_state: &mut Value,
|
||||
request: &RuntimeStoryActionRequest,
|
||||
) -> Result<StoryResolution, String> {
|
||||
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<StoryResolution, String> {
|
||||
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)),
|
||||
})
|
||||
}
|
||||
@@ -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::<Vec<_>>()
|
||||
})
|
||||
.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<String> {
|
||||
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::<Vec<_>>();
|
||||
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<String, Value>>,
|
||||
) -> 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::<String>();
|
||||
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<Value>,
|
||||
trade_stock_signature: &str,
|
||||
) -> Vec<Value> {
|
||||
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::<Vec<_>>();
|
||||
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<Value> {
|
||||
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<i32> {
|
||||
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<bool> {
|
||||
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<String, Value> {
|
||||
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<Value>> {
|
||||
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<Value>) {
|
||||
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));
|
||||
}
|
||||
}
|
||||
@@ -1,523 +0,0 @@
|
||||
use super::*;
|
||||
|
||||
pub(super) fn resolve_npc_preview_action(
|
||||
game_state: &mut Value,
|
||||
request: &RuntimeStoryActionRequest,
|
||||
) -> Result<StoryResolution, String> {
|
||||
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<StoryResolution, String> {
|
||||
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<StoryResolution, String> {
|
||||
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<StoryResolution, String> {
|
||||
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<StoryResolution, String> {
|
||||
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<Value> {
|
||||
let visible_formation = read_array_field(game_state, "sceneHostileNpcs")
|
||||
.into_iter()
|
||||
.cloned()
|
||||
.collect::<Vec<_>>();
|
||||
if !visible_formation.is_empty() {
|
||||
return visible_formation
|
||||
.into_iter()
|
||||
.map(|monster| normalize_npc_battle_monster(monster, battle_mode))
|
||||
.collect();
|
||||
}
|
||||
|
||||
encounter
|
||||
.map(|encounter| {
|
||||
vec![build_npc_battle_monster_from_encounter(
|
||||
game_state,
|
||||
encounter,
|
||||
battle_mode,
|
||||
3.2,
|
||||
0,
|
||||
)]
|
||||
})
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
fn normalize_npc_battle_monster(mut monster: Value, battle_mode: &str) -> Value {
|
||||
let Some(monster_object) = monster.as_object_mut() else {
|
||||
return monster;
|
||||
};
|
||||
monster_object
|
||||
.entry("animation".to_string())
|
||||
.or_insert_with(|| Value::String("idle".to_string()));
|
||||
monster_object
|
||||
.entry("facing".to_string())
|
||||
.or_insert_with(|| Value::String("left".to_string()));
|
||||
monster_object
|
||||
.entry("renderKind".to_string())
|
||||
.or_insert_with(|| Value::String("npc".to_string()));
|
||||
monster_object
|
||||
.entry("attackRange".to_string())
|
||||
.or_insert_with(|| json!(1.8));
|
||||
monster_object
|
||||
.entry("speed".to_string())
|
||||
.or_insert_with(|| json!(7));
|
||||
let max_hp = monster_object
|
||||
.get("maxHp")
|
||||
.and_then(Value::as_i64)
|
||||
.unwrap_or_else(|| if battle_mode == "spar" { 10 } else { 80 });
|
||||
monster_object
|
||||
.entry("hp".to_string())
|
||||
.or_insert_with(|| json!(max_hp));
|
||||
monster
|
||||
}
|
||||
|
||||
fn build_npc_battle_monster_from_encounter(
|
||||
game_state: &Value,
|
||||
encounter: &Value,
|
||||
battle_mode: &str,
|
||||
x_meters: f64,
|
||||
y_offset: i32,
|
||||
) -> Value {
|
||||
let npc_id = read_optional_string_field(encounter, "id")
|
||||
.unwrap_or_else(|| current_encounter_name(game_state));
|
||||
let npc_name = current_encounter_name(game_state);
|
||||
let npc_state =
|
||||
resolve_current_encounter_npc_state(game_state, npc_id.as_str(), npc_name.as_str());
|
||||
let affinity = npc_state
|
||||
.and_then(|state| read_i32_field(state, "affinity"))
|
||||
.or_else(|| read_i32_field(encounter, "initialAffinity"))
|
||||
.unwrap_or(0);
|
||||
let base_hp = if battle_mode == "spar" {
|
||||
10
|
||||
} else {
|
||||
(80 + affinity).max(24)
|
||||
};
|
||||
let monster_id = read_optional_string_field(encounter, "monsterPresetId")
|
||||
.unwrap_or_else(|| format!("npc-opponent-{npc_id}"));
|
||||
let mut battle_encounter = encounter.clone();
|
||||
if let Some(entry) = battle_encounter.as_object_mut() {
|
||||
entry.insert("hostile".to_string(), Value::Bool(true));
|
||||
entry.insert("xMeters".to_string(), json!(x_meters));
|
||||
}
|
||||
|
||||
json!({
|
||||
"id": monster_id,
|
||||
"name": npc_name,
|
||||
"action": if battle_mode == "spar" {
|
||||
"抱拳行礼,准备点到为止地切磋武艺"
|
||||
} else {
|
||||
"摆开架势,随时准备出手"
|
||||
},
|
||||
"description": read_optional_string_field(encounter, "npcDescription").unwrap_or_default(),
|
||||
"animation": "idle",
|
||||
"xMeters": x_meters,
|
||||
"yOffset": y_offset,
|
||||
"facing": "left",
|
||||
"attackRange": 1.8,
|
||||
"speed": 7,
|
||||
"hp": base_hp,
|
||||
"maxHp": base_hp,
|
||||
"renderKind": "npc",
|
||||
"levelProfile": read_field(encounter, "levelProfile").cloned(),
|
||||
"experienceReward": read_i32_field(encounter, "experienceReward").unwrap_or(0),
|
||||
"encounter": battle_encounter
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) fn resolve_npc_recruit_action(
|
||||
game_state: &mut Value,
|
||||
request: &RuntimeStoryActionRequest,
|
||||
) -> Result<StoryResolution, String> {
|
||||
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<StoryResolution, String> {
|
||||
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<StoryResolution, String> {
|
||||
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,
|
||||
})
|
||||
}
|
||||
@@ -1,735 +0,0 @@
|
||||
use super::*;
|
||||
|
||||
pub(super) fn build_runtime_story_state_response(
|
||||
requested_session_id: &str,
|
||||
client_version: Option<u32>,
|
||||
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::<Vec<_>>(),
|
||||
})
|
||||
}
|
||||
|
||||
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<Value> {
|
||||
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<Value> {
|
||||
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<RuntimeStoryOptionView> {
|
||||
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::<Vec<_>>();
|
||||
|
||||
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<RuntimeStoryOptionView> {
|
||||
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<String>,
|
||||
) -> 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<RuntimeStoryOptionView> {
|
||||
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<CurrentEncounterNpcQuestContext, 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 = 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<PendingQuestOfferContext> {
|
||||
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<Value>) -> Vec<Value> {
|
||||
let mut dialogue = existing.to_vec();
|
||||
dialogue.extend(additions);
|
||||
dialogue
|
||||
}
|
||||
|
||||
pub(super) fn build_pending_quest_offer_options(npc_id: &str) -> Vec<RuntimeStoryOptionView> {
|
||||
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<RuntimeStoryOptionView> {
|
||||
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<RuntimeStoryOptionView> {
|
||||
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<Value>,
|
||||
npc_id: &str,
|
||||
npc_name: &str,
|
||||
turn_count: i32,
|
||||
custom_input_placeholder: &str,
|
||||
pending_quest: Option<Value>,
|
||||
options: &[RuntimeStoryOptionView],
|
||||
) -> Value {
|
||||
json!({
|
||||
"text": dialogue
|
||||
.iter()
|
||||
.filter_map(|entry| read_optional_string_field(entry, "text"))
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n"),
|
||||
"options": options.iter().map(build_story_option_from_runtime_option).collect::<Vec<_>>(),
|
||||
"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<String> {
|
||||
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<Value, String> {
|
||||
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::<Vec<_>>();
|
||||
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::<Vec<_>>(),
|
||||
"streaming": false
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) fn read_story_text(current_story: Option<&Value>) -> Option<String> {
|
||||
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()
|
||||
}
|
||||
@@ -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<StoryResolution, String> {
|
||||
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<StoryResolution, String> {
|
||||
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<StoryResolution, String> {
|
||||
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<StoryResolution, String> {
|
||||
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<StoryResolution, String> {
|
||||
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,
|
||||
})
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -166,6 +166,27 @@ pub async fn get_story_session_state(
|
||||
))
|
||||
}
|
||||
|
||||
pub async fn get_story_runtime_projection(
|
||||
State(state): State<AppState>,
|
||||
Path(story_session_id): Path<String>,
|
||||
Extension(request_context): Extension<RequestContext>,
|
||||
Extension(authenticated): Extension<AuthenticatedAccessToken>,
|
||||
) -> Result<Json<Value>, 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
|
||||
|
||||
@@ -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. 当前仍未进入的范围
|
||||
|
||||
|
||||
@@ -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<AiTaskSnapshot>,
|
||||
pub text_chunk: Option<AiTextChunkSnapshot>,
|
||||
pub error_message: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct InMemoryAiTaskStore {
|
||||
inner: Arc<Mutex<InMemoryAiTaskStoreState>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
struct InMemoryAiTaskStoreState {
|
||||
tasks: HashMap<String, AiTaskSnapshot>,
|
||||
text_chunks: HashMap<String, Vec<AiTextChunkSnapshot>>,
|
||||
}
|
||||
|
||||
#[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<AiTaskSnapshot, AiTaskServiceError> {
|
||||
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<AiTaskSnapshot, AiTaskServiceError> {
|
||||
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<AiTaskSnapshot, AiTaskServiceError> {
|
||||
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<AiTaskSnapshot, AiTaskServiceError> {
|
||||
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<String>,
|
||||
created_at_micros: i64,
|
||||
) -> Result<AiTaskSnapshot, AiTaskServiceError> {
|
||||
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<AiTaskSnapshot, AiTaskServiceError> {
|
||||
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<AiTaskSnapshot, AiTaskServiceError> {
|
||||
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<AiTaskSnapshot, AiTaskServiceError> {
|
||||
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<AiTaskSnapshot, AiTaskServiceError> {
|
||||
self.store.get_task(task_id)
|
||||
}
|
||||
}
|
||||
|
||||
impl InMemoryAiTaskStore {
|
||||
fn insert_task(&self, task: AiTaskSnapshot) -> Result<AiTaskSnapshot, AiTaskServiceError> {
|
||||
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<F>(
|
||||
&self,
|
||||
task_id: &str,
|
||||
mut apply: F,
|
||||
) -> Result<AiTaskSnapshot, AiTaskServiceError>
|
||||
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<AiTaskSnapshot, AiTaskServiceError> {
|
||||
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::<Vec<_>>()
|
||||
.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<AiTaskSnapshot, AiTaskServiceError> {
|
||||
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(())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<String>,
|
||||
pub request_payload_json: Option<String>,
|
||||
pub stages: Vec<AiTaskStageBlueprint>,
|
||||
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<String>,
|
||||
pub structured_payload_json: Option<String>,
|
||||
pub warning_messages: Vec<String>,
|
||||
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<String>,
|
||||
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(())
|
||||
}
|
||||
|
||||
@@ -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<String>,
|
||||
pub structured_payload_json: Option<String>,
|
||||
pub warning_messages: Vec<String>,
|
||||
pub started_at_micros: Option<i64>,
|
||||
pub completed_at_micros: Option<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<String>,
|
||||
pub request_payload_json: Option<String>,
|
||||
pub status: AiTaskStatus,
|
||||
pub failure_message: Option<String>,
|
||||
pub stages: Vec<AiTaskStageSnapshot>,
|
||||
pub result_references: Vec<AiResultReferenceSnapshot>,
|
||||
pub latest_text_output: Option<String>,
|
||||
pub latest_structured_payload_json: Option<String>,
|
||||
pub version: u32,
|
||||
pub created_at_micros: i64,
|
||||
pub started_at_micros: Option<i64>,
|
||||
pub completed_at_micros: Option<i64>,
|
||||
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<String>,
|
||||
pub created_at_micros: i64,
|
||||
}
|
||||
|
||||
impl AiTaskKind {
|
||||
pub fn default_stage_blueprints(self) -> Vec<AiTaskStageBlueprint> {
|
||||
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<String>) -> Option<String> {
|
||||
normalize_shared_optional_string(value)
|
||||
}
|
||||
|
||||
pub fn normalize_string_list(values: Vec<String>) -> Vec<String> {
|
||||
normalize_shared_string_list(values)
|
||||
}
|
||||
|
||||
@@ -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 {}
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -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<String>,
|
||||
pub structured_payload_json: Option<String>,
|
||||
pub warning_messages: Vec<String>,
|
||||
pub started_at_micros: Option<i64>,
|
||||
pub completed_at_micros: Option<i64>,
|
||||
}
|
||||
|
||||
#[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<String>,
|
||||
pub request_payload_json: Option<String>,
|
||||
pub stages: Vec<AiTaskStageBlueprint>,
|
||||
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<String>,
|
||||
pub request_payload_json: Option<String>,
|
||||
pub status: AiTaskStatus,
|
||||
pub failure_message: Option<String>,
|
||||
pub stages: Vec<AiTaskStageSnapshot>,
|
||||
pub result_references: Vec<AiResultReferenceSnapshot>,
|
||||
pub latest_text_output: Option<String>,
|
||||
pub latest_structured_payload_json: Option<String>,
|
||||
pub version: u32,
|
||||
pub created_at_micros: i64,
|
||||
pub started_at_micros: Option<i64>,
|
||||
pub completed_at_micros: Option<i64>,
|
||||
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<String>,
|
||||
pub structured_payload_json: Option<String>,
|
||||
pub warning_messages: Vec<String>,
|
||||
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<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<String>,
|
||||
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<AiTaskSnapshot>,
|
||||
pub text_chunk: Option<AiTextChunkSnapshot>,
|
||||
pub error_message: Option<String>,
|
||||
}
|
||||
|
||||
#[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<Mutex<InMemoryAiTaskStoreState>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
struct InMemoryAiTaskStoreState {
|
||||
tasks: HashMap<String, AiTaskSnapshot>,
|
||||
text_chunks: HashMap<String, Vec<AiTextChunkSnapshot>>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct AiTaskService {
|
||||
store: InMemoryAiTaskStore,
|
||||
}
|
||||
|
||||
impl AiTaskKind {
|
||||
// 默认阶段蓝图只冻结通用语义,具体 prompt 内容与供应商策略仍由上层模块决定。
|
||||
pub fn default_stage_blueprints(self) -> Vec<AiTaskStageBlueprint> {
|
||||
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<AiTaskSnapshot, AiTaskServiceError> {
|
||||
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<AiTaskSnapshot, AiTaskServiceError> {
|
||||
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<AiTaskSnapshot, AiTaskServiceError> {
|
||||
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<AiTaskSnapshot, AiTaskServiceError> {
|
||||
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<String>,
|
||||
created_at_micros: i64,
|
||||
) -> Result<AiTaskSnapshot, AiTaskServiceError> {
|
||||
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<AiTaskSnapshot, AiTaskServiceError> {
|
||||
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<AiTaskSnapshot, AiTaskServiceError> {
|
||||
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<AiTaskSnapshot, AiTaskServiceError> {
|
||||
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<AiTaskSnapshot, AiTaskServiceError> {
|
||||
self.store.get_task(task_id)
|
||||
}
|
||||
}
|
||||
|
||||
impl InMemoryAiTaskStore {
|
||||
fn insert_task(&self, task: AiTaskSnapshot) -> Result<AiTaskSnapshot, AiTaskServiceError> {
|
||||
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<F>(
|
||||
&self,
|
||||
task_id: &str,
|
||||
mut apply: F,
|
||||
) -> Result<AiTaskSnapshot, AiTaskServiceError>
|
||||
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<AiTaskSnapshot, AiTaskServiceError> {
|
||||
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::<Vec<_>>()
|
||||
.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<AiTaskSnapshot, AiTaskServiceError> {
|
||||
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<String>) -> Option<String> {
|
||||
normalize_shared_optional_string(value)
|
||||
}
|
||||
|
||||
pub fn normalize_string_list(values: Vec<String>) -> Vec<String> {
|
||||
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 {
|
||||
|
||||
@@ -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<BigFishDomainEvent>,
|
||||
}
|
||||
|
||||
/// 评估 Big Fish 作品是否具备发布条件。
|
||||
///
|
||||
/// 规则只依赖草稿和资产槽:草稿必须存在,等级主图、基础动作和背景图
|
||||
/// 必须满足 `build_asset_coverage` 的统一口径。
|
||||
pub fn evaluate_publish_readiness(
|
||||
command: EvaluateBigFishPublishReadinessCommand,
|
||||
asset_slots: &[BigFishAssetSlotSnapshot],
|
||||
) -> Result<EvaluateBigFishPublishReadinessResult, BigFishApplicationError> {
|
||||
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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<BigFishGameDraft>,
|
||||
pub evaluated_at_micros: i64,
|
||||
}
|
||||
|
||||
@@ -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<String>,
|
||||
pub evaluated_at_micros: i64,
|
||||
}
|
||||
|
||||
@@ -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 {}
|
||||
|
||||
@@ -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<String>,
|
||||
occurred_at_micros: i64,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -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};
|
||||
|
||||
@@ -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。
|
||||
@@ -1,3 +0,0 @@
|
||||
//! runtime story 兼容应用编排过渡落位。
|
||||
//!
|
||||
//! 这里只组合旧规则并返回兼容结果;真实保存、SSE 和模型调用由外层完成。
|
||||
@@ -1,5 +1,5 @@
|
||||
[package]
|
||||
name = "module-runtime-story-compat"
|
||||
name = "module-runtime-story"
|
||||
edition.workspace = true
|
||||
version.workspace = true
|
||||
license.workspace = true
|
||||
13
server-rs/crates/module-runtime-story/README.md
Normal file
13
server-rs/crates/module-runtime-story/README.md
Normal file
@@ -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 命名。
|
||||
3
server-rs/crates/module-runtime-story/src/application.rs
Normal file
3
server-rs/crates/module-runtime-story/src/application.rs
Normal file
@@ -0,0 +1,3 @@
|
||||
//! runtime story 应用编排落位。
|
||||
//!
|
||||
//! 这里组合纯领域规则并返回后端投影;真实保存、SSE 和模型调用由外层完成。
|
||||
@@ -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::{
|
||||
188
server-rs/crates/module-runtime-story/src/projection.rs
Normal file
188
server-rs/crates/module-runtime-story/src/projection.rs
Normal file
@@ -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<StoryEventPayload>,
|
||||
pub game_state: Value,
|
||||
pub options: Vec<RuntimeStoryOptionView>,
|
||||
pub server_version: u32,
|
||||
pub current_narrative_text: Option<String>,
|
||||
pub action_result_text: Option<String>,
|
||||
pub toast: Option<String>,
|
||||
}
|
||||
|
||||
/// 将领域快照折成前端可直接消费的新 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("关系有所变化。"));
|
||||
}
|
||||
}
|
||||
@@ -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<StoryEventPayload>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct StoryRuntimeProjectionRequest {
|
||||
pub story_session_id: String,
|
||||
#[serde(default)]
|
||||
pub client_version: Option<u32>,
|
||||
}
|
||||
|
||||
#[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<Value>,
|
||||
pub equipment_slots: Vec<Value>,
|
||||
pub forge_recipes: Vec<Value>,
|
||||
}
|
||||
|
||||
#[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<String>,
|
||||
pub scope: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub payload: Option<Value>,
|
||||
pub enabled: bool,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub reason: Option<String>,
|
||||
}
|
||||
|
||||
#[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<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub current_npc_battle_mode: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub current_npc_battle_outcome: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct StoryRuntimeProjectionResponse {
|
||||
pub story_session: StorySessionPayload,
|
||||
pub story_events: Vec<StoryEventPayload>,
|
||||
pub server_version: u32,
|
||||
pub actor: StoryRuntimeActorProjection,
|
||||
pub inventory: StoryRuntimeInventoryProjection,
|
||||
pub options: Vec<StoryRuntimeOptionProjection>,
|
||||
pub status: StoryRuntimeStatusProjection,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub current_narrative_text: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub action_result_text: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub toast: Option<String>,
|
||||
}
|
||||
|
||||
#[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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"] }
|
||||
|
||||
@@ -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 生成流程刷新。
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
|
||||
@@ -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<String>) -> 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)
|
||||
|
||||
@@ -530,16 +530,12 @@ pub(crate) fn map_procedure_result(
|
||||
result: AssetObjectProcedureResult,
|
||||
) -> Result<AssetObjectRecord, 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 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<AssetEntityBindingRecord, 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 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<Vec<AssetHistoryEntryRecord>, 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<AuthStoreSnapshotRecord, 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 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<AiTaskMutationRecord, SpacetimeClientError> {
|
||||
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<BigFishSessionRecord, 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 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<Vec<BigFishWorkSummaryRecord>, 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::<Vec<CompatibleBigFishWorkSummaryRecord>>(&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<StorySessionResultRecord, 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 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<StorySessionStateRecord, 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 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<RuntimeInventoryStateRecord, 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 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<BattleStateRecord, 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 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<ResolveCombatActionRecord, 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 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<NpcBattleInteractionRecord, 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 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),
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
|
||||
227
server-rs/crates/spacetime-client/src/story_runtime.rs
Normal file
227
server-rs/crates/spacetime-client/src/story_runtime.rs
Normal file
@@ -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<StoryRuntimeProjectionSource, SpacetimeClientError> {
|
||||
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<Vec<RuntimeStoryOptionView>, SpacetimeClientError> {
|
||||
let Some(options) = current_story.and_then(|story| story.get("options")) else {
|
||||
return Ok(Vec::new());
|
||||
};
|
||||
|
||||
serde_json::from_value::<Vec<RuntimeStoryOptionView>>(options.clone()).map_err(|error| {
|
||||
SpacetimeClientError::Runtime(format!(
|
||||
"currentStory.options 无法映射为后端选项投影: {error}"
|
||||
))
|
||||
})
|
||||
}
|
||||
|
||||
fn read_current_story_text(current_story: Option<&Value>) -> Option<String> {
|
||||
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<String> {
|
||||
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<Value>,
|
||||
) -> 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,
|
||||
}
|
||||
}
|
||||
}
|
||||
100
server-rs/crates/spacetime-module/src/ai/events.rs
Normal file
100
server-rs/crates/spacetime-module/src/ai/events.rs
Normal file
@@ -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<AiTaskStatus>,
|
||||
pub(crate) stage_kind: Option<AiTaskStageKind>,
|
||||
pub(crate) text_chunk_row_id: Option<String>,
|
||||
pub(crate) result_reference_row_id: Option<String>,
|
||||
pub(crate) occurred_at: Timestamp,
|
||||
}
|
||||
|
||||
pub(crate) fn emit_ai_task_event(
|
||||
ctx: &ReducerContext,
|
||||
task: &AiTaskSnapshot,
|
||||
event_kind: AiTaskEventKind,
|
||||
stage_kind: Option<AiTaskStageKind>,
|
||||
text_chunk_row_id: Option<String>,
|
||||
result_reference_row_id: Option<String>,
|
||||
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<AiTaskStageKind>) -> &'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",
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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::*;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
56
server-rs/crates/spacetime-module/src/big_fish/events.rs
Normal file
56
server-rs/crates/spacetime-module/src/big_fish/events.rs
Normal file
@@ -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(())
|
||||
}
|
||||
@@ -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::*;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user