From fd08262bf0a7b5080b542957d62d9c3949c04dbb Mon Sep 17 00:00:00 2001 From: kdletters Date: Thu, 30 Apr 2026 16:15:05 +0800 Subject: [PATCH] Close DDD cleanup and tests-support closure --- docs/technical/README.md | 6 + ...VER_RS_DDD_PARALLEL_TASKLIST_2026-04-29.md | 197 ++- ..._TESTS_SUPPORT_CRATE_CLOSURE_2026-04-30.md | 30 + ...D_WP_BF_AND_G2_DRIFT_CLEANUP_2026-04-30.md | 34 + ...P_CW_ACTION_AND_DOMAIN_SPLIT_2026-04-30.md | 43 + ...WP_RPG_GAMEPLAY_DOMAIN_SPLIT_2026-04-30.md | 44 + ...RPG_PROGRESSION_DOMAIN_SPLIT_2026-04-30.md | 39 + ...S_RUNTIME_STORY_DOMAIN_SPLIT_2026-04-30.md | 38 + server-rs/Cargo.lock | 4 + server-rs/Cargo.toml | 1 + server-rs/README.md | 2 +- server-rs/crates/api-server/src/state.rs | 10 +- server-rs/crates/module-assets/src/events.rs | 2 +- server-rs/crates/module-big-fish/README.md | 34 + .../crates/module-big-fish/src/application.rs | 528 +++++- .../crates/module-big-fish/src/commands.rs | 144 +- .../crates/module-big-fish/src/domain.rs | 291 ++- .../crates/module-big-fish/src/errors.rs | 33 +- .../crates/module-big-fish/src/events.rs | 2 +- server-rs/crates/module-big-fish/src/lib.rs | 989 +---------- .../crates/module-combat/src/application.rs | 290 ++- .../crates/module-combat/src/commands.rs | 169 +- server-rs/crates/module-combat/src/domain.rs | 39 +- server-rs/crates/module-combat/src/errors.rs | 49 +- server-rs/crates/module-combat/src/events.rs | 33 +- server-rs/crates/module-combat/src/lib.rs | 525 +----- .../crates/module-custom-world/README.md | 41 +- .../module-custom-world/src/application.rs | 958 +++++++++- .../module-custom-world/src/commands.rs | 282 ++- .../crates/module-custom-world/src/domain.rs | 247 ++- .../crates/module-custom-world/src/errors.rs | 99 +- .../crates/module-custom-world/src/events.rs | 67 +- .../crates/module-custom-world/src/lib.rs | 1572 +---------------- .../module-inventory/src/application.rs | 563 +++++- .../crates/module-inventory/src/commands.rs | 60 +- .../crates/module-inventory/src/domain.rs | 111 +- .../crates/module-inventory/src/errors.rs | 57 +- .../crates/module-inventory/src/events.rs | 40 +- server-rs/crates/module-inventory/src/lib.rs | 767 +------- .../crates/module-npc/src/application.rs | 554 +++++- server-rs/crates/module-npc/src/commands.rs | 50 +- server-rs/crates/module-npc/src/domain.rs | 101 +- server-rs/crates/module-npc/src/errors.rs | 37 +- server-rs/crates/module-npc/src/events.rs | 50 +- server-rs/crates/module-npc/src/lib.rs | 721 +------- server-rs/crates/module-progression/README.md | 18 +- .../module-progression/src/application.rs | 387 +++- .../crates/module-progression/src/commands.rs | 75 +- .../crates/module-progression/src/domain.rs | 161 +- .../crates/module-progression/src/errors.rs | 41 +- .../crates/module-progression/src/events.rs | 47 +- .../crates/module-progression/src/lib.rs | 624 +------ server-rs/crates/module-puzzle/src/domain.rs | 2 +- server-rs/crates/module-puzzle/src/events.rs | 2 +- .../crates/module-quest/src/application.rs | 500 +++++- server-rs/crates/module-quest/src/commands.rs | 84 +- server-rs/crates/module-quest/src/domain.rs | 284 ++- server-rs/crates/module-quest/src/errors.rs | 73 +- server-rs/crates/module-quest/src/events.rs | 41 +- server-rs/crates/module-quest/src/lib.rs | 913 +--------- .../module-runtime-item/src/application.rs | 176 +- .../module-runtime-item/src/commands.rs | 28 +- .../crates/module-runtime-item/src/domain.rs | 73 +- .../crates/module-runtime-item/src/errors.rs | 61 +- .../crates/module-runtime-item/src/events.rs | 31 +- .../crates/module-runtime-item/src/lib.rs | 317 +--- .../crates/module-runtime-story/README.md | 15 +- .../module-runtime-story/src/application.rs | 55 + .../module-runtime-story/src/commands.rs | 15 +- .../crates/module-runtime-story/src/domain.rs | 42 +- .../crates/module-runtime-story/src/errors.rs | 25 +- .../crates/module-runtime-story/src/events.rs | 21 +- .../crates/module-runtime-story/src/lib.rs | 102 +- .../crates/module-runtime/src/application.rs | 2 +- .../crates/module-runtime/src/commands.rs | 2 +- server-rs/crates/module-runtime/src/errors.rs | 2 +- server-rs/crates/module-runtime/src/events.rs | 2 +- .../spacetime-module/src/custom_world/mod.rs | 788 ++++++++- server-rs/crates/tests-support/Cargo.toml | 7 + server-rs/crates/tests-support/README.md | 16 +- server-rs/crates/tests-support/src/lib.rs | 92 + 81 files changed, 8415 insertions(+), 6662 deletions(-) create mode 100644 docs/technical/SERVER_RS_DDD_TESTS_SUPPORT_CRATE_CLOSURE_2026-04-30.md create mode 100644 docs/technical/SERVER_RS_DDD_WP_BF_AND_G2_DRIFT_CLEANUP_2026-04-30.md create mode 100644 docs/technical/SERVER_RS_DDD_WP_CW_ACTION_AND_DOMAIN_SPLIT_2026-04-30.md create mode 100644 docs/technical/SERVER_RS_DDD_WP_RPG_GAMEPLAY_DOMAIN_SPLIT_2026-04-30.md create mode 100644 docs/technical/SERVER_RS_DDD_WP_RPG_PROGRESSION_DOMAIN_SPLIT_2026-04-30.md create mode 100644 docs/technical/SERVER_RS_DDD_WP_RS_RUNTIME_STORY_DOMAIN_SPLIT_2026-04-30.md create mode 100644 server-rs/crates/module-big-fish/README.md create mode 100644 server-rs/crates/tests-support/Cargo.toml create mode 100644 server-rs/crates/tests-support/src/lib.rs diff --git a/docs/technical/README.md b/docs/technical/README.md index a5ee9ba6..97776f29 100644 --- a/docs/technical/README.md +++ b/docs/technical/README.md @@ -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_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/*` 请求路径。 @@ -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_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_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_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 或前端行为。 diff --git a/docs/technical/SERVER_RS_DDD_PARALLEL_TASKLIST_2026-04-29.md b/docs/technical/SERVER_RS_DDD_PARALLEL_TASKLIST_2026-04-29.md index 9dbe4742..0fa4d580 100644 --- a/docs/technical/SERVER_RS_DDD_PARALLEL_TASKLIST_2026-04-29.md +++ b/docs/technical/SERVER_RS_DDD_PARALLEL_TASKLIST_2026-04-29.md @@ -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-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-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-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-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 | 已完成;运行态真相源和 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-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-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-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-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` 顶层 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 根入口瘦身、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-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` | @@ -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-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-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-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` | `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-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-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-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` 顶层 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 最小兼容占位已关闭,真实动作改为 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` 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-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-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-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-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-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 功能链,但阻塞最终“无迁移期口径”验收 | -| `tests-support` | 仍只是目录占位,不是 workspace crate | `server-rs/crates/tests-support/README.md` 写明“当前提交仅完成目录占位”;目录下无 `Cargo.toml` 和 Rust 源文件 | 到 `WP-V` 前若需要共享测试夹具,则补真实 crate;如果最终无使用场景,改成明确文档占位或删除 | P2;依赖最终测试策略 | +| `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/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. 工作包边界细则 @@ -2320,3 +2321,171 @@ cargo check -p module-story --manifest-path server-rs\Cargo.toml ``` 结果:通过,`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。 diff --git a/docs/technical/SERVER_RS_DDD_TESTS_SUPPORT_CRATE_CLOSURE_2026-04-30.md b/docs/technical/SERVER_RS_DDD_TESTS_SUPPORT_CRATE_CLOSURE_2026-04-30.md new file mode 100644 index 00000000..3174facf --- /dev/null +++ b/docs/technical/SERVER_RS_DDD_TESTS_SUPPORT_CRATE_CLOSURE_2026-04-30.md @@ -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 编译通过。 diff --git a/docs/technical/SERVER_RS_DDD_WP_BF_AND_G2_DRIFT_CLEANUP_2026-04-30.md b/docs/technical/SERVER_RS_DDD_WP_BF_AND_G2_DRIFT_CLEANUP_2026-04-30.md new file mode 100644 index 00000000..fce61402 --- /dev/null +++ b/docs/technical/SERVER_RS_DDD_WP_BF_AND_G2_DRIFT_CLEANUP_2026-04-30.md @@ -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 +``` + +结果:通过;上述源码不再命中 `过渡落位`。 diff --git a/docs/technical/SERVER_RS_DDD_WP_CW_ACTION_AND_DOMAIN_SPLIT_2026-04-30.md b/docs/technical/SERVER_RS_DDD_WP_CW_ACTION_AND_DOMAIN_SPLIT_2026-04-30.md new file mode 100644 index 00000000..7fd4ca28 --- /dev/null +++ b/docs/technical/SERVER_RS_DDD_WP_CW_ACTION_AND_DOMAIN_SPLIT_2026-04-30.md @@ -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`、`最小兼容占位` 或 `过渡落位`。 diff --git a/docs/technical/SERVER_RS_DDD_WP_RPG_GAMEPLAY_DOMAIN_SPLIT_2026-04-30.md b/docs/technical/SERVER_RS_DDD_WP_RPG_GAMEPLAY_DOMAIN_SPLIT_2026-04-30.md new file mode 100644 index 00000000..03fd57c0 --- /dev/null +++ b/docs/technical/SERVER_RS_DDD_WP_RPG_GAMEPLAY_DOMAIN_SPLIT_2026-04-30.md @@ -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 个单元测试通过。 diff --git a/docs/technical/SERVER_RS_DDD_WP_RPG_PROGRESSION_DOMAIN_SPLIT_2026-04-30.md b/docs/technical/SERVER_RS_DDD_WP_RPG_PROGRESSION_DOMAIN_SPLIT_2026-04-30.md new file mode 100644 index 00000000..5d46aa18 --- /dev/null +++ b/docs/technical/SERVER_RS_DDD_WP_RPG_PROGRESSION_DOMAIN_SPLIT_2026-04-30.md @@ -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 个单元测试通过。 diff --git a/docs/technical/SERVER_RS_DDD_WP_RS_RUNTIME_STORY_DOMAIN_SPLIT_2026-04-30.md b/docs/technical/SERVER_RS_DDD_WP_RS_RUNTIME_STORY_DOMAIN_SPLIT_2026-04-30.md new file mode 100644 index 00000000..b119ff7c --- /dev/null +++ b/docs/technical/SERVER_RS_DDD_WP_RS_RUNTIME_STORY_DOMAIN_SPLIT_2026-04-30.md @@ -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 个单元测试通过。 diff --git a/server-rs/Cargo.lock b/server-rs/Cargo.lock index de53fc36..39a92dd4 100644 --- a/server-rs/Cargo.lock +++ b/server-rs/Cargo.lock @@ -3066,6 +3066,10 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "tests-support" +version = "0.1.0" + [[package]] name = "thiserror" version = "1.0.69" diff --git a/server-rs/Cargo.toml b/server-rs/Cargo.toml index acb48fab..919a86ac 100644 --- a/server-rs/Cargo.toml +++ b/server-rs/Cargo.toml @@ -31,6 +31,7 @@ members = [ "crates/shared-logging", "crates/spacetime-client", "crates/spacetime-module", + "crates/tests-support", ] [workspace.package] diff --git a/server-rs/README.md b/server-rs/README.md index 61a16c62..f0d15e65 100644 --- a/server-rs/README.md +++ b/server-rs/README.md @@ -40,7 +40,7 @@ 22. 创建 `crates/platform-oss/` 目录占位,固定 OSS 平台适配 crate 落位。 23. 创建 `crates/platform-llm/` 目录占位,固定大模型平台适配 crate 落位。 24. 创建 `crates/spacetime-client/` 目录占位,固定 SpacetimeDB 客户端适配 crate 落位。 -25. 创建 `crates/tests-support/` 目录占位,固定测试支撑共享 crate 落位。 +25. 创建 `crates/tests-support/` 共享测试支撑 crate,固定 smoke/contract 测试辅助能力落位。 26. 创建 `scripts/dev.ps1`,固定 Windows 本地开发入口。 27. 创建 `scripts/dev.sh`,固定 Unix-like 本地开发入口。 28. 创建 `scripts/test.ps1`,固定 Windows 本地测试入口。 diff --git a/server-rs/crates/api-server/src/state.rs b/server-rs/crates/api-server/src/state.rs index b5746284..232ecb3a 100644 --- a/server-rs/crates/api-server/src/state.rs +++ b/server-rs/crates/api-server/src/state.rs @@ -227,7 +227,7 @@ impl AppState { self.spacetime_client .upsert_auth_store_snapshot(snapshot_json, updated_at_micros) .await?; - // ????????????????????????????????? + // 写入 SpacetimeDB 后立刻回读一次,确保内存快照与表真相对齐。 #[cfg(not(test))] self.spacetime_client.import_auth_store_snapshot().await?; #[cfg(not(test))] @@ -252,13 +252,13 @@ impl AppState { if !snapshot_json.trim().is_empty() { let auth_store = InMemoryAuthStore::from_snapshot_json(&snapshot_json) .map_err(AppStateInitError::AuthStore)?; - info!("?? SpacetimeDB ???????????"); + info!("已从 SpacetimeDB 表恢复认证快照"); return Self::new_with_auth_store(config, auth_store); } } } Err(error) => { - warn!(error = %error, "? SpacetimeDB ????????????????"); + warn!(error = %error, "从 SpacetimeDB 表恢复认证快照失败"); } } @@ -268,13 +268,13 @@ impl AppState { if !snapshot_json.trim().is_empty() { let auth_store = InMemoryAuthStore::from_snapshot_json(&snapshot_json) .map_err(AppStateInitError::AuthStore)?; - info!("?? SpacetimeDB ???????????"); + info!("已从 SpacetimeDB 快照记录恢复认证快照"); return Self::new_with_auth_store(config, auth_store); } } } Err(error) => { - warn!(error = %error, "? SpacetimeDB ?????????????????"); + warn!(error = %error, "从 SpacetimeDB 快照记录恢复认证快照失败"); } } diff --git a/server-rs/crates/module-assets/src/events.rs b/server-rs/crates/module-assets/src/events.rs index 47c4b000..7a4cc348 100644 --- a/server-rs/crates/module-assets/src/events.rs +++ b/server-rs/crates/module-assets/src/events.rs @@ -1,4 +1,4 @@ -//! 资产领域事件过渡落位。 +//! 资产领域事件。 //! //! 用于表达资产已确认、绑定已变更和资产历史投影待刷新等事实。 //! 当前阶段暂不新增事件类型,避免在 SpacetimeDB 表未补 event table 前扩散未消费 API。 diff --git a/server-rs/crates/module-big-fish/README.md b/server-rs/crates/module-big-fish/README.md new file mode 100644 index 00000000..3a35d080 --- /dev/null +++ b/server-rs/crates/module-big-fish/README.md @@ -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 承接。 diff --git a/server-rs/crates/module-big-fish/src/application.rs b/server-rs/crates/module-big-fish/src/application.rs index 3817db02..4b6cc670 100644 --- a/server-rs/crates/module-big-fish/src/application.rs +++ b/server-rs/crates/module-big-fish/src/application.rs @@ -1,20 +1,27 @@ -//! 大鱼吃小鱼应用编排过渡落位。 +//! 大鱼吃小鱼应用编排。 //! //! 这里只组合领域规则并返回结果或事件,不直接调用外部图片、视频或存储服务。 use shared_kernel::normalize_required_string; use crate::{ - BIG_FISH_DEFAULT_LEVEL_COUNT, BIG_FISH_TARGET_WILD_COUNT, BigFishAssetSlotSnapshot, - build_asset_coverage, commands::{ - EvaluateBigFishPublishReadinessCommand, StartBigFishRunCommand, SubmitBigFishInputCommand, + BigFishAssetGenerateInput, BigFishDraftCompileInput, BigFishInputSubmitInput, + BigFishMessageFinalizeInput, BigFishMessageSubmitInput, BigFishPlayRecordInput, + BigFishPublishInput, BigFishRunGetInput, BigFishRunStartInput, BigFishSessionCreateInput, + BigFishSessionGetInput, BigFishWorksListInput, EvaluateBigFishPublishReadinessCommand, + StartBigFishRunCommand, SubmitBigFishInputCommand, }, 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, - BigFishRuntimeSnapshot, BigFishVector2, + BigFishRuntimeParams, BigFishRuntimeSnapshot, BigFishVector2, }, - errors::BigFishApplicationError, + errors::{BigFishApplicationError, BigFishFieldError}, 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, + motion_key: Option, + asset_url: Option, + updated_at_micros: i64, +) -> Result { + 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 { + serde_json::to_string(anchor_pack) +} + +pub fn deserialize_anchor_pack(value: &str) -> Result { + serde_json::from_str(value) +} + +pub fn serialize_draft(draft: &BigFishGameDraft) -> Result { + serde_json::to_string(draft) +} + +pub fn deserialize_draft(value: &str) -> Result { + serde_json::from_str(value) +} + +pub fn serialize_asset_coverage( + coverage: &BigFishAssetCoverage, +) -> Result { + serde_json::to_string(coverage) +} + +pub fn deserialize_asset_coverage(value: &str) -> Result { + 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::>() + .into_iter() + .rev() + .collect(); + let threat_window = ((level + 1)..=(level + 2).min(level_count)).collect::>(); + 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, + motion_key: Option<&str>, +) -> Result { + 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, + 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, + 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, draft: &BigFishGameDraft) -> Result<(), BigFishFieldError> { + match level { + Some(value) if (1..=draft.runtime_params.level_count).contains(&value) => Ok(()), + _ => Err(BigFishFieldError::InvalidLevel), + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/server-rs/crates/module-big-fish/src/commands.rs b/server-rs/crates/module-big-fish/src/commands.rs index 0da0657c..7a381e0e 100644 --- a/server-rs/crates/module-big-fish/src/commands.rs +++ b/server-rs/crates/module-big-fish/src/commands.rs @@ -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 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, + pub error_message: Option, +} + +#[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, + pub assistant_reply_text: Option, + pub stage: BigFishCreationStage, + pub progress_percent: u32, + pub anchor_pack_json: String, + pub error_message: Option, + 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, + 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, + pub motion_key: Option, + pub asset_url: Option, + 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, + pub error_message: Option, +} diff --git a/server-rs/crates/module-big-fish/src/domain.rs b/server-rs/crates/module-big-fish/src/domain.rs index 7f41fd93..298292b9 100644 --- a/server-rs/crates/module-big-fish/src/domain.rs +++ b/server-rs/crates/module-big-fish/src/domain.rs @@ -1,12 +1,234 @@ -//! 大鱼吃小鱼领域模型过渡落位。 +//! 大鱼吃小鱼领域模型。 //! -//! 后续迁移创作会话、资产槽和运行态聚合时,只保留玩法状态与规则; -//! 图片生成、OSS 与 HTTP handler 均留在 adapter 层。 +//! 保留创作会话、资产槽、发布门禁和运行态聚合的纯领域结构;图片生成、OSS 与 HTTP handler 均留在 adapter 层。 use serde::{Deserialize, Serialize}; #[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, + pub prey_window: Vec, + pub threat_window: Vec, + 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, + pub threat_spawn_delta_levels: Vec, + 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, + 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, + pub motion_key: Option, + pub status: BigFishAssetStatus, + pub asset_url: Option, + 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, +} + +#[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, + pub asset_slots: Vec, + pub asset_coverage: BigFishAssetCoverage, + pub messages: Vec, + pub last_assistant_reply: Option, + 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, + pub error_message: Option, +} + +#[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, + 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", + } + } +} diff --git a/server-rs/crates/module-big-fish/src/errors.rs b/server-rs/crates/module-big-fish/src/errors.rs index 49d0afc7..8a585d4d 100644 --- a/server-rs/crates/module-big-fish/src/errors.rs +++ b/server-rs/crates/module-big-fish/src/errors.rs @@ -1,4 +1,4 @@ -//! 大鱼吃小鱼领域错误过渡落位。 +//! 大鱼吃小鱼领域错误。 //! //! 错误只表达玩法规则失败,由 HTTP 和 SpacetimeDB adapter 分别映射展示。 @@ -27,3 +27,34 @@ impl fmt::Display 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 {} diff --git a/server-rs/crates/module-big-fish/src/events.rs b/server-rs/crates/module-big-fish/src/events.rs index 17650477..825d962e 100644 --- a/server-rs/crates/module-big-fish/src/events.rs +++ b/server-rs/crates/module-big-fish/src/events.rs @@ -1,4 +1,4 @@ -//! 大鱼吃小鱼领域事件过渡落位。 +//! 大鱼吃小鱼领域事件。 //! //! 用于表达草稿变化、资产槽变化和运行态 tick 等事实。 diff --git a/server-rs/crates/module-big-fish/src/lib.rs b/server-rs/crates/module-big-fish/src/lib.rs index d567c189..ebf05717 100644 --- a/server-rs/crates/module-big-fish/src/lib.rs +++ b/server-rs/crates/module-big-fish/src/lib.rs @@ -4,990 +4,11 @@ mod domain; mod errors; mod events; -pub use application::{ - BigFishRuntimeResult, EvaluateBigFishPublishReadinessResult, deserialize_runtime_snapshot, - evaluate_publish_readiness, serialize_runtime_snapshot, start_big_fish_run, - submit_big_fish_input, -}; -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, - pub prey_window: Vec, - pub threat_window: Vec, - 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, - pub threat_spawn_delta_levels: Vec, - 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, - 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, - pub motion_key: Option, - pub status: BigFishAssetStatus, - pub asset_url: Option, - 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, -} - -#[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, - pub asset_slots: Vec, - pub asset_coverage: BigFishAssetCoverage, - pub messages: Vec, - pub last_assistant_reply: Option, - 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, - pub error_message: Option, -} - -#[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, - 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, - pub error_message: Option, -} - -#[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, - pub assistant_reply_text: Option, - pub stage: BigFishCreationStage, - pub progress_percent: u32, - pub anchor_pack_json: String, - pub error_message: Option, - 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, - 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, - pub motion_key: Option, - pub asset_url: Option, - 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, - pub error_message: Option, -} - -#[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, - motion_key: Option, - asset_url: Option, - updated_at_micros: i64, -) -> Result { - 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 { - serde_json::to_string(anchor_pack) -} - -pub fn deserialize_anchor_pack(value: &str) -> Result { - serde_json::from_str(value) -} - -pub fn serialize_draft(draft: &BigFishGameDraft) -> Result { - serde_json::to_string(draft) -} - -pub fn deserialize_draft(value: &str) -> Result { - serde_json::from_str(value) -} - -pub fn serialize_asset_coverage( - coverage: &BigFishAssetCoverage, -) -> Result { - serde_json::to_string(coverage) -} - -pub fn deserialize_asset_coverage(value: &str) -> Result { - 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::>() - .into_iter() - .rev() - .collect(); - let threat_window = ((level + 1)..=(level + 2).min(level_count)).collect::>(); - 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, - motion_key: Option<&str>, -) -> Result { - 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, - 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, - 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, 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 {} +pub use application::*; +pub use commands::*; +pub use domain::*; +pub use errors::*; +pub use events::*; #[cfg(test)] mod tests { diff --git a/server-rs/crates/module-combat/src/application.rs b/server-rs/crates/module-combat/src/application.rs index 8bae736d..c19413a0 100644 --- a/server-rs/crates/module-combat/src/application.rs +++ b/server-rs/crates/module-combat/src/application.rs @@ -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, + pub error_message: Option, +} + +#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct ResolveCombatActionProcedureResult { + pub ok: bool, + pub result: Option, + pub error_message: Option, +} + +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 { + 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 + ), + } +} diff --git a/server-rs/crates/module-combat/src/commands.rs b/server-rs/crates/module-combat/src/commands.rs index e4281bef..f0006c5d 100644 --- a/server-rs/crates/module-combat/src/commands.rs +++ b/server-rs/crates/module-combat/src/commands.rs @@ -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, + 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, + 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 { + 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) +} diff --git a/server-rs/crates/module-combat/src/domain.rs b/server-rs/crates/module-combat/src/domain.rs index 45253f91..7f14714b 100644 --- a/server-rs/crates/module-combat/src/domain.rs +++ b/server-rs/crates/module-combat/src/domain.rs @@ -1,8 +1,9 @@ -//! 战斗领域模型过渡落位。 +//! 战斗领域模型。 //! -//! 后续迁移 `BattleState` 与行动结算规则时,只保留单聚合内部状态变化; -//! 背包奖励、成长记账和任务联动由应用服务或 SpacetimeDB 事务 adapter 编排。 +//! 本文件只承载战斗聚合内部状态和值对象;背包奖励、成长记账和任务联动由 +//! SpacetimeDB 事务 adapter 编排,不在战斗领域内直连其他上下文。 +use module_runtime_item::RuntimeItemRewardItemSnapshot; use serde::{Deserialize, Serialize}; #[cfg(feature = "spacetime-types")] 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, + 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, + pub turn_index: u32, + pub last_action_function_id: Option, + pub last_action_text: Option, + pub last_result_text: Option, + 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, +} diff --git a/server-rs/crates/module-combat/src/errors.rs b/server-rs/crates/module-combat/src/errors.rs index d39ae03b..6bcd29a1 100644 --- a/server-rs/crates/module-combat/src/errors.rs +++ b/server-rs/crates/module-combat/src/errors.rs @@ -1,3 +1,50 @@ -//! 战斗领域错误过渡落位。 +//! 战斗领域错误。 //! //! 错误保持纯领域语义,不能绑定 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 {} diff --git a/server-rs/crates/module-combat/src/events.rs b/server-rs/crates/module-combat/src/events.rs index da3f7d73..c4828542 100644 --- a/server-rs/crates/module-combat/src/events.rs +++ b/server-rs/crates/module-combat/src/events.rs @@ -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, +} diff --git a/server-rs/crates/module-combat/src/lib.rs b/server-rs/crates/module-combat/src/lib.rs index 65ff8e61..735c4a5a 100644 --- a/server-rs/crates/module-combat/src/lib.rs +++ b/server-rs/crates/module-combat/src/lib.rs @@ -4,531 +4,16 @@ mod domain; mod errors; mod events; +pub use application::*; +pub use commands::*; pub use domain::*; - -use std::{error::Error, fmt}; - -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, - 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, - 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, - 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, - pub turn_index: u32, - pub last_action_function_id: Option, - pub last_action_text: Option, - pub last_result_text: Option, - 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, - pub error_message: Option, -} - -#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] -#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] -pub struct ResolveCombatActionProcedureResult { - pub ok: bool, - pub result: Option, - pub error_message: Option, -} - -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 { - 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 { - 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 {} +pub use errors::*; +pub use events::*; #[cfg(test)] mod tests { use super::*; + use module_runtime_item::RuntimeItemRewardItemSnapshot; fn build_fight_snapshot() -> BattleStateSnapshot { build_battle_state_snapshot(BattleStateInput { diff --git a/server-rs/crates/module-custom-world/README.md b/server-rs/crates/module-custom-world/README.md index 111d739f..226a235e 100644 --- a/server-rs/crates/module-custom-world/README.md +++ b/server-rs/crates/module-custom-world/README.md @@ -14,42 +14,41 @@ ## 2. 当前阶段说明 -当前阶段已经不再是单纯目录占位,而是先把 `M5` 首批 `custom world / agent` 类型契约与字段校验固定下来,避免 `spacetime-module` 在缺少领域边界的情况下直接堆表。 +当前阶段已经不再是单纯目录占位,`custom world / agent` 类型契约、字段校验、发布编译规则和 Agent action 应用结果已经固定到 DDD 骨架中,避免 `spacetime-module` 在缺少领域边界的情况下直接堆表。 当前已落地: 1. 真实 `Cargo.toml` crate scaffold -2. `src/domain.rs` 承接 `CustomWorldPublicationStatus`、`CustomWorldThemeMode`、`CustomWorldGenerationMode` -3. `CustomWorldSessionStatus`、`RpgAgentStage` -4. `RpgAgentMessageRole`、`RpgAgentMessageKind` -5. `RpgAgentOperationType`、`RpgAgentOperationStatus` -6. `RpgAgentDraftCardKind`、`RpgAgentDraftCardStatus` -7. `CustomWorldRoleAssetStatus` -8. 首批表字段校验函数与最小单测 -9. `published profile compile` 输入输出 contract -10. `publish_world` 串联输入输出 contract +2. `src/domain.rs` 承接基础枚举、进度常量、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。 +7. `src/lib.rs` 只保留模块声明、公开导出和测试,继续保持 `module_custom_world::*` 公开 API。 +8. `spacetime-module` 中 `generate_characters`、`generate_landmarks`、`generate_role_assets`、`sync_role_assets`、`generate_scene_assets`、`sync_scene_assets`、`expand_long_tail` 已移除最小兼容占位,改为确定性状态编排。 当前 crate 仍然只承接: -1. 共享枚举、进度常量与类型口径,基础枚举统一从 `src/domain.rs` 导出 -2. 字段校验与字符串归一化 -3. published profile compile 的最小编译摘要 contract -4. 后续 `spacetime-module` 聚合表时需要复用的领域边界 +1. 共享枚举、进度常量与类型口径,基础枚举统一从 `src/domain.rs` 导出。 +2. 字段校验、字符串归一化与发布编译纯规则。 +3. published profile compile 与 publish world 的输入输出 contract。 +4. 后续 `spacetime-module` 聚合表时需要复用的领域边界。 当前阶段明确不提前进入: 1. 旧问答流 reducer 编排 -2. RPG 创作 Agent 编排 -3. publish gate blocker 规则迁移 -4. 资产绑定与图片生成副作用 +2. 外部 LLM 创作编排、图片生成、OSS 上传和 SSE 推送。 +3. 资产对象真相表、资产绑定表和完整资产历史。 +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) -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) -3. [../../../backend-rewrite-tasklist/04_M5_CUSTOM_WORLD_AND_AGENT.md](../../../backend-rewrite-tasklist/04_M5_CUSTOM_WORLD_AND_AGENT.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) -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) +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. [../../../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. [../../../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_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 直接相关的任务包括: diff --git a/server-rs/crates/module-custom-world/src/application.rs b/server-rs/crates/module-custom-world/src/application.rs index ab49352e..0cf077d7 100644 --- a/server-rs/crates/module-custom-world/src/application.rs +++ b/server-rs/crates/module-custom-world/src/application.rs @@ -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 { + 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) -> Option { + 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) { + 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) { + Ok(Value::Array(_)) => Ok(()), + _ => Err(CustomWorldFieldError::InvalidJsonPayload), + } +} + +fn parse_required_json_object( + value: &str, + error: CustomWorldFieldError, +) -> Result, CustomWorldFieldError> { + match serde_json::from_str::(value) { + Ok(Value::Object(object)) => Ok(object), + _ => Err(error), + } +} + +fn parse_optional_json_object( + value: Option, + error: CustomWorldFieldError, +) -> Result, 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 { + 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 { + match value { + Some(Value::Array(items)) => items.clone(), + _ => Vec::new(), + } +} + +fn to_object(value: Option<&Value>) -> Option> { + 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::>() + .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::>() + .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::>() + .join("\n") +} + +fn has_meaningful_creator_intent(intent: &Map) -> 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 { + [ + 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::>() + .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, key: &str) -> Option { + 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, key: &str) -> Vec { + 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::>() + }) + .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, + legacy: &Map, + key: &str, +) -> Option { + to_text(draft.get(key)).or_else(|| to_text(legacy.get(key))) +} + +fn resolve_theme_mode(legacy: &Map) -> 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, + legacy: &Map, +) -> Option { + 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, + legacy: &Map, + world_name: &str, + subtitle: &str, + summary_text: &str, +) -> Result { + 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) +} diff --git a/server-rs/crates/module-custom-world/src/commands.rs b/server-rs/crates/module-custom-world/src/commands.rs index 2bca44c3..6d3b23fd 100644 --- a/server-rs/crates/module-custom-world/src/commands.rs +++ b/server-rs/crates/module-custom-world/src/commands.rs @@ -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, + pub author_public_user_code: Option, + pub source_agent_session_id: Option, + pub world_name: String, + pub subtitle: String, + pub summary_text: String, + pub theme_mode: CustomWorldThemeMode, + pub cover_image_src: Option, + 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, + 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, + pub creator_intent_readiness_json: String, + pub anchor_pack_json: Option, + pub lock_state_json: Option, + pub draft_profile_json: Option, + 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, + pub assistant_reply_text: Option, + 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, + pub anchor_content_json: String, + pub creator_intent_json: Option, + pub creator_intent_readiness_json: String, + pub anchor_pack_json: Option, + pub draft_profile_json: Option, + 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, + 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, + 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, + pub error_message: Option, +} + +#[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, + 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, + pub error_message: Option, +} + +#[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, + 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, + 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, + pub error_message: Option, +} + +#[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, + pub author_public_user_code: String, + pub draft_profile_json: String, + pub legacy_result_profile_json: Option, + 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, + pub entry: Option, + pub gallery_entry: Option, + pub session_stage: Option, + pub error_message: Option, +} diff --git a/server-rs/crates/module-custom-world/src/domain.rs b/server-rs/crates/module-custom-world/src/domain.rs index 4a0f0965..0c8fd8fa 100644 --- a/server-rs/crates/module-custom-world/src/domain.rs +++ b/server-rs/crates/module-custom-world/src/domain.rs @@ -1,7 +1,6 @@ -//! 自定义世界领域模型过渡落位。 +//! 自定义世界领域模型。 //! -//! 后续迁移 profile、Agent 会话、草稿卡、发布门禁和画廊投影规则时, -//! 只保留纯领域结构;LLM 推理、SSE 和 OSS 均留在外层 adapter。 +//! 只保留 profile、Agent 会话、草稿卡、发布门禁和画廊投影的纯领域结构;LLM 推理、SSE 和 OSS 均留在外层 adapter。 use serde::{Deserialize, Serialize}; #[cfg(feature = "spacetime-types")] @@ -139,6 +138,248 @@ pub enum CustomWorldRoleAssetStatus { 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, + pub author_public_user_code: Option, + pub source_agent_session_id: Option, + pub publication_status: CustomWorldPublicationStatus, + pub world_name: String, + pub subtitle: String, + pub summary_text: String, + pub theme_mode: CustomWorldThemeMode, + pub cover_image_src: Option, + pub profile_payload_json: String, + pub playable_npc_count: u32, + pub landmark_count: u32, + pub author_display_name: String, + pub published_at_micros: Option, + pub deleted_at_micros: Option, + 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, + 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, + pub gallery_entry: Option, + pub error_message: Option, +} + +#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct CustomWorldProfileListResult { + pub ok: bool, + pub entries: Vec, + pub error_message: Option, +} + +#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct CustomWorldGalleryListResult { + pub ok: bool, + pub entries: Vec, + pub error_message: Option, +} + +#[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, + 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, + pub cover_render_mode: Option, + pub cover_character_image_srcs_json: String, + pub updated_at_micros: i64, + pub published_at_micros: Option, + pub stage: Option, + pub stage_label: Option, + pub playable_npc_count: u32, + pub landmark_count: u32, + pub role_visual_ready_count: Option, + pub role_animation_ready_count: Option, + pub role_asset_summary_label: Option, + pub session_id: Option, + pub profile_id: Option, + 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, + pub error_message: Option, +} + +#[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, + 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, + 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, + pub asset_status_label: Option, + pub detail_payload_json: Option, + 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, + 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, + pub asset_status_label: Option, +} + +#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct CustomWorldDraftCardDetailResult { + pub ok: bool, + pub card: Option, + pub error_message: Option, +} + +#[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, + pub anchor_content_json: String, + pub creator_intent_json: Option, + pub creator_intent_readiness_json: String, + pub anchor_pack_json: Option, + pub lock_state_json: Option, + pub draft_profile_json: Option, + pub last_assistant_reply: Option, + pub publish_gate_json: Option, + pub result_preview_json: Option, + 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, + pub draft_cards: Vec, + pub operations: Vec, + 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, + pub error_message: Option, +} + impl CustomWorldPublicationStatus { pub fn as_str(&self) -> &'static str { match self { diff --git a/server-rs/crates/module-custom-world/src/errors.rs b/server-rs/crates/module-custom-world/src/errors.rs index 9481fbcd..39ef39fe 100644 --- a/server-rs/crates/module-custom-world/src/errors.rs +++ b/server-rs/crates/module-custom-world/src/errors.rs @@ -1,3 +1,100 @@ -//! 自定义世界领域错误过渡落位。 +//! 自定义世界领域错误。 //! //! 错误只表达世界创作规则失败,由 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 {} diff --git a/server-rs/crates/module-custom-world/src/events.rs b/server-rs/crates/module-custom-world/src/events.rs index 078e28f7..23712d24 100644 --- a/server-rs/crates/module-custom-world/src/events.rs +++ b/server-rs/crates/module-custom-world/src/events.rs @@ -1,3 +1,68 @@ -//! 自定义世界领域事件过渡落位。 +//! 自定义世界领域事件。 //! //! 用于表达草稿变化、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, +} diff --git a/server-rs/crates/module-custom-world/src/lib.rs b/server-rs/crates/module-custom-world/src/lib.rs index 88ba4c90..312ab928 100644 --- a/server-rs/crates/module-custom-world/src/lib.rs +++ b/server-rs/crates/module-custom-world/src/lib.rs @@ -4,1578 +4,16 @@ mod domain; mod errors; mod events; +pub use application::*; +pub use commands::*; pub use domain::*; - -use std::{error::Error, fmt}; - -use serde::{Deserialize, Serialize}; -use serde_json::{Map, Value}; -#[cfg(feature = "spacetime-types")] -use spacetimedb::SpacetimeType; - -#[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, -} - -#[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, - pub author_public_user_code: Option, - pub source_agent_session_id: Option, - pub publication_status: CustomWorldPublicationStatus, - pub world_name: String, - pub subtitle: String, - pub summary_text: String, - pub theme_mode: CustomWorldThemeMode, - pub cover_image_src: Option, - pub profile_payload_json: String, - pub playable_npc_count: u32, - pub landmark_count: u32, - pub author_display_name: String, - pub published_at_micros: Option, - pub deleted_at_micros: Option, - 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, - 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, - pub gallery_entry: Option, - pub error_message: Option, -} - -#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] -#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] -pub struct CustomWorldProfileListResult { - pub ok: bool, - pub entries: Vec, - pub error_message: Option, -} - -#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] -#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] -pub struct CustomWorldGalleryListResult { - pub ok: bool, - pub entries: Vec, - pub error_message: Option, -} - -#[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, - 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, - pub cover_render_mode: Option, - pub cover_character_image_srcs_json: String, - pub updated_at_micros: i64, - pub published_at_micros: Option, - pub stage: Option, - pub stage_label: Option, - pub playable_npc_count: u32, - pub landmark_count: u32, - pub role_visual_ready_count: Option, - pub role_animation_ready_count: Option, - pub role_asset_summary_label: Option, - pub session_id: Option, - pub profile_id: Option, - 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, - pub error_message: Option, -} - -#[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, - 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, - 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, - pub asset_status_label: Option, - pub detail_payload_json: Option, - 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, - 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, - pub asset_status_label: Option, -} - -#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] -#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] -pub struct CustomWorldDraftCardDetailResult { - pub ok: bool, - pub card: Option, - pub error_message: Option, -} - -#[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, - pub anchor_content_json: String, - pub creator_intent_json: Option, - pub creator_intent_readiness_json: String, - pub anchor_pack_json: Option, - pub lock_state_json: Option, - pub draft_profile_json: Option, - pub last_assistant_reply: Option, - pub publish_gate_json: Option, - pub result_preview_json: Option, - 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, - pub draft_cards: Vec, - pub operations: Vec, - 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, - pub error_message: Option, -} - -#[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, - pub author_public_user_code: Option, - pub source_agent_session_id: Option, - pub world_name: String, - pub subtitle: String, - pub summary_text: String, - pub theme_mode: CustomWorldThemeMode, - pub cover_image_src: Option, - 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, - 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, - pub creator_intent_readiness_json: String, - pub anchor_pack_json: Option, - pub lock_state_json: Option, - pub draft_profile_json: Option, - 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, - pub assistant_reply_text: Option, - 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, - pub anchor_content_json: String, - pub creator_intent_json: Option, - pub creator_intent_readiness_json: String, - pub anchor_pack_json: Option, - pub draft_profile_json: Option, - 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, - 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, - 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, - pub error_message: Option, -} - -#[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, - 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, - pub error_message: Option, -} - -#[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, - 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, - 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, - pub error_message: Option, -} - -#[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, - pub author_public_user_code: String, - pub draft_profile_json: String, - pub legacy_result_profile_json: Option, - 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, - pub entry: Option, - pub gallery_entry: Option, - pub session_stage: Option, - pub error_message: Option, -} - -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 { - 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) -> Option { - 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) { - 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) { - Ok(Value::Array(_)) => Ok(()), - _ => Err(CustomWorldFieldError::InvalidJsonPayload), - } -} - -fn parse_required_json_object( - value: &str, - error: CustomWorldFieldError, -) -> Result, CustomWorldFieldError> { - match serde_json::from_str::(value) { - Ok(Value::Object(object)) => Ok(object), - _ => Err(error), - } -} - -fn parse_optional_json_object( - value: Option, - error: CustomWorldFieldError, -) -> Result, 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 { - 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 { - match value { - Some(Value::Array(items)) => items.clone(), - _ => Vec::new(), - } -} - -fn to_object(value: Option<&Value>) -> Option> { - 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::>() - .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::>() - .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::>() - .join("\n") -} - -fn has_meaningful_creator_intent(intent: &Map) -> 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 { - [ - 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::>() - .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, key: &str) -> Option { - 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, key: &str) -> Vec { - 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::>() - }) - .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, - legacy: &Map, - key: &str, -) -> Option { - to_text(draft.get(key)).or_else(|| to_text(legacy.get(key))) -} - -fn resolve_theme_mode(legacy: &Map) -> 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, - legacy: &Map, -) -> Option { - 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, - legacy: &Map, - world_name: &str, - subtitle: &str, - summary_text: &str, -) -> Result { - 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) -} - -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 {} +pub use errors::*; +pub use events::*; #[cfg(test)] mod tests { use super::*; + use serde_json::Value; #[test] fn profile_validation_rejects_blank_owner() { diff --git a/server-rs/crates/module-inventory/src/application.rs b/server-rs/crates/module-inventory/src/application.rs index bf76943f..ca4f08f1 100644 --- a/server-rs/crates/module-inventory/src/application.rs +++ b/server-rs/crates/module-inventory/src/application.rs @@ -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, + pub equipment_items: Vec, +} + +#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct RuntimeInventoryStateProcedureResult { + pub ok: bool, + pub snapshot: Option, + pub error_message: Option, +} + +#[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, + pub quantity: u32, + pub rarity: String, + pub tags: Vec, + pub stackable: bool, + pub stack_key: String, + pub equipment_slot_id: Option, + pub source_kind: String, + pub source_reference_id: Option, + 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, + pub equipment_items: Vec, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct InventoryMutationOutcome { + pub next_slots: Vec, + pub changed: bool, + pub updated_slot_ids: Vec, + pub removed_slot_ids: Vec, + pub affected_equipment_slot: Option, +} + +pub fn normalize_optional_text(value: Option) -> Option { + normalize_shared_optional_string(value) +} + +pub fn normalize_string_list(values: Vec) -> Vec { + normalize_shared_string_list(values) +} + +pub fn build_runtime_inventory_state_query_input( + runtime_session_id: String, + actor_user_id: String, +) -> Result { + 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, +) -> 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, + input: InventoryMutationInput, +) -> Result { + 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, + removed_slot_ids: Vec, + affected_equipment_slot: Option, +} + +fn apply_grant_item( + slots: &mut Vec, + runtime_session_id: String, + story_session_id: Option, + actor_user_id: String, + grant: GrantInventoryItemInput, + updated_at_micros: i64, +) -> Result { + 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, + consume: ConsumeInventoryItemInput, + updated_at_micros: i64, +) -> Result { + 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 { + 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 { + 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 { + 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 { + normalize_required_string(value).ok_or(error) +} + +fn sort_inventory_slots(mut slots: Vec) -> Vec { + 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) -> Vec { + values.sort(); + values +} + +fn container_order(kind: InventoryContainerKind) -> u8 { + match kind { + InventoryContainerKind::Equipment => 0, + InventoryContainerKind::Backpack => 1, + } +} + +fn equipment_slot_order(slot: Option) -> 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", + } +} diff --git a/server-rs/crates/module-inventory/src/commands.rs b/server-rs/crates/module-inventory/src/commands.rs index cc2135b4..861939a0 100644 --- a/server-rs/crates/module-inventory/src/commands.rs +++ b/server-rs/crates/module-inventory/src/commands.rs @@ -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, + 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, +} diff --git a/server-rs/crates/module-inventory/src/domain.rs b/server-rs/crates/module-inventory/src/domain.rs index 0378ad01..4b66c799 100644 --- a/server-rs/crates/module-inventory/src/domain.rs +++ b/server-rs/crates/module-inventory/src/domain.rs @@ -1,4 +1,111 @@ -//! 背包领域模型过渡落位。 +//! 背包领域模型。 //! -//! 后续迁移背包槽、装备槽、堆叠和消耗规则时,只保留物品状态变化; +//! 本文件只承载背包槽、装备槽、堆叠和物品来源等稳定值对象; //! 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, + pub quantity: u32, + pub rarity: InventoryItemRarity, + pub tags: Vec, + pub stackable: bool, + pub stack_key: String, + pub equipment_slot_id: Option, + pub source_kind: InventoryItemSourceKind, + pub source_reference_id: Option, +} + +#[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, + 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, + pub quantity: u32, + pub rarity: InventoryItemRarity, + pub tags: Vec, + pub stackable: bool, + pub stack_key: String, + pub equipment_slot_id: Option, + pub source_kind: InventoryItemSourceKind, + pub source_reference_id: Option, + 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) +} diff --git a/server-rs/crates/module-inventory/src/errors.rs b/server-rs/crates/module-inventory/src/errors.rs index 1d1f235c..7cde7409 100644 --- a/server-rs/crates/module-inventory/src/errors.rs +++ b/server-rs/crates/module-inventory/src/errors.rs @@ -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 {} diff --git a/server-rs/crates/module-inventory/src/events.rs b/server-rs/crates/module-inventory/src/events.rs index d64bba27..28b61ab3 100644 --- a/server-rs/crates/module-inventory/src/events.rs +++ b/server-rs/crates/module-inventory/src/events.rs @@ -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, +} diff --git a/server-rs/crates/module-inventory/src/lib.rs b/server-rs/crates/module-inventory/src/lib.rs index 29f081f1..094a7f68 100644 --- a/server-rs/crates/module-inventory/src/lib.rs +++ b/server-rs/crates/module-inventory/src/lib.rs @@ -4,768 +4,11 @@ mod domain; mod errors; mod events; -use std::{error::Error, fmt}; - -use serde::{Deserialize, Serialize}; -use shared_kernel::{ - build_prefixed_seed_id, 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; - -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, - pub quantity: u32, - pub rarity: InventoryItemRarity, - pub tags: Vec, - pub stackable: bool, - pub stack_key: String, - pub equipment_slot_id: Option, - pub source_kind: InventoryItemSourceKind, - pub source_reference_id: Option, -} - -#[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, - 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, - pub quantity: u32, - pub rarity: InventoryItemRarity, - pub tags: Vec, - pub stackable: bool, - pub stack_key: String, - pub equipment_slot_id: Option, - pub source_kind: InventoryItemSourceKind, - pub source_reference_id: Option, - 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, - 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, - pub equipment_items: Vec, -} - -#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] -#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] -pub struct RuntimeInventoryStateProcedureResult { - pub ok: bool, - pub snapshot: Option, - pub error_message: Option, -} - -#[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, - pub quantity: u32, - pub rarity: String, - pub tags: Vec, - pub stackable: bool, - pub stack_key: String, - pub equipment_slot_id: Option, - pub source_kind: String, - pub source_reference_id: Option, - 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, - pub equipment_items: Vec, -} - -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct InventoryMutationOutcome { - pub next_slots: Vec, - pub changed: bool, - pub updated_slot_ids: Vec, - pub removed_slot_ids: Vec, - pub affected_equipment_slot: Option, -} - -#[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) -> Option { - normalize_shared_optional_string(value) -} - -pub fn normalize_string_list(values: Vec) -> Vec { - normalize_shared_string_list(values) -} - -pub fn build_runtime_inventory_state_query_input( - runtime_session_id: String, - actor_user_id: String, -) -> Result { - 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, -) -> 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, - input: InventoryMutationInput, -) -> Result { - 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, - removed_slot_ids: Vec, - affected_equipment_slot: Option, -} - -fn apply_grant_item( - slots: &mut Vec, - runtime_session_id: String, - story_session_id: Option, - actor_user_id: String, - grant: GrantInventoryItemInput, - updated_at_micros: i64, -) -> Result { - 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, - consume: ConsumeInventoryItemInput, - updated_at_micros: i64, -) -> Result { - 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 { - 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 { - 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 { - 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 { - normalize_required_string(value).ok_or(error) -} - -fn sort_inventory_slots(mut slots: Vec) -> Vec { - 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) -> Vec { - values.sort(); - values -} - -fn container_order(kind: InventoryContainerKind) -> u8 { - match kind { - InventoryContainerKind::Equipment => 0, - InventoryContainerKind::Backpack => 1, - } -} - -fn equipment_slot_order(slot: Option) -> 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 {} +pub use application::*; +pub use commands::*; +pub use domain::*; +pub use errors::*; +pub use events::*; #[cfg(test)] mod tests { diff --git a/server-rs/crates/module-npc/src/application.rs b/server-rs/crates/module-npc/src/application.rs index c248bbaf..11f249ef 100644 --- a/server-rs/crates/module-npc/src/application.rs +++ b/server-rs/crates/module-npc/src/application.rs @@ -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, + pub error_message: Option, +} + +#[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, + pub battle_mode: Option, + 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, + pub error_message: Option, +} + +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, +) -> Result { + 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 { + 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 { + 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) -> Option { + normalize_shared_optional_string(value) +} + +pub fn normalize_string_list(values: Vec) -> Vec { + 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, + 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 { + 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) -> Vec { + 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, 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 +} diff --git a/server-rs/crates/module-npc/src/commands.rs b/server-rs/crates/module-npc/src/commands.rs index 323c4985..6fb152ec 100644 --- a/server-rs/crates/module-npc/src/commands.rs +++ b/server-rs/crates/module-npc/src/commands.rs @@ -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, + pub revealed_facts: Vec, + pub known_attribute_rumors: Vec, + pub first_meaningful_contact_resolved: bool, + pub seen_backstory_chapter_ids: Vec, + pub stance_profile: Option, + 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, + pub note: Option, + 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, + pub updated_at_micros: i64, +} diff --git a/server-rs/crates/module-npc/src/domain.rs b/server-rs/crates/module-npc/src/domain.rs index 67d8ed08..f1fcb62d 100644 --- a/server-rs/crates/module-npc/src/domain.rs +++ b/server-rs/crates/module-npc/src/domain.rs @@ -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, + pub recent_approvals: Vec, + pub recent_disapprovals: Vec, +} + +#[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, + pub revealed_facts: Vec, + pub known_attribute_rumors: Vec, + pub first_meaningful_contact_resolved: bool, + pub seen_backstory_chapter_ids: Vec, + pub stance_profile: NpcStanceProfile, + pub created_at_micros: i64, + pub updated_at_micros: i64, +} diff --git a/server-rs/crates/module-npc/src/errors.rs b/server-rs/crates/module-npc/src/errors.rs index d90a0c8e..c8e3d261 100644 --- a/server-rs/crates/module-npc/src/errors.rs +++ b/server-rs/crates/module-npc/src/errors.rs @@ -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 {} diff --git a/server-rs/crates/module-npc/src/events.rs b/server-rs/crates/module-npc/src/events.rs index be1479d8..f42dc20d 100644 --- a/server-rs/crates/module-npc/src/events.rs +++ b/server-rs/crates/module-npc/src/events.rs @@ -1,3 +1,51 @@ -//! 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, +} diff --git a/server-rs/crates/module-npc/src/lib.rs b/server-rs/crates/module-npc/src/lib.rs index 46685a03..2983b3f0 100644 --- a/server-rs/crates/module-npc/src/lib.rs +++ b/server-rs/crates/module-npc/src/lib.rs @@ -4,722 +4,11 @@ mod domain; mod errors; mod events; -use std::{error::Error, fmt}; - -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 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, - pub recent_approvals: Vec, - pub recent_disapprovals: Vec, -} - -#[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, - pub revealed_facts: Vec, - pub known_attribute_rumors: Vec, - pub first_meaningful_contact_resolved: bool, - pub seen_backstory_chapter_ids: Vec, - 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, - pub revealed_facts: Vec, - pub known_attribute_rumors: Vec, - pub first_meaningful_contact_resolved: bool, - pub seen_backstory_chapter_ids: Vec, - pub stance_profile: Option, - 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, - pub note: Option, - 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, - 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, - pub error_message: Option, -} - -#[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, - pub battle_mode: Option, - 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, - pub error_message: Option, -} - -#[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, -) -> Result { - 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 { - 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 { - 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) -> Option { - normalize_shared_optional_string(value) -} - -pub fn normalize_string_list(values: Vec) -> Vec { - 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, - 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 { - 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) -> Vec { - 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, 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 {} +pub use application::*; +pub use commands::*; +pub use domain::*; +pub use errors::*; +pub use events::*; #[cfg(test)] mod tests { diff --git a/server-rs/crates/module-progression/README.md b/server-rs/crates/module-progression/README.md index bc5e57b1..ea20496c 100644 --- a/server-rs/crates/module-progression/README.md +++ b/server-rs/crates/module-progression/README.md @@ -1,6 +1,6 @@ # module-progression 成长与章节推进模块 crate 说明 -日期:`2026-04-21` +日期:`2026-04-30` ## 1. crate 职责 @@ -13,14 +13,15 @@ ## 2. 当前阶段说明 -当前阶段已不再是目录占位,已经完成以下首版落地: +当前阶段已完成 DDD 物理拆分收口,已经不再是“真实逻辑集中在 `lib.rs`、分层文件只占位”的状态: -1. 新增 `Cargo.toml` 与 `src/lib.rs`,形成真实可编译 crate。 -2. 冻结 `LevelBenchmark`、`PlayerProgressionSnapshot`、`ChapterProgressionSnapshot`、`RuntimeEntityLevelProfile` 等首版领域类型。 -3. 固化与 Node 侧一致的经验曲线、参考强度曲线、章节 pseudo level 曲线与敌对经验/生命值 fallback 规则。 -4. 提供 `create_initial_player_progression`、`grant_player_experience`、`build_chapter_progression_snapshot`、`apply_chapter_progression_ledger` 等领域原语。 -5. 提供 `build_chapter_auto_level_profile`、`build_hostile_experience_reward`、`resolve_hostile_battle_max_hp`,为后续 `quest / combat / npc` 联动提供统一成长基线。 -6. `spacetime-module` 已把 `turn_in_quest` 与 `resolve_combat_action(Victory)` 接到 `player_progression / chapter_progression` 最小经验结算链。 +1. `src/domain.rs` 承载 `LevelBenchmark`、`PlayerProgressionSnapshot`、`ChapterProgressionSnapshot`、`RuntimeEntityLevelProfile` 等成长领域类型和值对象。 +2. `src/commands.rs` 承载玩家成长查询/授予经验、章节预算、章节账本和章节自动定级输入。 +3. `src/application.rs` 固化与既有 Node 侧一致的经验曲线、参考强度曲线、章节 pseudo level 曲线、敌对经验/生命值 fallback 和章节账本应用规则。 +4. `src/events.rs` 承载经验授予、章节账本应用和自动定级解析等领域事件。 +5. `src/errors.rs` 承载成长字段错误与中文错误文案。 +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) 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) +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. 边界约束 diff --git a/server-rs/crates/module-progression/src/application.rs b/server-rs/crates/module-progression/src/application.rs index 45c21ae8..6d6a7c3f 100644 --- a/server-rs/crates/module-progression/src/application.rs +++ b/server-rs/crates/module-progression/src/application.rs @@ -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, + pub error_message: Option, +} + +#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct ChapterProgressionProcedureResult { + pub ok: bool, + pub record: Option, + pub error_message: Option, +} + +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, + created_at_micros: i64, + updated_at_micros: i64, +) -> Result { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + normalize_required_string(value).ok_or(error) +} diff --git a/server-rs/crates/module-progression/src/commands.rs b/server-rs/crates/module-progression/src/commands.rs index 9f33f42a..7bf0ff77 100644 --- a/server-rs/crates/module-progression/src/commands.rs +++ b/server-rs/crates/module-progression/src/commands.rs @@ -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, + 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, +} diff --git a/server-rs/crates/module-progression/src/domain.rs b/server-rs/crates/module-progression/src/domain.rs index afab1f2c..d5602e32 100644 --- a/server-rs/crates/module-progression/src/domain.rs +++ b/server-rs/crates/module-progression/src/domain.rs @@ -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, + 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, + 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, + pub chapter_index: Option, + pub progression_role: ProgressionRole, + pub source: LevelProfileSource, +} diff --git a/server-rs/crates/module-progression/src/errors.rs b/server-rs/crates/module-progression/src/errors.rs index d1420234..52e457fa 100644 --- a/server-rs/crates/module-progression/src/errors.rs +++ b/server-rs/crates/module-progression/src/errors.rs @@ -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 {} diff --git a/server-rs/crates/module-progression/src/events.rs b/server-rs/crates/module-progression/src/events.rs index 7b03c0a5..f1924cb7 100644 --- a/server-rs/crates/module-progression/src/events.rs +++ b/server-rs/crates/module-progression/src/events.rs @@ -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, +} diff --git a/server-rs/crates/module-progression/src/lib.rs b/server-rs/crates/module-progression/src/lib.rs index b5abc40d..63a51fbc 100644 --- a/server-rs/crates/module-progression/src/lib.rs +++ b/server-rs/crates/module-progression/src/lib.rs @@ -4,625 +4,11 @@ mod domain; mod errors; mod events; -use std::{error::Error, fmt}; - -use serde::{Deserialize, Serialize}; -use shared_kernel::normalize_required_string; -#[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; -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, - 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, - pub error_message: Option, -} - -#[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, - 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, - 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, - pub error_message: Option, -} - -#[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, - pub chapter_index: Option, - 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, - created_at_micros: i64, - updated_at_micros: i64, -) -> Result { - 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 { - 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 { - 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 { - 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 { - 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 { - 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 { - 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 { - 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 {} +pub use application::*; +pub use commands::*; +pub use domain::*; +pub use errors::*; +pub use events::*; #[cfg(test)] mod tests { diff --git a/server-rs/crates/module-puzzle/src/domain.rs b/server-rs/crates/module-puzzle/src/domain.rs index a1aa8251..712da3dc 100644 --- a/server-rs/crates/module-puzzle/src/domain.rs +++ b/server-rs/crates/module-puzzle/src/domain.rs @@ -1,4 +1,4 @@ -//! 拼图领域模型过渡落位。 +//! 拼图领域模型。 //! //! 后续迁移拼图 Agent 会话、作品 profile 和运行态聚合时,只保留玩法规则; //! 图片生成、发布 HTTP shape 和排行榜适配留在外层。 diff --git a/server-rs/crates/module-puzzle/src/events.rs b/server-rs/crates/module-puzzle/src/events.rs index 1ed04670..662d716a 100644 --- a/server-rs/crates/module-puzzle/src/events.rs +++ b/server-rs/crates/module-puzzle/src/events.rs @@ -1,4 +1,4 @@ -//! 拼图领域事件过渡落位。 +//! 拼图领域事件。 //! //! 用于表达草稿变化、作品发布、运行态推进和排行榜候选产生等事实。 diff --git a/server-rs/crates/module-quest/src/application.rs b/server-rs/crates/module-quest/src/application.rs index fce844be..f8756928 100644 --- a/server-rs/crates/module-quest/src/application.rs +++ b/server-rs/crates/module-quest/src/application.rs @@ -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) -> Option { + normalize_shared_optional_string(value) +} + +pub fn normalize_string_list(values: Vec) -> Vec { + normalize_shared_string_list(values) +} + +pub fn build_quest_record_snapshot( + input: QuestRecordInput, +) -> Result { + 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::, _>>()?; + 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 { + 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::>(); + + 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 { + 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 { + 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::>(); + 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 { + normalize_required_string(value).ok_or(error) +} + +fn normalize_quest_reward( + mut reward: QuestRewardSnapshot, +) -> Result { + 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 { + 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::, _>>()?; + 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 { + 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, + } +} diff --git a/server-rs/crates/module-quest/src/commands.rs b/server-rs/crates/module-quest/src/commands.rs index 5bbda4dc..2a16afa6 100644 --- a/server-rs/crates/module-quest/src/commands.rs +++ b/server-rs/crates/module-quest/src/commands.rs @@ -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, + pub actor_user_id: String, + pub issuer_npc_id: String, + pub issuer_npc_name: String, + pub scene_id: Option, + pub chapter_id: Option, + pub act_id: Option, + pub thread_id: Option, + pub contract_id: Option, + 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, + pub active_step_id: Option, + pub visible_stage: u32, + pub hidden_flags: Vec, + pub discovered_fact_ids: Vec, + pub related_carrier_ids: Vec, + pub consequence_ids: Vec, + 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, + pub changed_step_progress: Option, + 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, +} diff --git a/server-rs/crates/module-quest/src/domain.rs b/server-rs/crates/module-quest/src/domain.rs index e99f5df9..bd860d31 100644 --- a/server-rs/crates/module-quest/src/domain.rs +++ b/server-rs/crates/module-quest/src/domain.rs @@ -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, + pub quantity: u32, + pub rarity: QuestRewardItemRarity, + pub tags: Vec, + pub stackable: bool, + pub stack_key: String, + pub equipment_slot_id: Option, +} + +#[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, +} + +#[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, + pub items: Vec, + pub intel: Option, + pub story_hint: Option, +} + +#[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, +} + +#[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, + pub target_npc_id: Option, + pub target_scene_id: Option, + pub target_item_id: Option, + 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, + pub target_npc_id: Option, + pub target_scene_id: Option, + pub target_item_id: Option, + 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, + pub actor_user_id: String, + pub issuer_npc_id: String, + pub issuer_npc_name: String, + pub scene_id: Option, + pub chapter_id: Option, + pub act_id: Option, + pub thread_id: Option, + pub contract_id: Option, + 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, + pub active_step_id: Option, + pub visible_stage: u32, + pub hidden_flags: Vec, + pub discovered_fact_ids: Vec, + pub related_carrier_ids: Vec, + pub consequence_ids: Vec, + pub created_at_micros: i64, + pub updated_at_micros: i64, + pub completed_at_micros: Option, + pub turned_in_at_micros: Option, +} + +#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct QuestHostileNpcDefeatedSignal { + pub scene_id: Option, + 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, +} + +#[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, + } + } +} diff --git a/server-rs/crates/module-quest/src/errors.rs b/server-rs/crates/module-quest/src/errors.rs index 3c7d6dd4..2479dde5 100644 --- a/server-rs/crates/module-quest/src/errors.rs +++ b/server-rs/crates/module-quest/src/errors.rs @@ -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 {} diff --git a/server-rs/crates/module-quest/src/events.rs b/server-rs/crates/module-quest/src/events.rs index f058c25c..0cc01fd2 100644 --- a/server-rs/crates/module-quest/src/events.rs +++ b/server-rs/crates/module-quest/src/events.rs @@ -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, + 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, +} diff --git a/server-rs/crates/module-quest/src/lib.rs b/server-rs/crates/module-quest/src/lib.rs index cb51b1fa..d78a0466 100644 --- a/server-rs/crates/module-quest/src/lib.rs +++ b/server-rs/crates/module-quest/src/lib.rs @@ -4,914 +4,11 @@ mod domain; mod errors; mod events; -use std::{error::Error, fmt}; - -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 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, - pub quantity: u32, - pub rarity: QuestRewardItemRarity, - pub tags: Vec, - pub stackable: bool, - pub stack_key: String, - pub equipment_slot_id: Option, -} - -#[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, -} - -#[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, - pub items: Vec, - pub intel: Option, - pub story_hint: Option, -} - -#[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, -} - -#[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, - pub target_npc_id: Option, - pub target_scene_id: Option, - pub target_item_id: Option, - 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, - pub target_npc_id: Option, - pub target_scene_id: Option, - pub target_item_id: Option, - 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, - pub actor_user_id: String, - pub issuer_npc_id: String, - pub issuer_npc_name: String, - pub scene_id: Option, - pub chapter_id: Option, - pub act_id: Option, - pub thread_id: Option, - pub contract_id: Option, - 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, - pub active_step_id: Option, - pub visible_stage: u32, - pub hidden_flags: Vec, - pub discovered_fact_ids: Vec, - pub related_carrier_ids: Vec, - pub consequence_ids: Vec, - 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, - pub actor_user_id: String, - pub issuer_npc_id: String, - pub issuer_npc_name: String, - pub scene_id: Option, - pub chapter_id: Option, - pub act_id: Option, - pub thread_id: Option, - pub contract_id: Option, - 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, - pub active_step_id: Option, - pub visible_stage: u32, - pub hidden_flags: Vec, - pub discovered_fact_ids: Vec, - pub related_carrier_ids: Vec, - pub consequence_ids: Vec, - pub created_at_micros: i64, - pub updated_at_micros: i64, - pub completed_at_micros: Option, - pub turned_in_at_micros: Option, -} - -#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] -#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] -pub struct QuestHostileNpcDefeatedSignal { - pub scene_id: Option, - 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, -} - -#[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, - pub changed_step_progress: Option, - 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) -> Option { - normalize_shared_optional_string(value) -} - -pub fn normalize_string_list(values: Vec) -> Vec { - normalize_shared_string_list(values) -} - -pub fn build_quest_record_snapshot( - input: QuestRecordInput, -) -> Result { - 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::, _>>()?; - 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 { - 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::>(); - - 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 { - 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 { - 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::>(); - 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 { - normalize_required_string(value).ok_or(error) -} - -fn normalize_quest_reward( - mut reward: QuestRewardSnapshot, -) -> Result { - 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 { - 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::, _>>()?; - 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 { - 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 {} +pub use application::*; +pub use commands::*; +pub use domain::*; +pub use errors::*; +pub use events::*; #[cfg(test)] mod tests { diff --git a/server-rs/crates/module-runtime-item/src/application.rs b/server-rs/crates/module-runtime-item/src/application.rs index 92b91173..25cd33ad 100644 --- a/server-rs/crates/module-runtime-item/src/application.rs +++ b/server-rs/crates/module-runtime-item/src/application.rs @@ -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, + pub error_message: Option, +} + +pub fn build_treasure_record_snapshot( + input: TreasureResolveInput, +) -> Result { + 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::, _>>()?, + 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 { + 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 { + 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) -> Option { + normalize_shared_optional_string(value) +} + +fn normalize_reward_item( + mut item: RuntimeItemRewardItemSnapshot, +) -> Result { + 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 { + normalize_required_string(value).ok_or(error) +} + +fn normalize_string_list(values: Vec) -> Vec { + 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, + } +} diff --git a/server-rs/crates/module-runtime-item/src/commands.rs b/server-rs/crates/module-runtime-item/src/commands.rs index 65095481..6a2d2dbc 100644 --- a/server-rs/crates/module-runtime-item/src/commands.rs +++ b/server-rs/crates/module-runtime-item/src/commands.rs @@ -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, + pub scene_name: Option, + pub action: TreasureInteractionAction, + pub reward_items: Vec, + pub reward_hp: u32, + pub reward_mana: u32, + pub reward_currency: u32, + pub story_hint: Option, + pub created_at_micros: i64, + pub updated_at_micros: i64, +} diff --git a/server-rs/crates/module-runtime-item/src/domain.rs b/server-rs/crates/module-runtime-item/src/domain.rs index 3de58a29..6630b14a 100644 --- a/server-rs/crates/module-runtime-item/src/domain.rs +++ b/server-rs/crates/module-runtime-item/src/domain.rs @@ -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, + pub quantity: u32, + pub rarity: RuntimeItemRewardItemRarity, + pub tags: Vec, + pub stackable: bool, + pub stack_key: String, + pub equipment_slot_id: Option, +} + +#[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, + pub scene_name: Option, + pub action: TreasureInteractionAction, + pub reward_items: Vec, + pub reward_hp: u32, + pub reward_mana: u32, + pub reward_currency: u32, + pub story_hint: Option, + pub created_at_micros: i64, + pub updated_at_micros: i64, +} diff --git a/server-rs/crates/module-runtime-item/src/errors.rs b/server-rs/crates/module-runtime-item/src/errors.rs index 9d54cadb..6be181b0 100644 --- a/server-rs/crates/module-runtime-item/src/errors.rs +++ b/server-rs/crates/module-runtime-item/src/errors.rs @@ -1,3 +1,62 @@ -//! 运行时物品领域错误过渡落位。 +//! 运行时物品领域错误。 //! //! 错误只表达物品/奇遇规则失败,例如 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 {} diff --git a/server-rs/crates/module-runtime-item/src/events.rs b/server-rs/crates/module-runtime-item/src/events.rs index 11b245e3..56c00481 100644 --- a/server-rs/crates/module-runtime-item/src/events.rs +++ b/server-rs/crates/module-runtime-item/src/events.rs @@ -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, + pub occurred_at_micros: i64, +} diff --git a/server-rs/crates/module-runtime-item/src/lib.rs b/server-rs/crates/module-runtime-item/src/lib.rs index 7c30cb8f..f5d178f1 100644 --- a/server-rs/crates/module-runtime-item/src/lib.rs +++ b/server-rs/crates/module-runtime-item/src/lib.rs @@ -4,321 +4,16 @@ mod domain; mod errors; mod events; -use std::{error::Error, fmt}; - -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; - -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, - pub quantity: u32, - pub rarity: RuntimeItemRewardItemRarity, - pub tags: Vec, - pub stackable: bool, - pub stack_key: String, - pub equipment_slot_id: Option, -} - -#[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, - pub scene_name: Option, - pub action: TreasureInteractionAction, - pub reward_items: Vec, - pub reward_hp: u32, - pub reward_mana: u32, - pub reward_currency: u32, - pub story_hint: Option, - 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, - pub scene_name: Option, - pub action: TreasureInteractionAction, - pub reward_items: Vec, - pub reward_hp: u32, - pub reward_mana: u32, - pub reward_currency: u32, - pub story_hint: Option, - 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, - pub error_message: Option, -} - -#[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 { - 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::, _>>()?, - 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 { - 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 { - 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) -> Option { - normalize_shared_optional_string(value) -} - -fn normalize_reward_item( - mut item: RuntimeItemRewardItemSnapshot, -) -> Result { - 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 { - normalize_required_string(value).ok_or(error) -} - -fn normalize_string_list(values: Vec) -> Vec { - 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 {} +pub use application::*; +pub use commands::*; +pub use domain::*; +pub use errors::*; +pub use events::*; #[cfg(test)] mod tests { use super::*; + use module_inventory::{InventoryEquipmentSlot, InventoryItemRarity, InventoryItemSourceKind}; #[test] fn build_treasure_record_snapshot_accepts_minimal_contract() { diff --git a/server-rs/crates/module-runtime-story/README.md b/server-rs/crates/module-runtime-story/README.md index 071350f4..f8993ce1 100644 --- a/server-rs/crates/module-runtime-story/README.md +++ b/server-rs/crates/module-runtime-story/README.md @@ -2,12 +2,15 @@ `module-runtime-story` 承接 RPG runtime story 的纯领域规则、应用用例、事件和错误模型,不依赖 HTTP / `AppState` / SpacetimeDB。 -当前已经迁入的历史快照态纯逻辑会继续收口为 session scoped 新主链: +当前已经迁入的历史快照态纯逻辑会继续收口为 session scoped 新主链;顶层 DDD 物理拆分已经完成: -1. action 结算结果结构。 -2. action response 组装参数结构。 -3. NPC 委托上下文结构。 -4. functionId / 队伍上限常量。 -5. 少量只依赖 `serde_json::Value` 与 `shared-contracts` 的纯 helper。 +1. `src/domain.rs` 承载 action 结算结果结构、NPC 委托上下文、functionId / 队伍上限常量。 +2. `src/commands.rs` 承载 action 文本解析 helper。 +3. `src/application.rs` 承载 action response 组装参数、status patch 和 world type helper。 +4. `src/events.rs` 承载 runtime story 领域事件。 +5. `src/errors.rs` 承载 runtime story 纯规则错误。 +6. `src/lib.rs` 只保留模块声明、公开导出和子模块 re-export。 后续 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)。 diff --git a/server-rs/crates/module-runtime-story/src/application.rs b/server-rs/crates/module-runtime-story/src/application.rs index 53dc09bd..7fac98cd 100644 --- a/server-rs/crates/module-runtime-story/src/application.rs +++ b/server-rs/crates/module-runtime-story/src/application.rs @@ -1,3 +1,58 @@ //! runtime story 应用编排落位。 //! //! 这里组合纯领域规则并返回后端投影;真实保存、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, + pub patches: Vec, + pub toast: Option, + pub battle: Option, +} + +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 { + read_optional_string_field(game_state, "worldType") +} diff --git a/server-rs/crates/module-runtime-story/src/commands.rs b/server-rs/crates/module-runtime-story/src/commands.rs index ce75d7fd..fb4de603 100644 --- a/server-rs/crates/module-runtime-story/src/commands.rs +++ b/server-rs/crates/module-runtime-story/src/commands.rs @@ -1,3 +1,16 @@ -//! runtime story 写入命令过渡落位。 +//! runtime story 写入命令。 //! //! 用于表达剧情动作解析、战斗动作、锻造动作和 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()) +} diff --git a/server-rs/crates/module-runtime-story/src/domain.rs b/server-rs/crates/module-runtime-story/src/domain.rs index 2144d265..27e13fad 100644 --- a/server-rs/crates/module-runtime-story/src/domain.rs +++ b/server-rs/crates/module-runtime-story/src/domain.rs @@ -1,4 +1,44 @@ -//! runtime story 领域模型过渡落位。 +//! runtime story 领域模型。 //! //! 当前 crate 用于运行时剧情主链的纯规则收口。后续迁移时仍只能保留 JSON 规则、 //! 选项生成和视图模型转换,不引入 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, + pub presentation_options: Option>, + pub saved_current_story: Option, + pub patches: Vec, + pub battle: Option, + pub toast: Option, +} + +pub struct GeneratedStoryPayload { + pub story_text: String, + pub history_result_text: String, + pub presentation_options: Vec, + pub saved_current_story: Value, +} + +pub struct CurrentEncounterNpcQuestContext { + pub npc_id: String, + pub npc_name: String, +} + +pub struct PendingQuestOfferContext { + pub dialogue: Vec, + pub turn_count: i32, + pub custom_input_placeholder: String, + pub quest: Value, + pub quest_id: String, + pub intro_text: Option, +} diff --git a/server-rs/crates/module-runtime-story/src/errors.rs b/server-rs/crates/module-runtime-story/src/errors.rs index 8b9f3052..d5ec88e2 100644 --- a/server-rs/crates/module-runtime-story/src/errors.rs +++ b/server-rs/crates/module-runtime-story/src/errors.rs @@ -1,3 +1,26 @@ -//! runtime story 领域错误过渡落位。 +//! runtime story 领域错误。 //! //! 错误只表达运行时剧情规则失败,不能直接绑定 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 {} diff --git a/server-rs/crates/module-runtime-story/src/events.rs b/server-rs/crates/module-runtime-story/src/events.rs index 8af5ffe4..46ec6404 100644 --- a/server-rs/crates/module-runtime-story/src/events.rs +++ b/server-rs/crates/module-runtime-story/src/events.rs @@ -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, + occurred_at_micros: i64, + }, + BattlePresentationChanged { + runtime_session_id: String, + battle_state_id: Option, + occurred_at_micros: i64, + }, + CrossDomainSyncPending { + runtime_session_id: String, + reason: String, + occurred_at_micros: i64, + }, +} diff --git a/server-rs/crates/module-runtime-story/src/lib.rs b/server-rs/crates/module-runtime-story/src/lib.rs index d49999c6..1a8e97de 100644 --- a/server-rs/crates/module-runtime-story/src/lib.rs +++ b/server-rs/crates/module-runtime-story/src/lib.rs @@ -4,12 +4,6 @@ mod domain; mod errors; mod events; -use serde_json::Value; -use shared_contracts::runtime_story::{ - RuntimeBattlePresentation, RuntimeStoryActionRequest, RuntimeStoryOptionView, - RuntimeStoryPatch, RuntimeStorySnapshotPayload, -}; - pub mod battle; #[cfg(test)] mod battle_tests; @@ -25,10 +19,12 @@ pub mod prompt_context; pub mod story_engine; pub mod view_model; +pub use application::*; pub use battle::{ build_battle_runtime_story_options, inventory_item_has_usable_effect, resolve_battle_action, restore_player_resource, }; +pub use commands::*; pub use core::{ 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, @@ -41,6 +37,9 @@ pub use core::{ write_first_hostile_npc_i32_field, write_i32_field, write_null_field, write_string_field, 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_actions::{ 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, 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, - pub presentation_options: Option>, - pub saved_current_story: Option, - pub patches: Vec, - pub battle: Option, - pub toast: Option, -} - -pub struct GeneratedStoryPayload { - pub story_text: String, - pub history_result_text: String, - pub presentation_options: Vec, - pub saved_current_story: Value, -} - -pub struct CurrentEncounterNpcQuestContext { - pub npc_id: String, - pub npc_name: String, -} - -pub struct PendingQuestOfferContext { - pub dialogue: Vec, - pub turn_count: i32, - pub custom_input_placeholder: String, - pub quest: Value, - pub quest_id: String, - pub intro_text: Option, -} - -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, - pub patches: Vec, - pub toast: Option, - pub battle: Option, -} - -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 { - read_optional_string_field(game_state, "worldType") -} diff --git a/server-rs/crates/module-runtime/src/application.rs b/server-rs/crates/module-runtime/src/application.rs index d6782a3d..cf1af40c 100644 --- a/server-rs/crates/module-runtime/src/application.rs +++ b/server-rs/crates/module-runtime/src/application.rs @@ -1,4 +1,4 @@ -//! 运行时应用编排过渡落位。 +//! 运行时应用编排。 //! //! 这里只返回运行时快照、个人页投影和领域事件,不直接访问外部 adapter。 diff --git a/server-rs/crates/module-runtime/src/commands.rs b/server-rs/crates/module-runtime/src/commands.rs index 61ee2d63..38a5cdac 100644 --- a/server-rs/crates/module-runtime/src/commands.rs +++ b/server-rs/crates/module-runtime/src/commands.rs @@ -1,4 +1,4 @@ -//! 运行时写入命令过渡落位。 +//! 运行时写入命令。 //! //! 用于表达保存快照、更新设置、写入浏览历史、调整钱包和保存存档等输入。 diff --git a/server-rs/crates/module-runtime/src/errors.rs b/server-rs/crates/module-runtime/src/errors.rs index 037f8e9f..7640509d 100644 --- a/server-rs/crates/module-runtime/src/errors.rs +++ b/server-rs/crates/module-runtime/src/errors.rs @@ -1,4 +1,4 @@ -//! 运行时领域错误过渡落位。 +//! 运行时领域错误。 //! //! 错误保持运行时业务语义,例如快照版本非法、兑换码不可用或钱包余额不足。 diff --git a/server-rs/crates/module-runtime/src/events.rs b/server-rs/crates/module-runtime/src/events.rs index 1aa0bbc5..3966f764 100644 --- a/server-rs/crates/module-runtime/src/events.rs +++ b/server-rs/crates/module-runtime/src/events.rs @@ -1,3 +1,3 @@ -//! 运行时领域事件过渡落位。 +//! 运行时领域事件。 //! //! 用于表达快照已保存、设置已更新、钱包已记账和存档已变化等事实。 diff --git a/server-rs/crates/spacetime-module/src/custom_world/mod.rs b/server-rs/crates/spacetime-module/src/custom_world/mod.rs index ec2b8224..7a2eee29 100644 --- a/server-rs/crates/spacetime-module/src/custom_world/mod.rs +++ b/server-rs/crates/spacetime-module/src/custom_world/mod.rs @@ -1826,13 +1826,19 @@ fn execute_custom_world_agent_action_tx( } "publish_world" => execute_publish_world_action(ctx, &session, &input, &payload), "revert_checkpoint" => execute_revert_checkpoint_action(ctx, &session, &input, &payload), - "generate_characters" - | "generate_landmarks" - | "generate_role_assets" - | "sync_role_assets" - | "generate_scene_assets" - | "sync_scene_assets" - | "expand_long_tail" => execute_placeholder_custom_world_action(ctx, &session, &input), + "generate_characters" => { + execute_generate_characters_action(ctx, &session, &input, &payload) + } + "generate_landmarks" => execute_generate_landmarks_action(ctx, &session, &input, &payload), + "generate_role_assets" => { + execute_generate_role_assets_action(ctx, &session, &input, &payload) + } + "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}` 当前尚未支持")), } } @@ -2378,35 +2384,763 @@ fn execute_revert_checkpoint_action( Ok(build_custom_world_agent_operation_snapshot(&operation)) } -fn execute_placeholder_custom_world_action( +fn execute_generate_characters_action( ctx: &ReducerContext, session: &CustomWorldAgentSession, input: &CustomWorldAgentActionExecuteInput, + payload: &JsonMap, ) -> Result { - let operation_type = map_action_name_to_operation_type(input.action.as_str()) - .ok_or_else(|| format!("action {} 无法映射到 operation type", input.action))?; + ensure_refining_stage(session.stage, "generate_characters")?; + 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( ctx, &session.session_id, &input.operation_id, - &format!( - "动作 {} 已接入最小兼容占位,后续会继续补真实编排。", - input.action - ), + &format!("已生成并同步 {total_inserted} 个角色草稿。"), input.submitted_at_micros, ); - let operation = build_and_insert_custom_world_operation( + + let operation = complete_custom_world_operation( ctx, &input.operation_id, &session.session_id, - operation_type, - "动作已完成", - &format!("{} 当前已走最小兼容闭环。", input.action), + RpgAgentOperationType::GenerateCharacters, + "角色草稿已同步", + &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, +) -> Result { + 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, ); + + 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)) } +fn execute_generate_role_assets_action( + ctx: &ReducerContext, + session: &CustomWorldAgentSession, + input: &CustomWorldAgentActionExecuteInput, + payload: &JsonMap, +) -> Result { + 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, +) -> Result { + 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, +) -> Result { + 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, +) -> Result { + 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, +) -> Result { + 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 { + 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, + 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, + payload: &JsonMap, + payload_key: &str, + profile_key: &str, + id_prefix: &str, + card_kind: RpgAgentDraftCardKind, + ctx: &ReducerContext, + session_id: &str, + updated_at_micros: i64, +) -> Result { + 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, 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, + 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, + generated: bool, +) -> Result { + 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, + generated: bool, +) -> Result { + 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, + generated: bool, +) -> Vec { + 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, + generated: bool, +) -> Vec { + 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, + keys: &[&str], +) -> Vec> { + 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, + payload: &JsonMap, +) { + 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)] struct CustomWorldAgentSessionPatch { current_turn: Option, @@ -3310,24 +4044,6 @@ fn ensure_publishable_stage(stage: RpgAgentStage, action: &str) -> Result<(), St ensure_long_tail_stage(stage, action) } -fn map_action_name_to_operation_type(action: &str) -> Option { - 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 { match value.trim() { "collecting_intent" => Some(RpgAgentStage::CollectingIntent), diff --git a/server-rs/crates/tests-support/Cargo.toml b/server-rs/crates/tests-support/Cargo.toml new file mode 100644 index 00000000..d79bed35 --- /dev/null +++ b/server-rs/crates/tests-support/Cargo.toml @@ -0,0 +1,7 @@ +[package] +name = "tests-support" +edition.workspace = true +version.workspace = true +license.workspace = true + +[dependencies] diff --git a/server-rs/crates/tests-support/README.md b/server-rs/crates/tests-support/README.md index 474772e5..e09d9505 100644 --- a/server-rs/crates/tests-support/README.md +++ b/server-rs/crates/tests-support/README.md @@ -1,18 +1,21 @@ -# tests-support 共享 crate 占位说明 +# tests-support 共享测试支撑 crate 日期:`2026-04-20` ## 1. crate 职责 -`tests-support` 是测试支撑共享 crate,后续负责: +`tests-support` 是测试支撑共享 crate,当前已作为 `server-rs` workspace member 落位,负责承接跨 crate 复用的测试辅助能力。 -1. contract、integration、smoke 测试的共享夹具与辅助工具 -2. 测试环境配置、测试数据装配与断言工具 -3. 供 `crates/api-server`、`crates/spacetime-module` 与各模块 crate 复用的测试基础设施能力 +当前首版只放无业务规则的 smoke/HTTP 通用断言: + +1. Maincloud healthz 默认地址常量 +2. smoke URL 空值与尾斜杠归一化 +3. HTTP 2xx 状态码断言 +4. healthz 非空响应体断言 ## 2. 当前阶段说明 -当前提交仅完成目录占位,不提前进入测试夹具、断言工具与 smoke 支撑实现。 +当前阶段不提前引入伪环境、不编造业务夹具,也不承接 contract DTO 或 SpacetimeDB reducer 的测试数据装配。 后续与本 crate 直接相关的任务包括: @@ -26,3 +29,4 @@ 1. `tests-support` 只承接测试支撑能力,不承接业务规则实现。 2. 测试夹具要尽量贴近真实 contract 与真实模块边界,避免重新引入脱离现网的伪环境。 3. 不允许把测试辅助逻辑散落到各模块 crate 中重复实现。 +4. SpacetimeDB 表、reducer、procedure 和迁移规则仍归 `spacetime-module` 与 `WP-ST`,本 crate 不定义 schema。 diff --git a/server-rs/crates/tests-support/src/lib.rs b/server-rs/crates/tests-support/src/lib.rs new file mode 100644 index 00000000..29ae4390 --- /dev/null +++ b/server-rs/crates/tests-support/src/lib.rs @@ -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) -> 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) -> 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) -> 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 响应体不能为空"); + } +}