Close DDD cleanup and tests-support closure
This commit is contained in:
@@ -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 或前端行为。
|
||||||
|
|||||||
@@ -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/procedure;LLM、资产生成和 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` 只完成首批类型契约和字段校验,profile、agent 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。
|
||||||
|
|||||||
@@ -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 编译通过。
|
||||||
@@ -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
|
||||||
|
```
|
||||||
|
|
||||||
|
结果:通过;上述源码不再命中 `过渡落位`。
|
||||||
@@ -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`、`最小兼容占位` 或 `过渡落位`。
|
||||||
@@ -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 个单元测试通过。
|
||||||
@@ -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 个单元测试通过。
|
||||||
@@ -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
4
server-rs/Cargo.lock
generated
@@ -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"
|
||||||
|
|||||||
@@ -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]
|
||||||
|
|||||||
@@ -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 本地测试入口。
|
||||||
|
|||||||
@@ -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 快照记录恢复认证快照失败");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
//! 资产领域事件过渡落位。
|
//! 资产领域事件。
|
||||||
//!
|
//!
|
||||||
//! 用于表达资产已确认、绑定已变更和资产历史投影待刷新等事实。
|
//! 用于表达资产已确认、绑定已变更和资产历史投影待刷新等事实。
|
||||||
//! 当前阶段暂不新增事件类型,避免在 SpacetimeDB 表未补 event table 前扩散未消费 API。
|
//! 当前阶段暂不新增事件类型,避免在 SpacetimeDB 表未补 event table 前扩散未消费 API。
|
||||||
|
|||||||
34
server-rs/crates/module-big-fish/README.md
Normal file
34
server-rs/crates/module-big-fish/README.md
Normal 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 承接。
|
||||||
@@ -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::*;
|
||||||
|
|||||||
@@ -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>,
|
||||||
|
}
|
||||||
|
|||||||
@@ -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",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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 {}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
//! 大鱼吃小鱼领域事件过渡落位。
|
//! 大鱼吃小鱼领域事件。
|
||||||
//!
|
//!
|
||||||
//! 用于表达草稿变化、资产槽变化和运行态 tick 等事实。
|
//! 用于表达草稿变化、资产槽变化和运行态 tick 等事实。
|
||||||
|
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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, ¤t.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, ¤t.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
|
||||||
|
),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
}
|
||||||
|
|||||||
@@ -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 {}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
}
|
||||||
|
|||||||
@@ -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, ¤t.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, ¤t.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 {
|
||||||
|
|||||||
@@ -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 直接相关的任务包括:
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
|||||||
@@ -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>,
|
||||||
|
}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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 {}
|
||||||
|
|||||||
@@ -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
@@ -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",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
|||||||
@@ -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 {}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
}
|
||||||
|
|||||||
@@ -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 {}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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. 边界约束
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
}
|
||||||
|
|||||||
@@ -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 {}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
//! 拼图领域模型过渡落位。
|
//! 拼图领域模型。
|
||||||
//!
|
//!
|
||||||
//! 后续迁移拼图 Agent 会话、作品 profile 和运行态聚合时,只保留玩法规则;
|
//! 后续迁移拼图 Agent 会话、作品 profile 和运行态聚合时,只保留玩法规则;
|
||||||
//! 图片生成、发布 HTTP shape 和排行榜适配留在外层。
|
//! 图片生成、发布 HTTP shape 和排行榜适配留在外层。
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
//! 拼图领域事件过渡落位。
|
//! 拼图领域事件。
|
||||||
//!
|
//!
|
||||||
//! 用于表达草稿变化、作品发布、运行态推进和排行榜候选产生等事实。
|
//! 用于表达草稿变化、作品发布、运行态推进和排行榜候选产生等事实。
|
||||||
|
|
||||||
|
|||||||
@@ -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(¤t.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,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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 {}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
}
|
||||||
|
|||||||
@@ -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(¤t.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 {
|
||||||
|
|||||||
@@ -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,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
}
|
||||||
|
|||||||
@@ -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 {}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
}
|
||||||
|
|||||||
@@ -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() {
|
||||||
|
|||||||
@@ -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)。
|
||||||
|
|||||||
@@ -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")
|
||||||
|
}
|
||||||
|
|||||||
@@ -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())
|
||||||
|
}
|
||||||
|
|||||||
@@ -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>,
|
||||||
|
}
|
||||||
|
|||||||
@@ -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 {}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|||||||
@@ -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")
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
//! 运行时应用编排过渡落位。
|
//! 运行时应用编排。
|
||||||
//!
|
//!
|
||||||
//! 这里只返回运行时快照、个人页投影和领域事件,不直接访问外部 adapter。
|
//! 这里只返回运行时快照、个人页投影和领域事件,不直接访问外部 adapter。
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
//! 运行时写入命令过渡落位。
|
//! 运行时写入命令。
|
||||||
//!
|
//!
|
||||||
//! 用于表达保存快照、更新设置、写入浏览历史、调整钱包和保存存档等输入。
|
//! 用于表达保存快照、更新设置、写入浏览历史、调整钱包和保存存档等输入。
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
//! 运行时领域错误过渡落位。
|
//! 运行时领域错误。
|
||||||
//!
|
//!
|
||||||
//! 错误保持运行时业务语义,例如快照版本非法、兑换码不可用或钱包余额不足。
|
//! 错误保持运行时业务语义,例如快照版本非法、兑换码不可用或钱包余额不足。
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,3 @@
|
|||||||
//! 运行时领域事件过渡落位。
|
//! 运行时领域事件。
|
||||||
//!
|
//!
|
||||||
//! 用于表达快照已保存、设置已更新、钱包已记账和存档已变化等事实。
|
//! 用于表达快照已保存、设置已更新、钱包已记账和存档已变化等事实。
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
7
server-rs/crates/tests-support/Cargo.toml
Normal file
7
server-rs/crates/tests-support/Cargo.toml
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
[package]
|
||||||
|
name = "tests-support"
|
||||||
|
edition.workspace = true
|
||||||
|
version.workspace = true
|
||||||
|
license.workspace = true
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
@@ -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。
|
||||||
|
|||||||
92
server-rs/crates/tests-support/src/lib.rs
Normal file
92
server-rs/crates/tests-support/src/lib.rs
Normal 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 响应体不能为空");
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user