推进 server-rs DDD 分层与新接口接线

This commit is contained in:
Codex
2026-04-29 15:46:16 +08:00
parent 9d3fcfae77
commit f82775b852
89 changed files with 3657 additions and 9636 deletions

View File

@@ -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` 对齐时优先参考。

View File

@@ -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 路径

View File

@@ -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 和旧静态代理。

View File

@@ -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避免并行任务各自定义接口。

View File

@@ -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非本次新增。

View File

@@ -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` 重新启动后端。

View 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`,也不执行绑定生成。

View File

@@ -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`

View File

@@ -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
```

View File

@@ -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
```

View File

@@ -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
View File

@@ -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",

View File

@@ -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",

View File

@@ -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" }

View File

@@ -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"));

View File

@@ -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;

View File

@@ -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,

View File

@@ -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,
};

View File

@@ -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

View File

@@ -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} 的局势随之向下一步展开。")
}

View File

@@ -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)),
})
}

View File

@@ -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));
}
}

View File

@@ -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,
})
}

View File

@@ -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()
}

View File

@@ -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

View File

@@ -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

View File

@@ -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. 当前仍未进入的范围

View File

@@ -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(())
}
}

View File

@@ -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(())
}

View File

@@ -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)
}

View File

@@ -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 {}

View File

@@ -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,
},
}

View File

@@ -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 {

View File

@@ -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());
}
}

View File

@@ -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,
}

View File

@@ -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,
}

View File

@@ -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 {}

View File

@@ -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,
},
}

View File

@@ -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};

View File

@@ -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。

View File

@@ -1,3 +0,0 @@
//! runtime story 兼容应用编排过渡落位。
//!
//! 这里只组合旧规则并返回兼容结果真实保存、SSE 和模型调用由外层完成。

View File

@@ -1,5 +1,5 @@
[package]
name = "module-runtime-story-compat"
name = "module-runtime-story"
edition.workspace = true
version.workspace = true
license.workspace = true

View 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 命名。

View File

@@ -0,0 +1,3 @@
//! runtime story 应用编排落位。
//!
//! 这里组合纯领域规则并返回后端投影真实保存、SSE 和模型调用由外层完成。

View File

@@ -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::{

View 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("关系有所变化。"));
}
}

View File

@@ -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());
}
}

View File

@@ -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"] }

View File

@@ -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 生成流程刷新。

View File

@@ -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);
},

View File

@@ -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);
});

View File

@@ -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);
});

View File

@@ -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);
});

View File

@@ -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);
});

View File

@@ -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);
},

View File

@@ -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)

View File

@@ -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),

View File

@@ -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);
},

View File

@@ -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);
},

View 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,
}
}
}

View 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",
}
}
}

View File

@@ -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::*;

View File

@@ -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 {

View File

@@ -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)
}

View File

@@ -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)
}

View File

@@ -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,

View 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(())
}

View File

@@ -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::*;

View File

@@ -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,

View File

@@ -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
}
};
}