Close DDD cleanup and tests-support closure

This commit is contained in:
2026-04-30 16:15:05 +08:00
parent 7ab0933f6d
commit fd08262bf0
81 changed files with 8415 additions and 6662 deletions

View File

@@ -4,6 +4,9 @@
## 文档列表 ## 文档列表
- [SERVER_RS_DDD_WP_CW_ACTION_AND_DOMAIN_SPLIT_2026-04-30.md](./SERVER_RS_DDD_WP_CW_ACTION_AND_DOMAIN_SPLIT_2026-04-30.md):记录 `WP-CW Custom World` 的领域拆分与 Agent action 收口,将 `module-custom-world``lib.rs` 拆入 DDD 骨架,并移除 Custom World 运行代码中的最小兼容占位动作。
- [SERVER_RS_DDD_WP_BF_AND_G2_DRIFT_CLEANUP_2026-04-30.md](./SERVER_RS_DDD_WP_BF_AND_G2_DRIFT_CLEANUP_2026-04-30.md):记录 `WP-BF Big Fish` 物理拆分漂移和 G2 迁移期口径清理,将 Big Fish 创作域类型、命令、应用规则和错误层拆入 DDD 文件,并清理剩余 `过渡落位` 注释。
- [SERVER_RS_DDD_TESTS_SUPPORT_CRATE_CLOSURE_2026-04-30.md](./SERVER_RS_DDD_TESTS_SUPPORT_CRATE_CLOSURE_2026-04-30.md):记录 `tests-support` 从目录占位收口为 `server-rs` workspace 共享测试支撑 crate首版提供 Maincloud healthz 与 HTTP smoke 通用断言。
- [SERVER_RS_DDD_WP_BF_RUNTIME_BACKEND_TRUTH_2026-04-29.md](./SERVER_RS_DDD_WP_BF_RUNTIME_BACKEND_TRUTH_2026-04-29.md):记录 `WP-BF Big Fish` 运行态从前端本地规则切到 Rust 领域真相源、SpacetimeDB run 表、API facade 和前端新接口接入的关闭口径。 - [SERVER_RS_DDD_WP_BF_RUNTIME_BACKEND_TRUTH_2026-04-29.md](./SERVER_RS_DDD_WP_BF_RUNTIME_BACKEND_TRUTH_2026-04-29.md):记录 `WP-BF Big Fish` 运行态从前端本地规则切到 Rust 领域真相源、SpacetimeDB run 表、API facade 和前端新接口接入的关闭口径。
- [SERVER_RS_DDD_WP_PF_PLATFORM_ERROR_CLASSIFICATION_2026-04-29.md](./SERVER_RS_DDD_WP_PF_PLATFORM_ERROR_CLASSIFICATION_2026-04-29.md):记录 `WP-PF platform side effects` 平台副作用收口,统一 LLM、OSS、SMS、微信平台错误分类与 API 映射,并将微信 OAuth provider 下沉到 `platform-auth` - [SERVER_RS_DDD_WP_PF_PLATFORM_ERROR_CLASSIFICATION_2026-04-29.md](./SERVER_RS_DDD_WP_PF_PLATFORM_ERROR_CLASSIFICATION_2026-04-29.md):记录 `WP-PF platform side effects` 平台副作用收口,统一 LLM、OSS、SMS、微信平台错误分类与 API 映射,并将微信 OAuth provider 下沉到 `platform-auth`
- [SERVER_RS_DDD_WP_RT_ADAPTER_API_CLOSURE_2026-04-29.md](./SERVER_RS_DDD_WP_RT_ADAPTER_API_CLOSURE_2026-04-29.md):记录 `WP-RT Runtime/Profile/Save` Adapter/API 收口,将 checkpoint、profile/save archive meta、充值/邀请/兑换/钱包等剩余纯规则迁入 `module-runtime`,移除 `/api/runtime/profile/*` 旧兼容挂载并对齐前端 `/api/profile/*` 请求路径。 - [SERVER_RS_DDD_WP_RT_ADAPTER_API_CLOSURE_2026-04-29.md](./SERVER_RS_DDD_WP_RT_ADAPTER_API_CLOSURE_2026-04-29.md):记录 `WP-RT Runtime/Profile/Save` Adapter/API 收口,将 checkpoint、profile/save archive meta、充值/邀请/兑换/钱包等剩余纯规则迁入 `module-runtime`,移除 `/api/runtime/profile/*` 旧兼容挂载并对齐前端 `/api/profile/*` 请求路径。
@@ -13,6 +16,9 @@
- [SERVER_RS_DDD_WP_AI_INTERNAL_MODULE_SPLIT_2026-04-29.md](./SERVER_RS_DDD_WP_AI_INTERNAL_MODULE_SPLIT_2026-04-29.md):记录 `WP-AI AI Task``module-ai` 内部子模块拆分,将 domain、commands、application 与行为测试继续拆到职责更细的子文件,同时保持 `module_ai::*` 公开导出、SpacetimeDB schema、BFF route 和前端契约不变。 - [SERVER_RS_DDD_WP_AI_INTERNAL_MODULE_SPLIT_2026-04-29.md](./SERVER_RS_DDD_WP_AI_INTERNAL_MODULE_SPLIT_2026-04-29.md):记录 `WP-AI AI Task``module-ai` 内部子模块拆分,将 domain、commands、application 与行为测试继续拆到职责更细的子文件,同时保持 `module_ai::*` 公开导出、SpacetimeDB schema、BFF route 和前端契约不变。
- [SERVER_RS_DDD_WP_AI_TASK_BFF_CLOSURE_2026-04-29.md](./SERVER_RS_DDD_WP_AI_TASK_BFF_CLOSURE_2026-04-29.md):记录 `WP-AI AI Task` BFF 收口与关闭口径,补齐 AI task mutation route 鉴权和 SpacetimeDB 未发布错误 envelope 的定向验证不改表结构、LLM provider、SSE 或前端消费。 - [SERVER_RS_DDD_WP_AI_TASK_BFF_CLOSURE_2026-04-29.md](./SERVER_RS_DDD_WP_AI_TASK_BFF_CLOSURE_2026-04-29.md):记录 `WP-AI AI Task` BFF 收口与关闭口径,补齐 AI task mutation route 鉴权和 SpacetimeDB 未发布错误 envelope 的定向验证不改表结构、LLM provider、SSE 或前端消费。
- [SERVER_RS_DDD_WP_CW_DOMAIN_ENUM_REHOME_2026-04-29.md](./SERVER_RS_DDD_WP_CW_DOMAIN_ENUM_REHOME_2026-04-29.md):记录 `WP-CW Custom World` 基础领域枚举归位切片,将 Custom World / RPG Agent 基础枚举、进度常量和字符串口径迁入 `module-custom-world/src/domain.rs`,不改 SpacetimeDB、API 或前端行为。 - [SERVER_RS_DDD_WP_CW_DOMAIN_ENUM_REHOME_2026-04-29.md](./SERVER_RS_DDD_WP_CW_DOMAIN_ENUM_REHOME_2026-04-29.md):记录 `WP-CW Custom World` 基础领域枚举归位切片,将 Custom World / RPG Agent 基础枚举、进度常量和字符串口径迁入 `module-custom-world/src/domain.rs`,不改 SpacetimeDB、API 或前端行为。
- [SERVER_RS_DDD_WP_RPG_GAMEPLAY_DOMAIN_SPLIT_2026-04-30.md](./SERVER_RS_DDD_WP_RPG_GAMEPLAY_DOMAIN_SPLIT_2026-04-30.md):记录 `WP-RPG Gameplay 域` 的 combat、inventory、NPC、quest、runtime-item 领域拆分收口,将真实规则从 `lib.rs` 拆入 DDD 骨架文件并保留原公开 API。
- [SERVER_RS_DDD_WP_RPG_PROGRESSION_DOMAIN_SPLIT_2026-04-30.md](./SERVER_RS_DDD_WP_RPG_PROGRESSION_DOMAIN_SPLIT_2026-04-30.md):记录 `WP-RPG Gameplay 域``module-progression` 领域拆分收口,将玩家成长、章节预算、章节账本、自动定级、领域事件和错误层从 `lib.rs` 拆入 DDD 骨架文件。
- [SERVER_RS_DDD_WP_RS_RUNTIME_STORY_DOMAIN_SPLIT_2026-04-30.md](./SERVER_RS_DDD_WP_RS_RUNTIME_STORY_DOMAIN_SPLIT_2026-04-30.md):记录 `WP-RS Runtime Story 去兼容层``module-runtime-story` 顶层领域拆分收口,将 action 结果、状态 patch、响应组装参数、领域事件和错误从 `lib.rs` 拆入 DDD 骨架文件。
- [SERVER_RS_DDD_WP_RPG_STORY_DOMAIN_SPLIT_2026-04-29.md](./SERVER_RS_DDD_WP_RPG_STORY_DOMAIN_SPLIT_2026-04-29.md):记录 `WP-RPG Gameplay 域``module-story` 领域拆分收口,将 story session 领域模型、命令、事件、应用映射和错误层从 `lib.rs` 拆入 DDD 骨架文件,并修正 README 不再指向旧 `/api/runtime/story/*` 兼容链路。 - [SERVER_RS_DDD_WP_RPG_STORY_DOMAIN_SPLIT_2026-04-29.md](./SERVER_RS_DDD_WP_RPG_STORY_DOMAIN_SPLIT_2026-04-29.md):记录 `WP-RPG Gameplay 域``module-story` 领域拆分收口,将 story session 领域模型、命令、事件、应用映射和错误层从 `lib.rs` 拆入 DDD 骨架文件,并修正 README 不再指向旧 `/api/runtime/story/*` 兼容链路。
- [SERVER_RS_DDD_WP_PZ_DOMAIN_SPLIT_2026-04-29.md](./SERVER_RS_DDD_WP_PZ_DOMAIN_SPLIT_2026-04-29.md):记录 `WP-PZ Puzzle` 领域类型与规则拆分切片,将 Agent/作品/运行态领域类型、写入命令、应用规则、字段错误和最小领域事件归位到 `module-puzzle` 的 DDD 骨架文件,不改 SpacetimeDB、API 或前端行为。 - [SERVER_RS_DDD_WP_PZ_DOMAIN_SPLIT_2026-04-29.md](./SERVER_RS_DDD_WP_PZ_DOMAIN_SPLIT_2026-04-29.md):记录 `WP-PZ Puzzle` 领域类型与规则拆分切片,将 Agent/作品/运行态领域类型、写入命令、应用规则、字段错误和最小领域事件归位到 `module-puzzle` 的 DDD 骨架文件,不改 SpacetimeDB、API 或前端行为。
- [SERVER_RS_DDD_WP_PZ_DOMAIN_ENUM_REHOME_2026-04-29.md](./SERVER_RS_DDD_WP_PZ_DOMAIN_ENUM_REHOME_2026-04-29.md):记录 `WP-PZ Puzzle` 基础领域常量与枚举归位切片,将 Puzzle Agent、发布状态、运行态状态、ID 前缀、标签数量和洗牌次数口径迁入 `module-puzzle/src/domain.rs`,不改 SpacetimeDB、API 或前端行为。 - [SERVER_RS_DDD_WP_PZ_DOMAIN_ENUM_REHOME_2026-04-29.md](./SERVER_RS_DDD_WP_PZ_DOMAIN_ENUM_REHOME_2026-04-29.md):记录 `WP-PZ Puzzle` 基础领域常量与枚举归位切片,将 Puzzle Agent、发布状态、运行态状态、ID 前缀、标签数量和洗牌次数口径迁入 `module-puzzle/src/domain.rs`,不改 SpacetimeDB、API 或前端行为。

View File

@@ -160,13 +160,13 @@ LLM、OSS、SMS、微信等外部副作用可以独立准备不等待 `WP-SC`
| WP-A Auth | 已完成module-auth 已完成 domain/commands/application/errors/events 分层归位,账号、会话、验证码、微信 state/绑定规则由 module-auth 承接api-server/platform-auth/spacetime-module 边界已核查 | 未认领 | G1 后 | `module-auth``spacetime-module/src/auth*``api-server/src/auth*``platform-auth` | 其他玩法域 | 账号、会话、验证码、微信绑定领域化;真实短信/微信在 platform | `cargo test -p module-auth`auth API 测试 | | WP-A Auth | 已完成module-auth 已完成 domain/commands/application/errors/events 分层归位,账号、会话、验证码、微信 state/绑定规则由 module-auth 承接api-server/platform-auth/spacetime-module 边界已核查 | 未认领 | G1 后 | `module-auth``spacetime-module/src/auth*``api-server/src/auth*``platform-auth` | 其他玩法域 | 账号、会话、验证码、微信绑定领域化;真实短信/微信在 platform | `cargo test -p module-auth`auth API 测试 |
| WP-AS Assets | 进行中;资产对象类型归位、资产领域测试和 SpacetimeDB row mapper 切片已完成,资产 API/OSS/facade 尚未全链收口 | 未认领 | G1 后 | `module-assets``spacetime-module/src/asset_metadata/*`、资产 API、OSS adapter | 玩法业务规则 | 资产对象与绑定规则纯化OSS head/upload 移出领域核心 | `cargo test -p module-assets`,资产 facade 测试 | | WP-AS Assets | 进行中;资产对象类型归位、资产领域测试和 SpacetimeDB row mapper 切片已完成,资产 API/OSS/facade 尚未全链收口 | 未认领 | G1 后 | `module-assets``spacetime-module/src/asset_metadata/*`、资产 API、OSS adapter | 玩法业务规则 | 资产对象与绑定规则纯化OSS head/upload 移出领域核心 | `cargo test -p module-assets`,资产 facade 测试 |
| WP-AI AI Task | 已完成领域层、SpacetimeDB AI adapter/event、spacetime-client facade、BFF 路由和定向测试已闭环,`module-ai` 内部子模块拆分已完成,真实 LLM/SSE/前端消费归后续 WP-PF/WP-API/WP-FE 承接 | 未认领 | 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-AI AI Task | 已完成领域层、SpacetimeDB AI adapter/event、spacetime-client facade、BFF 路由和定向测试已闭环,`module-ai` 内部子模块拆分已完成,真实 LLM/SSE/前端消费归后续 WP-PF/WP-API/WP-FE 承接 | 未认领 | 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 | 进行中;基础领域枚举归位切片已关闭profile/agent/draft/gallery/publish gate 全链仍待收口 | 未认领 | 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-CW Custom World | 进行中;基础领域枚举、DDD 物理拆分和 Agent action 最小兼容占位已关闭profile/agent/draft/gallery/publish gate 全链仍待继续接 API/前端和资产对象链 | 未认领 | 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 | 已完成;运行态真相源已迁入 `module-big-fish`,并完成 SpacetimeDB run 表、spacetime-client facade、API 路由、前端 client 接入和本地运行态删除 | 未认领 | 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-BF Big Fish | 已完成;运行态真相源和 DDD 物理拆分均已收口,并完成 SpacetimeDB run 表、spacetime-client facade、API 路由、前端 client 接入和本地运行态删除 | 未认领 | 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 | 进行中领域类型与规则拆分切片已关闭Puzzle API/前端消费仍未全链完成 | 未认领 | 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-PZ Puzzle | 进行中领域类型与规则拆分切片已关闭Puzzle API/前端消费仍未全链完成 | 未认领 | 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 | 已完成runtime settings、snapshot/profile/save archive 类型、错误层、命令构造、应用记录投影、Adapter/API/Frontend profile 路径和剩余纯规则均已收口 | 未认领 | 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-RT Runtime/Profile/Save | 已完成runtime settings、snapshot/profile/save archive 类型、错误层、命令构造、应用记录投影、Adapter/API/Frontend profile 路径和剩余纯规则均已收口 | 未认领 | 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 域 | 进行中combat 基础领域常量与枚举归位、`module-story` DDD 物理拆分和 README 漂移收口切片已关闭inventory/npc/progression/quest/runtime-item 与跨域事件仍待收口 | 未认领 | G1 后 | `module-combat``module-inventory``module-npc``module-progression``module-quest``module-runtime-item``module-story` | 创作域 | 战斗、背包、NPC、成长、任务、宝箱、story session 纯规则与跨域事件 | 各 module 测试;跨域应用结果测试 | | WP-RPG Gameplay 域 | 进行中combat/inventory/npc/progression/quest/runtime-item/story 的 DDD 物理拆分漂移已关闭,完整 story action 写侧与跨域组合结算仍待 `WP-RS/WP-ST/WP-SC/WP-API/WP-FE` 主链收口 | 未认领 | G1 后 | `module-combat``module-inventory``module-npc``module-progression``module-quest``module-runtime-item``module-story` | 创作域 | 战斗、背包、NPC、成长、任务、宝箱、story session 纯规则与跨域事件 | 各 module 测试;跨域应用结果测试 |
| WP-RS Runtime Story 去兼容层 | 进行中compat 残留审计切片已关闭,module-runtime-story 运行代码注释口径已清理,旧前端写 client 与旧 contract 残留已冻结到后续 WP-FE-S/WP-DEL | 未认领 | 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-RS Runtime Story 去兼容层 | 进行中compat 残留审计`module-runtime-story` 顶层 DDD 物理拆分漂移已关闭,旧前端写 client 与旧 contract 残留已冻结到后续 WP-FE-S/WP-DEL | 未认领 | 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 | 进行中;已完成 AI task event、Big Fish readiness event、Asset row mapper、Puzzle publish event、Gameplay/Custom World 根入口瘦身和 Auth adapter 目录化切片,其他上下文和绑定生成仍待推进 | 未认领 | 领域任务输出稳定后 | `spacetime-module/src/**``migration.rs`、表目录 | `api-server` 业务逻辑 | table/reducer/procedure/mapper/queries 按上下文接入领域函数;必要 event/projection table`lib.rs/migration.rs/表目录` 单 owner 合流;已完成 AI task event、Big Fish readiness event、Asset row mapper、Puzzle publish event、Gameplay 根入口瘦身、Custom World 根入口瘦身、Auth adapter 目录化 | `cargo check -p spacetime-module`,需要时 `spacetime build/generate` | | WP-ST SpacetimeDB Adapter | 进行中;已完成 AI task event、Big Fish readiness event、Asset row mapper、Puzzle publish event、Gameplay/Custom World 根入口瘦身、Custom World Agent action 确定性编排和 Auth adapter 目录化切片,其他上下文和绑定生成仍待推进 | 未认领 | 领域任务输出稳定后 | `spacetime-module/src/**``migration.rs`、表目录 | `api-server` 业务逻辑 | table/reducer/procedure/mapper/queries 按上下文接入领域函数;必要 event/projection table`lib.rs/migration.rs/表目录` 单 owner 合流;已完成 AI task event、Big Fish readiness event、Asset row mapper、Puzzle publish event、Gameplay 根入口瘦身、Custom World 根入口瘦身、Auth adapter 目录化 | `cargo check -p spacetime-module`,需要时 `spacetime build/generate` |
| WP-SC Spacetime Client | 进行中story runtime projection inventory source 接线切片已关闭,读取投影已纳入稳定 runtime inventory facade后续仅随 WP-ST 新 facade 稳定后继续 typed facade/row mapper | 未认领 | 对应 WP-ST facade 稳定后 | `spacetime-client/src/**`、绑定 mapper | 领域规则、未稳定 facade 的预判接线 | typed facade、错误映射、row snapshot mapper | `cargo check -p spacetime-client` | | WP-SC Spacetime Client | 进行中story runtime projection inventory source 接线切片已关闭,读取投影已纳入稳定 runtime inventory facade后续仅随 WP-ST 新 facade 稳定后继续 typed facade/row mapper | 未认领 | 对应 WP-ST facade 稳定后 | `spacetime-client/src/**`、绑定 mapper | 领域规则、未稳定 facade 的预判接线 | typed facade、错误映射、row snapshot mapper | `cargo check -p spacetime-client` |
| WP-PF platform side effects | 已完成LLM/OSS/SMS/微信平台副作用错误分类与 API 接线错误模型已统一,微信 OAuth provider 已下沉到 `platform-auth` | 未认领 | G1 后可独立准备;接入 API 前与 WP-API 对齐错误模型 | `platform-*``api-server` platform 接线 | 领域状态机 | LLM、OSS、SMS、微信等副作用统一 adapter | platform crate 测试或 API smoke | | WP-PF platform side effects | 已完成LLM/OSS/SMS/微信平台副作用错误分类与 API 接线错误模型已统一,微信 OAuth provider 已下沉到 `platform-auth` | 未认领 | G1 后可独立准备;接入 API 前与 WP-API 对齐错误模型 | `platform-*``api-server` platform 接线 | 领域状态机 | LLM、OSS、SMS、微信等副作用统一 adapter | platform crate 测试或 API smoke |
| WP-API api-server BFF | 进行中story/game facade 当前阶段收口切片已关闭runtime projection、story battle DTO 和 owner guard 已推进,更多 session scoped 写接口依赖 WP-ST/WP-SC | 未认领 | 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-API api-server BFF | 进行中story/game facade 当前阶段收口切片已关闭runtime projection、story battle DTO 和 owner guard 已推进,更多 session scoped 写接口依赖 WP-ST/WP-SC | 未认领 | 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` |
@@ -186,22 +186,23 @@ LLM、OSS、SMS、微信等外部副作用可以独立准备不等待 `WP-SC`
| 归属工作包 | 未完整收口项 | 当前证据 | 收口边界 | 依赖与优先级 | | 归属工作包 | 未完整收口项 | 当前证据 | 收口边界 | 依赖与优先级 |
| --- | --- | --- | --- | --- | | --- | --- | --- | --- | --- |
| `WP-RPG Gameplay 域` | 成长系统归属 RPG 域;`module-progression` 真实首版,不是空占位,但仍未覆盖完整 RPG 成长闭环 | `server-rs/crates/module-progression/README.md` 已说明首版落地,同时列出 custom-world 章节蓝图、`repeatPenalty`、超预算衰减、完整章节偏差审计表仍未迁移`src/domain.rs``commands.rs``application.rs``events.rs``errors.rs` 仍命中 `过渡落位` | 保持在 `WP-RPG` 内推进,不拆成通用 Runtime 能力;补齐章节蓝图输入、章节预算审计、quest/combat/npc 联动事件和应用结果测试 | P0依赖 `module-custom-world` 章节蓝图 Rust 化边界稳定,随后接 `WP-ST/WP-SC/WP-API` | | `WP-RPG Gameplay 域` | 成长系统归属 RPG 域;`module-progression` 真实首版与 DDD 物理拆分已收口,完整 RPG 成长闭环仍需继续接跨域链路 | `server-rs/crates/module-progression/README.md` 已说明 DDD 物理拆分完成`src/domain.rs``commands.rs``application.rs``events.rs``errors.rs` 已承载真实类型、命令、应用规则、事件和错误;`cargo test -p module-progression` 通过 | 已关闭本项“分层文件仍是过渡壳”的漂移;继续保持在 `WP-RPG` 内推进,不拆成通用 Runtime 能力章节蓝图输入、章节预算审计、quest/combat/npc 联动事件继续跟随跨域主链 | 已关闭拆分漂移;完整成长闭环仍是 P0依赖 `module-custom-world` 章节蓝图 Rust 化边界稳定,随后接 `WP-ST/WP-SC/WP-API` |
| `WP-RPG Gameplay 域` | `module-story` README 与 DDD 物理拆分漂移已关闭;后续只剩完整 story action 写侧与跨域事件继续排队 | `server-rs/crates/module-story/README.md` 已改为当前真实边界;`src/domain.rs``commands.rs``application.rs``events.rs``errors.rs` 已承载真实类型、命令、应用映射、事件和错误;`cargo test -p module-story` 通过 | 保持 `module-story` 作为 story session 纯领域薄层;不恢复旧 `/api/runtime/story/*` compat route完整动作结算继续跟随 `WP-RS/WP-ST/WP-SC/WP-API/WP-FE` | 已关闭本项漂移;剩余写侧依赖仍在 P0 主链 | | `WP-RPG Gameplay 域` | `module-story` README 与 DDD 物理拆分漂移已关闭;后续只剩完整 story action 写侧与跨域事件继续排队 | `server-rs/crates/module-story/README.md` 已改为当前真实边界;`src/domain.rs``commands.rs``application.rs``events.rs``errors.rs` 已承载真实类型、命令、应用映射、事件和错误;`cargo test -p module-story` 通过 | 保持 `module-story` 作为 story session 纯领域薄层;不恢复旧 `/api/runtime/story/*` compat route完整动作结算继续跟随 `WP-RS/WP-ST/WP-SC/WP-API/WP-FE` | 已关闭本项漂移;剩余写侧依赖仍在 P0 主链 |
| `WP-RPG Gameplay 域` | `module-combat``module-inventory``module-npc``module-quest``module-runtime-item` 仍是首批领域化,跨域事件和完整应用用例未收口 | 上述 crate 的 `domain/commands/application/events/errors` 多数仍命中 `过渡落位`README 中仍有“最小主链”“刻意未做”“后续衔接”等范围说明 | 以 RPG 子域为单位补聚合、命令、事件和应用结果测试;跨域副作用只输出领域事件,不在单个 crate 内互相直连 | P0可按 combat、inventory、npc、quest、runtime-item 并行,但共享事件命名需先在 `WP-RPG` 冻结 | | `WP-RPG Gameplay 域` | `module-combat``module-inventory``module-npc``module-quest``module-runtime-item` 的 DDD 物理拆分漂移已关闭;完整跨域组合结算仍排队 | 上述 crate 的 `domain/commands/application/events/errors` 已承载真实类型、命令、应用规则、事件和错误RPG 六个子域源码不再命中 `过渡落位``cargo test -p module-combat -p module-inventory -p module-npc -p module-progression -p module-quest -p module-runtime-item` 通过 | 已关闭本项“分层文件仍是过渡壳”的漂移;跨域副作用继续只输出领域事件,不在单个 crate 内互相直连;完整 story action 写侧与组合结算跟随后续主链 | 已关闭拆分漂移;完整跨域写侧仍是 P0依赖 `WP-RS/WP-ST/WP-SC/WP-API/WP-FE` |
| `WP-RS Runtime Story 去兼容层` | `module-runtime-story` 已从 compat 口径转新主链,但仍有 DDD 文件命中 `过渡落位`旧写接口未完整替代 | `module-runtime-story/src/domain.rs``commands.rs``events.rs``errors.rs` 仍命中 `过渡落位`;文档记录旧前端写 client 与 `RuntimeStoryActionResponse` 等残留等待 `WP-FE-S/WP-DEL` | 补齐 session scoped 开局、动作结算、inventory action、NPC interaction、forge/battle/quest 写接口;前端只接新 `/api/story/*` 与新 contract | P0依赖 `WP-ST/WP-SC` facade完成后解锁 `WP-FE-S/WP-FE-H/WP-FE-C/WP-DEL` | | `WP-RS Runtime Story 去兼容层` | `module-runtime-story` 顶层 DDD 物理拆分漂移已关闭,但旧写接口未完整替代 | `module-runtime-story/src/domain.rs``commands.rs``application.rs``events.rs``errors.rs` 已承载顶层真实类型、命令 helper、应用 helper、事件和错误`cargo test -p module-runtime-story` 通过;旧前端写 client 与 `RuntimeStoryActionResponse` 等残留等待 `WP-FE-S/WP-DEL` | 已关闭本项“DDD 文件仍是过渡壳”的漂移;继续补齐 session scoped 开局、动作结算、inventory action、NPC interaction、forge/battle/quest 写接口;前端只接新 `/api/story/*` 与新 contract | 已关闭拆分漂移;旧写接口替换仍是 P0依赖 `WP-ST/WP-SC` facade完成后解锁 `WP-FE-S/WP-FE-H/WP-FE-C/WP-DEL` |
| `WP-CW Custom World` | Custom World agent 仍有多个动作接到最小兼容占位,未形成真实编排链 | `spacetime-module/src/custom_world/mod.rs``generate_characters``generate_landmarks``generate_role_assets``sync_role_assets``generate_scene_assets``sync_scene_assets``expand_long_tail` 仍调用 `execute_placeholder_custom_world_action`,并返回“最小兼容占位”消息 | 将动作拆入 `module-custom-world` 领域命令和应用结果,再由 `WP-ST` 接 reducer/procedureLLM、资产生成和 OSS 只通过 `WP-API/WP-PF/WP-AS` 编排 | P0依赖 `WP-AS` 资产对象链和 `WP-PF` LLM/OSS 能力 | | `WP-CW Custom World` | Custom World agent 最小兼容占位已关闭,真实动作改为 SpacetimeDB 内确定性状态编排;完整外部副作用链仍待 `WP-AS/WP-PF/WP-API` | `spacetime-module/src/custom_world/mod.rs` 已移除 `execute_placeholder_custom_world_action``generate_characters``generate_landmarks``generate_role_assets``sync_role_assets``generate_scene_assets``sync_scene_assets``expand_long_tail` 已分别写回 draft profile、draft card、asset coverage、publish gate 或 long-tail 状态;`cargo check -p spacetime-module` 通过 | 已关闭“最小兼容占位”漂移LLM、图片生成、OSS、资产对象确认仍只通过 `WP-API/WP-PF/WP-AS` 编排,不放进 reducer/procedure | 已关闭 P0 占位项;外部副作用和资产对象全链仍随 `WP-AS/WP-PF/WP-API` 继续 |
| `WP-CW Custom World` | `module-custom-world` 只完成首批类型契约和字段校验,profileagent session、draft/gallery/publish gate 未全链收口 | README 写明“当前阶段明确不提前进入”;`src/domain.rs``commands.rs``application.rs``events.rs``errors.rs` 仍命中 `过渡落位` | 继续把 profile、agent session、草稿卡、画廊投影、发布门禁迁成纯领域规则SpacetimeDB 表和 procedure 只在 `WP-ST` 落地 | P0可与 Custom World action 真实编排并行,但共享 domain enum 只能单 owner 合流 | | `WP-CW Custom World` | `module-custom-world` DDD 物理拆分已收口;profile/agent/draft/gallery/publish gate 的 API/前端全链仍待后续推进 | `module-custom-world/src/lib.rs` 已收口为模块声明、公开导出和测试;`domain/commands/application/events/errors` 已承载真实类型、命令、规则、事件和错误;`module-custom-world/README.md` 已更新当前边界;源码不再命中 `过渡落位``cargo test -p module-custom-world` 通过 | 已关闭“分层文件仍是过渡壳”的漂移;继续保持 SpacetimeDB 表和 procedure `WP-ST` 落地API/前端只消费稳定 facade | 已关闭拆分漂移;完整创作全链仍是 P0/P1 主链,依赖 `WP-ST/WP-SC/WP-API/WP-FE` |
| `WP-PZ Puzzle` | Puzzle 已完成领域类型和规则拆分切片,但 API、前端消费和运行态全链仍未完成 | 第 4 节仍标记 `WP-PZ` 进行中;`module-puzzle/README.md` 写明当前只固定 contract 与最小规则;`domain.rs``events.rs` 仍命中 `过渡落位` | 补齐 agent session、work profile、runtime run、排行榜、图片选择和结果页草稿的领域应用服务再接 `WP-ST/WP-SC/WP-API/WP-FE` | P1`WP-CW` 并行时避免共改创作 agent 通用文档 | | `WP-BF Big Fish` | `module-big-fish` 已完成 DDD 物理拆分和迁移期口径清理 | `module-big-fish/src/lib.rs` 已收口为模块声明、公开导出和测试;`domain/commands/application/events/errors` 已承载创作域类型、命令、应用规则、事件和错误;新增 `module-big-fish/README.md`;源码不再命中 `过渡落位``cargo test -p module-big-fish` 通过 | 已关闭“文档完成但 lib.rs 仍承载大量规则”的漂移;不改 SpacetimeDB/API/前端 shape | 已关闭;后续只随全链验证和回归维护 |
| `WP-AS Assets` | 资产对象类型、领域测试和 row mapper 已完成,资产 API、OSS adapter、facade 和 event table 尚未全链收口 | 第 4 节仍标记 `WP-AS` 进行中;`module-assets/README.md` 写明尚未进入完整资产状态建模;`module-assets/src/events.rs` 仍命中 `过渡落位` | 将 asset object、binding、manifest、history、OSS head/upload 和确认状态统一成领域应用结果SpacetimeDB 与 API 只做 adapter | P1依赖 `WP-PF` OSS 错误模型,反向支撑 `WP-CW/WP-PZ/WP-BF` | | `WP-PZ Puzzle` | Puzzle API、前端消费和运行态全链仍未完成DDD 注释漂移已清理 | 第 4 节仍标记 `WP-PZ` 进行中;`module-puzzle/README.md` 写明当前只固定 contract 与最小规则;`domain.rs``events.rs` 文件头已改为当前领域口径;源码不再命中 `过渡落位``cargo test -p module-puzzle` 通过 | 补齐 agent session、work profile、runtime run、排行榜、图片选择和结果页草稿的领域应用服务再接 `WP-ST/WP-SC/WP-API/WP-FE` | P1`WP-CW` 并行时避免共改创作 agent 通用文档 |
| `WP-AS Assets` | 资产对象类型、领域测试和 row mapper 已完成,资产 API、OSS adapter、facade 和 event table 尚未全链收口DDD 注释漂移已清理 | 第 4 节仍标记 `WP-AS` 进行中;`module-assets/README.md` 写明尚未进入完整资产状态建模;`module-assets/src/events.rs` 文件头已改为当前领域口径;源码不再命中 `过渡落位``cargo test -p module-assets` 通过 | 将 asset object、binding、manifest、history、OSS head/upload 和确认状态统一成领域应用结果SpacetimeDB 与 API 只做 adapter | P1依赖 `WP-PF` OSS 错误模型,反向支撑 `WP-CW/WP-PZ/WP-BF` |
| `WP-API api-server BFF` | LLM 流式代理、微信登录、手机号登录、角色动画资产仍有首版禁用或占位链 | `api-server/src/llm.rs` 流式请求返回 `501``wechat_auth.rs` 返回“微信登录暂未启用”;`phone_auth.rs``password_management.rs` 返回“手机号登录暂未启用”;`character_animation_assets.rs` 仍写 Stage 1 序列帧和视频占位链 | API 层只做 BFF 编排、鉴权、SSE 和 DTO 映射;真实副作用落 `platform-llm/platform-auth/platform-oss`,领域状态变化必须回写 SpacetimeDB facade | P1依赖 `WP-PF` 能力与 `WP-ST/WP-SC` 写入 facade | | `WP-API api-server BFF` | LLM 流式代理、微信登录、手机号登录、角色动画资产仍有首版禁用或占位链 | `api-server/src/llm.rs` 流式请求返回 `501``wechat_auth.rs` 返回“微信登录暂未启用”;`phone_auth.rs``password_management.rs` 返回“手机号登录暂未启用”;`character_animation_assets.rs` 仍写 Stage 1 序列帧和视频占位链 | API 层只做 BFF 编排、鉴权、SSE 和 DTO 映射;真实副作用落 `platform-llm/platform-auth/platform-oss`,领域状态变化必须回写 SpacetimeDB facade | P1依赖 `WP-PF` 能力与 `WP-ST/WP-SC` 写入 facade |
| `WP-FE-S/WP-FE-H/WP-FE-C` | 前端读取侧已推进,完整开局和动作写侧仍等后端新接口;不能继续补旧 compat client | 文档记录 `beginRuntimeStorySession``resolveRuntimeStoryAction` 暂未迁移;旧 `src/services/rpg-runtime/rpgRuntimeStoryClient.ts` 写侧和测试仍等待新接口 | `WP-FE-S` 先换 API client`WP-FE-H` 再换 hooks`WP-FE-C` 最后接组件;前端只做表现和临时 UI 状态,不重建规则 | P0强依赖 `WP-RS/WP-ST/WP-SC/WP-API` | | `WP-FE-S/WP-FE-H/WP-FE-C` | 前端读取侧已推进,完整开局和动作写侧仍等后端新接口;不能继续补旧 compat client | 文档记录 `beginRuntimeStorySession``resolveRuntimeStoryAction` 暂未迁移;旧 `src/services/rpg-runtime/rpgRuntimeStoryClient.ts` 写侧和测试仍等待新接口 | `WP-FE-S` 先换 API client`WP-FE-H` 再换 hooks`WP-FE-C` 最后接组件;前端只做表现和临时 UI 状态,不重建规则 | P0强依赖 `WP-RS/WP-ST/WP-SC/WP-API` |
| `WP-ST SpacetimeDB Adapter` | 多个上下文已根入口瘦身,但剩余 table/reducer/procedure、migration、表目录和绑定生成仍未整体闭环 | 第 4 节仍标记 `WP-ST` 进行中Custom World 真实动作、RPG 写接口、Puzzle/Assets 完整链都需要继续接 `spacetime-module` | 所有 SpacetimeDB schema 变更由 `WP-ST` 单 owner 合流,并同步 `migration.rs`、表目录和生成绑定;禁止 API 直连生成绑定绕过 `spacetime-client` | P0`WP-SC/WP-API/WP-FE/DEL` 的主依赖 | | `WP-ST SpacetimeDB Adapter` | 多个上下文已根入口瘦身,但剩余 table/reducer/procedure、migration、表目录和绑定生成仍未整体闭环 | 第 4 节仍标记 `WP-ST` 进行中Custom World 真实动作、RPG 写接口、Puzzle/Assets 完整链都需要继续接 `spacetime-module` | 所有 SpacetimeDB schema 变更由 `WP-ST` 单 owner 合流,并同步 `migration.rs`、表目录和生成绑定;禁止 API 直连生成绑定绕过 `spacetime-client` | P0`WP-SC/WP-API/WP-FE/DEL` 的主依赖 |
| `WP-SC Spacetime Client` | 已接 runtime projection inventory source但后续 typed facade、row mapper 和错误映射仍随 `WP-ST` 滚动补齐 | 第 4 节仍标记 `WP-SC` 进行中;文档记录“后续仅随 WP-ST 新 facade 稳定后继续 typed facade/row mapper” | 只在 reducer/procedure shape 稳定后补 typed facade不在 client crate 内发明领域规则或预判 row shape | P0跟随 `WP-ST` 分批执行 | | `WP-SC Spacetime Client` | 已接 runtime projection inventory source但后续 typed facade、row mapper 和错误映射仍随 `WP-ST` 滚动补齐 | 第 4 节仍标记 `WP-SC` 进行中;文档记录“后续仅随 WP-ST 新 facade 稳定后继续 typed facade/row mapper” | 只在 reducer/procedure shape 稳定后补 typed facade不在 client crate 内发明领域规则或预判 row shape | P0跟随 `WP-ST` 分批执行 |
| `WP-DEL 删除旧层与命名收口` | 旧 compat、旧 contract、旧 facade、旧测试仍不能删除 | 第 4 节标记暂不可执行;`RuntimeStoryActionResponse`、旧 runtime story contract、旧前端写 client 等仍等待新写接口和前端迁移完成 | 所有新接口和前端迁移完成后再统一物理删除;删除前必须搜索运行代码无旧层引用 | P0依赖 `WP-FE-S/WP-FE-H/WP-FE-C` 完成 | | `WP-DEL 删除旧层与命名收口` | 旧 compat、旧 contract、旧 facade、旧测试仍不能删除 | 第 4 节标记暂不可执行;`RuntimeStoryActionResponse`、旧 runtime story contract、旧前端写 client 等仍等待新写接口和前端迁移完成 | 所有新接口和前端迁移完成后再统一物理删除;删除前必须搜索运行代码无旧层引用 | P0依赖 `WP-FE-S/WP-FE-H/WP-FE-C` 完成 |
| `WP-V 全链验证与发布 smoke` | 全链验证仍被旧层删除和 Maincloud smoke 阻塞 | 第 4 节标记暂不可执行;此前 `api-server:maincloud` 多次以常驻服务超时或端口占用形式结束,需要在最终阶段统一验证 | 串行执行 cargo、npm、encoding、DDD boundary、SpacetimeDB build/generate 和 Maincloud smoke只记录非本轮阻塞不新增功能 | P2必须在 `WP-DEL` 后 | | `WP-V 全链验证与发布 smoke` | 全链验证仍被旧层删除和 Maincloud smoke 阻塞 | 第 4 节标记暂不可执行;此前 `api-server:maincloud` 多次以常驻服务超时或端口占用形式结束,需要在最终阶段统一验证 | 串行执行 cargo、npm、encoding、DDD boundary、SpacetimeDB build/generate 和 Maincloud smoke只记录非本轮阻塞不新增功能 | P2必须在 `WP-DEL` 后 |
| `G2/跨包清理` | `过渡落位` 注释横跨多个已完成或进行中 crate说明 DDD 物理拆分仍有收尾清理 | 搜索命中 `module-big-fish` 5 处、`module-runtime` 4 处`module-runtime-story` 4 处,以及 RPG、Custom World、Puzzle、Assets 多处 | 对进行中包,只有真实规则迁完后才能改注释;对已完成包如 `WP-BF/WP-RT`,若只剩注释口径,归入 `WP-DEL/WP-V` 前清理,不重开主功能包 | P2不阻塞 P0 功能链,但阻塞最终“无迁移期口径”验收 | | `G2/跨包清理` | `过渡落位` 迁移期口径已清零;后续只保留旧 compat/API/FE 链路的真实依赖项 | 搜索 `server-rs/crates` 不再命中 `过渡落位``module-runtime``module-puzzle``module-assets` 文件头已更新Big Fish 物理拆分漂移已关闭 | 后续 `WP-DEL/WP-V` 只关注旧 compat、旧 contract、旧 facade、旧测试和全链 smoke不再把已清理的注释漂移重复认领 | 已关闭 P2 清理项;最终验收继续依赖 `WP-DEL/WP-V` |
| `tests-support` | 仍只是目录占位,不是 workspace crate | `server-rs/crates/tests-support/README.md` 写明“当前提交仅完成目录占位”;目录下无 `Cargo.toml` 和 Rust 源文件 | 到 `WP-V` 前若需要共享测试夹具,则补真实 crate如果最终无使用场景改成明确文档占位或删除 | P2依赖最终测试策略 | | `tests-support` | 已从目录占位收口为 workspace 共享测试支撑 crate | `server-rs/Cargo.toml` 已纳入 `crates/tests-support``server-rs/crates/tests-support/src/lib.rs` 提供 Maincloud healthz 默认地址、smoke URL 归一化、HTTP 2xx 断言和 healthz 非空响应体断言;`cargo test -p tests-support` 通过 | 已关闭“只有 README 的悬空占位”;后续 contract/reducer/view/projection 夹具只在真实测试策略稳定后继续扩展 | 已关闭 P2 占位项;后续随 `WP-V` 复用和扩展 |
## 5. 工作包边界细则 ## 5. 工作包边界细则
@@ -2320,3 +2321,171 @@ cargo check -p module-story --manifest-path server-rs\Cargo.toml
``` ```
结果:通过,`module-story` 8 个单元测试通过。 结果:通过,`module-story` 8 个单元测试通过。
### 2026-04-30 WP-RPG module-progression 领域拆分收口切片
已完成:
1. 新增 `SERVER_RS_DDD_WP_RPG_PROGRESSION_DOMAIN_SPLIT_2026-04-30.md`,记录本次 `module-progression` 收口边界。
2. `module-progression/src/domain.rs` 收口成长等级常量、玩家成长快照、章节成长快照、章节节奏、实体定级角色和定级来源。
3. `module-progression/src/commands.rs` 收口玩家成长查询/授予经验、章节预算、章节账本和章节自动定级输入。
4. `module-progression/src/application.rs` 收口经验曲线、等级解析、玩家成长快照构造、章节预算、章节账本、章节自动定级、敌对生命值和经验奖励规则。
5. `module-progression/src/events.rs` 收口玩家经验授予、章节账本应用和章节自动定级解析领域事件。
6. `module-progression/src/errors.rs` 收口 `ProgressionFieldError` 与中文错误文案。
7. `module-progression/src/lib.rs` 收口为模块声明和公开导出,继续保持 `module_progression::*` 公开 API。
8. `module-progression/README.md` 已改为当前真实边界,明确 DDD 物理拆分已经收口。
边界说明:
1. 本次不改 SpacetimeDB 表、reducer、procedure、绑定 shape 或 `migration.rs`
2. 本次不把 `custom-world` 章节蓝图编译、`repeatPenalty`、超预算衰减和完整章节偏差审计提前迁入。
3. 完整成长闭环、quest/combat/npc 联动事件和应用结果测试继续等待 `WP-CW/WP-RPG/WP-ST/WP-SC/WP-API`
验证:
```powershell
cargo fmt -p module-progression --manifest-path server-rs\Cargo.toml --check
cargo test -p module-progression --manifest-path server-rs\Cargo.toml
cargo check -p module-progression --manifest-path server-rs\Cargo.toml
```
结果:通过,`module-progression` 7 个单元测试通过。
### 2026-04-30 WP-RPG Gameplay 子域领域拆分收口切片
已完成:
1. 新增 `SERVER_RS_DDD_WP_RPG_GAMEPLAY_DOMAIN_SPLIT_2026-04-30.md`,记录本次 RPG 子域统一收口边界。
2. `module-combat` 将战斗输入、战斗快照、行动结算、错误和战斗领域事件拆入 `commands/domain/application/errors/events`
3. `module-inventory` 将背包槽、物品快照、背包 mutation 输入、状态投影、应用规则、错误和背包领域事件拆入 DDD 文件。
4. `module-npc` 将 NPC 状态、关系、立场、互动输入、互动结算、错误和 NPC 领域事件拆入 DDD 文件。
5. `module-quest` 将任务模型、任务命令、任务状态流转、错误和任务领域事件拆入 DDD 文件。
6. `module-runtime-item` 将宝箱奖励模型、宝箱结算输入、奖励到背包映射、错误和运行时物品领域事件拆入 DDD 文件。
7. 上述 crate 的 `lib.rs` 均收口为模块声明、公开导出和原有测试,保持 `module_*::*` 公开 API。
边界说明:
1. 本次不改 SpacetimeDB 表、reducer、procedure、绑定 shape 或 `migration.rs`
2. 本次不新增完整 story action 写接口,不接 HTTP route、前端 hooks 或组件。
3. `inventory_use`、完整掉落、好感、任务信号、story AI 续写、多目标战斗、完整 build/cooldown 真相建模继续等待后续主链。
验证:
```powershell
cargo test -p module-combat -p module-inventory -p module-npc -p module-progression -p module-quest -p module-runtime-item --manifest-path server-rs\Cargo.toml
cargo check -p module-combat -p module-inventory -p module-npc -p module-progression -p module-quest -p module-runtime-item --manifest-path server-rs\Cargo.toml
```
结果通过RPG 六个子域共 39 个单元测试通过;六个子域源码不再命中 `过渡落位`
### 2026-04-30 WP-RS module-runtime-story 领域拆分收口切片
已完成:
1. 新增 `SERVER_RS_DDD_WP_RS_RUNTIME_STORY_DOMAIN_SPLIT_2026-04-30.md`,记录本次 `module-runtime-story` 顶层收口边界。
2. `module-runtime-story/src/domain.rs` 收口 runtime story 顶层常量、action 结算结果、生成故事 payload、NPC 任务上下文和待接任务上下文。
3. `module-runtime-story/src/commands.rs` 收口 `resolve_action_text`
4. `module-runtime-story/src/application.rs` 收口 `RuntimeStoryActionResponseParts``simple_story_resolution``build_status_patch``current_world_type`
5. `module-runtime-story/src/errors.rs` 补入 `RuntimeStoryRuleError`
6. `module-runtime-story/src/events.rs` 补入 `RuntimeStoryDomainEvent`
7. `module-runtime-story/src/lib.rs` 收口为模块声明、公开导出和既有子模块 re-export保持 `module_runtime_story::*` 公开 API。
边界说明:
1. 本次不迁移旧 `/api/runtime/story/*` 写侧接口,不新增 session scoped 写 route。
2. 本次不改 SpacetimeDB 表、reducer、procedure、绑定 shape 或 `migration.rs`
3. `RuntimeStoryActionResponse`、旧前端写 client 和旧 contract 删除仍等待后续 `WP-FE-S/WP-FE-H/WP-FE-C/WP-DEL`
验证:
```powershell
cargo fmt -p module-runtime-story --manifest-path server-rs\Cargo.toml --check
cargo test -p module-runtime-story --manifest-path server-rs\Cargo.toml
cargo check -p module-runtime-story --manifest-path server-rs\Cargo.toml
```
结果:通过,`module-runtime-story` 8 个单元测试通过;源码不再命中 `过渡落位`
### 2026-04-30 WP-CW Custom World 动作与领域拆分收口切片
已完成:
1. 新增 `SERVER_RS_DDD_WP_CW_ACTION_AND_DOMAIN_SPLIT_2026-04-30.md`,记录本次 Custom World 收口边界。
2. `module-custom-world/src/domain.rs` 收口基础枚举、进度常量、profile/session/card/gallery/publish gate 快照与结果类型。
3. `module-custom-world/src/commands.rs` 收口 profile、library/gallery、Agent session/message/operation/action、published profile compile 和 publish world 输入 DTO。
4. `module-custom-world/src/application.rs` 收口字段校验、默认 JSON、profile canonicalize、published profile compile 和 publish gate 相关纯规则。
5. `module-custom-world/src/errors.rs` 收口 `CustomWorldFieldError` 与中文错误文案。
6. `module-custom-world/src/events.rs` 收口 Custom World 领域事件与 payload struct避免 `spacetime-types` feature 下结构体式 enum 变体编译失败。
7. `module-custom-world/src/lib.rs` 收口为模块声明、公开导出和测试,继续保持 `module_custom_world::*` 公开 API。
8. `spacetime-module/src/custom_world/mod.rs` 移除 `execute_placeholder_custom_world_action`,将 `generate_characters``generate_landmarks``generate_role_assets``sync_role_assets``generate_scene_assets``sync_scene_assets``expand_long_tail` 改为确定性状态编排。
9. 更新 `module-custom-world/README.md``docs/technical/README.md`
边界说明:
1. 本次不改 SpacetimeDB 表结构、procedure 签名、绑定 shape 或 `migration.rs`
2. 本次不把 LLM、图片生成、OSS 上传或外部网络副作用塞进 reducer/procedure。
3. Custom World API/前端全链和资产对象确认继续等待 `WP-ST/WP-SC/WP-API/WP-AS/WP-FE`
验证:
```powershell
cargo test -p module-custom-world --manifest-path server-rs\Cargo.toml
cargo check -p spacetime-module --manifest-path server-rs\Cargo.toml
```
结果:通过,`module-custom-world` 13 个单元测试通过;`spacetime-module` 编译通过Custom World 源码不再命中 `execute_placeholder_custom_world_action``最小兼容占位``过渡落位`
### 2026-04-30 WP-BF 与 G2 迁移期口径清理切片
已完成:
1. 新增 `SERVER_RS_DDD_WP_BF_AND_G2_DRIFT_CLEANUP_2026-04-30.md`,记录本次 WP-BF 和 G2 清理边界。
2. `module-big-fish/src/domain.rs` 收口创作阶段、锚点、资产槽、草稿、会话、作品摘要、发布门禁和运行态领域类型。
3. `module-big-fish/src/commands.rs` 收口会话、消息、草稿、资产、发布、游玩记录和运行态输入 DTO。
4. `module-big-fish/src/application.rs` 收口锚点推断、默认草稿编译、资产覆盖、资产槽构造、字段校验、序列化与运行态真相源规则。
5. `module-big-fish/src/errors.rs` 收口应用错误、字段错误和中文错误文案。
6. `module-big-fish/src/lib.rs` 收口为模块声明、公开导出和测试,继续保持 `module_big_fish::*` 公开 API。
7. 新增 `module-big-fish/README.md`
8. 清理 `module-runtime``module-puzzle``module-assets` 的文件头迁移期口径。
边界说明:
1. 本次不改 Big Fish SpacetimeDB 表、procedure、API route、前端 client 或绑定 shape。
2. 本次不为 Assets 新增未消费 event API。
3. Puzzle/API/前端未完成链路仍按原工作包继续推进,不因注释漂移清理而误标完成。
验证:
```powershell
cargo test -p module-runtime -p module-puzzle -p module-assets -p module-big-fish --manifest-path server-rs\Cargo.toml
cargo fmt -p module-runtime -p module-puzzle -p module-assets -p module-big-fish --manifest-path server-rs\Cargo.toml --check
```
结果:通过;`server-rs/crates` 不再命中 `过渡落位`
### 2026-04-30 tests-support 共享测试支撑 crate 收口切片
已完成:
1. 新增 `SERVER_RS_DDD_TESTS_SUPPORT_CRATE_CLOSURE_2026-04-30.md`,记录 `tests-support` 从目录占位收口为真实 workspace crate 的边界。
2. `server-rs/Cargo.toml` 已将 `crates/tests-support` 纳入 workspace member。
3. 新增 `tests-support``Cargo.toml``src/lib.rs`,首版提供 Maincloud healthz 默认地址、smoke URL 归一化、HTTP 2xx 断言和 healthz 非空响应体断言。
4. 更新 `server-rs/crates/tests-support/README.md``server-rs/README.md``docs/technical/README.md` 与本清单第 4.1 节,避免后续继续把该目录当作悬空占位重复认领。
边界说明:
1. 本次不引入业务规则、不创建伪领域 fixture。
2. 本次不修改 SpacetimeDB 表、reducer、procedure、绑定或 `migration.rs`
3. Contract DTO、reducer/view/projection 共享夹具仍等待对应接口与最终 smoke 策略稳定后继续扩展。
验证:
```powershell
cargo fmt -p tests-support --manifest-path server-rs\Cargo.toml --check
cargo test -p tests-support --manifest-path server-rs\Cargo.toml
cargo check -p tests-support --manifest-path server-rs\Cargo.toml
npm.cmd run check:encoding -- docs/technical/README.md docs/technical/SERVER_RS_DDD_PARALLEL_TASKLIST_2026-04-29.md docs/technical/SERVER_RS_DDD_TESTS_SUPPORT_CRATE_CLOSURE_2026-04-30.md server-rs/crates/tests-support/README.md server-rs/crates/tests-support/src/lib.rs server-rs/Cargo.toml
npm.cmd run check:server-rs-ddd
```
结果:通过;`tests-support` 4 个单元测试通过crate 编译通过,编码检查通过 6 个文件DDD 边界检查继续通过 15 个 module crate。

View File

@@ -0,0 +1,30 @@
# server-rs DDD tests-support 共享测试支撑 crate 收口记录
日期:`2026-04-30`
## 1. 收口目标
本次关闭 `4.1 未完整收口内容整合清单``tests-support` 仍只是目录占位的问题,将其补成 `server-rs` workspace 中的真实共享测试支撑 crate。
## 2. 已完成
1. `server-rs/Cargo.toml` 已把 `crates/tests-support` 纳入 workspace member。
2. 新增 `server-rs/crates/tests-support/Cargo.toml`
3. 新增 `server-rs/crates/tests-support/src/lib.rs`,提供 Maincloud healthz 默认地址、smoke URL 归一化、HTTP 2xx 断言和 healthz 非空响应体断言。
4. 更新 `server-rs/crates/tests-support/README.md`,明确当前首版边界和后续可扩展方向。
5. 更新 `SERVER_RS_DDD_PARALLEL_TASKLIST_2026-04-29.md`,将 `tests-support` 从目录占位改为已收口的真实测试支撑 crate。
## 3. 边界说明
1. 本次不引入业务规则、不创建伪领域 fixture。
2. 本次不修改 SpacetimeDB 表、reducer、procedure、绑定或 `migration.rs`
3. Contract DTO、reducer/view/projection 共享夹具仍等待对应接口与最终 smoke 策略稳定后继续扩展。
## 4. 验证
```powershell
cargo test -p tests-support --manifest-path server-rs\Cargo.toml
cargo check -p tests-support --manifest-path server-rs\Cargo.toml
```
结果:通过,`tests-support` 4 个单元测试通过crate 编译通过。

View File

@@ -0,0 +1,34 @@
# WP-BF 与 G2 迁移期口径清理
日期:`2026-04-30`
## 1. 本次目标
本次收口 `4.1 未完整收口内容整合清单``G2/跨包清理``过渡落位` 的残留命中,重点处理:
1. `module-big-fish` 已标记 `WP-BF` 完成,但 `lib.rs` 仍承载大量创作域类型、命令、校验和序列化规则。
2. `module-runtime``module-puzzle``module-assets` 只剩文件头迁移期口径,与当前真实边界不一致。
## 2. 已完成
1. `module-big-fish/src/domain.rs` 承接创作阶段、锚点、资产槽、草稿、会话、作品摘要、发布门禁和运行态领域类型。
2. `module-big-fish/src/commands.rs` 承接会话、消息、草稿、资产、发布、游玩记录和运行态输入 DTO。
3. `module-big-fish/src/application.rs` 承接锚点推断、默认草稿编译、资产覆盖、资产槽构造、字段校验、序列化与运行态真相源规则。
4. `module-big-fish/src/errors.rs` 承接应用错误、字段错误和中文错误文案。
5. `module-big-fish/src/lib.rs` 收口为模块声明、公开导出和测试,继续保持 `module_big_fish::*` 公开 API。
6. `module-runtime``module-puzzle``module-assets` 的文件头已从迁移期“过渡落位”口径改为当前领域边界口径。
## 3. 边界说明
1. 本次不改 Big Fish SpacetimeDB 表、procedure、API route、前端 client 或绑定 shape。
2. 本次不为 Assets 新增未消费 event API`module-assets/src/events.rs` 只修正口径。
3. 本次不把 Puzzle/API/前端未完成链路误标为完成,只清理 DDD 物理拆分和注释漂移。
## 4. 验证
```powershell
cargo test -p module-runtime -p module-puzzle -p module-assets -p module-big-fish --manifest-path server-rs\Cargo.toml
cargo fmt -p module-runtime -p module-puzzle -p module-assets -p module-big-fish --manifest-path server-rs\Cargo.toml --check
```
结果:通过;上述源码不再命中 `过渡落位`

View File

@@ -0,0 +1,43 @@
# WP-CW Custom World 动作与领域拆分收口
日期:`2026-04-30`
## 1. 本次目标
本次收口 `4.1 未完整收口内容整合清单``WP-CW Custom World` 的两个漂移点:
1. `module-custom-world` 仍由 `lib.rs` 承载主要领域类型、命令、错误和应用规则。
2. `spacetime-module/src/custom_world/mod.rs` 中多个 Agent action 仍走最小兼容占位。
## 2. 已完成
1. `module-custom-world/src/lib.rs` 已收口为模块声明、公开导出和测试,继续保持 `module_custom_world::*` 公开 API。
2. `src/domain.rs` 承接 Custom World / RPG Agent 枚举、进度常量、profile/session/card/gallery/publish gate 快照与结果类型。
3. `src/commands.rs` 承接 profile、library/gallery、Agent session/message/operation/action、published profile compile 和 publish world 输入 DTO。
4. `src/application.rs` 承接字段校验、默认 JSON、profile canonicalize、published profile compile 和 publish gate 相关纯规则。
5. `src/errors.rs` 承接 `CustomWorldFieldError` 与中文错误文案。
6. `src/events.rs` 承接 Custom World 领域事件与 payload struct避免 `spacetime-types` feature 下使用结构体式 enum 变体。
7. `spacetime-module/src/custom_world/mod.rs` 已移除 `execute_placeholder_custom_world_action`,以下动作改为确定性状态编排:
- `generate_characters`
- `generate_landmarks`
- `generate_role_assets`
- `sync_role_assets`
- `generate_scene_assets`
- `sync_scene_assets`
- `expand_long_tail`
## 3. 边界说明
1. 本次不引入 LLM、图片生成、OSS 上传或外部网络副作用。
2. SpacetimeDB procedure/reducer 内只组合当前会话状态、payload、draft card、asset coverage 和 publish gate保持确定性。
3. 本次不改表结构、绑定 shape 或 `migration.rs`
4. 完整 profile/agent/draft/gallery/publish gate 全链仍按 `WP-CW/WP-ST/WP-SC/WP-API/WP-FE` 后续推进。
## 4. 验证
```powershell
cargo test -p module-custom-world --manifest-path server-rs\Cargo.toml
cargo check -p spacetime-module --manifest-path server-rs\Cargo.toml
```
结果:通过,`module-custom-world` 13 个单元测试通过,`spacetime-module` 编译通过Custom World 源码不再命中 `execute_placeholder_custom_world_action``最小兼容占位``过渡落位`

View File

@@ -0,0 +1,44 @@
# WP-RPG Gameplay 子域领域拆分收口2026-04-30
## 1. 收口目标
本切片关闭 `SERVER_RS_DDD_PARALLEL_TASKLIST_2026-04-29.md` 中 RPG 子域的 DDD 物理拆分漂移:
1. `module-combat``module-inventory``module-npc``module-quest``module-runtime-item` 的真实规则仍主要集中在 `lib.rs`
2. 上述 crate 的 `domain / commands / application / events / errors` 文件仍停留在“过渡落位”口径。
3. `module-progression` 已在前一切片完成物理拆分,本切片把 RPG 子域剩余同类壳层一起收口。
本次只做纯领域文件拆分、最小领域事件补位和文档对齐,不修改 SpacetimeDB 表结构、reducer/procedure 签名、绑定 shape、Axum route 或前端契约。
## 2. 已完成内容
1. `module-combat` 将战斗输入、战斗快照、行动结算、错误和战斗领域事件拆入对应 DDD 文件,`lib.rs` 只保留公开导出和测试。
2. `module-inventory` 将背包槽、物品快照、背包 mutation 输入、状态投影、应用规则、错误和背包领域事件拆入对应 DDD 文件。
3. `module-npc` 将 NPC 状态、关系、立场、互动输入、互动结算、错误和 NPC 领域事件拆入对应 DDD 文件。
4. `module-quest` 将任务模型、任务命令、任务状态流转、错误和任务领域事件拆入对应 DDD 文件。
5. `module-runtime-item` 将宝箱奖励模型、宝箱结算输入、奖励到背包映射、错误和运行时物品领域事件拆入对应 DDD 文件。
6. 五个 crate 的 `lib.rs` 均收口为 `mod` 声明、`pub use` 和原有测试,继续保持现有 `module_*::*` 公开 API。
7. RPG 六个子域源码已不再命中 `过渡落位`
## 3. 边界
1. 本切片不新增 `inventory_use`、完整掉落、好感、任务信号、story AI 续写、多目标战斗或完整 build/cooldown 真相建模。
2. 本切片不把任务货币、好感、情报统一发放提前塞进 `module-quest`,也不把背包落库塞进 `module-runtime-item`
3. 跨域副作用仍由 `spacetime-module` 事务 adapter、`spacetime-client` facade、`api-server` BFF 和前端主链分批接入。
4. 完整 story action 写侧、inventory action、NPC interaction、forge/battle/quest 组合结算继续跟随 `WP-RS/WP-ST/WP-SC/WP-API/WP-FE`
## 4. 验收
已执行:
```powershell
cargo fmt -p module-combat --manifest-path server-rs\Cargo.toml --check
cargo fmt -p module-inventory --manifest-path server-rs\Cargo.toml --check
cargo fmt -p module-npc --manifest-path server-rs\Cargo.toml --check
cargo fmt -p module-quest --manifest-path server-rs\Cargo.toml --check
cargo fmt -p module-runtime-item --manifest-path server-rs\Cargo.toml --check
cargo test -p module-combat -p module-inventory -p module-npc -p module-progression -p module-quest -p module-runtime-item --manifest-path server-rs\Cargo.toml
cargo check -p module-combat -p module-inventory -p module-npc -p module-progression -p module-quest -p module-runtime-item --manifest-path server-rs\Cargo.toml
```
结果通过。RPG 六个子域共 39 个单元测试通过。

View File

@@ -0,0 +1,39 @@
# WP-RPG module-progression 领域拆分收口2026-04-30
## 1. 收口目标
本切片关闭 `SERVER_RS_DDD_PARALLEL_TASKLIST_2026-04-29.md``module-progression` 的 DDD 物理拆分漂移:
1. `domain / commands / application / events / errors` 文件仍停留在“过渡落位”口径。
2. 玩家等级、章节预算、章节账本、章节自动定级和敌对奖励规则集中在 `lib.rs`
本次只做纯领域分层和文档对齐,不修改 SpacetimeDB 表结构,不触碰 `migration.rs`,不新增 HTTP、LLM、OSS 或前端接线。
## 2. 已完成内容
1. `src/domain.rs` 收口等级常量、玩家成长快照、章节成长快照、章节节奏、实体定级角色和定级来源。
2. `src/commands.rs` 收口玩家成长查询/授予经验、章节预算、章节账本和章节自动定级输入。
3. `src/application.rs` 收口经验曲线、等级解析、玩家成长快照构造、章节预算、章节账本、章节自动定级、敌对生命值和经验奖励规则。
4. `src/events.rs` 收口玩家经验授予、章节账本应用和章节自动定级解析领域事件。
5. `src/errors.rs` 收口 `ProgressionFieldError` 与中文错误文案。
6. `src/lib.rs` 收口为模块声明和公开导出,继续保持 `module_progression::*` 公开 API。
7. `module-progression/README.md` 更新为当前真实边界,明确 DDD 物理拆分已经收口。
## 3. 边界
1. 本切片不改变 `player_progression``chapter_progression` 的 SpacetimeDB row shape、reducer/procedure 签名或绑定生成结果。
2. 本切片不把 `custom-world` 章节蓝图编译、`repeatPenalty`、超预算衰减和完整章节偏差审计提前迁入。
3. 任务、战斗、NPC 和章节成长联动继续通过领域事件与 `spacetime-module` adapter 编排,不让单个 RPG 子域互相直连。
4. 后续完整成长闭环仍随 `WP-CW/WP-RPG/WP-ST/WP-SC/WP-API` 分批推进。
## 4. 验收
已执行:
```powershell
cargo fmt -p module-progression --manifest-path server-rs\Cargo.toml --check
cargo test -p module-progression --manifest-path server-rs\Cargo.toml
cargo check -p module-progression --manifest-path server-rs\Cargo.toml
```
结果:通过,`module-progression` 7 个单元测试通过。

View File

@@ -0,0 +1,38 @@
# WP-RS module-runtime-story 领域拆分收口2026-04-30
## 1. 收口目标
本切片关闭 `module-runtime-story` 顶层 DDD 文件仍停留在“过渡落位”的漂移:
1. 顶层 `StoryResolution``RuntimeStoryActionResponseParts`、NPC 任务上下文、常量和 helper 仍集中在 `lib.rs`
2. `domain / commands / application / events / errors` 文件没有承载真实类型或规则。
本次只做顶层纯规则拆分和文档对齐,不迁移旧 `/api/runtime/story/*` 写侧接口,不修改 SpacetimeDB 表结构、reducer/procedure、BFF route 或前端 client。
## 2. 已完成内容
1. `src/domain.rs` 收口 runtime story 顶层常量、`StoryResolution`、生成故事 payload、当前 NPC 任务上下文和待接任务上下文。
2. `src/commands.rs` 收口 `resolve_action_text`,固定从 action payload 读取展示文本的写入命令口径。
3. `src/application.rs` 收口 `RuntimeStoryActionResponseParts``simple_story_resolution``build_status_patch``current_world_type`
4. `src/errors.rs` 补入 `RuntimeStoryRuleError`,用于表达运行时剧情纯规则错误。
5. `src/events.rs` 补入 `RuntimeStoryDomainEvent`,用于表达快照变化、战斗表现变化和跨域同步待处理事实。
6. `src/lib.rs` 收口为模块声明、公开导出和既有子模块 re-export继续保持现有 `module_runtime_story::*` 公开 API。
## 3. 边界
1. 本切片不改 battle、forge、NPC、quest、presentation 等大模块内部逻辑。
2. 本切片不恢复旧 `/api/runtime/story/*`,也不新增 session scoped 写 route。
3. `RuntimeStoryActionResponse`、旧前端写 client 和旧 contract 删除仍等待后续 `WP-FE-S/WP-FE-H/WP-FE-C/WP-DEL`
4. 完整 story action 写侧、inventory action、NPC interaction、forge/battle/quest 组合结算仍需跟随 `WP-ST/WP-SC/WP-API` 接入。
## 4. 验收
已执行:
```powershell
cargo fmt -p module-runtime-story --manifest-path server-rs\Cargo.toml --check
cargo test -p module-runtime-story --manifest-path server-rs\Cargo.toml
cargo check -p module-runtime-story --manifest-path server-rs\Cargo.toml
```
结果:通过,`module-runtime-story` 8 个单元测试通过。

4
server-rs/Cargo.lock generated
View File

@@ -3066,6 +3066,10 @@ dependencies = [
"winapi-util", "winapi-util",
] ]
[[package]]
name = "tests-support"
version = "0.1.0"
[[package]] [[package]]
name = "thiserror" name = "thiserror"
version = "1.0.69" version = "1.0.69"

View File

@@ -31,6 +31,7 @@ members = [
"crates/shared-logging", "crates/shared-logging",
"crates/spacetime-client", "crates/spacetime-client",
"crates/spacetime-module", "crates/spacetime-module",
"crates/tests-support",
] ]
[workspace.package] [workspace.package]

View File

@@ -40,7 +40,7 @@
22. 创建 `crates/platform-oss/` 目录占位,固定 OSS 平台适配 crate 落位。 22. 创建 `crates/platform-oss/` 目录占位,固定 OSS 平台适配 crate 落位。
23. 创建 `crates/platform-llm/` 目录占位,固定大模型平台适配 crate 落位。 23. 创建 `crates/platform-llm/` 目录占位,固定大模型平台适配 crate 落位。
24. 创建 `crates/spacetime-client/` 目录占位,固定 SpacetimeDB 客户端适配 crate 落位。 24. 创建 `crates/spacetime-client/` 目录占位,固定 SpacetimeDB 客户端适配 crate 落位。
25. 创建 `crates/tests-support/` 目录占位,固定测试支撑共享 crate 落位。 25. 创建 `crates/tests-support/` 共享测试支撑 crate,固定 smoke/contract 测试辅助能力落位。
26. 创建 `scripts/dev.ps1`,固定 Windows 本地开发入口。 26. 创建 `scripts/dev.ps1`,固定 Windows 本地开发入口。
27. 创建 `scripts/dev.sh`,固定 Unix-like 本地开发入口。 27. 创建 `scripts/dev.sh`,固定 Unix-like 本地开发入口。
28. 创建 `scripts/test.ps1`,固定 Windows 本地测试入口。 28. 创建 `scripts/test.ps1`,固定 Windows 本地测试入口。

View File

@@ -227,7 +227,7 @@ impl AppState {
self.spacetime_client self.spacetime_client
.upsert_auth_store_snapshot(snapshot_json, updated_at_micros) .upsert_auth_store_snapshot(snapshot_json, updated_at_micros)
.await?; .await?;
// ????????????????????????????????? // 写入 SpacetimeDB 后立刻回读一次,确保内存快照与表真相对齐。
#[cfg(not(test))] #[cfg(not(test))]
self.spacetime_client.import_auth_store_snapshot().await?; self.spacetime_client.import_auth_store_snapshot().await?;
#[cfg(not(test))] #[cfg(not(test))]
@@ -252,13 +252,13 @@ impl AppState {
if !snapshot_json.trim().is_empty() { if !snapshot_json.trim().is_empty() {
let auth_store = InMemoryAuthStore::from_snapshot_json(&snapshot_json) let auth_store = InMemoryAuthStore::from_snapshot_json(&snapshot_json)
.map_err(AppStateInitError::AuthStore)?; .map_err(AppStateInitError::AuthStore)?;
info!("?? SpacetimeDB ???????????"); info!("已从 SpacetimeDB 表恢复认证快照");
return Self::new_with_auth_store(config, auth_store); return Self::new_with_auth_store(config, auth_store);
} }
} }
} }
Err(error) => { Err(error) => {
warn!(error = %error, "? SpacetimeDB ????????????????"); warn!(error = %error, " SpacetimeDB 表恢复认证快照失败");
} }
} }
@@ -268,13 +268,13 @@ impl AppState {
if !snapshot_json.trim().is_empty() { if !snapshot_json.trim().is_empty() {
let auth_store = InMemoryAuthStore::from_snapshot_json(&snapshot_json) let auth_store = InMemoryAuthStore::from_snapshot_json(&snapshot_json)
.map_err(AppStateInitError::AuthStore)?; .map_err(AppStateInitError::AuthStore)?;
info!("?? SpacetimeDB ???????????"); info!("已从 SpacetimeDB 快照记录恢复认证快照");
return Self::new_with_auth_store(config, auth_store); return Self::new_with_auth_store(config, auth_store);
} }
} }
} }
Err(error) => { Err(error) => {
warn!(error = %error, "? SpacetimeDB ?????????????????"); warn!(error = %error, " SpacetimeDB 快照记录恢复认证快照失败");
} }
} }

View File

@@ -1,4 +1,4 @@
//! 资产领域事件过渡落位 //! 资产领域事件。
//! //!
//! 用于表达资产已确认、绑定已变更和资产历史投影待刷新等事实。 //! 用于表达资产已确认、绑定已变更和资产历史投影待刷新等事实。
//! 当前阶段暂不新增事件类型,避免在 SpacetimeDB 表未补 event table 前扩散未消费 API。 //! 当前阶段暂不新增事件类型,避免在 SpacetimeDB 表未补 event table 前扩散未消费 API。

View File

@@ -0,0 +1,34 @@
# module-big-fish 独立模块 package 说明
日期:`2026-04-30`
## 1. package 职责
`module-big-fish` 是大鱼吃小鱼创作与运行态规则模块 package负责
1. 创作会话、锚点包、草稿、资产槽和作品摘要的纯领域类型。
2. 草稿编译、资产覆盖、发布门禁、字段校验和序列化规则。
3. Big Fish 运行态一局的服务端真相源规则。
4.`spacetime-module``spacetime-client``api-server` 提供稳定领域边界。
## 2. 当前阶段说明
当前 DDD 物理拆分已经收口:
1. `src/domain.rs` 承接创作阶段、锚点、资产槽、草稿、会话、作品摘要、发布门禁和运行态领域类型。
2. `src/commands.rs` 承接会话、消息、草稿、资产、发布、游玩记录和运行态输入 DTO。
3. `src/application.rs` 承接锚点推断、默认草稿编译、资产覆盖、资产槽构造、字段校验、序列化与运行态真相源规则。
4. `src/errors.rs` 承接应用错误、字段错误和中文错误文案。
5. `src/events.rs` 承接发布门禁和运行态领域事件。
6. `src/lib.rs` 只保留模块声明、公开导出和测试,继续保持 `module_big_fish::*` 公开 API。
当前设计依据:
1. [../../../docs/technical/SERVER_RS_DDD_WP_BF_RUNTIME_BACKEND_TRUTH_2026-04-29.md](../../../docs/technical/SERVER_RS_DDD_WP_BF_RUNTIME_BACKEND_TRUTH_2026-04-29.md)
2. [../../../docs/technical/SERVER_RS_DDD_WP_BF_AND_G2_DRIFT_CLEANUP_2026-04-30.md](../../../docs/technical/SERVER_RS_DDD_WP_BF_AND_G2_DRIFT_CLEANUP_2026-04-30.md)
## 3. 边界约束
1. `module-big-fish` 不直接调用图片生成、OSS、HTTP、SSE 或 SpacetimeDB SDK。
2. 领域函数只处理纯规则和可序列化领域事实。
3. 表、procedure、route、前端 client 和绑定 shape 由外层 adapter 承接。

View File

@@ -1,20 +1,27 @@
//! 大鱼吃小鱼应用编排过渡落位 //! 大鱼吃小鱼应用编排。
//! //!
//! 这里只组合领域规则并返回结果或事件,不直接调用外部图片、视频或存储服务。 //! 这里只组合领域规则并返回结果或事件,不直接调用外部图片、视频或存储服务。
use shared_kernel::normalize_required_string; use shared_kernel::normalize_required_string;
use crate::{ use crate::{
BIG_FISH_DEFAULT_LEVEL_COUNT, BIG_FISH_TARGET_WILD_COUNT, BigFishAssetSlotSnapshot,
build_asset_coverage,
commands::{ commands::{
EvaluateBigFishPublishReadinessCommand, StartBigFishRunCommand, SubmitBigFishInputCommand, BigFishAssetGenerateInput, BigFishDraftCompileInput, BigFishInputSubmitInput,
BigFishMessageFinalizeInput, BigFishMessageSubmitInput, BigFishPlayRecordInput,
BigFishPublishInput, BigFishRunGetInput, BigFishRunStartInput, BigFishSessionCreateInput,
BigFishSessionGetInput, BigFishWorksListInput, EvaluateBigFishPublishReadinessCommand,
StartBigFishRunCommand, SubmitBigFishInputCommand,
}, },
domain::{ domain::{
BIG_FISH_ASSET_SLOT_ID_PREFIX, BIG_FISH_DEFAULT_LEVEL_COUNT,
BIG_FISH_MERGE_COUNT_PER_UPGRADE, BIG_FISH_OFFSCREEN_CULL_SECONDS,
BIG_FISH_TARGET_WILD_COUNT, BigFishAnchorItem, BigFishAnchorPack, BigFishAnchorStatus,
BigFishAssetCoverage, BigFishAssetKind, BigFishAssetSlotSnapshot, BigFishAssetStatus,
BigFishBackgroundBlueprint, BigFishGameDraft, BigFishLevelBlueprint,
BigFishPublishReadiness, BigFishRunStatus, BigFishRuntimeEntitySnapshot, BigFishPublishReadiness, BigFishRunStatus, BigFishRuntimeEntitySnapshot,
BigFishRuntimeSnapshot, BigFishVector2, BigFishRuntimeParams, BigFishRuntimeSnapshot, BigFishVector2,
}, },
errors::BigFishApplicationError, errors::{BigFishApplicationError, BigFishFieldError},
events::BigFishDomainEvent, events::BigFishDomainEvent,
}; };
@@ -578,6 +585,515 @@ fn settlement_events(
}] }]
} }
pub fn empty_anchor_pack() -> BigFishAnchorPack {
BigFishAnchorPack {
gameplay_promise: BigFishAnchorItem {
key: "gameplayPromise".to_string(),
label: "玩法承诺".to_string(),
value: String::new(),
status: BigFishAnchorStatus::Missing,
},
ecology_visual_theme: BigFishAnchorItem {
key: "ecologyVisualTheme".to_string(),
label: "生态与视觉母题".to_string(),
value: String::new(),
status: BigFishAnchorStatus::Missing,
},
growth_ladder: BigFishAnchorItem {
key: "growthLadder".to_string(),
label: "成长阶梯".to_string(),
value: String::new(),
status: BigFishAnchorStatus::Missing,
},
risk_tempo: BigFishAnchorItem {
key: "riskTempo".to_string(),
label: "风险节奏".to_string(),
value: "平衡".to_string(),
status: BigFishAnchorStatus::Inferred,
},
}
}
pub fn infer_anchor_pack(seed_text: &str, latest_message: Option<&str>) -> BigFishAnchorPack {
let source = normalize_required_string(latest_message.unwrap_or(seed_text))
.or_else(|| normalize_required_string(seed_text))
.unwrap_or_else(|| "深海弱小逆袭,逐级吞噬成长".to_string());
let mut pack = empty_anchor_pack();
pack.gameplay_promise.value = if source.contains("可爱") {
"可爱生态成长".to_string()
} else if source.contains("机械") {
"机械微生物吞并进化".to_string()
} else {
"弱小逆袭和群体吞并".to_string()
};
pack.gameplay_promise.status = BigFishAnchorStatus::Inferred;
pack.ecology_visual_theme.value = if source.contains("机械") {
"机械微生物水域".to_string()
} else if source.contains("") {
"梦境纸鱼生态".to_string()
} else {
"深海生物生态".to_string()
};
pack.ecology_visual_theme.status = BigFishAnchorStatus::Inferred;
pack.growth_ladder.value = "8 级连续进化,从幼小个体成长为终局巨兽".to_string();
pack.growth_ladder.status = BigFishAnchorStatus::Inferred;
pack.risk_tempo.value = if source.contains("") {
"偏爽快".to_string()
} else if source.contains("压迫") {
"偏压迫".to_string()
} else {
"平衡".to_string()
};
pack
}
pub fn compile_default_draft(anchor_pack: &BigFishAnchorPack) -> BigFishGameDraft {
let level_count = BIG_FISH_DEFAULT_LEVEL_COUNT;
let theme = fallback_anchor_value(&anchor_pack.ecology_visual_theme, "深海生物生态");
let core_fun = fallback_anchor_value(&anchor_pack.gameplay_promise, "弱小逆袭和群体吞并");
let risk_tempo = fallback_anchor_value(&anchor_pack.risk_tempo, "平衡");
let levels = (1..=level_count)
.map(|level| build_level_blueprint(level, level_count, &theme))
.collect();
BigFishGameDraft {
title: format!("{theme} 大鱼吃小鱼"),
subtitle: format!("{core_fun} · {risk_tempo}节奏"),
core_fun,
ecology_theme: theme.clone(),
levels,
background: BigFishBackgroundBlueprint {
theme: theme.clone(),
color_mood: "深蓝、青绿、带少量暖色生物光".to_string(),
foreground_hints: "只保留少量漂浮颗粒和边缘水草,不遮挡中央操作区".to_string(),
midground_composition: "中央留出大面积清晰活动区域,边缘只做出生缓冲层".to_string(),
background_depth: "简洁纵深水域与极少量远处剪影".to_string(),
safe_play_area_hint: "9:16 竖屏中央 80% 为主要活动区".to_string(),
spawn_edge_hint: "四周边缘以少量暗礁或水草提示野生实体出生区".to_string(),
background_prompt_seed: format!(
"{theme},竖屏 9:16全屏大场地游戏背景元素少中央开阔无文字无 UI 框"
),
},
runtime_params: BigFishRuntimeParams {
level_count,
merge_count_per_upgrade: BIG_FISH_MERGE_COUNT_PER_UPGRADE,
spawn_target_count: BIG_FISH_TARGET_WILD_COUNT as u32,
leader_move_speed: 160.0,
follower_catch_up_speed: 120.0,
offscreen_cull_seconds: BIG_FISH_OFFSCREEN_CULL_SECONDS,
prey_spawn_delta_levels: vec![1, 2],
threat_spawn_delta_levels: vec![1, 2],
win_level: level_count,
},
}
}
pub fn build_asset_coverage(
draft: Option<&BigFishGameDraft>,
asset_slots: &[BigFishAssetSlotSnapshot],
) -> BigFishAssetCoverage {
let required_level_count = draft
.map(|value| value.runtime_params.level_count)
.unwrap_or(BIG_FISH_DEFAULT_LEVEL_COUNT);
let main_ready = asset_slots
.iter()
.filter(|slot| {
slot.asset_kind == BigFishAssetKind::LevelMainImage
&& slot.status == BigFishAssetStatus::Ready
})
.count() as u32;
let motion_ready = asset_slots
.iter()
.filter(|slot| {
slot.asset_kind == BigFishAssetKind::LevelMotion
&& slot.status == BigFishAssetStatus::Ready
})
.count() as u32;
let background_ready = asset_slots.iter().any(|slot| {
slot.asset_kind == BigFishAssetKind::StageBackground
&& slot.status == BigFishAssetStatus::Ready
});
let required_motion_count = required_level_count * 2;
let mut blockers = Vec::new();
if draft.is_none() {
blockers.push("玩法草稿尚未编译".to_string());
}
if main_ready < required_level_count {
blockers.push(format!(
"还缺少 {} 个等级主图",
required_level_count.saturating_sub(main_ready)
));
}
if motion_ready < required_motion_count {
blockers.push(format!(
"还缺少 {} 个基础动作",
required_motion_count.saturating_sub(motion_ready)
));
}
if !background_ready {
blockers.push("还缺少活动区域背景图".to_string());
}
BigFishAssetCoverage {
level_main_image_ready_count: main_ready,
level_motion_ready_count: motion_ready,
background_ready,
required_level_count,
publish_ready: blockers.is_empty(),
blockers,
}
}
pub fn build_generated_asset_slot(
session_id: &str,
draft: &BigFishGameDraft,
asset_kind: BigFishAssetKind,
level: Option<u32>,
motion_key: Option<String>,
asset_url: Option<String>,
updated_at_micros: i64,
) -> Result<BigFishAssetSlotSnapshot, BigFishFieldError> {
let session_id =
normalize_required_string(session_id).ok_or(BigFishFieldError::MissingSessionId)?;
let prompt_snapshot =
build_asset_prompt_snapshot(draft, asset_kind, level, motion_key.as_deref())?;
let slot_id = build_asset_slot_id(&session_id, asset_kind, level, motion_key.as_deref());
let resolved_asset_url = normalize_required_string(asset_url.as_deref().unwrap_or_default())
.unwrap_or_else(|| build_placeholder_asset_url(asset_kind, level, updated_at_micros));
Ok(BigFishAssetSlotSnapshot {
slot_id,
session_id,
asset_kind,
level,
motion_key,
status: BigFishAssetStatus::Ready,
asset_url: Some(resolved_asset_url),
prompt_snapshot,
updated_at_micros,
})
}
pub fn validate_session_get_input(input: &BigFishSessionGetInput) -> Result<(), BigFishFieldError> {
validate_session_owner(&input.session_id, &input.owner_user_id)
}
pub fn validate_works_list_input(input: &BigFishWorksListInput) -> Result<(), BigFishFieldError> {
if input.published_only {
return Ok(());
}
if normalize_required_string(&input.owner_user_id).is_none() {
return Err(BigFishFieldError::MissingOwnerUserId);
}
Ok(())
}
pub fn validate_session_create_input(
input: &BigFishSessionCreateInput,
) -> Result<(), BigFishFieldError> {
validate_session_owner(&input.session_id, &input.owner_user_id)?;
if normalize_required_string(&input.welcome_message_id).is_none() {
return Err(BigFishFieldError::MissingMessageId);
}
Ok(())
}
pub fn validate_message_submit_input(
input: &BigFishMessageSubmitInput,
) -> Result<(), BigFishFieldError> {
validate_session_owner(&input.session_id, &input.owner_user_id)?;
if normalize_required_string(&input.user_message_id).is_none()
|| normalize_required_string(&input.assistant_message_id).is_none()
{
return Err(BigFishFieldError::MissingMessageId);
}
if normalize_required_string(&input.user_message_text).is_none() {
return Err(BigFishFieldError::MissingMessageText);
}
Ok(())
}
pub fn validate_message_finalize_input(
input: &BigFishMessageFinalizeInput,
) -> Result<(), BigFishFieldError> {
validate_session_owner(&input.session_id, &input.owner_user_id)
}
pub fn validate_draft_compile_input(
input: &BigFishDraftCompileInput,
) -> Result<(), BigFishFieldError> {
validate_session_owner(&input.session_id, &input.owner_user_id)
}
pub fn validate_asset_generate_input(
input: &BigFishAssetGenerateInput,
draft: &BigFishGameDraft,
) -> Result<(), BigFishFieldError> {
validate_session_owner(&input.session_id, &input.owner_user_id)?;
match input.asset_kind {
BigFishAssetKind::LevelMainImage => validate_level(input.level, draft),
BigFishAssetKind::LevelMotion => {
validate_level(input.level, draft)?;
match input.motion_key.as_deref() {
Some("idle_float" | "move_swim") => Ok(()),
_ => Err(BigFishFieldError::InvalidAssetKind),
}
}
BigFishAssetKind::StageBackground => Ok(()),
}
}
pub fn validate_publish_input(input: &BigFishPublishInput) -> Result<(), BigFishFieldError> {
validate_session_owner(&input.session_id, &input.owner_user_id)
}
pub fn validate_play_record_input(input: &BigFishPlayRecordInput) -> Result<(), BigFishFieldError> {
if normalize_required_string(&input.session_id).is_none() {
return Err(BigFishFieldError::MissingSessionId);
}
if normalize_required_string(&input.user_id).is_none() {
return Err(BigFishFieldError::MissingOwnerUserId);
}
Ok(())
}
pub fn validate_run_start_input(input: &BigFishRunStartInput) -> Result<(), BigFishFieldError> {
validate_session_owner(&input.session_id, &input.owner_user_id)?;
if normalize_required_string(&input.run_id).is_none() {
return Err(BigFishFieldError::MissingRunId);
}
Ok(())
}
pub fn validate_run_get_input(input: &BigFishRunGetInput) -> Result<(), BigFishFieldError> {
if normalize_required_string(&input.run_id).is_none() {
return Err(BigFishFieldError::MissingRunId);
}
if normalize_required_string(&input.owner_user_id).is_none() {
return Err(BigFishFieldError::MissingOwnerUserId);
}
Ok(())
}
pub fn validate_input_submit_input(
input: &BigFishInputSubmitInput,
) -> Result<(), BigFishFieldError> {
if normalize_required_string(&input.run_id).is_none() {
return Err(BigFishFieldError::MissingRunId);
}
if normalize_required_string(&input.owner_user_id).is_none() {
return Err(BigFishFieldError::MissingOwnerUserId);
}
if !input.x.is_finite() || !input.y.is_finite() {
return Err(BigFishFieldError::InvalidRuntimeInput);
}
Ok(())
}
pub fn serialize_anchor_pack(anchor_pack: &BigFishAnchorPack) -> Result<String, serde_json::Error> {
serde_json::to_string(anchor_pack)
}
pub fn deserialize_anchor_pack(value: &str) -> Result<BigFishAnchorPack, serde_json::Error> {
serde_json::from_str(value)
}
pub fn serialize_draft(draft: &BigFishGameDraft) -> Result<String, serde_json::Error> {
serde_json::to_string(draft)
}
pub fn deserialize_draft(value: &str) -> Result<BigFishGameDraft, serde_json::Error> {
serde_json::from_str(value)
}
pub fn serialize_asset_coverage(
coverage: &BigFishAssetCoverage,
) -> Result<String, serde_json::Error> {
serde_json::to_string(coverage)
}
pub fn deserialize_asset_coverage(value: &str) -> Result<BigFishAssetCoverage, serde_json::Error> {
serde_json::from_str(value)
}
fn fallback_anchor_value(anchor: &BigFishAnchorItem, fallback: &str) -> String {
normalize_required_string(&anchor.value).unwrap_or_else(|| fallback.to_string())
}
fn build_level_blueprint(level: u32, level_count: u32, theme: &str) -> BigFishLevelBlueprint {
let prey_window = (1..level)
.rev()
.take(2)
.collect::<Vec<_>>()
.into_iter()
.rev()
.collect();
let threat_window = ((level + 1)..=(level + 2).min(level_count)).collect::<Vec<_>>();
let size_ratio = 1.0 + (level.saturating_sub(1) as f32 * 0.22);
let name = format!("{theme} L{level}");
let one_line_fantasy = if level == level_count {
"终局巨兽形态,获得即可通关".to_string()
} else {
format!("{level} 阶实体,继续吞噬同级和低级个体成长")
};
let text_description = if level == 1 {
format!(
"{name} 是这套 {theme} 等级阶梯的起点个体,体型最小、动作轻盈,会在谨慎试探中寻找第一个可吞噬目标。"
)
} else if level == level_count {
format!(
"{name} 是这套 {theme} 生态中的终局霸主形态,体格巨大、压迫感最强,一旦成型就代表本局成长链已经完成。"
)
} else {
format!(
"{name}{theme} 生态里的第 {level} 阶进化体,已经具备更鲜明的轮廓、猎食性和压迫感,会继续通过吞并同级与低级实体向上跃迁。"
)
};
let visual_description = if level == 1 {
format!(
"{theme} 风格的小型初始鱼形生物,体态轻巧,轮廓圆润,局部带少量发光纹路或主题特征,明显呈现弱小但灵动的开局形象。"
)
} else if level == level_count {
format!(
"{theme} 风格的终局巨型鱼形霸主,体长与鳍面明显扩张,轮廓锋利或威严,层次细节最丰富,拥有一眼可辨识的终局统治感。"
)
} else {
format!(
"{theme} 风格的第 {level} 级进化鱼形生物,相比上一阶段更大、更强、更成熟,身体主轮廓更清晰,局部装饰、鳍面结构和主题特征都更明显。"
)
};
let idle_motion_description = if level == level_count {
"待机时缓慢悬停,身体主体保持稳定,尾鳍与侧鳍做低频摆动,呈现强者从容压场的漂浮感。"
.to_string()
} else {
format!(
"待机时保持轻微漂浮与呼吸感摆动,尾鳍和侧鳍以小幅度节奏晃动,体现 Lv.{level} 生物在水中蓄势观察的状态。"
)
};
let move_motion_description = if level == level_count {
"移动时身体前倾,尾鳍和背鳍形成强力推进姿态,带出稳定而有压迫感的高速巡游动势。".to_string()
} else {
format!(
"移动时身体向前游动,尾鳍形成清晰摆尾推进,整体节奏比待机更主动,体现 Lv.{level} 生物追逐猎物时的连续游动感。"
)
};
BigFishLevelBlueprint {
level,
name,
one_line_fantasy,
text_description,
silhouette_direction: format!(
"体型约为初始的 {:.1} 倍,轮廓更清晰",
1.0 + level as f32 * 0.22
),
size_ratio,
visual_description: visual_description.clone(),
visual_prompt_seed: format!(
"{visual_description} 透明背景,单体完整入镜,适合作为竖屏吞噬成长玩法的等级主图。"
),
idle_motion_description: idle_motion_description.clone(),
move_motion_description: move_motion_description.clone(),
motion_prompt_seed: format!(
"待机动作:{idle_motion_description} 移动动作:{move_motion_description}"
),
merge_source_level: if level == 1 { None } else { Some(level - 1) },
prey_window,
threat_window,
is_final_level: level == level_count,
}
}
fn build_asset_prompt_snapshot(
draft: &BigFishGameDraft,
asset_kind: BigFishAssetKind,
level: Option<u32>,
motion_key: Option<&str>,
) -> Result<String, BigFishFieldError> {
match asset_kind {
BigFishAssetKind::LevelMainImage => {
let level = level.ok_or(BigFishFieldError::InvalidLevel)?;
let blueprint = draft
.levels
.iter()
.find(|item| item.level == level)
.ok_or(BigFishFieldError::InvalidLevel)?;
Ok(blueprint.visual_prompt_seed.clone())
}
BigFishAssetKind::LevelMotion => {
let level = level.ok_or(BigFishFieldError::InvalidLevel)?;
let blueprint = draft
.levels
.iter()
.find(|item| item.level == level)
.ok_or(BigFishFieldError::InvalidLevel)?;
let motion_key = motion_key.ok_or(BigFishFieldError::InvalidAssetKind)?;
let motion_description = match motion_key {
"idle_float" => blueprint.idle_motion_description.as_str(),
"move_swim" => blueprint.move_motion_description.as_str(),
_ => return Err(BigFishFieldError::InvalidAssetKind),
};
Ok(format!(
"{} 动作位:{}{} 透明背景,单体完整入镜。",
blueprint.motion_prompt_seed, motion_key, motion_description
))
}
BigFishAssetKind::StageBackground => Ok(draft.background.background_prompt_seed.clone()),
}
}
fn build_asset_slot_id(
session_id: &str,
asset_kind: BigFishAssetKind,
level: Option<u32>,
motion_key: Option<&str>,
) -> String {
let level_part = level
.map(|value| value.to_string())
.unwrap_or_else(|| "stage".to_string());
let motion_part = motion_key.unwrap_or("main");
format!(
"{BIG_FISH_ASSET_SLOT_ID_PREFIX}{session_id}_{}_{}_{}",
asset_kind.as_str(),
level_part,
motion_part
)
}
fn build_placeholder_asset_url(
asset_kind: BigFishAssetKind,
level: Option<u32>,
seed_micros: i64,
) -> String {
let level_part = level
.map(|value| format!("level-{value}"))
.unwrap_or_else(|| "stage".to_string());
format!(
"/generated-big-fish/{}/{}/{}.png",
asset_kind.as_str(),
level_part,
seed_micros
)
}
fn validate_session_owner(session_id: &str, owner_user_id: &str) -> Result<(), BigFishFieldError> {
if normalize_required_string(session_id).is_none() {
return Err(BigFishFieldError::MissingSessionId);
}
if normalize_required_string(owner_user_id).is_none() {
return Err(BigFishFieldError::MissingOwnerUserId);
}
Ok(())
}
fn validate_level(level: Option<u32>, draft: &BigFishGameDraft) -> Result<(), BigFishFieldError> {
match level {
Some(value) if (1..=draft.runtime_params.level_count).contains(&value) => Ok(()),
_ => Err(BigFishFieldError::InvalidLevel),
}
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;

View File

@@ -1,8 +1,11 @@
//! 大鱼吃小鱼写入命令过渡落位 //! 大鱼吃小鱼写入命令。
//! //!
//! 用于表达创建会话、写入消息、更新资产槽和推进运行态等输入。 //! 用于表达创建会话、写入消息、更新资产槽和推进运行态等输入。
use crate::{BigFishGameDraft, domain::BigFishRuntimeSnapshot}; use crate::domain::*;
use serde::{Deserialize, Serialize};
#[cfg(feature = "spacetime-types")]
use spacetimedb::SpacetimeType;
/// 评估作品是否可以发布的纯领域命令。 /// 评估作品是否可以发布的纯领域命令。
/// ///
@@ -36,3 +39,140 @@ pub struct SubmitBigFishInputCommand {
pub submitted_at_micros: i64, pub submitted_at_micros: i64,
pub current_snapshot: BigFishRuntimeSnapshot, pub current_snapshot: BigFishRuntimeSnapshot,
} }
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct BigFishWorksListInput {
pub owner_user_id: String,
pub published_only: bool,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct BigFishWorkDeleteInput {
pub session_id: String,
pub owner_user_id: String,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct BigFishWorksProcedureResult {
pub ok: bool,
pub items_json: Option<String>,
pub error_message: Option<String>,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct BigFishSessionCreateInput {
pub session_id: String,
pub owner_user_id: String,
pub seed_text: String,
pub welcome_message_id: String,
pub welcome_message_text: String,
pub created_at_micros: i64,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct BigFishSessionGetInput {
pub session_id: String,
pub owner_user_id: String,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct BigFishMessageSubmitInput {
pub session_id: String,
pub owner_user_id: String,
pub user_message_id: String,
pub user_message_text: String,
pub assistant_message_id: String,
pub submitted_at_micros: i64,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct BigFishMessageFinalizeInput {
pub session_id: String,
pub owner_user_id: String,
pub assistant_message_id: Option<String>,
pub assistant_reply_text: Option<String>,
pub stage: BigFishCreationStage,
pub progress_percent: u32,
pub anchor_pack_json: String,
pub error_message: Option<String>,
pub updated_at_micros: i64,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct BigFishDraftCompileInput {
pub session_id: String,
pub owner_user_id: String,
pub draft_json: Option<String>,
pub compiled_at_micros: i64,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct BigFishAssetGenerateInput {
pub session_id: String,
pub owner_user_id: String,
pub asset_kind: BigFishAssetKind,
pub level: Option<u32>,
pub motion_key: Option<String>,
pub asset_url: Option<String>,
pub generated_at_micros: i64,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct BigFishPublishInput {
pub session_id: String,
pub owner_user_id: String,
pub published_at_micros: i64,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct BigFishPlayRecordInput {
pub session_id: String,
pub user_id: String,
pub elapsed_ms: u64,
pub played_at_micros: i64,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct BigFishRunStartInput {
pub run_id: String,
pub session_id: String,
pub owner_user_id: String,
pub started_at_micros: i64,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct BigFishRunGetInput {
pub run_id: String,
pub owner_user_id: String,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct BigFishInputSubmitInput {
pub run_id: String,
pub owner_user_id: String,
pub x: f32,
pub y: f32,
pub submitted_at_micros: i64,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct BigFishRunProcedureResult {
pub ok: bool,
pub run_json: Option<String>,
pub error_message: Option<String>,
}

View File

@@ -1,12 +1,234 @@
//! 大鱼吃小鱼领域模型过渡落位 //! 大鱼吃小鱼领域模型。
//! //!
//! 后续迁移创作会话、资产槽和运行态聚合时,只保留玩法状态与规则; //! 保留创作会话、资产槽、发布门禁和运行态聚合的纯领域结构图片生成、OSS 与 HTTP handler 均留在 adapter 层。
//! 图片生成、OSS 与 HTTP handler 均留在 adapter 层。
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
#[cfg(feature = "spacetime-types")] #[cfg(feature = "spacetime-types")]
use spacetimedb::SpacetimeType; use spacetimedb::SpacetimeType;
pub const BIG_FISH_SESSION_ID_PREFIX: &str = "big-fish-session-";
pub const BIG_FISH_MESSAGE_ID_PREFIX: &str = "big-fish-message-";
pub const BIG_FISH_OPERATION_ID_PREFIX: &str = "big-fish-operation-";
pub const BIG_FISH_ASSET_SLOT_ID_PREFIX: &str = "big-fish-asset-";
pub const BIG_FISH_DEFAULT_LEVEL_COUNT: u32 = 8;
pub const BIG_FISH_MIN_LEVEL_COUNT: u32 = 6;
pub const BIG_FISH_MAX_LEVEL_COUNT: u32 = 12;
pub const BIG_FISH_MERGE_COUNT_PER_UPGRADE: u32 = 3;
pub const BIG_FISH_OFFSCREEN_CULL_SECONDS: f32 = 3.0;
pub const BIG_FISH_TARGET_WILD_COUNT: usize = 12;
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub enum BigFishCreationStage {
CollectingAnchors,
DraftReady,
AssetRefining,
ReadyToPublish,
Published,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub enum BigFishAnchorStatus {
Confirmed,
Inferred,
Missing,
Locked,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub enum BigFishAgentMessageRole {
User,
Assistant,
System,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub enum BigFishAgentMessageKind {
Chat,
Summary,
ActionResult,
Warning,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub enum BigFishAssetKind {
LevelMainImage,
LevelMotion,
StageBackground,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub enum BigFishAssetStatus {
Missing,
Ready,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct BigFishAnchorItem {
pub key: String,
pub label: String,
pub value: String,
pub status: BigFishAnchorStatus,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct BigFishAnchorPack {
pub gameplay_promise: BigFishAnchorItem,
pub ecology_visual_theme: BigFishAnchorItem,
pub growth_ladder: BigFishAnchorItem,
pub risk_tempo: BigFishAnchorItem,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct BigFishLevelBlueprint {
pub level: u32,
pub name: String,
pub one_line_fantasy: String,
pub text_description: String,
pub silhouette_direction: String,
pub size_ratio: f32,
pub visual_description: String,
pub visual_prompt_seed: String,
pub idle_motion_description: String,
pub move_motion_description: String,
pub motion_prompt_seed: String,
pub merge_source_level: Option<u32>,
pub prey_window: Vec<u32>,
pub threat_window: Vec<u32>,
pub is_final_level: bool,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct BigFishBackgroundBlueprint {
pub theme: String,
pub color_mood: String,
pub foreground_hints: String,
pub midground_composition: String,
pub background_depth: String,
pub safe_play_area_hint: String,
pub spawn_edge_hint: String,
pub background_prompt_seed: String,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct BigFishRuntimeParams {
pub level_count: u32,
pub merge_count_per_upgrade: u32,
pub spawn_target_count: u32,
pub leader_move_speed: f32,
pub follower_catch_up_speed: f32,
pub offscreen_cull_seconds: f32,
pub prey_spawn_delta_levels: Vec<u32>,
pub threat_spawn_delta_levels: Vec<u32>,
pub win_level: u32,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct BigFishGameDraft {
pub title: String,
pub subtitle: String,
pub core_fun: String,
pub ecology_theme: String,
pub levels: Vec<BigFishLevelBlueprint>,
pub background: BigFishBackgroundBlueprint,
pub runtime_params: BigFishRuntimeParams,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct BigFishAgentMessageSnapshot {
pub message_id: String,
pub session_id: String,
pub role: BigFishAgentMessageRole,
pub kind: BigFishAgentMessageKind,
pub text: String,
pub created_at_micros: i64,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct BigFishAssetSlotSnapshot {
pub slot_id: String,
pub session_id: String,
pub asset_kind: BigFishAssetKind,
pub level: Option<u32>,
pub motion_key: Option<String>,
pub status: BigFishAssetStatus,
pub asset_url: Option<String>,
pub prompt_snapshot: String,
pub updated_at_micros: i64,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct BigFishAssetCoverage {
pub level_main_image_ready_count: u32,
pub level_motion_ready_count: u32,
pub background_ready: bool,
pub required_level_count: u32,
pub publish_ready: bool,
pub blockers: Vec<String>,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct BigFishSessionSnapshot {
pub session_id: String,
pub owner_user_id: String,
pub seed_text: String,
pub current_turn: u32,
pub progress_percent: u32,
pub stage: BigFishCreationStage,
pub anchor_pack: BigFishAnchorPack,
pub draft: Option<BigFishGameDraft>,
pub asset_slots: Vec<BigFishAssetSlotSnapshot>,
pub asset_coverage: BigFishAssetCoverage,
pub messages: Vec<BigFishAgentMessageSnapshot>,
pub last_assistant_reply: Option<String>,
pub publish_ready: bool,
pub created_at_micros: i64,
pub updated_at_micros: i64,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct BigFishSessionProcedureResult {
pub ok: bool,
pub session: Option<BigFishSessionSnapshot>,
pub error_message: Option<String>,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct BigFishWorkSummarySnapshot {
pub work_id: String,
pub source_session_id: String,
pub owner_user_id: String,
pub title: String,
pub subtitle: String,
pub summary: String,
pub cover_image_src: Option<String>,
pub status: String,
pub updated_at_micros: i64,
pub publish_ready: bool,
pub level_count: u32,
pub level_main_image_ready_count: u32,
pub level_motion_ready_count: u32,
pub background_ready: bool,
pub play_count: u32,
}
/// 发布门禁的领域判定结果。 /// 发布门禁的领域判定结果。
/// ///
/// 这里不保存外部任务状态,只表达当前聚合快照是否满足发布条件。 /// 这里不保存外部任务状态,只表达当前聚合快照是否满足发布条件。
@@ -77,3 +299,66 @@ impl BigFishRunStatus {
} }
} }
} }
impl BigFishCreationStage {
pub fn as_str(self) -> &'static str {
match self {
Self::CollectingAnchors => "collecting_anchors",
Self::DraftReady => "draft_ready",
Self::AssetRefining => "asset_refining",
Self::ReadyToPublish => "ready_to_publish",
Self::Published => "published",
}
}
}
impl BigFishAnchorStatus {
pub fn as_str(self) -> &'static str {
match self {
Self::Confirmed => "confirmed",
Self::Inferred => "inferred",
Self::Missing => "missing",
Self::Locked => "locked",
}
}
}
impl BigFishAgentMessageRole {
pub fn as_str(self) -> &'static str {
match self {
Self::User => "user",
Self::Assistant => "assistant",
Self::System => "system",
}
}
}
impl BigFishAgentMessageKind {
pub fn as_str(self) -> &'static str {
match self {
Self::Chat => "chat",
Self::Summary => "summary",
Self::ActionResult => "action_result",
Self::Warning => "warning",
}
}
}
impl BigFishAssetKind {
pub fn as_str(self) -> &'static str {
match self {
Self::LevelMainImage => "level_main_image",
Self::LevelMotion => "level_motion",
Self::StageBackground => "stage_background",
}
}
}
impl BigFishAssetStatus {
pub fn as_str(self) -> &'static str {
match self {
Self::Missing => "missing",
Self::Ready => "ready",
}
}
}

View File

@@ -1,4 +1,4 @@
//! 大鱼吃小鱼领域错误过渡落位 //! 大鱼吃小鱼领域错误。
//! //!
//! 错误只表达玩法规则失败,由 HTTP 和 SpacetimeDB adapter 分别映射展示。 //! 错误只表达玩法规则失败,由 HTTP 和 SpacetimeDB adapter 分别映射展示。
@@ -27,3 +27,34 @@ impl fmt::Display for BigFishApplicationError {
} }
impl Error for BigFishApplicationError {} impl Error for BigFishApplicationError {}
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum BigFishFieldError {
MissingSessionId,
MissingOwnerUserId,
MissingMessageId,
MissingMessageText,
MissingDraft,
InvalidLevel,
InvalidAssetKind,
MissingRunId,
InvalidRuntimeInput,
}
impl fmt::Display for BigFishFieldError {
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 不能为空"),
Self::MissingMessageId => f.write_str("big_fish.message_id 不能为空"),
Self::MissingMessageText => f.write_str("big_fish.message_text 不能为空"),
Self::MissingDraft => f.write_str("big_fish.draft 尚未编译"),
Self::InvalidLevel => f.write_str("big_fish.level 不在合法等级范围内"),
Self::InvalidAssetKind => f.write_str("big_fish.asset_kind 或动作位非法"),
Self::MissingRunId => f.write_str("big_fish.run_id 不能为空"),
Self::InvalidRuntimeInput => f.write_str("big_fish.runtime_input 非法"),
}
}
}
impl Error for BigFishFieldError {}

View File

@@ -1,4 +1,4 @@
//! 大鱼吃小鱼领域事件过渡落位 //! 大鱼吃小鱼领域事件。
//! //!
//! 用于表达草稿变化、资产槽变化和运行态 tick 等事实。 //! 用于表达草稿变化、资产槽变化和运行态 tick 等事实。

View File

@@ -4,990 +4,11 @@ mod domain;
mod errors; mod errors;
mod events; mod events;
pub use application::{ pub use application::*;
BigFishRuntimeResult, EvaluateBigFishPublishReadinessResult, deserialize_runtime_snapshot, pub use commands::*;
evaluate_publish_readiness, serialize_runtime_snapshot, start_big_fish_run, pub use domain::*;
submit_big_fish_input, pub use errors::*;
}; pub use events::*;
pub use commands::{
EvaluateBigFishPublishReadinessCommand, StartBigFishRunCommand, SubmitBigFishInputCommand,
};
pub use domain::{
BigFishPublishReadiness, BigFishRunStatus, BigFishRuntimeEntitySnapshot,
BigFishRuntimeSnapshot, BigFishVector2,
};
pub use errors::BigFishApplicationError;
pub use events::BigFishDomainEvent;
use std::{error::Error, fmt};
use serde::{Deserialize, Serialize};
use shared_kernel::normalize_required_string;
#[cfg(feature = "spacetime-types")]
use spacetimedb::SpacetimeType;
pub const BIG_FISH_SESSION_ID_PREFIX: &str = "big-fish-session-";
pub const BIG_FISH_MESSAGE_ID_PREFIX: &str = "big-fish-message-";
pub const BIG_FISH_OPERATION_ID_PREFIX: &str = "big-fish-operation-";
pub const BIG_FISH_ASSET_SLOT_ID_PREFIX: &str = "big-fish-asset-";
pub const BIG_FISH_DEFAULT_LEVEL_COUNT: u32 = 8;
pub const BIG_FISH_MIN_LEVEL_COUNT: u32 = 6;
pub const BIG_FISH_MAX_LEVEL_COUNT: u32 = 12;
pub const BIG_FISH_MERGE_COUNT_PER_UPGRADE: u32 = 3;
pub const BIG_FISH_OFFSCREEN_CULL_SECONDS: f32 = 3.0;
pub const BIG_FISH_TARGET_WILD_COUNT: usize = 12;
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub enum BigFishCreationStage {
CollectingAnchors,
DraftReady,
AssetRefining,
ReadyToPublish,
Published,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub enum BigFishAnchorStatus {
Confirmed,
Inferred,
Missing,
Locked,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub enum BigFishAgentMessageRole {
User,
Assistant,
System,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub enum BigFishAgentMessageKind {
Chat,
Summary,
ActionResult,
Warning,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub enum BigFishAssetKind {
LevelMainImage,
LevelMotion,
StageBackground,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub enum BigFishAssetStatus {
Missing,
Ready,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct BigFishAnchorItem {
pub key: String,
pub label: String,
pub value: String,
pub status: BigFishAnchorStatus,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct BigFishAnchorPack {
pub gameplay_promise: BigFishAnchorItem,
pub ecology_visual_theme: BigFishAnchorItem,
pub growth_ladder: BigFishAnchorItem,
pub risk_tempo: BigFishAnchorItem,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct BigFishLevelBlueprint {
pub level: u32,
pub name: String,
pub one_line_fantasy: String,
pub text_description: String,
pub silhouette_direction: String,
pub size_ratio: f32,
pub visual_description: String,
pub visual_prompt_seed: String,
pub idle_motion_description: String,
pub move_motion_description: String,
pub motion_prompt_seed: String,
pub merge_source_level: Option<u32>,
pub prey_window: Vec<u32>,
pub threat_window: Vec<u32>,
pub is_final_level: bool,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct BigFishBackgroundBlueprint {
pub theme: String,
pub color_mood: String,
pub foreground_hints: String,
pub midground_composition: String,
pub background_depth: String,
pub safe_play_area_hint: String,
pub spawn_edge_hint: String,
pub background_prompt_seed: String,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct BigFishRuntimeParams {
pub level_count: u32,
pub merge_count_per_upgrade: u32,
pub spawn_target_count: u32,
pub leader_move_speed: f32,
pub follower_catch_up_speed: f32,
pub offscreen_cull_seconds: f32,
pub prey_spawn_delta_levels: Vec<u32>,
pub threat_spawn_delta_levels: Vec<u32>,
pub win_level: u32,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct BigFishGameDraft {
pub title: String,
pub subtitle: String,
pub core_fun: String,
pub ecology_theme: String,
pub levels: Vec<BigFishLevelBlueprint>,
pub background: BigFishBackgroundBlueprint,
pub runtime_params: BigFishRuntimeParams,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct BigFishAgentMessageSnapshot {
pub message_id: String,
pub session_id: String,
pub role: BigFishAgentMessageRole,
pub kind: BigFishAgentMessageKind,
pub text: String,
pub created_at_micros: i64,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct BigFishAssetSlotSnapshot {
pub slot_id: String,
pub session_id: String,
pub asset_kind: BigFishAssetKind,
pub level: Option<u32>,
pub motion_key: Option<String>,
pub status: BigFishAssetStatus,
pub asset_url: Option<String>,
pub prompt_snapshot: String,
pub updated_at_micros: i64,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct BigFishAssetCoverage {
pub level_main_image_ready_count: u32,
pub level_motion_ready_count: u32,
pub background_ready: bool,
pub required_level_count: u32,
pub publish_ready: bool,
pub blockers: Vec<String>,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct BigFishSessionSnapshot {
pub session_id: String,
pub owner_user_id: String,
pub seed_text: String,
pub current_turn: u32,
pub progress_percent: u32,
pub stage: BigFishCreationStage,
pub anchor_pack: BigFishAnchorPack,
pub draft: Option<BigFishGameDraft>,
pub asset_slots: Vec<BigFishAssetSlotSnapshot>,
pub asset_coverage: BigFishAssetCoverage,
pub messages: Vec<BigFishAgentMessageSnapshot>,
pub last_assistant_reply: Option<String>,
pub publish_ready: bool,
pub created_at_micros: i64,
pub updated_at_micros: i64,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct BigFishSessionProcedureResult {
pub ok: bool,
pub session: Option<BigFishSessionSnapshot>,
pub error_message: Option<String>,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct BigFishWorkSummarySnapshot {
pub work_id: String,
pub source_session_id: String,
pub owner_user_id: String,
pub title: String,
pub subtitle: String,
pub summary: String,
pub cover_image_src: Option<String>,
pub status: String,
pub updated_at_micros: i64,
pub publish_ready: bool,
pub level_count: u32,
pub level_main_image_ready_count: u32,
pub level_motion_ready_count: u32,
pub background_ready: bool,
pub play_count: u32,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct BigFishWorksListInput {
pub owner_user_id: String,
pub published_only: bool,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct BigFishWorkDeleteInput {
pub session_id: String,
pub owner_user_id: String,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct BigFishWorksProcedureResult {
pub ok: bool,
pub items_json: Option<String>,
pub error_message: Option<String>,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct BigFishSessionCreateInput {
pub session_id: String,
pub owner_user_id: String,
pub seed_text: String,
pub welcome_message_id: String,
pub welcome_message_text: String,
pub created_at_micros: i64,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct BigFishSessionGetInput {
pub session_id: String,
pub owner_user_id: String,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct BigFishMessageSubmitInput {
pub session_id: String,
pub owner_user_id: String,
pub user_message_id: String,
pub user_message_text: String,
pub assistant_message_id: String,
pub submitted_at_micros: i64,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct BigFishMessageFinalizeInput {
pub session_id: String,
pub owner_user_id: String,
pub assistant_message_id: Option<String>,
pub assistant_reply_text: Option<String>,
pub stage: BigFishCreationStage,
pub progress_percent: u32,
pub anchor_pack_json: String,
pub error_message: Option<String>,
pub updated_at_micros: i64,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct BigFishDraftCompileInput {
pub session_id: String,
pub owner_user_id: String,
pub draft_json: Option<String>,
pub compiled_at_micros: i64,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct BigFishAssetGenerateInput {
pub session_id: String,
pub owner_user_id: String,
pub asset_kind: BigFishAssetKind,
pub level: Option<u32>,
pub motion_key: Option<String>,
pub asset_url: Option<String>,
pub generated_at_micros: i64,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct BigFishPublishInput {
pub session_id: String,
pub owner_user_id: String,
pub published_at_micros: i64,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct BigFishPlayRecordInput {
pub session_id: String,
pub user_id: String,
pub elapsed_ms: u64,
pub played_at_micros: i64,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct BigFishRunStartInput {
pub run_id: String,
pub session_id: String,
pub owner_user_id: String,
pub started_at_micros: i64,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct BigFishRunGetInput {
pub run_id: String,
pub owner_user_id: String,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct BigFishInputSubmitInput {
pub run_id: String,
pub owner_user_id: String,
pub x: f32,
pub y: f32,
pub submitted_at_micros: i64,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct BigFishRunProcedureResult {
pub ok: bool,
pub run_json: Option<String>,
pub error_message: Option<String>,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum BigFishFieldError {
MissingSessionId,
MissingOwnerUserId,
MissingMessageId,
MissingMessageText,
MissingDraft,
InvalidLevel,
InvalidAssetKind,
MissingRunId,
InvalidRuntimeInput,
}
impl BigFishCreationStage {
pub fn as_str(self) -> &'static str {
match self {
Self::CollectingAnchors => "collecting_anchors",
Self::DraftReady => "draft_ready",
Self::AssetRefining => "asset_refining",
Self::ReadyToPublish => "ready_to_publish",
Self::Published => "published",
}
}
}
impl BigFishAnchorStatus {
pub fn as_str(self) -> &'static str {
match self {
Self::Confirmed => "confirmed",
Self::Inferred => "inferred",
Self::Missing => "missing",
Self::Locked => "locked",
}
}
}
impl BigFishAgentMessageRole {
pub fn as_str(self) -> &'static str {
match self {
Self::User => "user",
Self::Assistant => "assistant",
Self::System => "system",
}
}
}
impl BigFishAgentMessageKind {
pub fn as_str(self) -> &'static str {
match self {
Self::Chat => "chat",
Self::Summary => "summary",
Self::ActionResult => "action_result",
Self::Warning => "warning",
}
}
}
impl BigFishAssetKind {
pub fn as_str(self) -> &'static str {
match self {
Self::LevelMainImage => "level_main_image",
Self::LevelMotion => "level_motion",
Self::StageBackground => "stage_background",
}
}
}
impl BigFishAssetStatus {
pub fn as_str(self) -> &'static str {
match self {
Self::Missing => "missing",
Self::Ready => "ready",
}
}
}
pub fn empty_anchor_pack() -> BigFishAnchorPack {
BigFishAnchorPack {
gameplay_promise: BigFishAnchorItem {
key: "gameplayPromise".to_string(),
label: "玩法承诺".to_string(),
value: String::new(),
status: BigFishAnchorStatus::Missing,
},
ecology_visual_theme: BigFishAnchorItem {
key: "ecologyVisualTheme".to_string(),
label: "生态与视觉母题".to_string(),
value: String::new(),
status: BigFishAnchorStatus::Missing,
},
growth_ladder: BigFishAnchorItem {
key: "growthLadder".to_string(),
label: "成长阶梯".to_string(),
value: String::new(),
status: BigFishAnchorStatus::Missing,
},
risk_tempo: BigFishAnchorItem {
key: "riskTempo".to_string(),
label: "风险节奏".to_string(),
value: "平衡".to_string(),
status: BigFishAnchorStatus::Inferred,
},
}
}
pub fn infer_anchor_pack(seed_text: &str, latest_message: Option<&str>) -> BigFishAnchorPack {
let source = normalize_required_string(latest_message.unwrap_or(seed_text))
.or_else(|| normalize_required_string(seed_text))
.unwrap_or_else(|| "深海弱小逆袭,逐级吞噬成长".to_string());
let mut pack = empty_anchor_pack();
pack.gameplay_promise.value = if source.contains("可爱") {
"可爱生态成长".to_string()
} else if source.contains("机械") {
"机械微生物吞并进化".to_string()
} else {
"弱小逆袭和群体吞并".to_string()
};
pack.gameplay_promise.status = BigFishAnchorStatus::Inferred;
pack.ecology_visual_theme.value = if source.contains("机械") {
"机械微生物水域".to_string()
} else if source.contains("") {
"梦境纸鱼生态".to_string()
} else {
"深海生物生态".to_string()
};
pack.ecology_visual_theme.status = BigFishAnchorStatus::Inferred;
pack.growth_ladder.value = "8 级连续进化,从幼小个体成长为终局巨兽".to_string();
pack.growth_ladder.status = BigFishAnchorStatus::Inferred;
pack.risk_tempo.value = if source.contains("") {
"偏爽快".to_string()
} else if source.contains("压迫") {
"偏压迫".to_string()
} else {
"平衡".to_string()
};
pack
}
pub fn compile_default_draft(anchor_pack: &BigFishAnchorPack) -> BigFishGameDraft {
let level_count = BIG_FISH_DEFAULT_LEVEL_COUNT;
let theme = fallback_anchor_value(&anchor_pack.ecology_visual_theme, "深海生物生态");
let core_fun = fallback_anchor_value(&anchor_pack.gameplay_promise, "弱小逆袭和群体吞并");
let risk_tempo = fallback_anchor_value(&anchor_pack.risk_tempo, "平衡");
let levels = (1..=level_count)
.map(|level| build_level_blueprint(level, level_count, &theme))
.collect();
BigFishGameDraft {
title: format!("{theme} 大鱼吃小鱼"),
subtitle: format!("{core_fun} · {risk_tempo}节奏"),
core_fun,
ecology_theme: theme.clone(),
levels,
background: BigFishBackgroundBlueprint {
theme: theme.clone(),
color_mood: "深蓝、青绿、带少量暖色生物光".to_string(),
foreground_hints: "只保留少量漂浮颗粒和边缘水草,不遮挡中央操作区".to_string(),
midground_composition: "中央留出大面积清晰活动区域,边缘只做出生缓冲层".to_string(),
background_depth: "简洁纵深水域与极少量远处剪影".to_string(),
safe_play_area_hint: "9:16 竖屏中央 80% 为主要活动区".to_string(),
spawn_edge_hint: "四周边缘以少量暗礁或水草提示野生实体出生区".to_string(),
background_prompt_seed: format!(
"{theme},竖屏 9:16全屏大场地游戏背景元素少中央开阔无文字无 UI 框"
),
},
runtime_params: BigFishRuntimeParams {
level_count,
merge_count_per_upgrade: BIG_FISH_MERGE_COUNT_PER_UPGRADE,
spawn_target_count: BIG_FISH_TARGET_WILD_COUNT as u32,
leader_move_speed: 160.0,
follower_catch_up_speed: 120.0,
offscreen_cull_seconds: BIG_FISH_OFFSCREEN_CULL_SECONDS,
prey_spawn_delta_levels: vec![1, 2],
threat_spawn_delta_levels: vec![1, 2],
win_level: level_count,
},
}
}
pub fn build_asset_coverage(
draft: Option<&BigFishGameDraft>,
asset_slots: &[BigFishAssetSlotSnapshot],
) -> BigFishAssetCoverage {
let required_level_count = draft
.map(|value| value.runtime_params.level_count)
.unwrap_or(BIG_FISH_DEFAULT_LEVEL_COUNT);
let main_ready = asset_slots
.iter()
.filter(|slot| {
slot.asset_kind == BigFishAssetKind::LevelMainImage
&& slot.status == BigFishAssetStatus::Ready
})
.count() as u32;
let motion_ready = asset_slots
.iter()
.filter(|slot| {
slot.asset_kind == BigFishAssetKind::LevelMotion
&& slot.status == BigFishAssetStatus::Ready
})
.count() as u32;
let background_ready = asset_slots.iter().any(|slot| {
slot.asset_kind == BigFishAssetKind::StageBackground
&& slot.status == BigFishAssetStatus::Ready
});
let required_motion_count = required_level_count * 2;
let mut blockers = Vec::new();
if draft.is_none() {
blockers.push("玩法草稿尚未编译".to_string());
}
if main_ready < required_level_count {
blockers.push(format!(
"还缺少 {} 个等级主图",
required_level_count.saturating_sub(main_ready)
));
}
if motion_ready < required_motion_count {
blockers.push(format!(
"还缺少 {} 个基础动作",
required_motion_count.saturating_sub(motion_ready)
));
}
if !background_ready {
blockers.push("还缺少活动区域背景图".to_string());
}
BigFishAssetCoverage {
level_main_image_ready_count: main_ready,
level_motion_ready_count: motion_ready,
background_ready,
required_level_count,
publish_ready: blockers.is_empty(),
blockers,
}
}
pub fn build_generated_asset_slot(
session_id: &str,
draft: &BigFishGameDraft,
asset_kind: BigFishAssetKind,
level: Option<u32>,
motion_key: Option<String>,
asset_url: Option<String>,
updated_at_micros: i64,
) -> Result<BigFishAssetSlotSnapshot, BigFishFieldError> {
let session_id =
normalize_required_string(session_id).ok_or(BigFishFieldError::MissingSessionId)?;
let prompt_snapshot =
build_asset_prompt_snapshot(draft, asset_kind, level, motion_key.as_deref())?;
let slot_id = build_asset_slot_id(&session_id, asset_kind, level, motion_key.as_deref());
let resolved_asset_url = normalize_required_string(asset_url.as_deref().unwrap_or_default())
.unwrap_or_else(|| build_placeholder_asset_url(asset_kind, level, updated_at_micros));
Ok(BigFishAssetSlotSnapshot {
slot_id,
session_id,
asset_kind,
level,
motion_key,
status: BigFishAssetStatus::Ready,
asset_url: Some(resolved_asset_url),
prompt_snapshot,
updated_at_micros,
})
}
pub fn validate_session_get_input(input: &BigFishSessionGetInput) -> Result<(), BigFishFieldError> {
validate_session_owner(&input.session_id, &input.owner_user_id)
}
pub fn validate_works_list_input(input: &BigFishWorksListInput) -> Result<(), BigFishFieldError> {
if input.published_only {
return Ok(());
}
if normalize_required_string(&input.owner_user_id).is_none() {
return Err(BigFishFieldError::MissingOwnerUserId);
}
Ok(())
}
pub fn validate_session_create_input(
input: &BigFishSessionCreateInput,
) -> Result<(), BigFishFieldError> {
validate_session_owner(&input.session_id, &input.owner_user_id)?;
if normalize_required_string(&input.welcome_message_id).is_none() {
return Err(BigFishFieldError::MissingMessageId);
}
Ok(())
}
pub fn validate_message_submit_input(
input: &BigFishMessageSubmitInput,
) -> Result<(), BigFishFieldError> {
validate_session_owner(&input.session_id, &input.owner_user_id)?;
if normalize_required_string(&input.user_message_id).is_none()
|| normalize_required_string(&input.assistant_message_id).is_none()
{
return Err(BigFishFieldError::MissingMessageId);
}
if normalize_required_string(&input.user_message_text).is_none() {
return Err(BigFishFieldError::MissingMessageText);
}
Ok(())
}
pub fn validate_message_finalize_input(
input: &BigFishMessageFinalizeInput,
) -> Result<(), BigFishFieldError> {
validate_session_owner(&input.session_id, &input.owner_user_id)
}
pub fn validate_draft_compile_input(
input: &BigFishDraftCompileInput,
) -> Result<(), BigFishFieldError> {
validate_session_owner(&input.session_id, &input.owner_user_id)
}
pub fn validate_asset_generate_input(
input: &BigFishAssetGenerateInput,
draft: &BigFishGameDraft,
) -> Result<(), BigFishFieldError> {
validate_session_owner(&input.session_id, &input.owner_user_id)?;
match input.asset_kind {
BigFishAssetKind::LevelMainImage => validate_level(input.level, draft),
BigFishAssetKind::LevelMotion => {
validate_level(input.level, draft)?;
match input.motion_key.as_deref() {
Some("idle_float" | "move_swim") => Ok(()),
_ => Err(BigFishFieldError::InvalidAssetKind),
}
}
BigFishAssetKind::StageBackground => Ok(()),
}
}
pub fn validate_publish_input(input: &BigFishPublishInput) -> Result<(), BigFishFieldError> {
validate_session_owner(&input.session_id, &input.owner_user_id)
}
pub fn validate_play_record_input(input: &BigFishPlayRecordInput) -> Result<(), BigFishFieldError> {
if normalize_required_string(&input.session_id).is_none() {
return Err(BigFishFieldError::MissingSessionId);
}
if normalize_required_string(&input.user_id).is_none() {
return Err(BigFishFieldError::MissingOwnerUserId);
}
Ok(())
}
pub fn validate_run_start_input(input: &BigFishRunStartInput) -> Result<(), BigFishFieldError> {
validate_session_owner(&input.session_id, &input.owner_user_id)?;
if normalize_required_string(&input.run_id).is_none() {
return Err(BigFishFieldError::MissingRunId);
}
Ok(())
}
pub fn validate_run_get_input(input: &BigFishRunGetInput) -> Result<(), BigFishFieldError> {
if normalize_required_string(&input.run_id).is_none() {
return Err(BigFishFieldError::MissingRunId);
}
if normalize_required_string(&input.owner_user_id).is_none() {
return Err(BigFishFieldError::MissingOwnerUserId);
}
Ok(())
}
pub fn validate_input_submit_input(
input: &BigFishInputSubmitInput,
) -> Result<(), BigFishFieldError> {
if normalize_required_string(&input.run_id).is_none() {
return Err(BigFishFieldError::MissingRunId);
}
if normalize_required_string(&input.owner_user_id).is_none() {
return Err(BigFishFieldError::MissingOwnerUserId);
}
if !input.x.is_finite() || !input.y.is_finite() {
return Err(BigFishFieldError::InvalidRuntimeInput);
}
Ok(())
}
pub fn serialize_anchor_pack(anchor_pack: &BigFishAnchorPack) -> Result<String, serde_json::Error> {
serde_json::to_string(anchor_pack)
}
pub fn deserialize_anchor_pack(value: &str) -> Result<BigFishAnchorPack, serde_json::Error> {
serde_json::from_str(value)
}
pub fn serialize_draft(draft: &BigFishGameDraft) -> Result<String, serde_json::Error> {
serde_json::to_string(draft)
}
pub fn deserialize_draft(value: &str) -> Result<BigFishGameDraft, serde_json::Error> {
serde_json::from_str(value)
}
pub fn serialize_asset_coverage(
coverage: &BigFishAssetCoverage,
) -> Result<String, serde_json::Error> {
serde_json::to_string(coverage)
}
pub fn deserialize_asset_coverage(value: &str) -> Result<BigFishAssetCoverage, serde_json::Error> {
serde_json::from_str(value)
}
fn fallback_anchor_value(anchor: &BigFishAnchorItem, fallback: &str) -> String {
normalize_required_string(&anchor.value).unwrap_or_else(|| fallback.to_string())
}
fn build_level_blueprint(level: u32, level_count: u32, theme: &str) -> BigFishLevelBlueprint {
let prey_window = (1..level)
.rev()
.take(2)
.collect::<Vec<_>>()
.into_iter()
.rev()
.collect();
let threat_window = ((level + 1)..=(level + 2).min(level_count)).collect::<Vec<_>>();
let size_ratio = 1.0 + (level.saturating_sub(1) as f32 * 0.22);
let name = format!("{theme} L{level}");
let one_line_fantasy = if level == level_count {
"终局巨兽形态,获得即可通关".to_string()
} else {
format!("{level} 阶实体,继续吞噬同级和低级个体成长")
};
let text_description = if level == 1 {
format!(
"{name} 是这套 {theme} 等级阶梯的起点个体,体型最小、动作轻盈,会在谨慎试探中寻找第一个可吞噬目标。"
)
} else if level == level_count {
format!(
"{name} 是这套 {theme} 生态中的终局霸主形态,体格巨大、压迫感最强,一旦成型就代表本局成长链已经完成。"
)
} else {
format!(
"{name}{theme} 生态里的第 {level} 阶进化体,已经具备更鲜明的轮廓、猎食性和压迫感,会继续通过吞并同级与低级实体向上跃迁。"
)
};
let visual_description = if level == 1 {
format!(
"{theme} 风格的小型初始鱼形生物,体态轻巧,轮廓圆润,局部带少量发光纹路或主题特征,明显呈现弱小但灵动的开局形象。"
)
} else if level == level_count {
format!(
"{theme} 风格的终局巨型鱼形霸主,体长与鳍面明显扩张,轮廓锋利或威严,层次细节最丰富,拥有一眼可辨识的终局统治感。"
)
} else {
format!(
"{theme} 风格的第 {level} 级进化鱼形生物,相比上一阶段更大、更强、更成熟,身体主轮廓更清晰,局部装饰、鳍面结构和主题特征都更明显。"
)
};
let idle_motion_description = if level == level_count {
"待机时缓慢悬停,身体主体保持稳定,尾鳍与侧鳍做低频摆动,呈现强者从容压场的漂浮感。"
.to_string()
} else {
format!(
"待机时保持轻微漂浮与呼吸感摆动,尾鳍和侧鳍以小幅度节奏晃动,体现 Lv.{level} 生物在水中蓄势观察的状态。"
)
};
let move_motion_description = if level == level_count {
"移动时身体前倾,尾鳍和背鳍形成强力推进姿态,带出稳定而有压迫感的高速巡游动势。".to_string()
} else {
format!(
"移动时身体向前游动,尾鳍形成清晰摆尾推进,整体节奏比待机更主动,体现 Lv.{level} 生物追逐猎物时的连续游动感。"
)
};
BigFishLevelBlueprint {
level,
name,
one_line_fantasy,
text_description,
silhouette_direction: format!(
"体型约为初始的 {:.1} 倍,轮廓更清晰",
1.0 + level as f32 * 0.22
),
size_ratio,
visual_description: visual_description.clone(),
visual_prompt_seed: format!(
"{visual_description} 透明背景,单体完整入镜,适合作为竖屏吞噬成长玩法的等级主图。"
),
idle_motion_description: idle_motion_description.clone(),
move_motion_description: move_motion_description.clone(),
motion_prompt_seed: format!(
"待机动作:{idle_motion_description} 移动动作:{move_motion_description}"
),
merge_source_level: if level == 1 { None } else { Some(level - 1) },
prey_window,
threat_window,
is_final_level: level == level_count,
}
}
fn build_asset_prompt_snapshot(
draft: &BigFishGameDraft,
asset_kind: BigFishAssetKind,
level: Option<u32>,
motion_key: Option<&str>,
) -> Result<String, BigFishFieldError> {
match asset_kind {
BigFishAssetKind::LevelMainImage => {
let level = level.ok_or(BigFishFieldError::InvalidLevel)?;
let blueprint = draft
.levels
.iter()
.find(|item| item.level == level)
.ok_or(BigFishFieldError::InvalidLevel)?;
Ok(blueprint.visual_prompt_seed.clone())
}
BigFishAssetKind::LevelMotion => {
let level = level.ok_or(BigFishFieldError::InvalidLevel)?;
let blueprint = draft
.levels
.iter()
.find(|item| item.level == level)
.ok_or(BigFishFieldError::InvalidLevel)?;
let motion_key = motion_key.ok_or(BigFishFieldError::InvalidAssetKind)?;
let motion_description = match motion_key {
"idle_float" => blueprint.idle_motion_description.as_str(),
"move_swim" => blueprint.move_motion_description.as_str(),
_ => return Err(BigFishFieldError::InvalidAssetKind),
};
Ok(format!(
"{} 动作位:{}{} 透明背景,单体完整入镜。",
blueprint.motion_prompt_seed, motion_key, motion_description
))
}
BigFishAssetKind::StageBackground => Ok(draft.background.background_prompt_seed.clone()),
}
}
fn build_asset_slot_id(
session_id: &str,
asset_kind: BigFishAssetKind,
level: Option<u32>,
motion_key: Option<&str>,
) -> String {
let level_part = level
.map(|value| value.to_string())
.unwrap_or_else(|| "stage".to_string());
let motion_part = motion_key.unwrap_or("main");
format!(
"{BIG_FISH_ASSET_SLOT_ID_PREFIX}{session_id}_{}_{}_{}",
asset_kind.as_str(),
level_part,
motion_part
)
}
fn build_placeholder_asset_url(
asset_kind: BigFishAssetKind,
level: Option<u32>,
seed_micros: i64,
) -> String {
let level_part = level
.map(|value| format!("level-{value}"))
.unwrap_or_else(|| "stage".to_string());
format!(
"/generated-big-fish/{}/{}/{}.png",
asset_kind.as_str(),
level_part,
seed_micros
)
}
fn validate_session_owner(session_id: &str, owner_user_id: &str) -> Result<(), BigFishFieldError> {
if normalize_required_string(session_id).is_none() {
return Err(BigFishFieldError::MissingSessionId);
}
if normalize_required_string(owner_user_id).is_none() {
return Err(BigFishFieldError::MissingOwnerUserId);
}
Ok(())
}
fn validate_level(level: Option<u32>, draft: &BigFishGameDraft) -> Result<(), BigFishFieldError> {
match level {
Some(value) if (1..=draft.runtime_params.level_count).contains(&value) => Ok(()),
_ => Err(BigFishFieldError::InvalidLevel),
}
}
impl fmt::Display for BigFishFieldError {
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 不能为空"),
Self::MissingMessageId => f.write_str("big_fish.message_id 不能为空"),
Self::MissingMessageText => f.write_str("big_fish.message_text 不能为空"),
Self::MissingDraft => f.write_str("big_fish.draft 尚未编译"),
Self::InvalidLevel => f.write_str("big_fish.level 不在合法等级范围内"),
Self::InvalidAssetKind => f.write_str("big_fish.asset_kind 或动作位非法"),
Self::MissingRunId => f.write_str("big_fish.run_id 不能为空"),
Self::InvalidRuntimeInput => f.write_str("big_fish.runtime_input 非法"),
}
}
}
impl Error for BigFishFieldError {}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {

View File

@@ -1,3 +1,291 @@
//! 战斗应用编排过渡落位 //! 战斗应用编排。
//! //!
//! 这里只返回结算结果与待处理事件,不直接写入其他上下文表。 //! 这里只返回结算结果与待处理事件,不直接写入其他上下文表。
use crate::commands::{
BattleStateInput, ResolveCombatActionInput, validate_resolve_combat_action_input,
};
use crate::domain::{
BASIC_FIGHT_COUNTER_RATIO, BATTLE_STATE_ID_PREFIX, BattleMode, BattleStateSnapshot,
BattleStatus, CombatOutcome, INITIAL_BATTLE_VERSION, MIN_FIGHT_COUNTER_DAMAGE, SPAR_MIN_HP,
};
use crate::errors::CombatFieldError;
use serde::{Deserialize, Serialize};
use shared_kernel::{build_prefixed_seed_id, normalize_required_string};
#[cfg(feature = "spacetime-types")]
use spacetimedb::SpacetimeType;
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct ResolveCombatActionResult {
pub snapshot: BattleStateSnapshot,
pub damage_dealt: i32,
pub damage_taken: i32,
pub outcome: CombatOutcome,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct BattleStateProcedureResult {
pub ok: bool,
pub snapshot: Option<BattleStateSnapshot>,
pub error_message: Option<String>,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct ResolveCombatActionProcedureResult {
pub ok: bool,
pub result: Option<ResolveCombatActionResult>,
pub error_message: Option<String>,
}
pub fn build_battle_state_snapshot(input: BattleStateInput) -> BattleStateSnapshot {
BattleStateSnapshot {
battle_state_id: input.battle_state_id,
story_session_id: input.story_session_id,
runtime_session_id: input.runtime_session_id,
actor_user_id: input.actor_user_id,
chapter_id: input.chapter_id,
target_npc_id: input.target_npc_id,
target_name: input.target_name,
battle_mode: input.battle_mode,
status: BattleStatus::Ongoing,
player_hp: input.player_hp,
player_max_hp: input.player_max_hp,
player_mana: input.player_mana,
player_max_mana: input.player_max_mana,
target_hp: input.target_hp,
target_max_hp: input.target_max_hp,
experience_reward: input.experience_reward,
reward_items: input.reward_items,
turn_index: 0,
last_action_function_id: None,
last_action_text: None,
last_result_text: None,
last_damage_dealt: 0,
last_damage_taken: 0,
last_outcome: CombatOutcome::Ongoing,
version: INITIAL_BATTLE_VERSION,
created_at_micros: input.created_at_micros,
updated_at_micros: input.created_at_micros,
}
}
pub fn resolve_combat_action(
current: BattleStateSnapshot,
input: ResolveCombatActionInput,
) -> Result<ResolveCombatActionResult, CombatFieldError> {
validate_resolve_combat_action_input(&input)?;
if current.version == 0 {
return Err(CombatFieldError::InvalidVersion);
}
if current.status != BattleStatus::Ongoing {
return Err(CombatFieldError::BattleAlreadyResolved);
}
if current.player_mana < input.mana_cost.max(0) {
return Err(CombatFieldError::InsufficientMana);
}
let action_text = if input.action_text.trim().is_empty() {
input.function_id.clone()
} else {
normalize_required_string(input.action_text).unwrap_or_else(|| input.function_id.clone())
};
if input.function_id == "battle_escape_breakout" {
let next = BattleStateSnapshot {
status: BattleStatus::Resolved,
turn_index: current.turn_index + 1,
last_action_function_id: Some(input.function_id),
last_action_text: Some(action_text),
last_result_text: Some(format!("你抓住空当摆脱了{}的压制。", current.target_name)),
last_damage_dealt: 0,
last_damage_taken: 0,
last_outcome: CombatOutcome::Escaped,
version: current.version + 1,
updated_at_micros: input.updated_at_micros,
..current
};
return Ok(ResolveCombatActionResult {
snapshot: next,
damage_dealt: 0,
damage_taken: 0,
outcome: CombatOutcome::Escaped,
});
}
let mana_cost = input.mana_cost.max(0);
let heal = input.heal.max(0);
let mana_restore = input.mana_restore.max(0);
let base_damage = input.base_damage.max(0);
let mut next_player_hp = current.player_hp;
let mut next_player_mana = (current.player_mana - mana_cost).max(0);
let mut next_target_hp = current.target_hp;
let mut damage_dealt = 0;
let mut damage_taken = 0;
next_player_hp = clamp_hp(
current.battle_mode,
next_player_hp + heal,
current.player_max_hp,
);
next_player_mana = clamp_mana(next_player_mana + mana_restore, current.player_max_mana);
if base_damage > 0 {
next_target_hp =
clamp_target_hp_after_damage(current.battle_mode, current.target_hp, base_damage);
damage_dealt = current.target_hp - next_target_hp;
}
let (status, outcome, result_text) = if is_target_resolved(current.battle_mode, next_target_hp)
{
let outcome = match current.battle_mode {
BattleMode::Fight => CombatOutcome::Victory,
BattleMode::Spar => CombatOutcome::SparComplete,
};
(
BattleStatus::Resolved,
outcome,
build_resolved_result_text(&action_text, &current.target_name, outcome),
)
} else {
damage_taken = compute_counter_damage(
current.battle_mode,
current.target_max_hp,
input.counter_multiplier_basis_points,
);
next_player_hp = clamp_hp(
current.battle_mode,
next_player_hp - damage_taken,
current.player_max_hp,
);
(
BattleStatus::Ongoing,
CombatOutcome::Ongoing,
build_ongoing_result_text(&input.function_id, &action_text, &current.target_name),
)
};
let next = BattleStateSnapshot {
player_hp: next_player_hp,
player_mana: next_player_mana,
target_hp: next_target_hp,
status,
turn_index: current.turn_index + 1,
last_action_function_id: Some(input.function_id),
last_action_text: Some(action_text),
last_result_text: Some(result_text),
last_damage_dealt: damage_dealt,
last_damage_taken: damage_taken,
last_outcome: outcome,
version: current.version + 1,
updated_at_micros: input.updated_at_micros,
..current
};
Ok(ResolveCombatActionResult {
snapshot: next,
damage_dealt,
damage_taken,
outcome,
})
}
pub fn generate_battle_state_id(seed_micros: i64) -> String {
build_prefixed_seed_id(BATTLE_STATE_ID_PREFIX, seed_micros)
}
fn clamp_hp(mode: BattleMode, value: i32, max_hp: i32) -> i32 {
let min_hp = match mode {
BattleMode::Fight => 0,
BattleMode::Spar => SPAR_MIN_HP,
};
value.clamp(min_hp, max_hp)
}
fn clamp_mana(value: i32, max_mana: i32) -> i32 {
value.clamp(0, max_mana)
}
fn clamp_target_hp_after_damage(mode: BattleMode, current_hp: i32, damage: i32) -> i32 {
match mode {
BattleMode::Fight => (current_hp - damage).max(0),
BattleMode::Spar => (current_hp - damage).max(SPAR_MIN_HP),
}
}
fn is_target_resolved(mode: BattleMode, target_hp: i32) -> bool {
match mode {
BattleMode::Fight => target_hp <= 0,
BattleMode::Spar => target_hp <= SPAR_MIN_HP,
}
}
fn compute_counter_damage(
mode: BattleMode,
target_max_hp: i32,
counter_multiplier_basis_points: u32,
) -> i32 {
match mode {
BattleMode::Spar => 1,
BattleMode::Fight => {
let multiplier = counter_multiplier_basis_points as f32 / 10_000.0;
let raw =
(target_max_hp as f32 * BASIC_FIGHT_COUNTER_RATIO * multiplier).round() as i32;
raw.max(MIN_FIGHT_COUNTER_DAMAGE)
}
}
}
fn build_resolved_result_text(
action_text: &str,
target_name: &str,
outcome: CombatOutcome,
) -> String {
match outcome {
CombatOutcome::Victory => {
format!(
"{}命中了{},这轮战斗已经正式结束。",
action_text, target_name
)
}
CombatOutcome::SparComplete => {
format!(
"{}压住了{}的节奏,这场切磋已经分出高下。",
action_text, target_name
)
}
CombatOutcome::Escaped => {
format!("{}后你成功脱离了当前战斗。", action_text)
}
CombatOutcome::Ongoing => format!("{}已完成结算。", action_text),
}
}
fn build_ongoing_result_text(function_id: &str, action_text: &str, target_name: &str) -> String {
match function_id {
"battle_recover_breath" => {
format!(
"你先把伤势和气息稳住了一轮,但{}仍在持续逼近。",
target_name
)
}
"battle_use_skill" => {
format!(
"{}命中了{},这一轮技能效果已经直接结算。",
action_text, target_name
)
}
_ => format!(
"{}命中了{},本次攻击已经完成结算。",
action_text, target_name
),
}
}

View File

@@ -1,3 +1,170 @@
//! 战斗写入命令过渡落位 //! 战斗写入命令。
//! //!
//! 用于表达创建战斗、使用技能、逃离和结算等输入,不直接携带表行类型。 //! 用于表达创建战斗、使用技能、逃离和结算等输入,不直接携带表行类型。
use crate::domain::{BattleMode, LEGACY_ATTACK_FUNCTION_IDS};
use crate::errors::CombatFieldError;
use module_runtime_item::{
RuntimeItemRewardItemSnapshot, TreasureFieldError, normalize_reward_item_snapshot,
};
use serde::{Deserialize, Serialize};
use shared_kernel::normalize_required_string;
#[cfg(feature = "spacetime-types")]
use spacetimedb::SpacetimeType;
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct BattleStateInput {
pub battle_state_id: String,
pub story_session_id: String,
pub runtime_session_id: String,
pub actor_user_id: String,
pub chapter_id: Option<String>,
pub target_npc_id: String,
pub target_name: String,
pub battle_mode: BattleMode,
pub player_hp: i32,
pub player_max_hp: i32,
pub player_mana: i32,
pub player_max_mana: i32,
pub target_hp: i32,
pub target_max_hp: i32,
pub experience_reward: u32,
pub reward_items: Vec<RuntimeItemRewardItemSnapshot>,
pub created_at_micros: i64,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct ResolveCombatActionInput {
pub battle_state_id: String,
pub function_id: String,
pub action_text: String,
pub base_damage: i32,
pub mana_cost: i32,
pub heal: i32,
pub mana_restore: i32,
pub counter_multiplier_basis_points: u32,
pub updated_at_micros: i64,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct BattleStateQueryInput {
pub battle_state_id: String,
}
pub fn validate_battle_state_input(input: &BattleStateInput) -> Result<(), CombatFieldError> {
if normalize_required_string(&input.battle_state_id).is_none() {
return Err(CombatFieldError::MissingBattleStateId);
}
if normalize_required_string(&input.story_session_id).is_none() {
return Err(CombatFieldError::MissingStorySessionId);
}
if normalize_required_string(&input.runtime_session_id).is_none() {
return Err(CombatFieldError::MissingRuntimeSessionId);
}
if normalize_required_string(&input.actor_user_id).is_none() {
return Err(CombatFieldError::MissingActorUserId);
}
if normalize_required_string(&input.target_npc_id).is_none() {
return Err(CombatFieldError::MissingTargetNpcId);
}
if normalize_required_string(&input.target_name).is_none() {
return Err(CombatFieldError::MissingTargetName);
}
if input.player_max_hp <= 0 || input.player_hp <= 0 || input.player_hp > input.player_max_hp {
return Err(CombatFieldError::InvalidPlayerVitals);
}
if input.player_max_mana < 0
|| input.player_mana < 0
|| input.player_mana > input.player_max_mana
{
return Err(CombatFieldError::InvalidPlayerVitals);
}
if input.target_max_hp <= 0 || input.target_hp <= 0 || input.target_hp > input.target_max_hp {
return Err(CombatFieldError::InvalidTargetVitals);
}
for reward_item in input.reward_items.iter().cloned() {
normalize_reward_item_snapshot(reward_item).map_err(map_reward_item_field_error)?;
}
Ok(())
}
pub fn validate_resolve_combat_action_input(
input: &ResolveCombatActionInput,
) -> Result<(), CombatFieldError> {
if normalize_required_string(&input.battle_state_id).is_none() {
return Err(CombatFieldError::MissingBattleStateId);
}
if normalize_required_string(&input.function_id).is_none() {
return Err(CombatFieldError::MissingFunctionId);
}
if !is_supported_combat_function_id(&input.function_id) {
return Err(CombatFieldError::UnsupportedFunctionId);
}
Ok(())
}
pub fn build_battle_state_query_input(
battle_state_id: String,
) -> Result<BattleStateQueryInput, CombatFieldError> {
let input = BattleStateQueryInput {
battle_state_id: normalize_required_string(battle_state_id).unwrap_or_default(),
};
validate_battle_state_query_input(&input)?;
Ok(input)
}
pub fn validate_battle_state_query_input(
input: &BattleStateQueryInput,
) -> Result<(), CombatFieldError> {
if normalize_required_string(&input.battle_state_id).is_none() {
return Err(CombatFieldError::MissingBattleStateId);
}
Ok(())
}
pub fn is_supported_combat_function_id(function_id: &str) -> bool {
matches!(
function_id,
"battle_attack_basic"
| "battle_recover_breath"
| "battle_use_skill"
| "battle_escape_breakout"
) || LEGACY_ATTACK_FUNCTION_IDS.contains(&function_id)
}
fn map_reward_item_field_error(error: TreasureFieldError) -> CombatFieldError {
let message = match error {
TreasureFieldError::MissingRewardItemId => {
"battle_state.reward_items[].item_id 不能为空".to_string()
}
TreasureFieldError::MissingRewardItemCategory => {
"battle_state.reward_items[].category 不能为空".to_string()
}
TreasureFieldError::MissingRewardItemName => {
"battle_state.reward_items[].item_name 不能为空".to_string()
}
TreasureFieldError::InvalidRewardItemQuantity => {
"battle_state.reward_items[].quantity 必须大于 0".to_string()
}
TreasureFieldError::MissingRewardItemStackKey => {
"battle_state.reward_items[].stack_key 不能为空".to_string()
}
TreasureFieldError::RewardEquipmentItemCannotStack => {
"battle_state.reward_items[] 可装备物品不能标记为 stackable".to_string()
}
TreasureFieldError::RewardNonStackableItemMustStaySingleQuantity => {
"battle_state.reward_items[] 不可堆叠物品必须固定为单槽位单数量".to_string()
}
other => other.to_string(),
};
CombatFieldError::InvalidRewardItem(message)
}

View File

@@ -1,8 +1,9 @@
//! 战斗领域模型过渡落位 //! 战斗领域模型。
//! //!
//! 后续迁移 `BattleState` 与行动结算规则时,只保留单聚合内部状态变化; //! 本文件只承载战斗聚合内部状态和值对象;背包奖励、成长记账和任务联动由
//! 背包奖励、成长记账和任务联动由应用服务或 SpacetimeDB 事务 adapter 编排。 //! SpacetimeDB 事务 adapter 编排,不在战斗领域内直连其他上下文
use module_runtime_item::RuntimeItemRewardItemSnapshot;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
#[cfg(feature = "spacetime-types")] #[cfg(feature = "spacetime-types")]
use spacetimedb::SpacetimeType; use spacetimedb::SpacetimeType;
@@ -83,3 +84,35 @@ impl CombatOutcome {
} }
} }
} }
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct BattleStateSnapshot {
pub battle_state_id: String,
pub story_session_id: String,
pub runtime_session_id: String,
pub actor_user_id: String,
pub chapter_id: Option<String>,
pub target_npc_id: String,
pub target_name: String,
pub battle_mode: BattleMode,
pub status: BattleStatus,
pub player_hp: i32,
pub player_max_hp: i32,
pub player_mana: i32,
pub player_max_mana: i32,
pub target_hp: i32,
pub target_max_hp: i32,
pub experience_reward: u32,
pub reward_items: Vec<RuntimeItemRewardItemSnapshot>,
pub turn_index: u32,
pub last_action_function_id: Option<String>,
pub last_action_text: Option<String>,
pub last_result_text: Option<String>,
pub last_damage_dealt: i32,
pub last_damage_taken: i32,
pub last_outcome: CombatOutcome,
pub version: u32,
pub created_at_micros: i64,
pub updated_at_micros: i64,
}

View File

@@ -1,3 +1,50 @@
//! 战斗领域错误过渡落位 //! 战斗领域错误。
//! //!
//! 错误保持纯领域语义,不能绑定 HTTP 状态码或 SpacetimeDB 字符串格式。 //! 错误保持纯领域语义,不能绑定 HTTP 状态码或 SpacetimeDB 字符串格式。
use std::{error::Error, fmt};
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum CombatFieldError {
MissingBattleStateId,
MissingStorySessionId,
MissingRuntimeSessionId,
MissingActorUserId,
MissingTargetNpcId,
MissingTargetName,
MissingFunctionId,
InvalidVersion,
InvalidPlayerVitals,
InvalidTargetVitals,
InvalidRewardItem(String),
BattleAlreadyResolved,
UnsupportedFunctionId,
InsufficientMana,
}
impl fmt::Display for CombatFieldError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::MissingBattleStateId => f.write_str("battle_state.battle_state_id 不能为空"),
Self::MissingStorySessionId => f.write_str("battle_state.story_session_id 不能为空"),
Self::MissingRuntimeSessionId => {
f.write_str("battle_state.runtime_session_id 不能为空")
}
Self::MissingActorUserId => f.write_str("battle_state.actor_user_id 不能为空"),
Self::MissingTargetNpcId => f.write_str("battle_state.target_npc_id 不能为空"),
Self::MissingTargetName => f.write_str("battle_state.target_name 不能为空"),
Self::MissingFunctionId => f.write_str("resolve_combat_action.function_id 不能为空"),
Self::InvalidVersion => f.write_str("battle_state.version 必须大于 0"),
Self::InvalidPlayerVitals => f.write_str("battle_state 玩家生命或灵力字段不合法"),
Self::InvalidTargetVitals => f.write_str("battle_state 目标生命字段不合法"),
Self::InvalidRewardItem(message) => f.write_str(message),
Self::BattleAlreadyResolved => f.write_str("battle_state 已经结束,不能继续结算"),
Self::UnsupportedFunctionId => {
f.write_str("resolve_combat_action.function_id 当前不受支持")
}
Self::InsufficientMana => f.write_str("当前灵力不足,无法执行该战斗动作"),
}
}
}
impl Error for CombatFieldError {}

View File

@@ -1,3 +1,34 @@
//! 战斗领域事件过渡落位 //! 战斗领域事件。
//! //!
//! 用于表达战斗胜利、切磋完成、奖励待发放和战斗被终止等事实。 //! 用于表达战斗胜利、切磋完成、奖励待发放和战斗被终止等事实。
use crate::domain::CombatOutcome;
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 enum CombatDomainEvent {
BattleActionResolved(CombatBattleActionResolvedEvent),
BattleRewardPending(CombatBattleRewardPendingEvent),
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct CombatBattleActionResolvedEvent {
pub battle_state_id: String,
pub outcome: CombatOutcome,
pub damage_dealt: i32,
pub damage_taken: i32,
pub occurred_at_micros: i64,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct CombatBattleRewardPendingEvent {
pub battle_state_id: String,
pub actor_user_id: String,
pub experience_reward: u32,
pub occurred_at_micros: i64,
}

View File

@@ -4,531 +4,16 @@ mod domain;
mod errors; mod errors;
mod events; mod events;
pub use application::*;
pub use commands::*;
pub use domain::*; pub use domain::*;
pub use errors::*;
use std::{error::Error, fmt}; pub use events::*;
use crate::domain::LEGACY_ATTACK_FUNCTION_IDS;
use module_runtime_item::{
RuntimeItemRewardItemSnapshot, TreasureFieldError, normalize_reward_item_snapshot,
};
use serde::{Deserialize, Serialize};
use shared_kernel::{build_prefixed_seed_id, normalize_required_string};
#[cfg(feature = "spacetime-types")]
use spacetimedb::SpacetimeType;
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum CombatFieldError {
MissingBattleStateId,
MissingStorySessionId,
MissingRuntimeSessionId,
MissingActorUserId,
MissingTargetNpcId,
MissingTargetName,
MissingFunctionId,
InvalidVersion,
InvalidPlayerVitals,
InvalidTargetVitals,
InvalidRewardItem(String),
BattleAlreadyResolved,
UnsupportedFunctionId,
InsufficientMana,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct BattleStateInput {
pub battle_state_id: String,
pub story_session_id: String,
pub runtime_session_id: String,
pub actor_user_id: String,
pub chapter_id: Option<String>,
pub target_npc_id: String,
pub target_name: String,
pub battle_mode: BattleMode,
pub player_hp: i32,
pub player_max_hp: i32,
pub player_mana: i32,
pub player_max_mana: i32,
pub target_hp: i32,
pub target_max_hp: i32,
pub experience_reward: u32,
pub reward_items: Vec<RuntimeItemRewardItemSnapshot>,
pub created_at_micros: i64,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct BattleStateSnapshot {
pub battle_state_id: String,
pub story_session_id: String,
pub runtime_session_id: String,
pub actor_user_id: String,
pub chapter_id: Option<String>,
pub target_npc_id: String,
pub target_name: String,
pub battle_mode: BattleMode,
pub status: BattleStatus,
pub player_hp: i32,
pub player_max_hp: i32,
pub player_mana: i32,
pub player_max_mana: i32,
pub target_hp: i32,
pub target_max_hp: i32,
pub experience_reward: u32,
pub reward_items: Vec<RuntimeItemRewardItemSnapshot>,
pub turn_index: u32,
pub last_action_function_id: Option<String>,
pub last_action_text: Option<String>,
pub last_result_text: Option<String>,
pub last_damage_dealt: i32,
pub last_damage_taken: i32,
pub last_outcome: CombatOutcome,
pub version: u32,
pub created_at_micros: i64,
pub updated_at_micros: i64,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct ResolveCombatActionInput {
pub battle_state_id: String,
pub function_id: String,
pub action_text: String,
pub base_damage: i32,
pub mana_cost: i32,
pub heal: i32,
pub mana_restore: i32,
pub counter_multiplier_basis_points: u32,
pub updated_at_micros: i64,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct BattleStateQueryInput {
pub battle_state_id: String,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct ResolveCombatActionResult {
pub snapshot: BattleStateSnapshot,
pub damage_dealt: i32,
pub damage_taken: i32,
pub outcome: CombatOutcome,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct BattleStateProcedureResult {
pub ok: bool,
pub snapshot: Option<BattleStateSnapshot>,
pub error_message: Option<String>,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct ResolveCombatActionProcedureResult {
pub ok: bool,
pub result: Option<ResolveCombatActionResult>,
pub error_message: Option<String>,
}
pub fn validate_battle_state_input(input: &BattleStateInput) -> Result<(), CombatFieldError> {
if normalize_required_string(&input.battle_state_id).is_none() {
return Err(CombatFieldError::MissingBattleStateId);
}
if normalize_required_string(&input.story_session_id).is_none() {
return Err(CombatFieldError::MissingStorySessionId);
}
if normalize_required_string(&input.runtime_session_id).is_none() {
return Err(CombatFieldError::MissingRuntimeSessionId);
}
if normalize_required_string(&input.actor_user_id).is_none() {
return Err(CombatFieldError::MissingActorUserId);
}
if normalize_required_string(&input.target_npc_id).is_none() {
return Err(CombatFieldError::MissingTargetNpcId);
}
if normalize_required_string(&input.target_name).is_none() {
return Err(CombatFieldError::MissingTargetName);
}
if input.player_max_hp <= 0 || input.player_hp <= 0 || input.player_hp > input.player_max_hp {
return Err(CombatFieldError::InvalidPlayerVitals);
}
if input.player_max_mana < 0
|| input.player_mana < 0
|| input.player_mana > input.player_max_mana
{
return Err(CombatFieldError::InvalidPlayerVitals);
}
if input.target_max_hp <= 0 || input.target_hp <= 0 || input.target_hp > input.target_max_hp {
return Err(CombatFieldError::InvalidTargetVitals);
}
for reward_item in input.reward_items.iter().cloned() {
normalize_reward_item_snapshot(reward_item).map_err(map_reward_item_field_error)?;
}
Ok(())
}
pub fn validate_resolve_combat_action_input(
input: &ResolveCombatActionInput,
) -> Result<(), CombatFieldError> {
if normalize_required_string(&input.battle_state_id).is_none() {
return Err(CombatFieldError::MissingBattleStateId);
}
if normalize_required_string(&input.function_id).is_none() {
return Err(CombatFieldError::MissingFunctionId);
}
if !is_supported_combat_function_id(&input.function_id) {
return Err(CombatFieldError::UnsupportedFunctionId);
}
Ok(())
}
pub fn build_battle_state_query_input(
battle_state_id: String,
) -> Result<BattleStateQueryInput, CombatFieldError> {
let input = BattleStateQueryInput {
battle_state_id: normalize_required_string(battle_state_id).unwrap_or_default(),
};
validate_battle_state_query_input(&input)?;
Ok(input)
}
pub fn validate_battle_state_query_input(
input: &BattleStateQueryInput,
) -> Result<(), CombatFieldError> {
if normalize_required_string(&input.battle_state_id).is_none() {
return Err(CombatFieldError::MissingBattleStateId);
}
Ok(())
}
pub fn build_battle_state_snapshot(input: BattleStateInput) -> BattleStateSnapshot {
BattleStateSnapshot {
battle_state_id: input.battle_state_id,
story_session_id: input.story_session_id,
runtime_session_id: input.runtime_session_id,
actor_user_id: input.actor_user_id,
chapter_id: input.chapter_id,
target_npc_id: input.target_npc_id,
target_name: input.target_name,
battle_mode: input.battle_mode,
status: BattleStatus::Ongoing,
player_hp: input.player_hp,
player_max_hp: input.player_max_hp,
player_mana: input.player_mana,
player_max_mana: input.player_max_mana,
target_hp: input.target_hp,
target_max_hp: input.target_max_hp,
experience_reward: input.experience_reward,
reward_items: input.reward_items,
turn_index: 0,
last_action_function_id: None,
last_action_text: None,
last_result_text: None,
last_damage_dealt: 0,
last_damage_taken: 0,
last_outcome: CombatOutcome::Ongoing,
version: INITIAL_BATTLE_VERSION,
created_at_micros: input.created_at_micros,
updated_at_micros: input.created_at_micros,
}
}
pub fn resolve_combat_action(
current: BattleStateSnapshot,
input: ResolveCombatActionInput,
) -> Result<ResolveCombatActionResult, CombatFieldError> {
validate_resolve_combat_action_input(&input)?;
if current.version == 0 {
return Err(CombatFieldError::InvalidVersion);
}
if current.status != BattleStatus::Ongoing {
return Err(CombatFieldError::BattleAlreadyResolved);
}
if current.player_mana < input.mana_cost.max(0) {
return Err(CombatFieldError::InsufficientMana);
}
let action_text = if input.action_text.trim().is_empty() {
input.function_id.clone()
} else {
normalize_required_string(input.action_text).unwrap_or_else(|| input.function_id.clone())
};
if input.function_id == "battle_escape_breakout" {
let next = BattleStateSnapshot {
status: BattleStatus::Resolved,
turn_index: current.turn_index + 1,
last_action_function_id: Some(input.function_id),
last_action_text: Some(action_text),
last_result_text: Some(format!("你抓住空当摆脱了{}的压制。", current.target_name)),
last_damage_dealt: 0,
last_damage_taken: 0,
last_outcome: CombatOutcome::Escaped,
version: current.version + 1,
updated_at_micros: input.updated_at_micros,
..current
};
return Ok(ResolveCombatActionResult {
snapshot: next,
damage_dealt: 0,
damage_taken: 0,
outcome: CombatOutcome::Escaped,
});
}
let mana_cost = input.mana_cost.max(0);
let heal = input.heal.max(0);
let mana_restore = input.mana_restore.max(0);
let base_damage = input.base_damage.max(0);
let mut next_player_hp = current.player_hp;
let mut next_player_mana = (current.player_mana - mana_cost).max(0);
let mut next_target_hp = current.target_hp;
let mut damage_dealt = 0;
let mut damage_taken = 0;
next_player_hp = clamp_hp(
current.battle_mode,
next_player_hp + heal,
current.player_max_hp,
);
next_player_mana = clamp_mana(next_player_mana + mana_restore, current.player_max_mana);
if base_damage > 0 {
next_target_hp =
clamp_target_hp_after_damage(current.battle_mode, current.target_hp, base_damage);
damage_dealt = current.target_hp - next_target_hp;
}
let (status, outcome, result_text) = if is_target_resolved(current.battle_mode, next_target_hp)
{
let outcome = match current.battle_mode {
BattleMode::Fight => CombatOutcome::Victory,
BattleMode::Spar => CombatOutcome::SparComplete,
};
(
BattleStatus::Resolved,
outcome,
build_resolved_result_text(&action_text, &current.target_name, outcome),
)
} else {
damage_taken = compute_counter_damage(
current.battle_mode,
current.target_max_hp,
input.counter_multiplier_basis_points,
);
next_player_hp = clamp_hp(
current.battle_mode,
next_player_hp - damage_taken,
current.player_max_hp,
);
(
BattleStatus::Ongoing,
CombatOutcome::Ongoing,
build_ongoing_result_text(&input.function_id, &action_text, &current.target_name),
)
};
let next = BattleStateSnapshot {
player_hp: next_player_hp,
player_mana: next_player_mana,
target_hp: next_target_hp,
status,
turn_index: current.turn_index + 1,
last_action_function_id: Some(input.function_id),
last_action_text: Some(action_text),
last_result_text: Some(result_text),
last_damage_dealt: damage_dealt,
last_damage_taken: damage_taken,
last_outcome: outcome,
version: current.version + 1,
updated_at_micros: input.updated_at_micros,
..current
};
Ok(ResolveCombatActionResult {
snapshot: next,
damage_dealt,
damage_taken,
outcome,
})
}
pub fn generate_battle_state_id(seed_micros: i64) -> String {
build_prefixed_seed_id(BATTLE_STATE_ID_PREFIX, seed_micros)
}
pub fn is_supported_combat_function_id(function_id: &str) -> bool {
matches!(
function_id,
"battle_attack_basic"
| "battle_recover_breath"
| "battle_use_skill"
| "battle_escape_breakout"
) || LEGACY_ATTACK_FUNCTION_IDS.contains(&function_id)
}
fn clamp_hp(mode: BattleMode, value: i32, max_hp: i32) -> i32 {
let min_hp = match mode {
BattleMode::Fight => 0,
BattleMode::Spar => SPAR_MIN_HP,
};
value.clamp(min_hp, max_hp)
}
fn clamp_mana(value: i32, max_mana: i32) -> i32 {
value.clamp(0, max_mana)
}
fn clamp_target_hp_after_damage(mode: BattleMode, current_hp: i32, damage: i32) -> i32 {
match mode {
BattleMode::Fight => (current_hp - damage).max(0),
BattleMode::Spar => (current_hp - damage).max(SPAR_MIN_HP),
}
}
fn is_target_resolved(mode: BattleMode, target_hp: i32) -> bool {
match mode {
BattleMode::Fight => target_hp <= 0,
BattleMode::Spar => target_hp <= SPAR_MIN_HP,
}
}
fn compute_counter_damage(
mode: BattleMode,
target_max_hp: i32,
counter_multiplier_basis_points: u32,
) -> i32 {
match mode {
BattleMode::Spar => 1,
BattleMode::Fight => {
let multiplier = counter_multiplier_basis_points as f32 / 10_000.0;
let raw =
(target_max_hp as f32 * BASIC_FIGHT_COUNTER_RATIO * multiplier).round() as i32;
raw.max(MIN_FIGHT_COUNTER_DAMAGE)
}
}
}
fn build_resolved_result_text(
action_text: &str,
target_name: &str,
outcome: CombatOutcome,
) -> String {
match outcome {
CombatOutcome::Victory => {
format!(
"{}命中了{},这轮战斗已经正式结束。",
action_text, target_name
)
}
CombatOutcome::SparComplete => {
format!(
"{}压住了{}的节奏,这场切磋已经分出高下。",
action_text, target_name
)
}
CombatOutcome::Escaped => {
format!("{}后你成功脱离了当前战斗。", action_text)
}
CombatOutcome::Ongoing => format!("{}已完成结算。", action_text),
}
}
fn build_ongoing_result_text(function_id: &str, action_text: &str, target_name: &str) -> String {
match function_id {
"battle_recover_breath" => {
format!(
"你先把伤势和气息稳住了一轮,但{}仍在持续逼近。",
target_name
)
}
"battle_use_skill" => {
format!(
"{}命中了{},这一轮技能效果已经直接结算。",
action_text, target_name
)
}
_ => format!(
"{}命中了{},本次攻击已经完成结算。",
action_text, target_name
),
}
}
fn map_reward_item_field_error(error: TreasureFieldError) -> CombatFieldError {
let message = match error {
TreasureFieldError::MissingRewardItemId => {
"battle_state.reward_items[].item_id 不能为空".to_string()
}
TreasureFieldError::MissingRewardItemCategory => {
"battle_state.reward_items[].category 不能为空".to_string()
}
TreasureFieldError::MissingRewardItemName => {
"battle_state.reward_items[].item_name 不能为空".to_string()
}
TreasureFieldError::InvalidRewardItemQuantity => {
"battle_state.reward_items[].quantity 必须大于 0".to_string()
}
TreasureFieldError::MissingRewardItemStackKey => {
"battle_state.reward_items[].stack_key 不能为空".to_string()
}
TreasureFieldError::RewardEquipmentItemCannotStack => {
"battle_state.reward_items[] 可装备物品不能标记为 stackable".to_string()
}
TreasureFieldError::RewardNonStackableItemMustStaySingleQuantity => {
"battle_state.reward_items[] 不可堆叠物品必须固定为单槽位单数量".to_string()
}
other => other.to_string(),
};
CombatFieldError::InvalidRewardItem(message)
}
impl fmt::Display for CombatFieldError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::MissingBattleStateId => f.write_str("battle_state.battle_state_id 不能为空"),
Self::MissingStorySessionId => f.write_str("battle_state.story_session_id 不能为空"),
Self::MissingRuntimeSessionId => {
f.write_str("battle_state.runtime_session_id 不能为空")
}
Self::MissingActorUserId => f.write_str("battle_state.actor_user_id 不能为空"),
Self::MissingTargetNpcId => f.write_str("battle_state.target_npc_id 不能为空"),
Self::MissingTargetName => f.write_str("battle_state.target_name 不能为空"),
Self::MissingFunctionId => f.write_str("resolve_combat_action.function_id 不能为空"),
Self::InvalidVersion => f.write_str("battle_state.version 必须大于 0"),
Self::InvalidPlayerVitals => f.write_str("battle_state 玩家生命或灵力字段不合法"),
Self::InvalidTargetVitals => f.write_str("battle_state 目标生命字段不合法"),
Self::InvalidRewardItem(message) => f.write_str(message),
Self::BattleAlreadyResolved => f.write_str("battle_state 已经结束,不能继续结算"),
Self::UnsupportedFunctionId => {
f.write_str("resolve_combat_action.function_id 当前不受支持")
}
Self::InsufficientMana => f.write_str("当前灵力不足,无法执行该战斗动作"),
}
}
}
impl Error for CombatFieldError {}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
use module_runtime_item::RuntimeItemRewardItemSnapshot;
fn build_fight_snapshot() -> BattleStateSnapshot { fn build_fight_snapshot() -> BattleStateSnapshot {
build_battle_state_snapshot(BattleStateInput { build_battle_state_snapshot(BattleStateInput {

View File

@@ -14,42 +14,41 @@
## 2. 当前阶段说明 ## 2. 当前阶段说明
当前阶段已经不再是单纯目录占位,而是先把 `M5` 首批 `custom world / agent` 类型契约字段校验固定下来,避免 `spacetime-module` 在缺少领域边界的情况下直接堆表。 当前阶段已经不再是单纯目录占位,`custom world / agent` 类型契约字段校验、发布编译规则和 Agent action 应用结果已经固定到 DDD 骨架中,避免 `spacetime-module` 在缺少领域边界的情况下直接堆表。
当前已落地: 当前已落地:
1. 真实 `Cargo.toml` crate scaffold 1. 真实 `Cargo.toml` crate scaffold
2. `src/domain.rs` 承接 `CustomWorldPublicationStatus``CustomWorldThemeMode``CustomWorldGenerationMode` 2. `src/domain.rs` 承接基础枚举、进度常量、profile/session/card/gallery/publish gate 快照与结果类型。
3. `CustomWorldSessionStatus``RpgAgentStage` 3. `src/commands.rs` 承接 profile、library/gallery、Agent session/message/operation/action、published profile compile 和 publish world 输入 DTO。
4. `RpgAgentMessageRole``RpgAgentMessageKind` 4. `src/application.rs` 承接字段校验、默认 JSON、profile canonicalize、published profile compile 和 publish gate 相关纯规则。
5. `RpgAgentOperationType``RpgAgentOperationStatus` 5. `src/errors.rs` 承接 `CustomWorldFieldError` 与中文错误文案。
6. `RpgAgentDraftCardKind``RpgAgentDraftCardStatus` 6. `src/events.rs` 承接 Custom World 领域事件与 payload struct。
7. `CustomWorldRoleAssetStatus` 7. `src/lib.rs` 只保留模块声明、公开导出和测试,继续保持 `module_custom_world::*` 公开 API。
8. 首批表字段校验函数与最小单测 8. `spacetime-module``generate_characters``generate_landmarks``generate_role_assets``sync_role_assets``generate_scene_assets``sync_scene_assets``expand_long_tail` 已移除最小兼容占位,改为确定性状态编排。
9. `published profile compile` 输入输出 contract
10. `publish_world` 串联输入输出 contract
当前 crate 仍然只承接: 当前 crate 仍然只承接:
1. 共享枚举、进度常量与类型口径,基础枚举统一从 `src/domain.rs` 导出 1. 共享枚举、进度常量与类型口径,基础枚举统一从 `src/domain.rs` 导出
2. 字段校验字符串归一化 2. 字段校验字符串归一化与发布编译纯规则。
3. published profile compile 的最小编译摘要 contract 3. published profile compile 与 publish world 的输入输出 contract
4. 后续 `spacetime-module` 聚合表时需要复用的领域边界 4. 后续 `spacetime-module` 聚合表时需要复用的领域边界
当前阶段明确不提前进入: 当前阶段明确不提前进入:
1. 旧问答流 reducer 编排 1. 旧问答流 reducer 编排
2. RPG 创作 Agent 编排 2. 外部 LLM 创作编排、图片生成、OSS 上传和 SSE 推送。
3. publish gate blocker 规则迁移 3. 资产对象真相表、资产绑定表和完整资产历史。
4. 资产绑定与图片生成副作用 4. 前端创作流程和 UI 表现状态。
当前设计依据: 当前设计依据:
1. [../../../docs/technical/SERVER_RS_DDD_WP_CW_DOMAIN_ENUM_REHOME_2026-04-29.md](../../../docs/technical/SERVER_RS_DDD_WP_CW_DOMAIN_ENUM_REHOME_2026-04-29.md) 1. [../../../docs/technical/SERVER_RS_DDD_WP_CW_DOMAIN_ENUM_REHOME_2026-04-29.md](../../../docs/technical/SERVER_RS_DDD_WP_CW_DOMAIN_ENUM_REHOME_2026-04-29.md)
2. [../../../docs/technical/SPACETIMEDB_CUSTOM_WORLD_AGENT_STAGE1_TABLE_DESIGN_2026-04-21.md](../../../docs/technical/SPACETIMEDB_CUSTOM_WORLD_AGENT_STAGE1_TABLE_DESIGN_2026-04-21.md) 2. [../../../docs/technical/SERVER_RS_DDD_WP_CW_ACTION_AND_DOMAIN_SPLIT_2026-04-30.md](../../../docs/technical/SERVER_RS_DDD_WP_CW_ACTION_AND_DOMAIN_SPLIT_2026-04-30.md)
3. [../../../backend-rewrite-tasklist/04_M5_CUSTOM_WORLD_AND_AGENT.md](../../../backend-rewrite-tasklist/04_M5_CUSTOM_WORLD_AND_AGENT.md) 3. [../../../docs/technical/SPACETIMEDB_CUSTOM_WORLD_AGENT_STAGE1_TABLE_DESIGN_2026-04-21.md](../../../docs/technical/SPACETIMEDB_CUSTOM_WORLD_AGENT_STAGE1_TABLE_DESIGN_2026-04-21.md)
4. [../../../docs/technical/SPACETIMEDB_CUSTOM_WORLD_PUBLISHED_PROFILE_COMPILE_STAGE3_DESIGN_2026-04-21.md](../../../docs/technical/SPACETIMEDB_CUSTOM_WORLD_PUBLISHED_PROFILE_COMPILE_STAGE3_DESIGN_2026-04-21.md) 4. [../../../backend-rewrite-tasklist/04_M5_CUSTOM_WORLD_AND_AGENT.md](../../../backend-rewrite-tasklist/04_M5_CUSTOM_WORLD_AND_AGENT.md)
5. [../../../docs/technical/SPACETIMEDB_CUSTOM_WORLD_PUBLISH_WORLD_STAGE4_DESIGN_2026-04-21.md](../../../docs/technical/SPACETIMEDB_CUSTOM_WORLD_PUBLISH_WORLD_STAGE4_DESIGN_2026-04-21.md) 5. [../../../docs/technical/SPACETIMEDB_CUSTOM_WORLD_PUBLISHED_PROFILE_COMPILE_STAGE3_DESIGN_2026-04-21.md](../../../docs/technical/SPACETIMEDB_CUSTOM_WORLD_PUBLISHED_PROFILE_COMPILE_STAGE3_DESIGN_2026-04-21.md)
6. [../../../docs/technical/SPACETIMEDB_CUSTOM_WORLD_PUBLISH_WORLD_STAGE4_DESIGN_2026-04-21.md](../../../docs/technical/SPACETIMEDB_CUSTOM_WORLD_PUBLISH_WORLD_STAGE4_DESIGN_2026-04-21.md)
后续与本 package 直接相关的任务包括: 后续与本 package 直接相关的任务包括:

View File

@@ -1,3 +1,957 @@
//! 自定义世界应用编排过渡落位 //! 自定义世界应用规则
//! //!
//! 这里只组合领域规则并返回草稿、发布门禁、投影刷新等结果或事件 //! 这里只组合领域校验、草稿编译、profile 归一化和默认 JSON 结构,不承接外部副作用
use crate::{commands::*, domain::*, errors::CustomWorldFieldError};
use serde_json::{Map, Value};
pub fn validate_custom_world_profile_fields(
profile_id: &str,
owner_user_id: &str,
world_name: &str,
profile_payload_json: &str,
) -> Result<(), CustomWorldFieldError> {
if profile_id.trim().is_empty() {
return Err(CustomWorldFieldError::MissingProfileId);
}
if owner_user_id.trim().is_empty() {
return Err(CustomWorldFieldError::MissingOwnerUserId);
}
if world_name.trim().is_empty() {
return Err(CustomWorldFieldError::MissingWorldName);
}
if profile_payload_json.trim().is_empty() {
return Err(CustomWorldFieldError::MissingProfilePayloadJson);
}
Ok(())
}
pub fn validate_custom_world_published_profile_compile_input(
input: &CustomWorldPublishedProfileCompileInput,
) -> Result<(), CustomWorldFieldError> {
if input.session_id.trim().is_empty() {
return Err(CustomWorldFieldError::MissingSessionId);
}
if input.profile_id.trim().is_empty() {
return Err(CustomWorldFieldError::MissingProfileId);
}
if input.owner_user_id.trim().is_empty() {
return Err(CustomWorldFieldError::MissingOwnerUserId);
}
if input.draft_profile_json.trim().is_empty() {
return Err(CustomWorldFieldError::MissingDraftProfileJson);
}
if input.setting_text.trim().is_empty() {
return Err(CustomWorldFieldError::MissingSettingText);
}
if input.author_display_name.trim().is_empty() {
return Err(CustomWorldFieldError::MissingAuthorDisplayName);
}
Ok(())
}
pub fn validate_custom_world_publish_world_input(
input: &CustomWorldPublishWorldInput,
) -> Result<(), CustomWorldFieldError> {
if input.author_public_user_code.trim().is_empty() {
return Err(CustomWorldFieldError::MissingOwnerUserId);
}
validate_custom_world_published_profile_compile_input(
&CustomWorldPublishedProfileCompileInput {
session_id: input.session_id.clone(),
profile_id: input.profile_id.clone(),
owner_user_id: input.owner_user_id.clone(),
draft_profile_json: input.draft_profile_json.clone(),
legacy_result_profile_json: input.legacy_result_profile_json.clone(),
setting_text: input.setting_text.clone(),
author_display_name: input.author_display_name.clone(),
updated_at_micros: input.published_at_micros,
},
)
}
pub fn validate_custom_world_profile_upsert_input(
input: &CustomWorldProfileUpsertInput,
) -> Result<(), CustomWorldFieldError> {
validate_custom_world_profile_fields(
&input.profile_id,
&input.owner_user_id,
&input.world_name,
&input.profile_payload_json,
)?;
if input.author_display_name.trim().is_empty() {
return Err(CustomWorldFieldError::MissingAuthorDisplayName);
}
Ok(())
}
pub fn validate_custom_world_profile_publish_input(
input: &CustomWorldProfilePublishInput,
) -> Result<(), CustomWorldFieldError> {
if input.profile_id.trim().is_empty() {
return Err(CustomWorldFieldError::MissingProfileId);
}
if input.owner_user_id.trim().is_empty() {
return Err(CustomWorldFieldError::MissingOwnerUserId);
}
if input.author_display_name.trim().is_empty() {
return Err(CustomWorldFieldError::MissingAuthorDisplayName);
}
if input.author_public_user_code.trim().is_empty() {
return Err(CustomWorldFieldError::MissingOwnerUserId);
}
Ok(())
}
pub fn validate_custom_world_profile_unpublish_input(
input: &CustomWorldProfileUnpublishInput,
) -> Result<(), CustomWorldFieldError> {
if input.profile_id.trim().is_empty() {
return Err(CustomWorldFieldError::MissingProfileId);
}
if input.owner_user_id.trim().is_empty() {
return Err(CustomWorldFieldError::MissingOwnerUserId);
}
if input.author_display_name.trim().is_empty() {
return Err(CustomWorldFieldError::MissingAuthorDisplayName);
}
Ok(())
}
pub fn validate_custom_world_profile_delete_input(
input: &CustomWorldProfileDeleteInput,
) -> Result<(), CustomWorldFieldError> {
if input.profile_id.trim().is_empty() {
return Err(CustomWorldFieldError::MissingProfileId);
}
if input.owner_user_id.trim().is_empty() {
return Err(CustomWorldFieldError::MissingOwnerUserId);
}
Ok(())
}
pub fn validate_custom_world_profile_list_input(
input: &CustomWorldProfileListInput,
) -> Result<(), CustomWorldFieldError> {
if input.owner_user_id.trim().is_empty() {
return Err(CustomWorldFieldError::MissingOwnerUserId);
}
Ok(())
}
pub fn validate_custom_world_library_detail_input(
input: &CustomWorldLibraryDetailInput,
) -> Result<(), CustomWorldFieldError> {
if input.owner_user_id.trim().is_empty() {
return Err(CustomWorldFieldError::MissingOwnerUserId);
}
if input.profile_id.trim().is_empty() {
return Err(CustomWorldFieldError::MissingProfileId);
}
Ok(())
}
pub fn validate_custom_world_gallery_detail_input(
input: &CustomWorldGalleryDetailInput,
) -> Result<(), CustomWorldFieldError> {
if input.owner_user_id.trim().is_empty() {
return Err(CustomWorldFieldError::MissingOwnerUserId);
}
if input.profile_id.trim().is_empty() {
return Err(CustomWorldFieldError::MissingProfileId);
}
Ok(())
}
pub fn validate_custom_world_gallery_detail_by_code_input(
input: &CustomWorldGalleryDetailByCodeInput,
) -> Result<(), CustomWorldFieldError> {
if input.public_work_code.trim().is_empty() {
return Err(CustomWorldFieldError::MissingPublicWorkCode);
}
Ok(())
}
pub fn validate_custom_world_session_fields(
session_id: &str,
owner_user_id: &str,
setting_text: &str,
question_snapshot_json: &str,
) -> Result<(), CustomWorldFieldError> {
if session_id.trim().is_empty() {
return Err(CustomWorldFieldError::MissingSessionId);
}
if owner_user_id.trim().is_empty() {
return Err(CustomWorldFieldError::MissingOwnerUserId);
}
if setting_text.trim().is_empty() {
return Err(CustomWorldFieldError::MissingSettingText);
}
if question_snapshot_json.trim().is_empty() {
return Err(CustomWorldFieldError::MissingQuestionSnapshotJson);
}
Ok(())
}
pub fn validate_custom_world_agent_session_fields(
session_id: &str,
owner_user_id: &str,
anchor_content_json: &str,
creator_intent_readiness_json: &str,
pending_clarifications_json: &str,
asset_coverage_json: &str,
progress_percent: u32,
) -> Result<(), CustomWorldFieldError> {
if session_id.trim().is_empty() {
return Err(CustomWorldFieldError::MissingSessionId);
}
if owner_user_id.trim().is_empty() {
return Err(CustomWorldFieldError::MissingOwnerUserId);
}
if anchor_content_json.trim().is_empty() {
return Err(CustomWorldFieldError::MissingAnchorContentJson);
}
if creator_intent_readiness_json.trim().is_empty() {
return Err(CustomWorldFieldError::MissingCreatorIntentReadinessJson);
}
if pending_clarifications_json.trim().is_empty() {
return Err(CustomWorldFieldError::MissingPendingClarificationsJson);
}
if asset_coverage_json.trim().is_empty() {
return Err(CustomWorldFieldError::MissingAssetCoverageJson);
}
if progress_percent > MAX_PROGRESS_PERCENT {
return Err(CustomWorldFieldError::InvalidProgressPercent);
}
Ok(())
}
pub fn validate_custom_world_agent_session_create_input(
input: &CustomWorldAgentSessionCreateInput,
) -> Result<(), CustomWorldFieldError> {
validate_custom_world_agent_session_fields(
&input.session_id,
&input.owner_user_id,
&input.anchor_content_json,
&input.creator_intent_readiness_json,
&input.pending_clarifications_json,
&input.asset_coverage_json,
0,
)?;
validate_custom_world_agent_message_fields(
&input.welcome_message_id,
&input.session_id,
&input.welcome_message_text,
)?;
ensure_json_object(&input.anchor_content_json)?;
ensure_optional_json_object(input.creator_intent_json.as_deref())?;
ensure_json_object(&input.creator_intent_readiness_json)?;
ensure_optional_json_object(input.anchor_pack_json.as_deref())?;
ensure_optional_json_object(input.lock_state_json.as_deref())?;
ensure_optional_json_object(input.draft_profile_json.as_deref())?;
ensure_json_array(&input.pending_clarifications_json)?;
ensure_json_array(&input.suggested_actions_json)?;
ensure_json_array(&input.recommended_replies_json)?;
ensure_json_array(&input.quality_findings_json)?;
ensure_json_object(&input.asset_coverage_json)?;
ensure_json_array(&input.checkpoints_json)?;
Ok(())
}
pub fn validate_custom_world_agent_session_get_input(
input: &CustomWorldAgentSessionGetInput,
) -> Result<(), CustomWorldFieldError> {
if input.session_id.trim().is_empty() {
return Err(CustomWorldFieldError::MissingSessionId);
}
if input.owner_user_id.trim().is_empty() {
return Err(CustomWorldFieldError::MissingOwnerUserId);
}
Ok(())
}
pub fn validate_custom_world_agent_message_submit_input(
input: &CustomWorldAgentMessageSubmitInput,
) -> Result<(), CustomWorldFieldError> {
if input.owner_user_id.trim().is_empty() {
return Err(CustomWorldFieldError::MissingOwnerUserId);
}
validate_custom_world_agent_message_fields(
&input.user_message_id,
&input.session_id,
&input.user_message_text,
)?;
validate_custom_world_agent_operation_fields(
&input.operation_id,
&input.session_id,
"消息已处理",
MAX_PROGRESS_PERCENT,
)?;
Ok(())
}
pub fn validate_custom_world_agent_message_finalize_input(
input: &CustomWorldAgentMessageFinalizeInput,
) -> Result<(), CustomWorldFieldError> {
if input.owner_user_id.trim().is_empty() {
return Err(CustomWorldFieldError::MissingOwnerUserId);
}
match input.operation_status {
RpgAgentOperationStatus::Completed => {
validate_custom_world_agent_message_fields(
input.assistant_message_id.as_deref().unwrap_or_default(),
&input.session_id,
input.assistant_reply_text.as_deref().unwrap_or_default(),
)?;
}
RpgAgentOperationStatus::Failed => {}
_ => {
validate_custom_world_agent_message_fields(
input.assistant_message_id.as_deref().unwrap_or_default(),
&input.session_id,
input.assistant_reply_text.as_deref().unwrap_or_default(),
)?;
}
}
validate_custom_world_agent_operation_fields(
&input.operation_id,
&input.session_id,
&input.phase_label,
input.operation_progress,
)?;
validate_custom_world_agent_session_fields(
&input.session_id,
&input.owner_user_id,
&input.anchor_content_json,
&input.creator_intent_readiness_json,
&input.pending_clarifications_json,
&input.asset_coverage_json,
input.progress_percent,
)?;
ensure_json_object(&input.anchor_content_json)?;
ensure_optional_json_object(input.creator_intent_json.as_deref())?;
ensure_json_object(&input.creator_intent_readiness_json)?;
ensure_optional_json_object(input.anchor_pack_json.as_deref())?;
ensure_optional_json_object(input.draft_profile_json.as_deref())?;
ensure_json_array(&input.pending_clarifications_json)?;
ensure_json_array(&input.suggested_actions_json)?;
ensure_json_array(&input.recommended_replies_json)?;
ensure_json_array(&input.quality_findings_json)?;
ensure_json_object(&input.asset_coverage_json)?;
Ok(())
}
pub fn validate_custom_world_agent_operation_get_input(
input: &CustomWorldAgentOperationGetInput,
) -> Result<(), CustomWorldFieldError> {
if input.session_id.trim().is_empty() {
return Err(CustomWorldFieldError::MissingSessionId);
}
if input.owner_user_id.trim().is_empty() {
return Err(CustomWorldFieldError::MissingOwnerUserId);
}
if input.operation_id.trim().is_empty() {
return Err(CustomWorldFieldError::MissingOperationId);
}
Ok(())
}
pub fn validate_custom_world_agent_operation_progress_input(
input: &CustomWorldAgentOperationProgressInput,
) -> Result<(), CustomWorldFieldError> {
validate_custom_world_agent_operation_get_input(&CustomWorldAgentOperationGetInput {
session_id: input.session_id.clone(),
owner_user_id: input.owner_user_id.clone(),
operation_id: input.operation_id.clone(),
})?;
validate_custom_world_agent_operation_fields(
&input.operation_id,
&input.session_id,
&input.phase_label,
input.operation_progress,
)?;
Ok(())
}
pub fn validate_custom_world_works_list_input(
input: &CustomWorldWorksListInput,
) -> Result<(), CustomWorldFieldError> {
if input.owner_user_id.trim().is_empty() {
return Err(CustomWorldFieldError::MissingOwnerUserId);
}
Ok(())
}
pub fn validate_custom_world_agent_card_detail_get_input(
input: &CustomWorldAgentCardDetailGetInput,
) -> Result<(), CustomWorldFieldError> {
if input.session_id.trim().is_empty() {
return Err(CustomWorldFieldError::MissingSessionId);
}
if input.owner_user_id.trim().is_empty() {
return Err(CustomWorldFieldError::MissingOwnerUserId);
}
if input.card_id.trim().is_empty() {
return Err(CustomWorldFieldError::MissingCardId);
}
Ok(())
}
pub fn validate_custom_world_agent_action_execute_input(
input: &CustomWorldAgentActionExecuteInput,
) -> Result<(), CustomWorldFieldError> {
validate_custom_world_agent_operation_get_input(&CustomWorldAgentOperationGetInput {
session_id: input.session_id.clone(),
owner_user_id: input.owner_user_id.clone(),
operation_id: input.operation_id.clone(),
})?;
if input.action.trim().is_empty() {
return Err(CustomWorldFieldError::MissingAction);
}
ensure_optional_json_object(input.payload_json.as_deref())?;
Ok(())
}
pub fn validate_custom_world_agent_message_fields(
message_id: &str,
session_id: &str,
text: &str,
) -> Result<(), CustomWorldFieldError> {
if message_id.trim().is_empty() {
return Err(CustomWorldFieldError::MissingMessageId);
}
if session_id.trim().is_empty() {
return Err(CustomWorldFieldError::MissingSessionId);
}
if text.trim().is_empty() {
return Err(CustomWorldFieldError::MissingMessageText);
}
Ok(())
}
pub fn validate_custom_world_agent_operation_fields(
operation_id: &str,
session_id: &str,
phase_label: &str,
progress: u32,
) -> Result<(), CustomWorldFieldError> {
if operation_id.trim().is_empty() {
return Err(CustomWorldFieldError::MissingOperationId);
}
if session_id.trim().is_empty() {
return Err(CustomWorldFieldError::MissingSessionId);
}
if phase_label.trim().is_empty() {
return Err(CustomWorldFieldError::MissingPhaseLabel);
}
if progress > MAX_PROGRESS_PERCENT {
return Err(CustomWorldFieldError::InvalidProgressPercent);
}
Ok(())
}
pub fn validate_custom_world_draft_card_fields(
card_id: &str,
session_id: &str,
title: &str,
summary: &str,
linked_ids_json: &str,
) -> Result<(), CustomWorldFieldError> {
if card_id.trim().is_empty() {
return Err(CustomWorldFieldError::MissingCardId);
}
if session_id.trim().is_empty() {
return Err(CustomWorldFieldError::MissingSessionId);
}
if title.trim().is_empty() {
return Err(CustomWorldFieldError::MissingCardTitle);
}
if summary.trim().is_empty() {
return Err(CustomWorldFieldError::MissingCardSummary);
}
if linked_ids_json.trim().is_empty() {
return Err(CustomWorldFieldError::MissingLinkedIdsJson);
}
Ok(())
}
pub fn validate_custom_world_gallery_entry_fields(
profile_id: &str,
owner_user_id: &str,
author_display_name: &str,
world_name: &str,
) -> Result<(), CustomWorldFieldError> {
if profile_id.trim().is_empty() {
return Err(CustomWorldFieldError::MissingProfileId);
}
if owner_user_id.trim().is_empty() {
return Err(CustomWorldFieldError::MissingOwnerUserId);
}
if author_display_name.trim().is_empty() {
return Err(CustomWorldFieldError::MissingAuthorDisplayName);
}
if world_name.trim().is_empty() {
return Err(CustomWorldFieldError::MissingWorldName);
}
Ok(())
}
pub fn build_custom_world_published_profile_compile_snapshot(
input: CustomWorldPublishedProfileCompileInput,
) -> Result<CustomWorldPublishedProfileCompileSnapshot, CustomWorldFieldError> {
validate_custom_world_published_profile_compile_input(&input)?;
let draft = parse_required_json_object(
&input.draft_profile_json,
CustomWorldFieldError::InvalidDraftProfileJson,
)?;
let legacy = parse_optional_json_object(
input.legacy_result_profile_json.clone(),
CustomWorldFieldError::InvalidLegacyResultProfileJson,
)?;
let world_name = resolve_text_field(&draft, &legacy, "name")
.ok_or(CustomWorldFieldError::MissingWorldName)?;
let subtitle = resolve_text_field(&draft, &legacy, "subtitle").unwrap_or_default();
let summary_text = resolve_text_field(&draft, &legacy, "summary").unwrap_or_default();
let cover_image_src = resolve_cover_image_src(&draft, &legacy);
let theme_mode = resolve_theme_mode(&legacy);
let playable_npc_count =
count_distinct_roles(draft.get("playableNpcs"), draft.get("storyNpcs"));
let landmark_count = to_array(draft.get("landmarks")).len() as u32;
let compiled_payload_json = build_compiled_profile_payload_json(
&input,
&draft,
&legacy,
&world_name,
&subtitle,
&summary_text,
)?;
Ok(CustomWorldPublishedProfileCompileSnapshot {
profile_id: input.profile_id,
owner_user_id: input.owner_user_id,
world_name,
subtitle,
summary_text,
theme_mode,
cover_image_src,
playable_npc_count,
landmark_count,
author_display_name: input.author_display_name,
compiled_profile_payload_json: compiled_payload_json,
updated_at_micros: input.updated_at_micros,
})
}
pub fn canonicalize_custom_world_profile_before_save(profile: &mut Value) -> bool {
let Some(object) = profile.as_object_mut() else {
return false;
};
let foundation_text = build_creator_intent_foundation_text(object.get("creatorIntent"))
.trim()
.to_string();
if foundation_text.is_empty() {
return false;
}
let current_setting_text = object
.get("settingText")
.and_then(Value::as_str)
.map(str::trim)
.unwrap_or_default();
if current_setting_text == foundation_text {
return false;
}
// 中文注释:保存与 session 同步前统一以后端 creatorIntent 锚点重建 settingText
// 避免浏览器继续持有正式 profile canonicalize 规则。
object.insert("settingText".to_string(), Value::String(foundation_text));
true
}
pub fn empty_agent_anchor_content_json() -> String {
r#"{"worldPromise":null,"playerFantasy":null,"themeBoundary":null,"playerEntryPoint":null,"coreConflict":null,"keyRelationships":null,"hiddenLines":null,"iconicElements":null}"#.to_string()
}
pub fn empty_agent_creator_intent_readiness_json() -> String {
r#"{"isReady":false,"completedKeys":[],"missingKeys":[]}"#.to_string()
}
pub fn empty_agent_asset_coverage_json() -> String {
r#"{"roleAssets":[],"sceneAssets":[],"allRoleAssetsReady":false,"allSceneAssetsReady":false}"#
.to_string()
}
pub fn empty_json_object() -> String {
"{}".to_string()
}
pub fn empty_json_array() -> String {
"[]".to_string()
}
pub fn normalize_optional_json_slice(value: Option<String>) -> Option<String> {
value.and_then(|value| {
let value = value.trim().to_string();
if value.is_empty() { None } else { Some(value) }
})
}
fn ensure_json_object(value: &str) -> Result<(), CustomWorldFieldError> {
match serde_json::from_str::<Value>(value) {
Ok(Value::Object(_)) => Ok(()),
_ => Err(CustomWorldFieldError::InvalidJsonPayload),
}
}
fn ensure_optional_json_object(value: Option<&str>) -> Result<(), CustomWorldFieldError> {
match value.map(str::trim).filter(|value| !value.is_empty()) {
Some(value) => ensure_json_object(value),
None => Ok(()),
}
}
fn ensure_json_array(value: &str) -> Result<(), CustomWorldFieldError> {
match serde_json::from_str::<Value>(value) {
Ok(Value::Array(_)) => Ok(()),
_ => Err(CustomWorldFieldError::InvalidJsonPayload),
}
}
fn parse_required_json_object(
value: &str,
error: CustomWorldFieldError,
) -> Result<Map<String, Value>, CustomWorldFieldError> {
match serde_json::from_str::<Value>(value) {
Ok(Value::Object(object)) => Ok(object),
_ => Err(error),
}
}
fn parse_optional_json_object(
value: Option<String>,
error: CustomWorldFieldError,
) -> Result<Map<String, Value>, CustomWorldFieldError> {
match normalize_optional_json_slice(value) {
Some(value) => parse_required_json_object(&value, error),
None => Ok(Map::new()),
}
}
fn to_text(value: Option<&Value>) -> Option<String> {
match value {
Some(Value::String(value)) => {
let trimmed = value.trim();
if trimmed.is_empty() {
None
} else {
Some(trimmed.to_string())
}
}
_ => None,
}
}
fn to_array(value: Option<&Value>) -> Vec<Value> {
match value {
Some(Value::Array(items)) => items.clone(),
_ => Vec::new(),
}
}
fn to_object(value: Option<&Value>) -> Option<Map<String, Value>> {
match value {
Some(Value::Object(object)) => Some(object.clone()),
_ => None,
}
}
fn build_creator_intent_foundation_text(value: Option<&Value>) -> String {
let Some(intent) = value.and_then(Value::as_object) else {
return String::new();
};
if !has_meaningful_creator_intent(intent) {
return String::new();
}
let relationship_text = intent
.get("keyCharacters")
.and_then(Value::as_array)
.and_then(|items| items.first())
.and_then(Value::as_object)
.map(build_creator_intent_relationship_text)
.unwrap_or_default();
let player_opening_text = [
read_text(intent, "playerPremise"),
read_text(intent, "openingSituation"),
]
.into_iter()
.flatten()
.collect::<Vec<_>>()
.join("");
let theme_tone_text = [
read_string_list(intent, "themeKeywords").join(""),
read_string_list(intent, "toneDirectives").join(""),
]
.into_iter()
.filter(|value| !value.is_empty())
.collect::<Vec<_>>()
.join(" / ");
[
build_anchor_line(
"世界一句话",
read_text(intent, "worldHook").unwrap_or_default(),
),
build_anchor_line("玩家开局", player_opening_text),
build_anchor_line("主题气质", theme_tone_text),
build_anchor_line(
"核心冲突",
read_string_list(intent, "coreConflicts").join(""),
),
build_anchor_line("关键关系", relationship_text),
build_anchor_line(
"标志元素",
read_string_list(intent, "iconicElements").join(""),
),
]
.into_iter()
.filter(|value| !value.is_empty())
.collect::<Vec<_>>()
.join("\n")
}
fn has_meaningful_creator_intent(intent: &Map<String, Value>) -> bool {
[
"rawSettingText",
"worldHook",
"playerPremise",
"openingSituation",
]
.iter()
.any(|key| read_text(intent, key).is_some())
|| [
"themeKeywords",
"toneDirectives",
"coreConflicts",
"iconicElements",
"forbiddenDirectives",
]
.iter()
.any(|key| !read_string_list(intent, key).is_empty())
|| ["keyFactions", "keyCharacters", "keyLandmarks"]
.iter()
.any(|key| has_meaningful_creator_seed_array(intent.get(*key)))
}
fn build_creator_intent_relationship_text(character: &Map<String, Value>) -> String {
[
read_text(character, "name"),
read_text(character, "role"),
read_text(character, "relationToPlayer").map(|value| format!("与玩家 {value}")),
read_text(character, "hiddenHook").map(|value| format!("暗线 {value}")),
]
.into_iter()
.flatten()
.collect::<Vec<_>>()
.join(" · ")
}
fn build_anchor_line(label: &str, content: String) -> String {
if content.is_empty() {
String::new()
} else {
format!("{label}{content}")
}
}
fn read_text(object: &Map<String, Value>, key: &str) -> Option<String> {
object
.get(key)
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned)
}
fn read_string_list(object: &Map<String, Value>, key: &str) -> Vec<String> {
object
.get(key)
.and_then(Value::as_array)
.map(|items| {
items
.iter()
.filter_map(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned)
.collect::<Vec<_>>()
})
.unwrap_or_default()
}
fn has_meaningful_creator_seed_array(value: Option<&Value>) -> bool {
value.and_then(Value::as_array).is_some_and(|items| {
items.iter().any(|item| {
item.as_object().is_some_and(|object| {
[
"name",
"publicGoal",
"tension",
"notes",
"role",
"publicMask",
"hiddenHook",
"relationToPlayer",
"purpose",
"mood",
"secret",
]
.iter()
.any(|key| read_text(object, key).is_some())
})
})
})
}
fn resolve_text_field(
draft: &Map<String, Value>,
legacy: &Map<String, Value>,
key: &str,
) -> Option<String> {
to_text(draft.get(key)).or_else(|| to_text(legacy.get(key)))
}
fn resolve_theme_mode(legacy: &Map<String, Value>) -> CustomWorldThemeMode {
to_text(legacy.get("themeMode"))
.and_then(|value| CustomWorldThemeMode::from_client_str(&value))
.unwrap_or(CustomWorldThemeMode::Mythic)
}
fn resolve_cover_image_src(
draft: &Map<String, Value>,
legacy: &Map<String, Value>,
) -> Option<String> {
if let Some(camp) = to_object(draft.get("camp")) {
if let Some(image_src) = to_text(camp.get("imageSrc")) {
return Some(image_src);
}
}
for landmark in to_array(draft.get("landmarks")) {
if let Value::Object(landmark) = landmark {
if let Some(image_src) = to_text(landmark.get("imageSrc")) {
return Some(image_src);
}
}
}
if let Some(cover) = to_object(legacy.get("cover")) {
if let Some(image_src) = to_text(cover.get("imageSrc")) {
return Some(image_src);
}
}
to_text(legacy.get("coverImageSrc"))
}
fn count_distinct_roles(playable: Option<&Value>, story: Option<&Value>) -> u32 {
let mut seen = std::collections::BTreeSet::new();
for role in to_array(playable).into_iter().chain(to_array(story)) {
if let Value::Object(role) = role {
let key = to_text(role.get("id"))
.or_else(|| to_text(role.get("name")))
.unwrap_or_else(|| format!("role-{}", seen.len()));
seen.insert(key);
}
}
seen.len() as u32
}
fn build_compiled_profile_payload_json(
input: &CustomWorldPublishedProfileCompileInput,
draft: &Map<String, Value>,
legacy: &Map<String, Value>,
world_name: &str,
subtitle: &str,
summary_text: &str,
) -> Result<String, CustomWorldFieldError> {
let mut payload = legacy.clone();
payload.insert("id".to_string(), Value::String(input.profile_id.clone()));
payload.insert(
"settingText".to_string(),
Value::String(input.setting_text.trim().to_string()),
);
payload.insert("name".to_string(), Value::String(world_name.to_string()));
payload.insert("subtitle".to_string(), Value::String(subtitle.to_string()));
payload.insert(
"summary".to_string(),
Value::String(summary_text.to_string()),
);
payload.insert(
"updatedAtMicros".to_string(),
Value::Number(input.updated_at_micros.into()),
);
for key in ["tone", "playerGoal"] {
if let Some(value) = draft.get(key) {
payload.insert(key.to_string(), value.clone());
}
}
for key in [
"majorFactions",
"coreConflicts",
"playableNpcs",
"storyNpcs",
"landmarks",
"camp",
] {
if let Some(value) = draft.get(key) {
payload.insert(key.to_string(), value.clone());
}
}
if let Some(scene_chapters) = draft
.get("sceneChapterBlueprints")
.or_else(|| draft.get("sceneChapters"))
{
payload.insert("sceneChapterBlueprints".to_string(), scene_chapters.clone());
}
serde_json::to_string(&Value::Object(payload))
.map_err(|_| CustomWorldFieldError::InvalidDraftProfileJson)
}

View File

@@ -1,3 +1,283 @@
//! 自定义世界写入命令过渡落位 //! 自定义世界写入命令。
//! //!
//! 用于表达会话创建、消息写入、草稿更新、发布和下架等用例输入。 //! 用于表达会话创建、消息写入、草稿更新、发布和下架等用例输入。
use crate::domain::{
CustomWorldAgentOperationSnapshot, CustomWorldGalleryEntrySnapshot, CustomWorldProfileSnapshot,
CustomWorldThemeMode, RpgAgentOperationStatus, RpgAgentOperationType, RpgAgentStage,
};
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 CustomWorldProfileUpsertInput {
pub profile_id: String,
pub owner_user_id: String,
pub public_work_code: Option<String>,
pub author_public_user_code: Option<String>,
pub source_agent_session_id: Option<String>,
pub world_name: String,
pub subtitle: String,
pub summary_text: String,
pub theme_mode: CustomWorldThemeMode,
pub cover_image_src: Option<String>,
pub profile_payload_json: String,
pub playable_npc_count: u32,
pub landmark_count: u32,
pub author_display_name: String,
pub updated_at_micros: i64,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct CustomWorldProfilePublishInput {
pub profile_id: String,
pub owner_user_id: String,
pub public_work_code: Option<String>,
pub author_public_user_code: String,
pub author_display_name: String,
pub published_at_micros: i64,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct CustomWorldProfileUnpublishInput {
pub profile_id: String,
pub owner_user_id: String,
pub author_display_name: String,
pub updated_at_micros: i64,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct CustomWorldProfileDeleteInput {
pub profile_id: String,
pub owner_user_id: String,
pub deleted_at_micros: i64,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct CustomWorldProfileListInput {
pub owner_user_id: String,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct CustomWorldLibraryDetailInput {
pub owner_user_id: String,
pub profile_id: String,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct CustomWorldGalleryDetailInput {
pub owner_user_id: String,
pub profile_id: String,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct CustomWorldGalleryDetailByCodeInput {
pub public_work_code: String,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct CustomWorldAgentSessionCreateInput {
pub session_id: String,
pub owner_user_id: String,
pub seed_text: String,
pub welcome_message_id: String,
pub welcome_message_text: String,
pub anchor_content_json: String,
pub creator_intent_json: Option<String>,
pub creator_intent_readiness_json: String,
pub anchor_pack_json: Option<String>,
pub lock_state_json: Option<String>,
pub draft_profile_json: Option<String>,
pub pending_clarifications_json: String,
pub suggested_actions_json: String,
pub recommended_replies_json: String,
pub quality_findings_json: String,
pub asset_coverage_json: String,
pub checkpoints_json: String,
pub created_at_micros: i64,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct CustomWorldAgentSessionGetInput {
pub session_id: String,
pub owner_user_id: String,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct CustomWorldAgentMessageSubmitInput {
pub session_id: String,
pub owner_user_id: String,
pub user_message_id: String,
pub user_message_text: String,
pub operation_id: String,
pub submitted_at_micros: i64,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct CustomWorldAgentMessageFinalizeInput {
pub session_id: String,
pub owner_user_id: String,
pub operation_id: String,
pub assistant_message_id: Option<String>,
pub assistant_reply_text: Option<String>,
pub phase_label: String,
pub phase_detail: String,
pub operation_status: RpgAgentOperationStatus,
pub operation_progress: u32,
pub stage: RpgAgentStage,
pub progress_percent: u32,
pub focus_card_id: Option<String>,
pub anchor_content_json: String,
pub creator_intent_json: Option<String>,
pub creator_intent_readiness_json: String,
pub anchor_pack_json: Option<String>,
pub draft_profile_json: Option<String>,
pub pending_clarifications_json: String,
pub suggested_actions_json: String,
pub recommended_replies_json: String,
pub quality_findings_json: String,
pub asset_coverage_json: String,
pub error_message: Option<String>,
pub updated_at_micros: i64,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct CustomWorldAgentOperationGetInput {
pub session_id: String,
pub owner_user_id: String,
pub operation_id: String,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct CustomWorldAgentOperationProgressInput {
pub session_id: String,
pub owner_user_id: String,
pub operation_id: String,
pub operation_type: RpgAgentOperationType,
pub operation_status: RpgAgentOperationStatus,
pub phase_label: String,
pub phase_detail: String,
pub operation_progress: u32,
pub error_message: Option<String>,
pub updated_at_micros: i64,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct CustomWorldAgentOperationProcedureResult {
pub ok: bool,
pub operation: Option<CustomWorldAgentOperationSnapshot>,
pub error_message: Option<String>,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct CustomWorldWorksListInput {
pub owner_user_id: String,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct CustomWorldAgentCardDetailGetInput {
pub session_id: String,
pub owner_user_id: String,
pub card_id: String,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct CustomWorldAgentActionExecuteInput {
pub session_id: String,
pub owner_user_id: String,
pub operation_id: String,
pub action: String,
pub payload_json: Option<String>,
pub submitted_at_micros: i64,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct CustomWorldAgentActionExecuteResult {
pub ok: bool,
pub operation: Option<CustomWorldAgentOperationSnapshot>,
pub error_message: Option<String>,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct CustomWorldPublishedProfileCompileInput {
pub session_id: String,
pub profile_id: String,
pub owner_user_id: String,
pub draft_profile_json: String,
pub legacy_result_profile_json: Option<String>,
pub setting_text: String,
pub author_display_name: String,
pub updated_at_micros: i64,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct CustomWorldPublishedProfileCompileSnapshot {
pub profile_id: String,
pub owner_user_id: String,
pub world_name: String,
pub subtitle: String,
pub summary_text: String,
pub theme_mode: CustomWorldThemeMode,
pub cover_image_src: Option<String>,
pub playable_npc_count: u32,
pub landmark_count: u32,
pub author_display_name: String,
pub compiled_profile_payload_json: String,
pub updated_at_micros: i64,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct CustomWorldPublishedProfileCompileResult {
pub ok: bool,
pub record: Option<CustomWorldPublishedProfileCompileSnapshot>,
pub error_message: Option<String>,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct CustomWorldPublishWorldInput {
pub session_id: String,
pub profile_id: String,
pub owner_user_id: String,
pub public_work_code: Option<String>,
pub author_public_user_code: String,
pub draft_profile_json: String,
pub legacy_result_profile_json: Option<String>,
pub setting_text: String,
pub author_display_name: String,
pub published_at_micros: i64,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct CustomWorldPublishWorldResult {
pub ok: bool,
pub compiled_record: Option<CustomWorldPublishedProfileCompileSnapshot>,
pub entry: Option<CustomWorldProfileSnapshot>,
pub gallery_entry: Option<CustomWorldGalleryEntrySnapshot>,
pub session_stage: Option<RpgAgentStage>,
pub error_message: Option<String>,
}

View File

@@ -1,7 +1,6 @@
//! 自定义世界领域模型过渡落位 //! 自定义世界领域模型。
//! //!
//! 后续迁移 profile、Agent 会话、草稿卡、发布门禁和画廊投影规则时, //! 只保留 profile、Agent 会话、草稿卡、发布门禁和画廊投影的纯领域结构LLM 推理、SSE 和 OSS 均留在外层 adapter。
//! 只保留纯领域结构LLM 推理、SSE 和 OSS 均留在外层 adapter。
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
#[cfg(feature = "spacetime-types")] #[cfg(feature = "spacetime-types")]
@@ -139,6 +138,248 @@ pub enum CustomWorldRoleAssetStatus {
Complete, Complete,
} }
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct CustomWorldProfileSnapshot {
pub profile_id: String,
pub owner_user_id: String,
pub public_work_code: Option<String>,
pub author_public_user_code: Option<String>,
pub source_agent_session_id: Option<String>,
pub publication_status: CustomWorldPublicationStatus,
pub world_name: String,
pub subtitle: String,
pub summary_text: String,
pub theme_mode: CustomWorldThemeMode,
pub cover_image_src: Option<String>,
pub profile_payload_json: String,
pub playable_npc_count: u32,
pub landmark_count: u32,
pub author_display_name: String,
pub published_at_micros: Option<i64>,
pub deleted_at_micros: Option<i64>,
pub created_at_micros: i64,
pub updated_at_micros: i64,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct CustomWorldGalleryEntrySnapshot {
pub profile_id: String,
pub owner_user_id: String,
pub public_work_code: String,
pub author_public_user_code: String,
pub author_display_name: String,
pub world_name: String,
pub subtitle: String,
pub summary_text: String,
pub cover_image_src: Option<String>,
pub theme_mode: CustomWorldThemeMode,
pub playable_npc_count: u32,
pub landmark_count: u32,
pub published_at_micros: i64,
pub updated_at_micros: i64,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct CustomWorldLibraryMutationResult {
pub ok: bool,
pub entry: Option<CustomWorldProfileSnapshot>,
pub gallery_entry: Option<CustomWorldGalleryEntrySnapshot>,
pub error_message: Option<String>,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct CustomWorldProfileListResult {
pub ok: bool,
pub entries: Vec<CustomWorldProfileSnapshot>,
pub error_message: Option<String>,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct CustomWorldGalleryListResult {
pub ok: bool,
pub entries: Vec<CustomWorldGalleryEntrySnapshot>,
pub error_message: Option<String>,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct CustomWorldPublishBlockerSnapshot {
pub blocker_id: String,
pub code: String,
pub message: String,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct CustomWorldPublishGateSnapshot {
pub profile_id: String,
pub blockers: Vec<CustomWorldPublishBlockerSnapshot>,
pub blocker_count: u32,
pub publish_ready: bool,
pub can_enter_world: bool,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct CustomWorldWorkSummarySnapshot {
pub work_id: String,
pub source_type: String,
pub status: String,
pub title: String,
pub subtitle: String,
pub summary: String,
pub cover_image_src: Option<String>,
pub cover_render_mode: Option<String>,
pub cover_character_image_srcs_json: String,
pub updated_at_micros: i64,
pub published_at_micros: Option<i64>,
pub stage: Option<RpgAgentStage>,
pub stage_label: Option<String>,
pub playable_npc_count: u32,
pub landmark_count: u32,
pub role_visual_ready_count: Option<u32>,
pub role_animation_ready_count: Option<u32>,
pub role_asset_summary_label: Option<String>,
pub session_id: Option<String>,
pub profile_id: Option<String>,
pub can_resume: bool,
pub can_enter_world: bool,
pub blocker_count: u32,
pub publish_ready: bool,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct CustomWorldWorksListResult {
pub ok: bool,
pub items: Vec<CustomWorldWorkSummarySnapshot>,
pub error_message: Option<String>,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct CustomWorldAgentMessageSnapshot {
pub message_id: String,
pub session_id: String,
pub role: RpgAgentMessageRole,
pub kind: RpgAgentMessageKind,
pub text: String,
pub related_operation_id: Option<String>,
pub created_at_micros: i64,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct CustomWorldAgentOperationSnapshot {
pub operation_id: String,
pub session_id: String,
pub operation_type: RpgAgentOperationType,
pub status: RpgAgentOperationStatus,
pub phase_label: String,
pub phase_detail: String,
pub progress: u32,
pub error_message: Option<String>,
pub created_at_micros: i64,
pub updated_at_micros: i64,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct CustomWorldDraftCardSnapshot {
pub card_id: String,
pub session_id: String,
pub kind: RpgAgentDraftCardKind,
pub status: RpgAgentDraftCardStatus,
pub title: String,
pub subtitle: String,
pub summary: String,
pub linked_ids_json: String,
pub warning_count: u32,
pub asset_status: Option<CustomWorldRoleAssetStatus>,
pub asset_status_label: Option<String>,
pub detail_payload_json: Option<String>,
pub created_at_micros: i64,
pub updated_at_micros: i64,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct CustomWorldDraftCardDetailSectionSnapshot {
pub section_id: String,
pub label: String,
pub value: String,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct CustomWorldDraftCardDetailSnapshot {
pub card_id: String,
pub kind: RpgAgentDraftCardKind,
pub title: String,
pub sections: Vec<CustomWorldDraftCardDetailSectionSnapshot>,
pub linked_ids_json: String,
pub locked: bool,
pub editable: bool,
pub editable_section_ids_json: String,
pub warning_messages_json: String,
pub asset_status: Option<CustomWorldRoleAssetStatus>,
pub asset_status_label: Option<String>,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct CustomWorldDraftCardDetailResult {
pub ok: bool,
pub card: Option<CustomWorldDraftCardDetailSnapshot>,
pub error_message: Option<String>,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct CustomWorldAgentSessionSnapshot {
pub session_id: String,
pub owner_user_id: String,
pub seed_text: String,
pub current_turn: u32,
pub progress_percent: u32,
pub stage: RpgAgentStage,
pub focus_card_id: Option<String>,
pub anchor_content_json: String,
pub creator_intent_json: Option<String>,
pub creator_intent_readiness_json: String,
pub anchor_pack_json: Option<String>,
pub lock_state_json: Option<String>,
pub draft_profile_json: Option<String>,
pub last_assistant_reply: Option<String>,
pub publish_gate_json: Option<String>,
pub result_preview_json: Option<String>,
pub pending_clarifications_json: String,
pub quality_findings_json: String,
pub suggested_actions_json: String,
pub recommended_replies_json: String,
pub asset_coverage_json: String,
pub checkpoints_json: String,
pub supported_actions_json: String,
pub messages: Vec<CustomWorldAgentMessageSnapshot>,
pub draft_cards: Vec<CustomWorldDraftCardSnapshot>,
pub operations: Vec<CustomWorldAgentOperationSnapshot>,
pub created_at_micros: i64,
pub updated_at_micros: i64,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct CustomWorldAgentSessionProcedureResult {
pub ok: bool,
pub session: Option<CustomWorldAgentSessionSnapshot>,
pub error_message: Option<String>,
}
impl CustomWorldPublicationStatus { impl CustomWorldPublicationStatus {
pub fn as_str(&self) -> &'static str { pub fn as_str(&self) -> &'static str {
match self { match self {

View File

@@ -1,3 +1,100 @@
//! 自定义世界领域错误过渡落位 //! 自定义世界领域错误。
//! //!
//! 错误只表达世界创作规则失败,由 adapter 显式映射为 HTTP 或 reducer 错误。 //! 错误只表达世界创作规则失败,由 adapter 显式映射为 HTTP 或 reducer 错误。
use std::{error::Error, fmt};
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum CustomWorldFieldError {
MissingProfileId,
MissingSessionId,
MissingOwnerUserId,
MissingPublicWorkCode,
MissingAction,
MissingWorldName,
MissingDraftProfileJson,
MissingProfilePayloadJson,
MissingSettingText,
MissingQuestionSnapshotJson,
MissingAnchorContentJson,
MissingCreatorIntentReadinessJson,
MissingAssetCoverageJson,
MissingPendingClarificationsJson,
MissingMessageId,
MissingMessageText,
MissingOperationId,
MissingPhaseLabel,
InvalidProgressPercent,
MissingCardId,
MissingCardTitle,
MissingCardSummary,
MissingLinkedIdsJson,
MissingAuthorDisplayName,
InvalidDraftProfileJson,
InvalidLegacyResultProfileJson,
InvalidJsonPayload,
}
impl fmt::Display for CustomWorldFieldError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::MissingProfileId => f.write_str("custom_world.profile_id 不能为空"),
Self::MissingSessionId => f.write_str("custom_world.session_id 不能为空"),
Self::MissingOwnerUserId => f.write_str("custom_world.owner_user_id 不能为空"),
Self::MissingPublicWorkCode => {
f.write_str("custom_world_gallery_detail.public_work_code 不能为空")
}
Self::MissingAction => f.write_str("custom_world_agent_action.action 不能为空"),
Self::MissingWorldName => f.write_str("custom_world.world_name 不能为空"),
Self::MissingDraftProfileJson => {
f.write_str("custom_world.compile.draft_profile_json 不能为空")
}
Self::MissingProfilePayloadJson => {
f.write_str("custom_world.profile_payload_json 不能为空")
}
Self::MissingSettingText => f.write_str("custom_world.setting_text 不能为空"),
Self::MissingQuestionSnapshotJson => {
f.write_str("custom_world.question_snapshot_json 不能为空")
}
Self::MissingAnchorContentJson => {
f.write_str("custom_world.anchor_content_json 不能为空")
}
Self::MissingCreatorIntentReadinessJson => {
f.write_str("custom_world.creator_intent_readiness_json 不能为空")
}
Self::MissingAssetCoverageJson => {
f.write_str("custom_world.asset_coverage_json 不能为空")
}
Self::MissingPendingClarificationsJson => {
f.write_str("custom_world.pending_clarifications_json 不能为空")
}
Self::MissingMessageId => f.write_str("custom_world_agent_message.message_id 不能为空"),
Self::MissingMessageText => f.write_str("custom_world_agent_message.text 不能为空"),
Self::MissingOperationId => {
f.write_str("custom_world_agent_operation.operation_id 不能为空")
}
Self::MissingPhaseLabel => {
f.write_str("custom_world_agent_operation.phase_label 不能为空")
}
Self::InvalidProgressPercent => f.write_str("progress 必须位于 0~100"),
Self::MissingCardId => f.write_str("custom_world_draft_card.card_id 不能为空"),
Self::MissingCardTitle => f.write_str("custom_world_draft_card.title 不能为空"),
Self::MissingCardSummary => f.write_str("custom_world_draft_card.summary 不能为空"),
Self::MissingLinkedIdsJson => {
f.write_str("custom_world_draft_card.linked_ids_json 不能为空")
}
Self::MissingAuthorDisplayName => {
f.write_str("custom_world_gallery_entry.author_display_name 不能为空")
}
Self::InvalidDraftProfileJson => {
f.write_str("custom_world.compile.draft_profile_json 不是合法 JSON object")
}
Self::InvalidLegacyResultProfileJson => {
f.write_str("custom_world.compile.legacy_result_profile_json 不是合法 JSON object")
}
Self::InvalidJsonPayload => f.write_str("custom_world JSON payload 结构非法"),
}
}
}
impl Error for CustomWorldFieldError {}

View File

@@ -1,3 +1,68 @@
//! 自定义世界领域事件过渡落位 //! 自定义世界领域事件。
//! //!
//! 用于表达草稿变化、profile 发布、画廊投影刷新和 Agent 操作进度变化。 //! 用于表达草稿变化、profile 发布、画廊投影刷新和 Agent 操作进度变化。
use crate::domain::{
RpgAgentDraftCardKind, RpgAgentOperationStatus, RpgAgentOperationType, RpgAgentStage,
};
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 enum CustomWorldDomainEvent {
ProfileUpserted(CustomWorldProfileUpsertedEvent),
ProfilePublished(CustomWorldProfilePublishedEvent),
GalleryProjectionRefreshed(CustomWorldGalleryProjectionRefreshedEvent),
AgentSessionAdvanced(CustomWorldAgentSessionAdvancedEvent),
AgentOperationProgressed(CustomWorldAgentOperationProgressedEvent),
DraftCardUpdated(CustomWorldDraftCardUpdatedEvent),
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct CustomWorldProfileUpsertedEvent {
pub profile_id: String,
pub owner_user_id: String,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct CustomWorldProfilePublishedEvent {
pub profile_id: String,
pub public_work_code: String,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct CustomWorldGalleryProjectionRefreshedEvent {
pub profile_id: String,
pub public_work_code: String,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct CustomWorldAgentSessionAdvancedEvent {
pub session_id: String,
pub stage: RpgAgentStage,
pub progress_percent: u32,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct CustomWorldAgentOperationProgressedEvent {
pub session_id: String,
pub operation_id: String,
pub operation_type: RpgAgentOperationType,
pub status: RpgAgentOperationStatus,
pub progress: u32,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct CustomWorldDraftCardUpdatedEvent {
pub session_id: String,
pub card_id: String,
pub kind: RpgAgentDraftCardKind,
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,3 +1,564 @@
//! 背包应用编排过渡落位 //! 背包应用编排。
//! //!
//! 这里只返回背包变更结果和领域事件,不直接访问持久化。 //! 这里只返回背包变更结果和领域事件,不直接访问持久化。
use crate::commands::{
ConsumeInventoryItemInput, EquipInventoryItemInput, GrantInventoryItemInput, InventoryMutation,
InventoryMutationInput, RuntimeInventoryStateQueryInput, UnequipInventoryItemInput,
};
use crate::domain::{
InventoryContainerKind, InventoryEquipmentSlot, InventoryItemRarity, InventoryItemSnapshot,
InventoryItemSourceKind, InventorySlotSnapshot,
};
use crate::errors::InventoryMutationFieldError;
use serde::{Deserialize, Serialize};
use shared_kernel::{
format_timestamp_micros, normalize_optional_string as normalize_shared_optional_string,
normalize_required_string, normalize_string_list as normalize_shared_string_list,
};
#[cfg(feature = "spacetime-types")]
use spacetimedb::SpacetimeType;
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct RuntimeInventoryStateSnapshot {
pub runtime_session_id: String,
pub actor_user_id: String,
pub backpack_items: Vec<InventorySlotSnapshot>,
pub equipment_items: Vec<InventorySlotSnapshot>,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct RuntimeInventoryStateProcedureResult {
pub ok: bool,
pub snapshot: Option<RuntimeInventoryStateSnapshot>,
pub error_message: Option<String>,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct RuntimeInventorySlotRecord {
pub slot_id: String,
pub container_kind: String,
pub slot_key: String,
pub item_id: String,
pub category: String,
pub name: String,
pub description: Option<String>,
pub quantity: u32,
pub rarity: String,
pub tags: Vec<String>,
pub stackable: bool,
pub stack_key: String,
pub equipment_slot_id: Option<String>,
pub source_kind: String,
pub source_reference_id: Option<String>,
pub created_at: String,
pub updated_at: String,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct RuntimeInventoryStateRecord {
pub runtime_session_id: String,
pub actor_user_id: String,
pub backpack_items: Vec<RuntimeInventorySlotRecord>,
pub equipment_items: Vec<RuntimeInventorySlotRecord>,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct InventoryMutationOutcome {
pub next_slots: Vec<InventorySlotSnapshot>,
pub changed: bool,
pub updated_slot_ids: Vec<String>,
pub removed_slot_ids: Vec<String>,
pub affected_equipment_slot: Option<InventoryEquipmentSlot>,
}
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)
}
pub fn build_runtime_inventory_state_query_input(
runtime_session_id: String,
actor_user_id: String,
) -> Result<RuntimeInventoryStateQueryInput, InventoryMutationFieldError> {
let input = RuntimeInventoryStateQueryInput {
runtime_session_id: normalize_required_text(
runtime_session_id,
InventoryMutationFieldError::MissingRuntimeSessionId,
)?,
actor_user_id: normalize_required_text(
actor_user_id,
InventoryMutationFieldError::MissingActorUserId,
)?,
};
Ok(input)
}
pub fn build_runtime_inventory_state_snapshot(
input: RuntimeInventoryStateQueryInput,
slots: Vec<InventorySlotSnapshot>,
) -> RuntimeInventoryStateSnapshot {
let mut backpack_items = Vec::new();
let mut equipment_items = Vec::new();
for slot in slots {
match slot.container_kind {
InventoryContainerKind::Backpack => backpack_items.push(slot),
InventoryContainerKind::Equipment => equipment_items.push(slot),
}
}
backpack_items.sort_by(|left, right| {
left.slot_key
.cmp(&right.slot_key)
.then(left.slot_id.cmp(&right.slot_id))
});
equipment_items.sort_by(|left, right| {
equipment_slot_order(left.equipment_slot_id)
.cmp(&equipment_slot_order(right.equipment_slot_id))
.then(left.slot_id.cmp(&right.slot_id))
});
RuntimeInventoryStateSnapshot {
runtime_session_id: input.runtime_session_id,
actor_user_id: input.actor_user_id,
backpack_items,
equipment_items,
}
}
pub fn apply_inventory_mutation(
current_slots: Vec<InventorySlotSnapshot>,
input: InventoryMutationInput,
) -> Result<InventoryMutationOutcome, InventoryMutationFieldError> {
let _mutation_id = normalize_required_text(
input.mutation_id,
InventoryMutationFieldError::MissingMutationId,
)?;
let runtime_session_id = normalize_required_text(
input.runtime_session_id,
InventoryMutationFieldError::MissingRuntimeSessionId,
)?;
let actor_user_id = normalize_required_text(
input.actor_user_id,
InventoryMutationFieldError::MissingActorUserId,
)?;
let story_session_id = normalize_optional_text(input.story_session_id);
let mut slots = current_slots;
for slot in &slots {
if slot.runtime_session_id != runtime_session_id || slot.actor_user_id != actor_user_id {
return Err(InventoryMutationFieldError::SlotScopeMismatch);
}
}
let outcome = match input.mutation {
InventoryMutation::GrantItem(grant) => apply_grant_item(
&mut slots,
runtime_session_id,
story_session_id,
actor_user_id,
grant,
input.updated_at_micros,
)?,
InventoryMutation::ConsumeItem(consume) => {
apply_consume_item(&mut slots, consume, input.updated_at_micros)?
}
InventoryMutation::EquipItem(equip) => {
apply_equip_item(&mut slots, equip, input.updated_at_micros)?
}
InventoryMutation::UnequipItem(unequip) => {
apply_unequip_item(&mut slots, unequip, input.updated_at_micros)?
}
};
Ok(InventoryMutationOutcome {
next_slots: sort_inventory_slots(slots),
changed: outcome.changed,
updated_slot_ids: sort_string_list(outcome.updated_slot_ids),
removed_slot_ids: sort_string_list(outcome.removed_slot_ids),
affected_equipment_slot: outcome.affected_equipment_slot,
})
}
#[derive(Clone, Debug, PartialEq, Eq)]
struct InventoryMutationInternalOutcome {
changed: bool,
updated_slot_ids: Vec<String>,
removed_slot_ids: Vec<String>,
affected_equipment_slot: Option<InventoryEquipmentSlot>,
}
fn apply_grant_item(
slots: &mut Vec<InventorySlotSnapshot>,
runtime_session_id: String,
story_session_id: Option<String>,
actor_user_id: String,
grant: GrantInventoryItemInput,
updated_at_micros: i64,
) -> Result<InventoryMutationInternalOutcome, InventoryMutationFieldError> {
let slot_id =
normalize_required_text(grant.slot_id, InventoryMutationFieldError::MissingSlotId)?;
let item = normalize_inventory_item_snapshot(grant.item)?;
if item.stackable {
if let Some(existing) = slots.iter_mut().find(|slot| {
slot.container_kind == InventoryContainerKind::Backpack
&& slot.stackable
&& slot.item_id == item.item_id
&& slot.stack_key == item.stack_key
}) {
existing.category = item.category;
existing.name = item.name;
existing.description = item.description;
existing.quantity += item.quantity;
existing.rarity = item.rarity;
existing.tags = item.tags;
existing.stackable = item.stackable;
existing.stack_key = item.stack_key;
existing.equipment_slot_id = item.equipment_slot_id;
existing.source_kind = item.source_kind;
existing.source_reference_id = item.source_reference_id;
existing.updated_at_micros = updated_at_micros;
return Ok(InventoryMutationInternalOutcome {
changed: true,
updated_slot_ids: vec![existing.slot_id.clone()],
removed_slot_ids: vec![],
affected_equipment_slot: None,
});
}
}
slots.push(InventorySlotSnapshot {
slot_id: slot_id.clone(),
runtime_session_id,
story_session_id,
actor_user_id,
container_kind: InventoryContainerKind::Backpack,
slot_key: build_backpack_slot_key(&slot_id),
item_id: item.item_id,
category: item.category,
name: item.name,
description: item.description,
quantity: item.quantity,
rarity: item.rarity,
tags: item.tags,
stackable: item.stackable,
stack_key: item.stack_key,
equipment_slot_id: item.equipment_slot_id,
source_kind: item.source_kind,
source_reference_id: item.source_reference_id,
created_at_micros: updated_at_micros,
updated_at_micros,
});
Ok(InventoryMutationInternalOutcome {
changed: true,
updated_slot_ids: vec![slot_id],
removed_slot_ids: vec![],
affected_equipment_slot: None,
})
}
fn apply_consume_item(
slots: &mut Vec<InventorySlotSnapshot>,
consume: ConsumeInventoryItemInput,
updated_at_micros: i64,
) -> Result<InventoryMutationInternalOutcome, InventoryMutationFieldError> {
let slot_id =
normalize_required_text(consume.slot_id, InventoryMutationFieldError::MissingSlotId)?;
if consume.quantity == 0 {
return Err(InventoryMutationFieldError::InvalidQuantity);
}
let slot_index = slots
.iter()
.position(|slot| slot.slot_id == slot_id)
.ok_or(InventoryMutationFieldError::ItemNotFound)?;
if slots[slot_index].container_kind != InventoryContainerKind::Backpack {
return Err(InventoryMutationFieldError::ItemNotInBackpack);
}
if slots[slot_index].quantity < consume.quantity {
return Err(InventoryMutationFieldError::InsufficientQuantity);
}
if slots[slot_index].quantity == consume.quantity {
slots.remove(slot_index);
return Ok(InventoryMutationInternalOutcome {
changed: true,
updated_slot_ids: vec![],
removed_slot_ids: vec![slot_id],
affected_equipment_slot: None,
});
}
slots[slot_index].quantity -= consume.quantity;
slots[slot_index].updated_at_micros = updated_at_micros;
Ok(InventoryMutationInternalOutcome {
changed: true,
updated_slot_ids: vec![slots[slot_index].slot_id.clone()],
removed_slot_ids: vec![],
affected_equipment_slot: None,
})
}
fn apply_equip_item(
slots: &mut [InventorySlotSnapshot],
equip: EquipInventoryItemInput,
updated_at_micros: i64,
) -> Result<InventoryMutationInternalOutcome, InventoryMutationFieldError> {
let slot_id =
normalize_required_text(equip.slot_id, InventoryMutationFieldError::MissingSlotId)?;
let source_index = slots
.iter()
.position(|slot| slot.slot_id == slot_id)
.ok_or(InventoryMutationFieldError::ItemNotFound)?;
let target_slot = slots[source_index]
.equipment_slot_id
.ok_or(InventoryMutationFieldError::ItemNotEquippable)?;
if slots[source_index].stackable {
return Err(InventoryMutationFieldError::EquipmentItemCannotStack);
}
if slots[source_index].quantity != 1 {
return Err(InventoryMutationFieldError::NonStackableItemMustStaySingleQuantity);
}
if slots[source_index].container_kind != InventoryContainerKind::Backpack {
if slots[source_index].container_kind == InventoryContainerKind::Equipment {
return Ok(InventoryMutationInternalOutcome {
changed: false,
updated_slot_ids: vec![],
removed_slot_ids: vec![],
affected_equipment_slot: Some(target_slot),
});
}
return Err(InventoryMutationFieldError::ItemNotInBackpack);
}
let occupied_index = slots.iter().position(|slot| {
slot.container_kind == InventoryContainerKind::Equipment
&& slot.slot_key == build_equipment_slot_key(target_slot)
});
let mut updated_slot_ids = vec![slot_id.clone()];
if let Some(occupied_index) = occupied_index {
// 首版装备互换直接在同一条 slot 真相记录上切容器,不生成临时副本。
slots[occupied_index].container_kind = InventoryContainerKind::Backpack;
slots[occupied_index].slot_key = build_backpack_slot_key(&slots[occupied_index].slot_id);
slots[occupied_index].updated_at_micros = updated_at_micros;
updated_slot_ids.push(slots[occupied_index].slot_id.clone());
}
slots[source_index].container_kind = InventoryContainerKind::Equipment;
slots[source_index].slot_key = build_equipment_slot_key(target_slot);
slots[source_index].updated_at_micros = updated_at_micros;
Ok(InventoryMutationInternalOutcome {
changed: true,
updated_slot_ids,
removed_slot_ids: vec![],
affected_equipment_slot: Some(target_slot),
})
}
fn apply_unequip_item(
slots: &mut [InventorySlotSnapshot],
unequip: UnequipInventoryItemInput,
updated_at_micros: i64,
) -> Result<InventoryMutationInternalOutcome, InventoryMutationFieldError> {
let slot_id =
normalize_required_text(unequip.slot_id, InventoryMutationFieldError::MissingSlotId)?;
let slot_index = slots
.iter()
.position(|slot| slot.slot_id == slot_id)
.ok_or(InventoryMutationFieldError::ItemNotFound)?;
if slots[slot_index].container_kind != InventoryContainerKind::Equipment {
return Err(InventoryMutationFieldError::ItemNotEquipped);
}
let affected_equipment_slot = slots[slot_index].equipment_slot_id;
slots[slot_index].container_kind = InventoryContainerKind::Backpack;
slots[slot_index].slot_key = build_backpack_slot_key(&slot_id);
slots[slot_index].updated_at_micros = updated_at_micros;
Ok(InventoryMutationInternalOutcome {
changed: true,
updated_slot_ids: vec![slot_id],
removed_slot_ids: vec![],
affected_equipment_slot,
})
}
fn normalize_inventory_item_snapshot(
item: InventoryItemSnapshot,
) -> Result<InventoryItemSnapshot, InventoryMutationFieldError> {
let item_id =
normalize_required_text(item.item_id, InventoryMutationFieldError::MissingItemId)?;
let category =
normalize_required_text(item.category, InventoryMutationFieldError::MissingCategory)?;
let name = normalize_required_text(item.name, InventoryMutationFieldError::MissingName)?;
if item.quantity == 0 {
return Err(InventoryMutationFieldError::InvalidQuantity);
}
if !item.stackable && item.quantity != 1 {
return Err(InventoryMutationFieldError::NonStackableItemMustStaySingleQuantity);
}
if item.equipment_slot_id.is_some() && item.stackable {
return Err(InventoryMutationFieldError::EquipmentItemCannotStack);
}
let stack_key = if item.stackable {
normalize_required_text(item.stack_key, InventoryMutationFieldError::MissingStackKey)?
} else {
normalize_optional_text(Some(item.stack_key)).unwrap_or_else(|| item_id.clone())
};
Ok(InventoryItemSnapshot {
item_id,
category,
name,
description: normalize_optional_text(item.description),
quantity: item.quantity,
rarity: item.rarity,
tags: normalize_string_list(item.tags),
stackable: item.stackable,
stack_key,
equipment_slot_id: item.equipment_slot_id,
source_kind: item.source_kind,
source_reference_id: normalize_optional_text(item.source_reference_id),
})
}
fn normalize_required_text(
value: String,
error: InventoryMutationFieldError,
) -> Result<String, InventoryMutationFieldError> {
normalize_required_string(value).ok_or(error)
}
fn sort_inventory_slots(mut slots: Vec<InventorySlotSnapshot>) -> Vec<InventorySlotSnapshot> {
slots.sort_by(|left, right| {
container_order(left.container_kind)
.cmp(&container_order(right.container_kind))
.then(left.slot_key.cmp(&right.slot_key))
.then(left.slot_id.cmp(&right.slot_id))
});
slots
}
fn sort_string_list(mut values: Vec<String>) -> Vec<String> {
values.sort();
values
}
fn container_order(kind: InventoryContainerKind) -> u8 {
match kind {
InventoryContainerKind::Equipment => 0,
InventoryContainerKind::Backpack => 1,
}
}
fn equipment_slot_order(slot: Option<InventoryEquipmentSlot>) -> u8 {
match slot {
Some(InventoryEquipmentSlot::Weapon) => 0,
Some(InventoryEquipmentSlot::Armor) => 1,
Some(InventoryEquipmentSlot::Relic) => 2,
None => 3,
}
}
fn build_backpack_slot_key(slot_id: &str) -> String {
slot_id.to_string()
}
fn build_equipment_slot_key(slot: InventoryEquipmentSlot) -> String {
slot.as_str().to_string()
}
pub fn build_runtime_inventory_state_record(
snapshot: RuntimeInventoryStateSnapshot,
) -> RuntimeInventoryStateRecord {
RuntimeInventoryStateRecord {
runtime_session_id: snapshot.runtime_session_id,
actor_user_id: snapshot.actor_user_id,
backpack_items: snapshot
.backpack_items
.into_iter()
.map(build_runtime_inventory_slot_record)
.collect(),
equipment_items: snapshot
.equipment_items
.into_iter()
.map(build_runtime_inventory_slot_record)
.collect(),
}
}
fn build_runtime_inventory_slot_record(slot: InventorySlotSnapshot) -> RuntimeInventorySlotRecord {
RuntimeInventorySlotRecord {
slot_id: slot.slot_id,
container_kind: format_inventory_container_kind(slot.container_kind).to_string(),
slot_key: slot.slot_key,
item_id: slot.item_id,
category: slot.category,
name: slot.name,
description: slot.description,
quantity: slot.quantity,
rarity: format_inventory_item_rarity(slot.rarity).to_string(),
tags: slot.tags,
stackable: slot.stackable,
stack_key: slot.stack_key,
equipment_slot_id: slot
.equipment_slot_id
.map(|value| value.as_str().to_string()),
source_kind: format_inventory_item_source_kind(slot.source_kind).to_string(),
source_reference_id: slot.source_reference_id,
created_at: format_timestamp_micros(slot.created_at_micros),
updated_at: format_timestamp_micros(slot.updated_at_micros),
}
}
fn format_inventory_container_kind(value: InventoryContainerKind) -> &'static str {
match value {
InventoryContainerKind::Backpack => "backpack",
InventoryContainerKind::Equipment => "equipment",
}
}
fn format_inventory_item_rarity(value: InventoryItemRarity) -> &'static str {
match value {
InventoryItemRarity::Common => "common",
InventoryItemRarity::Uncommon => "uncommon",
InventoryItemRarity::Rare => "rare",
InventoryItemRarity::Epic => "epic",
InventoryItemRarity::Legendary => "legendary",
}
}
fn format_inventory_item_source_kind(value: InventoryItemSourceKind) -> &'static str {
match value {
InventoryItemSourceKind::StoryReward => "story_reward",
InventoryItemSourceKind::QuestReward => "quest_reward",
InventoryItemSourceKind::TreasureReward => "treasure_reward",
InventoryItemSourceKind::NpcGift => "npc_gift",
InventoryItemSourceKind::NpcTrade => "npc_trade",
InventoryItemSourceKind::CombatDrop => "combat_drop",
InventoryItemSourceKind::ForgeCraft => "forge_craft",
InventoryItemSourceKind::ForgeReforge => "forge_reforge",
InventoryItemSourceKind::ManualPatch => "manual_patch",
}
}

View File

@@ -1,3 +1,61 @@
//! 背包写入命令过渡落位 //! 背包写入命令。
//! //!
//! 用于表达授予物品、装备、卸下、消耗和整理等输入。 //! 用于表达授予物品、装备、卸下、消耗和整理等输入。
use crate::domain::InventoryItemSnapshot;
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 GrantInventoryItemInput {
pub slot_id: String,
pub item: InventoryItemSnapshot,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct ConsumeInventoryItemInput {
pub slot_id: String,
pub quantity: u32,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct EquipInventoryItemInput {
pub slot_id: String,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct UnequipInventoryItemInput {
pub slot_id: String,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub enum InventoryMutation {
GrantItem(GrantInventoryItemInput),
ConsumeItem(ConsumeInventoryItemInput),
EquipItem(EquipInventoryItemInput),
UnequipItem(UnequipInventoryItemInput),
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct InventoryMutationInput {
pub mutation_id: String,
pub runtime_session_id: String,
pub story_session_id: Option<String>,
pub actor_user_id: String,
pub mutation: InventoryMutation,
pub updated_at_micros: i64,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct RuntimeInventoryStateQueryInput {
pub runtime_session_id: String,
pub actor_user_id: String,
}

View File

@@ -1,4 +1,111 @@
//! 背包领域模型过渡落位 //! 背包领域模型。
//! //!
//! 后续迁移背包槽、装备槽、堆叠和消耗规则时,只保留物品状态变化 //! 本文件只承载背包槽、装备槽、堆叠和物品来源等稳定值对象
//! SpacetimeDB 表查询写回由 adapter 处理。 //! SpacetimeDB 表查询写回由 adapter 处理。
use serde::{Deserialize, Serialize};
use shared_kernel::build_prefixed_seed_id;
#[cfg(feature = "spacetime-types")]
use spacetimedb::SpacetimeType;
pub const INVENTORY_SLOT_ID_PREFIX: &str = "invslot_";
pub const INVENTORY_MUTATION_ID_PREFIX: &str = "invmut_";
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub enum InventoryContainerKind {
Backpack,
Equipment,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub enum InventoryItemRarity {
Common,
Uncommon,
Rare,
Epic,
Legendary,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub enum InventoryEquipmentSlot {
Weapon,
Armor,
Relic,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub enum InventoryItemSourceKind {
StoryReward,
QuestReward,
TreasureReward,
NpcGift,
NpcTrade,
CombatDrop,
ForgeCraft,
ForgeReforge,
ManualPatch,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct InventoryItemSnapshot {
pub item_id: String,
pub category: String,
pub name: String,
pub description: Option<String>,
pub quantity: u32,
pub rarity: InventoryItemRarity,
pub tags: Vec<String>,
pub stackable: bool,
pub stack_key: String,
pub equipment_slot_id: Option<InventoryEquipmentSlot>,
pub source_kind: InventoryItemSourceKind,
pub source_reference_id: Option<String>,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct InventorySlotSnapshot {
pub slot_id: String,
pub runtime_session_id: String,
pub story_session_id: Option<String>,
pub actor_user_id: String,
pub container_kind: InventoryContainerKind,
pub slot_key: String,
pub item_id: String,
pub category: String,
pub name: String,
pub description: Option<String>,
pub quantity: u32,
pub rarity: InventoryItemRarity,
pub tags: Vec<String>,
pub stackable: bool,
pub stack_key: String,
pub equipment_slot_id: Option<InventoryEquipmentSlot>,
pub source_kind: InventoryItemSourceKind,
pub source_reference_id: Option<String>,
pub created_at_micros: i64,
pub updated_at_micros: i64,
}
impl InventoryEquipmentSlot {
pub fn as_str(self) -> &'static str {
match self {
Self::Weapon => "weapon",
Self::Armor => "armor",
Self::Relic => "relic",
}
}
}
pub fn generate_inventory_slot_id(seed_micros: i64) -> String {
build_prefixed_seed_id(INVENTORY_SLOT_ID_PREFIX, seed_micros)
}
pub fn generate_inventory_mutation_id(seed_micros: i64) -> String {
build_prefixed_seed_id(INVENTORY_MUTATION_ID_PREFIX, seed_micros)
}

View File

@@ -1,3 +1,58 @@
//! 背包领域错误过渡落位 //! 背包领域错误。
//! //!
//! 错误保持可测试的业务语义,例如数量不足、槽位冲突和物品不存在。 //! 错误保持可测试的业务语义,例如数量不足、槽位冲突和物品不存在。
use std::{error::Error, fmt};
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum InventoryMutationFieldError {
MissingMutationId,
MissingRuntimeSessionId,
MissingActorUserId,
MissingSlotId,
MissingItemId,
MissingCategory,
MissingName,
InvalidQuantity,
MissingStackKey,
NonStackableItemMustStaySingleQuantity,
EquipmentItemCannotStack,
SlotScopeMismatch,
ItemNotFound,
ItemNotInBackpack,
ItemNotEquipped,
InsufficientQuantity,
ItemNotEquippable,
}
impl fmt::Display for InventoryMutationFieldError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::MissingMutationId => f.write_str("inventory_mutation.mutation_id 不能为空"),
Self::MissingRuntimeSessionId => {
f.write_str("inventory_mutation.runtime_session_id 不能为空")
}
Self::MissingActorUserId => f.write_str("inventory_mutation.actor_user_id 不能为空"),
Self::MissingSlotId => f.write_str("inventory_slot.slot_id 不能为空"),
Self::MissingItemId => f.write_str("inventory_item.item_id 不能为空"),
Self::MissingCategory => f.write_str("inventory_item.category 不能为空"),
Self::MissingName => f.write_str("inventory_item.name 不能为空"),
Self::InvalidQuantity => f.write_str("inventory_item.quantity 必须大于 0"),
Self::MissingStackKey => f.write_str("可堆叠物品必须提供 stack_key"),
Self::NonStackableItemMustStaySingleQuantity => {
f.write_str("不可堆叠物品必须固定为单槽位单数量")
}
Self::EquipmentItemCannotStack => f.write_str("可装备物品不能标记为 stackable"),
Self::SlotScopeMismatch => {
f.write_str("当前 inventory_slot 不属于本次 mutation 作用域")
}
Self::ItemNotFound => f.write_str("目标 inventory_slot 不存在"),
Self::ItemNotInBackpack => f.write_str("目标物品当前不在背包中"),
Self::ItemNotEquipped => f.write_str("目标物品当前不在装备位上"),
Self::InsufficientQuantity => f.write_str("当前背包数量不足,无法完成扣减"),
Self::ItemNotEquippable => f.write_str("目标物品当前不可装备"),
}
}
}
impl Error for InventoryMutationFieldError {}

View File

@@ -1,3 +1,41 @@
//! 背包领域事件过渡落位 //! 背包领域事件。
//! //!
//! 用于表达物品获得、物品消耗、装备变化和槽位投影变化等事实。 //! 用于表达物品获得、物品消耗、装备变化和槽位投影变化等事实。
use crate::domain::InventoryEquipmentSlot;
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 enum InventoryDomainEvent {
ItemGranted(InventoryItemGrantedEvent),
ItemConsumed(InventoryItemConsumedEvent),
EquipmentChanged(InventoryEquipmentChangedEvent),
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct InventoryItemGrantedEvent {
pub slot_id: String,
pub runtime_session_id: String,
pub actor_user_id: String,
pub occurred_at_micros: i64,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct InventoryItemConsumedEvent {
pub slot_id: String,
pub quantity: u32,
pub occurred_at_micros: i64,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct InventoryEquipmentChangedEvent {
pub slot_id: String,
pub equipment_slot: InventoryEquipmentSlot,
pub occurred_at_micros: i64,
}

View File

@@ -4,768 +4,11 @@ mod domain;
mod errors; mod errors;
mod events; mod events;
use std::{error::Error, fmt}; pub use application::*;
pub use commands::*;
use serde::{Deserialize, Serialize}; pub use domain::*;
use shared_kernel::{ pub use errors::*;
build_prefixed_seed_id, format_timestamp_micros, pub use events::*;
normalize_optional_string as normalize_shared_optional_string, normalize_required_string,
normalize_string_list as normalize_shared_string_list,
};
#[cfg(feature = "spacetime-types")]
use spacetimedb::SpacetimeType;
pub const INVENTORY_SLOT_ID_PREFIX: &str = "invslot_";
pub const INVENTORY_MUTATION_ID_PREFIX: &str = "invmut_";
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub enum InventoryContainerKind {
Backpack,
Equipment,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub enum InventoryItemRarity {
Common,
Uncommon,
Rare,
Epic,
Legendary,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub enum InventoryEquipmentSlot {
Weapon,
Armor,
Relic,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub enum InventoryItemSourceKind {
StoryReward,
QuestReward,
TreasureReward,
NpcGift,
NpcTrade,
CombatDrop,
ForgeCraft,
ForgeReforge,
ManualPatch,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct InventoryItemSnapshot {
pub item_id: String,
pub category: String,
pub name: String,
pub description: Option<String>,
pub quantity: u32,
pub rarity: InventoryItemRarity,
pub tags: Vec<String>,
pub stackable: bool,
pub stack_key: String,
pub equipment_slot_id: Option<InventoryEquipmentSlot>,
pub source_kind: InventoryItemSourceKind,
pub source_reference_id: Option<String>,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct InventorySlotSnapshot {
pub slot_id: String,
pub runtime_session_id: String,
pub story_session_id: Option<String>,
pub actor_user_id: String,
pub container_kind: InventoryContainerKind,
pub slot_key: String,
pub item_id: String,
pub category: String,
pub name: String,
pub description: Option<String>,
pub quantity: u32,
pub rarity: InventoryItemRarity,
pub tags: Vec<String>,
pub stackable: bool,
pub stack_key: String,
pub equipment_slot_id: Option<InventoryEquipmentSlot>,
pub source_kind: InventoryItemSourceKind,
pub source_reference_id: Option<String>,
pub created_at_micros: i64,
pub updated_at_micros: i64,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct GrantInventoryItemInput {
pub slot_id: String,
pub item: InventoryItemSnapshot,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct ConsumeInventoryItemInput {
pub slot_id: String,
pub quantity: u32,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct EquipInventoryItemInput {
pub slot_id: String,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct UnequipInventoryItemInput {
pub slot_id: String,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub enum InventoryMutation {
GrantItem(GrantInventoryItemInput),
ConsumeItem(ConsumeInventoryItemInput),
EquipItem(EquipInventoryItemInput),
UnequipItem(UnequipInventoryItemInput),
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct InventoryMutationInput {
pub mutation_id: String,
pub runtime_session_id: String,
pub story_session_id: Option<String>,
pub actor_user_id: String,
pub mutation: InventoryMutation,
pub updated_at_micros: i64,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct RuntimeInventoryStateQueryInput {
pub runtime_session_id: String,
pub actor_user_id: String,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct RuntimeInventoryStateSnapshot {
pub runtime_session_id: String,
pub actor_user_id: String,
pub backpack_items: Vec<InventorySlotSnapshot>,
pub equipment_items: Vec<InventorySlotSnapshot>,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct RuntimeInventoryStateProcedureResult {
pub ok: bool,
pub snapshot: Option<RuntimeInventoryStateSnapshot>,
pub error_message: Option<String>,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct RuntimeInventorySlotRecord {
pub slot_id: String,
pub container_kind: String,
pub slot_key: String,
pub item_id: String,
pub category: String,
pub name: String,
pub description: Option<String>,
pub quantity: u32,
pub rarity: String,
pub tags: Vec<String>,
pub stackable: bool,
pub stack_key: String,
pub equipment_slot_id: Option<String>,
pub source_kind: String,
pub source_reference_id: Option<String>,
pub created_at: String,
pub updated_at: String,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct RuntimeInventoryStateRecord {
pub runtime_session_id: String,
pub actor_user_id: String,
pub backpack_items: Vec<RuntimeInventorySlotRecord>,
pub equipment_items: Vec<RuntimeInventorySlotRecord>,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct InventoryMutationOutcome {
pub next_slots: Vec<InventorySlotSnapshot>,
pub changed: bool,
pub updated_slot_ids: Vec<String>,
pub removed_slot_ids: Vec<String>,
pub affected_equipment_slot: Option<InventoryEquipmentSlot>,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum InventoryMutationFieldError {
MissingMutationId,
MissingRuntimeSessionId,
MissingActorUserId,
MissingSlotId,
MissingItemId,
MissingCategory,
MissingName,
InvalidQuantity,
MissingStackKey,
NonStackableItemMustStaySingleQuantity,
EquipmentItemCannotStack,
SlotScopeMismatch,
ItemNotFound,
ItemNotInBackpack,
ItemNotEquipped,
InsufficientQuantity,
ItemNotEquippable,
}
impl InventoryEquipmentSlot {
pub fn as_str(self) -> &'static str {
match self {
Self::Weapon => "weapon",
Self::Armor => "armor",
Self::Relic => "relic",
}
}
}
pub fn generate_inventory_slot_id(seed_micros: i64) -> String {
build_prefixed_seed_id(INVENTORY_SLOT_ID_PREFIX, seed_micros)
}
pub fn generate_inventory_mutation_id(seed_micros: i64) -> String {
build_prefixed_seed_id(INVENTORY_MUTATION_ID_PREFIX, seed_micros)
}
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)
}
pub fn build_runtime_inventory_state_query_input(
runtime_session_id: String,
actor_user_id: String,
) -> Result<RuntimeInventoryStateQueryInput, InventoryMutationFieldError> {
let input = RuntimeInventoryStateQueryInput {
runtime_session_id: normalize_required_text(
runtime_session_id,
InventoryMutationFieldError::MissingRuntimeSessionId,
)?,
actor_user_id: normalize_required_text(
actor_user_id,
InventoryMutationFieldError::MissingActorUserId,
)?,
};
Ok(input)
}
pub fn build_runtime_inventory_state_snapshot(
input: RuntimeInventoryStateQueryInput,
slots: Vec<InventorySlotSnapshot>,
) -> RuntimeInventoryStateSnapshot {
let mut backpack_items = Vec::new();
let mut equipment_items = Vec::new();
for slot in slots {
match slot.container_kind {
InventoryContainerKind::Backpack => backpack_items.push(slot),
InventoryContainerKind::Equipment => equipment_items.push(slot),
}
}
backpack_items.sort_by(|left, right| {
left.slot_key
.cmp(&right.slot_key)
.then(left.slot_id.cmp(&right.slot_id))
});
equipment_items.sort_by(|left, right| {
equipment_slot_order(left.equipment_slot_id)
.cmp(&equipment_slot_order(right.equipment_slot_id))
.then(left.slot_id.cmp(&right.slot_id))
});
RuntimeInventoryStateSnapshot {
runtime_session_id: input.runtime_session_id,
actor_user_id: input.actor_user_id,
backpack_items,
equipment_items,
}
}
pub fn apply_inventory_mutation(
current_slots: Vec<InventorySlotSnapshot>,
input: InventoryMutationInput,
) -> Result<InventoryMutationOutcome, InventoryMutationFieldError> {
let _mutation_id = normalize_required_text(
input.mutation_id,
InventoryMutationFieldError::MissingMutationId,
)?;
let runtime_session_id = normalize_required_text(
input.runtime_session_id,
InventoryMutationFieldError::MissingRuntimeSessionId,
)?;
let actor_user_id = normalize_required_text(
input.actor_user_id,
InventoryMutationFieldError::MissingActorUserId,
)?;
let story_session_id = normalize_optional_text(input.story_session_id);
let mut slots = current_slots;
for slot in &slots {
if slot.runtime_session_id != runtime_session_id || slot.actor_user_id != actor_user_id {
return Err(InventoryMutationFieldError::SlotScopeMismatch);
}
}
let outcome = match input.mutation {
InventoryMutation::GrantItem(grant) => apply_grant_item(
&mut slots,
runtime_session_id,
story_session_id,
actor_user_id,
grant,
input.updated_at_micros,
)?,
InventoryMutation::ConsumeItem(consume) => {
apply_consume_item(&mut slots, consume, input.updated_at_micros)?
}
InventoryMutation::EquipItem(equip) => {
apply_equip_item(&mut slots, equip, input.updated_at_micros)?
}
InventoryMutation::UnequipItem(unequip) => {
apply_unequip_item(&mut slots, unequip, input.updated_at_micros)?
}
};
Ok(InventoryMutationOutcome {
next_slots: sort_inventory_slots(slots),
changed: outcome.changed,
updated_slot_ids: sort_string_list(outcome.updated_slot_ids),
removed_slot_ids: sort_string_list(outcome.removed_slot_ids),
affected_equipment_slot: outcome.affected_equipment_slot,
})
}
#[derive(Clone, Debug, PartialEq, Eq)]
struct InventoryMutationInternalOutcome {
changed: bool,
updated_slot_ids: Vec<String>,
removed_slot_ids: Vec<String>,
affected_equipment_slot: Option<InventoryEquipmentSlot>,
}
fn apply_grant_item(
slots: &mut Vec<InventorySlotSnapshot>,
runtime_session_id: String,
story_session_id: Option<String>,
actor_user_id: String,
grant: GrantInventoryItemInput,
updated_at_micros: i64,
) -> Result<InventoryMutationInternalOutcome, InventoryMutationFieldError> {
let slot_id =
normalize_required_text(grant.slot_id, InventoryMutationFieldError::MissingSlotId)?;
let item = normalize_inventory_item_snapshot(grant.item)?;
if item.stackable {
if let Some(existing) = slots.iter_mut().find(|slot| {
slot.container_kind == InventoryContainerKind::Backpack
&& slot.stackable
&& slot.item_id == item.item_id
&& slot.stack_key == item.stack_key
}) {
existing.category = item.category;
existing.name = item.name;
existing.description = item.description;
existing.quantity += item.quantity;
existing.rarity = item.rarity;
existing.tags = item.tags;
existing.stackable = item.stackable;
existing.stack_key = item.stack_key;
existing.equipment_slot_id = item.equipment_slot_id;
existing.source_kind = item.source_kind;
existing.source_reference_id = item.source_reference_id;
existing.updated_at_micros = updated_at_micros;
return Ok(InventoryMutationInternalOutcome {
changed: true,
updated_slot_ids: vec![existing.slot_id.clone()],
removed_slot_ids: vec![],
affected_equipment_slot: None,
});
}
}
slots.push(InventorySlotSnapshot {
slot_id: slot_id.clone(),
runtime_session_id,
story_session_id,
actor_user_id,
container_kind: InventoryContainerKind::Backpack,
slot_key: build_backpack_slot_key(&slot_id),
item_id: item.item_id,
category: item.category,
name: item.name,
description: item.description,
quantity: item.quantity,
rarity: item.rarity,
tags: item.tags,
stackable: item.stackable,
stack_key: item.stack_key,
equipment_slot_id: item.equipment_slot_id,
source_kind: item.source_kind,
source_reference_id: item.source_reference_id,
created_at_micros: updated_at_micros,
updated_at_micros,
});
Ok(InventoryMutationInternalOutcome {
changed: true,
updated_slot_ids: vec![slot_id],
removed_slot_ids: vec![],
affected_equipment_slot: None,
})
}
fn apply_consume_item(
slots: &mut Vec<InventorySlotSnapshot>,
consume: ConsumeInventoryItemInput,
updated_at_micros: i64,
) -> Result<InventoryMutationInternalOutcome, InventoryMutationFieldError> {
let slot_id =
normalize_required_text(consume.slot_id, InventoryMutationFieldError::MissingSlotId)?;
if consume.quantity == 0 {
return Err(InventoryMutationFieldError::InvalidQuantity);
}
let slot_index = slots
.iter()
.position(|slot| slot.slot_id == slot_id)
.ok_or(InventoryMutationFieldError::ItemNotFound)?;
if slots[slot_index].container_kind != InventoryContainerKind::Backpack {
return Err(InventoryMutationFieldError::ItemNotInBackpack);
}
if slots[slot_index].quantity < consume.quantity {
return Err(InventoryMutationFieldError::InsufficientQuantity);
}
if slots[slot_index].quantity == consume.quantity {
slots.remove(slot_index);
return Ok(InventoryMutationInternalOutcome {
changed: true,
updated_slot_ids: vec![],
removed_slot_ids: vec![slot_id],
affected_equipment_slot: None,
});
}
slots[slot_index].quantity -= consume.quantity;
slots[slot_index].updated_at_micros = updated_at_micros;
Ok(InventoryMutationInternalOutcome {
changed: true,
updated_slot_ids: vec![slots[slot_index].slot_id.clone()],
removed_slot_ids: vec![],
affected_equipment_slot: None,
})
}
fn apply_equip_item(
slots: &mut [InventorySlotSnapshot],
equip: EquipInventoryItemInput,
updated_at_micros: i64,
) -> Result<InventoryMutationInternalOutcome, InventoryMutationFieldError> {
let slot_id =
normalize_required_text(equip.slot_id, InventoryMutationFieldError::MissingSlotId)?;
let source_index = slots
.iter()
.position(|slot| slot.slot_id == slot_id)
.ok_or(InventoryMutationFieldError::ItemNotFound)?;
let target_slot = slots[source_index]
.equipment_slot_id
.ok_or(InventoryMutationFieldError::ItemNotEquippable)?;
if slots[source_index].stackable {
return Err(InventoryMutationFieldError::EquipmentItemCannotStack);
}
if slots[source_index].quantity != 1 {
return Err(InventoryMutationFieldError::NonStackableItemMustStaySingleQuantity);
}
if slots[source_index].container_kind != InventoryContainerKind::Backpack {
if slots[source_index].container_kind == InventoryContainerKind::Equipment {
return Ok(InventoryMutationInternalOutcome {
changed: false,
updated_slot_ids: vec![],
removed_slot_ids: vec![],
affected_equipment_slot: Some(target_slot),
});
}
return Err(InventoryMutationFieldError::ItemNotInBackpack);
}
let occupied_index = slots.iter().position(|slot| {
slot.container_kind == InventoryContainerKind::Equipment
&& slot.slot_key == build_equipment_slot_key(target_slot)
});
let mut updated_slot_ids = vec![slot_id.clone()];
if let Some(occupied_index) = occupied_index {
// 首版装备互换直接在同一条 slot 真相记录上切容器,不生成临时副本。
slots[occupied_index].container_kind = InventoryContainerKind::Backpack;
slots[occupied_index].slot_key = build_backpack_slot_key(&slots[occupied_index].slot_id);
slots[occupied_index].updated_at_micros = updated_at_micros;
updated_slot_ids.push(slots[occupied_index].slot_id.clone());
}
slots[source_index].container_kind = InventoryContainerKind::Equipment;
slots[source_index].slot_key = build_equipment_slot_key(target_slot);
slots[source_index].updated_at_micros = updated_at_micros;
Ok(InventoryMutationInternalOutcome {
changed: true,
updated_slot_ids,
removed_slot_ids: vec![],
affected_equipment_slot: Some(target_slot),
})
}
fn apply_unequip_item(
slots: &mut [InventorySlotSnapshot],
unequip: UnequipInventoryItemInput,
updated_at_micros: i64,
) -> Result<InventoryMutationInternalOutcome, InventoryMutationFieldError> {
let slot_id =
normalize_required_text(unequip.slot_id, InventoryMutationFieldError::MissingSlotId)?;
let slot_index = slots
.iter()
.position(|slot| slot.slot_id == slot_id)
.ok_or(InventoryMutationFieldError::ItemNotFound)?;
if slots[slot_index].container_kind != InventoryContainerKind::Equipment {
return Err(InventoryMutationFieldError::ItemNotEquipped);
}
let affected_equipment_slot = slots[slot_index].equipment_slot_id;
slots[slot_index].container_kind = InventoryContainerKind::Backpack;
slots[slot_index].slot_key = build_backpack_slot_key(&slot_id);
slots[slot_index].updated_at_micros = updated_at_micros;
Ok(InventoryMutationInternalOutcome {
changed: true,
updated_slot_ids: vec![slot_id],
removed_slot_ids: vec![],
affected_equipment_slot,
})
}
fn normalize_inventory_item_snapshot(
item: InventoryItemSnapshot,
) -> Result<InventoryItemSnapshot, InventoryMutationFieldError> {
let item_id =
normalize_required_text(item.item_id, InventoryMutationFieldError::MissingItemId)?;
let category =
normalize_required_text(item.category, InventoryMutationFieldError::MissingCategory)?;
let name = normalize_required_text(item.name, InventoryMutationFieldError::MissingName)?;
if item.quantity == 0 {
return Err(InventoryMutationFieldError::InvalidQuantity);
}
if !item.stackable && item.quantity != 1 {
return Err(InventoryMutationFieldError::NonStackableItemMustStaySingleQuantity);
}
if item.equipment_slot_id.is_some() && item.stackable {
return Err(InventoryMutationFieldError::EquipmentItemCannotStack);
}
let stack_key = if item.stackable {
normalize_required_text(item.stack_key, InventoryMutationFieldError::MissingStackKey)?
} else {
normalize_optional_text(Some(item.stack_key)).unwrap_or_else(|| item_id.clone())
};
Ok(InventoryItemSnapshot {
item_id,
category,
name,
description: normalize_optional_text(item.description),
quantity: item.quantity,
rarity: item.rarity,
tags: normalize_string_list(item.tags),
stackable: item.stackable,
stack_key,
equipment_slot_id: item.equipment_slot_id,
source_kind: item.source_kind,
source_reference_id: normalize_optional_text(item.source_reference_id),
})
}
fn normalize_required_text(
value: String,
error: InventoryMutationFieldError,
) -> Result<String, InventoryMutationFieldError> {
normalize_required_string(value).ok_or(error)
}
fn sort_inventory_slots(mut slots: Vec<InventorySlotSnapshot>) -> Vec<InventorySlotSnapshot> {
slots.sort_by(|left, right| {
container_order(left.container_kind)
.cmp(&container_order(right.container_kind))
.then(left.slot_key.cmp(&right.slot_key))
.then(left.slot_id.cmp(&right.slot_id))
});
slots
}
fn sort_string_list(mut values: Vec<String>) -> Vec<String> {
values.sort();
values
}
fn container_order(kind: InventoryContainerKind) -> u8 {
match kind {
InventoryContainerKind::Equipment => 0,
InventoryContainerKind::Backpack => 1,
}
}
fn equipment_slot_order(slot: Option<InventoryEquipmentSlot>) -> u8 {
match slot {
Some(InventoryEquipmentSlot::Weapon) => 0,
Some(InventoryEquipmentSlot::Armor) => 1,
Some(InventoryEquipmentSlot::Relic) => 2,
None => 3,
}
}
fn build_backpack_slot_key(slot_id: &str) -> String {
slot_id.to_string()
}
fn build_equipment_slot_key(slot: InventoryEquipmentSlot) -> String {
slot.as_str().to_string()
}
pub fn build_runtime_inventory_state_record(
snapshot: RuntimeInventoryStateSnapshot,
) -> RuntimeInventoryStateRecord {
RuntimeInventoryStateRecord {
runtime_session_id: snapshot.runtime_session_id,
actor_user_id: snapshot.actor_user_id,
backpack_items: snapshot
.backpack_items
.into_iter()
.map(build_runtime_inventory_slot_record)
.collect(),
equipment_items: snapshot
.equipment_items
.into_iter()
.map(build_runtime_inventory_slot_record)
.collect(),
}
}
fn build_runtime_inventory_slot_record(slot: InventorySlotSnapshot) -> RuntimeInventorySlotRecord {
RuntimeInventorySlotRecord {
slot_id: slot.slot_id,
container_kind: format_inventory_container_kind(slot.container_kind).to_string(),
slot_key: slot.slot_key,
item_id: slot.item_id,
category: slot.category,
name: slot.name,
description: slot.description,
quantity: slot.quantity,
rarity: format_inventory_item_rarity(slot.rarity).to_string(),
tags: slot.tags,
stackable: slot.stackable,
stack_key: slot.stack_key,
equipment_slot_id: slot
.equipment_slot_id
.map(|value| value.as_str().to_string()),
source_kind: format_inventory_item_source_kind(slot.source_kind).to_string(),
source_reference_id: slot.source_reference_id,
created_at: format_timestamp_micros(slot.created_at_micros),
updated_at: format_timestamp_micros(slot.updated_at_micros),
}
}
fn format_inventory_container_kind(value: InventoryContainerKind) -> &'static str {
match value {
InventoryContainerKind::Backpack => "backpack",
InventoryContainerKind::Equipment => "equipment",
}
}
fn format_inventory_item_rarity(value: InventoryItemRarity) -> &'static str {
match value {
InventoryItemRarity::Common => "common",
InventoryItemRarity::Uncommon => "uncommon",
InventoryItemRarity::Rare => "rare",
InventoryItemRarity::Epic => "epic",
InventoryItemRarity::Legendary => "legendary",
}
}
fn format_inventory_item_source_kind(value: InventoryItemSourceKind) -> &'static str {
match value {
InventoryItemSourceKind::StoryReward => "story_reward",
InventoryItemSourceKind::QuestReward => "quest_reward",
InventoryItemSourceKind::TreasureReward => "treasure_reward",
InventoryItemSourceKind::NpcGift => "npc_gift",
InventoryItemSourceKind::NpcTrade => "npc_trade",
InventoryItemSourceKind::CombatDrop => "combat_drop",
InventoryItemSourceKind::ForgeCraft => "forge_craft",
InventoryItemSourceKind::ForgeReforge => "forge_reforge",
InventoryItemSourceKind::ManualPatch => "manual_patch",
}
}
impl fmt::Display for InventoryMutationFieldError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::MissingMutationId => f.write_str("inventory_mutation.mutation_id 不能为空"),
Self::MissingRuntimeSessionId => {
f.write_str("inventory_mutation.runtime_session_id 不能为空")
}
Self::MissingActorUserId => f.write_str("inventory_mutation.actor_user_id 不能为空"),
Self::MissingSlotId => f.write_str("inventory_slot.slot_id 不能为空"),
Self::MissingItemId => f.write_str("inventory_item.item_id 不能为空"),
Self::MissingCategory => f.write_str("inventory_item.category 不能为空"),
Self::MissingName => f.write_str("inventory_item.name 不能为空"),
Self::InvalidQuantity => f.write_str("inventory_item.quantity 必须大于 0"),
Self::MissingStackKey => f.write_str("可堆叠物品必须提供 stack_key"),
Self::NonStackableItemMustStaySingleQuantity => {
f.write_str("不可堆叠物品必须固定为单槽位单数量")
}
Self::EquipmentItemCannotStack => f.write_str("可装备物品不能标记为 stackable"),
Self::SlotScopeMismatch => {
f.write_str("当前 inventory_slot 不属于本次 mutation 作用域")
}
Self::ItemNotFound => f.write_str("目标 inventory_slot 不存在"),
Self::ItemNotInBackpack => f.write_str("目标物品当前不在背包中"),
Self::ItemNotEquipped => f.write_str("目标物品当前不在装备位上"),
Self::InsufficientQuantity => f.write_str("当前背包数量不足,无法完成扣减"),
Self::ItemNotEquippable => f.write_str("目标物品当前不可装备"),
}
}
}
impl Error for InventoryMutationFieldError {}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {

View File

@@ -1,3 +1,555 @@
//! NPC 应用编排过渡落位 //! NPC 应用编排。
//! //!
//! 这里只返回关系变化、推荐动作和跨上下文事件,不直接写战斗表。 //! 这里只返回关系变化、推荐动作和跨上下文事件,不直接写战斗表。
use crate::commands::{
NpcStateUpsertInput, ResolveNpcInteractionInput, ResolveNpcSocialActionInput,
};
use crate::domain::*;
use crate::errors::NpcStateFieldError;
use serde::{Deserialize, Serialize};
use shared_kernel::{
normalize_optional_string as normalize_shared_optional_string, normalize_required_string,
normalize_string_list as normalize_shared_string_list,
};
#[cfg(feature = "spacetime-types")]
use spacetimedb::SpacetimeType;
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct NpcStateProcedureResult {
pub ok: bool,
pub record: Option<NpcStateSnapshot>,
pub error_message: Option<String>,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct NpcInteractionResult {
pub npc_state: NpcStateSnapshot,
pub interaction_status: NpcInteractionStatus,
pub action_text: String,
pub result_text: String,
pub story_text: Option<String>,
pub battle_mode: Option<NpcInteractionBattleMode>,
pub encounter_closed: bool,
pub affinity_changed: bool,
pub previous_affinity: i32,
pub next_affinity: i32,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct NpcInteractionProcedureResult {
pub ok: bool,
pub result: Option<NpcInteractionResult>,
pub error_message: Option<String>,
}
pub fn generate_npc_state_id(runtime_session_id: &str, npc_id: &str) -> String {
format!(
"{}{}:{}",
NPC_STATE_ID_PREFIX,
runtime_session_id.trim(),
npc_id.trim()
)
}
pub fn build_relation_state(affinity: i32) -> NpcRelationState {
NpcRelationState {
affinity,
stance: if affinity < 0 {
NpcRelationStance::Hostile
} else if affinity < 15 {
NpcRelationStance::Guarded
} else if affinity < 30 {
NpcRelationStance::Neutral
} else if affinity < NPC_RECRUIT_AFFINITY_THRESHOLD {
NpcRelationStance::Cooperative
} else {
NpcRelationStance::Bonded
},
}
}
pub fn build_initial_stance_profile(
affinity: i32,
recruited: bool,
hostile: bool,
role_text: Option<&str>,
) -> NpcStanceProfile {
let recruited_bonus = if recruited { 14.0 } else { 0.0 };
let hostile_penalty = if hostile { 18.0 } else { 0.0 };
let current_conflict_tag = role_text.and_then(infer_conflict_tag);
NpcStanceProfile {
trust: clamp_stance_metric(
42.0 + affinity as f32 * 0.55 + recruited_bonus - hostile_penalty,
),
warmth: clamp_stance_metric(36.0 + affinity as f32 * 0.5 + recruited_bonus),
ideological_fit: clamp_stance_metric(48.0 + affinity as f32 * 0.25),
fear_or_guard: clamp_stance_metric(62.0 - affinity as f32 * 0.55 + hostile_penalty),
loyalty: clamp_stance_metric(
24.0 + affinity as f32 * 0.35 + if recruited { 26.0 } else { 0.0 },
),
current_conflict_tag,
recent_approvals: Vec::new(),
recent_disapprovals: Vec::new(),
}
}
pub fn normalize_npc_state_snapshot(
input: NpcStateUpsertInput,
existing_created_at_micros: Option<i64>,
) -> Result<NpcStateSnapshot, NpcStateFieldError> {
validate_required_identity_fields(&input.runtime_session_id, &input.npc_id, &input.npc_name)?;
let affinity = input.affinity;
let stance_profile = normalize_stance_profile(
input.stance_profile,
affinity,
input.recruited,
affinity < 0,
None,
);
let created_at_micros = existing_created_at_micros.unwrap_or(input.updated_at_micros);
Ok(NpcStateSnapshot {
npc_state_id: generate_npc_state_id(&input.runtime_session_id, &input.npc_id),
runtime_session_id: normalize_required_string(input.runtime_session_id).unwrap_or_default(),
npc_id: normalize_required_string(input.npc_id).unwrap_or_default(),
npc_name: normalize_required_string(input.npc_name).unwrap_or_default(),
affinity,
relation_state: build_relation_state(affinity),
help_used: input.help_used,
chatted_count: input.chatted_count,
gifts_given: input.gifts_given,
recruited: input.recruited,
trade_stock_signature: normalize_optional_value(input.trade_stock_signature),
revealed_facts: normalize_string_list(input.revealed_facts),
known_attribute_rumors: normalize_string_list(input.known_attribute_rumors),
first_meaningful_contact_resolved: input.first_meaningful_contact_resolved,
seen_backstory_chapter_ids: normalize_string_list(input.seen_backstory_chapter_ids),
stance_profile,
created_at_micros,
updated_at_micros: input.updated_at_micros,
})
}
pub fn apply_npc_social_action(
current: NpcStateSnapshot,
input: ResolveNpcSocialActionInput,
) -> Result<NpcStateSnapshot, NpcStateFieldError> {
validate_required_identity_fields(&input.runtime_session_id, &input.npc_id, &input.npc_name)?;
let note = normalize_optional_value(input.note);
let mut next = current;
match input.action_kind {
NpcSocialActionKind::Chat => {
let affinity_gain = input
.affinity_gain_override
.unwrap_or_else(|| (6 - next.chatted_count as i32).max(2));
next.affinity += affinity_gain;
next.chatted_count += 1;
next.first_meaningful_contact_resolved = true;
next.stance_profile = apply_story_choice_to_stance_profile(
&next.stance_profile,
input.action_kind,
affinity_gain,
next.recruited,
note.as_deref(),
);
}
NpcSocialActionKind::Help => {
if next.help_used {
return Err(NpcStateFieldError::HelpAlreadyUsed);
}
let affinity_gain = input.affinity_gain_override.unwrap_or(4);
next.affinity += affinity_gain;
next.help_used = true;
next.stance_profile = apply_story_choice_to_stance_profile(
&next.stance_profile,
input.action_kind,
affinity_gain,
next.recruited,
note.as_deref(),
);
}
NpcSocialActionKind::Gift => {
let affinity_gain = input.affinity_gain_override.unwrap_or(4);
next.affinity += affinity_gain;
next.gifts_given += 1;
next.stance_profile = apply_story_choice_to_stance_profile(
&next.stance_profile,
input.action_kind,
affinity_gain,
next.recruited,
note.as_deref(),
);
}
NpcSocialActionKind::Recruit => {
if next.affinity < NPC_RECRUIT_AFFINITY_THRESHOLD {
return Err(NpcStateFieldError::RecruitAffinityTooLow);
}
next.recruited = true;
next.first_meaningful_contact_resolved = true;
next.stance_profile = apply_story_choice_to_stance_profile(
&next.stance_profile,
input.action_kind,
input.affinity_gain_override.unwrap_or(0),
true,
note.as_deref(),
);
}
NpcSocialActionKind::QuestAccept => {
let affinity_gain = input.affinity_gain_override.unwrap_or(3);
next.affinity += affinity_gain;
next.stance_profile = apply_story_choice_to_stance_profile(
&next.stance_profile,
input.action_kind,
affinity_gain,
next.recruited,
note.as_deref(),
);
}
}
next.affinity = next.affinity.clamp(-100, 100);
next.npc_name = normalize_required_string(input.npc_name).unwrap_or_default();
next.relation_state = build_relation_state(next.affinity);
next.updated_at_micros = input.updated_at_micros;
Ok(next)
}
pub fn resolve_npc_interaction(
current: NpcStateSnapshot,
input: ResolveNpcInteractionInput,
) -> Result<NpcInteractionResult, NpcStateFieldError> {
validate_required_identity_fields(&input.runtime_session_id, &input.npc_id, &input.npc_name)?;
let interaction_function_id = normalize_optional_value(Some(input.interaction_function_id))
.ok_or(NpcStateFieldError::MissingInteractionFunctionId)?;
if !is_supported_npc_interaction_function_id(&interaction_function_id) {
return Err(NpcStateFieldError::UnsupportedInteractionFunctionId);
}
let previous_affinity = current.affinity;
let mut next_state = current.clone();
let (interaction_status, action_text, result_text, story_text, battle_mode, encounter_closed) =
match interaction_function_id.as_str() {
NPC_PREVIEW_TALK_FUNCTION_ID => (
NpcInteractionStatus::Previewed,
format!("转向{}", current.npc_name),
format!(
"你把注意力真正收回到{}身上,接下来可以围绕这名角色做正式交互了。",
current.npc_name
),
None,
None,
false,
),
NPC_CHAT_FUNCTION_ID => {
next_state = apply_npc_social_action(
current,
ResolveNpcSocialActionInput {
runtime_session_id: input.runtime_session_id,
npc_id: input.npc_id,
npc_name: input.npc_name,
action_kind: NpcSocialActionKind::Chat,
affinity_gain_override: None,
note: None,
updated_at_micros: input.updated_at_micros,
},
)?;
(
NpcInteractionStatus::Dialogue,
format!("继续和{}交谈", next_state.npc_name),
format!(
"{}愿意把话接下去,态度比刚才明显松动了一些。",
next_state.npc_name
),
Some(format!(
"{}看起来已经愿意继续把话题往下接。",
next_state.npc_name
)),
None,
false,
)
}
NPC_HELP_FUNCTION_ID => {
next_state = apply_npc_social_action(
current,
ResolveNpcSocialActionInput {
runtime_session_id: input.runtime_session_id,
npc_id: input.npc_id,
npc_name: input.npc_name,
action_kind: NpcSocialActionKind::Help,
affinity_gain_override: None,
note: None,
updated_at_micros: input.updated_at_micros,
},
)?;
(
NpcInteractionStatus::Resolved,
format!("{}请求援手", next_state.npc_name),
format!(
"{}给了你一次及时支援,关系也顺势拉近了一点。",
next_state.npc_name
),
None,
None,
false,
)
}
NPC_RECRUIT_FUNCTION_ID => {
next_state = apply_npc_social_action(
current,
ResolveNpcSocialActionInput {
runtime_session_id: input.runtime_session_id,
npc_id: input.npc_id,
npc_name: input.npc_name,
action_kind: NpcSocialActionKind::Recruit,
affinity_gain_override: None,
note: None,
updated_at_micros: input.updated_at_micros,
},
)?;
(
NpcInteractionStatus::Recruited,
format!("邀请{}加入队伍", next_state.npc_name),
format!("{}接受了你的邀请。", next_state.npc_name),
Some(format!(
"{}已经明确接受了与你同行的关系。",
next_state.npc_name
)),
None,
true,
)
}
NPC_FIGHT_FUNCTION_ID => (
NpcInteractionStatus::BattlePending,
format!("{}正面开战", current.npc_name),
format!(
"{}已经不再保留余地,当前冲突正式转入战斗结算。",
current.npc_name
),
None,
Some(NpcInteractionBattleMode::Fight),
false,
),
NPC_SPAR_FUNCTION_ID => (
NpcInteractionStatus::BattlePending,
format!("{}点到为止切磋", current.npc_name),
format!(
"{}摆开架势,准备和你来一场点到为止的切磋。",
current.npc_name
),
None,
Some(NpcInteractionBattleMode::Spar),
false,
),
NPC_LEAVE_FUNCTION_ID => (
NpcInteractionStatus::Left,
format!("离开{}", current.npc_name),
format!(
"你暂时没有继续和{}纠缠,把注意力重新拉回了前路。",
current.npc_name
),
None,
None,
true,
),
_ => return Err(NpcStateFieldError::UnsupportedInteractionFunctionId),
};
Ok(NpcInteractionResult {
npc_state: next_state.clone(),
interaction_status,
action_text,
result_text,
story_text,
battle_mode,
encounter_closed,
affinity_changed: previous_affinity != next_state.affinity,
previous_affinity,
next_affinity: next_state.affinity,
})
}
pub fn normalize_optional_value(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)
}
pub fn is_supported_npc_interaction_function_id(function_id: &str) -> bool {
matches!(
function_id,
NPC_PREVIEW_TALK_FUNCTION_ID
| NPC_CHAT_FUNCTION_ID
| NPC_HELP_FUNCTION_ID
| NPC_RECRUIT_FUNCTION_ID
| NPC_FIGHT_FUNCTION_ID
| NPC_SPAR_FUNCTION_ID
| NPC_LEAVE_FUNCTION_ID
)
}
fn validate_required_identity_fields(
runtime_session_id: &str,
npc_id: &str,
npc_name: &str,
) -> Result<(), NpcStateFieldError> {
if normalize_required_string(runtime_session_id).is_none() {
return Err(NpcStateFieldError::MissingRuntimeSessionId);
}
if normalize_required_string(npc_id).is_none() {
return Err(NpcStateFieldError::MissingNpcId);
}
if normalize_required_string(npc_name).is_none() {
return Err(NpcStateFieldError::MissingNpcName);
}
Ok(())
}
fn normalize_stance_profile(
stance_profile: Option<NpcStanceProfile>,
affinity: i32,
recruited: bool,
hostile: bool,
role_text: Option<&str>,
) -> NpcStanceProfile {
let Some(stance_profile) = stance_profile else {
return build_initial_stance_profile(affinity, recruited, hostile, role_text);
};
NpcStanceProfile {
trust: clamp_stance_metric(stance_profile.trust as f32),
warmth: clamp_stance_metric(stance_profile.warmth as f32),
ideological_fit: clamp_stance_metric(stance_profile.ideological_fit as f32),
fear_or_guard: clamp_stance_metric(stance_profile.fear_or_guard as f32),
loyalty: clamp_stance_metric(stance_profile.loyalty as f32),
current_conflict_tag: normalize_optional_value(stance_profile.current_conflict_tag),
recent_approvals: trim_recent_notes(stance_profile.recent_approvals),
recent_disapprovals: trim_recent_notes(stance_profile.recent_disapprovals),
}
}
fn apply_story_choice_to_stance_profile(
stance_profile: &NpcStanceProfile,
action_kind: NpcSocialActionKind,
affinity_gain: i32,
recruited: bool,
note: Option<&str>,
) -> NpcStanceProfile {
let mut next = stance_profile.clone();
match action_kind {
NpcSocialActionKind::Chat => {
next.trust = clamp_stance_metric(next.trust as f32 + 6.0 + affinity_gain as f32 * 2.0);
next.warmth =
clamp_stance_metric(next.warmth as f32 + 4.0 + affinity_gain as f32 * 2.0);
next.fear_or_guard =
clamp_stance_metric(next.fear_or_guard as f32 - 5.0 - affinity_gain as f32);
if affinity_gain >= 0 {
push_recent_note(
&mut next.recent_approvals,
note.unwrap_or("你愿意先从眼前局势和试探开始说话。"),
);
} else {
push_recent_note(
&mut next.recent_disapprovals,
note.unwrap_or("这轮交流没能真正对上节奏。"),
);
}
}
NpcSocialActionKind::Help => {
next.trust = clamp_stance_metric(next.trust as f32 + 12.0);
next.warmth = clamp_stance_metric(next.warmth as f32 + 6.0);
next.fear_or_guard = clamp_stance_metric(next.fear_or_guard as f32 - 8.0);
push_recent_note(
&mut next.recent_approvals,
note.unwrap_or("你在对方需要的时候搭了手。"),
);
}
NpcSocialActionKind::Gift => {
next.trust = clamp_stance_metric(next.trust as f32 + 6.0 + affinity_gain as f32);
next.warmth =
clamp_stance_metric(next.warmth as f32 + 10.0 + affinity_gain as f32 * 2.0);
next.fear_or_guard = clamp_stance_metric(next.fear_or_guard as f32 - 4.0);
push_recent_note(
&mut next.recent_approvals,
note.unwrap_or("你给出的东西回应了对方眼下的处境。"),
);
}
NpcSocialActionKind::Recruit => {
next.trust = clamp_stance_metric(next.trust as f32 + 8.0);
next.warmth = clamp_stance_metric(next.warmth as f32 + 6.0);
next.loyalty =
clamp_stance_metric(next.loyalty as f32 + 18.0 + if recruited { 4.0 } else { 0.0 });
next.fear_or_guard = clamp_stance_metric(next.fear_or_guard as f32 - 10.0);
push_recent_note(
&mut next.recent_approvals,
note.unwrap_or("你正式把对方纳入了同行关系。"),
);
}
NpcSocialActionKind::QuestAccept => {
next.trust = clamp_stance_metric(next.trust as f32 + 7.0);
next.ideological_fit = clamp_stance_metric(next.ideological_fit as f32 + 5.0);
next.loyalty = clamp_stance_metric(next.loyalty as f32 + 4.0);
push_recent_note(
&mut next.recent_approvals,
note.unwrap_or("你接住了对方主动交出来的事。"),
);
}
}
next
}
fn infer_conflict_tag(value: &str) -> Option<String> {
let trimmed = value.trim();
if trimmed.is_empty() {
None
} else if trimmed.contains("旧案") || trimmed.contains("调查") || trimmed.contains("追查")
{
Some("旧案".to_string())
} else if trimmed.contains('守') || trimmed.contains('卫') || trimmed.contains('巡') {
Some("守线".to_string())
} else if trimmed.contains('商') || trimmed.contains('摊') || trimmed.contains("军需") {
Some("交易".to_string())
} else {
None
}
}
fn trim_recent_notes(values: Vec<String>) -> Vec<String> {
let mut values = normalize_string_list(values);
if values.len() > MAX_STANCE_NOTES {
values = values.split_off(values.len() - MAX_STANCE_NOTES);
}
values
}
fn push_recent_note(target: &mut Vec<String>, note: &str) {
let trimmed = note.trim();
if trimmed.is_empty() {
return;
}
target.push(trimmed.to_string());
if target.len() > MAX_STANCE_NOTES {
let drain_len = target.len() - MAX_STANCE_NOTES;
target.drain(0..drain_len);
}
}
fn clamp_stance_metric(value: f32) -> u8 {
value.round().clamp(0.0, 100.0) as u8
}

View File

@@ -1,3 +1,51 @@
//! NPC 写入命令过渡落位 //! NPC 写入命令。
//! //!
//! 用于表达聊天、帮助、送礼、招募、开战和切磋等输入。 //! 用于表达聊天、帮助、送礼、招募、开战和切磋等输入。
use crate::domain::{NpcSocialActionKind, NpcStanceProfile};
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 NpcStateUpsertInput {
pub runtime_session_id: String,
pub npc_id: String,
pub npc_name: String,
pub affinity: i32,
pub help_used: bool,
pub chatted_count: u32,
pub gifts_given: u32,
pub recruited: bool,
pub trade_stock_signature: Option<String>,
pub revealed_facts: Vec<String>,
pub known_attribute_rumors: Vec<String>,
pub first_meaningful_contact_resolved: bool,
pub seen_backstory_chapter_ids: Vec<String>,
pub stance_profile: Option<NpcStanceProfile>,
pub updated_at_micros: i64,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct ResolveNpcSocialActionInput {
pub runtime_session_id: String,
pub npc_id: String,
pub npc_name: String,
pub action_kind: NpcSocialActionKind,
pub affinity_gain_override: Option<i32>,
pub note: Option<String>,
pub updated_at_micros: i64,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct ResolveNpcInteractionInput {
pub runtime_session_id: String,
pub npc_id: String,
pub npc_name: String,
pub interaction_function_id: String,
pub release_npc_id: Option<String>,
pub updated_at_micros: i64,
}

View File

@@ -1,4 +1,99 @@
//! NPC 领域模型过渡落位 //! NPC 领域模型。
//! //!
//! 后续迁移 NPC 状态、关系、好感、招募和互动规则时,只保留社交领域变化; //! 本文件只承载 NPC 聚合状态、关系、立场和交互值对象;对话文本、战斗初始化和任务联动由外层应用服务或 adapter 编排。
//! 战斗初始化和跨表事务由外层编排。
use serde::{Deserialize, Serialize};
#[cfg(feature = "spacetime-types")]
use spacetimedb::SpacetimeType;
pub const NPC_STATE_ID_PREFIX: &str = "npcstate_";
pub const MAX_STANCE_NOTES: usize = 3;
pub const NPC_RECRUIT_AFFINITY_THRESHOLD: i32 = 60;
pub const NPC_PREVIEW_TALK_FUNCTION_ID: &str = "npc_preview_talk";
pub const NPC_CHAT_FUNCTION_ID: &str = "npc_chat";
pub const NPC_HELP_FUNCTION_ID: &str = "npc_help";
pub const NPC_RECRUIT_FUNCTION_ID: &str = "npc_recruit";
pub const NPC_FIGHT_FUNCTION_ID: &str = "npc_fight";
pub const NPC_SPAR_FUNCTION_ID: &str = "npc_spar";
pub const NPC_LEAVE_FUNCTION_ID: &str = "npc_leave";
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub enum NpcRelationStance {
Hostile,
Guarded,
Neutral,
Cooperative,
Bonded,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub enum NpcSocialActionKind {
Chat,
Help,
Gift,
Recruit,
QuestAccept,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub enum NpcInteractionStatus {
Previewed,
Dialogue,
Resolved,
Recruited,
BattlePending,
Left,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub enum NpcInteractionBattleMode {
Fight,
Spar,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct NpcRelationState {
pub affinity: i32,
pub stance: NpcRelationStance,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct NpcStanceProfile {
pub trust: u8,
pub warmth: u8,
pub ideological_fit: u8,
pub fear_or_guard: u8,
pub loyalty: u8,
pub current_conflict_tag: Option<String>,
pub recent_approvals: Vec<String>,
pub recent_disapprovals: Vec<String>,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct NpcStateSnapshot {
pub npc_state_id: String,
pub runtime_session_id: String,
pub npc_id: String,
pub npc_name: String,
pub affinity: i32,
pub relation_state: NpcRelationState,
pub help_used: bool,
pub chatted_count: u32,
pub gifts_given: u32,
pub recruited: bool,
pub trade_stock_signature: Option<String>,
pub revealed_facts: Vec<String>,
pub known_attribute_rumors: Vec<String>,
pub first_meaningful_contact_resolved: bool,
pub seen_backstory_chapter_ids: Vec<String>,
pub stance_profile: NpcStanceProfile,
pub created_at_micros: i64,
pub updated_at_micros: i64,
}

View File

@@ -1,3 +1,38 @@
//! NPC 领域错误过渡落位 //! NPC 领域错误。
//! //!
//! 错误只表达互动规则失败,例如状态不允许、好感不足或目标非法。 //! 错误只表达互动规则失败,例如状态不允许、好感不足或目标非法。
use std::{error::Error, fmt};
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum NpcStateFieldError {
MissingRuntimeSessionId,
MissingNpcId,
MissingNpcName,
MissingInteractionFunctionId,
HelpAlreadyUsed,
RecruitAffinityTooLow,
UnsupportedInteractionFunctionId,
}
impl fmt::Display for NpcStateFieldError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::MissingRuntimeSessionId => f.write_str("npc_state.runtime_session_id 不能为空"),
Self::MissingNpcId => f.write_str("npc_state.npc_id 不能为空"),
Self::MissingNpcName => f.write_str("npc_state.npc_name 不能为空"),
Self::MissingInteractionFunctionId => {
f.write_str("resolve_npc_interaction.interaction_function_id 不能为空")
}
Self::HelpAlreadyUsed => f.write_str("npc_state.help_used 已经消耗,不能重复援手"),
Self::RecruitAffinityTooLow => {
f.write_str("npc_state.affinity 未达到招募阈值,不能执行招募动作")
}
Self::UnsupportedInteractionFunctionId => {
f.write_str("resolve_npc_interaction.interaction_function_id 当前不受支持")
}
}
}
}
impl Error for NpcStateFieldError {}

View File

@@ -1,3 +1,51 @@
//! NPC 领域事件过渡落位 //! NPC 领域事件。
//! //!
//! 用于表达好感变化、关系变化、NPC 被招募和战斗请求产生等事实。 //! 用于表达好感变化、关系变化、NPC 被招募和战斗请求产生等事实。
use crate::domain::{NpcInteractionBattleMode, NpcRelationStance, NpcSocialActionKind};
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 enum NpcDomainEvent {
RelationChanged(NpcRelationChangedEvent),
SocialActionResolved(NpcSocialActionResolvedEvent),
RecruitResolved(NpcRecruitResolvedEvent),
BattleRequested(NpcBattleRequestedEvent),
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct NpcRelationChangedEvent {
pub npc_state_id: String,
pub previous_affinity: i32,
pub next_affinity: i32,
pub next_stance: NpcRelationStance,
pub occurred_at_micros: i64,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct NpcSocialActionResolvedEvent {
pub npc_state_id: String,
pub action_kind: NpcSocialActionKind,
pub occurred_at_micros: i64,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct NpcRecruitResolvedEvent {
pub npc_state_id: String,
pub recruited: bool,
pub occurred_at_micros: i64,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct NpcBattleRequestedEvent {
pub npc_state_id: String,
pub battle_mode: NpcInteractionBattleMode,
pub occurred_at_micros: i64,
}

View File

@@ -4,722 +4,11 @@ mod domain;
mod errors; mod errors;
mod events; mod events;
use std::{error::Error, fmt}; pub use application::*;
pub use commands::*;
use serde::{Deserialize, Serialize}; pub use domain::*;
use shared_kernel::{ pub use errors::*;
normalize_optional_string as normalize_shared_optional_string, normalize_required_string, pub use events::*;
normalize_string_list as normalize_shared_string_list,
};
#[cfg(feature = "spacetime-types")]
use spacetimedb::SpacetimeType;
pub const NPC_STATE_ID_PREFIX: &str = "npcstate_";
pub const MAX_STANCE_NOTES: usize = 3;
pub const NPC_RECRUIT_AFFINITY_THRESHOLD: i32 = 60;
pub const NPC_PREVIEW_TALK_FUNCTION_ID: &str = "npc_preview_talk";
pub const NPC_CHAT_FUNCTION_ID: &str = "npc_chat";
pub const NPC_HELP_FUNCTION_ID: &str = "npc_help";
pub const NPC_RECRUIT_FUNCTION_ID: &str = "npc_recruit";
pub const NPC_FIGHT_FUNCTION_ID: &str = "npc_fight";
pub const NPC_SPAR_FUNCTION_ID: &str = "npc_spar";
pub const NPC_LEAVE_FUNCTION_ID: &str = "npc_leave";
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub enum NpcRelationStance {
Hostile,
Guarded,
Neutral,
Cooperative,
Bonded,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub enum NpcSocialActionKind {
Chat,
Help,
Gift,
Recruit,
QuestAccept,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub enum NpcInteractionStatus {
Previewed,
Dialogue,
Resolved,
Recruited,
BattlePending,
Left,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub enum NpcInteractionBattleMode {
Fight,
Spar,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct NpcRelationState {
pub affinity: i32,
pub stance: NpcRelationStance,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct NpcStanceProfile {
pub trust: u8,
pub warmth: u8,
pub ideological_fit: u8,
pub fear_or_guard: u8,
pub loyalty: u8,
pub current_conflict_tag: Option<String>,
pub recent_approvals: Vec<String>,
pub recent_disapprovals: Vec<String>,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct NpcStateSnapshot {
pub npc_state_id: String,
pub runtime_session_id: String,
pub npc_id: String,
pub npc_name: String,
pub affinity: i32,
pub relation_state: NpcRelationState,
pub help_used: bool,
pub chatted_count: u32,
pub gifts_given: u32,
pub recruited: bool,
pub trade_stock_signature: Option<String>,
pub revealed_facts: Vec<String>,
pub known_attribute_rumors: Vec<String>,
pub first_meaningful_contact_resolved: bool,
pub seen_backstory_chapter_ids: Vec<String>,
pub stance_profile: NpcStanceProfile,
pub created_at_micros: i64,
pub updated_at_micros: i64,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct NpcStateUpsertInput {
pub runtime_session_id: String,
pub npc_id: String,
pub npc_name: String,
pub affinity: i32,
pub help_used: bool,
pub chatted_count: u32,
pub gifts_given: u32,
pub recruited: bool,
pub trade_stock_signature: Option<String>,
pub revealed_facts: Vec<String>,
pub known_attribute_rumors: Vec<String>,
pub first_meaningful_contact_resolved: bool,
pub seen_backstory_chapter_ids: Vec<String>,
pub stance_profile: Option<NpcStanceProfile>,
pub updated_at_micros: i64,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct ResolveNpcSocialActionInput {
pub runtime_session_id: String,
pub npc_id: String,
pub npc_name: String,
pub action_kind: NpcSocialActionKind,
pub affinity_gain_override: Option<i32>,
pub note: Option<String>,
pub updated_at_micros: i64,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct ResolveNpcInteractionInput {
pub runtime_session_id: String,
pub npc_id: String,
pub npc_name: String,
pub interaction_function_id: String,
pub release_npc_id: Option<String>,
pub updated_at_micros: i64,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct NpcStateProcedureResult {
pub ok: bool,
pub record: Option<NpcStateSnapshot>,
pub error_message: Option<String>,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct NpcInteractionResult {
pub npc_state: NpcStateSnapshot,
pub interaction_status: NpcInteractionStatus,
pub action_text: String,
pub result_text: String,
pub story_text: Option<String>,
pub battle_mode: Option<NpcInteractionBattleMode>,
pub encounter_closed: bool,
pub affinity_changed: bool,
pub previous_affinity: i32,
pub next_affinity: i32,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct NpcInteractionProcedureResult {
pub ok: bool,
pub result: Option<NpcInteractionResult>,
pub error_message: Option<String>,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum NpcStateFieldError {
MissingRuntimeSessionId,
MissingNpcId,
MissingNpcName,
MissingInteractionFunctionId,
HelpAlreadyUsed,
RecruitAffinityTooLow,
UnsupportedInteractionFunctionId,
}
pub fn generate_npc_state_id(runtime_session_id: &str, npc_id: &str) -> String {
format!(
"{}{}:{}",
NPC_STATE_ID_PREFIX,
runtime_session_id.trim(),
npc_id.trim()
)
}
pub fn build_relation_state(affinity: i32) -> NpcRelationState {
NpcRelationState {
affinity,
stance: if affinity < 0 {
NpcRelationStance::Hostile
} else if affinity < 15 {
NpcRelationStance::Guarded
} else if affinity < 30 {
NpcRelationStance::Neutral
} else if affinity < NPC_RECRUIT_AFFINITY_THRESHOLD {
NpcRelationStance::Cooperative
} else {
NpcRelationStance::Bonded
},
}
}
pub fn build_initial_stance_profile(
affinity: i32,
recruited: bool,
hostile: bool,
role_text: Option<&str>,
) -> NpcStanceProfile {
let recruited_bonus = if recruited { 14.0 } else { 0.0 };
let hostile_penalty = if hostile { 18.0 } else { 0.0 };
let current_conflict_tag = role_text.and_then(infer_conflict_tag);
NpcStanceProfile {
trust: clamp_stance_metric(
42.0 + affinity as f32 * 0.55 + recruited_bonus - hostile_penalty,
),
warmth: clamp_stance_metric(36.0 + affinity as f32 * 0.5 + recruited_bonus),
ideological_fit: clamp_stance_metric(48.0 + affinity as f32 * 0.25),
fear_or_guard: clamp_stance_metric(62.0 - affinity as f32 * 0.55 + hostile_penalty),
loyalty: clamp_stance_metric(
24.0 + affinity as f32 * 0.35 + if recruited { 26.0 } else { 0.0 },
),
current_conflict_tag,
recent_approvals: Vec::new(),
recent_disapprovals: Vec::new(),
}
}
pub fn normalize_npc_state_snapshot(
input: NpcStateUpsertInput,
existing_created_at_micros: Option<i64>,
) -> Result<NpcStateSnapshot, NpcStateFieldError> {
validate_required_identity_fields(&input.runtime_session_id, &input.npc_id, &input.npc_name)?;
let affinity = input.affinity;
let stance_profile = normalize_stance_profile(
input.stance_profile,
affinity,
input.recruited,
affinity < 0,
None,
);
let created_at_micros = existing_created_at_micros.unwrap_or(input.updated_at_micros);
Ok(NpcStateSnapshot {
npc_state_id: generate_npc_state_id(&input.runtime_session_id, &input.npc_id),
runtime_session_id: normalize_required_string(input.runtime_session_id).unwrap_or_default(),
npc_id: normalize_required_string(input.npc_id).unwrap_or_default(),
npc_name: normalize_required_string(input.npc_name).unwrap_or_default(),
affinity,
relation_state: build_relation_state(affinity),
help_used: input.help_used,
chatted_count: input.chatted_count,
gifts_given: input.gifts_given,
recruited: input.recruited,
trade_stock_signature: normalize_optional_value(input.trade_stock_signature),
revealed_facts: normalize_string_list(input.revealed_facts),
known_attribute_rumors: normalize_string_list(input.known_attribute_rumors),
first_meaningful_contact_resolved: input.first_meaningful_contact_resolved,
seen_backstory_chapter_ids: normalize_string_list(input.seen_backstory_chapter_ids),
stance_profile,
created_at_micros,
updated_at_micros: input.updated_at_micros,
})
}
pub fn apply_npc_social_action(
current: NpcStateSnapshot,
input: ResolveNpcSocialActionInput,
) -> Result<NpcStateSnapshot, NpcStateFieldError> {
validate_required_identity_fields(&input.runtime_session_id, &input.npc_id, &input.npc_name)?;
let note = normalize_optional_value(input.note);
let mut next = current;
match input.action_kind {
NpcSocialActionKind::Chat => {
let affinity_gain = input
.affinity_gain_override
.unwrap_or_else(|| (6 - next.chatted_count as i32).max(2));
next.affinity += affinity_gain;
next.chatted_count += 1;
next.first_meaningful_contact_resolved = true;
next.stance_profile = apply_story_choice_to_stance_profile(
&next.stance_profile,
input.action_kind,
affinity_gain,
next.recruited,
note.as_deref(),
);
}
NpcSocialActionKind::Help => {
if next.help_used {
return Err(NpcStateFieldError::HelpAlreadyUsed);
}
let affinity_gain = input.affinity_gain_override.unwrap_or(4);
next.affinity += affinity_gain;
next.help_used = true;
next.stance_profile = apply_story_choice_to_stance_profile(
&next.stance_profile,
input.action_kind,
affinity_gain,
next.recruited,
note.as_deref(),
);
}
NpcSocialActionKind::Gift => {
let affinity_gain = input.affinity_gain_override.unwrap_or(4);
next.affinity += affinity_gain;
next.gifts_given += 1;
next.stance_profile = apply_story_choice_to_stance_profile(
&next.stance_profile,
input.action_kind,
affinity_gain,
next.recruited,
note.as_deref(),
);
}
NpcSocialActionKind::Recruit => {
if next.affinity < NPC_RECRUIT_AFFINITY_THRESHOLD {
return Err(NpcStateFieldError::RecruitAffinityTooLow);
}
next.recruited = true;
next.first_meaningful_contact_resolved = true;
next.stance_profile = apply_story_choice_to_stance_profile(
&next.stance_profile,
input.action_kind,
input.affinity_gain_override.unwrap_or(0),
true,
note.as_deref(),
);
}
NpcSocialActionKind::QuestAccept => {
let affinity_gain = input.affinity_gain_override.unwrap_or(3);
next.affinity += affinity_gain;
next.stance_profile = apply_story_choice_to_stance_profile(
&next.stance_profile,
input.action_kind,
affinity_gain,
next.recruited,
note.as_deref(),
);
}
}
next.affinity = next.affinity.clamp(-100, 100);
next.npc_name = normalize_required_string(input.npc_name).unwrap_or_default();
next.relation_state = build_relation_state(next.affinity);
next.updated_at_micros = input.updated_at_micros;
Ok(next)
}
pub fn resolve_npc_interaction(
current: NpcStateSnapshot,
input: ResolveNpcInteractionInput,
) -> Result<NpcInteractionResult, NpcStateFieldError> {
validate_required_identity_fields(&input.runtime_session_id, &input.npc_id, &input.npc_name)?;
let interaction_function_id = normalize_optional_value(Some(input.interaction_function_id))
.ok_or(NpcStateFieldError::MissingInteractionFunctionId)?;
if !is_supported_npc_interaction_function_id(&interaction_function_id) {
return Err(NpcStateFieldError::UnsupportedInteractionFunctionId);
}
let previous_affinity = current.affinity;
let mut next_state = current.clone();
let (interaction_status, action_text, result_text, story_text, battle_mode, encounter_closed) =
match interaction_function_id.as_str() {
NPC_PREVIEW_TALK_FUNCTION_ID => (
NpcInteractionStatus::Previewed,
format!("转向{}", current.npc_name),
format!(
"你把注意力真正收回到{}身上,接下来可以围绕这名角色做正式交互了。",
current.npc_name
),
None,
None,
false,
),
NPC_CHAT_FUNCTION_ID => {
next_state = apply_npc_social_action(
current,
ResolveNpcSocialActionInput {
runtime_session_id: input.runtime_session_id,
npc_id: input.npc_id,
npc_name: input.npc_name,
action_kind: NpcSocialActionKind::Chat,
affinity_gain_override: None,
note: None,
updated_at_micros: input.updated_at_micros,
},
)?;
(
NpcInteractionStatus::Dialogue,
format!("继续和{}交谈", next_state.npc_name),
format!(
"{}愿意把话接下去,态度比刚才明显松动了一些。",
next_state.npc_name
),
Some(format!(
"{}看起来已经愿意继续把话题往下接。",
next_state.npc_name
)),
None,
false,
)
}
NPC_HELP_FUNCTION_ID => {
next_state = apply_npc_social_action(
current,
ResolveNpcSocialActionInput {
runtime_session_id: input.runtime_session_id,
npc_id: input.npc_id,
npc_name: input.npc_name,
action_kind: NpcSocialActionKind::Help,
affinity_gain_override: None,
note: None,
updated_at_micros: input.updated_at_micros,
},
)?;
(
NpcInteractionStatus::Resolved,
format!("{}请求援手", next_state.npc_name),
format!(
"{}给了你一次及时支援,关系也顺势拉近了一点。",
next_state.npc_name
),
None,
None,
false,
)
}
NPC_RECRUIT_FUNCTION_ID => {
next_state = apply_npc_social_action(
current,
ResolveNpcSocialActionInput {
runtime_session_id: input.runtime_session_id,
npc_id: input.npc_id,
npc_name: input.npc_name,
action_kind: NpcSocialActionKind::Recruit,
affinity_gain_override: None,
note: None,
updated_at_micros: input.updated_at_micros,
},
)?;
(
NpcInteractionStatus::Recruited,
format!("邀请{}加入队伍", next_state.npc_name),
format!("{}接受了你的邀请。", next_state.npc_name),
Some(format!(
"{}已经明确接受了与你同行的关系。",
next_state.npc_name
)),
None,
true,
)
}
NPC_FIGHT_FUNCTION_ID => (
NpcInteractionStatus::BattlePending,
format!("{}正面开战", current.npc_name),
format!(
"{}已经不再保留余地,当前冲突正式转入战斗结算。",
current.npc_name
),
None,
Some(NpcInteractionBattleMode::Fight),
false,
),
NPC_SPAR_FUNCTION_ID => (
NpcInteractionStatus::BattlePending,
format!("{}点到为止切磋", current.npc_name),
format!(
"{}摆开架势,准备和你来一场点到为止的切磋。",
current.npc_name
),
None,
Some(NpcInteractionBattleMode::Spar),
false,
),
NPC_LEAVE_FUNCTION_ID => (
NpcInteractionStatus::Left,
format!("离开{}", current.npc_name),
format!(
"你暂时没有继续和{}纠缠,把注意力重新拉回了前路。",
current.npc_name
),
None,
None,
true,
),
_ => return Err(NpcStateFieldError::UnsupportedInteractionFunctionId),
};
Ok(NpcInteractionResult {
npc_state: next_state.clone(),
interaction_status,
action_text,
result_text,
story_text,
battle_mode,
encounter_closed,
affinity_changed: previous_affinity != next_state.affinity,
previous_affinity,
next_affinity: next_state.affinity,
})
}
pub fn normalize_optional_value(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)
}
pub fn is_supported_npc_interaction_function_id(function_id: &str) -> bool {
matches!(
function_id,
NPC_PREVIEW_TALK_FUNCTION_ID
| NPC_CHAT_FUNCTION_ID
| NPC_HELP_FUNCTION_ID
| NPC_RECRUIT_FUNCTION_ID
| NPC_FIGHT_FUNCTION_ID
| NPC_SPAR_FUNCTION_ID
| NPC_LEAVE_FUNCTION_ID
)
}
fn validate_required_identity_fields(
runtime_session_id: &str,
npc_id: &str,
npc_name: &str,
) -> Result<(), NpcStateFieldError> {
if normalize_required_string(runtime_session_id).is_none() {
return Err(NpcStateFieldError::MissingRuntimeSessionId);
}
if normalize_required_string(npc_id).is_none() {
return Err(NpcStateFieldError::MissingNpcId);
}
if normalize_required_string(npc_name).is_none() {
return Err(NpcStateFieldError::MissingNpcName);
}
Ok(())
}
fn normalize_stance_profile(
stance_profile: Option<NpcStanceProfile>,
affinity: i32,
recruited: bool,
hostile: bool,
role_text: Option<&str>,
) -> NpcStanceProfile {
let Some(stance_profile) = stance_profile else {
return build_initial_stance_profile(affinity, recruited, hostile, role_text);
};
NpcStanceProfile {
trust: clamp_stance_metric(stance_profile.trust as f32),
warmth: clamp_stance_metric(stance_profile.warmth as f32),
ideological_fit: clamp_stance_metric(stance_profile.ideological_fit as f32),
fear_or_guard: clamp_stance_metric(stance_profile.fear_or_guard as f32),
loyalty: clamp_stance_metric(stance_profile.loyalty as f32),
current_conflict_tag: normalize_optional_value(stance_profile.current_conflict_tag),
recent_approvals: trim_recent_notes(stance_profile.recent_approvals),
recent_disapprovals: trim_recent_notes(stance_profile.recent_disapprovals),
}
}
fn apply_story_choice_to_stance_profile(
stance_profile: &NpcStanceProfile,
action_kind: NpcSocialActionKind,
affinity_gain: i32,
recruited: bool,
note: Option<&str>,
) -> NpcStanceProfile {
let mut next = stance_profile.clone();
match action_kind {
NpcSocialActionKind::Chat => {
next.trust = clamp_stance_metric(next.trust as f32 + 6.0 + affinity_gain as f32 * 2.0);
next.warmth =
clamp_stance_metric(next.warmth as f32 + 4.0 + affinity_gain as f32 * 2.0);
next.fear_or_guard =
clamp_stance_metric(next.fear_or_guard as f32 - 5.0 - affinity_gain as f32);
if affinity_gain >= 0 {
push_recent_note(
&mut next.recent_approvals,
note.unwrap_or("你愿意先从眼前局势和试探开始说话。"),
);
} else {
push_recent_note(
&mut next.recent_disapprovals,
note.unwrap_or("这轮交流没能真正对上节奏。"),
);
}
}
NpcSocialActionKind::Help => {
next.trust = clamp_stance_metric(next.trust as f32 + 12.0);
next.warmth = clamp_stance_metric(next.warmth as f32 + 6.0);
next.fear_or_guard = clamp_stance_metric(next.fear_or_guard as f32 - 8.0);
push_recent_note(
&mut next.recent_approvals,
note.unwrap_or("你在对方需要的时候搭了手。"),
);
}
NpcSocialActionKind::Gift => {
next.trust = clamp_stance_metric(next.trust as f32 + 6.0 + affinity_gain as f32);
next.warmth =
clamp_stance_metric(next.warmth as f32 + 10.0 + affinity_gain as f32 * 2.0);
next.fear_or_guard = clamp_stance_metric(next.fear_or_guard as f32 - 4.0);
push_recent_note(
&mut next.recent_approvals,
note.unwrap_or("你给出的东西回应了对方眼下的处境。"),
);
}
NpcSocialActionKind::Recruit => {
next.trust = clamp_stance_metric(next.trust as f32 + 8.0);
next.warmth = clamp_stance_metric(next.warmth as f32 + 6.0);
next.loyalty =
clamp_stance_metric(next.loyalty as f32 + 18.0 + if recruited { 4.0 } else { 0.0 });
next.fear_or_guard = clamp_stance_metric(next.fear_or_guard as f32 - 10.0);
push_recent_note(
&mut next.recent_approvals,
note.unwrap_or("你正式把对方纳入了同行关系。"),
);
}
NpcSocialActionKind::QuestAccept => {
next.trust = clamp_stance_metric(next.trust as f32 + 7.0);
next.ideological_fit = clamp_stance_metric(next.ideological_fit as f32 + 5.0);
next.loyalty = clamp_stance_metric(next.loyalty as f32 + 4.0);
push_recent_note(
&mut next.recent_approvals,
note.unwrap_or("你接住了对方主动交出来的事。"),
);
}
}
next
}
fn infer_conflict_tag(value: &str) -> Option<String> {
let trimmed = value.trim();
if trimmed.is_empty() {
None
} else if trimmed.contains("旧案") || trimmed.contains("调查") || trimmed.contains("追查")
{
Some("旧案".to_string())
} else if trimmed.contains('守') || trimmed.contains('卫') || trimmed.contains('巡') {
Some("守线".to_string())
} else if trimmed.contains('商') || trimmed.contains('摊') || trimmed.contains("军需") {
Some("交易".to_string())
} else {
None
}
}
fn trim_recent_notes(values: Vec<String>) -> Vec<String> {
let mut values = normalize_string_list(values);
if values.len() > MAX_STANCE_NOTES {
values = values.split_off(values.len() - MAX_STANCE_NOTES);
}
values
}
fn push_recent_note(target: &mut Vec<String>, note: &str) {
let trimmed = note.trim();
if trimmed.is_empty() {
return;
}
target.push(trimmed.to_string());
if target.len() > MAX_STANCE_NOTES {
let drain_len = target.len() - MAX_STANCE_NOTES;
target.drain(0..drain_len);
}
}
fn clamp_stance_metric(value: f32) -> u8 {
value.round().clamp(0.0, 100.0) as u8
}
impl fmt::Display for NpcStateFieldError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::MissingRuntimeSessionId => f.write_str("npc_state.runtime_session_id 不能为空"),
Self::MissingNpcId => f.write_str("npc_state.npc_id 不能为空"),
Self::MissingNpcName => f.write_str("npc_state.npc_name 不能为空"),
Self::MissingInteractionFunctionId => {
f.write_str("resolve_npc_interaction.interaction_function_id 不能为空")
}
Self::HelpAlreadyUsed => f.write_str("npc_state.help_used 已经消耗,不能重复援手"),
Self::RecruitAffinityTooLow => {
f.write_str("npc_state.affinity 未达到招募阈值,不能执行招募动作")
}
Self::UnsupportedInteractionFunctionId => {
f.write_str("resolve_npc_interaction.interaction_function_id 当前不受支持")
}
}
}
}
impl Error for NpcStateFieldError {}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {

View File

@@ -1,6 +1,6 @@
# module-progression 成长与章节推进模块 crate 说明 # module-progression 成长与章节推进模块 crate 说明
日期:`2026-04-21` 日期:`2026-04-30`
## 1. crate 职责 ## 1. crate 职责
@@ -13,14 +13,15 @@
## 2. 当前阶段说明 ## 2. 当前阶段说明
当前阶段已不再是目录占位,已经完成以下首版落地 当前阶段已完成 DDD 物理拆分收口,已经不再是“真实逻辑集中在 `lib.rs`、分层文件只占位”的状态
1. 新增 `Cargo.toml` `src/lib.rs`,形成真实可编译 crate 1. `src/domain.rs` 承载 `LevelBenchmark``PlayerProgressionSnapshot``ChapterProgressionSnapshot``RuntimeEntityLevelProfile` 等成长领域类型和值对象
2. 冻结 `LevelBenchmark``PlayerProgressionSnapshot``ChapterProgressionSnapshot``RuntimeEntityLevelProfile` 等首版领域类型 2. `src/commands.rs` 承载玩家成长查询/授予经验、章节预算、章节账本和章节自动定级输入
3. 固化与 Node 侧一致的经验曲线、参考强度曲线、章节 pseudo level 曲线敌对经验/生命值 fallback 规则。 3. `src/application.rs` 固化与既有 Node 侧一致的经验曲线、参考强度曲线、章节 pseudo level 曲线敌对经验/生命值 fallback 和章节账本应用规则。
4. 提供 `create_initial_player_progression``grant_player_experience``build_chapter_progression_snapshot``apply_chapter_progression_ledger` 等领域原语 4. `src/events.rs` 承载经验授予、章节账本应用和自动定级解析等领域事件
5. 提供 `build_chapter_auto_level_profile``build_hostile_experience_reward``resolve_hostile_battle_max_hp`,为后续 `quest / combat / npc` 联动提供统一成长基线 5. `src/errors.rs` 承载成长字段错误与中文错误文案
6. `spacetime-module` 已把 `turn_in_quest``resolve_combat_action(Victory)` 接到 `player_progression / chapter_progression` 最小经验结算链 6. `src/lib.rs` 只保留模块声明和公开导出,继续保持 `module_progression::*` 公开 API
7. `spacetime-module` 已把 `turn_in_quest``resolve_combat_action(Victory)` 接到 `player_progression / chapter_progression` 最小经验结算链。
当前这轮刻意未做的范围: 当前这轮刻意未做的范围:
@@ -34,6 +35,7 @@
2. [../../../docs/technical/SPACETIMEDB_AXUM_OSS_BACKEND_REWRITE_DESIGN_2026-04-20.md](../../../docs/technical/SPACETIMEDB_AXUM_OSS_BACKEND_REWRITE_DESIGN_2026-04-20.md) 2. [../../../docs/technical/SPACETIMEDB_AXUM_OSS_BACKEND_REWRITE_DESIGN_2026-04-20.md](../../../docs/technical/SPACETIMEDB_AXUM_OSS_BACKEND_REWRITE_DESIGN_2026-04-20.md)
3. [../../../docs/technical/M4_MODULE_PROGRESSION_SPACETIMEDB_BASELINE_2026-04-21.md](../../../docs/technical/M4_MODULE_PROGRESSION_SPACETIMEDB_BASELINE_2026-04-21.md) 3. [../../../docs/technical/M4_MODULE_PROGRESSION_SPACETIMEDB_BASELINE_2026-04-21.md](../../../docs/technical/M4_MODULE_PROGRESSION_SPACETIMEDB_BASELINE_2026-04-21.md)
4. [../../../docs/technical/M4_PROGRESSION_QUEST_COMBAT_INTEGRATION_2026-04-21.md](../../../docs/technical/M4_PROGRESSION_QUEST_COMBAT_INTEGRATION_2026-04-21.md) 4. [../../../docs/technical/M4_PROGRESSION_QUEST_COMBAT_INTEGRATION_2026-04-21.md](../../../docs/technical/M4_PROGRESSION_QUEST_COMBAT_INTEGRATION_2026-04-21.md)
5. [../../../docs/technical/SERVER_RS_DDD_WP_RPG_PROGRESSION_DOMAIN_SPLIT_2026-04-30.md](../../../docs/technical/SERVER_RS_DDD_WP_RPG_PROGRESSION_DOMAIN_SPLIT_2026-04-30.md)
## 4. 边界约束 ## 4. 边界约束

View File

@@ -1,3 +1,386 @@
//! 成长应用编排过渡落位 //! 成长应用服务
//! //!
//! 这里只返回等级变化、预算变化和账本结果,不直接读取其他上下文表。 //! 应用层把成长命令转换成玩家等级、章节预算、章节账本和实体定级结果;它不直接读取
//! 其他上下文表,也不执行 HTTP、LLM、OSS 等外部副作用。
use crate::commands::{
ChapterAutoLevelProfileInput, ChapterProgressionInput, ChapterProgressionLedgerInput,
PlayerProgressionGrantInput,
};
use crate::domain::{
ChapterProgressionSnapshot, DEFAULT_TERMINAL_STORY_LEVEL, LevelBenchmark, LevelProfileSource,
MAX_PLAYER_LEVEL, MIN_TERMINAL_STORY_LEVEL, PSEUDO_LEVEL_CURVE_EXPONENT,
PlayerProgressionGrantSource, PlayerProgressionSnapshot, ProgressionRole,
RuntimeEntityLevelProfile,
};
use crate::errors::ProgressionFieldError;
use serde::{Deserialize, Serialize};
use shared_kernel::normalize_required_string;
#[cfg(feature = "spacetime-types")]
use spacetimedb::SpacetimeType;
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct PlayerProgressionProcedureResult {
pub ok: bool,
pub record: Option<PlayerProgressionSnapshot>,
pub error_message: Option<String>,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct ChapterProgressionProcedureResult {
pub ok: bool,
pub record: Option<ChapterProgressionSnapshot>,
pub error_message: Option<String>,
}
fn clamp_level(level: u32) -> u32 {
level.clamp(1, MAX_PLAYER_LEVEL)
}
fn round_metric(value: f64, digits: usize) -> f64 {
let factor = 10_f64.powi(digits as i32);
(value * factor).round() / factor
}
fn scale(level: u32) -> u32 {
level.saturating_sub(1)
}
// 等级经验曲线与现有 Node 版保持同一公式,避免 Rust 迁移后成长节奏漂移。
pub fn compute_xp_to_next_level(level: u32) -> u32 {
let normalized_level = clamp_level(level);
let scale = scale(normalized_level);
60 + 20 * scale + 8 * scale * scale
}
pub fn build_level_benchmark(level: u32) -> LevelBenchmark {
let normalized_level = clamp_level(level);
let current_scale = scale(normalized_level);
let mut cumulative_xp_required = 0_u32;
for current in 1..normalized_level {
cumulative_xp_required += compute_xp_to_next_level(current);
}
let xp_to_next_level = if normalized_level >= MAX_PLAYER_LEVEL {
0
} else {
compute_xp_to_next_level(normalized_level)
};
LevelBenchmark {
level: normalized_level,
xp_to_next_level,
cumulative_xp_required,
reference_strength: 100 + 16 * current_scale + 6 * current_scale * current_scale,
base_hp: 180 + 24 * current_scale + 10 * current_scale * current_scale,
base_mana: 80 + 14 * current_scale + 6 * current_scale * current_scale,
baseline_damage_scale: round_metric(
1.0 + 0.12 * f64::from(current_scale) + 0.03 * f64::from(current_scale * current_scale),
3,
) as f32,
}
}
// 总经验决定真实等级SpacetimeDB 持久化后不再允许前端自己推导等级结果。
pub fn resolve_level_from_total_xp(total_xp: u32) -> u32 {
let mut resolved_level = 1;
for level in 2..=MAX_PLAYER_LEVEL {
if total_xp < build_level_benchmark(level).cumulative_xp_required {
break;
}
resolved_level = level;
}
resolved_level
}
pub fn build_player_progression_snapshot(
user_id: String,
total_xp: u32,
last_granted_source: Option<PlayerProgressionGrantSource>,
created_at_micros: i64,
updated_at_micros: i64,
) -> Result<PlayerProgressionSnapshot, ProgressionFieldError> {
let user_id = normalize_required_text(user_id, ProgressionFieldError::MissingUserId)?;
let level = resolve_level_from_total_xp(total_xp);
let benchmark = build_level_benchmark(level);
let (current_level_xp, xp_to_next_level) = if level >= MAX_PLAYER_LEVEL {
(0, 0)
} else {
(
total_xp.saturating_sub(benchmark.cumulative_xp_required),
benchmark.xp_to_next_level,
)
};
Ok(PlayerProgressionSnapshot {
user_id,
level,
current_level_xp,
total_xp,
xp_to_next_level,
pending_level_ups: 0,
last_granted_source,
created_at_micros,
updated_at_micros,
})
}
// 新存档默认统一回填为 Lv.1 / 0 XP后续再由任务和战斗奖励驱动成长。
pub fn create_initial_player_progression(
user_id: String,
created_at_micros: i64,
) -> Result<PlayerProgressionSnapshot, ProgressionFieldError> {
build_player_progression_snapshot(user_id, 0, None, created_at_micros, created_at_micros)
}
// 经验结算统一以“旧快照 + grant 输入”生成新快照,避免各调用方自行改等级字段。
pub fn grant_player_experience(
current: PlayerProgressionSnapshot,
input: PlayerProgressionGrantInput,
) -> Result<PlayerProgressionSnapshot, ProgressionFieldError> {
let user_id = normalize_required_text(input.user_id, ProgressionFieldError::MissingUserId)?;
if current.user_id != user_id {
return Err(ProgressionFieldError::MissingUserId);
}
let next_total_xp = current.total_xp.saturating_add(input.amount);
let mut next = build_player_progression_snapshot(
current.user_id.clone(),
next_total_xp,
Some(input.source),
current.created_at_micros,
input.updated_at_micros,
)?;
next.pending_level_ups = next.level.saturating_sub(current.level);
Ok(next)
}
// 章节快照同时承载计划预算与实际记账,这样 chapter_progression 一张表就能覆盖计划/偏差回看。
pub fn build_chapter_progression_snapshot(
input: ChapterProgressionInput,
) -> Result<ChapterProgressionSnapshot, ProgressionFieldError> {
let user_id = normalize_required_text(input.user_id, ProgressionFieldError::MissingUserId)?;
let chapter_id =
normalize_required_text(input.chapter_id, ProgressionFieldError::MissingChapterId)?;
if input.chapter_index == 0 {
return Err(ProgressionFieldError::InvalidChapterIndex);
}
if input.total_chapters == 0 || input.chapter_index > input.total_chapters {
return Err(ProgressionFieldError::InvalidTotalChapters);
}
let entry_level = clamp_level(input.entry_level);
let exit_level = clamp_level(input.exit_level);
if exit_level < entry_level {
return Err(ProgressionFieldError::InvalidEntryExitLevel);
}
if input.planned_total_xp < input.planned_quest_xp + input.planned_hostile_xp {
return Err(ProgressionFieldError::InvalidXpBudget);
}
Ok(ChapterProgressionSnapshot {
user_id,
chapter_id,
chapter_index: input.chapter_index,
total_chapters: input.total_chapters,
entry_pseudo_level_millis: input.entry_pseudo_level_millis.max(1_000),
exit_pseudo_level_millis: input
.exit_pseudo_level_millis
.max(input.entry_pseudo_level_millis.max(1_000)),
entry_level,
exit_level,
planned_total_xp: input.planned_total_xp,
planned_quest_xp: input.planned_quest_xp,
planned_hostile_xp: input.planned_hostile_xp,
actual_quest_xp: 0,
actual_hostile_xp: 0,
expected_hostile_defeat_count: input.expected_hostile_defeat_count,
actual_hostile_defeat_count: 0,
level_at_entry: clamp_level(input.level_at_entry),
level_at_exit: None,
pace_band: input.pace_band,
created_at_micros: input.updated_at_micros,
updated_at_micros: input.updated_at_micros,
})
}
// 按章累计实际任务经验、敌对经验与击杀次数,为后续章节节奏评估提供同一真相源。
pub fn apply_chapter_progression_ledger(
current: ChapterProgressionSnapshot,
input: ChapterProgressionLedgerInput,
) -> Result<ChapterProgressionSnapshot, ProgressionFieldError> {
let user_id = normalize_required_text(input.user_id, ProgressionFieldError::MissingUserId)?;
let chapter_id =
normalize_required_text(input.chapter_id, ProgressionFieldError::MissingChapterId)?;
if current.user_id != user_id || current.chapter_id != chapter_id {
return Err(ProgressionFieldError::MissingChapterId);
}
Ok(ChapterProgressionSnapshot {
actual_quest_xp: current
.actual_quest_xp
.saturating_add(input.granted_quest_xp),
actual_hostile_xp: current
.actual_hostile_xp
.saturating_add(input.granted_hostile_xp),
actual_hostile_defeat_count: current
.actual_hostile_defeat_count
.saturating_add(input.hostile_defeat_increment),
level_at_exit: input
.level_at_exit
.map(clamp_level)
.or(current.level_at_exit),
updated_at_micros: input.updated_at_micros,
..current
})
}
pub fn resolve_terminal_story_level(total_chapters: u32) -> u32 {
let resolved = (3_f64 + f64::from(total_chapters.max(1)) * 2.4).round() as u32;
resolved.clamp(MIN_TERMINAL_STORY_LEVEL, DEFAULT_TERMINAL_STORY_LEVEL)
}
// 章节边界先算 pseudo level再反推经验预算这里固化设计文档中的 0.92 曲线。
pub fn resolve_chapter_boundary_pseudo_level_millis(
boundary_index: u32,
total_chapters: u32,
) -> u32 {
if boundary_index == 0 || total_chapters == 0 {
return 1_000;
}
let progress = (f64::from(boundary_index) / f64::from(total_chapters)).clamp(0.0, 1.0);
let terminal_story_level = resolve_terminal_story_level(total_chapters);
let pseudo_level = 1.0
+ progress.powf(PSEUDO_LEVEL_CURVE_EXPONENT)
* f64::from(terminal_story_level.saturating_sub(1));
(round_metric(pseudo_level, 3) * 1_000.0).round() as u32
}
pub fn resolve_pseudo_level_xp_millis(pseudo_level_millis: u32) -> u32 {
let pseudo_level = f64::from(pseudo_level_millis.max(1_000)) / 1_000.0;
let lower_level = pseudo_level.floor().max(1.0) as u32;
let mut lower_level_xp = 0_u32;
for level in 1..lower_level {
lower_level_xp = lower_level_xp.saturating_add(compute_xp_to_next_level(level));
}
let partial = (f64::from(compute_xp_to_next_level(lower_level))
* (pseudo_level - f64::from(lower_level)))
.round() as u32;
lower_level_xp.saturating_add(partial)
}
// 章节自动定级当前先抽成纯数学 helper等 custom-world Rust crate 就位后再直接接蓝图编译结果。
pub fn build_chapter_auto_level_profile(
input: ChapterAutoLevelProfileInput,
) -> Result<RuntimeEntityLevelProfile, ProgressionFieldError> {
let chapter_id =
normalize_required_text(input.chapter_id, ProgressionFieldError::MissingChapterId)?;
if input.chapter_index == 0 {
return Err(ProgressionFieldError::InvalidChapterIndex);
}
let base_stage_level = f64::from(input.entry_pseudo_level_millis.max(1_000))
+ f64::from(
input
.exit_pseudo_level_millis
.max(input.entry_pseudo_level_millis.max(1_000))
.saturating_sub(input.entry_pseudo_level_millis.max(1_000)),
) * (f64::from(input.stage_progress_millis.min(1_000)) / 1_000.0);
let base_stage_level = base_stage_level / 1_000.0;
let role_offset = role_level_offset(input.progression_role);
let level = clamp_level((base_stage_level + f64::from(role_offset)).round().max(1.0) as u32);
let benchmark = build_level_benchmark(level);
Ok(RuntimeEntityLevelProfile {
level,
reference_strength: benchmark.reference_strength,
chapter_id: Some(chapter_id),
chapter_index: Some(input.chapter_index),
progression_role: input.progression_role,
source: LevelProfileSource::ChapterAuto,
})
}
pub fn resolve_hostile_battle_max_hp(level_profile: &RuntimeEntityLevelProfile) -> u32 {
let benchmark = build_level_benchmark(level_profile.level);
let role_bonus = match level_profile.progression_role {
ProgressionRole::HostileElite => 10,
ProgressionRole::HostileBoss => 24,
ProgressionRole::Rival => 6,
_ => 0,
};
(benchmark.base_hp / 9).max(32).saturating_add(role_bonus)
}
// 击败敌对 NPC 的经验掉落沿用现有倍率口径,避免迁移后任务/战斗经验节奏突然变化。
pub fn build_hostile_experience_reward(
player_level: u32,
level_profile: &RuntimeEntityLevelProfile,
chapter_stage_multiplier_millis: u32,
explicit_base_xp: Option<u32>,
) -> u32 {
let benchmark = build_level_benchmark(level_profile.level);
let base_kill_xp = explicit_base_xp
.unwrap_or_else(|| ((benchmark.xp_to_next_level as f64) * 0.08).round() as u32);
let level_delta_multiplier_millis =
resolve_level_delta_multiplier_millis(player_level, level_profile.level);
let role_multiplier_millis = match level_profile.progression_role {
ProgressionRole::HostileElite => 1_150,
ProgressionRole::HostileBoss => 1_300,
ProgressionRole::Guide | ProgressionRole::Ambient | ProgressionRole::Support => 0,
_ => 1_000,
};
let scaled = u64::from(base_kill_xp)
.saturating_mul(u64::from(chapter_stage_multiplier_millis))
.saturating_mul(u64::from(level_delta_multiplier_millis))
.saturating_mul(u64::from(role_multiplier_millis as u32))
/ 1_000
/ 1_000
/ 1_000;
let rounded = ((scaled as u32 + 2) / 5) * 5;
rounded.max(5)
}
fn resolve_level_delta_multiplier_millis(player_level: u32, target_level: u32) -> u32 {
if target_level + 4 <= player_level {
return 300;
}
if target_level + 2 <= player_level {
return 700;
}
if target_level >= player_level + 2 {
return 1_150;
}
1_000
}
fn role_level_offset(role: ProgressionRole) -> i32 {
match role {
ProgressionRole::Ambient => -1,
ProgressionRole::HostileElite => 1,
ProgressionRole::HostileBoss => 2,
_ => 0,
}
}
fn normalize_required_text(
value: String,
error: ProgressionFieldError,
) -> Result<String, ProgressionFieldError> {
normalize_required_string(value).ok_or(error)
}

View File

@@ -1,3 +1,74 @@
//! 成长写入命令过渡落位 //! 成长写入命令。
//! //!
//! 用于表达授予经验、创建章节预算、结算章节节奏等输入。 //! 这里固定授予经验、章节预算、章节账本和自动定级等输入结构adapter 只能把外部
//! 请求映射到这些命令,不在 SpacetimeDB 或 HTTP 层重复定义字段规则。
use crate::domain::{ChapterPaceBand, PlayerProgressionGrantSource, ProgressionRole};
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 PlayerProgressionGetInput {
pub user_id: String,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct PlayerProgressionGrantInput {
pub user_id: String,
pub amount: u32,
pub source: PlayerProgressionGrantSource,
pub updated_at_micros: i64,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct ChapterProgressionGetInput {
pub user_id: String,
pub chapter_id: String,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct ChapterProgressionInput {
pub user_id: String,
pub chapter_id: String,
pub chapter_index: u32,
pub total_chapters: u32,
pub entry_pseudo_level_millis: u32,
pub exit_pseudo_level_millis: u32,
pub entry_level: u32,
pub exit_level: u32,
pub planned_total_xp: u32,
pub planned_quest_xp: u32,
pub planned_hostile_xp: u32,
pub expected_hostile_defeat_count: u32,
pub level_at_entry: u32,
pub pace_band: ChapterPaceBand,
pub updated_at_micros: i64,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct ChapterProgressionLedgerInput {
pub user_id: String,
pub chapter_id: String,
pub granted_quest_xp: u32,
pub granted_hostile_xp: u32,
pub hostile_defeat_increment: u32,
pub level_at_exit: Option<u32>,
pub updated_at_micros: i64,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct ChapterAutoLevelProfileInput {
pub chapter_id: String,
pub chapter_index: u32,
pub entry_pseudo_level_millis: u32,
pub exit_pseudo_level_millis: u32,
pub stage_progress_millis: u32,
pub progression_role: ProgressionRole,
}

View File

@@ -1,4 +1,159 @@
//! 成长领域模型过渡落位 //! 成长领域模型。
//! //!
//! 后续迁移玩家等级、章节预算和经验曲线时,只保留成长规则 //! 本文件只承载玩家等级、章节预算、自动定级和实体强度相关的稳定值对象
//! 任务、战斗等奖励来源通过事件或应用结果接入。 //! 任务、战斗、NPC 等奖励来源通过应用服务输入和领域事件接入。
use serde::{Deserialize, Serialize};
#[cfg(feature = "spacetime-types")]
use spacetimedb::SpacetimeType;
/// 玩家成长系统当前允许的最高等级。
pub const MAX_PLAYER_LEVEL: u32 = 20;
/// 根据章节数推导终局叙事等级时的默认上限。
pub const DEFAULT_TERMINAL_STORY_LEVEL: u32 = 15;
/// 根据章节数推导终局叙事等级时的最低上限。
pub const MIN_TERMINAL_STORY_LEVEL: u32 = 5;
/// 章节 pseudo level 曲线指数,保持与既有 Node 侧节奏一致。
pub const PSEUDO_LEVEL_CURVE_EXPONENT: f64 = 0.92;
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub enum PlayerProgressionGrantSource {
Quest,
HostileNpc,
}
impl PlayerProgressionGrantSource {
pub fn as_str(&self) -> &'static str {
match self {
Self::Quest => "quest",
Self::HostileNpc => "hostile_npc",
}
}
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub enum ChapterPaceBand {
OpeningFast,
Steady,
Pressure,
FinaleDense,
}
impl ChapterPaceBand {
pub fn as_str(&self) -> &'static str {
match self {
Self::OpeningFast => "opening_fast",
Self::Steady => "steady",
Self::Pressure => "pressure",
Self::FinaleDense => "finale_dense",
}
}
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub enum ProgressionRole {
Guide,
Ambient,
Support,
HostileStandard,
HostileElite,
HostileBoss,
Rival,
}
impl ProgressionRole {
pub fn as_str(&self) -> &'static str {
match self {
Self::Guide => "guide",
Self::Ambient => "ambient",
Self::Support => "support",
Self::HostileStandard => "hostile_standard",
Self::HostileElite => "hostile_elite",
Self::HostileBoss => "hostile_boss",
Self::Rival => "rival",
}
}
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub enum LevelProfileSource {
ChapterAuto,
PresetOverride,
Manual,
}
impl LevelProfileSource {
pub fn as_str(&self) -> &'static str {
match self {
Self::ChapterAuto => "chapter_auto",
Self::PresetOverride => "preset_override",
Self::Manual => "manual",
}
}
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct LevelBenchmark {
pub level: u32,
pub xp_to_next_level: u32,
pub cumulative_xp_required: u32,
pub reference_strength: u32,
pub base_hp: u32,
pub base_mana: u32,
pub baseline_damage_scale: f32,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct PlayerProgressionSnapshot {
pub user_id: String,
pub level: u32,
pub current_level_xp: u32,
pub total_xp: u32,
pub xp_to_next_level: u32,
pub pending_level_ups: u32,
pub last_granted_source: Option<PlayerProgressionGrantSource>,
pub created_at_micros: i64,
pub updated_at_micros: i64,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct ChapterProgressionSnapshot {
pub user_id: String,
pub chapter_id: String,
pub chapter_index: u32,
pub total_chapters: u32,
pub entry_pseudo_level_millis: u32,
pub exit_pseudo_level_millis: u32,
pub entry_level: u32,
pub exit_level: u32,
pub planned_total_xp: u32,
pub planned_quest_xp: u32,
pub planned_hostile_xp: u32,
pub actual_quest_xp: u32,
pub actual_hostile_xp: u32,
pub expected_hostile_defeat_count: u32,
pub actual_hostile_defeat_count: u32,
pub level_at_entry: u32,
pub level_at_exit: Option<u32>,
pub pace_band: ChapterPaceBand,
pub created_at_micros: i64,
pub updated_at_micros: i64,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct RuntimeEntityLevelProfile {
pub level: u32,
pub reference_strength: u32,
pub chapter_id: Option<String>,
pub chapter_index: Option<u32>,
pub progression_role: ProgressionRole,
pub source: LevelProfileSource,
}

View File

@@ -1,3 +1,40 @@
//! 成长领域错误过渡落位 //! 成长领域错误。
//! //!
//! 错误保持纯领域语义,例如章节参数非法经验来源不被接受 //! 错误保持纯领域语义,例如章节参数非法经验预算非法或用户/章节标识缺失
use std::{error::Error, fmt};
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum ProgressionFieldError {
MissingUserId,
MissingChapterId,
InvalidChapterIndex,
InvalidTotalChapters,
InvalidLevel,
InvalidEntryExitLevel,
InvalidXpBudget,
InvalidExpectedHostileDefeatCount,
}
impl fmt::Display for ProgressionFieldError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::MissingUserId => f.write_str("player_progression.user_id 不能为空"),
Self::MissingChapterId => f.write_str("chapter_progression.chapter_id 不能为空"),
Self::InvalidChapterIndex => {
f.write_str("chapter_progression.chapter_index 必须大于 0")
}
Self::InvalidTotalChapters => f.write_str("chapter_progression.total_chapters 非法"),
Self::InvalidLevel => f.write_str("player_progression.level 非法"),
Self::InvalidEntryExitLevel => {
f.write_str("chapter_progression.entry_level / exit_level 非法")
}
Self::InvalidXpBudget => f.write_str("chapter_progression 经验预算非法"),
Self::InvalidExpectedHostileDefeatCount => {
f.write_str("chapter_progression.expected_hostile_defeat_count 非法")
}
}
}
}
impl Error for ProgressionFieldError {}

View File

@@ -1,3 +1,46 @@
//! 成长领域事件过渡落位 //! 成长领域事件。
//! //!
//! 用于表达经验已授予、升级待处理和章节节奏变化等事实。 //! 领域事件用于表达经验、升级和章节账本已经发生的事实;是否持久化为 SpacetimeDB
//! event table 或向前端投影,由外层 adapter 决定。
use crate::domain::{PlayerProgressionGrantSource, RuntimeEntityLevelProfile};
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 enum ProgressionDomainEvent {
PlayerExperienceGranted(PlayerExperienceGrantedEvent),
ChapterProgressionLedgerApplied(ChapterProgressionLedgerAppliedEvent),
ChapterAutoLevelProfileResolved(ChapterAutoLevelProfileResolvedEvent),
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct PlayerExperienceGrantedEvent {
pub user_id: String,
pub amount: u32,
pub source: PlayerProgressionGrantSource,
pub level: u32,
pub pending_level_ups: u32,
pub occurred_at_micros: i64,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct ChapterProgressionLedgerAppliedEvent {
pub user_id: String,
pub chapter_id: String,
pub granted_quest_xp: u32,
pub granted_hostile_xp: u32,
pub hostile_defeat_increment: u32,
pub occurred_at_micros: i64,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct ChapterAutoLevelProfileResolvedEvent {
pub profile: RuntimeEntityLevelProfile,
pub occurred_at_micros: i64,
}

View File

@@ -4,625 +4,11 @@ mod domain;
mod errors; mod errors;
mod events; mod events;
use std::{error::Error, fmt}; pub use application::*;
pub use commands::*;
use serde::{Deserialize, Serialize}; pub use domain::*;
use shared_kernel::normalize_required_string; pub use errors::*;
#[cfg(feature = "spacetime-types")] pub use events::*;
use spacetimedb::SpacetimeType;
pub const MAX_PLAYER_LEVEL: u32 = 20;
pub const DEFAULT_TERMINAL_STORY_LEVEL: u32 = 15;
pub const MIN_TERMINAL_STORY_LEVEL: u32 = 5;
pub const PSEUDO_LEVEL_CURVE_EXPONENT: f64 = 0.92;
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub enum PlayerProgressionGrantSource {
Quest,
HostileNpc,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub enum ChapterPaceBand {
OpeningFast,
Steady,
Pressure,
FinaleDense,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub enum ProgressionRole {
Guide,
Ambient,
Support,
HostileStandard,
HostileElite,
HostileBoss,
Rival,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub enum LevelProfileSource {
ChapterAuto,
PresetOverride,
Manual,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct LevelBenchmark {
pub level: u32,
pub xp_to_next_level: u32,
pub cumulative_xp_required: u32,
pub reference_strength: u32,
pub base_hp: u32,
pub base_mana: u32,
pub baseline_damage_scale: f32,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct PlayerProgressionSnapshot {
pub user_id: String,
pub level: u32,
pub current_level_xp: u32,
pub total_xp: u32,
pub xp_to_next_level: u32,
pub pending_level_ups: u32,
pub last_granted_source: Option<PlayerProgressionGrantSource>,
pub created_at_micros: i64,
pub updated_at_micros: i64,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct PlayerProgressionGetInput {
pub user_id: String,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct PlayerProgressionGrantInput {
pub user_id: String,
pub amount: u32,
pub source: PlayerProgressionGrantSource,
pub updated_at_micros: i64,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct PlayerProgressionProcedureResult {
pub ok: bool,
pub record: Option<PlayerProgressionSnapshot>,
pub error_message: Option<String>,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct ChapterProgressionSnapshot {
pub user_id: String,
pub chapter_id: String,
pub chapter_index: u32,
pub total_chapters: u32,
pub entry_pseudo_level_millis: u32,
pub exit_pseudo_level_millis: u32,
pub entry_level: u32,
pub exit_level: u32,
pub planned_total_xp: u32,
pub planned_quest_xp: u32,
pub planned_hostile_xp: u32,
pub actual_quest_xp: u32,
pub actual_hostile_xp: u32,
pub expected_hostile_defeat_count: u32,
pub actual_hostile_defeat_count: u32,
pub level_at_entry: u32,
pub level_at_exit: Option<u32>,
pub pace_band: ChapterPaceBand,
pub created_at_micros: i64,
pub updated_at_micros: i64,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct ChapterProgressionGetInput {
pub user_id: String,
pub chapter_id: String,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct ChapterProgressionInput {
pub user_id: String,
pub chapter_id: String,
pub chapter_index: u32,
pub total_chapters: u32,
pub entry_pseudo_level_millis: u32,
pub exit_pseudo_level_millis: u32,
pub entry_level: u32,
pub exit_level: u32,
pub planned_total_xp: u32,
pub planned_quest_xp: u32,
pub planned_hostile_xp: u32,
pub expected_hostile_defeat_count: u32,
pub level_at_entry: u32,
pub pace_band: ChapterPaceBand,
pub updated_at_micros: i64,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct ChapterProgressionLedgerInput {
pub user_id: String,
pub chapter_id: String,
pub granted_quest_xp: u32,
pub granted_hostile_xp: u32,
pub hostile_defeat_increment: u32,
pub level_at_exit: Option<u32>,
pub updated_at_micros: i64,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct ChapterProgressionProcedureResult {
pub ok: bool,
pub record: Option<ChapterProgressionSnapshot>,
pub error_message: Option<String>,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct RuntimeEntityLevelProfile {
pub level: u32,
pub reference_strength: u32,
pub chapter_id: Option<String>,
pub chapter_index: Option<u32>,
pub progression_role: ProgressionRole,
pub source: LevelProfileSource,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct ChapterAutoLevelProfileInput {
pub chapter_id: String,
pub chapter_index: u32,
pub entry_pseudo_level_millis: u32,
pub exit_pseudo_level_millis: u32,
pub stage_progress_millis: u32,
pub progression_role: ProgressionRole,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum ProgressionFieldError {
MissingUserId,
MissingChapterId,
InvalidChapterIndex,
InvalidTotalChapters,
InvalidLevel,
InvalidEntryExitLevel,
InvalidXpBudget,
InvalidExpectedHostileDefeatCount,
}
fn clamp_level(level: u32) -> u32 {
level.clamp(1, MAX_PLAYER_LEVEL)
}
fn round_metric(value: f64, digits: usize) -> f64 {
let factor = 10_f64.powi(digits as i32);
(value * factor).round() / factor
}
fn scale(level: u32) -> u32 {
level.saturating_sub(1)
}
// 等级经验曲线与现有 Node 版保持同一公式,避免 Rust 迁移后成长节奏漂移。
pub fn compute_xp_to_next_level(level: u32) -> u32 {
let normalized_level = clamp_level(level);
let scale = scale(normalized_level);
60 + 20 * scale + 8 * scale * scale
}
pub fn build_level_benchmark(level: u32) -> LevelBenchmark {
let normalized_level = clamp_level(level);
let current_scale = scale(normalized_level);
let mut cumulative_xp_required = 0_u32;
for current in 1..normalized_level {
cumulative_xp_required += compute_xp_to_next_level(current);
}
let xp_to_next_level = if normalized_level >= MAX_PLAYER_LEVEL {
0
} else {
compute_xp_to_next_level(normalized_level)
};
LevelBenchmark {
level: normalized_level,
xp_to_next_level,
cumulative_xp_required,
reference_strength: 100 + 16 * current_scale + 6 * current_scale * current_scale,
base_hp: 180 + 24 * current_scale + 10 * current_scale * current_scale,
base_mana: 80 + 14 * current_scale + 6 * current_scale * current_scale,
baseline_damage_scale: round_metric(
1.0 + 0.12 * f64::from(current_scale) + 0.03 * f64::from(current_scale * current_scale),
3,
) as f32,
}
}
// 总经验决定真实等级SpacetimeDB 持久化后不再允许前端自己推导等级结果。
pub fn resolve_level_from_total_xp(total_xp: u32) -> u32 {
let mut resolved_level = 1;
for level in 2..=MAX_PLAYER_LEVEL {
if total_xp < build_level_benchmark(level).cumulative_xp_required {
break;
}
resolved_level = level;
}
resolved_level
}
pub fn build_player_progression_snapshot(
user_id: String,
total_xp: u32,
last_granted_source: Option<PlayerProgressionGrantSource>,
created_at_micros: i64,
updated_at_micros: i64,
) -> Result<PlayerProgressionSnapshot, ProgressionFieldError> {
let user_id = normalize_required_text(user_id, ProgressionFieldError::MissingUserId)?;
let level = resolve_level_from_total_xp(total_xp);
let benchmark = build_level_benchmark(level);
let (current_level_xp, xp_to_next_level) = if level >= MAX_PLAYER_LEVEL {
(0, 0)
} else {
(
total_xp.saturating_sub(benchmark.cumulative_xp_required),
benchmark.xp_to_next_level,
)
};
Ok(PlayerProgressionSnapshot {
user_id,
level,
current_level_xp,
total_xp,
xp_to_next_level,
pending_level_ups: 0,
last_granted_source,
created_at_micros,
updated_at_micros,
})
}
// 新存档默认统一回填为 Lv.1 / 0 XP后续再由任务和战斗奖励驱动成长。
pub fn create_initial_player_progression(
user_id: String,
created_at_micros: i64,
) -> Result<PlayerProgressionSnapshot, ProgressionFieldError> {
build_player_progression_snapshot(user_id, 0, None, created_at_micros, created_at_micros)
}
// 经验结算统一以“旧快照 + grant 输入”生成新快照,避免各调用方自行改等级字段。
pub fn grant_player_experience(
current: PlayerProgressionSnapshot,
input: PlayerProgressionGrantInput,
) -> Result<PlayerProgressionSnapshot, ProgressionFieldError> {
let user_id = normalize_required_text(input.user_id, ProgressionFieldError::MissingUserId)?;
if current.user_id != user_id {
return Err(ProgressionFieldError::MissingUserId);
}
let next_total_xp = current.total_xp.saturating_add(input.amount);
let mut next = build_player_progression_snapshot(
current.user_id.clone(),
next_total_xp,
Some(input.source),
current.created_at_micros,
input.updated_at_micros,
)?;
next.pending_level_ups = next.level.saturating_sub(current.level);
Ok(next)
}
// 章节快照同时承载计划预算与实际记账,这样 chapter_progression 一张表就能覆盖计划/偏差回看。
pub fn build_chapter_progression_snapshot(
input: ChapterProgressionInput,
) -> Result<ChapterProgressionSnapshot, ProgressionFieldError> {
let user_id = normalize_required_text(input.user_id, ProgressionFieldError::MissingUserId)?;
let chapter_id =
normalize_required_text(input.chapter_id, ProgressionFieldError::MissingChapterId)?;
if input.chapter_index == 0 {
return Err(ProgressionFieldError::InvalidChapterIndex);
}
if input.total_chapters == 0 || input.chapter_index > input.total_chapters {
return Err(ProgressionFieldError::InvalidTotalChapters);
}
let entry_level = clamp_level(input.entry_level);
let exit_level = clamp_level(input.exit_level);
if exit_level < entry_level {
return Err(ProgressionFieldError::InvalidEntryExitLevel);
}
if input.planned_total_xp < input.planned_quest_xp + input.planned_hostile_xp {
return Err(ProgressionFieldError::InvalidXpBudget);
}
Ok(ChapterProgressionSnapshot {
user_id,
chapter_id,
chapter_index: input.chapter_index,
total_chapters: input.total_chapters,
entry_pseudo_level_millis: input.entry_pseudo_level_millis.max(1_000),
exit_pseudo_level_millis: input
.exit_pseudo_level_millis
.max(input.entry_pseudo_level_millis.max(1_000)),
entry_level,
exit_level,
planned_total_xp: input.planned_total_xp,
planned_quest_xp: input.planned_quest_xp,
planned_hostile_xp: input.planned_hostile_xp,
actual_quest_xp: 0,
actual_hostile_xp: 0,
expected_hostile_defeat_count: input.expected_hostile_defeat_count,
actual_hostile_defeat_count: 0,
level_at_entry: clamp_level(input.level_at_entry),
level_at_exit: None,
pace_band: input.pace_band,
created_at_micros: input.updated_at_micros,
updated_at_micros: input.updated_at_micros,
})
}
// 按章累计实际任务经验、敌对经验与击杀次数,为后续章节节奏评估提供同一真相源。
pub fn apply_chapter_progression_ledger(
current: ChapterProgressionSnapshot,
input: ChapterProgressionLedgerInput,
) -> Result<ChapterProgressionSnapshot, ProgressionFieldError> {
let user_id = normalize_required_text(input.user_id, ProgressionFieldError::MissingUserId)?;
let chapter_id =
normalize_required_text(input.chapter_id, ProgressionFieldError::MissingChapterId)?;
if current.user_id != user_id || current.chapter_id != chapter_id {
return Err(ProgressionFieldError::MissingChapterId);
}
Ok(ChapterProgressionSnapshot {
actual_quest_xp: current
.actual_quest_xp
.saturating_add(input.granted_quest_xp),
actual_hostile_xp: current
.actual_hostile_xp
.saturating_add(input.granted_hostile_xp),
actual_hostile_defeat_count: current
.actual_hostile_defeat_count
.saturating_add(input.hostile_defeat_increment),
level_at_exit: input
.level_at_exit
.map(clamp_level)
.or(current.level_at_exit),
updated_at_micros: input.updated_at_micros,
..current
})
}
pub fn resolve_terminal_story_level(total_chapters: u32) -> u32 {
let resolved = (3_f64 + f64::from(total_chapters.max(1)) * 2.4).round() as u32;
resolved.clamp(MIN_TERMINAL_STORY_LEVEL, DEFAULT_TERMINAL_STORY_LEVEL)
}
// 章节边界先算 pseudo level再反推经验预算这里固化设计文档中的 0.92 曲线。
pub fn resolve_chapter_boundary_pseudo_level_millis(
boundary_index: u32,
total_chapters: u32,
) -> u32 {
if boundary_index == 0 || total_chapters == 0 {
return 1_000;
}
let progress = (f64::from(boundary_index) / f64::from(total_chapters)).clamp(0.0, 1.0);
let terminal_story_level = resolve_terminal_story_level(total_chapters);
let pseudo_level = 1.0
+ progress.powf(PSEUDO_LEVEL_CURVE_EXPONENT)
* f64::from(terminal_story_level.saturating_sub(1));
(round_metric(pseudo_level, 3) * 1_000.0).round() as u32
}
pub fn resolve_pseudo_level_xp_millis(pseudo_level_millis: u32) -> u32 {
let pseudo_level = f64::from(pseudo_level_millis.max(1_000)) / 1_000.0;
let lower_level = pseudo_level.floor().max(1.0) as u32;
let mut lower_level_xp = 0_u32;
for level in 1..lower_level {
lower_level_xp = lower_level_xp.saturating_add(compute_xp_to_next_level(level));
}
let partial = (f64::from(compute_xp_to_next_level(lower_level))
* (pseudo_level - f64::from(lower_level)))
.round() as u32;
lower_level_xp.saturating_add(partial)
}
// 章节自动定级当前先抽成纯数学 helper等 custom-world Rust crate 就位后再直接接蓝图编译结果。
pub fn build_chapter_auto_level_profile(
input: ChapterAutoLevelProfileInput,
) -> Result<RuntimeEntityLevelProfile, ProgressionFieldError> {
let chapter_id =
normalize_required_text(input.chapter_id, ProgressionFieldError::MissingChapterId)?;
if input.chapter_index == 0 {
return Err(ProgressionFieldError::InvalidChapterIndex);
}
let base_stage_level = f64::from(input.entry_pseudo_level_millis.max(1_000))
+ f64::from(
input
.exit_pseudo_level_millis
.max(input.entry_pseudo_level_millis.max(1_000))
.saturating_sub(input.entry_pseudo_level_millis.max(1_000)),
) * (f64::from(input.stage_progress_millis.min(1_000)) / 1_000.0);
let base_stage_level = base_stage_level / 1_000.0;
let role_offset = role_level_offset(input.progression_role);
let level = clamp_level((base_stage_level + f64::from(role_offset)).round().max(1.0) as u32);
let benchmark = build_level_benchmark(level);
Ok(RuntimeEntityLevelProfile {
level,
reference_strength: benchmark.reference_strength,
chapter_id: Some(chapter_id),
chapter_index: Some(input.chapter_index),
progression_role: input.progression_role,
source: LevelProfileSource::ChapterAuto,
})
}
pub fn resolve_hostile_battle_max_hp(level_profile: &RuntimeEntityLevelProfile) -> u32 {
let benchmark = build_level_benchmark(level_profile.level);
let role_bonus = match level_profile.progression_role {
ProgressionRole::HostileElite => 10,
ProgressionRole::HostileBoss => 24,
ProgressionRole::Rival => 6,
_ => 0,
};
(benchmark.base_hp / 9).max(32).saturating_add(role_bonus)
}
// 击败敌对 NPC 的经验掉落沿用现有倍率口径,避免迁移后任务/战斗经验节奏突然变化。
pub fn build_hostile_experience_reward(
player_level: u32,
level_profile: &RuntimeEntityLevelProfile,
chapter_stage_multiplier_millis: u32,
explicit_base_xp: Option<u32>,
) -> u32 {
let benchmark = build_level_benchmark(level_profile.level);
let base_kill_xp = explicit_base_xp
.unwrap_or_else(|| ((benchmark.xp_to_next_level as f64) * 0.08).round() as u32);
let level_delta_multiplier_millis =
resolve_level_delta_multiplier_millis(player_level, level_profile.level);
let role_multiplier_millis = match level_profile.progression_role {
ProgressionRole::HostileElite => 1_150,
ProgressionRole::HostileBoss => 1_300,
ProgressionRole::Guide | ProgressionRole::Ambient | ProgressionRole::Support => 0,
_ => 1_000,
};
let scaled = u64::from(base_kill_xp)
.saturating_mul(u64::from(chapter_stage_multiplier_millis))
.saturating_mul(u64::from(level_delta_multiplier_millis))
.saturating_mul(u64::from(role_multiplier_millis as u32))
/ 1_000
/ 1_000
/ 1_000;
let rounded = ((scaled as u32 + 2) / 5) * 5;
rounded.max(5)
}
fn resolve_level_delta_multiplier_millis(player_level: u32, target_level: u32) -> u32 {
if target_level + 4 <= player_level {
return 300;
}
if target_level + 2 <= player_level {
return 700;
}
if target_level >= player_level + 2 {
return 1_150;
}
1_000
}
fn role_level_offset(role: ProgressionRole) -> i32 {
match role {
ProgressionRole::Ambient => -1,
ProgressionRole::HostileElite => 1,
ProgressionRole::HostileBoss => 2,
_ => 0,
}
}
fn normalize_required_text(
value: String,
error: ProgressionFieldError,
) -> Result<String, ProgressionFieldError> {
normalize_required_string(value).ok_or(error)
}
impl ChapterPaceBand {
pub fn as_str(&self) -> &'static str {
match self {
Self::OpeningFast => "opening_fast",
Self::Steady => "steady",
Self::Pressure => "pressure",
Self::FinaleDense => "finale_dense",
}
}
}
impl ProgressionRole {
pub fn as_str(&self) -> &'static str {
match self {
Self::Guide => "guide",
Self::Ambient => "ambient",
Self::Support => "support",
Self::HostileStandard => "hostile_standard",
Self::HostileElite => "hostile_elite",
Self::HostileBoss => "hostile_boss",
Self::Rival => "rival",
}
}
}
impl LevelProfileSource {
pub fn as_str(&self) -> &'static str {
match self {
Self::ChapterAuto => "chapter_auto",
Self::PresetOverride => "preset_override",
Self::Manual => "manual",
}
}
}
impl PlayerProgressionGrantSource {
pub fn as_str(&self) -> &'static str {
match self {
Self::Quest => "quest",
Self::HostileNpc => "hostile_npc",
}
}
}
impl fmt::Display for ProgressionFieldError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::MissingUserId => f.write_str("player_progression.user_id 不能为空"),
Self::MissingChapterId => f.write_str("chapter_progression.chapter_id 不能为空"),
Self::InvalidChapterIndex => {
f.write_str("chapter_progression.chapter_index 必须大于 0")
}
Self::InvalidTotalChapters => f.write_str("chapter_progression.total_chapters 非法"),
Self::InvalidLevel => f.write_str("player_progression.level 非法"),
Self::InvalidEntryExitLevel => {
f.write_str("chapter_progression.entry_level / exit_level 非法")
}
Self::InvalidXpBudget => f.write_str("chapter_progression 经验预算非法"),
Self::InvalidExpectedHostileDefeatCount => {
f.write_str("chapter_progression.expected_hostile_defeat_count 非法")
}
}
}
}
impl Error for ProgressionFieldError {}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {

View File

@@ -1,4 +1,4 @@
//! 拼图领域模型过渡落位 //! 拼图领域模型。
//! //!
//! 后续迁移拼图 Agent 会话、作品 profile 和运行态聚合时,只保留玩法规则; //! 后续迁移拼图 Agent 会话、作品 profile 和运行态聚合时,只保留玩法规则;
//! 图片生成、发布 HTTP shape 和排行榜适配留在外层。 //! 图片生成、发布 HTTP shape 和排行榜适配留在外层。

View File

@@ -1,4 +1,4 @@
//! 拼图领域事件过渡落位 //! 拼图领域事件。
//! //!
//! 用于表达草稿变化、作品发布、运行态推进和排行榜候选产生等事实。 //! 用于表达草稿变化、作品发布、运行态推进和排行榜候选产生等事实。

View File

@@ -1,3 +1,499 @@
//! 任务应用编排过渡落位 //! 任务应用编排。
//! //!
//! 这里只返回任务变更结果、日志和奖励待处理事件,不直接写背包或成长表 //! 这里只推进任务状态、步骤进度和交付标记,不直接发放货币、背包或关系奖励
use crate::commands::{
QuestCompletionAckInput, QuestCompletionAckOutcome, QuestRecordInput, QuestSignalApplyInput,
QuestSignalApplyOutcome, QuestTurnInInput,
};
use crate::domain::*;
use crate::errors::QuestRecordFieldError;
use shared_kernel::{
normalize_optional_string as normalize_shared_optional_string, normalize_required_string,
normalize_string_list as normalize_shared_string_list,
};
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)
}
pub fn build_quest_record_snapshot(
input: QuestRecordInput,
) -> Result<QuestRecordSnapshot, QuestRecordFieldError> {
let quest_id = normalize_required_text(input.quest_id, QuestRecordFieldError::MissingQuestId)?;
let runtime_session_id = normalize_required_text(
input.runtime_session_id,
QuestRecordFieldError::MissingRuntimeSessionId,
)?;
let actor_user_id = normalize_required_text(
input.actor_user_id,
QuestRecordFieldError::MissingActorUserId,
)?;
let issuer_npc_id = normalize_required_text(
input.issuer_npc_id,
QuestRecordFieldError::MissingIssuerNpcId,
)?;
let issuer_npc_name = normalize_required_text(
input.issuer_npc_name,
QuestRecordFieldError::MissingIssuerNpcName,
)?;
let title = normalize_required_text(input.title, QuestRecordFieldError::MissingTitle)?;
let description =
normalize_required_text(input.description, QuestRecordFieldError::MissingDescription)?;
let reward_text =
normalize_required_text(input.reward_text, QuestRecordFieldError::MissingRewardText)?;
if input.steps.is_empty() {
return Err(QuestRecordFieldError::EmptySteps);
}
let steps = input
.steps
.into_iter()
.map(normalize_quest_step)
.collect::<Result<Vec<_>, _>>()?;
let active_step = resolve_active_step(&steps, input.active_step_id.as_deref());
let active_step_id = active_step.map(|step| step.step_id.clone());
let fallback_step = steps
.last()
.cloned()
.expect("BUG: validated quest steps should not be empty");
let objective = build_objective_from_step(active_step.unwrap_or(&fallback_step));
let progress = active_step
.map(|step| step.progress)
.unwrap_or(fallback_step.required_count);
let status = normalize_quest_status(input.status, active_step.is_some());
let completed_at_micros = if status.is_reward_ready() {
Some(input.created_at_micros)
} else {
None
};
let turned_in_at_micros = if status == QuestStatus::TurnedIn {
Some(input.created_at_micros)
} else {
None
};
Ok(QuestRecordSnapshot {
quest_id,
runtime_session_id,
story_session_id: normalize_optional_text(input.story_session_id),
actor_user_id,
issuer_npc_id,
issuer_npc_name,
scene_id: normalize_optional_text(input.scene_id),
chapter_id: normalize_optional_text(input.chapter_id),
act_id: normalize_optional_text(input.act_id),
thread_id: normalize_optional_text(input.thread_id),
contract_id: normalize_optional_text(input.contract_id),
title,
description: description.clone(),
summary: normalize_optional_text(Some(input.summary)).unwrap_or(description),
objective,
progress,
status,
completion_notified: input.completion_notified || status == QuestStatus::TurnedIn,
reward: normalize_quest_reward(input.reward)?,
reward_text,
narrative_binding: normalize_quest_narrative_binding(input.narrative_binding),
steps,
active_step_id,
visible_stage: input.visible_stage,
hidden_flags: normalize_string_list(input.hidden_flags),
discovered_fact_ids: normalize_string_list(input.discovered_fact_ids),
related_carrier_ids: normalize_string_list(input.related_carrier_ids),
consequence_ids: normalize_string_list(input.consequence_ids),
created_at_micros: input.created_at_micros,
updated_at_micros: input.created_at_micros,
completed_at_micros,
turned_in_at_micros,
})
}
// 任务推进只认当前 active step未命中或已终态时统一保持 no-op确保 story action 可安全重复派发信号。
pub fn apply_quest_signal(
current: QuestRecordSnapshot,
input: QuestSignalApplyInput,
) -> Result<QuestSignalApplyOutcome, QuestRecordFieldError> {
let quest_id = normalize_required_text(input.quest_id, QuestRecordFieldError::MissingQuestId)?;
let signal_kind = QuestSignalKind::from(&input.signal);
if current.quest_id != quest_id
|| current.status.is_terminal()
|| current.status.is_reward_ready()
{
return Ok(QuestSignalApplyOutcome {
next_record: current,
changed: false,
completed_now: false,
changed_step_id: None,
changed_step_progress: None,
signal_kind,
});
}
let active_step = match resolve_active_step(&current.steps, current.active_step_id.as_deref()) {
Some(step) => step,
None => {
return Ok(QuestSignalApplyOutcome {
next_record: current,
changed: false,
completed_now: false,
changed_step_id: None,
changed_step_progress: None,
signal_kind,
});
}
};
if !step_matches_signal(active_step, &input.signal) {
return Ok(QuestSignalApplyOutcome {
next_record: current,
changed: false,
completed_now: false,
changed_step_id: None,
changed_step_progress: None,
signal_kind,
});
}
let increment = signal_progress_increment(&input.signal);
let mut changed_step_id = None;
let mut changed_step_progress = None;
let next_steps = current
.steps
.iter()
.cloned()
.map(|mut step| {
if step.step_id == active_step.step_id {
let next_progress = (step.progress + increment).min(step.required_count);
if next_progress != step.progress {
step.progress = next_progress;
changed_step_id = Some(step.step_id.clone());
changed_step_progress = Some(step.progress);
}
}
step
})
.collect::<Vec<_>>();
if changed_step_id.is_none() {
return Ok(QuestSignalApplyOutcome {
next_record: current,
changed: false,
completed_now: false,
changed_step_id: None,
changed_step_progress: None,
signal_kind,
});
}
let next_active_step = resolve_active_step(&next_steps, None);
let next_active_step_id = next_active_step.map(|step| step.step_id.clone());
let fallback_step = next_steps
.last()
.cloned()
.expect("BUG: progressed quest should still contain steps");
let next_status = normalize_quest_status(current.status, next_active_step.is_some());
let completed_now = !current.status.is_reward_ready() && next_status.is_reward_ready();
let next_objective = build_objective_from_step(next_active_step.unwrap_or(&fallback_step));
let next_progress = next_active_step
.map(|step| step.progress)
.unwrap_or(fallback_step.required_count);
Ok(QuestSignalApplyOutcome {
next_record: QuestRecordSnapshot {
objective: next_objective,
progress: next_progress,
status: next_status,
completion_notified: false,
steps: next_steps,
active_step_id: next_active_step_id,
updated_at_micros: input.updated_at_micros,
completed_at_micros: if completed_now {
Some(input.updated_at_micros)
} else {
current.completed_at_micros
},
..current
},
changed: true,
completed_now,
changed_step_id,
changed_step_progress,
signal_kind,
})
}
pub fn acknowledge_quest_completion(
current: QuestRecordSnapshot,
input: QuestCompletionAckInput,
) -> Result<QuestCompletionAckOutcome, QuestRecordFieldError> {
let quest_id = normalize_required_text(input.quest_id, QuestRecordFieldError::MissingQuestId)?;
if current.quest_id != quest_id || current.completion_notified {
return Ok(QuestCompletionAckOutcome {
next_record: current,
changed: false,
});
}
Ok(QuestCompletionAckOutcome {
next_record: QuestRecordSnapshot {
completion_notified: true,
updated_at_micros: input.updated_at_micros,
..current
},
changed: true,
})
}
// 任务交付只负责把任务固定到 TurnedIn不在本轮提前掺入货币、背包和关系奖励发放。
pub fn turn_in_quest_record(
current: QuestRecordSnapshot,
input: QuestTurnInInput,
) -> Result<QuestRecordSnapshot, QuestRecordFieldError> {
let quest_id = normalize_required_text(input.quest_id, QuestRecordFieldError::MissingQuestId)?;
if current.quest_id != quest_id || !current.status.is_reward_ready() {
return Err(QuestRecordFieldError::QuestNotReadyToTurnIn);
}
let steps = current
.steps
.into_iter()
.map(|mut step| {
step.progress = step.required_count;
step
})
.collect::<Vec<_>>();
let fallback_step = steps
.last()
.cloned()
.expect("BUG: turn in quest should preserve steps");
Ok(QuestRecordSnapshot {
objective: build_objective_from_step(&fallback_step),
progress: fallback_step.required_count,
status: QuestStatus::TurnedIn,
completion_notified: true,
steps,
active_step_id: None,
updated_at_micros: input.turned_in_at_micros,
completed_at_micros: current
.completed_at_micros
.or(Some(input.turned_in_at_micros)),
turned_in_at_micros: Some(input.turned_in_at_micros),
..current
})
}
pub fn generate_quest_log_id(
quest_id: &str,
event_kind: QuestLogEventKind,
seed_micros: i64,
) -> String {
format!(
"{}{}_{:x}_{}",
QUEST_LOG_ID_PREFIX,
event_kind.as_str(),
seed_micros,
quest_id
)
}
fn normalize_required_text(
value: String,
error: QuestRecordFieldError,
) -> Result<String, QuestRecordFieldError> {
normalize_required_string(value).ok_or(error)
}
fn normalize_quest_reward(
mut reward: QuestRewardSnapshot,
) -> Result<QuestRewardSnapshot, QuestRecordFieldError> {
reward.story_hint = normalize_optional_text(reward.story_hint);
reward.intel = reward.intel.and_then(|intel| {
let rumor_text = intel.rumor_text.trim().to_string();
let unlocked_scene_id = normalize_optional_text(intel.unlocked_scene_id);
if rumor_text.is_empty() {
None
} else {
Some(QuestRewardIntel {
rumor_text,
unlocked_scene_id,
})
}
});
reward.items = reward
.items
.into_iter()
.map(
|mut item| -> Result<QuestRewardItem, QuestRecordFieldError> {
item.item_id = normalize_required_text(
item.item_id,
QuestRecordFieldError::MissingRewardItemId,
)?;
item.category = normalize_required_text(
item.category,
QuestRecordFieldError::MissingRewardItemCategory,
)?;
item.name = normalize_required_text(
item.name,
QuestRecordFieldError::MissingRewardItemName,
)?;
item.description = normalize_optional_text(item.description);
if item.quantity == 0 {
return Err(QuestRecordFieldError::InvalidRewardItemQuantity);
}
if !item.stackable && item.quantity != 1 {
return Err(
QuestRecordFieldError::RewardNonStackableItemMustStaySingleQuantity,
);
}
if item.equipment_slot_id.is_some() && item.stackable {
return Err(QuestRecordFieldError::RewardEquipmentItemCannotStack);
}
item.tags = normalize_string_list(item.tags);
item.stack_key = if item.stackable {
normalize_required_text(
item.stack_key,
QuestRecordFieldError::MissingRewardItemStackKey,
)?
} else {
normalize_optional_text(Some(item.stack_key))
.unwrap_or_else(|| item.item_id.clone())
};
Ok(item)
},
)
.collect::<Result<Vec<_>, _>>()?;
Ok(reward)
}
fn normalize_quest_narrative_binding(
mut binding: QuestNarrativeBindingSnapshot,
) -> QuestNarrativeBindingSnapshot {
binding.dramatic_need = binding.dramatic_need.trim().to_string();
binding.issuer_goal = binding.issuer_goal.trim().to_string();
binding.player_hook = binding.player_hook.trim().to_string();
binding.world_reason = binding.world_reason.trim().to_string();
binding.followup_hooks = normalize_string_list(binding.followup_hooks);
binding
}
fn normalize_quest_step(
mut step: QuestStepSnapshot,
) -> Result<QuestStepSnapshot, QuestRecordFieldError> {
step.step_id = normalize_required_text(step.step_id, QuestRecordFieldError::MissingStepId)?;
step.title = normalize_required_text(step.title, QuestRecordFieldError::MissingStepTitle)?;
step.reveal_text = normalize_required_text(
step.reveal_text,
QuestRecordFieldError::MissingStepRevealText,
)?;
step.complete_text = normalize_required_text(
step.complete_text,
QuestRecordFieldError::MissingStepCompleteText,
)?;
step.required_count = step.required_count.max(1);
step.progress = step.progress.min(step.required_count);
step.target_hostile_npc_id = normalize_optional_text(step.target_hostile_npc_id);
step.target_npc_id = normalize_optional_text(step.target_npc_id);
step.target_scene_id = normalize_optional_text(step.target_scene_id);
step.target_item_id = normalize_optional_text(step.target_item_id);
Ok(step)
}
fn resolve_active_step<'a>(
steps: &'a [QuestStepSnapshot],
active_step_id: Option<&str>,
) -> Option<&'a QuestStepSnapshot> {
if let Some(active_step_id) = active_step_id {
let active_step_id = active_step_id.trim();
if !active_step_id.is_empty() {
if let Some(step) = steps
.iter()
.find(|step| step.step_id == active_step_id && step.progress < step.required_count)
{
return Some(step);
}
}
}
steps
.iter()
.find(|step| step.progress < step.required_count)
}
fn build_objective_from_step(step: &QuestStepSnapshot) -> QuestObjectiveSnapshot {
QuestObjectiveSnapshot {
kind: step.kind,
target_hostile_npc_id: step.target_hostile_npc_id.clone(),
target_npc_id: step.target_npc_id.clone(),
target_scene_id: step.target_scene_id.clone(),
target_item_id: step.target_item_id.clone(),
required_count: step.required_count,
}
}
fn normalize_quest_status(status: QuestStatus, has_active_step: bool) -> QuestStatus {
if status.is_terminal() {
return status;
}
if has_active_step {
QuestStatus::Active
} else if status == QuestStatus::ReadyToTurnIn {
QuestStatus::ReadyToTurnIn
} else {
QuestStatus::Completed
}
}
fn step_matches_signal(step: &QuestStepSnapshot, signal: &QuestProgressSignal) -> bool {
match signal {
QuestProgressSignal::HostileNpcDefeated(payload) => {
step.kind == QuestObjectiveKind::DefeatHostileNpc
&& step.target_hostile_npc_id.as_deref() == Some(payload.hostile_npc_id.as_str())
&& step
.target_scene_id
.as_deref()
.is_none_or(|value| Some(value.to_string()) == payload.scene_id.clone())
}
QuestProgressSignal::TreasureInspected(payload) => {
step.kind == QuestObjectiveKind::InspectTreasure
&& step
.target_scene_id
.as_deref()
.is_none_or(|value| Some(value.to_string()) == payload.scene_id.clone())
}
QuestProgressSignal::NpcSparCompleted(payload) => {
step.kind == QuestObjectiveKind::SparWithNpc
&& step.target_npc_id.as_deref() == Some(payload.npc_id.as_str())
}
QuestProgressSignal::NpcTalkCompleted(payload) => {
step.kind == QuestObjectiveKind::TalkToNpc
&& step.target_npc_id.as_deref() == Some(payload.npc_id.as_str())
}
QuestProgressSignal::SceneReached(payload) => {
step.kind == QuestObjectiveKind::ReachScene
&& step.target_scene_id.as_deref() == Some(payload.scene_id.as_str())
}
QuestProgressSignal::ItemDelivered(payload) => {
step.kind == QuestObjectiveKind::DeliverItem
&& step.target_npc_id.as_deref() == Some(payload.npc_id.as_str())
&& step.target_item_id.as_deref() == Some(payload.item_id.as_str())
}
}
}
fn signal_progress_increment(signal: &QuestProgressSignal) -> u32 {
match signal {
QuestProgressSignal::ItemDelivered(payload) => payload.quantity.max(1),
_ => 1,
}
}

View File

@@ -1,3 +1,83 @@
//! 任务写入命令过渡落位 //! 任务写入命令。
//! //!
//! 用于表达领取任务、推进信号、确认完成和交付任务等输入。 //! 用于表达任务创建、信号推进、完成确认和交付等输入。
use crate::domain::{
QuestNarrativeBindingSnapshot, QuestProgressSignal, QuestRecordSnapshot, QuestRewardSnapshot,
QuestSignalKind, QuestStatus, QuestStepSnapshot,
};
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 QuestRecordInput {
pub quest_id: String,
pub runtime_session_id: String,
pub story_session_id: Option<String>,
pub actor_user_id: String,
pub issuer_npc_id: String,
pub issuer_npc_name: String,
pub scene_id: Option<String>,
pub chapter_id: Option<String>,
pub act_id: Option<String>,
pub thread_id: Option<String>,
pub contract_id: Option<String>,
pub title: String,
pub description: String,
pub summary: String,
pub status: QuestStatus,
pub completion_notified: bool,
pub reward: QuestRewardSnapshot,
pub reward_text: String,
pub narrative_binding: QuestNarrativeBindingSnapshot,
pub steps: Vec<QuestStepSnapshot>,
pub active_step_id: Option<String>,
pub visible_stage: u32,
pub hidden_flags: Vec<String>,
pub discovered_fact_ids: Vec<String>,
pub related_carrier_ids: Vec<String>,
pub consequence_ids: Vec<String>,
pub created_at_micros: i64,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct QuestSignalApplyInput {
pub quest_id: String,
pub signal: QuestProgressSignal,
pub updated_at_micros: i64,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct QuestSignalApplyOutcome {
pub next_record: QuestRecordSnapshot,
pub changed: bool,
pub completed_now: bool,
pub changed_step_id: Option<String>,
pub changed_step_progress: Option<u32>,
pub signal_kind: QuestSignalKind,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct QuestCompletionAckInput {
pub quest_id: String,
pub updated_at_micros: i64,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct QuestCompletionAckOutcome {
pub next_record: QuestRecordSnapshot,
pub changed: bool,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct QuestTurnInInput {
pub quest_id: String,
pub turned_in_at_micros: i64,
}

View File

@@ -1,4 +1,282 @@
//! 任务领域模型过渡落位 //! 任务领域模型。
//! //!
//! 后续迁移任务记录、步骤、目标、奖励和日志规则时,只保留任务聚合内部变化; //! 本文件承载任务状态、步骤、奖励、叙事绑定和进度信号等稳定值对象。
//! 奖励发放和成长记账通过事件交给外层事务编排。
use serde::{Deserialize, Serialize};
#[cfg(feature = "spacetime-types")]
use spacetimedb::SpacetimeType;
pub const QUEST_LOG_ID_PREFIX: &str = "questlog_";
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub enum QuestStatus {
Active,
ReadyToTurnIn,
Completed,
TurnedIn,
Failed,
Expired,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub enum QuestNarrativeType {
Bounty,
Escort,
Investigation,
Retrieval,
Relationship,
Trial,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub enum QuestObjectiveKind {
DefeatHostileNpc,
InspectTreasure,
SparWithNpc,
TalkToNpc,
ReachScene,
DeliverItem,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub enum QuestRewardItemRarity {
Common,
Uncommon,
Rare,
Epic,
Legendary,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub enum QuestNarrativeOrigin {
AiCompiled,
FallbackBuilder,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub enum QuestLogEventKind {
Accepted,
Progressed,
Completed,
CompletionAcknowledged,
TurnedIn,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub enum QuestSignalKind {
HostileNpcDefeated,
TreasureInspected,
NpcSparCompleted,
NpcTalkCompleted,
SceneReached,
ItemDelivered,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct QuestRewardItem {
pub item_id: String,
pub category: String,
pub name: String,
pub description: Option<String>,
pub quantity: u32,
pub rarity: QuestRewardItemRarity,
pub tags: Vec<String>,
pub stackable: bool,
pub stack_key: String,
pub equipment_slot_id: Option<QuestRewardEquipmentSlot>,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub enum QuestRewardEquipmentSlot {
Weapon,
Armor,
Relic,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct QuestRewardIntel {
pub rumor_text: String,
pub unlocked_scene_id: Option<String>,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct QuestRewardSnapshot {
pub affinity_bonus: i32,
pub currency: i64,
pub experience: Option<u32>,
pub items: Vec<QuestRewardItem>,
pub intel: Option<QuestRewardIntel>,
pub story_hint: Option<String>,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct QuestNarrativeBindingSnapshot {
pub origin: QuestNarrativeOrigin,
pub narrative_type: QuestNarrativeType,
pub dramatic_need: String,
pub issuer_goal: String,
pub player_hook: String,
pub world_reason: String,
pub followup_hooks: Vec<String>,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct QuestObjectiveSnapshot {
pub kind: QuestObjectiveKind,
pub target_hostile_npc_id: Option<String>,
pub target_npc_id: Option<String>,
pub target_scene_id: Option<String>,
pub target_item_id: Option<String>,
pub required_count: u32,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct QuestStepSnapshot {
pub step_id: String,
pub kind: QuestObjectiveKind,
pub target_hostile_npc_id: Option<String>,
pub target_npc_id: Option<String>,
pub target_scene_id: Option<String>,
pub target_item_id: Option<String>,
pub required_count: u32,
pub progress: u32,
pub title: String,
pub reveal_text: String,
pub complete_text: String,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct QuestRecordSnapshot {
pub quest_id: String,
pub runtime_session_id: String,
pub story_session_id: Option<String>,
pub actor_user_id: String,
pub issuer_npc_id: String,
pub issuer_npc_name: String,
pub scene_id: Option<String>,
pub chapter_id: Option<String>,
pub act_id: Option<String>,
pub thread_id: Option<String>,
pub contract_id: Option<String>,
pub title: String,
pub description: String,
pub summary: String,
pub objective: QuestObjectiveSnapshot,
pub progress: u32,
pub status: QuestStatus,
pub completion_notified: bool,
pub reward: QuestRewardSnapshot,
pub reward_text: String,
pub narrative_binding: QuestNarrativeBindingSnapshot,
pub steps: Vec<QuestStepSnapshot>,
pub active_step_id: Option<String>,
pub visible_stage: u32,
pub hidden_flags: Vec<String>,
pub discovered_fact_ids: Vec<String>,
pub related_carrier_ids: Vec<String>,
pub consequence_ids: Vec<String>,
pub created_at_micros: i64,
pub updated_at_micros: i64,
pub completed_at_micros: Option<i64>,
pub turned_in_at_micros: Option<i64>,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct QuestHostileNpcDefeatedSignal {
pub scene_id: Option<String>,
pub hostile_npc_id: String,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct QuestTreasureInspectedSignal {
pub scene_id: Option<String>,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct QuestNpcSparCompletedSignal {
pub npc_id: String,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct QuestNpcTalkCompletedSignal {
pub npc_id: String,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct QuestSceneReachedSignal {
pub scene_id: String,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct QuestItemDeliveredSignal {
pub npc_id: String,
pub item_id: String,
pub quantity: u32,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub enum QuestProgressSignal {
HostileNpcDefeated(QuestHostileNpcDefeatedSignal),
TreasureInspected(QuestTreasureInspectedSignal),
NpcSparCompleted(QuestNpcSparCompletedSignal),
NpcTalkCompleted(QuestNpcTalkCompletedSignal),
SceneReached(QuestSceneReachedSignal),
ItemDelivered(QuestItemDeliveredSignal),
}
impl QuestStatus {
pub fn is_terminal(self) -> bool {
matches!(self, Self::TurnedIn | Self::Failed | Self::Expired)
}
pub fn is_reward_ready(self) -> bool {
matches!(self, Self::ReadyToTurnIn | Self::Completed)
}
}
impl QuestLogEventKind {
pub fn as_str(self) -> &'static str {
match self {
Self::Accepted => "accepted",
Self::Progressed => "progressed",
Self::Completed => "completed",
Self::CompletionAcknowledged => "completion_ack",
Self::TurnedIn => "turned_in",
}
}
}
impl From<&QuestProgressSignal> for QuestSignalKind {
fn from(value: &QuestProgressSignal) -> Self {
match value {
QuestProgressSignal::HostileNpcDefeated(_) => Self::HostileNpcDefeated,
QuestProgressSignal::TreasureInspected(_) => Self::TreasureInspected,
QuestProgressSignal::NpcSparCompleted(_) => Self::NpcSparCompleted,
QuestProgressSignal::NpcTalkCompleted(_) => Self::NpcTalkCompleted,
QuestProgressSignal::SceneReached(_) => Self::SceneReached,
QuestProgressSignal::ItemDelivered(_) => Self::ItemDelivered,
}
}
}

View File

@@ -1,3 +1,72 @@
//! 任务领域错误过渡落位 //! 任务领域错误。
//! //!
//! 错误保持任务规则语义,例如状态不允许、目标不匹配或重复交付。 //! 错误保持任务业务语义,例如字段缺失、步骤非法或任务尚未可交付。
use std::{error::Error, fmt};
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum QuestRecordFieldError {
MissingQuestId,
MissingRuntimeSessionId,
MissingActorUserId,
MissingIssuerNpcId,
MissingIssuerNpcName,
MissingTitle,
MissingDescription,
MissingRewardText,
EmptySteps,
MissingStepId,
MissingStepTitle,
MissingStepRevealText,
MissingStepCompleteText,
QuestNotReadyToTurnIn,
MissingRewardItemId,
MissingRewardItemCategory,
MissingRewardItemName,
InvalidRewardItemQuantity,
MissingRewardItemStackKey,
RewardEquipmentItemCannotStack,
RewardNonStackableItemMustStaySingleQuantity,
}
impl fmt::Display for QuestRecordFieldError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::MissingQuestId => f.write_str("quest_record.quest_id 不能为空"),
Self::MissingRuntimeSessionId => {
f.write_str("quest_record.runtime_session_id 不能为空")
}
Self::MissingActorUserId => f.write_str("quest_record.actor_user_id 不能为空"),
Self::MissingIssuerNpcId => f.write_str("quest_record.issuer_npc_id 不能为空"),
Self::MissingIssuerNpcName => f.write_str("quest_record.issuer_npc_name 不能为空"),
Self::MissingTitle => f.write_str("quest_record.title 不能为空"),
Self::MissingDescription => f.write_str("quest_record.description 不能为空"),
Self::MissingRewardText => f.write_str("quest_record.reward_text 不能为空"),
Self::EmptySteps => f.write_str("quest_record.steps 至少需要一条 step"),
Self::MissingStepId => f.write_str("quest_step.step_id 不能为空"),
Self::MissingStepTitle => f.write_str("quest_step.title 不能为空"),
Self::MissingStepRevealText => f.write_str("quest_step.reveal_text 不能为空"),
Self::MissingStepCompleteText => f.write_str("quest_step.complete_text 不能为空"),
Self::QuestNotReadyToTurnIn => f.write_str("当前任务还没有进入可交付状态"),
Self::MissingRewardItemId => f.write_str("quest_reward.items[].item_id 不能为空"),
Self::MissingRewardItemCategory => {
f.write_str("quest_reward.items[].category 不能为空")
}
Self::MissingRewardItemName => f.write_str("quest_reward.items[].name 不能为空"),
Self::InvalidRewardItemQuantity => {
f.write_str("quest_reward.items[].quantity 必须大于 0")
}
Self::MissingRewardItemStackKey => {
f.write_str("quest_reward.items[].stack_key 不能为空")
}
Self::RewardEquipmentItemCannotStack => {
f.write_str("quest_reward.items[] 可装备物品不能标记为 stackable")
}
Self::RewardNonStackableItemMustStaySingleQuantity => {
f.write_str("quest_reward.items[] 不可堆叠物品必须固定为单槽位单数量")
}
}
}
}
impl Error for QuestRecordFieldError {}

View File

@@ -1,3 +1,40 @@
//! 任务领域事件过渡落位 //! 任务领域事件。
//! //!
//! 用于表达任务已领取、进度已推进、任务已完成和奖励待发放等事实。 //! 用于表达任务接受、推进、完成确认和交付等事实。
use crate::domain::{QuestLogEventKind, QuestSignalKind};
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 enum QuestDomainEvent {
QuestAccepted(QuestAcceptedEvent),
QuestProgressed(QuestProgressedEvent),
QuestLogRecorded(QuestLogRecordedEvent),
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct QuestAcceptedEvent {
pub quest_id: String,
pub occurred_at_micros: i64,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct QuestProgressedEvent {
pub quest_id: String,
pub signal_kind: QuestSignalKind,
pub changed_step_id: Option<String>,
pub occurred_at_micros: i64,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct QuestLogRecordedEvent {
pub quest_id: String,
pub event_kind: QuestLogEventKind,
pub occurred_at_micros: i64,
}

View File

@@ -4,914 +4,11 @@ mod domain;
mod errors; mod errors;
mod events; mod events;
use std::{error::Error, fmt}; pub use application::*;
pub use commands::*;
use serde::{Deserialize, Serialize}; pub use domain::*;
use shared_kernel::{ pub use errors::*;
normalize_optional_string as normalize_shared_optional_string, normalize_required_string, pub use events::*;
normalize_string_list as normalize_shared_string_list,
};
#[cfg(feature = "spacetime-types")]
use spacetimedb::SpacetimeType;
pub const QUEST_LOG_ID_PREFIX: &str = "questlog_";
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub enum QuestStatus {
Active,
ReadyToTurnIn,
Completed,
TurnedIn,
Failed,
Expired,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub enum QuestNarrativeType {
Bounty,
Escort,
Investigation,
Retrieval,
Relationship,
Trial,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub enum QuestObjectiveKind {
DefeatHostileNpc,
InspectTreasure,
SparWithNpc,
TalkToNpc,
ReachScene,
DeliverItem,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub enum QuestRewardItemRarity {
Common,
Uncommon,
Rare,
Epic,
Legendary,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub enum QuestNarrativeOrigin {
AiCompiled,
FallbackBuilder,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub enum QuestLogEventKind {
Accepted,
Progressed,
Completed,
CompletionAcknowledged,
TurnedIn,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub enum QuestSignalKind {
HostileNpcDefeated,
TreasureInspected,
NpcSparCompleted,
NpcTalkCompleted,
SceneReached,
ItemDelivered,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct QuestRewardItem {
pub item_id: String,
pub category: String,
pub name: String,
pub description: Option<String>,
pub quantity: u32,
pub rarity: QuestRewardItemRarity,
pub tags: Vec<String>,
pub stackable: bool,
pub stack_key: String,
pub equipment_slot_id: Option<QuestRewardEquipmentSlot>,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub enum QuestRewardEquipmentSlot {
Weapon,
Armor,
Relic,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct QuestRewardIntel {
pub rumor_text: String,
pub unlocked_scene_id: Option<String>,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct QuestRewardSnapshot {
pub affinity_bonus: i32,
pub currency: i64,
pub experience: Option<u32>,
pub items: Vec<QuestRewardItem>,
pub intel: Option<QuestRewardIntel>,
pub story_hint: Option<String>,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct QuestNarrativeBindingSnapshot {
pub origin: QuestNarrativeOrigin,
pub narrative_type: QuestNarrativeType,
pub dramatic_need: String,
pub issuer_goal: String,
pub player_hook: String,
pub world_reason: String,
pub followup_hooks: Vec<String>,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct QuestObjectiveSnapshot {
pub kind: QuestObjectiveKind,
pub target_hostile_npc_id: Option<String>,
pub target_npc_id: Option<String>,
pub target_scene_id: Option<String>,
pub target_item_id: Option<String>,
pub required_count: u32,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct QuestStepSnapshot {
pub step_id: String,
pub kind: QuestObjectiveKind,
pub target_hostile_npc_id: Option<String>,
pub target_npc_id: Option<String>,
pub target_scene_id: Option<String>,
pub target_item_id: Option<String>,
pub required_count: u32,
pub progress: u32,
pub title: String,
pub reveal_text: String,
pub complete_text: String,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct QuestRecordInput {
pub quest_id: String,
pub runtime_session_id: String,
pub story_session_id: Option<String>,
pub actor_user_id: String,
pub issuer_npc_id: String,
pub issuer_npc_name: String,
pub scene_id: Option<String>,
pub chapter_id: Option<String>,
pub act_id: Option<String>,
pub thread_id: Option<String>,
pub contract_id: Option<String>,
pub title: String,
pub description: String,
pub summary: String,
pub status: QuestStatus,
pub completion_notified: bool,
pub reward: QuestRewardSnapshot,
pub reward_text: String,
pub narrative_binding: QuestNarrativeBindingSnapshot,
pub steps: Vec<QuestStepSnapshot>,
pub active_step_id: Option<String>,
pub visible_stage: u32,
pub hidden_flags: Vec<String>,
pub discovered_fact_ids: Vec<String>,
pub related_carrier_ids: Vec<String>,
pub consequence_ids: Vec<String>,
pub created_at_micros: i64,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct QuestRecordSnapshot {
pub quest_id: String,
pub runtime_session_id: String,
pub story_session_id: Option<String>,
pub actor_user_id: String,
pub issuer_npc_id: String,
pub issuer_npc_name: String,
pub scene_id: Option<String>,
pub chapter_id: Option<String>,
pub act_id: Option<String>,
pub thread_id: Option<String>,
pub contract_id: Option<String>,
pub title: String,
pub description: String,
pub summary: String,
pub objective: QuestObjectiveSnapshot,
pub progress: u32,
pub status: QuestStatus,
pub completion_notified: bool,
pub reward: QuestRewardSnapshot,
pub reward_text: String,
pub narrative_binding: QuestNarrativeBindingSnapshot,
pub steps: Vec<QuestStepSnapshot>,
pub active_step_id: Option<String>,
pub visible_stage: u32,
pub hidden_flags: Vec<String>,
pub discovered_fact_ids: Vec<String>,
pub related_carrier_ids: Vec<String>,
pub consequence_ids: Vec<String>,
pub created_at_micros: i64,
pub updated_at_micros: i64,
pub completed_at_micros: Option<i64>,
pub turned_in_at_micros: Option<i64>,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct QuestHostileNpcDefeatedSignal {
pub scene_id: Option<String>,
pub hostile_npc_id: String,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct QuestTreasureInspectedSignal {
pub scene_id: Option<String>,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct QuestNpcSparCompletedSignal {
pub npc_id: String,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct QuestNpcTalkCompletedSignal {
pub npc_id: String,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct QuestSceneReachedSignal {
pub scene_id: String,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct QuestItemDeliveredSignal {
pub npc_id: String,
pub item_id: String,
pub quantity: u32,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub enum QuestProgressSignal {
HostileNpcDefeated(QuestHostileNpcDefeatedSignal),
TreasureInspected(QuestTreasureInspectedSignal),
NpcSparCompleted(QuestNpcSparCompletedSignal),
NpcTalkCompleted(QuestNpcTalkCompletedSignal),
SceneReached(QuestSceneReachedSignal),
ItemDelivered(QuestItemDeliveredSignal),
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct QuestSignalApplyInput {
pub quest_id: String,
pub signal: QuestProgressSignal,
pub updated_at_micros: i64,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct QuestSignalApplyOutcome {
pub next_record: QuestRecordSnapshot,
pub changed: bool,
pub completed_now: bool,
pub changed_step_id: Option<String>,
pub changed_step_progress: Option<u32>,
pub signal_kind: QuestSignalKind,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct QuestCompletionAckInput {
pub quest_id: String,
pub updated_at_micros: i64,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct QuestCompletionAckOutcome {
pub next_record: QuestRecordSnapshot,
pub changed: bool,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct QuestTurnInInput {
pub quest_id: String,
pub turned_in_at_micros: i64,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum QuestRecordFieldError {
MissingQuestId,
MissingRuntimeSessionId,
MissingActorUserId,
MissingIssuerNpcId,
MissingIssuerNpcName,
MissingTitle,
MissingDescription,
MissingRewardText,
EmptySteps,
MissingStepId,
MissingStepTitle,
MissingStepRevealText,
MissingStepCompleteText,
QuestNotReadyToTurnIn,
MissingRewardItemId,
MissingRewardItemCategory,
MissingRewardItemName,
InvalidRewardItemQuantity,
MissingRewardItemStackKey,
RewardEquipmentItemCannotStack,
RewardNonStackableItemMustStaySingleQuantity,
}
impl QuestStatus {
pub fn is_terminal(self) -> bool {
matches!(self, Self::TurnedIn | Self::Failed | Self::Expired)
}
pub fn is_reward_ready(self) -> bool {
matches!(self, Self::ReadyToTurnIn | Self::Completed)
}
}
impl QuestLogEventKind {
pub fn as_str(self) -> &'static str {
match self {
Self::Accepted => "accepted",
Self::Progressed => "progressed",
Self::Completed => "completed",
Self::CompletionAcknowledged => "completion_ack",
Self::TurnedIn => "turned_in",
}
}
}
impl From<&QuestProgressSignal> for QuestSignalKind {
fn from(value: &QuestProgressSignal) -> Self {
match value {
QuestProgressSignal::HostileNpcDefeated(_) => Self::HostileNpcDefeated,
QuestProgressSignal::TreasureInspected(_) => Self::TreasureInspected,
QuestProgressSignal::NpcSparCompleted(_) => Self::NpcSparCompleted,
QuestProgressSignal::NpcTalkCompleted(_) => Self::NpcTalkCompleted,
QuestProgressSignal::SceneReached(_) => Self::SceneReached,
QuestProgressSignal::ItemDelivered(_) => Self::ItemDelivered,
}
}
}
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)
}
pub fn build_quest_record_snapshot(
input: QuestRecordInput,
) -> Result<QuestRecordSnapshot, QuestRecordFieldError> {
let quest_id = normalize_required_text(input.quest_id, QuestRecordFieldError::MissingQuestId)?;
let runtime_session_id = normalize_required_text(
input.runtime_session_id,
QuestRecordFieldError::MissingRuntimeSessionId,
)?;
let actor_user_id = normalize_required_text(
input.actor_user_id,
QuestRecordFieldError::MissingActorUserId,
)?;
let issuer_npc_id = normalize_required_text(
input.issuer_npc_id,
QuestRecordFieldError::MissingIssuerNpcId,
)?;
let issuer_npc_name = normalize_required_text(
input.issuer_npc_name,
QuestRecordFieldError::MissingIssuerNpcName,
)?;
let title = normalize_required_text(input.title, QuestRecordFieldError::MissingTitle)?;
let description =
normalize_required_text(input.description, QuestRecordFieldError::MissingDescription)?;
let reward_text =
normalize_required_text(input.reward_text, QuestRecordFieldError::MissingRewardText)?;
if input.steps.is_empty() {
return Err(QuestRecordFieldError::EmptySteps);
}
let steps = input
.steps
.into_iter()
.map(normalize_quest_step)
.collect::<Result<Vec<_>, _>>()?;
let active_step = resolve_active_step(&steps, input.active_step_id.as_deref());
let active_step_id = active_step.map(|step| step.step_id.clone());
let fallback_step = steps
.last()
.cloned()
.expect("BUG: validated quest steps should not be empty");
let objective = build_objective_from_step(active_step.unwrap_or(&fallback_step));
let progress = active_step
.map(|step| step.progress)
.unwrap_or(fallback_step.required_count);
let status = normalize_quest_status(input.status, active_step.is_some());
let completed_at_micros = if status.is_reward_ready() {
Some(input.created_at_micros)
} else {
None
};
let turned_in_at_micros = if status == QuestStatus::TurnedIn {
Some(input.created_at_micros)
} else {
None
};
Ok(QuestRecordSnapshot {
quest_id,
runtime_session_id,
story_session_id: normalize_optional_text(input.story_session_id),
actor_user_id,
issuer_npc_id,
issuer_npc_name,
scene_id: normalize_optional_text(input.scene_id),
chapter_id: normalize_optional_text(input.chapter_id),
act_id: normalize_optional_text(input.act_id),
thread_id: normalize_optional_text(input.thread_id),
contract_id: normalize_optional_text(input.contract_id),
title,
description: description.clone(),
summary: normalize_optional_text(Some(input.summary)).unwrap_or(description),
objective,
progress,
status,
completion_notified: input.completion_notified || status == QuestStatus::TurnedIn,
reward: normalize_quest_reward(input.reward)?,
reward_text,
narrative_binding: normalize_quest_narrative_binding(input.narrative_binding),
steps,
active_step_id,
visible_stage: input.visible_stage,
hidden_flags: normalize_string_list(input.hidden_flags),
discovered_fact_ids: normalize_string_list(input.discovered_fact_ids),
related_carrier_ids: normalize_string_list(input.related_carrier_ids),
consequence_ids: normalize_string_list(input.consequence_ids),
created_at_micros: input.created_at_micros,
updated_at_micros: input.created_at_micros,
completed_at_micros,
turned_in_at_micros,
})
}
// 任务推进只认当前 active step未命中或已终态时统一保持 no-op确保 story action 可安全重复派发信号。
pub fn apply_quest_signal(
current: QuestRecordSnapshot,
input: QuestSignalApplyInput,
) -> Result<QuestSignalApplyOutcome, QuestRecordFieldError> {
let quest_id = normalize_required_text(input.quest_id, QuestRecordFieldError::MissingQuestId)?;
let signal_kind = QuestSignalKind::from(&input.signal);
if current.quest_id != quest_id
|| current.status.is_terminal()
|| current.status.is_reward_ready()
{
return Ok(QuestSignalApplyOutcome {
next_record: current,
changed: false,
completed_now: false,
changed_step_id: None,
changed_step_progress: None,
signal_kind,
});
}
let active_step = match resolve_active_step(&current.steps, current.active_step_id.as_deref()) {
Some(step) => step,
None => {
return Ok(QuestSignalApplyOutcome {
next_record: current,
changed: false,
completed_now: false,
changed_step_id: None,
changed_step_progress: None,
signal_kind,
});
}
};
if !step_matches_signal(active_step, &input.signal) {
return Ok(QuestSignalApplyOutcome {
next_record: current,
changed: false,
completed_now: false,
changed_step_id: None,
changed_step_progress: None,
signal_kind,
});
}
let increment = signal_progress_increment(&input.signal);
let mut changed_step_id = None;
let mut changed_step_progress = None;
let next_steps = current
.steps
.iter()
.cloned()
.map(|mut step| {
if step.step_id == active_step.step_id {
let next_progress = (step.progress + increment).min(step.required_count);
if next_progress != step.progress {
step.progress = next_progress;
changed_step_id = Some(step.step_id.clone());
changed_step_progress = Some(step.progress);
}
}
step
})
.collect::<Vec<_>>();
if changed_step_id.is_none() {
return Ok(QuestSignalApplyOutcome {
next_record: current,
changed: false,
completed_now: false,
changed_step_id: None,
changed_step_progress: None,
signal_kind,
});
}
let next_active_step = resolve_active_step(&next_steps, None);
let next_active_step_id = next_active_step.map(|step| step.step_id.clone());
let fallback_step = next_steps
.last()
.cloned()
.expect("BUG: progressed quest should still contain steps");
let next_status = normalize_quest_status(current.status, next_active_step.is_some());
let completed_now = !current.status.is_reward_ready() && next_status.is_reward_ready();
let next_objective = build_objective_from_step(next_active_step.unwrap_or(&fallback_step));
let next_progress = next_active_step
.map(|step| step.progress)
.unwrap_or(fallback_step.required_count);
Ok(QuestSignalApplyOutcome {
next_record: QuestRecordSnapshot {
objective: next_objective,
progress: next_progress,
status: next_status,
completion_notified: false,
steps: next_steps,
active_step_id: next_active_step_id,
updated_at_micros: input.updated_at_micros,
completed_at_micros: if completed_now {
Some(input.updated_at_micros)
} else {
current.completed_at_micros
},
..current
},
changed: true,
completed_now,
changed_step_id,
changed_step_progress,
signal_kind,
})
}
pub fn acknowledge_quest_completion(
current: QuestRecordSnapshot,
input: QuestCompletionAckInput,
) -> Result<QuestCompletionAckOutcome, QuestRecordFieldError> {
let quest_id = normalize_required_text(input.quest_id, QuestRecordFieldError::MissingQuestId)?;
if current.quest_id != quest_id || current.completion_notified {
return Ok(QuestCompletionAckOutcome {
next_record: current,
changed: false,
});
}
Ok(QuestCompletionAckOutcome {
next_record: QuestRecordSnapshot {
completion_notified: true,
updated_at_micros: input.updated_at_micros,
..current
},
changed: true,
})
}
// 任务交付只负责把任务固定到 TurnedIn不在本轮提前掺入货币、背包和关系奖励发放。
pub fn turn_in_quest_record(
current: QuestRecordSnapshot,
input: QuestTurnInInput,
) -> Result<QuestRecordSnapshot, QuestRecordFieldError> {
let quest_id = normalize_required_text(input.quest_id, QuestRecordFieldError::MissingQuestId)?;
if current.quest_id != quest_id || !current.status.is_reward_ready() {
return Err(QuestRecordFieldError::QuestNotReadyToTurnIn);
}
let steps = current
.steps
.into_iter()
.map(|mut step| {
step.progress = step.required_count;
step
})
.collect::<Vec<_>>();
let fallback_step = steps
.last()
.cloned()
.expect("BUG: turn in quest should preserve steps");
Ok(QuestRecordSnapshot {
objective: build_objective_from_step(&fallback_step),
progress: fallback_step.required_count,
status: QuestStatus::TurnedIn,
completion_notified: true,
steps,
active_step_id: None,
updated_at_micros: input.turned_in_at_micros,
completed_at_micros: current
.completed_at_micros
.or(Some(input.turned_in_at_micros)),
turned_in_at_micros: Some(input.turned_in_at_micros),
..current
})
}
pub fn generate_quest_log_id(
quest_id: &str,
event_kind: QuestLogEventKind,
seed_micros: i64,
) -> String {
format!(
"{}{}_{:x}_{}",
QUEST_LOG_ID_PREFIX,
event_kind.as_str(),
seed_micros,
quest_id
)
}
fn normalize_required_text(
value: String,
error: QuestRecordFieldError,
) -> Result<String, QuestRecordFieldError> {
normalize_required_string(value).ok_or(error)
}
fn normalize_quest_reward(
mut reward: QuestRewardSnapshot,
) -> Result<QuestRewardSnapshot, QuestRecordFieldError> {
reward.story_hint = normalize_optional_text(reward.story_hint);
reward.intel = reward.intel.and_then(|intel| {
let rumor_text = intel.rumor_text.trim().to_string();
let unlocked_scene_id = normalize_optional_text(intel.unlocked_scene_id);
if rumor_text.is_empty() {
None
} else {
Some(QuestRewardIntel {
rumor_text,
unlocked_scene_id,
})
}
});
reward.items = reward
.items
.into_iter()
.map(
|mut item| -> Result<QuestRewardItem, QuestRecordFieldError> {
item.item_id = normalize_required_text(
item.item_id,
QuestRecordFieldError::MissingRewardItemId,
)?;
item.category = normalize_required_text(
item.category,
QuestRecordFieldError::MissingRewardItemCategory,
)?;
item.name = normalize_required_text(
item.name,
QuestRecordFieldError::MissingRewardItemName,
)?;
item.description = normalize_optional_text(item.description);
if item.quantity == 0 {
return Err(QuestRecordFieldError::InvalidRewardItemQuantity);
}
if !item.stackable && item.quantity != 1 {
return Err(
QuestRecordFieldError::RewardNonStackableItemMustStaySingleQuantity,
);
}
if item.equipment_slot_id.is_some() && item.stackable {
return Err(QuestRecordFieldError::RewardEquipmentItemCannotStack);
}
item.tags = normalize_string_list(item.tags);
item.stack_key = if item.stackable {
normalize_required_text(
item.stack_key,
QuestRecordFieldError::MissingRewardItemStackKey,
)?
} else {
normalize_optional_text(Some(item.stack_key))
.unwrap_or_else(|| item.item_id.clone())
};
Ok(item)
},
)
.collect::<Result<Vec<_>, _>>()?;
Ok(reward)
}
fn normalize_quest_narrative_binding(
mut binding: QuestNarrativeBindingSnapshot,
) -> QuestNarrativeBindingSnapshot {
binding.dramatic_need = binding.dramatic_need.trim().to_string();
binding.issuer_goal = binding.issuer_goal.trim().to_string();
binding.player_hook = binding.player_hook.trim().to_string();
binding.world_reason = binding.world_reason.trim().to_string();
binding.followup_hooks = normalize_string_list(binding.followup_hooks);
binding
}
fn normalize_quest_step(
mut step: QuestStepSnapshot,
) -> Result<QuestStepSnapshot, QuestRecordFieldError> {
step.step_id = normalize_required_text(step.step_id, QuestRecordFieldError::MissingStepId)?;
step.title = normalize_required_text(step.title, QuestRecordFieldError::MissingStepTitle)?;
step.reveal_text = normalize_required_text(
step.reveal_text,
QuestRecordFieldError::MissingStepRevealText,
)?;
step.complete_text = normalize_required_text(
step.complete_text,
QuestRecordFieldError::MissingStepCompleteText,
)?;
step.required_count = step.required_count.max(1);
step.progress = step.progress.min(step.required_count);
step.target_hostile_npc_id = normalize_optional_text(step.target_hostile_npc_id);
step.target_npc_id = normalize_optional_text(step.target_npc_id);
step.target_scene_id = normalize_optional_text(step.target_scene_id);
step.target_item_id = normalize_optional_text(step.target_item_id);
Ok(step)
}
fn resolve_active_step<'a>(
steps: &'a [QuestStepSnapshot],
active_step_id: Option<&str>,
) -> Option<&'a QuestStepSnapshot> {
if let Some(active_step_id) = active_step_id {
let active_step_id = active_step_id.trim();
if !active_step_id.is_empty() {
if let Some(step) = steps
.iter()
.find(|step| step.step_id == active_step_id && step.progress < step.required_count)
{
return Some(step);
}
}
}
steps
.iter()
.find(|step| step.progress < step.required_count)
}
fn build_objective_from_step(step: &QuestStepSnapshot) -> QuestObjectiveSnapshot {
QuestObjectiveSnapshot {
kind: step.kind,
target_hostile_npc_id: step.target_hostile_npc_id.clone(),
target_npc_id: step.target_npc_id.clone(),
target_scene_id: step.target_scene_id.clone(),
target_item_id: step.target_item_id.clone(),
required_count: step.required_count,
}
}
fn normalize_quest_status(status: QuestStatus, has_active_step: bool) -> QuestStatus {
if status.is_terminal() {
return status;
}
if has_active_step {
QuestStatus::Active
} else if status == QuestStatus::ReadyToTurnIn {
QuestStatus::ReadyToTurnIn
} else {
QuestStatus::Completed
}
}
fn step_matches_signal(step: &QuestStepSnapshot, signal: &QuestProgressSignal) -> bool {
match signal {
QuestProgressSignal::HostileNpcDefeated(payload) => {
step.kind == QuestObjectiveKind::DefeatHostileNpc
&& step.target_hostile_npc_id.as_deref() == Some(payload.hostile_npc_id.as_str())
&& step
.target_scene_id
.as_deref()
.is_none_or(|value| Some(value.to_string()) == payload.scene_id.clone())
}
QuestProgressSignal::TreasureInspected(payload) => {
step.kind == QuestObjectiveKind::InspectTreasure
&& step
.target_scene_id
.as_deref()
.is_none_or(|value| Some(value.to_string()) == payload.scene_id.clone())
}
QuestProgressSignal::NpcSparCompleted(payload) => {
step.kind == QuestObjectiveKind::SparWithNpc
&& step.target_npc_id.as_deref() == Some(payload.npc_id.as_str())
}
QuestProgressSignal::NpcTalkCompleted(payload) => {
step.kind == QuestObjectiveKind::TalkToNpc
&& step.target_npc_id.as_deref() == Some(payload.npc_id.as_str())
}
QuestProgressSignal::SceneReached(payload) => {
step.kind == QuestObjectiveKind::ReachScene
&& step.target_scene_id.as_deref() == Some(payload.scene_id.as_str())
}
QuestProgressSignal::ItemDelivered(payload) => {
step.kind == QuestObjectiveKind::DeliverItem
&& step.target_npc_id.as_deref() == Some(payload.npc_id.as_str())
&& step.target_item_id.as_deref() == Some(payload.item_id.as_str())
}
}
}
fn signal_progress_increment(signal: &QuestProgressSignal) -> u32 {
match signal {
QuestProgressSignal::ItemDelivered(payload) => payload.quantity.max(1),
_ => 1,
}
}
impl fmt::Display for QuestRecordFieldError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::MissingQuestId => f.write_str("quest_record.quest_id 不能为空"),
Self::MissingRuntimeSessionId => {
f.write_str("quest_record.runtime_session_id 不能为空")
}
Self::MissingActorUserId => f.write_str("quest_record.actor_user_id 不能为空"),
Self::MissingIssuerNpcId => f.write_str("quest_record.issuer_npc_id 不能为空"),
Self::MissingIssuerNpcName => f.write_str("quest_record.issuer_npc_name 不能为空"),
Self::MissingTitle => f.write_str("quest_record.title 不能为空"),
Self::MissingDescription => f.write_str("quest_record.description 不能为空"),
Self::MissingRewardText => f.write_str("quest_record.reward_text 不能为空"),
Self::EmptySteps => f.write_str("quest_record.steps 至少需要一条 step"),
Self::MissingStepId => f.write_str("quest_step.step_id 不能为空"),
Self::MissingStepTitle => f.write_str("quest_step.title 不能为空"),
Self::MissingStepRevealText => f.write_str("quest_step.reveal_text 不能为空"),
Self::MissingStepCompleteText => f.write_str("quest_step.complete_text 不能为空"),
Self::QuestNotReadyToTurnIn => f.write_str("当前任务还没有进入可交付状态"),
Self::MissingRewardItemId => f.write_str("quest_reward.items[].item_id 不能为空"),
Self::MissingRewardItemCategory => {
f.write_str("quest_reward.items[].category 不能为空")
}
Self::MissingRewardItemName => f.write_str("quest_reward.items[].name 不能为空"),
Self::InvalidRewardItemQuantity => {
f.write_str("quest_reward.items[].quantity 必须大于 0")
}
Self::MissingRewardItemStackKey => {
f.write_str("quest_reward.items[].stack_key 不能为空")
}
Self::RewardEquipmentItemCannotStack => {
f.write_str("quest_reward.items[] 可装备物品不能标记为 stackable")
}
Self::RewardNonStackableItemMustStaySingleQuantity => {
f.write_str("quest_reward.items[] 不可堆叠物品必须固定为单槽位单数量")
}
}
}
}
impl Error for QuestRecordFieldError {}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {

View File

@@ -1,3 +1,177 @@
//! 运行时物品应用编排过渡落位 //! 运行时物品应用编排。
//! //!
//! 这里只返回奖励结果、记录快照和待写入背包事件。 //! 这里只返回奖励结果、记录快照和待写入背包事件。
use crate::commands::TreasureResolveInput;
use crate::domain::{
RuntimeItemEquipmentSlot, RuntimeItemRewardItemRarity, RuntimeItemRewardItemSnapshot,
TreasureRecordSnapshot,
};
use crate::errors::TreasureFieldError;
use module_inventory::{
InventoryEquipmentSlot, InventoryItemRarity, InventoryItemSnapshot, InventoryItemSourceKind,
};
use serde::{Deserialize, Serialize};
use shared_kernel::{
normalize_optional_string as normalize_shared_optional_string, normalize_required_string,
normalize_string_list as normalize_shared_string_list,
};
#[cfg(feature = "spacetime-types")]
use spacetimedb::SpacetimeType;
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct TreasureRecordProcedureResult {
pub ok: bool,
pub record: Option<TreasureRecordSnapshot>,
pub error_message: Option<String>,
}
pub fn build_treasure_record_snapshot(
input: TreasureResolveInput,
) -> Result<TreasureRecordSnapshot, TreasureFieldError> {
validate_treasure_input(&input)?;
Ok(TreasureRecordSnapshot {
treasure_record_id: input.treasure_record_id,
runtime_session_id: input.runtime_session_id,
story_session_id: input.story_session_id,
actor_user_id: input.actor_user_id,
encounter_id: input.encounter_id,
encounter_name: input.encounter_name,
scene_id: normalize_optional_value(input.scene_id),
scene_name: normalize_optional_value(input.scene_name),
action: input.action,
reward_items: input
.reward_items
.into_iter()
.map(normalize_reward_item)
.collect::<Result<Vec<_>, _>>()?,
reward_hp: input.reward_hp,
reward_mana: input.reward_mana,
reward_currency: input.reward_currency,
story_hint: normalize_optional_value(input.story_hint),
created_at_micros: input.created_at_micros,
updated_at_micros: input.updated_at_micros,
})
}
pub fn build_inventory_item_snapshot_from_reward_item(
treasure_record_id: &str,
reward_item: RuntimeItemRewardItemSnapshot,
) -> Result<InventoryItemSnapshot, TreasureFieldError> {
let treasure_record_id = normalize_required_value(
treasure_record_id.to_string(),
TreasureFieldError::MissingTreasureRecordId,
)?;
let reward_item = normalize_reward_item(reward_item)?;
Ok(InventoryItemSnapshot {
item_id: reward_item.item_id,
category: reward_item.category,
name: reward_item.item_name,
description: reward_item.description,
quantity: reward_item.quantity,
rarity: map_reward_item_rarity(reward_item.rarity),
tags: reward_item.tags,
stackable: reward_item.stackable,
stack_key: reward_item.stack_key,
equipment_slot_id: reward_item
.equipment_slot_id
.map(map_reward_item_equipment_slot),
source_kind: InventoryItemSourceKind::TreasureReward,
source_reference_id: Some(treasure_record_id),
})
}
pub fn normalize_reward_item_snapshot(
reward_item: RuntimeItemRewardItemSnapshot,
) -> Result<RuntimeItemRewardItemSnapshot, TreasureFieldError> {
normalize_reward_item(reward_item)
}
fn validate_treasure_input(input: &TreasureResolveInput) -> Result<(), TreasureFieldError> {
if input.treasure_record_id.trim().is_empty() {
return Err(TreasureFieldError::MissingTreasureRecordId);
}
if input.runtime_session_id.trim().is_empty() {
return Err(TreasureFieldError::MissingRuntimeSessionId);
}
if input.story_session_id.trim().is_empty() {
return Err(TreasureFieldError::MissingStorySessionId);
}
if input.actor_user_id.trim().is_empty() {
return Err(TreasureFieldError::MissingActorUserId);
}
if input.encounter_id.trim().is_empty() {
return Err(TreasureFieldError::MissingEncounterId);
}
if input.encounter_name.trim().is_empty() {
return Err(TreasureFieldError::MissingEncounterName);
}
Ok(())
}
fn normalize_optional_value(value: Option<String>) -> Option<String> {
normalize_shared_optional_string(value)
}
fn normalize_reward_item(
mut item: RuntimeItemRewardItemSnapshot,
) -> Result<RuntimeItemRewardItemSnapshot, TreasureFieldError> {
item.item_id = normalize_required_value(item.item_id, TreasureFieldError::MissingRewardItemId)?;
item.category =
normalize_required_value(item.category, TreasureFieldError::MissingRewardItemCategory)?;
item.item_name =
normalize_required_value(item.item_name, TreasureFieldError::MissingRewardItemName)?;
item.description = normalize_optional_value(item.description);
if item.quantity == 0 {
return Err(TreasureFieldError::InvalidRewardItemQuantity);
}
if !item.stackable && item.quantity != 1 {
return Err(TreasureFieldError::RewardNonStackableItemMustStaySingleQuantity);
}
if item.equipment_slot_id.is_some() && item.stackable {
return Err(TreasureFieldError::RewardEquipmentItemCannotStack);
}
item.tags = normalize_string_list(item.tags);
item.stack_key = if item.stackable {
normalize_required_value(
item.stack_key,
TreasureFieldError::MissingRewardItemStackKey,
)?
} else {
normalize_optional_value(Some(item.stack_key)).unwrap_or_else(|| item.item_id.clone())
};
Ok(item)
}
fn normalize_required_value(
value: String,
error: TreasureFieldError,
) -> Result<String, TreasureFieldError> {
normalize_required_string(value).ok_or(error)
}
fn normalize_string_list(values: Vec<String>) -> Vec<String> {
normalize_shared_string_list(values)
}
fn map_reward_item_rarity(rarity: RuntimeItemRewardItemRarity) -> InventoryItemRarity {
match rarity {
RuntimeItemRewardItemRarity::Common => InventoryItemRarity::Common,
RuntimeItemRewardItemRarity::Uncommon => InventoryItemRarity::Uncommon,
RuntimeItemRewardItemRarity::Rare => InventoryItemRarity::Rare,
RuntimeItemRewardItemRarity::Epic => InventoryItemRarity::Epic,
RuntimeItemRewardItemRarity::Legendary => InventoryItemRarity::Legendary,
}
}
fn map_reward_item_equipment_slot(slot: RuntimeItemEquipmentSlot) -> InventoryEquipmentSlot {
match slot {
RuntimeItemEquipmentSlot::Weapon => InventoryEquipmentSlot::Weapon,
RuntimeItemEquipmentSlot::Armor => InventoryEquipmentSlot::Armor,
RuntimeItemEquipmentSlot::Relic => InventoryEquipmentSlot::Relic,
}
}

View File

@@ -1,3 +1,29 @@
//! 运行时物品写入命令过渡落位 //! 运行时物品写入命令。
//! //!
//! 用于表达宝箱检查、开启、离开和奖励记录等输入。 //! 用于表达宝箱检查、开启、离开和奖励记录等输入。
use crate::domain::{RuntimeItemRewardItemSnapshot, TreasureInteractionAction};
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 TreasureResolveInput {
pub treasure_record_id: String,
pub runtime_session_id: String,
pub story_session_id: String,
pub actor_user_id: String,
pub encounter_id: String,
pub encounter_name: String,
pub scene_id: Option<String>,
pub scene_name: Option<String>,
pub action: TreasureInteractionAction,
pub reward_items: Vec<RuntimeItemRewardItemSnapshot>,
pub reward_hp: u32,
pub reward_mana: u32,
pub reward_currency: u32,
pub story_hint: Option<String>,
pub created_at_micros: i64,
pub updated_at_micros: i64,
}

View File

@@ -1,4 +1,71 @@
//! 运行时物品领域模型过渡落位 //! 运行时物品领域模型。
//! //!
//! 后续迁移宝箱、奇遇和奖励物品规则时,只保留奖励生成与记录规则; //! 本文件承载宝箱、奇遇和奖励物品的稳定值对象;背包落库由外层事务 adapter 编排。
//! 背包落库由外层事务 adapter 编排。
use serde::{Deserialize, Serialize};
#[cfg(feature = "spacetime-types")]
use spacetimedb::SpacetimeType;
pub const TREASURE_RECORD_ID_PREFIX: &str = "treasure_";
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub enum TreasureInteractionAction {
Inspect,
Leave,
Secure,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct RuntimeItemRewardItemSnapshot {
pub item_id: String,
pub category: String,
pub item_name: String,
pub description: Option<String>,
pub quantity: u32,
pub rarity: RuntimeItemRewardItemRarity,
pub tags: Vec<String>,
pub stackable: bool,
pub stack_key: String,
pub equipment_slot_id: Option<RuntimeItemEquipmentSlot>,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub enum RuntimeItemRewardItemRarity {
Common,
Uncommon,
Rare,
Epic,
Legendary,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub enum RuntimeItemEquipmentSlot {
Weapon,
Armor,
Relic,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct TreasureRecordSnapshot {
pub treasure_record_id: String,
pub runtime_session_id: String,
pub story_session_id: String,
pub actor_user_id: String,
pub encounter_id: String,
pub encounter_name: String,
pub scene_id: Option<String>,
pub scene_name: Option<String>,
pub action: TreasureInteractionAction,
pub reward_items: Vec<RuntimeItemRewardItemSnapshot>,
pub reward_hp: u32,
pub reward_mana: u32,
pub reward_currency: u32,
pub story_hint: Option<String>,
pub created_at_micros: i64,
pub updated_at_micros: i64,
}

View File

@@ -1,3 +1,62 @@
//! 运行时物品领域错误过渡落位 //! 运行时物品领域错误。
//! //!
//! 错误只表达物品/奇遇规则失败,例如 encounter 缺失或奖励字段非法。 //! 错误只表达物品/奇遇规则失败,例如 encounter 缺失或奖励字段非法。
use std::{error::Error, fmt};
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum TreasureFieldError {
MissingTreasureRecordId,
MissingRuntimeSessionId,
MissingStorySessionId,
MissingActorUserId,
MissingEncounterId,
MissingEncounterName,
MissingRewardItemId,
MissingRewardItemCategory,
MissingRewardItemName,
InvalidRewardItemQuantity,
MissingRewardItemStackKey,
RewardEquipmentItemCannotStack,
RewardNonStackableItemMustStaySingleQuantity,
}
impl fmt::Display for TreasureFieldError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::MissingTreasureRecordId => {
f.write_str("treasure_record.treasure_record_id 不能为空")
}
Self::MissingRuntimeSessionId => {
f.write_str("treasure_record.runtime_session_id 不能为空")
}
Self::MissingStorySessionId => f.write_str("treasure_record.story_session_id 不能为空"),
Self::MissingActorUserId => f.write_str("treasure_record.actor_user_id 不能为空"),
Self::MissingEncounterId => f.write_str("treasure_record.encounter_id 不能为空"),
Self::MissingEncounterName => f.write_str("treasure_record.encounter_name 不能为空"),
Self::MissingRewardItemId => {
f.write_str("treasure_record.reward_items[].item_id 不能为空")
}
Self::MissingRewardItemCategory => {
f.write_str("treasure_record.reward_items[].category 不能为空")
}
Self::MissingRewardItemName => {
f.write_str("treasure_record.reward_items[].item_name 不能为空")
}
Self::InvalidRewardItemQuantity => {
f.write_str("treasure_record.reward_items[].quantity 必须大于 0")
}
Self::MissingRewardItemStackKey => {
f.write_str("treasure_record.reward_items[].stack_key 不能为空")
}
Self::RewardEquipmentItemCannotStack => {
f.write_str("treasure_record.reward_items[] 可装备物品不能标记为 stackable")
}
Self::RewardNonStackableItemMustStaySingleQuantity => {
f.write_str("treasure_record.reward_items[] 不可堆叠物品必须固定为单槽位单数量")
}
}
}
}
impl Error for TreasureFieldError {}

View File

@@ -1,3 +1,32 @@
//! 运行时物品领域事件过渡落位 //! 运行时物品领域事件。
//! //!
//! 用于表达宝箱已结算、奖励物品已生成和资源奖励待入账等事实。 //! 用于表达宝箱已结算、奖励物品已生成和资源奖励待入账等事实。
use crate::domain::RuntimeItemRewardItemSnapshot;
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 enum RuntimeItemDomainEvent {
TreasureResolved(RuntimeItemTreasureResolvedEvent),
TreasureRewardItemsGenerated(RuntimeItemTreasureRewardItemsGeneratedEvent),
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct RuntimeItemTreasureResolvedEvent {
pub treasure_record_id: String,
pub runtime_session_id: String,
pub story_session_id: String,
pub occurred_at_micros: i64,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct RuntimeItemTreasureRewardItemsGeneratedEvent {
pub treasure_record_id: String,
pub reward_items: Vec<RuntimeItemRewardItemSnapshot>,
pub occurred_at_micros: i64,
}

View File

@@ -4,321 +4,16 @@ mod domain;
mod errors; mod errors;
mod events; mod events;
use std::{error::Error, fmt}; pub use application::*;
pub use commands::*;
use module_inventory::{ pub use domain::*;
InventoryEquipmentSlot, InventoryItemRarity, InventoryItemSnapshot, InventoryItemSourceKind, pub use errors::*;
}; pub use events::*;
use serde::{Deserialize, Serialize};
use shared_kernel::{
normalize_optional_string as normalize_shared_optional_string, normalize_required_string,
normalize_string_list as normalize_shared_string_list,
};
#[cfg(feature = "spacetime-types")]
use spacetimedb::SpacetimeType;
pub const TREASURE_RECORD_ID_PREFIX: &str = "treasure_";
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub enum TreasureInteractionAction {
Inspect,
Leave,
Secure,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct RuntimeItemRewardItemSnapshot {
pub item_id: String,
pub category: String,
pub item_name: String,
pub description: Option<String>,
pub quantity: u32,
pub rarity: RuntimeItemRewardItemRarity,
pub tags: Vec<String>,
pub stackable: bool,
pub stack_key: String,
pub equipment_slot_id: Option<RuntimeItemEquipmentSlot>,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub enum RuntimeItemRewardItemRarity {
Common,
Uncommon,
Rare,
Epic,
Legendary,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub enum RuntimeItemEquipmentSlot {
Weapon,
Armor,
Relic,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct TreasureResolveInput {
pub treasure_record_id: String,
pub runtime_session_id: String,
pub story_session_id: String,
pub actor_user_id: String,
pub encounter_id: String,
pub encounter_name: String,
pub scene_id: Option<String>,
pub scene_name: Option<String>,
pub action: TreasureInteractionAction,
pub reward_items: Vec<RuntimeItemRewardItemSnapshot>,
pub reward_hp: u32,
pub reward_mana: u32,
pub reward_currency: u32,
pub story_hint: Option<String>,
pub created_at_micros: i64,
pub updated_at_micros: i64,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct TreasureRecordSnapshot {
pub treasure_record_id: String,
pub runtime_session_id: String,
pub story_session_id: String,
pub actor_user_id: String,
pub encounter_id: String,
pub encounter_name: String,
pub scene_id: Option<String>,
pub scene_name: Option<String>,
pub action: TreasureInteractionAction,
pub reward_items: Vec<RuntimeItemRewardItemSnapshot>,
pub reward_hp: u32,
pub reward_mana: u32,
pub reward_currency: u32,
pub story_hint: Option<String>,
pub created_at_micros: i64,
pub updated_at_micros: i64,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct TreasureRecordProcedureResult {
pub ok: bool,
pub record: Option<TreasureRecordSnapshot>,
pub error_message: Option<String>,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum TreasureFieldError {
MissingTreasureRecordId,
MissingRuntimeSessionId,
MissingStorySessionId,
MissingActorUserId,
MissingEncounterId,
MissingEncounterName,
MissingRewardItemId,
MissingRewardItemCategory,
MissingRewardItemName,
InvalidRewardItemQuantity,
MissingRewardItemStackKey,
RewardEquipmentItemCannotStack,
RewardNonStackableItemMustStaySingleQuantity,
}
pub fn build_treasure_record_snapshot(
input: TreasureResolveInput,
) -> Result<TreasureRecordSnapshot, TreasureFieldError> {
validate_treasure_input(&input)?;
Ok(TreasureRecordSnapshot {
treasure_record_id: input.treasure_record_id,
runtime_session_id: input.runtime_session_id,
story_session_id: input.story_session_id,
actor_user_id: input.actor_user_id,
encounter_id: input.encounter_id,
encounter_name: input.encounter_name,
scene_id: normalize_optional_value(input.scene_id),
scene_name: normalize_optional_value(input.scene_name),
action: input.action,
reward_items: input
.reward_items
.into_iter()
.map(normalize_reward_item)
.collect::<Result<Vec<_>, _>>()?,
reward_hp: input.reward_hp,
reward_mana: input.reward_mana,
reward_currency: input.reward_currency,
story_hint: normalize_optional_value(input.story_hint),
created_at_micros: input.created_at_micros,
updated_at_micros: input.updated_at_micros,
})
}
pub fn build_inventory_item_snapshot_from_reward_item(
treasure_record_id: &str,
reward_item: RuntimeItemRewardItemSnapshot,
) -> Result<InventoryItemSnapshot, TreasureFieldError> {
let treasure_record_id = normalize_required_value(
treasure_record_id.to_string(),
TreasureFieldError::MissingTreasureRecordId,
)?;
let reward_item = normalize_reward_item(reward_item)?;
Ok(InventoryItemSnapshot {
item_id: reward_item.item_id,
category: reward_item.category,
name: reward_item.item_name,
description: reward_item.description,
quantity: reward_item.quantity,
rarity: map_reward_item_rarity(reward_item.rarity),
tags: reward_item.tags,
stackable: reward_item.stackable,
stack_key: reward_item.stack_key,
equipment_slot_id: reward_item
.equipment_slot_id
.map(map_reward_item_equipment_slot),
source_kind: InventoryItemSourceKind::TreasureReward,
source_reference_id: Some(treasure_record_id),
})
}
pub fn normalize_reward_item_snapshot(
reward_item: RuntimeItemRewardItemSnapshot,
) -> Result<RuntimeItemRewardItemSnapshot, TreasureFieldError> {
normalize_reward_item(reward_item)
}
fn validate_treasure_input(input: &TreasureResolveInput) -> Result<(), TreasureFieldError> {
if input.treasure_record_id.trim().is_empty() {
return Err(TreasureFieldError::MissingTreasureRecordId);
}
if input.runtime_session_id.trim().is_empty() {
return Err(TreasureFieldError::MissingRuntimeSessionId);
}
if input.story_session_id.trim().is_empty() {
return Err(TreasureFieldError::MissingStorySessionId);
}
if input.actor_user_id.trim().is_empty() {
return Err(TreasureFieldError::MissingActorUserId);
}
if input.encounter_id.trim().is_empty() {
return Err(TreasureFieldError::MissingEncounterId);
}
if input.encounter_name.trim().is_empty() {
return Err(TreasureFieldError::MissingEncounterName);
}
Ok(())
}
fn normalize_optional_value(value: Option<String>) -> Option<String> {
normalize_shared_optional_string(value)
}
fn normalize_reward_item(
mut item: RuntimeItemRewardItemSnapshot,
) -> Result<RuntimeItemRewardItemSnapshot, TreasureFieldError> {
item.item_id = normalize_required_value(item.item_id, TreasureFieldError::MissingRewardItemId)?;
item.category =
normalize_required_value(item.category, TreasureFieldError::MissingRewardItemCategory)?;
item.item_name =
normalize_required_value(item.item_name, TreasureFieldError::MissingRewardItemName)?;
item.description = normalize_optional_value(item.description);
if item.quantity == 0 {
return Err(TreasureFieldError::InvalidRewardItemQuantity);
}
if !item.stackable && item.quantity != 1 {
return Err(TreasureFieldError::RewardNonStackableItemMustStaySingleQuantity);
}
if item.equipment_slot_id.is_some() && item.stackable {
return Err(TreasureFieldError::RewardEquipmentItemCannotStack);
}
item.tags = normalize_string_list(item.tags);
item.stack_key = if item.stackable {
normalize_required_value(
item.stack_key,
TreasureFieldError::MissingRewardItemStackKey,
)?
} else {
normalize_optional_value(Some(item.stack_key)).unwrap_or_else(|| item.item_id.clone())
};
Ok(item)
}
fn normalize_required_value(
value: String,
error: TreasureFieldError,
) -> Result<String, TreasureFieldError> {
normalize_required_string(value).ok_or(error)
}
fn normalize_string_list(values: Vec<String>) -> Vec<String> {
normalize_shared_string_list(values)
}
fn map_reward_item_rarity(rarity: RuntimeItemRewardItemRarity) -> InventoryItemRarity {
match rarity {
RuntimeItemRewardItemRarity::Common => InventoryItemRarity::Common,
RuntimeItemRewardItemRarity::Uncommon => InventoryItemRarity::Uncommon,
RuntimeItemRewardItemRarity::Rare => InventoryItemRarity::Rare,
RuntimeItemRewardItemRarity::Epic => InventoryItemRarity::Epic,
RuntimeItemRewardItemRarity::Legendary => InventoryItemRarity::Legendary,
}
}
fn map_reward_item_equipment_slot(slot: RuntimeItemEquipmentSlot) -> InventoryEquipmentSlot {
match slot {
RuntimeItemEquipmentSlot::Weapon => InventoryEquipmentSlot::Weapon,
RuntimeItemEquipmentSlot::Armor => InventoryEquipmentSlot::Armor,
RuntimeItemEquipmentSlot::Relic => InventoryEquipmentSlot::Relic,
}
}
impl fmt::Display for TreasureFieldError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::MissingTreasureRecordId => {
f.write_str("treasure_record.treasure_record_id 不能为空")
}
Self::MissingRuntimeSessionId => {
f.write_str("treasure_record.runtime_session_id 不能为空")
}
Self::MissingStorySessionId => f.write_str("treasure_record.story_session_id 不能为空"),
Self::MissingActorUserId => f.write_str("treasure_record.actor_user_id 不能为空"),
Self::MissingEncounterId => f.write_str("treasure_record.encounter_id 不能为空"),
Self::MissingEncounterName => f.write_str("treasure_record.encounter_name 不能为空"),
Self::MissingRewardItemId => {
f.write_str("treasure_record.reward_items[].item_id 不能为空")
}
Self::MissingRewardItemCategory => {
f.write_str("treasure_record.reward_items[].category 不能为空")
}
Self::MissingRewardItemName => {
f.write_str("treasure_record.reward_items[].item_name 不能为空")
}
Self::InvalidRewardItemQuantity => {
f.write_str("treasure_record.reward_items[].quantity 必须大于 0")
}
Self::MissingRewardItemStackKey => {
f.write_str("treasure_record.reward_items[].stack_key 不能为空")
}
Self::RewardEquipmentItemCannotStack => {
f.write_str("treasure_record.reward_items[] 可装备物品不能标记为 stackable")
}
Self::RewardNonStackableItemMustStaySingleQuantity => {
f.write_str("treasure_record.reward_items[] 不可堆叠物品必须固定为单槽位单数量")
}
}
}
}
impl Error for TreasureFieldError {}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
use module_inventory::{InventoryEquipmentSlot, InventoryItemRarity, InventoryItemSourceKind};
#[test] #[test]
fn build_treasure_record_snapshot_accepts_minimal_contract() { fn build_treasure_record_snapshot_accepts_minimal_contract() {

View File

@@ -2,12 +2,15 @@
`module-runtime-story` 承接 RPG runtime story 的纯领域规则、应用用例、事件和错误模型,不依赖 HTTP / `AppState` / SpacetimeDB。 `module-runtime-story` 承接 RPG runtime story 的纯领域规则、应用用例、事件和错误模型,不依赖 HTTP / `AppState` / SpacetimeDB。
当前已经迁入的历史快照态纯逻辑会继续收口为 session scoped 新主链: 当前已经迁入的历史快照态纯逻辑会继续收口为 session scoped 新主链;顶层 DDD 物理拆分已经完成
1. action 结算结果结构 1. `src/domain.rs` 承载 action 结算结果结构、NPC 委托上下文、functionId / 队伍上限常量
2. action response 组装参数结构 2. `src/commands.rs` 承载 action 文本解析 helper
3. NPC 委托上下文结构 3. `src/application.rs` 承载 action response 组装参数、status patch 和 world type helper
4. functionId / 队伍上限常量 4. `src/events.rs` 承载 runtime story 领域事件
5. 少量只依赖 `serde_json::Value``shared-contracts` 的纯 helper 5. `src/errors.rs` 承载 runtime story 纯规则错误
6. `src/lib.rs` 只保留模块声明、公开导出和子模块 re-export。
后续 WP-RS 继续按 battle / forge / NPC / quest / presentation 的顺序,把旧 `/api/runtime/story/*` 写侧能力迁到 session scoped 新接口,并删除运行代码中的旧入口命名。 后续 WP-RS 继续按 battle / forge / NPC / quest / presentation 的顺序,把旧 `/api/runtime/story/*` 写侧能力迁到 session scoped 新接口,并删除运行代码中的旧入口命名。
配套记录见 [../../../docs/technical/SERVER_RS_DDD_WP_RS_RUNTIME_STORY_DOMAIN_SPLIT_2026-04-30.md](../../../docs/technical/SERVER_RS_DDD_WP_RS_RUNTIME_STORY_DOMAIN_SPLIT_2026-04-30.md)。

View File

@@ -1,3 +1,58 @@
//! runtime story 应用编排落位。 //! runtime story 应用编排落位。
//! //!
//! 这里组合纯领域规则并返回后端投影真实保存、SSE 和模型调用由外层完成。 //! 这里组合纯领域规则并返回后端投影真实保存、SSE 和模型调用由外层完成。
use serde_json::Value;
use shared_contracts::runtime_story::{
RuntimeBattlePresentation, RuntimeStoryOptionView, RuntimeStoryPatch,
RuntimeStorySnapshotPayload,
};
use crate::{StoryResolution, read_bool_field, read_optional_string_field};
pub struct RuntimeStoryActionResponseParts {
pub requested_session_id: String,
pub server_version: u32,
pub snapshot: RuntimeStorySnapshotPayload,
pub action_text: String,
pub result_text: String,
pub story_text: String,
pub options: Vec<RuntimeStoryOptionView>,
pub patches: Vec<RuntimeStoryPatch>,
pub toast: Option<String>,
pub battle: Option<RuntimeBattlePresentation>,
}
pub fn simple_story_resolution(
game_state: &Value,
action_text: String,
result_text: &str,
) -> StoryResolution {
StoryResolution {
action_text,
result_text: result_text.to_string(),
story_text: None,
presentation_options: None,
saved_current_story: None,
patches: vec![build_status_patch(game_state)],
battle: None,
toast: None,
}
}
pub fn build_status_patch(game_state: &Value) -> RuntimeStoryPatch {
RuntimeStoryPatch::StatusChanged {
in_battle: read_bool_field(game_state, "inBattle").unwrap_or(false),
npc_interaction_active: read_bool_field(game_state, "npcInteractionActive")
.unwrap_or(false),
current_npc_battle_mode: read_optional_string_field(game_state, "currentNpcBattleMode"),
current_npc_battle_outcome: read_optional_string_field(
game_state,
"currentNpcBattleOutcome",
),
}
}
pub fn current_world_type(game_state: &Value) -> Option<String> {
read_optional_string_field(game_state, "worldType")
}

View File

@@ -1,3 +1,16 @@
//! runtime story 写入命令过渡落位 //! runtime story 写入命令。
//! //!
//! 用于表达剧情动作解析、战斗动作、锻造动作和 NPC 互动等输入。 //! 用于表达剧情动作解析、战斗动作、锻造动作和 NPC 互动等输入。
use shared_contracts::runtime_story::RuntimeStoryActionRequest;
use crate::read_optional_string_field;
pub fn resolve_action_text(default_text: &str, request: &RuntimeStoryActionRequest) -> String {
request
.action
.payload
.as_ref()
.and_then(|payload| read_optional_string_field(payload, "optionText"))
.unwrap_or_else(|| default_text.to_string())
}

View File

@@ -1,4 +1,44 @@
//! runtime story 领域模型过渡落位 //! runtime story 领域模型。
//! //!
//! 当前 crate 用于运行时剧情主链的纯规则收口。后续迁移时仍只能保留 JSON 规则、 //! 当前 crate 用于运行时剧情主链的纯规则收口。后续迁移时仍只能保留 JSON 规则、
//! 选项生成和视图模型转换,不引入 Axum、LLM 或 SpacetimeDB。 //! 选项生成和视图模型转换,不引入 Axum、LLM 或 SpacetimeDB。
use serde_json::Value;
use shared_contracts::runtime_story::{
RuntimeBattlePresentation, RuntimeStoryOptionView, RuntimeStoryPatch,
};
pub const CONTINUE_ADVENTURE_FUNCTION_ID: &str = "story_continue_adventure";
pub const MAX_TASK5_COMPANIONS: usize = 2;
pub struct StoryResolution {
pub action_text: String,
pub result_text: String,
pub story_text: Option<String>,
pub presentation_options: Option<Vec<RuntimeStoryOptionView>>,
pub saved_current_story: Option<Value>,
pub patches: Vec<RuntimeStoryPatch>,
pub battle: Option<RuntimeBattlePresentation>,
pub toast: Option<String>,
}
pub struct GeneratedStoryPayload {
pub story_text: String,
pub history_result_text: String,
pub presentation_options: Vec<RuntimeStoryOptionView>,
pub saved_current_story: Value,
}
pub struct CurrentEncounterNpcQuestContext {
pub npc_id: String,
pub npc_name: String,
}
pub struct PendingQuestOfferContext {
pub dialogue: Vec<Value>,
pub turn_count: i32,
pub custom_input_placeholder: String,
pub quest: Value,
pub quest_id: String,
pub intro_text: Option<String>,
}

View File

@@ -1,3 +1,26 @@
//! runtime story 领域错误过渡落位 //! runtime story 领域错误。
//! //!
//! 错误只表达运行时剧情规则失败,不能直接绑定 HTTP 或数据库错误模型。 //! 错误只表达运行时剧情规则失败,不能直接绑定 HTTP 或数据库错误模型。
use std::{error::Error, fmt};
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum RuntimeStoryRuleError {
MissingRuntimeSessionId,
MissingStoryAction,
UnsupportedStoryAction,
InvalidRuntimeSnapshot,
}
impl fmt::Display for RuntimeStoryRuleError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::MissingRuntimeSessionId => f.write_str("runtime_story.session_id 不能为空"),
Self::MissingStoryAction => f.write_str("runtime_story.action 不能为空"),
Self::UnsupportedStoryAction => f.write_str("runtime_story.action 当前不受支持"),
Self::InvalidRuntimeSnapshot => f.write_str("runtime_story.snapshot 非法"),
}
}
}
impl Error for RuntimeStoryRuleError {}

View File

@@ -1,3 +1,22 @@
//! runtime story 领域事件过渡落位 //! runtime story 领域事件。
//! //!
//! 用于表达剧情快照变化、战斗表现变化和物品/成长待同步等事实。 //! 用于表达剧情快照变化、战斗表现变化和物品/成长待同步等事实。
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum RuntimeStoryDomainEvent {
SnapshotChanged {
runtime_session_id: String,
story_session_id: Option<String>,
occurred_at_micros: i64,
},
BattlePresentationChanged {
runtime_session_id: String,
battle_state_id: Option<String>,
occurred_at_micros: i64,
},
CrossDomainSyncPending {
runtime_session_id: String,
reason: String,
occurred_at_micros: i64,
},
}

View File

@@ -4,12 +4,6 @@ mod domain;
mod errors; mod errors;
mod events; mod events;
use serde_json::Value;
use shared_contracts::runtime_story::{
RuntimeBattlePresentation, RuntimeStoryActionRequest, RuntimeStoryOptionView,
RuntimeStoryPatch, RuntimeStorySnapshotPayload,
};
pub mod battle; pub mod battle;
#[cfg(test)] #[cfg(test)]
mod battle_tests; mod battle_tests;
@@ -25,10 +19,12 @@ pub mod prompt_context;
pub mod story_engine; pub mod story_engine;
pub mod view_model; pub mod view_model;
pub use application::*;
pub use battle::{ pub use battle::{
build_battle_runtime_story_options, inventory_item_has_usable_effect, resolve_battle_action, build_battle_runtime_story_options, inventory_item_has_usable_effect, resolve_battle_action,
restore_player_resource, restore_player_resource,
}; };
pub use commands::*;
pub use core::{ pub use core::{
MAX_PLAYER_LEVEL, add_player_currency, add_player_inventory_items, append_active_build_buffs, MAX_PLAYER_LEVEL, add_player_currency, add_player_inventory_items, append_active_build_buffs,
append_story_history, clear_encounter_only, clear_encounter_state, cumulative_xp_required, append_story_history, clear_encounter_only, clear_encounter_state, cumulative_xp_required,
@@ -41,6 +37,9 @@ pub use core::{
write_first_hostile_npc_i32_field, write_i32_field, write_null_field, write_string_field, write_first_hostile_npc_i32_field, write_i32_field, write_null_field, write_string_field,
write_u32_field, xp_to_next_level_for, write_u32_field, xp_to_next_level_for,
}; };
pub use domain::*;
pub use errors::*;
pub use events::*;
pub use forge::{build_runtime_equipment_item, build_runtime_material_item, format_currency_text}; pub use forge::{build_runtime_equipment_item, build_runtime_material_item, format_currency_text};
pub use forge_actions::{ pub use forge_actions::{
resolve_forge_craft_action, resolve_forge_dismantle_action, resolve_forge_reforge_action, resolve_forge_craft_action, resolve_forge_dismantle_action, resolve_forge_reforge_action,
@@ -77,94 +76,3 @@ pub use view_model::{
build_runtime_story_companions, build_runtime_story_encounter, build_runtime_story_view_model, build_runtime_story_companions, build_runtime_story_encounter, build_runtime_story_view_model,
resolve_current_encounter_npc_state, resolve_current_encounter_npc_state,
}; };
pub const CONTINUE_ADVENTURE_FUNCTION_ID: &str = "story_continue_adventure";
pub const MAX_TASK5_COMPANIONS: usize = 2;
pub struct StoryResolution {
pub action_text: String,
pub result_text: String,
pub story_text: Option<String>,
pub presentation_options: Option<Vec<RuntimeStoryOptionView>>,
pub saved_current_story: Option<Value>,
pub patches: Vec<RuntimeStoryPatch>,
pub battle: Option<RuntimeBattlePresentation>,
pub toast: Option<String>,
}
pub struct GeneratedStoryPayload {
pub story_text: String,
pub history_result_text: String,
pub presentation_options: Vec<RuntimeStoryOptionView>,
pub saved_current_story: Value,
}
pub struct CurrentEncounterNpcQuestContext {
pub npc_id: String,
pub npc_name: String,
}
pub struct PendingQuestOfferContext {
pub dialogue: Vec<Value>,
pub turn_count: i32,
pub custom_input_placeholder: String,
pub quest: Value,
pub quest_id: String,
pub intro_text: Option<String>,
}
pub struct RuntimeStoryActionResponseParts {
pub requested_session_id: String,
pub server_version: u32,
pub snapshot: RuntimeStorySnapshotPayload,
pub action_text: String,
pub result_text: String,
pub story_text: String,
pub options: Vec<RuntimeStoryOptionView>,
pub patches: Vec<RuntimeStoryPatch>,
pub toast: Option<String>,
pub battle: Option<RuntimeBattlePresentation>,
}
pub fn simple_story_resolution(
game_state: &Value,
action_text: String,
result_text: &str,
) -> StoryResolution {
StoryResolution {
action_text,
result_text: result_text.to_string(),
story_text: None,
presentation_options: None,
saved_current_story: None,
patches: vec![build_status_patch(game_state)],
battle: None,
toast: None,
}
}
pub fn resolve_action_text(default_text: &str, request: &RuntimeStoryActionRequest) -> String {
request
.action
.payload
.as_ref()
.and_then(|payload| read_optional_string_field(payload, "optionText"))
.unwrap_or_else(|| default_text.to_string())
}
pub fn build_status_patch(game_state: &Value) -> RuntimeStoryPatch {
RuntimeStoryPatch::StatusChanged {
in_battle: read_bool_field(game_state, "inBattle").unwrap_or(false),
npc_interaction_active: read_bool_field(game_state, "npcInteractionActive")
.unwrap_or(false),
current_npc_battle_mode: read_optional_string_field(game_state, "currentNpcBattleMode"),
current_npc_battle_outcome: read_optional_string_field(
game_state,
"currentNpcBattleOutcome",
),
}
}
pub fn current_world_type(game_state: &Value) -> Option<String> {
read_optional_string_field(game_state, "worldType")
}

View File

@@ -1,4 +1,4 @@
//! 运行时应用编排过渡落位 //! 运行时应用编排。
//! //!
//! 这里只返回运行时快照、个人页投影和领域事件,不直接访问外部 adapter。 //! 这里只返回运行时快照、个人页投影和领域事件,不直接访问外部 adapter。

View File

@@ -1,4 +1,4 @@
//! 运行时写入命令过渡落位 //! 运行时写入命令。
//! //!
//! 用于表达保存快照、更新设置、写入浏览历史、调整钱包和保存存档等输入。 //! 用于表达保存快照、更新设置、写入浏览历史、调整钱包和保存存档等输入。

View File

@@ -1,4 +1,4 @@
//! 运行时领域错误过渡落位 //! 运行时领域错误。
//! //!
//! 错误保持运行时业务语义,例如快照版本非法、兑换码不可用或钱包余额不足。 //! 错误保持运行时业务语义,例如快照版本非法、兑换码不可用或钱包余额不足。

View File

@@ -1,3 +1,3 @@
//! 运行时领域事件过渡落位 //! 运行时领域事件。
//! //!
//! 用于表达快照已保存、设置已更新、钱包已记账和存档已变化等事实。 //! 用于表达快照已保存、设置已更新、钱包已记账和存档已变化等事实。

View File

@@ -1826,13 +1826,19 @@ fn execute_custom_world_agent_action_tx(
} }
"publish_world" => execute_publish_world_action(ctx, &session, &input, &payload), "publish_world" => execute_publish_world_action(ctx, &session, &input, &payload),
"revert_checkpoint" => execute_revert_checkpoint_action(ctx, &session, &input, &payload), "revert_checkpoint" => execute_revert_checkpoint_action(ctx, &session, &input, &payload),
"generate_characters" "generate_characters" => {
| "generate_landmarks" execute_generate_characters_action(ctx, &session, &input, &payload)
| "generate_role_assets" }
| "sync_role_assets" "generate_landmarks" => execute_generate_landmarks_action(ctx, &session, &input, &payload),
| "generate_scene_assets" "generate_role_assets" => {
| "sync_scene_assets" execute_generate_role_assets_action(ctx, &session, &input, &payload)
| "expand_long_tail" => execute_placeholder_custom_world_action(ctx, &session, &input), }
"sync_role_assets" => execute_sync_role_assets_action(ctx, &session, &input, &payload),
"generate_scene_assets" => {
execute_generate_scene_assets_action(ctx, &session, &input, &payload)
}
"sync_scene_assets" => execute_sync_scene_assets_action(ctx, &session, &input, &payload),
"expand_long_tail" => execute_expand_long_tail_action(ctx, &session, &input, &payload),
other => Err(format!("custom world action `{other}` 当前尚未支持")), other => Err(format!("custom world action `{other}` 当前尚未支持")),
} }
} }
@@ -2378,35 +2384,763 @@ fn execute_revert_checkpoint_action(
Ok(build_custom_world_agent_operation_snapshot(&operation)) Ok(build_custom_world_agent_operation_snapshot(&operation))
} }
fn execute_placeholder_custom_world_action( fn execute_generate_characters_action(
ctx: &ReducerContext, ctx: &ReducerContext,
session: &CustomWorldAgentSession, session: &CustomWorldAgentSession,
input: &CustomWorldAgentActionExecuteInput, input: &CustomWorldAgentActionExecuteInput,
payload: &JsonMap<String, JsonValue>,
) -> Result<CustomWorldAgentOperationSnapshot, String> { ) -> Result<CustomWorldAgentOperationSnapshot, String> {
let operation_type = map_action_name_to_operation_type(input.action.as_str()) ensure_refining_stage(session.stage, "generate_characters")?;
.ok_or_else(|| format!("action {} 无法映射到 operation type", input.action))?; let mut draft_profile = current_custom_world_draft_profile(session);
let inserted = upsert_draft_profile_array_from_payload(
&mut draft_profile,
payload,
"characters",
"playableNpcs",
"character",
RpgAgentDraftCardKind::Character,
ctx,
&session.session_id,
input.submitted_at_micros,
)?;
let inserted_story = upsert_draft_profile_array_from_payload(
&mut draft_profile,
payload,
"storyNpcs",
"storyNpcs",
"story-npc",
RpgAgentDraftCardKind::Character,
ctx,
&session.session_id,
input.submitted_at_micros,
)?;
let total_inserted = inserted.saturating_add(inserted_story);
persist_custom_world_draft_profile_update(
ctx,
session,
draft_profile,
input.submitted_at_micros,
RpgAgentStage::ObjectRefining,
format!("已同步 {total_inserted} 个角色草稿。"),
"generate-characters",
"生成角色草稿",
)?;
append_custom_world_action_result_message( append_custom_world_action_result_message(
ctx, ctx,
&session.session_id, &session.session_id,
&input.operation_id, &input.operation_id,
&format!( &format!("已生成并同步 {total_inserted} 个角色草稿。"),
"动作 {} 已接入最小兼容占位,后续会继续补真实编排。",
input.action
),
input.submitted_at_micros, input.submitted_at_micros,
); );
let operation = build_and_insert_custom_world_operation(
let operation = complete_custom_world_operation(
ctx, ctx,
&input.operation_id, &input.operation_id,
&session.session_id, &session.session_id,
operation_type, RpgAgentOperationType::GenerateCharacters,
"动作已完成", "角色草稿已同步",
&format!("{} 当前已走最小兼容闭环。", input.action), &format!("角色草稿已写入 draft_profile 与卡片表,新增 {total_inserted} 条。"),
input.submitted_at_micros,
)?;
Ok(build_custom_world_agent_operation_snapshot(&operation))
}
fn execute_generate_landmarks_action(
ctx: &ReducerContext,
session: &CustomWorldAgentSession,
input: &CustomWorldAgentActionExecuteInput,
payload: &JsonMap<String, JsonValue>,
) -> Result<CustomWorldAgentOperationSnapshot, String> {
ensure_refining_stage(session.stage, "generate_landmarks")?;
let mut draft_profile = current_custom_world_draft_profile(session);
let inserted = upsert_draft_profile_array_from_payload(
&mut draft_profile,
payload,
"landmarks",
"landmarks",
"landmark",
RpgAgentDraftCardKind::Landmark,
ctx,
&session.session_id,
input.submitted_at_micros,
)?;
persist_custom_world_draft_profile_update(
ctx,
session,
draft_profile,
input.submitted_at_micros,
RpgAgentStage::ObjectRefining,
format!("已同步 {inserted} 个地标草稿。"),
"generate-landmarks",
"生成地标草稿",
)?;
append_custom_world_action_result_message(
ctx,
&session.session_id,
&input.operation_id,
&format!("已生成并同步 {inserted} 个地标草稿。"),
input.submitted_at_micros, input.submitted_at_micros,
); );
let operation = complete_custom_world_operation(
ctx,
&input.operation_id,
&session.session_id,
RpgAgentOperationType::GenerateLandmarks,
"地标草稿已同步",
&format!("地标草稿已写入 draft_profile 与卡片表,新增 {inserted} 条。"),
input.submitted_at_micros,
)?;
Ok(build_custom_world_agent_operation_snapshot(&operation)) Ok(build_custom_world_agent_operation_snapshot(&operation))
} }
fn execute_generate_role_assets_action(
ctx: &ReducerContext,
session: &CustomWorldAgentSession,
input: &CustomWorldAgentActionExecuteInput,
payload: &JsonMap<String, JsonValue>,
) -> Result<CustomWorldAgentOperationSnapshot, String> {
ensure_refining_stage(session.stage, "generate_role_assets")?;
let next_coverage = build_role_asset_coverage_json(session, payload, true)?;
let next_session = rebuild_custom_world_agent_session_row(
session,
CustomWorldAgentSessionPatch {
stage: Some(RpgAgentStage::VisualRefining),
asset_coverage_json: Some(next_coverage),
last_assistant_reply: Some(Some(
"角色视觉资产槽位已生成并进入视觉打磨阶段。".to_string(),
)),
updated_at_micros: Some(input.submitted_at_micros),
..CustomWorldAgentSessionPatch::default()
},
)?;
replace_custom_world_agent_session(ctx, session, next_session);
update_role_asset_cards(
ctx,
&session.session_id,
CustomWorldRoleAssetStatus::VisualReady,
"角色主图已就绪",
input.submitted_at_micros,
);
append_custom_world_action_result_message(
ctx,
&session.session_id,
&input.operation_id,
"角色视觉资产槽位已生成,角色卡片状态已刷新。",
input.submitted_at_micros,
);
let operation = complete_custom_world_operation(
ctx,
&input.operation_id,
&session.session_id,
RpgAgentOperationType::GenerateRoleAssets,
"角色资产已生成",
"asset_coverage.roleAssets 与角色卡片视觉状态已更新。",
input.submitted_at_micros,
)?;
Ok(build_custom_world_agent_operation_snapshot(&operation))
}
fn execute_sync_role_assets_action(
ctx: &ReducerContext,
session: &CustomWorldAgentSession,
input: &CustomWorldAgentActionExecuteInput,
payload: &JsonMap<String, JsonValue>,
) -> Result<CustomWorldAgentOperationSnapshot, String> {
ensure_refining_stage(session.stage, "sync_role_assets")?;
let next_coverage = build_role_asset_coverage_json(session, payload, false)?;
let next_session = rebuild_custom_world_agent_session_row(
session,
CustomWorldAgentSessionPatch {
stage: Some(RpgAgentStage::VisualRefining),
asset_coverage_json: Some(next_coverage),
last_assistant_reply: Some(Some("角色资产状态已按外部资产结果同步。".to_string())),
updated_at_micros: Some(input.submitted_at_micros),
..CustomWorldAgentSessionPatch::default()
},
)?;
replace_custom_world_agent_session(ctx, session, next_session);
update_role_asset_cards(
ctx,
&session.session_id,
CustomWorldRoleAssetStatus::Complete,
"角色资产已同步",
input.submitted_at_micros,
);
append_custom_world_action_result_message(
ctx,
&session.session_id,
&input.operation_id,
"角色资产结果已同步到会话覆盖率与角色卡片。",
input.submitted_at_micros,
);
let operation = complete_custom_world_operation(
ctx,
&input.operation_id,
&session.session_id,
RpgAgentOperationType::SyncRoleAssets,
"角色资产已同步",
"asset_coverage.roleAssets 与角色卡片完成状态已更新。",
input.submitted_at_micros,
)?;
Ok(build_custom_world_agent_operation_snapshot(&operation))
}
fn execute_generate_scene_assets_action(
ctx: &ReducerContext,
session: &CustomWorldAgentSession,
input: &CustomWorldAgentActionExecuteInput,
payload: &JsonMap<String, JsonValue>,
) -> Result<CustomWorldAgentOperationSnapshot, String> {
ensure_refining_stage(session.stage, "generate_scene_assets")?;
let next_coverage = build_scene_asset_coverage_json(session, payload, true)?;
let next_session = rebuild_custom_world_agent_session_row(
session,
CustomWorldAgentSessionPatch {
stage: Some(RpgAgentStage::VisualRefining),
asset_coverage_json: Some(next_coverage),
last_assistant_reply: Some(Some(
"场景视觉资产槽位已生成并进入视觉打磨阶段。".to_string(),
)),
updated_at_micros: Some(input.submitted_at_micros),
..CustomWorldAgentSessionPatch::default()
},
)?;
replace_custom_world_agent_session(ctx, session, next_session);
append_custom_world_action_result_message(
ctx,
&session.session_id,
&input.operation_id,
"场景视觉资产槽位已生成,等待外层资产链写回对象结果。",
input.submitted_at_micros,
);
let operation = complete_custom_world_operation(
ctx,
&input.operation_id,
&session.session_id,
RpgAgentOperationType::GenerateSceneAssets,
"场景资产已生成",
"asset_coverage.sceneAssets 已根据当前草稿刷新。",
input.submitted_at_micros,
)?;
Ok(build_custom_world_agent_operation_snapshot(&operation))
}
fn execute_sync_scene_assets_action(
ctx: &ReducerContext,
session: &CustomWorldAgentSession,
input: &CustomWorldAgentActionExecuteInput,
payload: &JsonMap<String, JsonValue>,
) -> Result<CustomWorldAgentOperationSnapshot, String> {
ensure_refining_stage(session.stage, "sync_scene_assets")?;
let next_coverage = build_scene_asset_coverage_json(session, payload, false)?;
let next_session = rebuild_custom_world_agent_session_row(
session,
CustomWorldAgentSessionPatch {
stage: Some(RpgAgentStage::VisualRefining),
asset_coverage_json: Some(next_coverage),
last_assistant_reply: Some(Some("场景资产状态已按外部资产结果同步。".to_string())),
updated_at_micros: Some(input.submitted_at_micros),
..CustomWorldAgentSessionPatch::default()
},
)?;
replace_custom_world_agent_session(ctx, session, next_session);
append_custom_world_action_result_message(
ctx,
&session.session_id,
&input.operation_id,
"场景资产结果已同步到会话覆盖率。",
input.submitted_at_micros,
);
let operation = complete_custom_world_operation(
ctx,
&input.operation_id,
&session.session_id,
RpgAgentOperationType::SyncSceneAssets,
"场景资产已同步",
"asset_coverage.sceneAssets 已更新为同步结果。",
input.submitted_at_micros,
)?;
Ok(build_custom_world_agent_operation_snapshot(&operation))
}
fn execute_expand_long_tail_action(
ctx: &ReducerContext,
session: &CustomWorldAgentSession,
input: &CustomWorldAgentActionExecuteInput,
payload: &JsonMap<String, JsonValue>,
) -> Result<CustomWorldAgentOperationSnapshot, String> {
ensure_long_tail_stage(session.stage, "expand_long_tail")?;
let mut draft_profile = current_custom_world_draft_profile(session);
merge_long_tail_payload(&mut draft_profile, payload);
let gate = summarize_publish_gate_from_json(
&session.session_id,
RpgAgentStage::LongTailReview,
Some(&draft_profile),
&parse_json_array_or_empty(&session.quality_findings_json),
);
let next_session = rebuild_custom_world_agent_session_row(
session,
CustomWorldAgentSessionPatch {
stage: Some(if gate.publish_ready {
RpgAgentStage::ReadyToPublish
} else {
RpgAgentStage::LongTailReview
}),
draft_profile_json: Some(Some(serialize_json_value(&JsonValue::Object(
draft_profile.clone(),
))?)),
publish_gate_json: Some(Some(serialize_json_value(&publish_gate_to_json_value(
&gate,
))?)),
result_preview_json: Some(build_result_preview_json(
Some(&draft_profile),
&gate,
&parse_json_array_or_empty(&session.quality_findings_json),
input.submitted_at_micros,
)?),
checkpoints_json: Some(append_checkpoint_json(
&session.checkpoints_json,
&build_session_checkpoint_value("expand-long-tail", "补齐长尾内容", session),
)?),
last_assistant_reply: Some(Some("长尾内容已合并,并重新计算发布门禁。".to_string())),
updated_at_micros: Some(input.submitted_at_micros),
..CustomWorldAgentSessionPatch::default()
},
)?;
replace_custom_world_agent_session(ctx, session, next_session);
append_custom_world_action_result_message(
ctx,
&session.session_id,
&input.operation_id,
"长尾内容已合并到当前世界草稿,并刷新发布门禁。",
input.submitted_at_micros,
);
let operation = complete_custom_world_operation(
ctx,
&input.operation_id,
&session.session_id,
RpgAgentOperationType::ExpandLongTail,
"长尾内容已扩展",
"世界草稿、预览和发布门禁已同步刷新。",
input.submitted_at_micros,
)?;
Ok(build_custom_world_agent_operation_snapshot(&operation))
}
fn current_custom_world_draft_profile(
session: &CustomWorldAgentSession,
) -> JsonMap<String, JsonValue> {
ensure_minimal_draft_profile(
parse_optional_session_object(session.draft_profile_json.as_deref()).unwrap_or_default(),
&session.seed_text,
)
}
fn persist_custom_world_draft_profile_update(
ctx: &ReducerContext,
session: &CustomWorldAgentSession,
draft_profile: JsonMap<String, JsonValue>,
updated_at_micros: i64,
stage: RpgAgentStage,
assistant_reply: String,
checkpoint_suffix: &str,
checkpoint_label: &str,
) -> Result<(), String> {
let gate = summarize_publish_gate_from_json(
&session.session_id,
stage,
Some(&draft_profile),
&parse_json_array_or_empty(&session.quality_findings_json),
);
let next_session = rebuild_custom_world_agent_session_row(
session,
CustomWorldAgentSessionPatch {
stage: Some(stage),
draft_profile_json: Some(Some(serialize_json_value(&JsonValue::Object(
draft_profile.clone(),
))?)),
publish_gate_json: Some(Some(serialize_json_value(&publish_gate_to_json_value(
&gate,
))?)),
result_preview_json: Some(build_result_preview_json(
Some(&draft_profile),
&gate,
&parse_json_array_or_empty(&session.quality_findings_json),
updated_at_micros,
)?),
checkpoints_json: Some(append_checkpoint_json(
&session.checkpoints_json,
&build_session_checkpoint_value(checkpoint_suffix, checkpoint_label, session),
)?),
last_assistant_reply: Some(Some(assistant_reply)),
updated_at_micros: Some(updated_at_micros),
..CustomWorldAgentSessionPatch::default()
},
)?;
replace_custom_world_agent_session(ctx, session, next_session);
Ok(())
}
fn upsert_draft_profile_array_from_payload(
draft_profile: &mut JsonMap<String, JsonValue>,
payload: &JsonMap<String, JsonValue>,
payload_key: &str,
profile_key: &str,
id_prefix: &str,
card_kind: RpgAgentDraftCardKind,
ctx: &ReducerContext,
session_id: &str,
updated_at_micros: i64,
) -> Result<u32, String> {
let payload_items = payload
.get(payload_key)
.and_then(JsonValue::as_array)
.cloned()
.unwrap_or_else(|| {
draft_profile
.get(profile_key)
.and_then(JsonValue::as_array)
.cloned()
.unwrap_or_default()
});
if payload_items.is_empty() {
return Ok(0);
}
let mut merged = draft_profile
.get(profile_key)
.and_then(JsonValue::as_array)
.cloned()
.unwrap_or_default();
let mut inserted = 0u32;
for (index, item) in payload_items.into_iter().enumerate() {
let Some(mut object) = item.as_object().cloned() else {
continue;
};
let id = read_optional_text_field(&object, &["id"])
.unwrap_or_else(|| format!("{id_prefix}-{}-{}", session_id, index + 1));
object.insert("id".to_string(), JsonValue::String(id.clone()));
let value = JsonValue::Object(object.clone());
upsert_json_array_object_by_id(&mut merged, value);
upsert_custom_world_entity_card(
ctx,
session_id,
card_kind,
&id,
&object,
updated_at_micros,
)?;
inserted = inserted.saturating_add(1);
}
draft_profile.insert(profile_key.to_string(), JsonValue::Array(merged));
Ok(inserted)
}
fn upsert_json_array_object_by_id(items: &mut Vec<JsonValue>, next: JsonValue) {
let Some(next_id) = next
.get("id")
.and_then(JsonValue::as_str)
.map(ToOwned::to_owned)
else {
items.push(next);
return;
};
if let Some(existing) = items
.iter_mut()
.find(|entry| entry.get("id").and_then(JsonValue::as_str) == Some(next_id.as_str()))
{
*existing = next;
} else {
items.push(next);
}
}
fn upsert_custom_world_entity_card(
ctx: &ReducerContext,
session_id: &str,
kind: RpgAgentDraftCardKind,
entity_id: &str,
object: &JsonMap<String, JsonValue>,
updated_at_micros: i64,
) -> Result<(), String> {
let card_id = format!(
"custom-world:{}:{}:{}",
session_id,
kind.as_str(),
entity_id
);
let title = read_optional_text_field(object, &["name", "title"])
.unwrap_or_else(|| entity_id.to_string());
let subtitle =
read_optional_text_field(object, &["role", "subtitle", "purpose"]).unwrap_or_default();
let summary = read_optional_text_field(
object,
&["summary", "notes", "publicGoal", "description", "mood"],
)
.unwrap_or_else(|| title.clone());
let detail_payload_json = serialize_json_value(&json!({
"id": card_id,
"entityId": entity_id,
"kind": kind.as_str(),
"title": title,
"sections": [
{ "id": "title", "label": "标题", "value": title },
{ "id": "subtitle", "label": "副标题", "value": subtitle },
{ "id": "summary", "label": "摘要", "value": summary },
],
"linkedIds": [entity_id],
"locked": false,
"editable": false,
"editableSectionIds": [],
"warningMessages": [],
}))?;
let existing = ctx
.db
.custom_world_draft_card()
.card_id()
.find(&card_id)
.filter(|row| row.session_id == session_id);
let next = CustomWorldDraftCard {
card_id: card_id.clone(),
session_id: session_id.to_string(),
kind,
status: RpgAgentDraftCardStatus::Suggested,
title,
subtitle,
summary,
linked_ids_json: serialize_json_value(&json!([entity_id]))?,
warning_count: 0,
asset_status: None,
asset_status_label: None,
detail_payload_json: Some(detail_payload_json),
created_at: existing
.as_ref()
.map(|row| row.created_at)
.unwrap_or_else(|| Timestamp::from_micros_since_unix_epoch(updated_at_micros)),
updated_at: Timestamp::from_micros_since_unix_epoch(updated_at_micros),
};
if let Some(existing) = existing {
replace_custom_world_draft_card(ctx, &existing, next);
} else {
ctx.db.custom_world_draft_card().insert(next);
}
Ok(())
}
fn build_role_asset_coverage_json(
session: &CustomWorldAgentSession,
payload: &JsonMap<String, JsonValue>,
generated: bool,
) -> Result<String, String> {
let mut coverage = parse_optional_session_object(Some(&session.asset_coverage_json))
.unwrap_or_else(JsonMap::new);
let profile = current_custom_world_draft_profile(session);
let mut role_assets = payload
.get("roleAssets")
.and_then(JsonValue::as_array)
.cloned()
.unwrap_or_else(|| build_role_asset_entries_from_profile(&profile, generated));
if role_assets.is_empty() {
role_assets = build_role_asset_entries_from_profile(&profile, generated);
}
let all_ready = !role_assets.is_empty()
&& role_assets
.iter()
.all(|entry| asset_entry_ready(entry, &["visualReady", "animationsReady"]));
coverage.insert("roleAssets".to_string(), JsonValue::Array(role_assets));
coverage.insert("allRoleAssetsReady".to_string(), JsonValue::Bool(all_ready));
coverage
.entry("sceneAssets".to_string())
.or_insert_with(|| JsonValue::Array(Vec::new()));
coverage
.entry("allSceneAssetsReady".to_string())
.or_insert_with(|| JsonValue::Bool(false));
serialize_json_value(&JsonValue::Object(coverage))
}
fn build_scene_asset_coverage_json(
session: &CustomWorldAgentSession,
payload: &JsonMap<String, JsonValue>,
generated: bool,
) -> Result<String, String> {
let mut coverage = parse_optional_session_object(Some(&session.asset_coverage_json))
.unwrap_or_else(JsonMap::new);
let profile = current_custom_world_draft_profile(session);
let mut scene_assets = payload
.get("sceneAssets")
.and_then(JsonValue::as_array)
.cloned()
.unwrap_or_else(|| build_scene_asset_entries_from_profile(&profile, generated));
if scene_assets.is_empty() {
scene_assets = build_scene_asset_entries_from_profile(&profile, generated);
}
let all_ready = !scene_assets.is_empty()
&& scene_assets
.iter()
.all(|entry| asset_entry_ready(entry, &["visualReady", "synced"]));
coverage.insert("sceneAssets".to_string(), JsonValue::Array(scene_assets));
coverage.insert(
"allSceneAssetsReady".to_string(),
JsonValue::Bool(all_ready),
);
coverage
.entry("roleAssets".to_string())
.or_insert_with(|| JsonValue::Array(Vec::new()));
coverage
.entry("allRoleAssetsReady".to_string())
.or_insert_with(|| JsonValue::Bool(false));
serialize_json_value(&JsonValue::Object(coverage))
}
fn build_role_asset_entries_from_profile(
profile: &JsonMap<String, JsonValue>,
generated: bool,
) -> Vec<JsonValue> {
collect_profile_entities(profile, &["playableNpcs", "storyNpcs"])
.into_iter()
.map(|entry| {
let id = entry
.get("id")
.and_then(JsonValue::as_str)
.unwrap_or("role");
json!({
"roleId": id,
"name": read_optional_text_field(&entry, &["name", "title"]).unwrap_or_else(|| id.to_string()),
"visualReady": generated,
"animationsReady": !generated,
})
})
.collect()
}
fn build_scene_asset_entries_from_profile(
profile: &JsonMap<String, JsonValue>,
generated: bool,
) -> Vec<JsonValue> {
collect_profile_entities(profile, &["landmarks", "sceneChapters", "sceneChapterBlueprints"])
.into_iter()
.map(|entry| {
let id = entry
.get("id")
.and_then(JsonValue::as_str)
.unwrap_or("scene");
json!({
"sceneId": id,
"name": read_optional_text_field(&entry, &["name", "title"]).unwrap_or_else(|| id.to_string()),
"visualReady": generated,
"synced": !generated,
})
})
.collect()
}
fn collect_profile_entities(
profile: &JsonMap<String, JsonValue>,
keys: &[&str],
) -> Vec<JsonMap<String, JsonValue>> {
let mut result = Vec::new();
for key in keys {
if let Some(entries) = profile.get(*key).and_then(JsonValue::as_array) {
for entry in entries {
if let Some(object) = entry.as_object() {
result.push(object.clone());
}
}
}
}
result
}
fn asset_entry_ready(entry: &JsonValue, keys: &[&str]) -> bool {
keys.iter().all(|key| {
entry
.get(*key)
.and_then(JsonValue::as_bool)
.unwrap_or(false)
})
}
fn update_role_asset_cards(
ctx: &ReducerContext,
session_id: &str,
status: CustomWorldRoleAssetStatus,
label: &str,
updated_at_micros: i64,
) {
for card in
ctx.db.custom_world_draft_card().iter().filter(|row| {
row.session_id == session_id && row.kind == RpgAgentDraftCardKind::Character
})
{
replace_custom_world_draft_card(
ctx,
&card,
CustomWorldDraftCard {
card_id: card.card_id.clone(),
session_id: card.session_id.clone(),
kind: card.kind,
status: card.status,
title: card.title.clone(),
subtitle: card.subtitle.clone(),
summary: card.summary.clone(),
linked_ids_json: card.linked_ids_json.clone(),
warning_count: card.warning_count,
asset_status: Some(status),
asset_status_label: Some(label.to_string()),
detail_payload_json: card.detail_payload_json.clone(),
created_at: card.created_at,
updated_at: Timestamp::from_micros_since_unix_epoch(updated_at_micros),
},
);
}
}
fn merge_long_tail_payload(
draft_profile: &mut JsonMap<String, JsonValue>,
payload: &JsonMap<String, JsonValue>,
) {
for key in [
"coreConflicts",
"chapters",
"sceneChapters",
"sceneChapterBlueprints",
"sidequestSeeds",
"carrierHooks",
] {
if let Some(entries) = payload.get(key).and_then(JsonValue::as_array) {
let mut merged = draft_profile
.get(key)
.and_then(JsonValue::as_array)
.cloned()
.unwrap_or_default();
for entry in entries {
if let Some(object) = entry.as_object() {
upsert_json_array_object_by_id(&mut merged, JsonValue::Object(object.clone()));
} else if !merged.contains(entry) {
merged.push(entry.clone());
}
}
draft_profile.insert(key.to_string(), JsonValue::Array(merged));
}
}
for key in ["worldHook", "playerPremise", "summary", "subtitle"] {
if let Some(value) = payload
.get(key)
.and_then(JsonValue::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
{
draft_profile.insert(key.to_string(), JsonValue::String(value.to_string()));
}
}
}
#[derive(Clone, Debug, Default)] #[derive(Clone, Debug, Default)]
struct CustomWorldAgentSessionPatch { struct CustomWorldAgentSessionPatch {
current_turn: Option<u32>, current_turn: Option<u32>,
@@ -3310,24 +4044,6 @@ fn ensure_publishable_stage(stage: RpgAgentStage, action: &str) -> Result<(), St
ensure_long_tail_stage(stage, action) ensure_long_tail_stage(stage, action)
} }
fn map_action_name_to_operation_type(action: &str) -> Option<RpgAgentOperationType> {
match action {
"draft_foundation" => Some(RpgAgentOperationType::DraftFoundation),
"update_draft_card" => Some(RpgAgentOperationType::UpdateDraftCard),
"sync_result_profile" => Some(RpgAgentOperationType::SyncResultProfile),
"generate_characters" => Some(RpgAgentOperationType::GenerateCharacters),
"generate_landmarks" => Some(RpgAgentOperationType::GenerateLandmarks),
"generate_role_assets" => Some(RpgAgentOperationType::GenerateRoleAssets),
"sync_role_assets" => Some(RpgAgentOperationType::SyncRoleAssets),
"generate_scene_assets" => Some(RpgAgentOperationType::GenerateSceneAssets),
"sync_scene_assets" => Some(RpgAgentOperationType::SyncSceneAssets),
"expand_long_tail" => Some(RpgAgentOperationType::ExpandLongTail),
"publish_world" => Some(RpgAgentOperationType::PublishWorld),
"revert_checkpoint" => Some(RpgAgentOperationType::RevertCheckpoint),
_ => None,
}
}
fn parse_rpg_agent_stage(value: &str) -> Option<RpgAgentStage> { fn parse_rpg_agent_stage(value: &str) -> Option<RpgAgentStage> {
match value.trim() { match value.trim() {
"collecting_intent" => Some(RpgAgentStage::CollectingIntent), "collecting_intent" => Some(RpgAgentStage::CollectingIntent),

View File

@@ -0,0 +1,7 @@
[package]
name = "tests-support"
edition.workspace = true
version.workspace = true
license.workspace = true
[dependencies]

View File

@@ -1,18 +1,21 @@
# tests-support 共享 crate 占位说明 # tests-support 共享测试支撑 crate
日期:`2026-04-20` 日期:`2026-04-20`
## 1. crate 职责 ## 1. crate 职责
`tests-support` 是测试支撑共享 crate后续负责: `tests-support` 是测试支撑共享 crate当前已作为 `server-rs` workspace member 落位,负责承接跨 crate 复用的测试辅助能力。
1. contract、integration、smoke 测试的共享夹具与辅助工具 当前首版只放无业务规则的 smoke/HTTP 通用断言:
2. 测试环境配置、测试数据装配与断言工具
3. `crates/api-server``crates/spacetime-module` 与各模块 crate 复用的测试基础设施能力 1. Maincloud healthz 默认地址常量
2. smoke URL 空值与尾斜杠归一化
3. HTTP 2xx 状态码断言
4. healthz 非空响应体断言
## 2. 当前阶段说明 ## 2. 当前阶段说明
当前提交仅完成目录占位,不提前进入测试夹具、断言工具与 smoke 支撑实现 当前阶段不提前引入伪环境、不编造业务夹具,也不承接 contract DTO 或 SpacetimeDB reducer 的测试数据装配
后续与本 crate 直接相关的任务包括: 后续与本 crate 直接相关的任务包括:
@@ -26,3 +29,4 @@
1. `tests-support` 只承接测试支撑能力,不承接业务规则实现。 1. `tests-support` 只承接测试支撑能力,不承接业务规则实现。
2. 测试夹具要尽量贴近真实 contract 与真实模块边界,避免重新引入脱离现网的伪环境。 2. 测试夹具要尽量贴近真实 contract 与真实模块边界,避免重新引入脱离现网的伪环境。
3. 不允许把测试辅助逻辑散落到各模块 crate 中重复实现。 3. 不允许把测试辅助逻辑散落到各模块 crate 中重复实现。
4. SpacetimeDB 表、reducer、procedure 和迁移规则仍归 `spacetime-module``WP-ST`,本 crate 不定义 schema。

View File

@@ -0,0 +1,92 @@
use std::fmt;
pub const DEFAULT_MAINCLOUD_HEALTHZ_URL: &str = "http://127.0.0.1:3100/healthz";
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SmokeAssertionError {
message: String,
}
impl SmokeAssertionError {
/// 测试支撑 crate 只提供断言辅助,不承接业务错误分类。
pub fn new(message: impl Into<String>) -> Self {
Self {
message: message.into(),
}
}
pub fn message(&self) -> &str {
&self.message
}
}
impl fmt::Display for SmokeAssertionError {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.write_str(&self.message)
}
}
impl std::error::Error for SmokeAssertionError {}
/// 归一化本地 smoke URL供不同测试入口复用同一套空值与斜杠处理口径。
pub fn normalize_smoke_url(input: impl AsRef<str>) -> String {
let trimmed = input.as_ref().trim();
if trimmed.is_empty() {
return DEFAULT_MAINCLOUD_HEALTHZ_URL.to_string();
}
trimmed.trim_end_matches('/').to_string()
}
/// 断言 HTTP 状态码处于 2xx避免 smoke 测试散落重复判断。
pub fn assert_success_status(status: u16) -> Result<(), SmokeAssertionError> {
if (200..=299).contains(&status) {
return Ok(());
}
Err(SmokeAssertionError::new(format!(
"期望 HTTP 2xx 状态码,实际为 {status}"
)))
}
/// 断言 healthz 响应体非空。具体 JSON 字段语义仍归 api-server 自己的 contract 测试负责。
pub fn assert_non_empty_healthz_body(body: impl AsRef<str>) -> Result<(), SmokeAssertionError> {
if body.as_ref().trim().is_empty() {
return Err(SmokeAssertionError::new("healthz 响应体不能为空"));
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn normalize_smoke_url_uses_maincloud_healthz_when_empty() {
assert_eq!(
normalize_smoke_url(" "),
DEFAULT_MAINCLOUD_HEALTHZ_URL.to_string()
);
}
#[test]
fn normalize_smoke_url_removes_trailing_slash() {
assert_eq!(
normalize_smoke_url(" http://127.0.0.1:3100/ "),
"http://127.0.0.1:3100"
);
}
#[test]
fn assert_success_status_rejects_non_2xx() {
let error = assert_success_status(503).expect_err("非 2xx 状态码必须失败");
assert_eq!(error.message(), "期望 HTTP 2xx 状态码,实际为 503");
}
#[test]
fn assert_non_empty_healthz_body_rejects_blank_body() {
let error = assert_non_empty_healthz_body(" \n ").expect_err("空响应体必须失败");
assert_eq!(error.message(), "healthz 响应体不能为空");
}
}