Merge branch 'codex/backend-rewrite-spacetimedb' of http://82.157.175.59:3000/GenarrativeAI/Genarrative into codex/backend-rewrite-spacetimedb
This commit is contained in:
155
server-rs/Cargo.lock
generated
155
server-rs/Cargo.lock
generated
@@ -71,10 +71,12 @@ version = "0.1.0"
|
||||
dependencies = [
|
||||
"axum",
|
||||
"base64 0.22.1",
|
||||
"bytes",
|
||||
"dotenvy",
|
||||
"hmac",
|
||||
"http-body-util",
|
||||
"httpdate",
|
||||
"image",
|
||||
"module-ai",
|
||||
"module-assets",
|
||||
"module-auth",
|
||||
@@ -85,6 +87,7 @@ dependencies = [
|
||||
"module-npc",
|
||||
"module-runtime",
|
||||
"module-runtime-item",
|
||||
"module-runtime-story-compat",
|
||||
"module-story",
|
||||
"platform-auth",
|
||||
"platform-llm",
|
||||
@@ -99,12 +102,14 @@ dependencies = [
|
||||
"spacetime-client",
|
||||
"time",
|
||||
"tokio",
|
||||
"tokio-stream",
|
||||
"tower",
|
||||
"tower-http",
|
||||
"tracing",
|
||||
"url",
|
||||
"urlencoding",
|
||||
"uuid",
|
||||
"webp",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -293,6 +298,12 @@ version = "1.25.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c8efb64bd706a16a1bdde310ae86b351e4d21550d98d056f22f8a7f7a2183fec"
|
||||
|
||||
[[package]]
|
||||
name = "byteorder-lite"
|
||||
version = "0.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495"
|
||||
|
||||
[[package]]
|
||||
name = "bytes"
|
||||
version = "1.11.1"
|
||||
@@ -325,6 +336,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "43c5703da9466b66a946814e1adf53ea2c90f10063b86290cc9eb67ce3478a20"
|
||||
dependencies = [
|
||||
"find-msvc-tools",
|
||||
"jobserver",
|
||||
"libc",
|
||||
"shlex",
|
||||
]
|
||||
|
||||
@@ -653,6 +666,15 @@ version = "2.4.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6"
|
||||
|
||||
[[package]]
|
||||
name = "fdeflate"
|
||||
version = "0.3.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c"
|
||||
dependencies = [
|
||||
"simd-adler32",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "find-msvc-tools"
|
||||
version = "0.1.9"
|
||||
@@ -849,6 +871,12 @@ dependencies = [
|
||||
"wasip3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "glob"
|
||||
version = "0.3.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280"
|
||||
|
||||
[[package]]
|
||||
name = "hashbrown"
|
||||
version = "0.12.3"
|
||||
@@ -1168,6 +1196,32 @@ dependencies = [
|
||||
"icu_properties",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "image"
|
||||
version = "0.25.10"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "85ab80394333c02fe689eaf900ab500fbd0c2213da414687ebf995a65d5a6104"
|
||||
dependencies = [
|
||||
"bytemuck",
|
||||
"byteorder-lite",
|
||||
"image-webp",
|
||||
"moxcms",
|
||||
"num-traits",
|
||||
"png",
|
||||
"zune-core",
|
||||
"zune-jpeg",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "image-webp"
|
||||
version = "0.2.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "525e9ff3e1a4be2fbea1fdf0e98686a6d98b4d8f937e1bf7402245af1909e8c3"
|
||||
dependencies = [
|
||||
"byteorder-lite",
|
||||
"quick-error",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "indexmap"
|
||||
version = "1.9.3"
|
||||
@@ -1238,6 +1292,16 @@ version = "1.0.18"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
|
||||
|
||||
[[package]]
|
||||
name = "jobserver"
|
||||
version = "0.1.34"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33"
|
||||
dependencies = [
|
||||
"getrandom 0.3.4",
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "js-sys"
|
||||
version = "0.3.95"
|
||||
@@ -1303,6 +1367,16 @@ version = "0.2.185"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "52ff2c0fe9bc6cb6b14a0592c2ff4fa9ceb83eea9db979b0487cd054946a2b8f"
|
||||
|
||||
[[package]]
|
||||
name = "libwebp-sys"
|
||||
version = "0.9.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "54cd30df7c7165ce74a456e4ca9732c603e8dc5e60784558c1c6dc047f876733"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"glob",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "linux-raw-sys"
|
||||
version = "0.12.1"
|
||||
@@ -1522,6 +1596,16 @@ dependencies = [
|
||||
"spacetimedb",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "module-runtime-story-compat"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"serde_json",
|
||||
"shared-contracts",
|
||||
"shared-kernel",
|
||||
"time",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "module-story"
|
||||
version = "0.1.0"
|
||||
@@ -1531,6 +1615,16 @@ dependencies = [
|
||||
"spacetimedb",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "moxcms"
|
||||
version = "0.8.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bb85c154ba489f01b25c0d36ae69a87e4a1c73a72631fc6c0eb6dde34a73e44b"
|
||||
dependencies = [
|
||||
"num-traits",
|
||||
"pxfm",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "native-tls"
|
||||
version = "0.2.14"
|
||||
@@ -1767,6 +1861,19 @@ dependencies = [
|
||||
"tokio",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "png"
|
||||
version = "0.18.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61"
|
||||
dependencies = [
|
||||
"bitflags",
|
||||
"crc32fast",
|
||||
"fdeflate",
|
||||
"flate2",
|
||||
"miniz_oxide",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "potential_utf"
|
||||
version = "0.1.5"
|
||||
@@ -1845,6 +1952,18 @@ dependencies = [
|
||||
"thiserror 1.0.69",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pxfm"
|
||||
version = "0.1.29"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e0c5ccf5294c6ccd63a74f1565028353830a9c2f5eb0c682c355c471726a6e3f"
|
||||
|
||||
[[package]]
|
||||
name = "quick-error"
|
||||
version = "2.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3"
|
||||
|
||||
[[package]]
|
||||
name = "quinn"
|
||||
version = "0.11.9"
|
||||
@@ -3062,6 +3181,17 @@ dependencies = [
|
||||
"tokio",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tokio-stream"
|
||||
version = "0.1.18"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "32da49809aab5c3bc678af03902d4ccddea2a87d028d86392a4b1560c6906c70"
|
||||
dependencies = [
|
||||
"futures-core",
|
||||
"pin-project-lite",
|
||||
"tokio",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tokio-tungstenite"
|
||||
version = "0.27.0"
|
||||
@@ -3521,6 +3651,16 @@ dependencies = [
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "webp"
|
||||
version = "0.3.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c071456adef4aca59bf6a583c46b90ff5eb0b4f758fc347cea81290288f37ce1"
|
||||
dependencies = [
|
||||
"image",
|
||||
"libwebp-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "webpki-roots"
|
||||
version = "1.0.7"
|
||||
@@ -3897,3 +4037,18 @@ name = "zmij"
|
||||
version = "1.0.21"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa"
|
||||
|
||||
[[package]]
|
||||
name = "zune-core"
|
||||
version = "0.5.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cb8a0807f7c01457d0379ba880ba6322660448ddebc890ce29bb64da71fb40f9"
|
||||
|
||||
[[package]]
|
||||
name = "zune-jpeg"
|
||||
version = "0.5.15"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "27bc9d5b815bc103f142aa054f561d9187d191692ec7c2d1e2b4737f8dbd7296"
|
||||
dependencies = [
|
||||
"zune-core",
|
||||
]
|
||||
|
||||
@@ -17,6 +17,7 @@ members = [
|
||||
"crates/module-progression",
|
||||
"crates/module-quest",
|
||||
"crates/module-runtime",
|
||||
"crates/module-runtime-story-compat",
|
||||
"crates/module-runtime-item",
|
||||
"crates/module-story",
|
||||
"crates/platform-oss",
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
|
||||
## 2. 当前阶段说明
|
||||
|
||||
当前目录已经完成以下三十五项初始化:
|
||||
当前目录已经完成以下三十九项初始化:
|
||||
|
||||
1. 为新后端预留正式目录并把路径固定到仓库结构中。
|
||||
2. 创建虚拟 workspace `Cargo.toml`,后续 crate 会逐项挂入。
|
||||
@@ -52,6 +52,9 @@
|
||||
34. 创建 `scripts/spacetime-dev.ps1`,固定 Windows 本地 SpacetimeDB 启动入口。
|
||||
35. 创建 `scripts/spacetime-dev.sh`,固定 Unix-like 本地 SpacetimeDB 启动入口。
|
||||
36. 创建 `scripts/oss-smoke.ps1`,固定 Windows 本地阿里云 OSS 真实联调入口。
|
||||
37. 创建 `scripts/m7-preflight.ps1`,固定 M7 切流前 Rust 后端预检入口。
|
||||
38. 创建根目录 `scripts/m7-api-compare.ts`,固定旧 Node 与新 Rust 的无状态 API contract 对比入口。
|
||||
39. 固定 Vite dev proxy 的 `GENARRATIVE_BACKEND_STACK` / `GENARRATIVE_RUNTIME_SERVER_TARGET` 切流和回退开关。
|
||||
|
||||
后续任务会继续在本目录内按顺序补齐:
|
||||
|
||||
|
||||
@@ -6,8 +6,12 @@ license.workspace = true
|
||||
|
||||
[dependencies]
|
||||
axum = "0.8"
|
||||
base64 = "0.22"
|
||||
bytes = "1"
|
||||
dotenvy = "0.15"
|
||||
image = { version = "0.25", default-features = false, features = ["jpeg", "png", "webp"] }
|
||||
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] }
|
||||
webp = "0.3"
|
||||
module-ai = { path = "../module-ai" }
|
||||
module-assets = { path = "../module-assets" }
|
||||
module-auth = { path = "../module-auth" }
|
||||
@@ -17,6 +21,7 @@ module-custom-world = { path = "../module-custom-world" }
|
||||
module-inventory = { path = "../module-inventory" }
|
||||
module-npc = { path = "../module-npc" }
|
||||
module-runtime = { path = "../module-runtime" }
|
||||
module-runtime-story-compat = { path = "../module-runtime-story-compat" }
|
||||
module-runtime-item = { path = "../module-runtime-item" }
|
||||
module-story = { path = "../module-story" }
|
||||
platform-auth = { path = "../platform-auth" }
|
||||
@@ -28,7 +33,8 @@ shared-contracts = { path = "../shared-contracts" }
|
||||
shared-kernel = { path = "../shared-kernel" }
|
||||
shared-logging = { path = "../shared-logging" }
|
||||
spacetime-client = { path = "../spacetime-client" }
|
||||
tokio = { version = "1", features = ["macros", "rt-multi-thread", "net"] }
|
||||
tokio = { version = "1", features = ["macros", "rt-multi-thread", "net", "time"] }
|
||||
tokio-stream = "0.1"
|
||||
time = { version = "0.3", features = ["formatting"] }
|
||||
tower-http = { version = "0.6", features = ["trace"] }
|
||||
tracing = "0.1"
|
||||
|
||||
@@ -44,6 +44,23 @@
|
||||
22. 接入 `POST /api/assets/sts-upload-credentials` 禁用式 STS 写权限 contract
|
||||
23. 接入 `custom-world-library`、`custom-world-gallery` 与 agent `publish_world` 首批 Axum facade
|
||||
24. 接入 custom world agent `session create / session snapshot` Axum facade
|
||||
25. 接入旧 `runtime story` 兼容接口:
|
||||
- `POST /api/runtime/story/state/resolve`
|
||||
- `GET /api/runtime/story/state/{session_id}`
|
||||
- `POST /api/runtime/story/actions/resolve`
|
||||
- `POST /api/runtime/story/initial`
|
||||
- `POST /api/runtime/story/continue`
|
||||
26. 接入 `POST /api/assets/character-visual/generate`
|
||||
27. 接入 `GET /api/assets/character-visual/jobs/{task_id}`
|
||||
28. 接入 `POST /api/assets/character-visual/publish`
|
||||
29. 接入 `GET /api/assets/character-animation/templates`
|
||||
30. 接入 `POST /api/assets/character-animation/import-video`
|
||||
31. 接入 `GET /api/assets/character-workflow-cache/{character_id}`
|
||||
32. 接入 `POST /api/assets/character-workflow-cache`
|
||||
33. 接入 `POST /api/assets/character-animation/generate`
|
||||
34. 接入 `GET /api/assets/character-animation/jobs/{task_id}`
|
||||
35. 接入 `POST /api/assets/character-animation/publish`
|
||||
36. 接入旧 `/generated-character-drafts/*`、`/generated-characters/*`、`/generated-animations/*`、`/generated-custom-world-scenes/*`、`/generated-custom-world-covers/*`、`/generated-qwen-sprites/*` 到 OSS 私有读代理
|
||||
|
||||
后续与本 crate 直接相关的任务包括:
|
||||
|
||||
@@ -68,6 +85,12 @@
|
||||
19. [x] 接入 `/api/assets/sts-upload-credentials`
|
||||
20. [x] 接入 `custom world library / gallery / publish_world` 首批 facade
|
||||
21. [x] 接入 `custom world agent session create / snapshot` facade
|
||||
22. [x] 接入旧 `runtime story` compat facade
|
||||
23. [x] 接入 `character-visual generate / jobs / publish` 第一批 OSS 主链兼容 facade
|
||||
24. [x] 接入 `character-animation templates / import-video` 第一批 OSS 草稿兼容 facade
|
||||
25. [x] 接入 `character-workflow-cache get / save` 第一批 OSS JSON 草稿兼容 facade
|
||||
26. [x] 接入 `character-animation generate / jobs / publish` 第一批 OSS 主链兼容 facade
|
||||
27. [x] 接入旧 `/generated-*` 路径到 OSS 私有读同源代理
|
||||
|
||||
当前 tracing 约定:
|
||||
|
||||
@@ -136,3 +159,10 @@
|
||||
12. 当前微信回调不会把第三方 token 直接透传给前端或 SpacetimeDB,而是统一换成系统签发的 JWT。
|
||||
13. 当前 `/api/assets/sts-upload-credentials` 按“服务器上传、Web 只下载”口径固定返回 `403`,不向浏览器下发 OSS 写权限。
|
||||
14. 当前 `/api/runtime/custom-world/agent/sessions` 与 `/api/runtime/custom-world/agent/sessions/{session_id}` 只提供 deterministic session 骨架与 snapshot 读取,不承诺 message submit、operation query、card detail 的完整能力。
|
||||
15. 当前 `/api/runtime/story/*` 已在 Rust 侧补齐 compat handler,但内部仍是 `runtime_snapshot` 驱动的兼容桥与确定性动作编排,不应误判为真正的 SpacetimeDB `resolve_story_action` 真相链已完成。
|
||||
16. 当前 `/api/assets/character-visual/*` 第一批只保证旧接口 contract、OSS 草稿/正式对象、`asset_object` 与 `asset_entity_binding` 主链可用;真实图片模型、workflow cache 与本地角色覆盖写回仍在后续阶段。
|
||||
17. 当前 `/api/assets/character-animation/import-video` 第一批只接受 `data:video/*;base64,...` 并写入 OSS 草稿区,不读取旧本地 `public/` 路径,也不创建正式 `asset_object`。
|
||||
18. 当前 `/api/assets/character-workflow-cache/*` 第一批只把工作流 JSON 草稿写入 OSS,不迁移历史本地缓存,也不创建正式 `asset_object`。
|
||||
19. 当前 `/api/assets/character-animation/generate` 第一批只用 Rust 占位产物打通 `AiTaskService + OSS` 草稿链;`image-sequence` 写 SVG 帧,视频类策略优先复用参考视频或仓库内可播放占位视频,不代表真实上游视频模型已完成迁移。
|
||||
20. 当前 `/api/assets/character-animation/publish` 会把前端提交帧、动作级 manifest 与总 manifest 写入 OSS,并只把总 manifest 确认为 `asset_object` 后绑定到 `character / animation_set`。
|
||||
21. 当前旧 `/generated-*` 读取兼容层只代理受支持 generated 前缀到 OSS 私有读签名,不回退仓库 `public/`,Stage 1 不支持视频 Range 分片。
|
||||
|
||||
@@ -6,8 +6,11 @@ use axum::{
|
||||
middleware,
|
||||
routing::{get, post},
|
||||
};
|
||||
use tower_http::trace::{DefaultOnFailure, DefaultOnRequest, DefaultOnResponse, TraceLayer};
|
||||
use tracing::{Level, info_span};
|
||||
use tower_http::{
|
||||
classify::ServerErrorsFailureClass,
|
||||
trace::{DefaultOnRequest, TraceLayer},
|
||||
};
|
||||
use tracing::{Level, Span, error, info, info_span, warn};
|
||||
|
||||
use crate::{
|
||||
ai_tasks::{
|
||||
@@ -29,13 +32,19 @@ use crate::{
|
||||
start_big_fish_run, stream_big_fish_message, submit_big_fish_input,
|
||||
submit_big_fish_message,
|
||||
},
|
||||
character_animation_assets::{
|
||||
generate_character_animation, get_character_animation_job, get_character_workflow_cache,
|
||||
import_character_animation_video, list_character_animation_templates,
|
||||
publish_character_animation, save_character_workflow_cache,
|
||||
},
|
||||
character_visual_assets::{
|
||||
generate_character_visual, get_character_visual_job, publish_character_visual,
|
||||
},
|
||||
custom_world::{
|
||||
create_custom_world_agent_session, execute_custom_world_agent_action,
|
||||
get_custom_world_agent_card_detail,
|
||||
get_custom_world_agent_operation, get_custom_world_agent_session,
|
||||
get_custom_world_works,
|
||||
get_custom_world_gallery_detail, get_custom_world_library,
|
||||
get_custom_world_library_detail, list_custom_world_gallery,
|
||||
get_custom_world_agent_card_detail, get_custom_world_agent_operation,
|
||||
get_custom_world_agent_session, get_custom_world_gallery_detail, get_custom_world_library,
|
||||
get_custom_world_library_detail, get_custom_world_works, list_custom_world_gallery,
|
||||
publish_custom_world_library_profile, put_custom_world_library_profile,
|
||||
stream_custom_world_agent_message, submit_custom_world_agent_message,
|
||||
unpublish_custom_world_library_profile,
|
||||
@@ -47,6 +56,11 @@ use crate::{
|
||||
},
|
||||
error_middleware::normalize_error_response,
|
||||
health::health_check,
|
||||
legacy_generated_assets::{
|
||||
proxy_generated_animations, proxy_generated_character_drafts, proxy_generated_characters,
|
||||
proxy_generated_custom_world_covers, proxy_generated_custom_world_scenes,
|
||||
proxy_generated_qwen_sprites,
|
||||
},
|
||||
llm::proxy_llm_chat_completions,
|
||||
login_options::auth_login_options,
|
||||
logout::logout,
|
||||
@@ -74,8 +88,8 @@ use crate::{
|
||||
},
|
||||
runtime_settings::{get_runtime_settings, put_runtime_settings},
|
||||
runtime_story::{
|
||||
generate_runtime_story_continue, generate_runtime_story_initial,
|
||||
get_runtime_story_state, resolve_runtime_story_action, resolve_runtime_story_state,
|
||||
generate_runtime_story_continue, generate_runtime_story_initial, get_runtime_story_state,
|
||||
resolve_runtime_story_action, resolve_runtime_story_state,
|
||||
},
|
||||
state::AppState,
|
||||
story_battles::{
|
||||
@@ -87,6 +101,8 @@ use crate::{
|
||||
|
||||
// 统一由这里构造 Axum 路由树,后续再逐项挂接中间件与业务路由。
|
||||
pub fn build_router(state: AppState) -> Router {
|
||||
let slow_request_threshold_ms = state.config.slow_request_threshold_ms;
|
||||
|
||||
Router::new()
|
||||
.route(
|
||||
"/healthz",
|
||||
@@ -109,6 +125,30 @@ pub fn build_router(state: AppState) -> Router {
|
||||
)),
|
||||
)
|
||||
.route("/api/auth/login-options", get(auth_login_options))
|
||||
.route(
|
||||
"/generated-character-drafts/{*path}",
|
||||
get(proxy_generated_character_drafts),
|
||||
)
|
||||
.route(
|
||||
"/generated-characters/{*path}",
|
||||
get(proxy_generated_characters),
|
||||
)
|
||||
.route(
|
||||
"/generated-animations/{*path}",
|
||||
get(proxy_generated_animations),
|
||||
)
|
||||
.route(
|
||||
"/generated-custom-world-scenes/{*path}",
|
||||
get(proxy_generated_custom_world_scenes),
|
||||
)
|
||||
.route(
|
||||
"/generated-custom-world-covers/{*path}",
|
||||
get(proxy_generated_custom_world_covers),
|
||||
)
|
||||
.route(
|
||||
"/generated-qwen-sprites/{*path}",
|
||||
get(proxy_generated_qwen_sprites),
|
||||
)
|
||||
.route(
|
||||
"/api/auth/me",
|
||||
get(auth_me).route_layer(middleware::from_fn_with_state(
|
||||
@@ -248,15 +288,55 @@ pub fn build_router(state: AppState) -> Router {
|
||||
"/api/assets/objects/bind",
|
||||
post(bind_asset_object_to_entity),
|
||||
)
|
||||
.route(
|
||||
"/api/assets/character-visual/generate",
|
||||
post(generate_character_visual),
|
||||
)
|
||||
.route(
|
||||
"/api/assets/character-visual/jobs/{task_id}",
|
||||
get(get_character_visual_job),
|
||||
)
|
||||
.route(
|
||||
"/api/assets/character-visual/publish",
|
||||
post(publish_character_visual),
|
||||
)
|
||||
.route(
|
||||
"/api/assets/character-animation/generate",
|
||||
post(generate_character_animation),
|
||||
)
|
||||
.route(
|
||||
"/api/assets/character-animation/jobs/{task_id}",
|
||||
get(get_character_animation_job),
|
||||
)
|
||||
.route(
|
||||
"/api/assets/character-animation/publish",
|
||||
post(publish_character_animation),
|
||||
)
|
||||
.route(
|
||||
"/api/assets/character-animation/import-video",
|
||||
post(import_character_animation_video),
|
||||
)
|
||||
.route(
|
||||
"/api/assets/character-animation/templates",
|
||||
get(list_character_animation_templates),
|
||||
)
|
||||
.route(
|
||||
"/api/assets/character-workflow-cache",
|
||||
post(save_character_workflow_cache),
|
||||
)
|
||||
.route(
|
||||
"/api/assets/character-workflow-cache/{character_id}",
|
||||
get(get_character_workflow_cache),
|
||||
)
|
||||
.route("/api/assets/read-url", get(get_asset_read_url))
|
||||
.route(
|
||||
"/api/runtime/settings",
|
||||
get(get_runtime_settings)
|
||||
.put(put_runtime_settings)
|
||||
.route_layer(middleware::from_fn_with_state(
|
||||
state.clone(),
|
||||
require_bearer_auth,
|
||||
)),
|
||||
state.clone(),
|
||||
require_bearer_auth,
|
||||
)),
|
||||
)
|
||||
.route(
|
||||
"/api/runtime/save/snapshot",
|
||||
@@ -328,9 +408,10 @@ pub fn build_router(state: AppState) -> Router {
|
||||
)
|
||||
.route(
|
||||
"/api/runtime/custom-world/agent/sessions/{session_id}/cards/{card_id}",
|
||||
get(get_custom_world_agent_card_detail).route_layer(
|
||||
middleware::from_fn_with_state(state.clone(), require_bearer_auth),
|
||||
),
|
||||
get(get_custom_world_agent_card_detail).route_layer(middleware::from_fn_with_state(
|
||||
state.clone(),
|
||||
require_bearer_auth,
|
||||
)),
|
||||
)
|
||||
.route(
|
||||
"/api/runtime/custom-world/agent/sessions/{session_id}/messages",
|
||||
@@ -523,45 +604,52 @@ pub fn build_router(state: AppState) -> Router {
|
||||
)
|
||||
.route(
|
||||
"/api/custom-world/scene-npc",
|
||||
post(generate_custom_world_scene_npc).route_layer(
|
||||
middleware::from_fn_with_state(state.clone(), require_bearer_auth),
|
||||
),
|
||||
post(generate_custom_world_scene_npc).route_layer(middleware::from_fn_with_state(
|
||||
state.clone(),
|
||||
require_bearer_auth,
|
||||
)),
|
||||
)
|
||||
.route(
|
||||
"/api/runtime/custom-world/scene-npc",
|
||||
post(generate_custom_world_scene_npc).route_layer(
|
||||
middleware::from_fn_with_state(state.clone(), require_bearer_auth),
|
||||
),
|
||||
post(generate_custom_world_scene_npc).route_layer(middleware::from_fn_with_state(
|
||||
state.clone(),
|
||||
require_bearer_auth,
|
||||
)),
|
||||
)
|
||||
.route(
|
||||
"/api/custom-world/scene-image",
|
||||
post(generate_custom_world_scene_image).route_layer(
|
||||
middleware::from_fn_with_state(state.clone(), require_bearer_auth),
|
||||
),
|
||||
post(generate_custom_world_scene_image).route_layer(middleware::from_fn_with_state(
|
||||
state.clone(),
|
||||
require_bearer_auth,
|
||||
)),
|
||||
)
|
||||
.route(
|
||||
"/api/custom-world/cover-image",
|
||||
post(generate_custom_world_cover_image).route_layer(
|
||||
middleware::from_fn_with_state(state.clone(), require_bearer_auth),
|
||||
),
|
||||
post(generate_custom_world_cover_image).route_layer(middleware::from_fn_with_state(
|
||||
state.clone(),
|
||||
require_bearer_auth,
|
||||
)),
|
||||
)
|
||||
.route(
|
||||
"/api/runtime/custom-world/cover-image",
|
||||
post(generate_custom_world_cover_image).route_layer(
|
||||
middleware::from_fn_with_state(state.clone(), require_bearer_auth),
|
||||
),
|
||||
post(generate_custom_world_cover_image).route_layer(middleware::from_fn_with_state(
|
||||
state.clone(),
|
||||
require_bearer_auth,
|
||||
)),
|
||||
)
|
||||
.route(
|
||||
"/api/custom-world/cover-upload",
|
||||
post(upload_custom_world_cover_image).route_layer(
|
||||
middleware::from_fn_with_state(state.clone(), require_bearer_auth),
|
||||
),
|
||||
post(upload_custom_world_cover_image).route_layer(middleware::from_fn_with_state(
|
||||
state.clone(),
|
||||
require_bearer_auth,
|
||||
)),
|
||||
)
|
||||
.route(
|
||||
"/api/runtime/custom-world/cover-upload",
|
||||
post(upload_custom_world_cover_image).route_layer(
|
||||
middleware::from_fn_with_state(state.clone(), require_bearer_auth),
|
||||
),
|
||||
post(upload_custom_world_cover_image).route_layer(middleware::from_fn_with_state(
|
||||
state.clone(),
|
||||
require_bearer_auth,
|
||||
)),
|
||||
)
|
||||
.route(
|
||||
"/api/runtime/profile/browse-history",
|
||||
@@ -764,8 +852,47 @@ pub fn build_router(state: AppState) -> Router {
|
||||
)
|
||||
})
|
||||
.on_request(DefaultOnRequest::new().level(Level::INFO))
|
||||
.on_response(DefaultOnResponse::new().level(Level::INFO))
|
||||
.on_failure(DefaultOnFailure::new().level(Level::ERROR)),
|
||||
.on_response(
|
||||
move |response: &axum::response::Response,
|
||||
latency: std::time::Duration,
|
||||
span: &Span| {
|
||||
let latency_ms = latency.as_millis().min(u64::MAX as u128) as u64;
|
||||
let status = response.status().as_u16();
|
||||
let slow_request = latency_ms >= slow_request_threshold_ms;
|
||||
span.record("status", status);
|
||||
span.record("latency_ms", latency_ms);
|
||||
if slow_request {
|
||||
warn!(
|
||||
parent: span,
|
||||
status,
|
||||
latency_ms,
|
||||
slow_request = true,
|
||||
"http request completed slowly"
|
||||
);
|
||||
} else {
|
||||
info!(
|
||||
parent: span,
|
||||
status,
|
||||
latency_ms,
|
||||
slow_request = false,
|
||||
"http request completed"
|
||||
);
|
||||
}
|
||||
},
|
||||
)
|
||||
.on_failure(
|
||||
|failure: ServerErrorsFailureClass,
|
||||
latency: std::time::Duration,
|
||||
span: &Span| {
|
||||
let latency_ms = latency.as_millis().min(u64::MAX as u128) as u64;
|
||||
error!(
|
||||
parent: span,
|
||||
latency_ms,
|
||||
failure = %failure,
|
||||
"http request failed"
|
||||
);
|
||||
},
|
||||
),
|
||||
)
|
||||
// request_id 中间件先进入请求链,确保后续 tracing、错误处理和响应头层都能复用同一份请求标识。
|
||||
.layer(middleware::from_fn(attach_request_context))
|
||||
|
||||
1930
server-rs/crates/api-server/src/character_animation_assets.rs
Normal file
1930
server-rs/crates/api-server/src/character_animation_assets.rs
Normal file
File diff suppressed because it is too large
Load Diff
938
server-rs/crates/api-server/src/character_visual_assets.rs
Normal file
938
server-rs/crates/api-server/src/character_visual_assets.rs
Normal file
@@ -0,0 +1,938 @@
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use axum::{
|
||||
Json,
|
||||
extract::{Extension, Path, State, rejection::JsonRejection},
|
||||
http::StatusCode,
|
||||
response::Response,
|
||||
};
|
||||
use module_ai::{
|
||||
AiResultReferenceKind, AiStageCompletionInput, AiTaskCreateInput, AiTaskKind,
|
||||
AiTaskServiceError, AiTaskSnapshot, AiTaskStageKind, AiTaskStatus, generate_ai_task_id,
|
||||
};
|
||||
use module_assets::{
|
||||
AssetObjectAccessPolicy, AssetObjectFieldError, build_asset_entity_binding_input,
|
||||
build_asset_object_upsert_input, generate_asset_binding_id, generate_asset_object_id,
|
||||
};
|
||||
use platform_llm::{LlmMessage, LlmTextRequest};
|
||||
use platform_oss::{
|
||||
LegacyAssetPrefix, OssHeadObjectRequest, OssObjectAccess, OssPutObjectRequest,
|
||||
OssSignedGetObjectUrlRequest,
|
||||
};
|
||||
use serde_json::{Value, json};
|
||||
use shared_contracts::assets::{
|
||||
CharacterAssetJobStatusPayload, CharacterAssetJobStatusText, CharacterVisualDraftPayload,
|
||||
CharacterVisualGenerateRequest, CharacterVisualGenerateResponse, CharacterVisualPublishRequest,
|
||||
CharacterVisualPublishResponse,
|
||||
};
|
||||
use spacetime_client::SpacetimeClientError;
|
||||
|
||||
use crate::{
|
||||
api_response::json_success_body, http_error::AppError, request_context::RequestContext,
|
||||
state::AppState,
|
||||
};
|
||||
|
||||
const CHARACTER_VISUAL_MODEL: &str = "rust-svg-character-visual";
|
||||
const CHARACTER_VISUAL_ASSET_KIND: &str = "character_visual";
|
||||
const CHARACTER_VISUAL_ENTITY_KIND: &str = "character";
|
||||
const CHARACTER_VISUAL_SLOT: &str = "primary_visual";
|
||||
|
||||
pub async fn generate_character_visual(
|
||||
State(state): State<AppState>,
|
||||
Extension(request_context): Extension<RequestContext>,
|
||||
payload: Result<Json<CharacterVisualGenerateRequest>, JsonRejection>,
|
||||
) -> Result<Json<Value>, Response> {
|
||||
let Json(payload) = payload.map_err(|error| {
|
||||
character_visual_error_response(
|
||||
&request_context,
|
||||
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
|
||||
"provider": "character-visual",
|
||||
"message": error.body_text(),
|
||||
})),
|
||||
)
|
||||
})?;
|
||||
|
||||
// 旧资产工坊接口没有显式 Bearer 头,Rust 兼容层先使用工具用户归属,避免破坏现有前端调用。
|
||||
let owner_user_id = "asset-tool".to_string();
|
||||
let task_id = generate_ai_task_id(current_utc_micros());
|
||||
let prompt = build_character_visual_prompt(
|
||||
payload.prompt_text.as_str(),
|
||||
payload.character_brief_text.as_deref(),
|
||||
);
|
||||
let character_id = normalize_required_text(payload.character_id.as_str(), "character");
|
||||
let model = normalize_required_text(payload.image_model.as_str(), CHARACTER_VISUAL_MODEL);
|
||||
let size = normalize_required_text(payload.size.as_str(), "1024*1024");
|
||||
let candidate_count = payload.candidate_count.clamp(1, 4);
|
||||
|
||||
let created = create_visual_task(
|
||||
&state,
|
||||
&task_id,
|
||||
&owner_user_id,
|
||||
&character_id,
|
||||
&model,
|
||||
&prompt,
|
||||
)
|
||||
.map_err(|error| character_visual_error_response(&request_context, error))?;
|
||||
|
||||
let result = async {
|
||||
state
|
||||
.ai_task_service()
|
||||
.start_task(task_id.as_str(), current_utc_micros())
|
||||
.map_err(map_ai_task_error)?;
|
||||
state
|
||||
.ai_task_service()
|
||||
.start_stage(
|
||||
task_id.as_str(),
|
||||
AiTaskStageKind::PreparePrompt,
|
||||
current_utc_micros(),
|
||||
)
|
||||
.map_err(map_ai_task_error)?;
|
||||
state
|
||||
.ai_task_service()
|
||||
.complete_stage(AiStageCompletionInput {
|
||||
task_id: task_id.clone(),
|
||||
stage_kind: AiTaskStageKind::PreparePrompt,
|
||||
text_output: Some(prompt.clone()),
|
||||
structured_payload_json: Some(
|
||||
json!({
|
||||
"characterId": character_id,
|
||||
"sourceMode": payload.source_mode,
|
||||
"size": size,
|
||||
"referenceImageCount": payload.reference_image_data_urls.len(),
|
||||
})
|
||||
.to_string(),
|
||||
),
|
||||
warning_messages: Vec::new(),
|
||||
completed_at_micros: current_utc_micros(),
|
||||
})
|
||||
.map_err(map_ai_task_error)?;
|
||||
|
||||
let visual_seed = generate_visual_seed_with_llm(&state, &prompt, &character_id).await;
|
||||
|
||||
state
|
||||
.ai_task_service()
|
||||
.start_stage(
|
||||
task_id.as_str(),
|
||||
AiTaskStageKind::RequestModel,
|
||||
current_utc_micros(),
|
||||
)
|
||||
.map_err(map_ai_task_error)?;
|
||||
state
|
||||
.ai_task_service()
|
||||
.complete_stage(AiStageCompletionInput {
|
||||
task_id: task_id.clone(),
|
||||
stage_kind: AiTaskStageKind::RequestModel,
|
||||
text_output: Some(visual_seed.clone()),
|
||||
structured_payload_json: None,
|
||||
warning_messages: Vec::new(),
|
||||
completed_at_micros: current_utc_micros(),
|
||||
})
|
||||
.map_err(map_ai_task_error)?;
|
||||
|
||||
let drafts = persist_visual_drafts(
|
||||
&state,
|
||||
&owner_user_id,
|
||||
&character_id,
|
||||
&task_id,
|
||||
&visual_seed,
|
||||
&size,
|
||||
candidate_count,
|
||||
)
|
||||
.await?;
|
||||
|
||||
let result_payload = json!({
|
||||
"drafts": drafts,
|
||||
"draftRelativeDir": format!(
|
||||
"generated-character-drafts/{}/visual/{}",
|
||||
sanitize_storage_segment(character_id.as_str(), "character"),
|
||||
task_id
|
||||
),
|
||||
});
|
||||
|
||||
state
|
||||
.ai_task_service()
|
||||
.start_stage(
|
||||
task_id.as_str(),
|
||||
AiTaskStageKind::NormalizeResult,
|
||||
current_utc_micros(),
|
||||
)
|
||||
.map_err(map_ai_task_error)?;
|
||||
state
|
||||
.ai_task_service()
|
||||
.complete_stage(AiStageCompletionInput {
|
||||
task_id: task_id.clone(),
|
||||
stage_kind: AiTaskStageKind::NormalizeResult,
|
||||
text_output: None,
|
||||
structured_payload_json: Some(result_payload.to_string()),
|
||||
warning_messages: Vec::new(),
|
||||
completed_at_micros: current_utc_micros(),
|
||||
})
|
||||
.map_err(map_ai_task_error)?;
|
||||
state
|
||||
.ai_task_service()
|
||||
.complete_stage(AiStageCompletionInput {
|
||||
task_id: task_id.clone(),
|
||||
stage_kind: AiTaskStageKind::PersistResult,
|
||||
text_output: Some("角色主形象候选草稿已写入 OSS。".to_string()),
|
||||
structured_payload_json: Some(result_payload.to_string()),
|
||||
warning_messages: Vec::new(),
|
||||
completed_at_micros: current_utc_micros(),
|
||||
})
|
||||
.map_err(map_ai_task_error)?;
|
||||
state
|
||||
.ai_task_service()
|
||||
.complete_task(task_id.as_str(), current_utc_micros())
|
||||
.map_err(map_ai_task_error)?;
|
||||
|
||||
Ok::<_, AppError>(drafts)
|
||||
}
|
||||
.await;
|
||||
|
||||
let drafts = match result {
|
||||
Ok(drafts) => drafts,
|
||||
Err(error) => {
|
||||
let _ = state.ai_task_service().fail_task(
|
||||
created.task_id.as_str(),
|
||||
error.message().to_string(),
|
||||
current_utc_micros(),
|
||||
);
|
||||
return Err(character_visual_error_response(&request_context, error));
|
||||
}
|
||||
};
|
||||
|
||||
Ok(json_success_body(
|
||||
Some(&request_context),
|
||||
CharacterVisualGenerateResponse {
|
||||
ok: true,
|
||||
task_id,
|
||||
model,
|
||||
prompt,
|
||||
drafts,
|
||||
},
|
||||
))
|
||||
}
|
||||
|
||||
pub async fn get_character_visual_job(
|
||||
State(state): State<AppState>,
|
||||
Extension(request_context): Extension<RequestContext>,
|
||||
Path(task_id): Path<String>,
|
||||
) -> Result<Json<Value>, Response> {
|
||||
let task = state
|
||||
.ai_task_service()
|
||||
.get_task(task_id.as_str())
|
||||
.map_err(map_ai_task_error)
|
||||
.map_err(|error| character_visual_error_response(&request_context, error))?;
|
||||
|
||||
Ok(json_success_body(
|
||||
Some(&request_context),
|
||||
build_character_visual_job_payload(task),
|
||||
))
|
||||
}
|
||||
|
||||
pub async fn publish_character_visual(
|
||||
State(state): State<AppState>,
|
||||
Extension(request_context): Extension<RequestContext>,
|
||||
payload: Result<Json<CharacterVisualPublishRequest>, JsonRejection>,
|
||||
) -> Result<Json<Value>, Response> {
|
||||
let Json(payload) = payload.map_err(|error| {
|
||||
character_visual_error_response(
|
||||
&request_context,
|
||||
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
|
||||
"provider": "character-visual",
|
||||
"message": error.body_text(),
|
||||
})),
|
||||
)
|
||||
})?;
|
||||
|
||||
// 旧资产工坊接口没有显式 Bearer 头,Rust 兼容层先使用工具用户归属,避免破坏现有前端调用。
|
||||
let owner_user_id = "asset-tool".to_string();
|
||||
let character_id = normalize_required_text(payload.character_id.as_str(), "character");
|
||||
if payload.selected_preview_source.trim().is_empty() {
|
||||
return Err(character_visual_error_response(
|
||||
&request_context,
|
||||
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
|
||||
"provider": "character-visual",
|
||||
"message": "selectedPreviewSource is required.",
|
||||
})),
|
||||
));
|
||||
}
|
||||
|
||||
let asset_id = format!("visual-{}", current_utc_millis());
|
||||
let published = persist_published_visual(
|
||||
&state,
|
||||
&owner_user_id,
|
||||
&character_id,
|
||||
asset_id.as_str(),
|
||||
payload.selected_preview_source.as_str(),
|
||||
payload.prompt_text.as_deref(),
|
||||
)
|
||||
.await
|
||||
.map_err(|error| character_visual_error_response(&request_context, error))?;
|
||||
|
||||
Ok(json_success_body(
|
||||
Some(&request_context),
|
||||
CharacterVisualPublishResponse {
|
||||
ok: true,
|
||||
asset_id,
|
||||
portrait_path: published,
|
||||
override_map: json!({}),
|
||||
save_message: if payload.update_character_override == Some(false) {
|
||||
"主形象已写入 OSS 并绑定当前角色,可直接写回当前自定义世界角色。".to_string()
|
||||
} else {
|
||||
"主形象已写入 OSS 并绑定当前角色;Rust 后端不再写本地角色覆盖文件。".to_string()
|
||||
},
|
||||
},
|
||||
))
|
||||
}
|
||||
|
||||
fn create_visual_task(
|
||||
state: &AppState,
|
||||
task_id: &str,
|
||||
owner_user_id: &str,
|
||||
character_id: &str,
|
||||
model: &str,
|
||||
prompt: &str,
|
||||
) -> Result<AiTaskSnapshot, AppError> {
|
||||
state
|
||||
.ai_task_service()
|
||||
.create_task(AiTaskCreateInput {
|
||||
task_id: task_id.to_string(),
|
||||
task_kind: AiTaskKind::CustomWorldGeneration,
|
||||
owner_user_id: owner_user_id.to_string(),
|
||||
request_label: "生成角色主形象".to_string(),
|
||||
source_module: "assets.character_visual".to_string(),
|
||||
source_entity_id: Some(character_id.to_string()),
|
||||
request_payload_json: Some(
|
||||
json!({
|
||||
"characterId": character_id,
|
||||
"model": model,
|
||||
"prompt": prompt,
|
||||
})
|
||||
.to_string(),
|
||||
),
|
||||
stages: AiTaskKind::CustomWorldGeneration.default_stage_blueprints(),
|
||||
created_at_micros: current_utc_micros(),
|
||||
})
|
||||
.map_err(map_ai_task_error)
|
||||
}
|
||||
|
||||
async fn generate_visual_seed_with_llm(
|
||||
state: &AppState,
|
||||
prompt: &str,
|
||||
character_id: &str,
|
||||
) -> String {
|
||||
let fallback = format!("{character_id}:{prompt}");
|
||||
let Some(llm_client) = state.llm_client() else {
|
||||
return fallback;
|
||||
};
|
||||
|
||||
let request = LlmTextRequest::new(vec![
|
||||
LlmMessage::system(
|
||||
"你是游戏角色主形象草稿描述器。只输出一句中文视觉摘要,不要输出 Markdown。",
|
||||
),
|
||||
LlmMessage::user(
|
||||
json!({
|
||||
"task": "summarize_character_visual_seed",
|
||||
"characterId": character_id,
|
||||
"prompt": prompt,
|
||||
})
|
||||
.to_string(),
|
||||
),
|
||||
])
|
||||
.with_max_tokens(96);
|
||||
|
||||
llm_client
|
||||
.request_text(request)
|
||||
.await
|
||||
.ok()
|
||||
.map(|response| response.content.trim().to_string())
|
||||
.filter(|value| !value.is_empty())
|
||||
.unwrap_or(fallback)
|
||||
}
|
||||
|
||||
async fn persist_visual_drafts(
|
||||
state: &AppState,
|
||||
owner_user_id: &str,
|
||||
character_id: &str,
|
||||
task_id: &str,
|
||||
visual_seed: &str,
|
||||
size: &str,
|
||||
candidate_count: u32,
|
||||
) -> Result<Vec<CharacterVisualDraftPayload>, AppError> {
|
||||
let mut drafts = Vec::with_capacity(candidate_count as usize);
|
||||
for index in 0..candidate_count {
|
||||
let file_name = format!("candidate-{:02}.svg", index + 1);
|
||||
let body =
|
||||
build_character_visual_svg(size, visual_seed, format!("候选 {}", index + 1).as_str())
|
||||
.into_bytes();
|
||||
let put_result = put_character_visual_object(
|
||||
state,
|
||||
LegacyAssetPrefix::CharacterDrafts,
|
||||
vec![
|
||||
sanitize_storage_segment(character_id, "character"),
|
||||
"visual".to_string(),
|
||||
task_id.to_string(),
|
||||
],
|
||||
file_name,
|
||||
"image/svg+xml".to_string(),
|
||||
body,
|
||||
build_asset_metadata(
|
||||
CHARACTER_VISUAL_ASSET_KIND,
|
||||
owner_user_id,
|
||||
CHARACTER_VISUAL_ENTITY_KIND,
|
||||
character_id,
|
||||
"draft",
|
||||
),
|
||||
)
|
||||
.await?;
|
||||
|
||||
drafts.push(CharacterVisualDraftPayload {
|
||||
id: format!("candidate-{}", index + 1),
|
||||
label: format!("候选 {}", index + 1),
|
||||
image_src: put_result.legacy_public_path,
|
||||
width: parse_size(size).0,
|
||||
height: parse_size(size).1,
|
||||
});
|
||||
}
|
||||
|
||||
Ok(drafts)
|
||||
}
|
||||
|
||||
async fn persist_published_visual(
|
||||
state: &AppState,
|
||||
owner_user_id: &str,
|
||||
character_id: &str,
|
||||
asset_id: &str,
|
||||
selected_preview_source: &str,
|
||||
prompt_text: Option<&str>,
|
||||
) -> Result<String, AppError> {
|
||||
let oss_client = require_oss_client(state)?;
|
||||
let http_client = reqwest::Client::new();
|
||||
let source_object_key = resolve_object_key_from_legacy_path(selected_preview_source)?;
|
||||
let head = oss_client
|
||||
.head_object(
|
||||
&http_client,
|
||||
OssHeadObjectRequest {
|
||||
object_key: source_object_key.clone(),
|
||||
},
|
||||
)
|
||||
.await
|
||||
.map_err(map_character_visual_oss_error)?;
|
||||
let signed = oss_client
|
||||
.sign_get_object_url(OssSignedGetObjectUrlRequest {
|
||||
object_key: source_object_key,
|
||||
expire_seconds: Some(60),
|
||||
})
|
||||
.map_err(map_character_visual_oss_error)?;
|
||||
let source_body = http_client
|
||||
.get(signed.signed_url)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|error| {
|
||||
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
|
||||
"provider": "aliyun-oss",
|
||||
"message": format!("读取候选主形象失败:{error}"),
|
||||
}))
|
||||
})?
|
||||
.error_for_status()
|
||||
.map_err(|error| {
|
||||
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
|
||||
"provider": "aliyun-oss",
|
||||
"message": format!("读取候选主形象失败:{error}"),
|
||||
}))
|
||||
})?
|
||||
.bytes()
|
||||
.await
|
||||
.map_err(|error| {
|
||||
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
|
||||
"provider": "aliyun-oss",
|
||||
"message": format!("读取候选主形象内容失败:{error}"),
|
||||
}))
|
||||
})?
|
||||
.to_vec();
|
||||
|
||||
let content_type = head
|
||||
.content_type
|
||||
.clone()
|
||||
.unwrap_or_else(|| "image/svg+xml".to_string());
|
||||
let file_name = match content_type.as_str() {
|
||||
"image/png" => "master.png",
|
||||
"image/jpeg" => "master.jpg",
|
||||
"image/webp" => "master.webp",
|
||||
_ => "master.svg",
|
||||
}
|
||||
.to_string();
|
||||
let put_result = put_character_visual_object(
|
||||
state,
|
||||
LegacyAssetPrefix::Characters,
|
||||
vec![
|
||||
sanitize_storage_segment(character_id, "character"),
|
||||
"visual".to_string(),
|
||||
asset_id.to_string(),
|
||||
],
|
||||
file_name,
|
||||
content_type.clone(),
|
||||
source_body,
|
||||
build_asset_metadata(
|
||||
CHARACTER_VISUAL_ASSET_KIND,
|
||||
owner_user_id,
|
||||
CHARACTER_VISUAL_ENTITY_KIND,
|
||||
character_id,
|
||||
CHARACTER_VISUAL_SLOT,
|
||||
),
|
||||
)
|
||||
.await?;
|
||||
let confirmed = confirm_character_visual_asset_object(
|
||||
state,
|
||||
owner_user_id,
|
||||
character_id,
|
||||
asset_id,
|
||||
put_result.object_key.clone(),
|
||||
content_type,
|
||||
prompt_text.map(str::to_string),
|
||||
)
|
||||
.await?;
|
||||
bind_character_visual_asset(
|
||||
state,
|
||||
owner_user_id,
|
||||
character_id,
|
||||
confirmed.record.asset_object_id,
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(put_result.legacy_public_path)
|
||||
}
|
||||
|
||||
async fn put_character_visual_object(
|
||||
state: &AppState,
|
||||
prefix: LegacyAssetPrefix,
|
||||
path_segments: Vec<String>,
|
||||
file_name: String,
|
||||
content_type: String,
|
||||
body: Vec<u8>,
|
||||
metadata: BTreeMap<String, String>,
|
||||
) -> Result<platform_oss::OssPutObjectResponse, AppError> {
|
||||
let oss_client = require_oss_client(state)?;
|
||||
oss_client
|
||||
.put_object(
|
||||
&reqwest::Client::new(),
|
||||
OssPutObjectRequest {
|
||||
prefix,
|
||||
path_segments,
|
||||
file_name,
|
||||
content_type: Some(content_type),
|
||||
access: OssObjectAccess::Private,
|
||||
metadata,
|
||||
body,
|
||||
},
|
||||
)
|
||||
.await
|
||||
.map_err(map_character_visual_oss_error)
|
||||
}
|
||||
|
||||
async fn confirm_character_visual_asset_object(
|
||||
state: &AppState,
|
||||
owner_user_id: &str,
|
||||
character_id: &str,
|
||||
source_job_id: &str,
|
||||
object_key: String,
|
||||
content_type: String,
|
||||
prompt_text: Option<String>,
|
||||
) -> Result<module_assets::ConfirmAssetObjectResult, AppError> {
|
||||
let oss_client = require_oss_client(state)?;
|
||||
let head = oss_client
|
||||
.head_object(&reqwest::Client::new(), OssHeadObjectRequest { object_key })
|
||||
.await
|
||||
.map_err(map_character_visual_oss_error)?;
|
||||
let now_micros = current_utc_micros();
|
||||
let record = state
|
||||
.spacetime_client()
|
||||
.confirm_asset_object(
|
||||
build_asset_object_upsert_input(
|
||||
generate_asset_object_id(now_micros),
|
||||
head.bucket,
|
||||
head.object_key,
|
||||
AssetObjectAccessPolicy::Private,
|
||||
head.content_type.or(Some(content_type)),
|
||||
head.content_length,
|
||||
prompt_text.or(head.etag),
|
||||
CHARACTER_VISUAL_ASSET_KIND.to_string(),
|
||||
Some(source_job_id.to_string()),
|
||||
Some(owner_user_id.to_string()),
|
||||
None,
|
||||
Some(character_id.to_string()),
|
||||
now_micros,
|
||||
)
|
||||
.map_err(map_asset_object_prepare_error)?,
|
||||
)
|
||||
.await
|
||||
.map_err(map_character_visual_spacetime_error)?;
|
||||
let _ = state.ai_task_service().attach_result_reference(
|
||||
source_job_id,
|
||||
AiResultReferenceKind::AssetObject,
|
||||
record.asset_object_id.clone(),
|
||||
Some("角色主形象正式对象".to_string()),
|
||||
now_micros,
|
||||
);
|
||||
Ok(module_assets::ConfirmAssetObjectResult { record })
|
||||
}
|
||||
|
||||
async fn bind_character_visual_asset(
|
||||
state: &AppState,
|
||||
owner_user_id: &str,
|
||||
character_id: &str,
|
||||
asset_object_id: String,
|
||||
) -> Result<(), AppError> {
|
||||
let now_micros = current_utc_micros();
|
||||
state
|
||||
.spacetime_client()
|
||||
.bind_asset_object_to_entity(
|
||||
build_asset_entity_binding_input(
|
||||
generate_asset_binding_id(now_micros),
|
||||
asset_object_id,
|
||||
CHARACTER_VISUAL_ENTITY_KIND.to_string(),
|
||||
character_id.to_string(),
|
||||
CHARACTER_VISUAL_SLOT.to_string(),
|
||||
CHARACTER_VISUAL_ASSET_KIND.to_string(),
|
||||
Some(owner_user_id.to_string()),
|
||||
None,
|
||||
now_micros,
|
||||
)
|
||||
.map_err(map_asset_binding_prepare_error)?,
|
||||
)
|
||||
.await
|
||||
.map_err(map_character_visual_spacetime_error)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn build_character_visual_job_payload(task: AiTaskSnapshot) -> CharacterAssetJobStatusPayload {
|
||||
let request_payload = task
|
||||
.request_payload_json
|
||||
.as_deref()
|
||||
.and_then(|value| serde_json::from_str::<Value>(value).ok())
|
||||
.unwrap_or_else(|| json!({}));
|
||||
let result = task
|
||||
.latest_structured_payload_json
|
||||
.as_deref()
|
||||
.and_then(|value| serde_json::from_str::<Value>(value).ok());
|
||||
|
||||
CharacterAssetJobStatusPayload {
|
||||
task_id: task.task_id,
|
||||
kind: "visual".to_string(),
|
||||
status: match task.status {
|
||||
AiTaskStatus::Pending => CharacterAssetJobStatusText::Queued,
|
||||
AiTaskStatus::Running => CharacterAssetJobStatusText::Running,
|
||||
AiTaskStatus::Completed => CharacterAssetJobStatusText::Completed,
|
||||
AiTaskStatus::Failed | AiTaskStatus::Cancelled => CharacterAssetJobStatusText::Failed,
|
||||
},
|
||||
character_id: request_payload
|
||||
.get("characterId")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or_default()
|
||||
.to_string(),
|
||||
animation: None,
|
||||
strategy: None,
|
||||
model: request_payload
|
||||
.get("model")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or(CHARACTER_VISUAL_MODEL)
|
||||
.to_string(),
|
||||
prompt: request_payload
|
||||
.get("prompt")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or_default()
|
||||
.to_string(),
|
||||
created_at: format_utc_micros(task.created_at_micros),
|
||||
updated_at: format_utc_micros(task.updated_at_micros),
|
||||
result,
|
||||
error_message: task.failure_message,
|
||||
}
|
||||
}
|
||||
|
||||
fn build_character_visual_prompt(prompt_text: &str, character_brief_text: Option<&str>) -> String {
|
||||
let merged = [character_brief_text.unwrap_or_default(), prompt_text]
|
||||
.into_iter()
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
|
||||
format!(
|
||||
"{}\n单人全身,右向斜侧身,3 到 4 头身,像素动作角色,纯绿色背景,服装完整,轮廓清晰,不要复杂背景。",
|
||||
if merged.is_empty() {
|
||||
"自定义世界角色,服装完整,姿态自然。"
|
||||
} else {
|
||||
merged.as_str()
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
fn build_character_visual_svg(size: &str, label: &str, candidate_label: &str) -> String {
|
||||
let (width, height) = parse_size(size);
|
||||
format!(
|
||||
r##"<svg xmlns="http://www.w3.org/2000/svg" width="{width}" height="{height}" viewBox="0 0 {width} {height}">
|
||||
<rect width="100%" height="100%" fill="#00ff00"/>
|
||||
<ellipse cx="{shadow_x}" cy="{shadow_y}" rx="{shadow_rx}" ry="{shadow_ry}" fill="rgba(0,0,0,0.18)"/>
|
||||
<path d="M {body_x} {body_y} C {body_c1x} {body_c1y}, {body_c2x} {body_c2y}, {body_x2} {body_y2} L {leg_x} {leg_y} L {leg2_x} {leg_y} Z" fill="#1f2937"/>
|
||||
<circle cx="{head_x}" cy="{head_y}" r="{head_r}" fill="#f8d7b0"/>
|
||||
<path d="M {weapon_x} {weapon_y} L {weapon_x2} {weapon_y2}" stroke="#e5e7eb" stroke-width="{weapon_w}" stroke-linecap="round"/>
|
||||
<text x="50%" y="{text_y}" text-anchor="middle" fill="#0f172a" font-size="{font_main}" font-family="Microsoft YaHei, PingFang SC, sans-serif">{title}</text>
|
||||
<text x="50%" y="{sub_y}" text-anchor="middle" fill="#0f172a" font-size="{font_sub}" font-family="Microsoft YaHei, PingFang SC, sans-serif">{candidate}</text>
|
||||
</svg>"##,
|
||||
width = width,
|
||||
height = height,
|
||||
shadow_x = width / 2,
|
||||
shadow_y = height * 5 / 6,
|
||||
shadow_rx = width / 5,
|
||||
shadow_ry = height / 28,
|
||||
body_x = width * 45 / 100,
|
||||
body_y = height * 34 / 100,
|
||||
body_c1x = width * 34 / 100,
|
||||
body_c1y = height * 50 / 100,
|
||||
body_c2x = width * 43 / 100,
|
||||
body_c2y = height * 72 / 100,
|
||||
body_x2 = width * 56 / 100,
|
||||
body_y2 = height * 72 / 100,
|
||||
leg_x = width * 48 / 100,
|
||||
leg_y = height * 84 / 100,
|
||||
leg2_x = width * 62 / 100,
|
||||
head_x = width * 53 / 100,
|
||||
head_y = height * 25 / 100,
|
||||
head_r = (width.min(height) / 12).max(18),
|
||||
weapon_x = width * 57 / 100,
|
||||
weapon_y = height * 42 / 100,
|
||||
weapon_x2 = width * 76 / 100,
|
||||
weapon_y2 = height * 34 / 100,
|
||||
weapon_w = (width.min(height) / 90).max(4),
|
||||
text_y = height * 91 / 100,
|
||||
sub_y = height * 96 / 100,
|
||||
font_main = (width.min(height) / 28).max(14),
|
||||
font_sub = (width.min(height) / 36).max(11),
|
||||
title = escape_svg_text(label),
|
||||
candidate = escape_svg_text(candidate_label),
|
||||
)
|
||||
}
|
||||
|
||||
fn resolve_object_key_from_legacy_path(value: &str) -> Result<String, AppError> {
|
||||
let trimmed = value.trim();
|
||||
if trimmed.is_empty() {
|
||||
return Err(
|
||||
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
|
||||
"provider": "character-visual",
|
||||
"message": "selectedPreviewSource is required.",
|
||||
})),
|
||||
);
|
||||
}
|
||||
if trimmed.starts_with("data:") {
|
||||
return Err(AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
|
||||
"provider": "character-visual",
|
||||
"message": "Rust 版 publish 当前要求 selectedPreviewSource 为已写入 OSS 的 /generated-* 路径。",
|
||||
})));
|
||||
}
|
||||
Ok(trimmed.trim_start_matches('/').to_string())
|
||||
}
|
||||
|
||||
fn build_asset_metadata(
|
||||
asset_kind: &str,
|
||||
owner_user_id: &str,
|
||||
entity_kind: &str,
|
||||
entity_id: &str,
|
||||
slot: &str,
|
||||
) -> BTreeMap<String, String> {
|
||||
BTreeMap::from([
|
||||
("asset_kind".to_string(), asset_kind.to_string()),
|
||||
("owner_user_id".to_string(), owner_user_id.to_string()),
|
||||
("entity_kind".to_string(), entity_kind.to_string()),
|
||||
("entity_id".to_string(), entity_id.to_string()),
|
||||
("slot".to_string(), slot.to_string()),
|
||||
])
|
||||
}
|
||||
|
||||
fn require_oss_client(state: &AppState) -> Result<&platform_oss::OssClient, AppError> {
|
||||
state.oss_client().ok_or_else(|| {
|
||||
AppError::from_status(StatusCode::SERVICE_UNAVAILABLE).with_details(json!({
|
||||
"provider": "aliyun-oss",
|
||||
"reason": "OSS 未完成环境变量配置",
|
||||
}))
|
||||
})
|
||||
}
|
||||
|
||||
fn normalize_required_text(value: &str, fallback: &str) -> String {
|
||||
value
|
||||
.trim()
|
||||
.split_whitespace()
|
||||
.collect::<Vec<_>>()
|
||||
.join(" ")
|
||||
.chars()
|
||||
.take(180)
|
||||
.collect::<String>()
|
||||
.trim()
|
||||
.to_string()
|
||||
.if_empty_then(fallback)
|
||||
}
|
||||
|
||||
fn sanitize_storage_segment(value: &str, fallback: &str) -> String {
|
||||
let normalized = value
|
||||
.trim()
|
||||
.chars()
|
||||
.map(|character| match character {
|
||||
'a'..='z' | '0'..='9' | '-' | '_' => character,
|
||||
'A'..='Z' => character.to_ascii_lowercase(),
|
||||
_ => '-',
|
||||
})
|
||||
.collect::<String>();
|
||||
let normalized = collapse_dashes(&normalized);
|
||||
if normalized.is_empty() {
|
||||
fallback.to_string()
|
||||
} else {
|
||||
normalized
|
||||
}
|
||||
}
|
||||
|
||||
fn collapse_dashes(value: &str) -> String {
|
||||
value
|
||||
.chars()
|
||||
.fold(
|
||||
(String::new(), false),
|
||||
|(mut output, last_is_dash), character| {
|
||||
let is_dash = character == '-';
|
||||
if is_dash && last_is_dash {
|
||||
return (output, true);
|
||||
}
|
||||
output.push(character);
|
||||
(output, is_dash)
|
||||
},
|
||||
)
|
||||
.0
|
||||
.trim_matches('-')
|
||||
.to_string()
|
||||
}
|
||||
|
||||
fn parse_size(size: &str) -> (u32, u32) {
|
||||
let mut parts = size.split('*');
|
||||
let width = parts
|
||||
.next()
|
||||
.and_then(|value| value.trim().parse::<u32>().ok())
|
||||
.filter(|value| *value > 0)
|
||||
.unwrap_or(1024);
|
||||
let height = parts
|
||||
.next()
|
||||
.and_then(|value| value.trim().parse::<u32>().ok())
|
||||
.filter(|value| *value > 0)
|
||||
.unwrap_or(1024);
|
||||
(width, height)
|
||||
}
|
||||
|
||||
fn escape_svg_text(value: &str) -> String {
|
||||
value
|
||||
.replace('&', "&")
|
||||
.replace('<', "<")
|
||||
.replace('>', ">")
|
||||
}
|
||||
|
||||
fn format_utc_micros(micros: i64) -> String {
|
||||
module_runtime::format_utc_micros(micros)
|
||||
}
|
||||
|
||||
fn current_utc_millis() -> i64 {
|
||||
current_utc_micros() / 1_000
|
||||
}
|
||||
|
||||
fn current_utc_micros() -> i64 {
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
let duration = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.expect("system clock should be after unix epoch");
|
||||
i64::try_from(duration.as_micros()).expect("current unix micros should fit in i64")
|
||||
}
|
||||
|
||||
fn map_ai_task_error(error: AiTaskServiceError) -> AppError {
|
||||
let status = match error {
|
||||
AiTaskServiceError::TaskNotFound => StatusCode::NOT_FOUND,
|
||||
AiTaskServiceError::TaskAlreadyExists => StatusCode::CONFLICT,
|
||||
AiTaskServiceError::Field(_) | AiTaskServiceError::StageNotFound => StatusCode::BAD_REQUEST,
|
||||
AiTaskServiceError::Store(_) => StatusCode::INTERNAL_SERVER_ERROR,
|
||||
};
|
||||
AppError::from_status(status).with_details(json!({
|
||||
"provider": "ai-task",
|
||||
"message": error.to_string(),
|
||||
}))
|
||||
}
|
||||
|
||||
fn map_asset_object_prepare_error(error: AssetObjectFieldError) -> AppError {
|
||||
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
|
||||
"provider": "asset-object",
|
||||
"message": error.to_string(),
|
||||
}))
|
||||
}
|
||||
|
||||
fn map_asset_binding_prepare_error(error: AssetObjectFieldError) -> AppError {
|
||||
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
|
||||
"provider": "asset-entity-binding",
|
||||
"message": error.to_string(),
|
||||
}))
|
||||
}
|
||||
|
||||
fn map_character_visual_spacetime_error(error: SpacetimeClientError) -> AppError {
|
||||
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
|
||||
"provider": "spacetimedb",
|
||||
"message": error.to_string(),
|
||||
}))
|
||||
}
|
||||
|
||||
fn map_character_visual_oss_error(error: platform_oss::OssError) -> AppError {
|
||||
let status = match error {
|
||||
platform_oss::OssError::InvalidConfig(_) | platform_oss::OssError::InvalidRequest(_) => {
|
||||
StatusCode::BAD_REQUEST
|
||||
}
|
||||
platform_oss::OssError::ObjectNotFound(_) => StatusCode::NOT_FOUND,
|
||||
platform_oss::OssError::Request(_)
|
||||
| platform_oss::OssError::SerializePolicy(_)
|
||||
| platform_oss::OssError::Sign(_) => StatusCode::BAD_GATEWAY,
|
||||
};
|
||||
AppError::from_status(status).with_details(json!({
|
||||
"provider": "aliyun-oss",
|
||||
"message": error.to_string(),
|
||||
}))
|
||||
}
|
||||
|
||||
fn character_visual_error_response(request_context: &RequestContext, error: AppError) -> Response {
|
||||
error.into_response_with_context(Some(request_context))
|
||||
}
|
||||
|
||||
trait EmptyFallback {
|
||||
fn if_empty_then(self, fallback: &str) -> String;
|
||||
}
|
||||
|
||||
impl EmptyFallback for String {
|
||||
fn if_empty_then(self, fallback: &str) -> String {
|
||||
if self.is_empty() {
|
||||
fallback.to_string()
|
||||
} else {
|
||||
self
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn build_character_visual_prompt_keeps_generation_constraints() {
|
||||
let prompt = build_character_visual_prompt("潮雾港向导", Some("旧港守望者"));
|
||||
|
||||
assert!(prompt.contains("潮雾港向导"));
|
||||
assert!(prompt.contains("右向斜侧身"));
|
||||
assert!(prompt.contains("纯绿色背景"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sanitize_storage_segment_keeps_legacy_safe_shape() {
|
||||
assert_eq!(
|
||||
sanitize_storage_segment("Harbor Guide/潮雾", "character"),
|
||||
"harbor-guide"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -56,6 +56,10 @@ pub struct AppConfig {
|
||||
pub llm_request_timeout_ms: u64,
|
||||
pub llm_max_retries: u32,
|
||||
pub llm_retry_backoff_ms: u64,
|
||||
pub dashscope_base_url: String,
|
||||
pub dashscope_api_key: Option<String>,
|
||||
pub dashscope_image_request_timeout_ms: u64,
|
||||
pub slow_request_threshold_ms: u64,
|
||||
}
|
||||
|
||||
impl Default for AppConfig {
|
||||
@@ -107,6 +111,10 @@ impl Default for AppConfig {
|
||||
llm_request_timeout_ms: DEFAULT_REQUEST_TIMEOUT_MS,
|
||||
llm_max_retries: DEFAULT_MAX_RETRIES,
|
||||
llm_retry_backoff_ms: DEFAULT_RETRY_BACKOFF_MS,
|
||||
dashscope_base_url: "https://dashscope.aliyuncs.com/api/v1".to_string(),
|
||||
dashscope_api_key: None,
|
||||
dashscope_image_request_timeout_ms: 150_000,
|
||||
slow_request_threshold_ms: 1_000,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -311,6 +319,24 @@ impl AppConfig {
|
||||
config.llm_retry_backoff_ms = llm_retry_backoff_ms;
|
||||
}
|
||||
|
||||
if let Some(dashscope_base_url) = read_first_non_empty_env(&["DASHSCOPE_BASE_URL"]) {
|
||||
config.dashscope_base_url = dashscope_base_url;
|
||||
}
|
||||
|
||||
config.dashscope_api_key = read_first_non_empty_env(&["DASHSCOPE_API_KEY"]);
|
||||
|
||||
if let Some(dashscope_image_request_timeout_ms) =
|
||||
read_first_positive_u64_env(&["DASHSCOPE_IMAGE_REQUEST_TIMEOUT_MS"])
|
||||
{
|
||||
config.dashscope_image_request_timeout_ms = dashscope_image_request_timeout_ms;
|
||||
}
|
||||
|
||||
if let Some(slow_request_threshold_ms) =
|
||||
read_first_positive_u64_env(&["GENARRATIVE_SLOW_REQUEST_THRESHOLD_MS"])
|
||||
{
|
||||
config.slow_request_threshold_ms = slow_request_threshold_ms;
|
||||
}
|
||||
|
||||
config
|
||||
}
|
||||
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
use axum::{
|
||||
Json,
|
||||
extract::{Extension, Path, State, rejection::JsonRejection},
|
||||
http::{HeaderName, StatusCode, header},
|
||||
response::{IntoResponse, Response},
|
||||
http::StatusCode,
|
||||
response::{
|
||||
IntoResponse, Response,
|
||||
sse::{Event, Sse},
|
||||
},
|
||||
};
|
||||
use module_custom_world::{
|
||||
CustomWorldThemeMode, empty_agent_anchor_content_json, empty_agent_asset_coverage_json,
|
||||
@@ -10,31 +13,31 @@ use module_custom_world::{
|
||||
};
|
||||
use serde_json::{Map, Value, json};
|
||||
use shared_contracts::runtime::{
|
||||
CreateCustomWorldAgentSessionRequest, CustomWorldAgentCheckpointResponse,
|
||||
CustomWorldAgentCardDetailResponse,
|
||||
CustomWorldAgentMessageResponse, CustomWorldAgentOperationResponse,
|
||||
CustomWorldAgentSessionResponse, CustomWorldAgentSessionSnapshotResponse,
|
||||
CustomWorldDraftCardDetailResponse, CustomWorldDraftCardDetailSectionResponse,
|
||||
CustomWorldDraftCardSummaryResponse, CustomWorldGalleryCardResponse,
|
||||
CustomWorldGalleryDetailResponse, CustomWorldGalleryResponse, CustomWorldLibraryEntryResponse,
|
||||
CustomWorldLibraryMutationResponse, CustomWorldLibraryResponse,
|
||||
CustomWorldProfileUpsertRequest, CustomWorldSupportedActionResponse,
|
||||
CustomWorldPublishGateResponse, CustomWorldResultPreviewBlockerResponse,
|
||||
CustomWorldWorkSummaryResponse, CustomWorldWorksResponse,
|
||||
ExecuteCustomWorldAgentActionRequest, SendCustomWorldAgentMessageRequest,
|
||||
CreateCustomWorldAgentSessionRequest, CustomWorldAgentCardDetailResponse,
|
||||
CustomWorldAgentCheckpointResponse, CustomWorldAgentMessageResponse,
|
||||
CustomWorldAgentOperationResponse, CustomWorldAgentSessionResponse,
|
||||
CustomWorldAgentSessionSnapshotResponse, CustomWorldDraftCardDetailResponse,
|
||||
CustomWorldDraftCardDetailSectionResponse, CustomWorldDraftCardSummaryResponse,
|
||||
CustomWorldGalleryCardResponse, CustomWorldGalleryDetailResponse, CustomWorldGalleryResponse,
|
||||
CustomWorldLibraryEntryResponse, CustomWorldLibraryMutationResponse,
|
||||
CustomWorldLibraryResponse, CustomWorldProfileUpsertRequest, CustomWorldPublishGateResponse,
|
||||
CustomWorldResultPreviewBlockerResponse, CustomWorldSupportedActionResponse,
|
||||
CustomWorldWorkSummaryResponse, CustomWorldWorksResponse, ExecuteCustomWorldAgentActionRequest,
|
||||
SendCustomWorldAgentMessageRequest,
|
||||
};
|
||||
use shared_kernel::build_prefixed_uuid_id;
|
||||
use spacetime_client::{
|
||||
CustomWorldAgentActionExecuteRecordInput,
|
||||
CustomWorldAgentCheckpointRecord, CustomWorldAgentMessageRecord,
|
||||
CustomWorldAgentMessageSubmitRecordInput, CustomWorldAgentOperationRecord,
|
||||
CustomWorldAgentSessionCreateRecordInput, CustomWorldAgentSessionRecord,
|
||||
CustomWorldDraftCardDetailRecord, CustomWorldDraftCardDetailSectionRecord,
|
||||
CustomWorldDraftCardRecord, CustomWorldGalleryEntryRecord, CustomWorldLibraryEntryRecord,
|
||||
CustomWorldAgentActionExecuteRecordInput, CustomWorldAgentCheckpointRecord,
|
||||
CustomWorldAgentMessageRecord, CustomWorldAgentMessageSubmitRecordInput,
|
||||
CustomWorldAgentOperationRecord, CustomWorldAgentSessionCreateRecordInput,
|
||||
CustomWorldAgentSessionRecord, CustomWorldDraftCardDetailRecord,
|
||||
CustomWorldDraftCardDetailSectionRecord, CustomWorldDraftCardRecord,
|
||||
CustomWorldGalleryEntryRecord, CustomWorldLibraryEntryRecord,
|
||||
CustomWorldProfileUpsertRecordInput, CustomWorldPublishGateRecord,
|
||||
CustomWorldResultPreviewBlockerRecord, CustomWorldWorkSummaryRecord,
|
||||
CustomWorldSupportedActionRecord, SpacetimeClientError,
|
||||
CustomWorldResultPreviewBlockerRecord, CustomWorldSupportedActionRecord,
|
||||
CustomWorldWorkSummaryRecord, SpacetimeClientError,
|
||||
};
|
||||
use std::convert::Infallible;
|
||||
|
||||
use crate::{
|
||||
api_response::json_success_body, auth::AuthenticatedAccessToken, http_error::AppError,
|
||||
@@ -84,10 +87,7 @@ pub async fn get_custom_world_library_detail(
|
||||
|
||||
let detail = state
|
||||
.spacetime_client()
|
||||
.get_custom_world_library_detail(
|
||||
authenticated.claims().user_id().to_string(),
|
||||
profile_id,
|
||||
)
|
||||
.get_custom_world_library_detail(authenticated.claims().user_id().to_string(), profile_id)
|
||||
.await
|
||||
.map_err(|error| {
|
||||
custom_world_error_response(&request_context, map_custom_world_client_error(error))
|
||||
@@ -580,21 +580,23 @@ pub async fn stream_custom_world_agent_message(
|
||||
let session_response = map_custom_world_agent_session_response(session);
|
||||
let reply_text = resolve_stream_reply_text(&session_response);
|
||||
|
||||
// 这里先用“一次性构造完整 SSE 文本”的最小兼容方案,
|
||||
// 复用 Stage 7 的同步 deterministic 写表逻辑,保证前端当前的 reader 协议可直接消费。
|
||||
let mut sse_body = String::new();
|
||||
append_sse_event(&mut sse_body, "reply_delta", &json!({ "text": reply_text }))
|
||||
.map_err(|error| custom_world_error_response(&request_context, error))?;
|
||||
append_sse_event(
|
||||
&mut sse_body,
|
||||
"session",
|
||||
&json!({ "session": session_response }),
|
||||
)
|
||||
.map_err(|error| custom_world_error_response(&request_context, error))?;
|
||||
append_sse_event(&mut sse_body, "done", &json!({ "ok": true }))
|
||||
.map_err(|error| custom_world_error_response(&request_context, error))?;
|
||||
// 这里仍保持“一次性返回完整事件序列”的兼容语义;
|
||||
// SSE 编码、标准响应头与 body frame 交给 Axum 内建实现维护。
|
||||
let events = vec![
|
||||
custom_world_sse_json_event("reply_delta", json!({ "text": reply_text }))
|
||||
.map_err(|error| custom_world_error_response(&request_context, error))?,
|
||||
custom_world_sse_json_event("session", json!({ "session": session_response }))
|
||||
.map_err(|error| custom_world_error_response(&request_context, error))?,
|
||||
custom_world_sse_json_event("done", json!({ "ok": true }))
|
||||
.map_err(|error| custom_world_error_response(&request_context, error))?,
|
||||
];
|
||||
let stream = tokio_stream::iter(
|
||||
events
|
||||
.into_iter()
|
||||
.map(|event| Ok::<Event, Infallible>(event)),
|
||||
);
|
||||
|
||||
Ok(build_event_stream_response(sse_body))
|
||||
Ok(Sse::new(stream).into_response())
|
||||
}
|
||||
|
||||
pub async fn get_custom_world_agent_operation(
|
||||
@@ -815,7 +817,9 @@ fn map_custom_world_agent_session_response(
|
||||
.into_iter()
|
||||
.map(map_custom_world_supported_action_response)
|
||||
.collect(),
|
||||
publish_gate: session.publish_gate.map(map_custom_world_publish_gate_response),
|
||||
publish_gate: session
|
||||
.publish_gate
|
||||
.map(map_custom_world_publish_gate_response),
|
||||
result_preview: session.result_preview,
|
||||
updated_at: session.updated_at,
|
||||
}
|
||||
@@ -958,40 +962,11 @@ fn resolve_stream_reply_text(session: &CustomWorldAgentSessionSnapshotResponse)
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
fn append_sse_event(body: &mut String, event: &str, payload: &Value) -> Result<(), AppError> {
|
||||
let payload_text = serde_json::to_string(payload).map_err(|error| {
|
||||
AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_details(json!({
|
||||
"provider": "custom-world-agent",
|
||||
"message": format!("SSE payload 序列化失败:{error}"),
|
||||
}))
|
||||
})?;
|
||||
|
||||
body.push_str("event: ");
|
||||
body.push_str(event);
|
||||
body.push('\n');
|
||||
body.push_str("data: ");
|
||||
body.push_str(&payload_text);
|
||||
body.push_str("\n\n");
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn build_event_stream_response(body: String) -> Response {
|
||||
(
|
||||
[
|
||||
(header::CONTENT_TYPE, "text/event-stream; charset=utf-8"),
|
||||
(header::CACHE_CONTROL, "no-cache"),
|
||||
// 反向代理场景下显式关闭缓冲,避免 SSE 事件被聚合后才下发。
|
||||
(HeaderName::from_static("x-accel-buffering"), "no"),
|
||||
],
|
||||
body,
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
|
||||
fn map_custom_world_client_error(error: SpacetimeClientError) -> AppError {
|
||||
let status = match &error {
|
||||
SpacetimeClientError::Procedure(message) if message.contains("custom_world_profile 不存在") => {
|
||||
SpacetimeClientError::Procedure(message)
|
||||
if message.contains("custom_world_profile 不存在") =>
|
||||
{
|
||||
StatusCode::NOT_FOUND
|
||||
}
|
||||
SpacetimeClientError::Procedure(message)
|
||||
@@ -1018,6 +993,18 @@ fn custom_world_error_response(request_context: &RequestContext, error: AppError
|
||||
error.into_response_with_context(Some(request_context))
|
||||
}
|
||||
|
||||
fn custom_world_sse_json_event(event_name: &str, payload: Value) -> Result<Event, AppError> {
|
||||
Event::default()
|
||||
.event(event_name)
|
||||
.json_data(payload)
|
||||
.map_err(|error| {
|
||||
AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_details(json!({
|
||||
"provider": "sse",
|
||||
"message": format!("SSE payload 序列化失败:{error}"),
|
||||
}))
|
||||
})
|
||||
}
|
||||
|
||||
fn resolve_author_display_name(_authenticated: &AuthenticatedAccessToken) -> String {
|
||||
"玩家".to_string()
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
209
server-rs/crates/api-server/src/legacy_generated_assets.rs
Normal file
209
server-rs/crates/api-server/src/legacy_generated_assets.rs
Normal file
@@ -0,0 +1,209 @@
|
||||
use axum::{
|
||||
body::Body,
|
||||
extract::{Path, State},
|
||||
http::{HeaderName, HeaderValue, StatusCode, header},
|
||||
response::{IntoResponse, Response},
|
||||
};
|
||||
use platform_oss::{LegacyAssetPrefix, OssSignedGetObjectUrlRequest};
|
||||
use serde_json::json;
|
||||
|
||||
use crate::{http_error::AppError, state::AppState};
|
||||
|
||||
const CACHE_CONTROL_VALUE: &str = "private, max-age=60";
|
||||
const ASSET_OBJECT_KEY_HEADER: &str = "x-genarrative-asset-object-key";
|
||||
|
||||
pub async fn proxy_generated_character_drafts(
|
||||
State(state): State<AppState>,
|
||||
Path(path): Path<String>,
|
||||
) -> Response {
|
||||
proxy_legacy_generated_asset(state, LegacyAssetPrefix::CharacterDrafts, path).await
|
||||
}
|
||||
|
||||
pub async fn proxy_generated_characters(
|
||||
State(state): State<AppState>,
|
||||
Path(path): Path<String>,
|
||||
) -> Response {
|
||||
proxy_legacy_generated_asset(state, LegacyAssetPrefix::Characters, path).await
|
||||
}
|
||||
|
||||
pub async fn proxy_generated_animations(
|
||||
State(state): State<AppState>,
|
||||
Path(path): Path<String>,
|
||||
) -> Response {
|
||||
proxy_legacy_generated_asset(state, LegacyAssetPrefix::Animations, path).await
|
||||
}
|
||||
|
||||
pub async fn proxy_generated_custom_world_scenes(
|
||||
State(state): State<AppState>,
|
||||
Path(path): Path<String>,
|
||||
) -> Response {
|
||||
proxy_legacy_generated_asset(state, LegacyAssetPrefix::CustomWorldScenes, path).await
|
||||
}
|
||||
|
||||
pub async fn proxy_generated_custom_world_covers(
|
||||
State(state): State<AppState>,
|
||||
Path(path): Path<String>,
|
||||
) -> Response {
|
||||
proxy_legacy_generated_asset(state, LegacyAssetPrefix::CustomWorldCovers, path).await
|
||||
}
|
||||
|
||||
pub async fn proxy_generated_qwen_sprites(
|
||||
State(state): State<AppState>,
|
||||
Path(path): Path<String>,
|
||||
) -> Response {
|
||||
proxy_legacy_generated_asset(state, LegacyAssetPrefix::QwenSprites, path).await
|
||||
}
|
||||
|
||||
async fn proxy_legacy_generated_asset(
|
||||
state: AppState,
|
||||
prefix: LegacyAssetPrefix,
|
||||
path: String,
|
||||
) -> Response {
|
||||
match read_legacy_generated_asset(&state, prefix, path).await {
|
||||
Ok(response) => response,
|
||||
Err(error) => error.into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
async fn read_legacy_generated_asset(
|
||||
state: &AppState,
|
||||
prefix: LegacyAssetPrefix,
|
||||
path: String,
|
||||
) -> Result<Response, AppError> {
|
||||
let oss_client = state.oss_client().ok_or_else(|| {
|
||||
AppError::from_status(StatusCode::SERVICE_UNAVAILABLE).with_details(json!({
|
||||
"provider": "aliyun-oss",
|
||||
"reason": "OSS 未完成环境变量配置",
|
||||
}))
|
||||
})?;
|
||||
let object_key = build_generated_object_key(prefix, path.as_str())?;
|
||||
let signed = oss_client
|
||||
.sign_get_object_url(OssSignedGetObjectUrlRequest {
|
||||
object_key: object_key.clone(),
|
||||
expire_seconds: Some(60),
|
||||
})
|
||||
.map_err(map_legacy_generated_oss_error)?;
|
||||
let upstream_response = reqwest::Client::new()
|
||||
.get(signed.signed_url)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|error| {
|
||||
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
|
||||
"provider": "aliyun-oss",
|
||||
"message": format!("读取 OSS 旧 generated 资源失败:{error}"),
|
||||
}))
|
||||
})?;
|
||||
|
||||
if upstream_response.status() == reqwest::StatusCode::NOT_FOUND {
|
||||
return Err(
|
||||
AppError::from_status(StatusCode::NOT_FOUND).with_details(json!({
|
||||
"provider": "aliyun-oss",
|
||||
"objectKey": object_key,
|
||||
})),
|
||||
);
|
||||
}
|
||||
|
||||
let status = upstream_response.status();
|
||||
let content_type = upstream_response
|
||||
.headers()
|
||||
.get(header::CONTENT_TYPE)
|
||||
.cloned();
|
||||
let bytes = upstream_response
|
||||
.error_for_status()
|
||||
.map_err(|error| {
|
||||
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
|
||||
"provider": "aliyun-oss",
|
||||
"message": format!("读取 OSS 旧 generated 资源失败:{error}"),
|
||||
}))
|
||||
})?
|
||||
.bytes()
|
||||
.await
|
||||
.map_err(|error| {
|
||||
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
|
||||
"provider": "aliyun-oss",
|
||||
"message": format!("读取 OSS 旧 generated 资源内容失败:{error}"),
|
||||
}))
|
||||
})?;
|
||||
let mut response = Response::builder()
|
||||
.status(status)
|
||||
.header(header::CACHE_CONTROL, CACHE_CONTROL_VALUE)
|
||||
.header(
|
||||
HeaderName::from_static(ASSET_OBJECT_KEY_HEADER),
|
||||
HeaderValue::from_str(object_key.as_str()).map_err(|error| {
|
||||
AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_details(json!({
|
||||
"provider": "legacy-generated-assets",
|
||||
"message": format!("构造资源响应头失败:{error}"),
|
||||
}))
|
||||
})?,
|
||||
);
|
||||
if let Some(content_type) = content_type {
|
||||
response = response.header(header::CONTENT_TYPE, content_type);
|
||||
}
|
||||
|
||||
response.body(Body::from(bytes)).map_err(|error| {
|
||||
AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_details(json!({
|
||||
"provider": "legacy-generated-assets",
|
||||
"message": format!("构造资源响应失败:{error}"),
|
||||
}))
|
||||
})
|
||||
}
|
||||
|
||||
fn build_generated_object_key(prefix: LegacyAssetPrefix, path: &str) -> Result<String, AppError> {
|
||||
let path = path.trim().trim_matches('/');
|
||||
if path.is_empty() || path.split('/').any(is_invalid_path_segment) {
|
||||
return Err(
|
||||
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
|
||||
"provider": "legacy-generated-assets",
|
||||
"message": "generated 资源路径不合法。",
|
||||
})),
|
||||
);
|
||||
}
|
||||
|
||||
Ok(format!("{}/{}", prefix.as_str(), path))
|
||||
}
|
||||
|
||||
fn is_invalid_path_segment(segment: &str) -> bool {
|
||||
segment.is_empty() || segment == "." || segment == ".." || segment.contains('\\')
|
||||
}
|
||||
|
||||
fn map_legacy_generated_oss_error(error: platform_oss::OssError) -> AppError {
|
||||
let status = match error {
|
||||
platform_oss::OssError::InvalidConfig(_) | platform_oss::OssError::InvalidRequest(_) => {
|
||||
StatusCode::BAD_REQUEST
|
||||
}
|
||||
platform_oss::OssError::ObjectNotFound(_) => StatusCode::NOT_FOUND,
|
||||
platform_oss::OssError::Request(_)
|
||||
| platform_oss::OssError::SerializePolicy(_)
|
||||
| platform_oss::OssError::Sign(_) => StatusCode::BAD_GATEWAY,
|
||||
};
|
||||
AppError::from_status(status).with_details(json!({
|
||||
"provider": "aliyun-oss",
|
||||
"message": error.to_string(),
|
||||
}))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn build_generated_object_key_keeps_supported_prefix() {
|
||||
let object_key = build_generated_object_key(
|
||||
LegacyAssetPrefix::Animations,
|
||||
"hero/animation-set-1/idle/frame01.png",
|
||||
)
|
||||
.expect("object key should build");
|
||||
|
||||
assert_eq!(
|
||||
object_key,
|
||||
"generated-animations/hero/animation-set-1/idle/frame01.png"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_generated_object_key_rejects_parent_segment() {
|
||||
assert!(
|
||||
build_generated_object_key(LegacyAssetPrefix::Characters, "../secret.png").is_err()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -7,12 +7,15 @@ mod auth_me;
|
||||
mod auth_session;
|
||||
mod auth_sessions;
|
||||
mod big_fish;
|
||||
mod character_animation_assets;
|
||||
mod character_visual_assets;
|
||||
mod config;
|
||||
mod custom_world;
|
||||
mod custom_world_ai;
|
||||
mod error_middleware;
|
||||
mod health;
|
||||
mod http_error;
|
||||
mod legacy_generated_assets;
|
||||
mod llm;
|
||||
mod login_options;
|
||||
mod logout;
|
||||
|
||||
@@ -32,10 +32,11 @@ pub async fn get_runtime_snapshot(
|
||||
) -> Result<Json<Value>, Response> {
|
||||
let user_id = authenticated.claims().user_id().to_string();
|
||||
let record = state
|
||||
.spacetime_client()
|
||||
.get_runtime_snapshot(user_id)
|
||||
.get_runtime_snapshot_record(user_id)
|
||||
.await
|
||||
.map_err(|error| runtime_save_error_response(&request_context, map_runtime_save_client_error(error)))?;
|
||||
.map_err(|error| {
|
||||
runtime_save_error_response(&request_context, map_runtime_save_client_error(error))
|
||||
})?;
|
||||
|
||||
Ok(json_success_body(
|
||||
Some(&request_context),
|
||||
@@ -70,8 +71,7 @@ pub async fn put_runtime_snapshot(
|
||||
let saved_at_micros = offset_datetime_to_unix_micros(saved_at);
|
||||
|
||||
let record = state
|
||||
.spacetime_client()
|
||||
.put_runtime_snapshot(
|
||||
.put_runtime_snapshot_record(
|
||||
user_id,
|
||||
saved_at_micros,
|
||||
payload.bottom_tab,
|
||||
@@ -80,7 +80,9 @@ pub async fn put_runtime_snapshot(
|
||||
updated_at_micros,
|
||||
)
|
||||
.await
|
||||
.map_err(|error| runtime_save_error_response(&request_context, map_runtime_save_client_error(error)))?;
|
||||
.map_err(|error| {
|
||||
runtime_save_error_response(&request_context, map_runtime_save_client_error(error))
|
||||
})?;
|
||||
|
||||
Ok(json_success_body(
|
||||
Some(&request_context),
|
||||
@@ -95,10 +97,11 @@ pub async fn delete_runtime_snapshot(
|
||||
) -> Result<Json<Value>, Response> {
|
||||
let user_id = authenticated.claims().user_id().to_string();
|
||||
state
|
||||
.spacetime_client()
|
||||
.delete_runtime_snapshot(user_id)
|
||||
.delete_runtime_snapshot_record(user_id)
|
||||
.await
|
||||
.map_err(|error| runtime_save_error_response(&request_context, map_runtime_save_client_error(error)))?;
|
||||
.map_err(|error| {
|
||||
runtime_save_error_response(&request_context, map_runtime_save_client_error(error))
|
||||
})?;
|
||||
|
||||
Ok(json_success_body(
|
||||
Some(&request_context),
|
||||
@@ -116,7 +119,9 @@ pub async fn list_profile_save_archives(
|
||||
.spacetime_client()
|
||||
.list_profile_save_archives(user_id)
|
||||
.await
|
||||
.map_err(|error| runtime_save_error_response(&request_context, map_runtime_save_client_error(error)))?;
|
||||
.map_err(|error| {
|
||||
runtime_save_error_response(&request_context, map_runtime_save_client_error(error))
|
||||
})?;
|
||||
|
||||
Ok(json_success_body(
|
||||
Some(&request_context),
|
||||
@@ -151,7 +156,12 @@ pub async fn resume_profile_save_archive(
|
||||
.spacetime_client()
|
||||
.resume_profile_save_archive(user_id, world_key)
|
||||
.await
|
||||
.map_err(|error| runtime_save_error_response(&request_context, map_runtime_save_resume_client_error(error)))?;
|
||||
.map_err(|error| {
|
||||
runtime_save_error_response(
|
||||
&request_context,
|
||||
map_runtime_save_resume_client_error(error),
|
||||
)
|
||||
})?;
|
||||
|
||||
Ok(json_success_body(
|
||||
Some(&request_context),
|
||||
@@ -205,7 +215,8 @@ fn map_runtime_save_client_error(error: SpacetimeClientError) -> AppError {
|
||||
fn map_runtime_save_resume_client_error(error: SpacetimeClientError) -> AppError {
|
||||
let (status, provider) = match &error {
|
||||
SpacetimeClientError::Procedure(message)
|
||||
if message.contains("world_key 不存在") || message.contains("对应 world_key 不存在") =>
|
||||
if message.contains("world_key 不存在")
|
||||
|| message.contains("对应 world_key 不存在") =>
|
||||
{
|
||||
(StatusCode::NOT_FOUND, "runtime-save")
|
||||
}
|
||||
|
||||
@@ -1,733 +1,6 @@
|
||||
use axum::{
|
||||
Json,
|
||||
extract::{Extension, Path, State, rejection::JsonRejection},
|
||||
http::StatusCode,
|
||||
response::Response,
|
||||
mod compat;
|
||||
|
||||
pub use compat::{
|
||||
generate_runtime_story_continue, generate_runtime_story_initial, get_runtime_story_state,
|
||||
resolve_runtime_story_action, resolve_runtime_story_state,
|
||||
};
|
||||
use serde_json::{Value, json};
|
||||
use shared_contracts::runtime_story::{
|
||||
RuntimeStoryActionResponse, RuntimeStoryCompanionViewModel, RuntimeStoryEncounterViewModel,
|
||||
RuntimeStoryOptionInteraction, RuntimeStoryOptionView, RuntimeStoryPlayerViewModel,
|
||||
RuntimeStoryPresentation, RuntimeStorySnapshotPayload, RuntimeStoryStateResolveRequest,
|
||||
RuntimeStoryStatusViewModel, RuntimeStoryViewModel,
|
||||
};
|
||||
|
||||
use crate::{
|
||||
api_response::json_success_body, auth::AuthenticatedAccessToken, http_error::AppError,
|
||||
request_context::RequestContext, state::AppState,
|
||||
};
|
||||
|
||||
pub async fn resolve_runtime_story_state(
|
||||
State(_state): State<AppState>,
|
||||
Extension(request_context): Extension<RequestContext>,
|
||||
Extension(_authenticated): Extension<AuthenticatedAccessToken>,
|
||||
Json(payload): Json<RuntimeStoryStateResolveRequest>,
|
||||
) -> Result<Json<Value>, Response> {
|
||||
let session_id = normalize_required_string(payload.session_id.as_str()).ok_or_else(|| {
|
||||
runtime_story_error_response(
|
||||
&request_context,
|
||||
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
|
||||
"provider": "runtime-story",
|
||||
"field": "sessionId",
|
||||
"message": "sessionId 不能为空",
|
||||
})),
|
||||
)
|
||||
})?;
|
||||
let snapshot = payload.snapshot.ok_or_else(|| {
|
||||
runtime_story_error_response(
|
||||
&request_context,
|
||||
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
|
||||
"provider": "runtime-story",
|
||||
"field": "snapshot",
|
||||
"message": "当前首版兼容状态桥要求随请求提交 snapshot",
|
||||
})),
|
||||
)
|
||||
})?;
|
||||
|
||||
Ok(json_success_body(
|
||||
Some(&request_context),
|
||||
build_runtime_story_state_response(
|
||||
&session_id,
|
||||
payload.client_version,
|
||||
snapshot,
|
||||
),
|
||||
))
|
||||
}
|
||||
|
||||
pub async fn get_runtime_story_state(
|
||||
State(_state): State<AppState>,
|
||||
Path(session_id): Path<String>,
|
||||
Extension(request_context): Extension<RequestContext>,
|
||||
Extension(_authenticated): Extension<AuthenticatedAccessToken>,
|
||||
) -> Result<Json<Value>, Response> {
|
||||
let session_id = normalize_required_string(session_id.as_str()).ok_or_else(|| {
|
||||
runtime_story_error_response(
|
||||
&request_context,
|
||||
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
|
||||
"provider": "runtime-story",
|
||||
"field": "sessionId",
|
||||
"message": "sessionId 不能为空",
|
||||
})),
|
||||
)
|
||||
})?;
|
||||
|
||||
Ok(json_success_body(
|
||||
Some(&request_context),
|
||||
build_runtime_story_state_response(&session_id, None, build_runtime_story_empty_snapshot(&session_id)),
|
||||
))
|
||||
}
|
||||
|
||||
pub async fn resolve_runtime_story_action(
|
||||
State(_state): State<AppState>,
|
||||
Extension(request_context): Extension<RequestContext>,
|
||||
Extension(_authenticated): Extension<AuthenticatedAccessToken>,
|
||||
payload: Result<Json<Value>, JsonRejection>,
|
||||
) -> Result<Json<Value>, Response> {
|
||||
let payload = optional_runtime_story_payload(payload)?;
|
||||
let session_id = read_payload_session_id(&payload).ok_or_else(|| {
|
||||
runtime_story_error_response(
|
||||
&request_context,
|
||||
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
|
||||
"provider": "runtime-story",
|
||||
"field": "sessionId",
|
||||
"message": "sessionId 不能为空",
|
||||
})),
|
||||
)
|
||||
})?;
|
||||
let client_version = read_u32_field(&payload, "clientVersion");
|
||||
let snapshot = read_payload_snapshot(&payload)
|
||||
.unwrap_or_else(|| build_runtime_story_empty_snapshot(&session_id));
|
||||
let mut response = build_runtime_story_state_response(&session_id, client_version, snapshot);
|
||||
response.presentation.action_text = read_runtime_story_action_text(&payload).unwrap_or_default();
|
||||
|
||||
Ok(json_success_body(Some(&request_context), response))
|
||||
}
|
||||
|
||||
pub async fn generate_runtime_story_initial(
|
||||
State(_state): State<AppState>,
|
||||
Extension(request_context): Extension<RequestContext>,
|
||||
Extension(_authenticated): Extension<AuthenticatedAccessToken>,
|
||||
payload: Result<Json<Value>, JsonRejection>,
|
||||
) -> Result<Json<Value>, Response> {
|
||||
let payload = optional_runtime_story_payload(payload)?;
|
||||
let session_id = read_payload_session_id(&payload).unwrap_or_else(|| "runtime-main".to_string());
|
||||
let client_version = read_u32_field(&payload, "clientVersion");
|
||||
let snapshot = read_payload_snapshot(&payload)
|
||||
.unwrap_or_else(|| build_runtime_story_empty_snapshot(&session_id));
|
||||
|
||||
Ok(json_success_body(
|
||||
Some(&request_context),
|
||||
build_runtime_story_state_response(&session_id, client_version, snapshot),
|
||||
))
|
||||
}
|
||||
|
||||
pub async fn generate_runtime_story_continue(
|
||||
State(_state): State<AppState>,
|
||||
Extension(request_context): Extension<RequestContext>,
|
||||
Extension(_authenticated): Extension<AuthenticatedAccessToken>,
|
||||
payload: Result<Json<Value>, JsonRejection>,
|
||||
) -> Result<Json<Value>, Response> {
|
||||
let payload = optional_runtime_story_payload(payload)?;
|
||||
let session_id = read_payload_session_id(&payload).unwrap_or_else(|| "runtime-main".to_string());
|
||||
let client_version = read_u32_field(&payload, "clientVersion");
|
||||
let snapshot = read_payload_snapshot(&payload)
|
||||
.unwrap_or_else(|| build_runtime_story_empty_snapshot(&session_id));
|
||||
|
||||
Ok(json_success_body(
|
||||
Some(&request_context),
|
||||
build_runtime_story_state_response(&session_id, client_version, snapshot),
|
||||
))
|
||||
}
|
||||
|
||||
fn build_runtime_story_state_response(
|
||||
requested_session_id: &str,
|
||||
client_version: Option<u32>,
|
||||
snapshot: RuntimeStorySnapshotPayload,
|
||||
) -> RuntimeStoryActionResponse {
|
||||
let session_id = read_runtime_session_id(&snapshot.game_state)
|
||||
.unwrap_or_else(|| requested_session_id.to_string());
|
||||
let options = build_runtime_story_options(snapshot.current_story.as_ref(), &snapshot.game_state);
|
||||
let story_text =
|
||||
read_story_text(snapshot.current_story.as_ref()).unwrap_or_else(|| build_fallback_story_text(&snapshot.game_state));
|
||||
let server_version =
|
||||
read_u32_field(&snapshot.game_state, "runtimeActionVersion").or(client_version).unwrap_or(0);
|
||||
|
||||
RuntimeStoryActionResponse {
|
||||
session_id,
|
||||
server_version,
|
||||
view_model: RuntimeStoryViewModel {
|
||||
player: RuntimeStoryPlayerViewModel {
|
||||
hp: read_i32_field(&snapshot.game_state, "playerHp").unwrap_or(0),
|
||||
max_hp: read_i32_field(&snapshot.game_state, "playerMaxHp").unwrap_or(1),
|
||||
mana: read_i32_field(&snapshot.game_state, "playerMana").unwrap_or(0),
|
||||
max_mana: read_i32_field(&snapshot.game_state, "playerMaxMana").unwrap_or(1),
|
||||
},
|
||||
encounter: build_runtime_story_encounter(&snapshot.game_state),
|
||||
companions: build_runtime_story_companions(&snapshot.game_state),
|
||||
available_options: options.clone(),
|
||||
status: RuntimeStoryStatusViewModel {
|
||||
in_battle: read_bool_field(&snapshot.game_state, "inBattle").unwrap_or(false),
|
||||
npc_interaction_active: read_bool_field(&snapshot.game_state, "npcInteractionActive")
|
||||
.unwrap_or(false),
|
||||
current_npc_battle_mode: read_optional_string_field(
|
||||
&snapshot.game_state,
|
||||
"currentNpcBattleMode",
|
||||
),
|
||||
current_npc_battle_outcome: read_optional_string_field(
|
||||
&snapshot.game_state,
|
||||
"currentNpcBattleOutcome",
|
||||
),
|
||||
},
|
||||
},
|
||||
presentation: RuntimeStoryPresentation {
|
||||
action_text: String::new(),
|
||||
result_text: String::new(),
|
||||
story_text,
|
||||
options,
|
||||
toast: None,
|
||||
battle: None,
|
||||
},
|
||||
patches: Vec::new(),
|
||||
snapshot,
|
||||
}
|
||||
}
|
||||
|
||||
fn optional_runtime_story_payload(
|
||||
payload: Result<Json<Value>, JsonRejection>,
|
||||
) -> Result<Value, Response> {
|
||||
match payload {
|
||||
Ok(Json(value)) => Ok(value),
|
||||
Err(error) if error.status() == StatusCode::UNSUPPORTED_MEDIA_TYPE => Ok(json!({})),
|
||||
Err(error) if error.status() == StatusCode::BAD_REQUEST => Ok(json!({})),
|
||||
Err(error) => Err(AppError::from_status(StatusCode::BAD_REQUEST)
|
||||
.with_details(json!({
|
||||
"provider": "runtime-story",
|
||||
"message": error.body_text(),
|
||||
}))
|
||||
.into_response_with_context(None)),
|
||||
}
|
||||
}
|
||||
|
||||
fn read_payload_session_id(payload: &Value) -> Option<String> {
|
||||
read_required_string_field(payload, "sessionId")
|
||||
.or_else(|| read_field(payload, "action").and_then(|action| read_required_string_field(action, "sessionId")))
|
||||
.or_else(|| read_field(payload, "snapshot").and_then(|snapshot| {
|
||||
read_object_field(snapshot, "gameState").and_then(read_runtime_session_id)
|
||||
}))
|
||||
}
|
||||
|
||||
fn read_payload_snapshot(payload: &Value) -> Option<RuntimeStorySnapshotPayload> {
|
||||
let snapshot = read_field(payload, "snapshot")?.clone();
|
||||
serde_json::from_value(snapshot).ok()
|
||||
}
|
||||
|
||||
fn read_runtime_story_action_text(payload: &Value) -> Option<String> {
|
||||
let action = read_field(payload, "action")?;
|
||||
read_optional_string_field(action, "functionId")
|
||||
.or_else(|| read_optional_string_field(action, "type"))
|
||||
}
|
||||
|
||||
fn build_runtime_story_empty_snapshot(session_id: &str) -> RuntimeStorySnapshotPayload {
|
||||
RuntimeStorySnapshotPayload {
|
||||
saved_at: time::OffsetDateTime::now_utc()
|
||||
.format(&time::format_description::well_known::Rfc3339)
|
||||
.unwrap_or_else(|_| "1970-01-01T00:00:00Z".to_string()),
|
||||
bottom_tab: "adventure".to_string(),
|
||||
game_state: json!({
|
||||
"runtimeSessionId": session_id,
|
||||
"runtimeActionVersion": 0,
|
||||
"playerHp": 1,
|
||||
"playerMaxHp": 1,
|
||||
"playerMana": 0,
|
||||
"playerMaxMana": 1,
|
||||
"inBattle": false,
|
||||
"npcInteractionActive": false
|
||||
}),
|
||||
current_story: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn build_runtime_story_companions(game_state: &Value) -> Vec<RuntimeStoryCompanionViewModel> {
|
||||
read_array_field(game_state, "companions")
|
||||
.into_iter()
|
||||
.filter_map(|entry| {
|
||||
let npc_id = read_required_string_field(entry, "npcId")?;
|
||||
Some(RuntimeStoryCompanionViewModel {
|
||||
npc_id,
|
||||
character_id: read_optional_string_field(entry, "characterId"),
|
||||
joined_at_affinity: read_i32_field(entry, "joinedAtAffinity").unwrap_or(0),
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn build_runtime_story_encounter(game_state: &Value) -> Option<RuntimeStoryEncounterViewModel> {
|
||||
let encounter = read_object_field(game_state, "currentEncounter")?;
|
||||
let npc_name = read_required_string_field(encounter, "npcName")?;
|
||||
let encounter_id = read_required_string_field(encounter, "id").unwrap_or_else(|| npc_name.clone());
|
||||
let npc_state = resolve_current_encounter_npc_state(game_state, &encounter_id, &npc_name);
|
||||
|
||||
Some(RuntimeStoryEncounterViewModel {
|
||||
id: encounter_id,
|
||||
kind: read_required_string_field(encounter, "kind").unwrap_or_else(|| "npc".to_string()),
|
||||
npc_name,
|
||||
hostile: read_bool_field(encounter, "hostile").unwrap_or(false),
|
||||
affinity: npc_state.and_then(|state| read_i32_field(state, "affinity")),
|
||||
recruited: npc_state.and_then(|state| read_bool_field(state, "recruited")),
|
||||
interaction_active: read_bool_field(game_state, "npcInteractionActive").unwrap_or(false),
|
||||
battle_mode: read_optional_string_field(game_state, "currentNpcBattleMode"),
|
||||
})
|
||||
}
|
||||
|
||||
fn resolve_current_encounter_npc_state<'a>(
|
||||
game_state: &'a Value,
|
||||
encounter_id: &str,
|
||||
npc_name: &str,
|
||||
) -> Option<&'a Value> {
|
||||
let npc_states = read_object_field(game_state, "npcStates")?;
|
||||
|
||||
npc_states
|
||||
.get(encounter_id)
|
||||
.or_else(|| npc_states.get(npc_name))
|
||||
}
|
||||
|
||||
fn build_runtime_story_options(
|
||||
current_story: Option<&Value>,
|
||||
game_state: &Value,
|
||||
) -> Vec<RuntimeStoryOptionView> {
|
||||
if let Some(story) = current_story {
|
||||
let prefers_deferred = read_required_string_field(story, "displayMode")
|
||||
.is_some_and(|value| value == "dialogue")
|
||||
&& !read_array_field(story, "deferredOptions").is_empty();
|
||||
|
||||
let source = if prefers_deferred {
|
||||
read_array_field(story, "deferredOptions")
|
||||
} else {
|
||||
read_array_field(story, "options")
|
||||
};
|
||||
|
||||
let compiled = source
|
||||
.into_iter()
|
||||
.filter_map(build_runtime_story_option_from_story_option)
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
if !compiled.is_empty() {
|
||||
return compiled;
|
||||
}
|
||||
}
|
||||
|
||||
build_fallback_runtime_story_options(game_state)
|
||||
}
|
||||
|
||||
fn build_runtime_story_option_from_story_option(value: &Value) -> Option<RuntimeStoryOptionView> {
|
||||
let function_id = read_required_string_field(value, "functionId")?;
|
||||
let action_text = read_required_string_field(value, "actionText")
|
||||
.or_else(|| read_required_string_field(value, "text"))
|
||||
.unwrap_or_else(|| function_id.clone());
|
||||
|
||||
Some(RuntimeStoryOptionView {
|
||||
scope: infer_option_scope(function_id.as_str()).to_string(),
|
||||
detail_text: read_optional_string_field(value, "detailText"),
|
||||
interaction: build_runtime_story_option_interaction(read_field(value, "interaction")),
|
||||
payload: read_field(value, "runtimePayload").cloned(),
|
||||
disabled: read_bool_field(value, "disabled"),
|
||||
reason: read_optional_string_field(value, "disabledReason")
|
||||
.or_else(|| read_optional_string_field(value, "reason")),
|
||||
function_id,
|
||||
action_text,
|
||||
})
|
||||
}
|
||||
|
||||
fn build_runtime_story_option_interaction(
|
||||
value: Option<&Value>,
|
||||
) -> Option<RuntimeStoryOptionInteraction> {
|
||||
let interaction = value?;
|
||||
match read_required_string_field(interaction, "kind")?.as_str() {
|
||||
"npc" => Some(RuntimeStoryOptionInteraction::Npc {
|
||||
npc_id: read_required_string_field(interaction, "npcId")?,
|
||||
action: read_required_string_field(interaction, "action")?,
|
||||
quest_id: read_optional_string_field(interaction, "questId"),
|
||||
}),
|
||||
"treasure" => Some(RuntimeStoryOptionInteraction::Treasure {
|
||||
action: read_required_string_field(interaction, "action")?,
|
||||
}),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn build_fallback_runtime_story_options(game_state: &Value) -> Vec<RuntimeStoryOptionView> {
|
||||
if read_bool_field(game_state, "inBattle").unwrap_or(false) {
|
||||
return vec![
|
||||
build_static_runtime_story_option("battle_attack_basic", "普通攻击", "combat"),
|
||||
build_static_runtime_story_option("battle_recover_breath", "恢复", "combat"),
|
||||
build_static_runtime_story_option("battle_escape_breakout", "强行脱离战斗", "combat"),
|
||||
];
|
||||
}
|
||||
|
||||
let encounter = read_object_field(game_state, "currentEncounter");
|
||||
if let Some(encounter) = encounter {
|
||||
match read_required_string_field(encounter, "kind").as_deref() {
|
||||
Some("npc") => {
|
||||
let interaction_active =
|
||||
read_bool_field(game_state, "npcInteractionActive").unwrap_or(false);
|
||||
if interaction_active {
|
||||
return vec![
|
||||
build_static_runtime_story_option("npc_chat", "继续交谈", "npc"),
|
||||
build_static_runtime_story_option("npc_help", "请求援手", "npc"),
|
||||
build_static_runtime_story_option("npc_spar", "点到为止切磋", "npc"),
|
||||
build_static_runtime_story_option("npc_fight", "与对方战斗", "npc"),
|
||||
build_static_runtime_story_option("npc_leave", "离开当前角色", "npc"),
|
||||
];
|
||||
}
|
||||
|
||||
return vec![
|
||||
build_static_runtime_story_option("npc_preview_talk", "转向眼前角色", "npc"),
|
||||
build_static_runtime_story_option("npc_fight", "与对方战斗", "npc"),
|
||||
build_static_runtime_story_option("npc_leave", "离开当前角色", "npc"),
|
||||
];
|
||||
}
|
||||
Some("treasure") => {
|
||||
return vec![
|
||||
build_static_runtime_story_option("treasure_secure", "直接收取", "story"),
|
||||
build_static_runtime_story_option("treasure_inspect", "仔细检查", "story"),
|
||||
build_static_runtime_story_option("treasure_leave", "先记下位置", "story"),
|
||||
];
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
vec![
|
||||
build_static_runtime_story_option("idle_observe_signs", "观察周围迹象", "story"),
|
||||
build_static_runtime_story_option("idle_call_out", "主动出声试探", "story"),
|
||||
build_static_runtime_story_option("idle_rest_focus", "原地调息", "story"),
|
||||
build_static_runtime_story_option("idle_explore_forward", "继续向前探索", "story"),
|
||||
build_static_runtime_story_option("idle_travel_next_scene", "前往相邻场景", "story"),
|
||||
build_static_runtime_story_option("story_continue_adventure", "继续推进冒险", "story"),
|
||||
]
|
||||
}
|
||||
|
||||
fn build_static_runtime_story_option(
|
||||
function_id: &str,
|
||||
action_text: &str,
|
||||
scope: &str,
|
||||
) -> RuntimeStoryOptionView {
|
||||
RuntimeStoryOptionView {
|
||||
function_id: function_id.to_string(),
|
||||
action_text: action_text.to_string(),
|
||||
detail_text: None,
|
||||
scope: scope.to_string(),
|
||||
interaction: None,
|
||||
payload: None,
|
||||
disabled: None,
|
||||
reason: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn infer_option_scope(function_id: &str) -> &'static str {
|
||||
if function_id.starts_with("battle_") || function_id == "inventory_use" {
|
||||
"combat"
|
||||
} else if function_id.starts_with("npc_") {
|
||||
"npc"
|
||||
} else {
|
||||
"story"
|
||||
}
|
||||
}
|
||||
|
||||
fn read_story_text(current_story: Option<&Value>) -> Option<String> {
|
||||
current_story.and_then(|story| read_optional_string_field(story, "text"))
|
||||
}
|
||||
|
||||
fn build_fallback_story_text(game_state: &Value) -> String {
|
||||
if read_bool_field(game_state, "inBattle").unwrap_or(false) {
|
||||
let encounter_name = read_object_field(game_state, "currentEncounter")
|
||||
.and_then(|encounter| read_optional_string_field(encounter, "npcName"))
|
||||
.unwrap_or_else(|| "眼前的敌人".to_string());
|
||||
return format!("战斗还没有结束,{encounter_name} 仍在逼你立刻做出下一步判断。");
|
||||
}
|
||||
|
||||
if let Some(encounter) = read_object_field(game_state, "currentEncounter")
|
||||
&& let Some(npc_name) = read_optional_string_field(encounter, "npcName")
|
||||
{
|
||||
return format!("{npc_name} 正在等你表态,当前局势已经可以继续推进。");
|
||||
}
|
||||
|
||||
"当前故事状态已经同步到兼容状态桥,可以继续推进这一轮运行时动作。".to_string()
|
||||
}
|
||||
|
||||
fn read_runtime_session_id(game_state: &Value) -> Option<String> {
|
||||
read_optional_string_field(game_state, "runtimeSessionId")
|
||||
}
|
||||
|
||||
fn read_field<'a>(value: &'a Value, key: &str) -> Option<&'a Value> {
|
||||
value.as_object()?.get(key)
|
||||
}
|
||||
|
||||
fn read_object_field<'a>(value: &'a Value, key: &str) -> Option<&'a Value> {
|
||||
let field = read_field(value, key)?;
|
||||
field.is_object().then_some(field)
|
||||
}
|
||||
|
||||
fn read_array_field<'a>(value: &'a Value, key: &str) -> Vec<&'a Value> {
|
||||
read_field(value, key)
|
||||
.and_then(Value::as_array)
|
||||
.map(|items| items.iter().collect())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
fn read_required_string_field(value: &Value, key: &str) -> Option<String> {
|
||||
normalize_required_string(read_field(value, key)?.as_str()?)
|
||||
}
|
||||
|
||||
fn read_optional_string_field(value: &Value, key: &str) -> Option<String> {
|
||||
normalize_optional_string(read_field(value, key).and_then(Value::as_str))
|
||||
}
|
||||
|
||||
fn read_bool_field(value: &Value, key: &str) -> Option<bool> {
|
||||
read_field(value, key).and_then(Value::as_bool)
|
||||
}
|
||||
|
||||
fn read_i32_field(value: &Value, key: &str) -> Option<i32> {
|
||||
read_field(value, key)
|
||||
.and_then(Value::as_i64)
|
||||
.and_then(|number| i32::try_from(number).ok())
|
||||
}
|
||||
|
||||
fn read_u32_field(value: &Value, key: &str) -> Option<u32> {
|
||||
read_field(value, key)
|
||||
.and_then(Value::as_u64)
|
||||
.and_then(|number| u32::try_from(number).ok())
|
||||
}
|
||||
|
||||
fn normalize_required_string(value: &str) -> Option<String> {
|
||||
let trimmed = value.trim();
|
||||
(!trimmed.is_empty()).then(|| trimmed.to_string())
|
||||
}
|
||||
|
||||
fn normalize_optional_string(value: Option<&str>) -> Option<String> {
|
||||
value.and_then(normalize_required_string)
|
||||
}
|
||||
|
||||
fn runtime_story_error_response(request_context: &RequestContext, error: AppError) -> Response {
|
||||
error.into_response_with_context(Some(request_context))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use axum::{
|
||||
body::Body,
|
||||
http::{Request, StatusCode},
|
||||
};
|
||||
use http_body_util::BodyExt;
|
||||
use platform_auth::{
|
||||
AccessTokenClaims, AccessTokenClaimsInput, AuthProvider, BindingStatus, sign_access_token,
|
||||
};
|
||||
use serde_json::{Value, json};
|
||||
use time::OffsetDateTime;
|
||||
use tower::ServiceExt;
|
||||
|
||||
use crate::{app::build_router, config::AppConfig, state::AppState};
|
||||
|
||||
#[tokio::test]
|
||||
async fn runtime_story_state_resolve_requires_authentication() {
|
||||
let app = build_router(AppState::new(AppConfig::default()).expect("state should build"));
|
||||
|
||||
let response = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri("/api/runtime/story/state/resolve")
|
||||
.header("content-type", "application/json")
|
||||
.body(Body::from(
|
||||
json!({
|
||||
"sessionId": "runtime-main",
|
||||
"snapshot": {
|
||||
"savedAt": "2026-04-22T12:00:00.000Z",
|
||||
"bottomTab": "adventure",
|
||||
"gameState": {
|
||||
"runtimeSessionId": "runtime-main"
|
||||
},
|
||||
"currentStory": null
|
||||
}
|
||||
})
|
||||
.to_string(),
|
||||
))
|
||||
.expect("request should build"),
|
||||
)
|
||||
.await
|
||||
.expect("request should succeed");
|
||||
|
||||
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn runtime_story_state_resolve_rejects_missing_snapshot() {
|
||||
let state = seed_authenticated_state().await;
|
||||
let token = issue_access_token(&state);
|
||||
let app = build_router(state);
|
||||
|
||||
let response = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri("/api/runtime/story/state/resolve")
|
||||
.header("authorization", format!("Bearer {token}"))
|
||||
.header("content-type", "application/json")
|
||||
.header("x-genarrative-response-envelope", "v1")
|
||||
.body(Body::from(
|
||||
json!({
|
||||
"sessionId": "runtime-main"
|
||||
})
|
||||
.to_string(),
|
||||
))
|
||||
.expect("request should build"),
|
||||
)
|
||||
.await
|
||||
.expect("request should succeed");
|
||||
|
||||
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn runtime_story_state_resolve_returns_compiled_snapshot_response() {
|
||||
let state = seed_authenticated_state().await;
|
||||
let token = issue_access_token(&state);
|
||||
let app = build_router(state);
|
||||
|
||||
let response = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri("/api/runtime/story/state/resolve")
|
||||
.header("authorization", format!("Bearer {token}"))
|
||||
.header("content-type", "application/json")
|
||||
.header("x-genarrative-response-envelope", "v1")
|
||||
.body(Body::from(
|
||||
json!({
|
||||
"sessionId": "runtime-main",
|
||||
"clientVersion": 7,
|
||||
"snapshot": {
|
||||
"savedAt": "2026-04-22T12:00:00.000Z",
|
||||
"bottomTab": "adventure",
|
||||
"gameState": {
|
||||
"runtimeSessionId": "runtime-main",
|
||||
"runtimeActionVersion": 7,
|
||||
"playerHp": 32,
|
||||
"playerMaxHp": 40,
|
||||
"playerMana": 18,
|
||||
"playerMaxMana": 20,
|
||||
"inBattle": false,
|
||||
"npcInteractionActive": true,
|
||||
"currentEncounter": {
|
||||
"id": "npc_camp_firekeeper",
|
||||
"kind": "npc",
|
||||
"npcName": "守火人",
|
||||
"hostile": false
|
||||
},
|
||||
"npcStates": {
|
||||
"npc_camp_firekeeper": {
|
||||
"affinity": 12,
|
||||
"recruited": false
|
||||
}
|
||||
},
|
||||
"companions": [{
|
||||
"npcId": "npc_companion_001",
|
||||
"characterId": "char_companion_001",
|
||||
"joinedAtAffinity": 64
|
||||
}]
|
||||
},
|
||||
"currentStory": {
|
||||
"text": "守火人抬眼看了你一瞬,示意你把想问的话继续说完。",
|
||||
"displayMode": "dialogue",
|
||||
"options": [{
|
||||
"functionId": "story_continue_adventure",
|
||||
"actionText": "继续冒险"
|
||||
}],
|
||||
"deferredOptions": [{
|
||||
"functionId": "npc_chat",
|
||||
"actionText": "继续交谈",
|
||||
"detailText": "围绕当前话题继续推进关系判断。",
|
||||
"interaction": {
|
||||
"kind": "npc",
|
||||
"npcId": "npc_camp_firekeeper",
|
||||
"action": "chat"
|
||||
},
|
||||
"runtimePayload": {
|
||||
"note": "server-runtime-test"
|
||||
}
|
||||
}]
|
||||
}
|
||||
}
|
||||
})
|
||||
.to_string(),
|
||||
))
|
||||
.expect("request should build"),
|
||||
)
|
||||
.await
|
||||
.expect("request should succeed");
|
||||
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
|
||||
let body = response
|
||||
.into_body()
|
||||
.collect()
|
||||
.await
|
||||
.expect("body should collect")
|
||||
.to_bytes();
|
||||
let payload: Value =
|
||||
serde_json::from_slice(&body).expect("response body should be valid json");
|
||||
|
||||
assert_eq!(payload["ok"], Value::Bool(true));
|
||||
assert_eq!(payload["data"]["sessionId"], json!("runtime-main"));
|
||||
assert_eq!(payload["data"]["serverVersion"], json!(7));
|
||||
assert_eq!(
|
||||
payload["data"]["viewModel"]["encounter"]["npcName"],
|
||||
json!("守火人")
|
||||
);
|
||||
assert_eq!(
|
||||
payload["data"]["viewModel"]["availableOptions"][0]["functionId"],
|
||||
json!("npc_chat")
|
||||
);
|
||||
assert_eq!(
|
||||
payload["data"]["presentation"]["options"][0]["interaction"]["npcId"],
|
||||
json!("npc_camp_firekeeper")
|
||||
);
|
||||
assert_eq!(
|
||||
payload["data"]["snapshot"]["currentStory"]["deferredOptions"][0]["functionId"],
|
||||
json!("npc_chat")
|
||||
);
|
||||
}
|
||||
|
||||
async fn seed_authenticated_state() -> AppState {
|
||||
let state = AppState::new(AppConfig::default()).expect("state should build");
|
||||
state
|
||||
.password_entry_service()
|
||||
.execute(module_auth::PasswordEntryInput {
|
||||
username: "runtime_story_state_user".to_string(),
|
||||
password: "secret123".to_string(),
|
||||
})
|
||||
.await
|
||||
.expect("seed login should succeed");
|
||||
state
|
||||
}
|
||||
|
||||
fn issue_access_token(state: &AppState) -> String {
|
||||
let claims = AccessTokenClaims::from_input(
|
||||
AccessTokenClaimsInput {
|
||||
user_id: "user_00000001".to_string(),
|
||||
session_id: "sess_runtime_story_state".to_string(),
|
||||
provider: AuthProvider::Password,
|
||||
roles: vec!["user".to_string()],
|
||||
token_version: 1,
|
||||
phone_verified: true,
|
||||
binding_status: BindingStatus::Active,
|
||||
display_name: Some("运行时剧情状态用户".to_string()),
|
||||
},
|
||||
state.auth_jwt_config(),
|
||||
OffsetDateTime::now_utc(),
|
||||
)
|
||||
.expect("claims should build");
|
||||
|
||||
sign_access_token(&claims, state.auth_jwt_config()).expect("token should sign")
|
||||
}
|
||||
}
|
||||
|
||||
635
server-rs/crates/api-server/src/runtime_story/compat.rs
Normal file
635
server-rs/crates/api-server/src/runtime_story/compat.rs
Normal file
@@ -0,0 +1,635 @@
|
||||
use axum::{
|
||||
Json,
|
||||
extract::{Extension, Path, State},
|
||||
http::StatusCode,
|
||||
response::Response,
|
||||
};
|
||||
use module_npc::{
|
||||
NpcRelationStance, build_initial_stance_profile as build_module_npc_initial_stance_profile,
|
||||
build_relation_state as build_module_npc_relation_state,
|
||||
};
|
||||
use module_runtime::RuntimeSnapshotRecord;
|
||||
use module_runtime_story_compat::{
|
||||
CONTINUE_ADVENTURE_FUNCTION_ID, CurrentEncounterNpcQuestContext, GeneratedStoryPayload,
|
||||
PendingQuestOfferContext, RuntimeStoryActionResponseParts,
|
||||
StoryResolution, add_player_currency, add_player_inventory_items,
|
||||
append_story_history, apply_equipment_loadout_to_state,
|
||||
battle_mode_text, build_battle_runtime_story_options, build_current_build_toast,
|
||||
build_status_patch,
|
||||
build_npc_gift_result_text,
|
||||
build_runtime_story_view_model,
|
||||
clear_encounter_only, clear_encounter_state, clone_inventory_item_with_quantity,
|
||||
current_encounter_id, current_encounter_name, current_world_type,
|
||||
ensure_inventory_action_available, ensure_json_object, equipment_slot_label,
|
||||
find_player_inventory_entry,
|
||||
format_now_rfc3339, grant_player_progression_experience, has_giftable_player_inventory,
|
||||
format_currency_text,
|
||||
increment_runtime_stat, normalize_equipped_item,
|
||||
normalize_equipment_slot_id, normalize_required_string, npc_buyback_price,
|
||||
npc_purchase_price, read_array_field, read_bool_field, read_field, read_i32_field,
|
||||
read_inventory_item_name, read_object_field, read_optional_string_field,
|
||||
read_player_equipment_item, read_required_string_field, read_runtime_session_id,
|
||||
read_u32_field, recruit_companion_to_party, remove_player_inventory_item,
|
||||
restore_player_resource,
|
||||
resolve_action_text, resolve_battle_action, resolve_equipment_slot_for_item,
|
||||
resolve_forge_craft_action,
|
||||
resolve_forge_dismantle_action, resolve_forge_reforge_action,
|
||||
resolve_npc_gift_affinity_gain, simple_story_resolution, trade_quantity_suffix,
|
||||
resolve_current_encounter_npc_state,
|
||||
build_disabled_runtime_story_option, build_runtime_story_option_from_story_option,
|
||||
build_static_runtime_story_option, build_story_option_from_runtime_option,
|
||||
write_bool_field, write_i32_field, write_null_field, write_player_equipment_item,
|
||||
write_string_field, write_u32_field,
|
||||
};
|
||||
use platform_llm::{LlmClient, LlmMessage, LlmTextRequest};
|
||||
use serde_json::{Map, Value, json};
|
||||
use shared_contracts::runtime_story::{
|
||||
RuntimeBattlePresentation, RuntimeStoryActionRequest, RuntimeStoryActionResponse,
|
||||
RuntimeStoryAiRequest, RuntimeStoryAiResponse, RuntimeStoryOptionInteraction,
|
||||
RuntimeStoryOptionView, RuntimeStoryPatch, RuntimeStoryPresentation,
|
||||
RuntimeStorySnapshotPayload, RuntimeStoryStateResolveRequest,
|
||||
};
|
||||
use shared_kernel::{offset_datetime_to_unix_micros, parse_rfc3339};
|
||||
use spacetime_client::SpacetimeClientError;
|
||||
use time::OffsetDateTime;
|
||||
|
||||
use crate::{
|
||||
api_response::json_success_body, auth::AuthenticatedAccessToken, http_error::AppError,
|
||||
request_context::RequestContext, state::AppState,
|
||||
};
|
||||
|
||||
#[path = "compat/ai.rs"]
|
||||
mod ai;
|
||||
#[path = "compat/equipment_actions.rs"]
|
||||
mod equipment_actions;
|
||||
#[path = "compat/game_state.rs"]
|
||||
mod game_state;
|
||||
#[path = "compat/npc_actions.rs"]
|
||||
mod npc_actions;
|
||||
#[path = "compat/presentation.rs"]
|
||||
mod presentation;
|
||||
#[path = "compat/quest_actions.rs"]
|
||||
mod quest_actions;
|
||||
|
||||
use self::{
|
||||
ai::*, equipment_actions::*, game_state::*, npc_actions::*, presentation::*, quest_actions::*,
|
||||
};
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "compat/tests.rs"]
|
||||
mod tests;
|
||||
|
||||
pub async fn resolve_runtime_story_state(
|
||||
State(state): State<AppState>,
|
||||
Extension(request_context): Extension<RequestContext>,
|
||||
Extension(authenticated): Extension<AuthenticatedAccessToken>,
|
||||
Json(payload): Json<RuntimeStoryStateResolveRequest>,
|
||||
) -> Result<Json<Value>, Response> {
|
||||
let session_id = normalize_required_string(payload.session_id.as_str()).ok_or_else(|| {
|
||||
runtime_story_error_response(
|
||||
&request_context,
|
||||
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
|
||||
"provider": "runtime-story",
|
||||
"field": "sessionId",
|
||||
"message": "sessionId 不能为空",
|
||||
})),
|
||||
)
|
||||
})?;
|
||||
let snapshot = resolve_snapshot_for_request(
|
||||
&state,
|
||||
&request_context,
|
||||
authenticated.claims().user_id().to_string(),
|
||||
payload.snapshot,
|
||||
)
|
||||
.await?;
|
||||
|
||||
validate_client_version(
|
||||
&request_context,
|
||||
payload.client_version,
|
||||
&snapshot.game_state,
|
||||
"运行时版本已变化,请先同步最新快照后再读取状态",
|
||||
)?;
|
||||
|
||||
Ok(json_success_body(
|
||||
Some(&request_context),
|
||||
build_runtime_story_state_response(&session_id, payload.client_version, snapshot),
|
||||
))
|
||||
}
|
||||
|
||||
pub async fn get_runtime_story_state(
|
||||
State(state): State<AppState>,
|
||||
Path(session_id): Path<String>,
|
||||
Extension(request_context): Extension<RequestContext>,
|
||||
Extension(authenticated): Extension<AuthenticatedAccessToken>,
|
||||
) -> Result<Json<Value>, Response> {
|
||||
let session_id = normalize_required_string(session_id.as_str()).ok_or_else(|| {
|
||||
runtime_story_error_response(
|
||||
&request_context,
|
||||
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
|
||||
"provider": "runtime-story",
|
||||
"field": "sessionId",
|
||||
"message": "sessionId 不能为空",
|
||||
})),
|
||||
)
|
||||
})?;
|
||||
let snapshot = resolve_snapshot_for_request(
|
||||
&state,
|
||||
&request_context,
|
||||
authenticated.claims().user_id().to_string(),
|
||||
None,
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(json_success_body(
|
||||
Some(&request_context),
|
||||
build_runtime_story_state_response(&session_id, None, snapshot),
|
||||
))
|
||||
}
|
||||
|
||||
pub async fn resolve_runtime_story_action(
|
||||
State(state): State<AppState>,
|
||||
Extension(request_context): Extension<RequestContext>,
|
||||
Extension(authenticated): Extension<AuthenticatedAccessToken>,
|
||||
Json(payload): Json<RuntimeStoryActionRequest>,
|
||||
) -> Result<Json<Value>, Response> {
|
||||
let requested_session_id =
|
||||
normalize_required_string(payload.session_id.as_str()).ok_or_else(|| {
|
||||
runtime_story_error_response(
|
||||
&request_context,
|
||||
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
|
||||
"provider": "runtime-story",
|
||||
"field": "sessionId",
|
||||
"message": "sessionId 不能为空",
|
||||
})),
|
||||
)
|
||||
})?;
|
||||
let function_id =
|
||||
normalize_required_string(payload.action.function_id.as_str()).ok_or_else(|| {
|
||||
runtime_story_error_response(
|
||||
&request_context,
|
||||
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
|
||||
"provider": "runtime-story",
|
||||
"field": "action.functionId",
|
||||
"message": "functionId 不能为空",
|
||||
})),
|
||||
)
|
||||
})?;
|
||||
if payload.action.action_type.trim() != "story_choice" {
|
||||
return Err(runtime_story_error_response(
|
||||
&request_context,
|
||||
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
|
||||
"provider": "runtime-story",
|
||||
"field": "action.type",
|
||||
"message": "runtime story 当前只支持 story_choice 动作",
|
||||
})),
|
||||
));
|
||||
}
|
||||
|
||||
let mut snapshot = resolve_snapshot_for_request(
|
||||
&state,
|
||||
&request_context,
|
||||
authenticated.claims().user_id().to_string(),
|
||||
payload.snapshot.clone(),
|
||||
)
|
||||
.await?;
|
||||
validate_client_version(
|
||||
&request_context,
|
||||
payload.client_version,
|
||||
&snapshot.game_state,
|
||||
"运行时版本已变化,请先同步最新快照后再提交动作",
|
||||
)?;
|
||||
|
||||
let current_story_before = snapshot.current_story.clone();
|
||||
let mut game_state = snapshot.game_state.clone();
|
||||
let mut resolution = resolve_runtime_story_choice_action(
|
||||
&mut game_state,
|
||||
current_story_before.as_ref(),
|
||||
&payload,
|
||||
&function_id,
|
||||
)
|
||||
.map_err(|message| {
|
||||
runtime_story_error_response(
|
||||
&request_context,
|
||||
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
|
||||
"provider": "runtime-story",
|
||||
"message": message,
|
||||
})),
|
||||
)
|
||||
})?;
|
||||
|
||||
let server_version = read_u32_field(&game_state, "runtimeActionVersion")
|
||||
.unwrap_or(0)
|
||||
.saturating_add(1);
|
||||
write_u32_field(&mut game_state, "runtimeActionVersion", server_version);
|
||||
write_string_field(
|
||||
&mut game_state,
|
||||
"runtimeSessionId",
|
||||
requested_session_id.as_str(),
|
||||
);
|
||||
|
||||
let mut options = resolution
|
||||
.presentation_options
|
||||
.take()
|
||||
.unwrap_or_else(|| build_fallback_runtime_story_options(&game_state));
|
||||
if options.is_empty() {
|
||||
options = build_fallback_runtime_story_options(&game_state);
|
||||
}
|
||||
|
||||
let mut story_text = resolution
|
||||
.story_text
|
||||
.clone()
|
||||
.unwrap_or_else(|| resolution.result_text.clone());
|
||||
let mut history_result_text = resolution.result_text.clone();
|
||||
let mut saved_current_story = resolution
|
||||
.saved_current_story
|
||||
.take()
|
||||
.unwrap_or_else(|| build_legacy_current_story(story_text.as_str(), &options));
|
||||
if let Some(generated_payload) = generate_action_story_payload(
|
||||
&state,
|
||||
&game_state,
|
||||
&payload,
|
||||
&function_id,
|
||||
resolution.action_text.as_str(),
|
||||
resolution.result_text.as_str(),
|
||||
&options,
|
||||
resolution.battle.as_ref(),
|
||||
)
|
||||
.await
|
||||
{
|
||||
story_text = generated_payload.story_text;
|
||||
history_result_text = generated_payload.history_result_text;
|
||||
options = generated_payload.presentation_options;
|
||||
saved_current_story = generated_payload.saved_current_story;
|
||||
}
|
||||
append_story_history(
|
||||
&mut game_state,
|
||||
resolution.action_text.as_str(),
|
||||
history_result_text.as_str(),
|
||||
);
|
||||
|
||||
let mut patches = vec![RuntimeStoryPatch::StoryHistoryAppend {
|
||||
action_text: resolution.action_text.clone(),
|
||||
result_text: history_result_text,
|
||||
}];
|
||||
patches.extend(resolution.patches);
|
||||
|
||||
snapshot.saved_at = Some(format_now_rfc3339());
|
||||
snapshot.game_state = game_state;
|
||||
snapshot.current_story = Some(saved_current_story);
|
||||
let persisted = persist_runtime_story_snapshot(
|
||||
&state,
|
||||
&request_context,
|
||||
authenticated.claims().user_id().to_string(),
|
||||
snapshot,
|
||||
)
|
||||
.await?;
|
||||
let persisted_snapshot = runtime_snapshot_payload_from_record(&persisted);
|
||||
|
||||
Ok(json_success_body(
|
||||
Some(&request_context),
|
||||
build_runtime_story_action_response(RuntimeStoryActionResponseParts {
|
||||
requested_session_id,
|
||||
server_version,
|
||||
snapshot: persisted_snapshot,
|
||||
action_text: resolution.action_text,
|
||||
result_text: resolution.result_text,
|
||||
story_text,
|
||||
options,
|
||||
patches,
|
||||
toast: resolution.toast,
|
||||
battle: resolution.battle,
|
||||
}),
|
||||
))
|
||||
}
|
||||
|
||||
pub async fn generate_runtime_story_initial(
|
||||
State(state): State<AppState>,
|
||||
Extension(request_context): Extension<RequestContext>,
|
||||
Extension(_authenticated): Extension<AuthenticatedAccessToken>,
|
||||
Json(payload): Json<RuntimeStoryAiRequest>,
|
||||
) -> Result<Json<Value>, Response> {
|
||||
Ok(json_success_body(
|
||||
Some(&request_context),
|
||||
build_runtime_story_ai_response(&state, payload, true).await,
|
||||
))
|
||||
}
|
||||
|
||||
pub async fn generate_runtime_story_continue(
|
||||
State(state): State<AppState>,
|
||||
Extension(request_context): Extension<RequestContext>,
|
||||
Extension(_authenticated): Extension<AuthenticatedAccessToken>,
|
||||
Json(payload): Json<RuntimeStoryAiRequest>,
|
||||
) -> Result<Json<Value>, Response> {
|
||||
Ok(json_success_body(
|
||||
Some(&request_context),
|
||||
build_runtime_story_ai_response(&state, payload, false).await,
|
||||
))
|
||||
}
|
||||
|
||||
async fn resolve_snapshot_for_request(
|
||||
state: &AppState,
|
||||
request_context: &RequestContext,
|
||||
user_id: String,
|
||||
snapshot: Option<RuntimeStorySnapshotPayload>,
|
||||
) -> Result<RuntimeStorySnapshotPayload, Response> {
|
||||
if let Some(snapshot) = snapshot {
|
||||
let record =
|
||||
persist_runtime_story_snapshot(state, request_context, user_id, snapshot).await?;
|
||||
return Ok(runtime_snapshot_payload_from_record(&record));
|
||||
}
|
||||
|
||||
let record = state
|
||||
.get_runtime_snapshot_record(user_id)
|
||||
.await
|
||||
.map_err(|error| {
|
||||
runtime_story_error_response(request_context, map_runtime_story_client_error(error))
|
||||
})?
|
||||
.ok_or_else(|| {
|
||||
runtime_story_error_response(
|
||||
request_context,
|
||||
AppError::from_status(StatusCode::CONFLICT).with_details(json!({
|
||||
"provider": "runtime-story",
|
||||
"message": "运行时快照不存在,请先初始化并保存一次游戏",
|
||||
})),
|
||||
)
|
||||
})?;
|
||||
|
||||
Ok(runtime_snapshot_payload_from_record(&record))
|
||||
}
|
||||
|
||||
async fn persist_runtime_story_snapshot(
|
||||
state: &AppState,
|
||||
request_context: &RequestContext,
|
||||
user_id: String,
|
||||
snapshot: RuntimeStorySnapshotPayload,
|
||||
) -> Result<RuntimeSnapshotRecord, Response> {
|
||||
validate_snapshot_payload(&snapshot).map_err(|message| {
|
||||
runtime_story_error_response(
|
||||
request_context,
|
||||
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
|
||||
"provider": "runtime-story",
|
||||
"message": message,
|
||||
})),
|
||||
)
|
||||
})?;
|
||||
|
||||
let now = OffsetDateTime::now_utc();
|
||||
let saved_at = snapshot
|
||||
.saved_at
|
||||
.as_deref()
|
||||
.and_then(|value| normalize_required_string(value))
|
||||
.map(|value| parse_rfc3339(value.as_str()))
|
||||
.transpose()
|
||||
.map_err(|error| {
|
||||
runtime_story_error_response(
|
||||
request_context,
|
||||
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
|
||||
"provider": "runtime-story",
|
||||
"field": "snapshot.savedAt",
|
||||
"message": format!("savedAt 非法: {error}"),
|
||||
})),
|
||||
)
|
||||
})?
|
||||
.unwrap_or(now);
|
||||
|
||||
state
|
||||
.put_runtime_snapshot_record(
|
||||
user_id,
|
||||
offset_datetime_to_unix_micros(saved_at),
|
||||
snapshot.bottom_tab,
|
||||
snapshot.game_state,
|
||||
snapshot.current_story,
|
||||
offset_datetime_to_unix_micros(now),
|
||||
)
|
||||
.await
|
||||
.map_err(|error| {
|
||||
runtime_story_error_response(request_context, map_runtime_story_client_error(error))
|
||||
})
|
||||
}
|
||||
|
||||
fn validate_snapshot_payload(snapshot: &RuntimeStorySnapshotPayload) -> Result<(), String> {
|
||||
if normalize_required_string(snapshot.bottom_tab.as_str()).is_none() {
|
||||
return Err("snapshot.bottomTab 不能为空".to_string());
|
||||
}
|
||||
if !snapshot.game_state.is_object() {
|
||||
return Err("snapshot.gameState 必须是 JSON object".to_string());
|
||||
}
|
||||
if snapshot
|
||||
.current_story
|
||||
.as_ref()
|
||||
.is_some_and(|current_story| !current_story.is_object())
|
||||
{
|
||||
return Err("snapshot.currentStory 必须是 JSON object 或 null".to_string());
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn runtime_snapshot_payload_from_record(
|
||||
record: &RuntimeSnapshotRecord,
|
||||
) -> RuntimeStorySnapshotPayload {
|
||||
RuntimeStorySnapshotPayload {
|
||||
saved_at: Some(record.saved_at.clone()),
|
||||
bottom_tab: record.bottom_tab.clone(),
|
||||
game_state: record.game_state.clone(),
|
||||
current_story: record.current_story.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
fn validate_client_version(
|
||||
request_context: &RequestContext,
|
||||
client_version: Option<u32>,
|
||||
game_state: &Value,
|
||||
message: &str,
|
||||
) -> Result<(), Response> {
|
||||
let Some(client_version) = client_version else {
|
||||
return Ok(());
|
||||
};
|
||||
let Some(server_version) = read_u32_field(game_state, "runtimeActionVersion") else {
|
||||
return Ok(());
|
||||
};
|
||||
if client_version == server_version {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
Err(runtime_story_error_response(
|
||||
request_context,
|
||||
AppError::from_status(StatusCode::CONFLICT).with_details(json!({
|
||||
"provider": "runtime-story",
|
||||
"message": message,
|
||||
"clientVersion": client_version,
|
||||
"serverVersion": server_version,
|
||||
})),
|
||||
))
|
||||
}
|
||||
|
||||
fn resolve_runtime_story_choice_action(
|
||||
game_state: &mut Value,
|
||||
current_story: Option<&Value>,
|
||||
request: &RuntimeStoryActionRequest,
|
||||
function_id: &str,
|
||||
) -> Result<StoryResolution, String> {
|
||||
ensure_runtime_story_bridge_state(game_state);
|
||||
match function_id {
|
||||
CONTINUE_ADVENTURE_FUNCTION_ID => resolve_continue_adventure_action(current_story),
|
||||
"story_opening_camp_dialogue" => resolve_npc_affinity_action(
|
||||
game_state,
|
||||
request,
|
||||
"交换开场判断",
|
||||
2,
|
||||
"你把眼前局势先讲清楚,对方终于愿意把第一轮判断说出口。",
|
||||
),
|
||||
"camp_travel_home_scene" => {
|
||||
clear_encounter_state(game_state);
|
||||
Ok(StoryResolution {
|
||||
action_text: resolve_action_text("返回营地", request),
|
||||
result_text: "你主动结束了当前遭遇,把节奏带回了更安全的营地。".to_string(),
|
||||
story_text: None,
|
||||
presentation_options: None,
|
||||
saved_current_story: None,
|
||||
patches: vec![
|
||||
build_status_patch(game_state),
|
||||
RuntimeStoryPatch::EncounterChanged { encounter_id: None },
|
||||
],
|
||||
battle: None,
|
||||
toast: None,
|
||||
})
|
||||
}
|
||||
"idle_call_out" => Ok(simple_story_resolution(
|
||||
game_state,
|
||||
resolve_action_text("主动出声试探", request),
|
||||
"你的喊话打破了当前静场,周围潜着的动静也更难继续藏住。",
|
||||
)),
|
||||
"idle_explore_forward" => Ok(simple_story_resolution(
|
||||
game_state,
|
||||
resolve_action_text("继续向前探索", request),
|
||||
"你没有停在原地,而是继续向前压,把下一段遭遇主动推到自己面前。",
|
||||
)),
|
||||
"idle_observe_signs" => Ok(simple_story_resolution(
|
||||
game_state,
|
||||
resolve_action_text("观察周围迹象", request),
|
||||
"你先压住动作,把风向、脚印和气味这些细节重新读了一遍。",
|
||||
)),
|
||||
"idle_rest_focus" => {
|
||||
restore_player_resource(game_state, 8, 6);
|
||||
Ok(simple_story_resolution(
|
||||
game_state,
|
||||
resolve_action_text("原地调息", request),
|
||||
"你把呼吸慢下来重新稳住节奏,生命和灵力都回上来一点。",
|
||||
))
|
||||
}
|
||||
"idle_travel_next_scene" => {
|
||||
clear_encounter_state(game_state);
|
||||
increment_runtime_stat(game_state, "scenesTraveled", 1);
|
||||
Ok(StoryResolution {
|
||||
action_text: resolve_action_text("前往相邻场景", request),
|
||||
result_text: "你收束了这一段遭遇,顺着路线把故事推进到新的场景段落。".to_string(),
|
||||
story_text: None,
|
||||
presentation_options: None,
|
||||
saved_current_story: None,
|
||||
patches: vec![
|
||||
build_status_patch(game_state),
|
||||
RuntimeStoryPatch::EncounterChanged { encounter_id: None },
|
||||
],
|
||||
battle: None,
|
||||
toast: None,
|
||||
})
|
||||
}
|
||||
"npc_preview_talk" => resolve_npc_preview_action(game_state, request),
|
||||
"npc_chat" => resolve_npc_chat_action(game_state, request),
|
||||
"npc_help" => resolve_npc_help_action(game_state, request),
|
||||
"npc_chat_quest_offer_view" => {
|
||||
resolve_pending_quest_offer_view_action(game_state, current_story, request)
|
||||
}
|
||||
"npc_chat_quest_offer_replace" => {
|
||||
resolve_pending_quest_offer_replace_action(game_state, current_story, request)
|
||||
}
|
||||
"npc_chat_quest_offer_abandon" => {
|
||||
resolve_pending_quest_offer_abandon_action(game_state, current_story, request)
|
||||
}
|
||||
"npc_quest_accept" => {
|
||||
resolve_pending_quest_accept_action(game_state, current_story, request)
|
||||
}
|
||||
"npc_quest_turn_in" => resolve_pending_quest_turn_in_action(game_state, request),
|
||||
"npc_leave" => {
|
||||
let npc_name = current_encounter_name(game_state);
|
||||
clear_encounter_state(game_state);
|
||||
Ok(StoryResolution {
|
||||
action_text: resolve_action_text("离开当前角色", request),
|
||||
result_text: format!("你结束了与 {npc_name} 的这一轮接触,把注意力重新放回旅途。"),
|
||||
story_text: None,
|
||||
presentation_options: None,
|
||||
saved_current_story: None,
|
||||
patches: vec![
|
||||
build_status_patch(game_state),
|
||||
RuntimeStoryPatch::EncounterChanged { encounter_id: None },
|
||||
],
|
||||
battle: None,
|
||||
toast: None,
|
||||
})
|
||||
}
|
||||
"npc_fight" | "npc_spar" => {
|
||||
resolve_npc_battle_entry_action(game_state, request, function_id)
|
||||
}
|
||||
"npc_trade" => resolve_npc_trade_action(game_state, request),
|
||||
"npc_gift" => resolve_npc_gift_action(game_state, request),
|
||||
"npc_recruit" => resolve_npc_recruit_action(game_state, request),
|
||||
"equipment_equip" => resolve_equipment_equip_action(game_state, request),
|
||||
"equipment_unequip" => resolve_equipment_unequip_action(game_state, request),
|
||||
"forge_craft" => resolve_forge_craft_action(game_state, request),
|
||||
"forge_dismantle" => resolve_forge_dismantle_action(game_state, request),
|
||||
"forge_reforge" => resolve_forge_reforge_action(game_state, request),
|
||||
"battle_attack_basic"
|
||||
| "battle_use_skill"
|
||||
| "battle_all_in_crush"
|
||||
| "battle_escape_breakout"
|
||||
| "battle_feint_step"
|
||||
| "battle_finisher_window"
|
||||
| "battle_guard_break"
|
||||
| "battle_probe_pressure"
|
||||
| "battle_recover_breath"
|
||||
| "inventory_use" => resolve_battle_action(game_state, request, function_id),
|
||||
_ => Err(format!("暂不支持的 runtime action:{function_id}")),
|
||||
}
|
||||
}
|
||||
|
||||
fn resolve_continue_adventure_action(
|
||||
current_story: Option<&Value>,
|
||||
) -> Result<StoryResolution, String> {
|
||||
let deferred_options = current_story
|
||||
.map(|story| {
|
||||
read_array_field(story, "deferredOptions")
|
||||
.into_iter()
|
||||
.filter_map(build_runtime_story_option_from_story_option)
|
||||
.collect::<Vec<_>>()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
let options = (!deferred_options.is_empty()).then_some(deferred_options);
|
||||
|
||||
Ok(StoryResolution {
|
||||
action_text: "继续推进冒险".to_string(),
|
||||
result_text: "你没有把节奏停下来,而是顺着当前局势继续向前推进了这一段故事。".to_string(),
|
||||
story_text: None,
|
||||
presentation_options: options,
|
||||
saved_current_story: None,
|
||||
patches: Vec::new(),
|
||||
battle: None,
|
||||
toast: None,
|
||||
})
|
||||
}
|
||||
|
||||
fn map_runtime_story_client_error(error: SpacetimeClientError) -> AppError {
|
||||
let (status, provider) = match error {
|
||||
SpacetimeClientError::Runtime(_) => (StatusCode::BAD_REQUEST, "runtime-story"),
|
||||
_ => (StatusCode::BAD_GATEWAY, "spacetimedb"),
|
||||
};
|
||||
|
||||
AppError::from_status(status).with_details(json!({
|
||||
"provider": provider,
|
||||
"message": error.to_string(),
|
||||
}))
|
||||
}
|
||||
|
||||
fn runtime_story_error_response(request_context: &RequestContext, error: AppError) -> Response {
|
||||
error.into_response_with_context(Some(request_context))
|
||||
}
|
||||
358
server-rs/crates/api-server/src/runtime_story/compat/ai.rs
Normal file
358
server-rs/crates/api-server/src/runtime_story/compat/ai.rs
Normal file
@@ -0,0 +1,358 @@
|
||||
use super::*;
|
||||
|
||||
pub(super) async fn build_runtime_story_ai_response(
|
||||
state: &AppState,
|
||||
payload: RuntimeStoryAiRequest,
|
||||
initial: bool,
|
||||
) -> RuntimeStoryAiResponse {
|
||||
let options = build_ai_response_options(&payload);
|
||||
let fallback = build_ai_fallback_story_text(&payload, initial);
|
||||
let story_text = generate_ai_story_text(state, &payload, initial)
|
||||
.await
|
||||
.filter(|text| !text.trim().is_empty())
|
||||
.unwrap_or(fallback);
|
||||
|
||||
RuntimeStoryAiResponse {
|
||||
story_text,
|
||||
options,
|
||||
encounter: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) async fn generate_ai_story_text(
|
||||
state: &AppState,
|
||||
payload: &RuntimeStoryAiRequest,
|
||||
initial: bool,
|
||||
) -> Option<String> {
|
||||
let llm_client = state.llm_client()?;
|
||||
let system_prompt = if initial {
|
||||
"你是游戏运行时剧情导演。请用中文输出一段可直接展示给玩家的开局剧情,不要输出 JSON。"
|
||||
} else {
|
||||
"你是游戏运行时剧情导演。请用中文根据玩家选择续写一段剧情,不要输出 JSON。"
|
||||
};
|
||||
let user_prompt = json!({
|
||||
"worldType": payload.world_type,
|
||||
"character": payload.character,
|
||||
"monsters": payload.monsters,
|
||||
"history": payload.history,
|
||||
"choice": payload.choice,
|
||||
"context": payload.context,
|
||||
"availableOptions": payload.request_options.available_options,
|
||||
})
|
||||
.to_string();
|
||||
let mut request = LlmTextRequest::new(vec![
|
||||
LlmMessage::system(system_prompt),
|
||||
LlmMessage::user(user_prompt),
|
||||
]);
|
||||
request.max_tokens = Some(700);
|
||||
|
||||
llm_client
|
||||
.request_text(request)
|
||||
.await
|
||||
.ok()
|
||||
.map(|response| response.content.trim().to_string())
|
||||
.filter(|text| !text.is_empty())
|
||||
}
|
||||
|
||||
pub(super) async fn generate_action_story_payload(
|
||||
state: &AppState,
|
||||
game_state: &Value,
|
||||
request: &RuntimeStoryActionRequest,
|
||||
function_id: &str,
|
||||
action_text: &str,
|
||||
result_text: &str,
|
||||
options: &[RuntimeStoryOptionView],
|
||||
battle: Option<&RuntimeBattlePresentation>,
|
||||
) -> Option<GeneratedStoryPayload> {
|
||||
let llm_client = state.llm_client()?;
|
||||
// 动作结算仍由确定性规则完成;LLM 只负责把已结算结果改写为可展示文本,失败时不影响主链。
|
||||
if function_id == "npc_chat" || function_id == "story_opening_camp_dialogue" {
|
||||
return generate_npc_dialogue_payload(
|
||||
llm_client,
|
||||
game_state,
|
||||
request,
|
||||
action_text,
|
||||
result_text,
|
||||
options,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
if should_generate_reasoned_combat_story(battle) {
|
||||
return generate_reasoned_story_payload(
|
||||
llm_client,
|
||||
game_state,
|
||||
request,
|
||||
action_text,
|
||||
result_text,
|
||||
options,
|
||||
battle,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
pub(super) async fn generate_npc_dialogue_payload(
|
||||
llm_client: &LlmClient,
|
||||
game_state: &Value,
|
||||
request: &RuntimeStoryActionRequest,
|
||||
action_text: &str,
|
||||
result_text: &str,
|
||||
deferred_options: &[RuntimeStoryOptionView],
|
||||
) -> Option<GeneratedStoryPayload> {
|
||||
let world_type = current_world_type(game_state)?;
|
||||
let character = read_object_field(game_state, "playerCharacter")?.clone();
|
||||
let encounter = read_object_field(game_state, "currentEncounter")?;
|
||||
if read_required_string_field(encounter, "kind").as_deref() != Some("npc") {
|
||||
return None;
|
||||
}
|
||||
let npc_name = read_optional_string_field(encounter, "npcName")
|
||||
.or_else(|| read_optional_string_field(encounter, "name"))
|
||||
.unwrap_or_else(|| "对方".to_string());
|
||||
let user_prompt = json!({
|
||||
"worldType": world_type,
|
||||
"character": character,
|
||||
"encounter": encounter,
|
||||
"monsters": read_array_field(game_state, "sceneHostileNpcs").into_iter().cloned().collect::<Vec<_>>(),
|
||||
"history": build_action_story_history(game_state, action_text, result_text),
|
||||
"context": build_action_story_prompt_context(game_state, None),
|
||||
"topic": action_text,
|
||||
"resultSummary": result_text,
|
||||
"requestedOption": request.action.payload,
|
||||
"availableOptions": build_action_prompt_options(deferred_options),
|
||||
})
|
||||
.to_string();
|
||||
let mut llm_request = LlmTextRequest::new(vec![
|
||||
LlmMessage::system(
|
||||
"你是游戏运行时 NPC 对话导演。只输出中文正文,不要输出 JSON、Markdown 或规则说明;不要新增系统尚未结算的奖励、任务结果或战斗结果。",
|
||||
),
|
||||
LlmMessage::user(format!(
|
||||
"请基于以下运行时状态,把玩家这一轮选择改写成 2 到 5 行可直接展示的 NPC 对话。可以使用“你:”和“{npc_name}:”格式,必须保留既有结算含义。\n{user_prompt}"
|
||||
)),
|
||||
]);
|
||||
llm_request.max_tokens = Some(700);
|
||||
|
||||
let dialogue_text = llm_client
|
||||
.request_text(llm_request)
|
||||
.await
|
||||
.ok()
|
||||
.map(|response| response.content.trim().to_string())
|
||||
.filter(|text| !text.is_empty())?;
|
||||
let presentation_options = vec![build_continue_adventure_runtime_story_option()];
|
||||
let saved_current_story =
|
||||
build_dialogue_current_story(npc_name.as_str(), dialogue_text.as_str(), deferred_options);
|
||||
|
||||
Some(GeneratedStoryPayload {
|
||||
story_text: dialogue_text.clone(),
|
||||
history_result_text: dialogue_text,
|
||||
presentation_options,
|
||||
saved_current_story,
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) async fn generate_reasoned_story_payload(
|
||||
llm_client: &LlmClient,
|
||||
game_state: &Value,
|
||||
request: &RuntimeStoryActionRequest,
|
||||
action_text: &str,
|
||||
result_text: &str,
|
||||
options: &[RuntimeStoryOptionView],
|
||||
battle: Option<&RuntimeBattlePresentation>,
|
||||
) -> Option<GeneratedStoryPayload> {
|
||||
let world_type = current_world_type(game_state)?;
|
||||
let character = read_object_field(game_state, "playerCharacter")?.clone();
|
||||
let user_prompt = json!({
|
||||
"worldType": world_type,
|
||||
"character": character,
|
||||
"monsters": read_array_field(game_state, "sceneHostileNpcs").into_iter().cloned().collect::<Vec<_>>(),
|
||||
"history": build_action_story_history(game_state, action_text, result_text),
|
||||
"context": build_action_story_prompt_context(game_state, battle),
|
||||
"choice": action_text,
|
||||
"resultSummary": result_text,
|
||||
"requestedOption": request.action.payload,
|
||||
"availableOptions": build_action_prompt_options(options),
|
||||
})
|
||||
.to_string();
|
||||
let mut llm_request = LlmTextRequest::new(vec![
|
||||
LlmMessage::system(
|
||||
"你是游戏运行时剧情导演。只输出中文剧情正文,不要输出 JSON、Markdown 或规则说明;必须尊重已结算的战斗 outcome、伤害和状态,不要发明额外奖励。",
|
||||
),
|
||||
LlmMessage::user(format!(
|
||||
"请基于以下运行时状态,为这一轮战斗结算生成一段 120 字以内的结果叙事,并自然引出下一组选项。\n{user_prompt}"
|
||||
)),
|
||||
]);
|
||||
llm_request.max_tokens = Some(700);
|
||||
|
||||
let story_text = llm_client
|
||||
.request_text(llm_request)
|
||||
.await
|
||||
.ok()
|
||||
.map(|response| response.content.trim().to_string())
|
||||
.filter(|text| !text.is_empty())?;
|
||||
|
||||
Some(GeneratedStoryPayload {
|
||||
story_text: story_text.clone(),
|
||||
history_result_text: story_text.clone(),
|
||||
presentation_options: options.to_vec(),
|
||||
saved_current_story: build_legacy_current_story(story_text.as_str(), options),
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) fn should_generate_reasoned_combat_story(
|
||||
battle: Option<&RuntimeBattlePresentation>,
|
||||
) -> bool {
|
||||
battle
|
||||
.and_then(|presentation| presentation.outcome.as_deref())
|
||||
.is_some_and(|outcome| matches!(outcome, "victory" | "spar_complete" | "escaped"))
|
||||
}
|
||||
|
||||
pub(super) fn build_action_story_history(
|
||||
game_state: &Value,
|
||||
action_text: &str,
|
||||
result_text: &str,
|
||||
) -> Vec<Value> {
|
||||
let mut history = read_array_field(game_state, "storyHistory")
|
||||
.into_iter()
|
||||
.filter_map(|entry| {
|
||||
let text = read_optional_string_field(entry, "text")?;
|
||||
let history_role = read_optional_string_field(entry, "historyRole")
|
||||
.unwrap_or_else(|| "result".to_string());
|
||||
Some(json!({
|
||||
"text": text,
|
||||
"historyRole": history_role,
|
||||
}))
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
history.push(json!({
|
||||
"text": action_text,
|
||||
"historyRole": "action",
|
||||
}));
|
||||
history.push(json!({
|
||||
"text": result_text,
|
||||
"historyRole": "result",
|
||||
}));
|
||||
let keep_from = history.len().saturating_sub(12);
|
||||
history.into_iter().skip(keep_from).collect()
|
||||
}
|
||||
|
||||
pub(super) fn build_action_story_prompt_context(
|
||||
game_state: &Value,
|
||||
battle: Option<&RuntimeBattlePresentation>,
|
||||
) -> Value {
|
||||
let scene_preset = read_object_field(game_state, "currentScenePreset");
|
||||
let battle_value = battle
|
||||
.and_then(|presentation| serde_json::to_value(presentation).ok())
|
||||
.unwrap_or(Value::Null);
|
||||
|
||||
json!({
|
||||
"sceneName": scene_preset
|
||||
.and_then(|scene| read_optional_string_field(scene, "name"))
|
||||
.or_else(|| read_optional_string_field(game_state, "currentScene"))
|
||||
.unwrap_or_else(|| "当前区域".to_string()),
|
||||
"sceneDescription": scene_preset
|
||||
.and_then(|scene| read_optional_string_field(scene, "description"))
|
||||
.or_else(|| read_optional_string_field(game_state, "sceneDescription"))
|
||||
.unwrap_or_else(|| "周围气氛仍在继续变化。".to_string()),
|
||||
"encounterName": read_object_field(game_state, "currentEncounter")
|
||||
.and_then(|encounter| {
|
||||
read_optional_string_field(encounter, "npcName")
|
||||
.or_else(|| read_optional_string_field(encounter, "name"))
|
||||
}),
|
||||
"encounterId": current_encounter_id(game_state),
|
||||
"playerHp": read_i32_field(game_state, "playerHp").unwrap_or(0),
|
||||
"playerMaxHp": read_i32_field(game_state, "playerMaxHp").unwrap_or(1),
|
||||
"playerMana": read_i32_field(game_state, "playerMana").unwrap_or(0),
|
||||
"playerMaxMana": read_i32_field(game_state, "playerMaxMana").unwrap_or(1),
|
||||
"inBattle": read_bool_field(game_state, "inBattle").unwrap_or(false),
|
||||
"currentNpcBattleOutcome": read_optional_string_field(game_state, "currentNpcBattleOutcome"),
|
||||
"battle": battle_value,
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) fn build_action_prompt_options(options: &[RuntimeStoryOptionView]) -> Vec<Value> {
|
||||
options
|
||||
.iter()
|
||||
.filter(|option| !option.disabled.unwrap_or(false))
|
||||
.map(|option| {
|
||||
json!({
|
||||
"functionId": option.function_id,
|
||||
"actionText": option.action_text,
|
||||
"text": option.action_text,
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub(super) fn build_ai_response_options(payload: &RuntimeStoryAiRequest) -> Vec<Value> {
|
||||
let source = if payload.request_options.available_options.is_empty() {
|
||||
&payload.request_options.option_catalog
|
||||
} else {
|
||||
&payload.request_options.available_options
|
||||
};
|
||||
let options = source
|
||||
.iter()
|
||||
.filter_map(normalize_ai_story_option)
|
||||
.collect::<Vec<_>>();
|
||||
if !options.is_empty() {
|
||||
return options;
|
||||
}
|
||||
|
||||
vec![
|
||||
build_ai_story_option_value("idle_observe_signs", "观察周围迹象"),
|
||||
build_ai_story_option_value("idle_explore_forward", "继续向前探索"),
|
||||
build_ai_story_option_value("idle_rest_focus", "原地调息"),
|
||||
]
|
||||
}
|
||||
|
||||
pub(super) fn normalize_ai_story_option(value: &Value) -> Option<Value> {
|
||||
let function_id = read_required_string_field(value, "functionId")?;
|
||||
let action_text = read_required_string_field(value, "actionText")
|
||||
.or_else(|| read_required_string_field(value, "text"))
|
||||
.unwrap_or_else(|| function_id.clone());
|
||||
let mut option = value.as_object()?.clone();
|
||||
option.insert("functionId".to_string(), Value::String(function_id));
|
||||
option.insert("actionText".to_string(), Value::String(action_text.clone()));
|
||||
option
|
||||
.entry("text".to_string())
|
||||
.or_insert_with(|| Value::String(action_text));
|
||||
|
||||
Some(Value::Object(option))
|
||||
}
|
||||
|
||||
pub(super) fn build_ai_story_option_value(function_id: &str, action_text: &str) -> Value {
|
||||
json!({
|
||||
"functionId": function_id,
|
||||
"actionText": action_text,
|
||||
"text": action_text,
|
||||
"visuals": {
|
||||
"playerAnimation": "idle",
|
||||
"playerMoveMeters": 0,
|
||||
"playerOffsetY": 0,
|
||||
"playerFacing": "right",
|
||||
"scrollWorld": false,
|
||||
"monsterChanges": []
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) fn build_ai_fallback_story_text(
|
||||
payload: &RuntimeStoryAiRequest,
|
||||
initial: bool,
|
||||
) -> String {
|
||||
let character_name =
|
||||
read_optional_string_field(&payload.character, "name").unwrap_or_else(|| "你".to_string());
|
||||
let scene_name = read_optional_string_field(&payload.context, "sceneName")
|
||||
.or_else(|| read_optional_string_field(&payload.context, "scene"))
|
||||
.unwrap_or_else(|| "当前区域".to_string());
|
||||
if initial {
|
||||
return format!(
|
||||
"{character_name} 在 {scene_name} 稳住脚步,周围的气息正在变化,第一轮选择已经摆到眼前。"
|
||||
);
|
||||
}
|
||||
|
||||
let choice = normalize_required_string(payload.choice.as_str())
|
||||
.unwrap_or_else(|| "继续推进".to_string());
|
||||
format!("{character_name} 选择了「{choice}」,{scene_name} 的局势随之向下一步展开。")
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
use super::*;
|
||||
|
||||
/// 对齐 Node 旧 inventory compat,先按装备位把物品从背包切到 playerEquipment,
|
||||
/// 再把基础面板属性回算到快照上。
|
||||
pub(super) fn resolve_equipment_equip_action(
|
||||
game_state: &mut Value,
|
||||
request: &RuntimeStoryActionRequest,
|
||||
) -> Result<StoryResolution, String> {
|
||||
if read_field(game_state, "playerCharacter").is_none() {
|
||||
return Err("缺少玩家角色,无法调整装备。".to_string());
|
||||
}
|
||||
if read_bool_field(game_state, "inBattle").unwrap_or(false) {
|
||||
return Err("战斗中无法调整装备。".to_string());
|
||||
}
|
||||
let item_id = request
|
||||
.action
|
||||
.payload
|
||||
.as_ref()
|
||||
.and_then(|payload| read_optional_string_field(payload, "itemId"))
|
||||
.or_else(|| request.action.target_id.clone())
|
||||
.ok_or_else(|| "equipment_equip 缺少 itemId".to_string())?;
|
||||
let item = find_player_inventory_entry(game_state, item_id.as_str())
|
||||
.cloned()
|
||||
.ok_or_else(|| "背包里没有这件装备。".to_string())?;
|
||||
let slot_id = resolve_equipment_slot_for_item(&item)
|
||||
.ok_or_else(|| format!("{} 不是可装备物品。", read_inventory_item_name(&item)))?;
|
||||
let previous_equipment = read_player_equipment_item(game_state, slot_id);
|
||||
let next_equipment_item = normalize_equipped_item(&item);
|
||||
|
||||
remove_player_inventory_item(game_state, item_id.as_str(), 1);
|
||||
if let Some(previous_equipment) = previous_equipment.as_ref() {
|
||||
add_player_inventory_items(game_state, vec![previous_equipment.clone()]);
|
||||
}
|
||||
write_player_equipment_item(game_state, slot_id, Some(next_equipment_item));
|
||||
apply_equipment_loadout_to_state(game_state);
|
||||
|
||||
let item_name = read_inventory_item_name(&item);
|
||||
let result_text = if let Some(previous_equipment) = previous_equipment.as_ref() {
|
||||
format!(
|
||||
"你将{}从{}位上换下,改为装备{}。",
|
||||
read_inventory_item_name(previous_equipment),
|
||||
equipment_slot_label(slot_id),
|
||||
item_name
|
||||
)
|
||||
} else {
|
||||
format!(
|
||||
"你将{}装备在{}位上。",
|
||||
item_name,
|
||||
equipment_slot_label(slot_id)
|
||||
)
|
||||
};
|
||||
|
||||
Ok(StoryResolution {
|
||||
action_text: resolve_action_text(&format!("装备{}", item_name), request),
|
||||
result_text,
|
||||
story_text: None,
|
||||
presentation_options: None,
|
||||
saved_current_story: None,
|
||||
patches: Vec::new(),
|
||||
battle: None,
|
||||
toast: Some(build_current_build_toast(game_state)),
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) fn resolve_equipment_unequip_action(
|
||||
game_state: &mut Value,
|
||||
request: &RuntimeStoryActionRequest,
|
||||
) -> Result<StoryResolution, String> {
|
||||
ensure_inventory_action_available(
|
||||
game_state,
|
||||
"缺少玩家角色,无法卸下装备。",
|
||||
"战斗中无法卸下装备。",
|
||||
)?;
|
||||
let slot_id = request
|
||||
.action
|
||||
.payload
|
||||
.as_ref()
|
||||
.and_then(|payload| read_optional_string_field(payload, "slotId"))
|
||||
.or_else(|| request.action.target_id.clone())
|
||||
.ok_or_else(|| "equipment_unequip 缺少合法 slotId".to_string())?;
|
||||
let slot_id = normalize_equipment_slot_id(slot_id.as_str())
|
||||
.ok_or_else(|| "equipment_unequip 缺少合法 slotId".to_string())?;
|
||||
let equipped_item = read_player_equipment_item(game_state, slot_id)
|
||||
.ok_or_else(|| format!("{}位当前没有装备。", equipment_slot_label(slot_id)))?;
|
||||
|
||||
write_player_equipment_item(game_state, slot_id, None);
|
||||
add_player_inventory_items(game_state, vec![equipped_item.clone()]);
|
||||
apply_equipment_loadout_to_state(game_state);
|
||||
|
||||
Ok(StoryResolution {
|
||||
action_text: resolve_action_text(
|
||||
&format!("卸下{}", read_inventory_item_name(&equipped_item)),
|
||||
request,
|
||||
),
|
||||
result_text: format!(
|
||||
"你卸下了{},暂时收回背包。",
|
||||
read_inventory_item_name(&equipped_item)
|
||||
),
|
||||
story_text: None,
|
||||
presentation_options: None,
|
||||
saved_current_story: None,
|
||||
patches: Vec::new(),
|
||||
battle: None,
|
||||
toast: Some(build_current_build_toast(game_state)),
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
use super::*;
|
||||
|
||||
pub(super) use module_runtime_story_compat::{
|
||||
build_runtime_equipment_item, build_runtime_material_item,
|
||||
};
|
||||
@@ -0,0 +1,699 @@
|
||||
use super::*;
|
||||
use module_runtime_story_compat::{build_runtime_equipment_item, build_runtime_material_item};
|
||||
|
||||
pub(super) fn current_npc_trade_context(game_state: &Value) -> Result<(String, String), String> {
|
||||
let encounter = read_object_field(game_state, "currentEncounter")
|
||||
.ok_or_else(|| "当前不在可结算的 NPC 交互态,无法执行交易或赠礼。".to_string())?;
|
||||
let kind = read_required_string_field(encounter, "kind")
|
||||
.ok_or_else(|| "当前不在可结算的 NPC 交互态,无法执行交易或赠礼。".to_string())?;
|
||||
if kind != "npc" {
|
||||
return Err("当前不在可结算的 NPC 交互态,无法执行交易或赠礼。".to_string());
|
||||
}
|
||||
let npc_name = current_encounter_name(game_state);
|
||||
let npc_id = current_encounter_id(game_state).unwrap_or_else(|| npc_name.clone());
|
||||
if resolve_current_encounter_npc_state(game_state, npc_id.as_str(), npc_name.as_str()).is_none()
|
||||
{
|
||||
return Err("当前 NPC 状态不存在,无法继续结算。".to_string());
|
||||
}
|
||||
Ok((npc_id, npc_name))
|
||||
}
|
||||
|
||||
pub(super) fn current_npc_inventory_items<'a>(game_state: &'a Value) -> Vec<&'a Value> {
|
||||
let Some(npc_id) = current_encounter_id(game_state) else {
|
||||
return Vec::new();
|
||||
};
|
||||
let npc_name = current_encounter_name(game_state);
|
||||
resolve_current_encounter_npc_state(game_state, npc_id.as_str(), npc_name.as_str())
|
||||
.map(|state| read_array_field(state, "inventory"))
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// 兼容桥沿用 Node 旧域的入口预处理:在读取选项或结算动作前,
|
||||
/// 先确保当前 NPC 的持久状态最少可用,避免空快照直接打断交易/赠礼/委托主链。
|
||||
pub(super) fn ensure_runtime_story_bridge_state(game_state: &mut Value) {
|
||||
ensure_current_encounter_npc_state_initialized(game_state);
|
||||
}
|
||||
|
||||
/// 这里不尝试一次性重建完整真相态,只补 compat bridge 当前确实依赖的字段,
|
||||
/// 并为“纯商贩型 NPC”补一份确定性 trade stock,保证旧前端菜单不因空状态掉链子。
|
||||
pub(super) fn ensure_current_encounter_npc_state_initialized(game_state: &mut Value) {
|
||||
let Some(encounter) = read_object_field(game_state, "currentEncounter").cloned() else {
|
||||
return;
|
||||
};
|
||||
if read_optional_string_field(&encounter, "kind").as_deref() != Some("npc") {
|
||||
return;
|
||||
}
|
||||
|
||||
let npc_name = read_optional_string_field(&encounter, "npcName")
|
||||
.or_else(|| read_optional_string_field(&encounter, "name"))
|
||||
.unwrap_or_else(|| "当前遭遇".to_string());
|
||||
let npc_id = read_optional_string_field(&encounter, "id").unwrap_or_else(|| npc_name.clone());
|
||||
let storage_key = resolve_npc_state_storage_key(game_state, npc_id.as_str(), npc_name.as_str());
|
||||
let existing_state = read_field(game_state, "npcStates")
|
||||
.and_then(|states| read_field(states, storage_key.as_str()))
|
||||
.cloned();
|
||||
|
||||
let affinity = existing_state
|
||||
.as_ref()
|
||||
.and_then(|state| read_i32_field(state, "affinity"))
|
||||
.unwrap_or_else(|| default_current_npc_affinity(&encounter));
|
||||
let recruited = existing_state
|
||||
.as_ref()
|
||||
.and_then(|state| read_bool_field(state, "recruited"))
|
||||
.unwrap_or(false);
|
||||
let chatted_count = existing_state
|
||||
.as_ref()
|
||||
.and_then(|state| read_i32_field(state, "chattedCount"))
|
||||
.unwrap_or(0)
|
||||
.max(0);
|
||||
let gifts_given = existing_state
|
||||
.as_ref()
|
||||
.and_then(|state| read_i32_field(state, "giftsGiven"))
|
||||
.unwrap_or(0)
|
||||
.max(0);
|
||||
let help_used = existing_state
|
||||
.as_ref()
|
||||
.and_then(|state| read_bool_field(state, "helpUsed"))
|
||||
.unwrap_or(false);
|
||||
let first_meaningful_contact_resolved = existing_state
|
||||
.as_ref()
|
||||
.and_then(|state| read_bool_field(state, "firstMeaningfulContactResolved"))
|
||||
.unwrap_or(false);
|
||||
let revealed_facts = existing_state
|
||||
.as_ref()
|
||||
.map(|state| read_string_list_field(state, "revealedFacts"))
|
||||
.unwrap_or_default();
|
||||
let known_attribute_rumors = existing_state
|
||||
.as_ref()
|
||||
.map(|state| read_string_list_field(state, "knownAttributeRumors"))
|
||||
.unwrap_or_default();
|
||||
let seen_backstory_chapter_ids = existing_state
|
||||
.as_ref()
|
||||
.map(|state| read_string_list_field(state, "seenBackstoryChapterIds"))
|
||||
.unwrap_or_default();
|
||||
let existing_inventory = existing_state
|
||||
.as_ref()
|
||||
.map(|state| {
|
||||
read_array_field(state, "inventory")
|
||||
.into_iter()
|
||||
.cloned()
|
||||
.collect::<Vec<_>>()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
let existing_trade_stock_signature = existing_state
|
||||
.as_ref()
|
||||
.and_then(|state| read_optional_string_field(state, "tradeStockSignature"));
|
||||
let hostile = read_bool_field(&encounter, "hostile").unwrap_or(false)
|
||||
|| read_optional_string_field(&encounter, "monsterPresetId").is_some()
|
||||
|| affinity < 0;
|
||||
let context_text = read_optional_string_field(&encounter, "context");
|
||||
|
||||
let (inventory, trade_stock_signature) = if is_trade_driven_role_npc(&encounter) {
|
||||
let next_signature = build_current_npc_trade_stock_signature(game_state, npc_id.as_str());
|
||||
if existing_trade_stock_signature.as_deref() == Some(next_signature.as_str()) {
|
||||
(existing_inventory, Some(next_signature))
|
||||
} else {
|
||||
(
|
||||
sync_bootstrapped_trade_inventory(
|
||||
game_state,
|
||||
npc_id.as_str(),
|
||||
npc_name.as_str(),
|
||||
existing_inventory,
|
||||
next_signature.as_str(),
|
||||
),
|
||||
Some(next_signature),
|
||||
)
|
||||
}
|
||||
} else {
|
||||
(existing_inventory, existing_trade_stock_signature)
|
||||
};
|
||||
|
||||
let relation_state = build_runtime_story_relation_state_value(affinity);
|
||||
let stance_profile = build_runtime_story_stance_profile_value(
|
||||
affinity,
|
||||
recruited,
|
||||
hostile,
|
||||
context_text.as_deref(),
|
||||
existing_state
|
||||
.as_ref()
|
||||
.and_then(|state| read_field(state, "stanceProfile"))
|
||||
.and_then(Value::as_object),
|
||||
);
|
||||
let npc_state = json!({
|
||||
"affinity": affinity,
|
||||
"chattedCount": chatted_count,
|
||||
"helpUsed": help_used,
|
||||
"giftsGiven": gifts_given,
|
||||
"inventory": inventory,
|
||||
"recruited": recruited,
|
||||
"relationState": relation_state,
|
||||
"revealedFacts": revealed_facts,
|
||||
"knownAttributeRumors": known_attribute_rumors,
|
||||
"firstMeaningfulContactResolved": first_meaningful_contact_resolved,
|
||||
"seenBackstoryChapterIds": seen_backstory_chapter_ids,
|
||||
"tradeStockSignature": trade_stock_signature,
|
||||
"stanceProfile": stance_profile,
|
||||
});
|
||||
|
||||
let root = ensure_json_object(game_state);
|
||||
let npc_states = root
|
||||
.entry("npcStates".to_string())
|
||||
.or_insert_with(|| Value::Object(Map::new()));
|
||||
if !npc_states.is_object() {
|
||||
*npc_states = Value::Object(Map::new());
|
||||
}
|
||||
npc_states
|
||||
.as_object_mut()
|
||||
.expect("npcStates should be object")
|
||||
.insert(storage_key, npc_state);
|
||||
}
|
||||
|
||||
pub(super) fn resolve_npc_state_storage_key(
|
||||
game_state: &Value,
|
||||
npc_id: &str,
|
||||
npc_name: &str,
|
||||
) -> String {
|
||||
read_object_field(game_state, "npcStates")
|
||||
.and_then(Value::as_object)
|
||||
.and_then(|states| {
|
||||
if states.contains_key(npc_id) {
|
||||
Some(npc_id.to_string())
|
||||
} else if states.contains_key(npc_name) {
|
||||
Some(npc_name.to_string())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.unwrap_or_else(|| npc_id.to_string())
|
||||
}
|
||||
|
||||
pub(super) fn default_current_npc_affinity(encounter: &Value) -> i32 {
|
||||
read_i32_field(encounter, "initialAffinity").unwrap_or_else(|| {
|
||||
if read_optional_string_field(encounter, "monsterPresetId").is_some() {
|
||||
-40
|
||||
} else if read_optional_string_field(encounter, "characterId").is_some() {
|
||||
18
|
||||
} else {
|
||||
6
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) fn read_string_list_field(value: &Value, key: &str) -> Vec<String> {
|
||||
let mut items = read_array_field(value, key)
|
||||
.into_iter()
|
||||
.filter_map(Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|entry| !entry.is_empty())
|
||||
.map(str::to_string)
|
||||
.collect::<Vec<_>>();
|
||||
if items.len() > 3 {
|
||||
items = items.split_off(items.len() - 3);
|
||||
}
|
||||
items
|
||||
}
|
||||
|
||||
pub(super) fn build_runtime_story_relation_state_value(affinity: i32) -> Value {
|
||||
let relation_state = build_module_npc_relation_state(affinity);
|
||||
json!({
|
||||
"affinity": relation_state.affinity,
|
||||
"stance": npc_relation_stance_key(relation_state.stance),
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) fn npc_relation_stance_key(value: NpcRelationStance) -> &'static str {
|
||||
match value {
|
||||
NpcRelationStance::Hostile => "hostile",
|
||||
NpcRelationStance::Guarded => "guarded",
|
||||
NpcRelationStance::Neutral => "neutral",
|
||||
NpcRelationStance::Cooperative => "cooperative",
|
||||
NpcRelationStance::Bonded => "bonded",
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn build_runtime_story_stance_profile_value(
|
||||
affinity: i32,
|
||||
recruited: bool,
|
||||
hostile: bool,
|
||||
role_text: Option<&str>,
|
||||
existing_profile: Option<&Map<String, Value>>,
|
||||
) -> Value {
|
||||
let base = build_module_npc_initial_stance_profile(affinity, recruited, hostile, role_text);
|
||||
let read_metric = |key: &str, fallback: u8| -> i32 {
|
||||
existing_profile
|
||||
.and_then(|profile| profile.get(key))
|
||||
.and_then(Value::as_i64)
|
||||
.and_then(|value| i32::try_from(value).ok())
|
||||
.unwrap_or(i32::from(fallback))
|
||||
.clamp(0, 100)
|
||||
};
|
||||
let recent_approvals = existing_profile
|
||||
.and_then(|profile| profile.get("recentApprovals"))
|
||||
.map(|value| read_string_list_field(value, ""))
|
||||
.unwrap_or_else(|| base.recent_approvals.clone());
|
||||
let recent_disapprovals = existing_profile
|
||||
.and_then(|profile| profile.get("recentDisapprovals"))
|
||||
.map(|value| read_string_list_field(value, ""))
|
||||
.unwrap_or_else(|| base.recent_disapprovals.clone());
|
||||
|
||||
json!({
|
||||
"trust": read_metric("trust", base.trust),
|
||||
"warmth": read_metric("warmth", base.warmth),
|
||||
"ideologicalFit": read_metric("ideologicalFit", base.ideological_fit),
|
||||
"fearOrGuard": read_metric("fearOrGuard", base.fear_or_guard),
|
||||
"loyalty": read_metric("loyalty", base.loyalty),
|
||||
"currentConflictTag": existing_profile
|
||||
.and_then(|profile| profile.get("currentConflictTag"))
|
||||
.and_then(Value::as_str)
|
||||
.map(str::to_string)
|
||||
.or(base.current_conflict_tag),
|
||||
"recentApprovals": recent_approvals,
|
||||
"recentDisapprovals": recent_disapprovals,
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) fn is_trade_driven_role_npc(encounter: &Value) -> bool {
|
||||
read_optional_string_field(encounter, "characterId").is_none()
|
||||
&& read_optional_string_field(encounter, "monsterPresetId").is_none()
|
||||
}
|
||||
|
||||
pub(super) fn build_current_npc_trade_stock_signature(game_state: &Value, npc_id: &str) -> String {
|
||||
let scene_key = read_object_field(game_state, "currentScenePreset")
|
||||
.and_then(|preset| {
|
||||
read_optional_string_field(preset, "id")
|
||||
.or_else(|| read_optional_string_field(preset, "name"))
|
||||
})
|
||||
.or_else(|| read_optional_string_field(game_state, "currentScene"))
|
||||
.unwrap_or_else(|| "scene".to_string());
|
||||
let world_key = current_world_type(game_state).unwrap_or_else(|| "world".to_string());
|
||||
format!(
|
||||
"{}:{}:{}",
|
||||
sanitize_trade_stock_fragment(npc_id),
|
||||
sanitize_trade_stock_fragment(scene_key.as_str()),
|
||||
sanitize_trade_stock_fragment(world_key.as_str())
|
||||
)
|
||||
}
|
||||
|
||||
pub(super) fn sanitize_trade_stock_fragment(value: &str) -> String {
|
||||
let normalized = value
|
||||
.trim()
|
||||
.chars()
|
||||
.map(|ch| match ch {
|
||||
':' | '/' | '\\' | ' ' => '-',
|
||||
_ => ch,
|
||||
})
|
||||
.collect::<String>();
|
||||
if normalized.is_empty() {
|
||||
"unknown".to_string()
|
||||
} else {
|
||||
normalized
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn sync_bootstrapped_trade_inventory(
|
||||
game_state: &Value,
|
||||
npc_id: &str,
|
||||
npc_name: &str,
|
||||
existing_inventory: Vec<Value>,
|
||||
trade_stock_signature: &str,
|
||||
) -> Vec<Value> {
|
||||
let preserved_inventory = existing_inventory
|
||||
.into_iter()
|
||||
.filter(|item| {
|
||||
read_field(item, "runtimeMetadata")
|
||||
.and_then(|metadata| read_optional_string_field(metadata, "generationChannel"))
|
||||
.as_deref()
|
||||
!= Some("npc_trade")
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
let mut next_inventory = preserved_inventory;
|
||||
next_inventory.extend(build_bootstrapped_trade_inventory(
|
||||
game_state,
|
||||
npc_id,
|
||||
npc_name,
|
||||
trade_stock_signature,
|
||||
));
|
||||
next_inventory
|
||||
}
|
||||
|
||||
pub(super) fn build_bootstrapped_trade_inventory(
|
||||
game_state: &Value,
|
||||
npc_id: &str,
|
||||
npc_name: &str,
|
||||
trade_stock_signature: &str,
|
||||
) -> Vec<Value> {
|
||||
let world_type = current_world_type(game_state);
|
||||
let consumable_name = if world_type.as_deref() == Some("XIANXIA") {
|
||||
"回灵散"
|
||||
} else {
|
||||
"回气散"
|
||||
};
|
||||
let material_name = if world_type.as_deref() == Some("XIANXIA") {
|
||||
"凝光纱"
|
||||
} else {
|
||||
"工巧残材"
|
||||
};
|
||||
let relic_name = if world_type.as_deref() == Some("XIANXIA") {
|
||||
"行旅护符"
|
||||
} else {
|
||||
"结绳护符"
|
||||
};
|
||||
let armor_name = if world_type.as_deref() == Some("XIANXIA") {
|
||||
"护行法衣"
|
||||
} else {
|
||||
"护行短甲"
|
||||
};
|
||||
let tonic_id = format!("npc-trade:{trade_stock_signature}:tonic");
|
||||
let material_id = format!("npc-trade:{trade_stock_signature}:material");
|
||||
let relic_id = format!("npc-trade:{trade_stock_signature}:relic");
|
||||
let armor_id = format!("npc-trade:{trade_stock_signature}:armor");
|
||||
|
||||
vec![
|
||||
build_bootstrapped_trade_consumable_item(
|
||||
tonic_id.as_str(),
|
||||
consumable_name,
|
||||
npc_name,
|
||||
world_type.as_deref(),
|
||||
),
|
||||
attach_generated_trade_metadata(
|
||||
build_runtime_material_item(
|
||||
game_state,
|
||||
material_name,
|
||||
2,
|
||||
&["工巧", "补给"],
|
||||
"uncommon",
|
||||
),
|
||||
material_id.as_str(),
|
||||
"npc_trade",
|
||||
format!("{npc_id}:material").as_str(),
|
||||
format!("{npc_name}整理出来的可交易工坊材料。").as_str(),
|
||||
),
|
||||
attach_generated_trade_metadata(
|
||||
build_runtime_equipment_item(
|
||||
game_state,
|
||||
relic_name,
|
||||
"relic",
|
||||
"rare",
|
||||
"适合长途行路时稳住灵力与节奏的护符。",
|
||||
"护持",
|
||||
&["护持", "法力"],
|
||||
&["护持", "法力"],
|
||||
json!({
|
||||
"maxManaBonus": 12,
|
||||
"outgoingDamageBonus": 0.05
|
||||
}),
|
||||
),
|
||||
relic_id.as_str(),
|
||||
"npc_trade",
|
||||
format!("{npc_id}:relic").as_str(),
|
||||
format!("{npc_name}随身携带的护身小物。").as_str(),
|
||||
),
|
||||
attach_generated_trade_metadata(
|
||||
build_runtime_equipment_item(
|
||||
game_state,
|
||||
armor_name,
|
||||
"armor",
|
||||
"rare",
|
||||
"为行路与近身护体准备的轻装护具。",
|
||||
"守御",
|
||||
&["守御", "护体"],
|
||||
&["守御", "护体"],
|
||||
json!({
|
||||
"maxHpBonus": 18,
|
||||
"incomingDamageMultiplier": 0.93
|
||||
}),
|
||||
),
|
||||
armor_id.as_str(),
|
||||
"npc_trade",
|
||||
format!("{npc_id}:armor").as_str(),
|
||||
format!("{npc_name}压箱底留下的一件护身装备。").as_str(),
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
pub(super) fn build_bootstrapped_trade_consumable_item(
|
||||
item_id: &str,
|
||||
name: &str,
|
||||
npc_name: &str,
|
||||
world_type: Option<&str>,
|
||||
) -> Value {
|
||||
json!({
|
||||
"id": item_id,
|
||||
"category": "消耗品",
|
||||
"name": name,
|
||||
"description": format!("{npc_name}常备的一份行路补给。"),
|
||||
"quantity": 2,
|
||||
"rarity": "uncommon",
|
||||
"tags": if world_type == Some("XIANXIA") {
|
||||
vec!["mana", "support", "trade"]
|
||||
} else {
|
||||
vec!["mana", "support", "trade"]
|
||||
},
|
||||
"useProfile": {
|
||||
"hpRestore": 0,
|
||||
"manaRestore": 10,
|
||||
"cooldownReduction": 0,
|
||||
"buildBuffs": []
|
||||
},
|
||||
"runtimeMetadata": {
|
||||
"origin": "procedural",
|
||||
"generationChannel": "npc_trade",
|
||||
"seedKey": format!("{item_id}:seed"),
|
||||
"sourceReason": format!("{npc_name}把最常用的补给拿出来做成了交易库存。"),
|
||||
"storyFingerprint": {
|
||||
"relatedScarIds": [format!("scar:npc_trade:{item_id}")],
|
||||
"relatedThreadIds": [],
|
||||
"visibleClue": format!("{npc_name}随身药囊里最顺手的一味补给。"),
|
||||
"witnessMark": "药包封口处还留着反复拆开的折痕。",
|
||||
"unresolvedQuestion": "这份补给之前究竟替谁留着。"
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) fn attach_generated_trade_metadata(
|
||||
mut item: Value,
|
||||
item_id: &str,
|
||||
generation_channel: &str,
|
||||
seed_key: &str,
|
||||
source_reason: &str,
|
||||
) -> Value {
|
||||
let item_name = read_inventory_item_name(&item);
|
||||
let entry = ensure_json_object(&mut item);
|
||||
entry.insert("id".to_string(), Value::String(item_id.to_string()));
|
||||
entry.insert(
|
||||
"runtimeMetadata".to_string(),
|
||||
json!({
|
||||
"origin": "procedural",
|
||||
"generationChannel": generation_channel,
|
||||
"seedKey": seed_key,
|
||||
"sourceReason": source_reason,
|
||||
"storyFingerprint": {
|
||||
"relatedScarIds": [format!("scar:{generation_channel}:{seed_key}")],
|
||||
"relatedThreadIds": [],
|
||||
"visibleClue": format!("{item_name}上保留着反复流转留下的使用痕迹。"),
|
||||
"witnessMark": "表面仍残留旧主人长期携带的磨损。",
|
||||
"unresolvedQuestion": format!("{item_name}最初为什么会落到这名 NPC 手里。"),
|
||||
}
|
||||
}),
|
||||
);
|
||||
item
|
||||
}
|
||||
|
||||
pub(super) fn read_current_npc_inventory_item<'a>(
|
||||
game_state: &'a Value,
|
||||
item_id: &str,
|
||||
) -> Option<&'a Value> {
|
||||
current_npc_inventory_items(game_state)
|
||||
.into_iter()
|
||||
.find(|item| read_optional_string_field(item, "id").as_deref() == Some(item_id))
|
||||
}
|
||||
|
||||
pub(super) fn adjust_current_npc_affinity(
|
||||
game_state: &mut Value,
|
||||
delta: i32,
|
||||
) -> Option<(String, i32, i32)> {
|
||||
let npc_id = current_encounter_id(game_state)?;
|
||||
let npc_name = current_encounter_name(game_state);
|
||||
let state = ensure_npc_state_object(game_state, npc_id.as_str(), npc_name.as_str());
|
||||
let previous_affinity = state
|
||||
.get("affinity")
|
||||
.and_then(Value::as_i64)
|
||||
.and_then(|value| i32::try_from(value).ok())
|
||||
.unwrap_or(0);
|
||||
let next_affinity = (previous_affinity + delta).clamp(-100, 100);
|
||||
state.insert("affinity".to_string(), json!(next_affinity));
|
||||
state
|
||||
.entry("recruited".to_string())
|
||||
.or_insert(Value::Bool(false));
|
||||
|
||||
Some((npc_id, previous_affinity, next_affinity))
|
||||
}
|
||||
|
||||
pub(super) fn read_current_npc_state_i32_field(game_state: &Value, key: &str) -> Option<i32> {
|
||||
let npc_id = current_encounter_id(game_state)?;
|
||||
let npc_name = current_encounter_name(game_state);
|
||||
resolve_current_encounter_npc_state(game_state, npc_id.as_str(), npc_name.as_str())
|
||||
.and_then(|state| read_i32_field(state, key))
|
||||
}
|
||||
|
||||
pub(super) fn read_current_npc_state_bool_field(game_state: &Value, key: &str) -> Option<bool> {
|
||||
let npc_id = current_encounter_id(game_state)?;
|
||||
let npc_name = current_encounter_name(game_state);
|
||||
resolve_current_encounter_npc_state(game_state, npc_id.as_str(), npc_name.as_str())
|
||||
.and_then(|state| read_bool_field(state, key))
|
||||
}
|
||||
|
||||
pub(super) fn write_current_npc_state_i32_field(game_state: &mut Value, key: &str, value: i32) {
|
||||
let Some(npc_id) = current_encounter_id(game_state) else {
|
||||
return;
|
||||
};
|
||||
let npc_name = current_encounter_name(game_state);
|
||||
let state = ensure_npc_state_object(game_state, npc_id.as_str(), npc_name.as_str());
|
||||
state.insert(key.to_string(), json!(value));
|
||||
}
|
||||
|
||||
pub(super) fn write_current_npc_state_bool_field(game_state: &mut Value, key: &str, value: bool) {
|
||||
let Some(npc_id) = current_encounter_id(game_state) else {
|
||||
return;
|
||||
};
|
||||
let npc_name = current_encounter_name(game_state);
|
||||
let state = ensure_npc_state_object(game_state, npc_id.as_str(), npc_name.as_str());
|
||||
state.insert(key.to_string(), Value::Bool(value));
|
||||
}
|
||||
|
||||
pub(super) fn set_current_npc_recruited(
|
||||
game_state: &mut Value,
|
||||
recruited: bool,
|
||||
) -> Option<(i32, i32)> {
|
||||
let npc_id = current_encounter_id(game_state)?;
|
||||
let npc_name = current_encounter_name(game_state);
|
||||
let state = ensure_npc_state_object(game_state, npc_id.as_str(), npc_name.as_str());
|
||||
let previous_affinity = state
|
||||
.get("affinity")
|
||||
.and_then(Value::as_i64)
|
||||
.and_then(|value| i32::try_from(value).ok())
|
||||
.unwrap_or(0);
|
||||
let next_affinity = previous_affinity.max(60);
|
||||
state.insert("affinity".to_string(), json!(next_affinity));
|
||||
state.insert("recruited".to_string(), Value::Bool(recruited));
|
||||
|
||||
Some((previous_affinity, next_affinity))
|
||||
}
|
||||
|
||||
pub(super) fn read_current_npc_affinity(game_state: &Value) -> i32 {
|
||||
let Some(npc_id) = current_encounter_id(game_state) else {
|
||||
return 0;
|
||||
};
|
||||
let npc_name = current_encounter_name(game_state);
|
||||
resolve_current_encounter_npc_state(game_state, npc_id.as_str(), npc_name.as_str())
|
||||
.and_then(|state| read_i32_field(state, "affinity"))
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
pub(super) fn ensure_npc_state_object<'a>(
|
||||
game_state: &'a mut Value,
|
||||
npc_id: &str,
|
||||
npc_name: &str,
|
||||
) -> &'a mut Map<String, Value> {
|
||||
let root = ensure_json_object(game_state);
|
||||
let npc_states = root
|
||||
.entry("npcStates".to_string())
|
||||
.or_insert_with(|| Value::Object(Map::new()));
|
||||
if !npc_states.is_object() {
|
||||
*npc_states = Value::Object(Map::new());
|
||||
}
|
||||
let states = npc_states
|
||||
.as_object_mut()
|
||||
.expect("npcStates should be object");
|
||||
let existing_key = if states.contains_key(npc_id) {
|
||||
npc_id.to_string()
|
||||
} else if states.contains_key(npc_name) {
|
||||
npc_name.to_string()
|
||||
} else {
|
||||
npc_id.to_string()
|
||||
};
|
||||
let state = states
|
||||
.entry(existing_key)
|
||||
.or_insert_with(|| Value::Object(Map::new()));
|
||||
if !state.is_object() {
|
||||
*state = Value::Object(Map::new());
|
||||
}
|
||||
state.as_object_mut().expect("npc state should be object")
|
||||
}
|
||||
|
||||
pub(super) fn mark_current_npc_first_meaningful_contact_resolved(game_state: &mut Value) {
|
||||
write_current_npc_state_bool_field(game_state, "firstMeaningfulContactResolved", true);
|
||||
}
|
||||
|
||||
pub(super) fn ensure_current_npc_inventory_array<'a>(
|
||||
game_state: &'a mut Value,
|
||||
) -> Option<&'a mut Vec<Value>> {
|
||||
let npc_id = current_encounter_id(game_state)?;
|
||||
let npc_name = current_encounter_name(game_state);
|
||||
let state = ensure_npc_state_object(game_state, npc_id.as_str(), npc_name.as_str());
|
||||
let inventory = state
|
||||
.entry("inventory".to_string())
|
||||
.or_insert_with(|| Value::Array(Vec::new()));
|
||||
if !inventory.is_array() {
|
||||
*inventory = Value::Array(Vec::new());
|
||||
}
|
||||
inventory.as_array_mut()
|
||||
}
|
||||
|
||||
pub(super) fn add_current_npc_inventory_items(game_state: &mut Value, additions: Vec<Value>) {
|
||||
if additions.is_empty() {
|
||||
return;
|
||||
}
|
||||
let Some(items) = ensure_current_npc_inventory_array(game_state) else {
|
||||
return;
|
||||
};
|
||||
for addition in additions {
|
||||
let Some(add_id) = read_optional_string_field(&addition, "id") else {
|
||||
continue;
|
||||
};
|
||||
let add_quantity = read_i32_field(&addition, "quantity").unwrap_or(1).max(1);
|
||||
if let Some(existing) = items
|
||||
.iter_mut()
|
||||
.find(|item| read_optional_string_field(item, "id").as_deref() == Some(add_id.as_str()))
|
||||
{
|
||||
let next_quantity =
|
||||
read_i32_field(existing, "quantity").unwrap_or(0).max(0) + add_quantity;
|
||||
if let Some(existing_object) = existing.as_object_mut() {
|
||||
existing_object.insert("quantity".to_string(), json!(next_quantity));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
items.push(addition);
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn remove_current_npc_inventory_item(
|
||||
game_state: &mut Value,
|
||||
item_id: &str,
|
||||
quantity: i32,
|
||||
) {
|
||||
if quantity <= 0 {
|
||||
return;
|
||||
}
|
||||
let Some(items) = ensure_current_npc_inventory_array(game_state) else {
|
||||
return;
|
||||
};
|
||||
let Some(index) = items
|
||||
.iter()
|
||||
.position(|entry| read_optional_string_field(entry, "id").as_deref() == Some(item_id))
|
||||
else {
|
||||
return;
|
||||
};
|
||||
let current_quantity = read_i32_field(&items[index], "quantity")
|
||||
.unwrap_or(0)
|
||||
.max(0);
|
||||
let next_quantity = current_quantity - quantity;
|
||||
if next_quantity <= 0 {
|
||||
items.remove(index);
|
||||
return;
|
||||
}
|
||||
if let Some(entry) = items[index].as_object_mut() {
|
||||
entry.insert("quantity".to_string(), json!(next_quantity));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,398 @@
|
||||
use super::*;
|
||||
|
||||
pub(super) fn resolve_npc_preview_action(
|
||||
game_state: &mut Value,
|
||||
request: &RuntimeStoryActionRequest,
|
||||
) -> Result<StoryResolution, String> {
|
||||
let npc_name = current_encounter_name(game_state);
|
||||
write_bool_field(game_state, "npcInteractionActive", true);
|
||||
|
||||
Ok(StoryResolution {
|
||||
action_text: resolve_action_text("转向眼前角色", request),
|
||||
result_text: format!("{npc_name} 注意到了你的靠近,正在等你先把话说出来。"),
|
||||
story_text: None,
|
||||
presentation_options: None,
|
||||
saved_current_story: None,
|
||||
patches: vec![build_status_patch(game_state)],
|
||||
battle: None,
|
||||
toast: None,
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) fn resolve_npc_affinity_action(
|
||||
game_state: &mut Value,
|
||||
request: &RuntimeStoryActionRequest,
|
||||
default_action_text: &str,
|
||||
affinity_delta: i32,
|
||||
fallback_result_text: &str,
|
||||
) -> Result<StoryResolution, String> {
|
||||
write_bool_field(game_state, "npcInteractionActive", true);
|
||||
let affinity_patch = adjust_current_npc_affinity(game_state, affinity_delta).map(
|
||||
|(npc_id, previous_affinity, next_affinity)| RuntimeStoryPatch::NpcAffinityChanged {
|
||||
npc_id,
|
||||
previous_affinity,
|
||||
next_affinity,
|
||||
},
|
||||
);
|
||||
let mut patches = Vec::new();
|
||||
if let Some(patch) = affinity_patch {
|
||||
patches.push(patch);
|
||||
}
|
||||
patches.push(build_status_patch(game_state));
|
||||
|
||||
Ok(StoryResolution {
|
||||
action_text: resolve_action_text(default_action_text, request),
|
||||
result_text: fallback_result_text.to_string(),
|
||||
story_text: None,
|
||||
presentation_options: None,
|
||||
saved_current_story: None,
|
||||
patches,
|
||||
battle: None,
|
||||
toast: None,
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) fn resolve_npc_chat_action(
|
||||
game_state: &mut Value,
|
||||
request: &RuntimeStoryActionRequest,
|
||||
) -> Result<StoryResolution, String> {
|
||||
let chatted_count = read_current_npc_state_i32_field(game_state, "chattedCount").unwrap_or(0);
|
||||
let affinity_gain = (6 - chatted_count).max(2);
|
||||
let result_text = format!(
|
||||
"{} 愿意把话接下去,态度比刚才明显松动了一些。当前关系推进了 {} 点。",
|
||||
current_encounter_name(game_state),
|
||||
affinity_gain
|
||||
);
|
||||
let mut resolution = resolve_npc_affinity_action(
|
||||
game_state,
|
||||
request,
|
||||
"继续交谈",
|
||||
affinity_gain,
|
||||
result_text.as_str(),
|
||||
)?;
|
||||
write_current_npc_state_i32_field(game_state, "chattedCount", chatted_count.saturating_add(1));
|
||||
write_current_npc_state_bool_field(game_state, "firstMeaningfulContactResolved", true);
|
||||
resolution.action_text = format!("继续和{}交谈", current_encounter_name(game_state));
|
||||
Ok(resolution)
|
||||
}
|
||||
|
||||
pub(super) fn resolve_npc_help_action(
|
||||
game_state: &mut Value,
|
||||
request: &RuntimeStoryActionRequest,
|
||||
) -> Result<StoryResolution, String> {
|
||||
if read_current_npc_state_bool_field(game_state, "helpUsed").unwrap_or(false) {
|
||||
return Err("当前 NPC 的一次性援手已经用完了".to_string());
|
||||
}
|
||||
|
||||
restore_player_resource(game_state, 10, 8);
|
||||
write_current_npc_state_bool_field(game_state, "helpUsed", true);
|
||||
resolve_npc_affinity_action(
|
||||
game_state,
|
||||
request,
|
||||
&format!("向{}请求援手", current_encounter_name(game_state)),
|
||||
4,
|
||||
&format!(
|
||||
"{} 给了你一次及时支援,你的状态暂时稳住了,关系也顺势拉近了一点。",
|
||||
current_encounter_name(game_state)
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
pub(super) fn resolve_npc_battle_entry_action(
|
||||
game_state: &mut Value,
|
||||
request: &RuntimeStoryActionRequest,
|
||||
function_id: &str,
|
||||
) -> Result<StoryResolution, String> {
|
||||
let npc_id = current_encounter_id(game_state).unwrap_or_else(|| "npc_current".to_string());
|
||||
let npc_name = current_encounter_name(game_state);
|
||||
let battle_mode = if function_id == "npc_spar" {
|
||||
"spar"
|
||||
} else {
|
||||
"fight"
|
||||
};
|
||||
write_bool_field(game_state, "inBattle", true);
|
||||
write_bool_field(game_state, "npcInteractionActive", false);
|
||||
write_string_field(game_state, "currentBattleNpcId", npc_id.as_str());
|
||||
write_string_field(game_state, "currentNpcBattleMode", battle_mode);
|
||||
write_null_field(game_state, "currentNpcBattleOutcome");
|
||||
|
||||
Ok(StoryResolution {
|
||||
action_text: resolve_action_text(
|
||||
if battle_mode == "spar" {
|
||||
"点到为止切磋"
|
||||
} else {
|
||||
"与对方战斗"
|
||||
},
|
||||
request,
|
||||
),
|
||||
result_text: format!(
|
||||
"{npc_name} 已经进入{}节奏,下一步必须按战斗动作结算。",
|
||||
battle_mode_text(battle_mode)
|
||||
),
|
||||
story_text: None,
|
||||
presentation_options: None,
|
||||
saved_current_story: None,
|
||||
patches: vec![build_status_patch(game_state)],
|
||||
battle: Some(RuntimeBattlePresentation {
|
||||
target_id: Some(npc_id),
|
||||
target_name: Some(npc_name),
|
||||
damage_dealt: None,
|
||||
damage_taken: None,
|
||||
outcome: Some("ongoing".to_string()),
|
||||
}),
|
||||
toast: None,
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) fn resolve_npc_recruit_action(
|
||||
game_state: &mut Value,
|
||||
request: &RuntimeStoryActionRequest,
|
||||
) -> Result<StoryResolution, String> {
|
||||
let npc_id = current_encounter_id(game_state).unwrap_or_else(|| "npc_current".to_string());
|
||||
let npc_name = current_encounter_name(game_state);
|
||||
let current_affinity = read_current_npc_affinity(game_state);
|
||||
if read_current_npc_state_bool_field(game_state, "recruited").unwrap_or(false) {
|
||||
return Err("当前 NPC 已经处于已招募状态".to_string());
|
||||
}
|
||||
if current_affinity < 60 {
|
||||
return Err("当前关系还没达到招募阈值,暂时不能邀请入队".to_string());
|
||||
}
|
||||
|
||||
let release_npc_id = request
|
||||
.action
|
||||
.payload
|
||||
.as_ref()
|
||||
.and_then(|payload| read_optional_string_field(payload, "releaseNpcId"));
|
||||
let released_companion_name = recruit_companion_to_party(
|
||||
game_state,
|
||||
npc_id.as_str(),
|
||||
current_affinity,
|
||||
release_npc_id.as_deref(),
|
||||
)?;
|
||||
let affinity_patch =
|
||||
set_current_npc_recruited(game_state, true).map(|(previous_affinity, next_affinity)| {
|
||||
RuntimeStoryPatch::NpcAffinityChanged {
|
||||
npc_id: npc_id.clone(),
|
||||
previous_affinity,
|
||||
next_affinity,
|
||||
}
|
||||
});
|
||||
write_current_npc_state_bool_field(game_state, "firstMeaningfulContactResolved", true);
|
||||
write_bool_field(game_state, "npcInteractionActive", false);
|
||||
clear_encounter_only(game_state);
|
||||
write_null_field(game_state, "currentNpcBattleMode");
|
||||
write_null_field(game_state, "currentNpcBattleOutcome");
|
||||
write_bool_field(game_state, "inBattle", false);
|
||||
|
||||
let mut patches = Vec::new();
|
||||
if let Some(patch) = affinity_patch {
|
||||
patches.push(patch);
|
||||
}
|
||||
patches.push(build_status_patch(game_state));
|
||||
patches.push(RuntimeStoryPatch::EncounterChanged { encounter_id: None });
|
||||
|
||||
Ok(StoryResolution {
|
||||
action_text: resolve_action_text(&format!("邀请{npc_name}加入队伍"), request),
|
||||
result_text: match released_companion_name {
|
||||
Some(released_name) => format!(
|
||||
"{npc_name} 接受了你的邀请,你先让 {released_name} 暂时离队,把位置腾给了新的同行者。"
|
||||
),
|
||||
None => format!("{npc_name} 接受了你的邀请,正式进入了同行队伍。"),
|
||||
},
|
||||
story_text: None,
|
||||
presentation_options: None,
|
||||
saved_current_story: None,
|
||||
patches,
|
||||
battle: None,
|
||||
toast: Some(format!("{npc_name} 已加入队伍")),
|
||||
})
|
||||
}
|
||||
|
||||
/// 先按 NPC 当前遭遇态结算简化版买卖逻辑,保持与 Node compat 一致的字段写回,
|
||||
/// 后续再由真相态 inventory / runtime-item reducer 接管。
|
||||
pub(super) fn resolve_npc_trade_action(
|
||||
game_state: &mut Value,
|
||||
request: &RuntimeStoryActionRequest,
|
||||
) -> Result<StoryResolution, String> {
|
||||
let (_npc_id, npc_name) = current_npc_trade_context(game_state)?;
|
||||
let payload = request.action.payload.as_ref();
|
||||
let mode = payload
|
||||
.and_then(|value| read_optional_string_field(value, "mode"))
|
||||
.ok_or_else(|| "npc_trade 缺少合法 mode,需为 buy 或 sell".to_string())?;
|
||||
if mode != "buy" && mode != "sell" {
|
||||
return Err("npc_trade 缺少合法 mode,需为 buy 或 sell".to_string());
|
||||
}
|
||||
let item_id = payload
|
||||
.and_then(|value| {
|
||||
read_optional_string_field(value, "itemId")
|
||||
.or_else(|| read_optional_string_field(value, "selectedNpcItemId"))
|
||||
.or_else(|| read_optional_string_field(value, "selectedPlayerItemId"))
|
||||
})
|
||||
.or_else(|| request.action.target_id.clone())
|
||||
.ok_or_else(|| "npc_trade 缺少 itemId".to_string())?;
|
||||
let quantity = payload
|
||||
.and_then(|value| read_i32_field(value, "quantity"))
|
||||
.unwrap_or(1)
|
||||
.max(1);
|
||||
|
||||
if mode == "buy" {
|
||||
let npc_item = read_current_npc_inventory_item(game_state, item_id.as_str())
|
||||
.cloned()
|
||||
.ok_or_else(|| "目标商品不存在或库存不足。".to_string())?;
|
||||
let available_quantity = read_i32_field(&npc_item, "quantity").unwrap_or(0).max(0);
|
||||
if available_quantity < quantity {
|
||||
return Err("目标商品不存在或库存不足。".to_string());
|
||||
}
|
||||
let total_price = npc_purchase_price(&npc_item, read_current_npc_affinity(game_state))
|
||||
.saturating_mul(quantity);
|
||||
let player_currency = read_i32_field(game_state, "playerCurrency").unwrap_or(0);
|
||||
if player_currency < total_price {
|
||||
return Err("当前钱币不足,无法完成购买。".to_string());
|
||||
}
|
||||
|
||||
write_i32_field(game_state, "playerCurrency", player_currency - total_price);
|
||||
add_player_inventory_items(
|
||||
game_state,
|
||||
vec![clone_inventory_item_with_quantity(&npc_item, quantity)],
|
||||
);
|
||||
remove_current_npc_inventory_item(game_state, item_id.as_str(), quantity);
|
||||
mark_current_npc_first_meaningful_contact_resolved(game_state);
|
||||
|
||||
let item_name = read_inventory_item_name(&npc_item);
|
||||
return Ok(StoryResolution {
|
||||
action_text: resolve_action_text(
|
||||
&format!(
|
||||
"从{}手里买下{}{}",
|
||||
npc_name,
|
||||
item_name,
|
||||
trade_quantity_suffix(quantity)
|
||||
),
|
||||
request,
|
||||
),
|
||||
result_text: format!(
|
||||
"{}收下了{},把{}{}卖给了你。",
|
||||
npc_name,
|
||||
format_currency_text(
|
||||
total_price,
|
||||
read_optional_string_field(game_state, "worldType").as_deref()
|
||||
),
|
||||
item_name,
|
||||
trade_quantity_suffix(quantity)
|
||||
),
|
||||
story_text: None,
|
||||
presentation_options: None,
|
||||
saved_current_story: None,
|
||||
patches: Vec::new(),
|
||||
battle: None,
|
||||
toast: None,
|
||||
});
|
||||
}
|
||||
|
||||
let player_item = find_player_inventory_entry(game_state, item_id.as_str())
|
||||
.cloned()
|
||||
.ok_or_else(|| "背包里没有足够数量的目标物品。".to_string())?;
|
||||
let available_quantity = read_i32_field(&player_item, "quantity").unwrap_or(0).max(0);
|
||||
if available_quantity < quantity {
|
||||
return Err("背包里没有足够数量的目标物品。".to_string());
|
||||
}
|
||||
let total_price = npc_buyback_price(&player_item, read_current_npc_affinity(game_state))
|
||||
.saturating_mul(quantity);
|
||||
let player_currency = read_i32_field(game_state, "playerCurrency").unwrap_or(0);
|
||||
write_i32_field(
|
||||
game_state,
|
||||
"playerCurrency",
|
||||
player_currency.saturating_add(total_price),
|
||||
);
|
||||
remove_player_inventory_item(game_state, item_id.as_str(), quantity);
|
||||
add_current_npc_inventory_items(
|
||||
game_state,
|
||||
vec![clone_inventory_item_with_quantity(&player_item, quantity)],
|
||||
);
|
||||
mark_current_npc_first_meaningful_contact_resolved(game_state);
|
||||
|
||||
let item_name = read_inventory_item_name(&player_item);
|
||||
Ok(StoryResolution {
|
||||
action_text: resolve_action_text(
|
||||
&format!(
|
||||
"把{}{}卖给{}",
|
||||
item_name,
|
||||
trade_quantity_suffix(quantity),
|
||||
npc_name
|
||||
),
|
||||
request,
|
||||
),
|
||||
result_text: format!(
|
||||
"{}收下了{}{},付给你{}。",
|
||||
npc_name,
|
||||
item_name,
|
||||
trade_quantity_suffix(quantity),
|
||||
format_currency_text(
|
||||
total_price,
|
||||
read_optional_string_field(game_state, "worldType").as_deref()
|
||||
)
|
||||
),
|
||||
story_text: None,
|
||||
presentation_options: None,
|
||||
saved_current_story: None,
|
||||
patches: Vec::new(),
|
||||
battle: None,
|
||||
toast: None,
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) fn resolve_npc_gift_action(
|
||||
game_state: &mut Value,
|
||||
request: &RuntimeStoryActionRequest,
|
||||
) -> Result<StoryResolution, String> {
|
||||
let (npc_id, npc_name) = current_npc_trade_context(game_state)?;
|
||||
let item_id = request
|
||||
.action
|
||||
.payload
|
||||
.as_ref()
|
||||
.and_then(|payload| read_optional_string_field(payload, "itemId"))
|
||||
.or_else(|| request.action.target_id.clone())
|
||||
.ok_or_else(|| "npc_gift 缺少 itemId".to_string())?;
|
||||
let gift_item = find_player_inventory_entry(game_state, item_id.as_str())
|
||||
.cloned()
|
||||
.ok_or_else(|| "背包里没有这件可赠送的物品。".to_string())?;
|
||||
if read_i32_field(&gift_item, "quantity").unwrap_or(0) <= 0 {
|
||||
return Err("背包里没有这件可赠送的物品。".to_string());
|
||||
}
|
||||
|
||||
let previous_affinity = read_current_npc_affinity(game_state);
|
||||
let affinity_gain = resolve_npc_gift_affinity_gain(&gift_item);
|
||||
let next_affinity = (previous_affinity + affinity_gain).clamp(-100, 100);
|
||||
remove_player_inventory_item(game_state, item_id.as_str(), 1);
|
||||
add_current_npc_inventory_items(
|
||||
game_state,
|
||||
vec![clone_inventory_item_with_quantity(&gift_item, 1)],
|
||||
);
|
||||
write_current_npc_state_i32_field(game_state, "affinity", next_affinity);
|
||||
let next_gifts_given =
|
||||
read_current_npc_state_i32_field(game_state, "giftsGiven").unwrap_or(0) + 1;
|
||||
write_current_npc_state_i32_field(game_state, "giftsGiven", next_gifts_given);
|
||||
mark_current_npc_first_meaningful_contact_resolved(game_state);
|
||||
|
||||
Ok(StoryResolution {
|
||||
action_text: resolve_action_text(
|
||||
&format!("把{}赠给{}", read_inventory_item_name(&gift_item), npc_name),
|
||||
request,
|
||||
),
|
||||
result_text: build_npc_gift_result_text(
|
||||
npc_name.as_str(),
|
||||
&gift_item,
|
||||
affinity_gain,
|
||||
next_affinity,
|
||||
),
|
||||
story_text: None,
|
||||
presentation_options: None,
|
||||
saved_current_story: None,
|
||||
patches: vec![RuntimeStoryPatch::NpcAffinityChanged {
|
||||
npc_id,
|
||||
previous_affinity,
|
||||
next_affinity,
|
||||
}],
|
||||
battle: None,
|
||||
toast: None,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,734 @@
|
||||
use super::*;
|
||||
|
||||
pub(super) fn build_runtime_story_state_response(
|
||||
requested_session_id: &str,
|
||||
client_version: Option<u32>,
|
||||
mut snapshot: RuntimeStorySnapshotPayload,
|
||||
) -> RuntimeStoryActionResponse {
|
||||
ensure_runtime_story_bridge_state(&mut snapshot.game_state);
|
||||
let session_id = read_runtime_session_id(&snapshot.game_state)
|
||||
.unwrap_or_else(|| requested_session_id.to_string());
|
||||
let options =
|
||||
build_runtime_story_options(snapshot.current_story.as_ref(), &snapshot.game_state);
|
||||
let story_text = read_story_text(snapshot.current_story.as_ref())
|
||||
.unwrap_or_else(|| build_fallback_story_text(&snapshot.game_state));
|
||||
let server_version = read_u32_field(&snapshot.game_state, "runtimeActionVersion")
|
||||
.or(client_version)
|
||||
.unwrap_or(0);
|
||||
|
||||
build_runtime_story_action_response(RuntimeStoryActionResponseParts {
|
||||
requested_session_id: session_id,
|
||||
server_version,
|
||||
snapshot,
|
||||
action_text: String::new(),
|
||||
result_text: String::new(),
|
||||
story_text,
|
||||
options,
|
||||
patches: Vec::new(),
|
||||
toast: None,
|
||||
battle: None,
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) fn build_runtime_story_action_response(
|
||||
parts: RuntimeStoryActionResponseParts,
|
||||
) -> RuntimeStoryActionResponse {
|
||||
let session_id = read_runtime_session_id(&parts.snapshot.game_state)
|
||||
.unwrap_or_else(|| parts.requested_session_id);
|
||||
|
||||
RuntimeStoryActionResponse {
|
||||
session_id,
|
||||
server_version: parts.server_version,
|
||||
view_model: build_runtime_story_view_model(&parts.snapshot.game_state, &parts.options),
|
||||
presentation: RuntimeStoryPresentation {
|
||||
action_text: parts.action_text,
|
||||
result_text: parts.result_text,
|
||||
story_text: parts.story_text,
|
||||
options: parts.options,
|
||||
toast: parts.toast,
|
||||
battle: parts.battle,
|
||||
},
|
||||
patches: parts.patches,
|
||||
snapshot: parts.snapshot,
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn build_dialogue_current_story(
|
||||
npc_name: &str,
|
||||
text: &str,
|
||||
deferred_options: &[RuntimeStoryOptionView],
|
||||
) -> Value {
|
||||
let continue_option = build_continue_adventure_runtime_story_option();
|
||||
// 对齐 Node 旧 currentStory:先展示单轮对话,只把真实下一步选项压到 deferredOptions。
|
||||
json!({
|
||||
"text": text,
|
||||
"options": vec![build_story_option_from_runtime_option(&continue_option)],
|
||||
"displayMode": "dialogue",
|
||||
"dialogue": parse_dialogue_turns(text, npc_name),
|
||||
"streaming": false,
|
||||
"deferredOptions": deferred_options
|
||||
.iter()
|
||||
.map(build_story_option_from_runtime_option)
|
||||
.collect::<Vec<_>>(),
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) fn build_continue_adventure_runtime_story_option() -> RuntimeStoryOptionView {
|
||||
build_static_runtime_story_option(CONTINUE_ADVENTURE_FUNCTION_ID, "继续推进冒险", "story")
|
||||
}
|
||||
|
||||
pub(super) fn parse_dialogue_turns(text: &str, npc_name: &str) -> Vec<Value> {
|
||||
let mut turns = Vec::new();
|
||||
for raw_line in text.lines() {
|
||||
let line = raw_line.trim();
|
||||
if line.is_empty() {
|
||||
continue;
|
||||
}
|
||||
if let Some(turn) = parse_dialogue_line(line, npc_name) {
|
||||
turns.push(turn);
|
||||
}
|
||||
}
|
||||
|
||||
if turns.is_empty() && !text.trim().is_empty() {
|
||||
turns.push(json!({
|
||||
"speaker": "npc",
|
||||
"speakerName": npc_name,
|
||||
"text": text.trim(),
|
||||
}));
|
||||
}
|
||||
|
||||
turns
|
||||
}
|
||||
|
||||
pub(super) fn parse_dialogue_line(line: &str, npc_name: &str) -> Option<Value> {
|
||||
let delimiter_index = line.find(':').or_else(|| line.find(':'))?;
|
||||
let speaker_name = line[..delimiter_index].trim();
|
||||
let content_start = delimiter_index + line[delimiter_index..].chars().next()?.len_utf8();
|
||||
let content = line[content_start..].trim();
|
||||
if content.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
if speaker_name == "你" {
|
||||
return Some(json!({
|
||||
"speaker": "player",
|
||||
"text": content,
|
||||
}));
|
||||
}
|
||||
|
||||
if speaker_name == npc_name {
|
||||
return Some(json!({
|
||||
"speaker": "npc",
|
||||
"speakerName": npc_name,
|
||||
"text": content,
|
||||
}));
|
||||
}
|
||||
|
||||
Some(json!({
|
||||
"speaker": "companion",
|
||||
"speakerName": speaker_name,
|
||||
"text": content,
|
||||
}))
|
||||
}
|
||||
|
||||
pub(super) fn build_runtime_story_options(
|
||||
current_story: Option<&Value>,
|
||||
game_state: &Value,
|
||||
) -> Vec<RuntimeStoryOptionView> {
|
||||
if let Some(story) = current_story {
|
||||
let prefers_deferred = read_required_string_field(story, "displayMode")
|
||||
.is_some_and(|value| value == "dialogue")
|
||||
&& !read_array_field(story, "deferredOptions").is_empty();
|
||||
|
||||
let source = if prefers_deferred {
|
||||
read_array_field(story, "deferredOptions")
|
||||
} else {
|
||||
read_array_field(story, "options")
|
||||
};
|
||||
|
||||
let compiled = source
|
||||
.into_iter()
|
||||
.filter_map(build_runtime_story_option_from_story_option)
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
if !compiled.is_empty() {
|
||||
return compiled;
|
||||
}
|
||||
}
|
||||
|
||||
build_fallback_runtime_story_options(game_state)
|
||||
}
|
||||
|
||||
pub(super) fn build_fallback_runtime_story_options(
|
||||
game_state: &Value,
|
||||
) -> Vec<RuntimeStoryOptionView> {
|
||||
if read_bool_field(game_state, "inBattle").unwrap_or(false) {
|
||||
return build_battle_runtime_story_options(game_state);
|
||||
}
|
||||
|
||||
let encounter = read_object_field(game_state, "currentEncounter");
|
||||
if let Some(encounter) = encounter {
|
||||
if matches!(
|
||||
read_required_string_field(encounter, "kind").as_deref(),
|
||||
Some("npc")
|
||||
) {
|
||||
let interaction_active =
|
||||
read_bool_field(game_state, "npcInteractionActive").unwrap_or(false);
|
||||
let npc_id = read_required_string_field(encounter, "id")
|
||||
.unwrap_or_else(|| "npc_current".to_string());
|
||||
if let Some(active_quest) = find_active_quest_for_issuer(game_state, npc_id.as_str()) {
|
||||
if read_optional_string_field(active_quest, "status")
|
||||
.is_some_and(|status| status == "completed")
|
||||
{
|
||||
return vec![
|
||||
build_npc_runtime_story_option_with_quest(
|
||||
"npc_quest_turn_in",
|
||||
&format!("向{}交付委托", current_encounter_name(game_state)),
|
||||
&npc_id,
|
||||
"quest_turn_in",
|
||||
read_optional_string_field(active_quest, "id"),
|
||||
),
|
||||
build_npc_runtime_story_option(
|
||||
"npc_leave",
|
||||
"离开当前角色",
|
||||
&npc_id,
|
||||
"leave",
|
||||
),
|
||||
];
|
||||
}
|
||||
}
|
||||
if interaction_active {
|
||||
return build_active_npc_runtime_story_options(game_state, npc_id.as_str());
|
||||
}
|
||||
|
||||
return vec![
|
||||
build_npc_runtime_story_option("npc_preview_talk", "转向眼前角色", &npc_id, "chat"),
|
||||
build_npc_runtime_story_option("npc_fight", "与对方战斗", &npc_id, "fight"),
|
||||
build_npc_runtime_story_option("npc_leave", "离开当前角色", &npc_id, "leave"),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
vec![
|
||||
build_static_runtime_story_option("idle_observe_signs", "观察周围迹象", "story"),
|
||||
build_static_runtime_story_option("idle_call_out", "主动出声试探", "story"),
|
||||
build_static_runtime_story_option("idle_rest_focus", "原地调息", "story"),
|
||||
build_static_runtime_story_option("idle_explore_forward", "继续向前探索", "story"),
|
||||
build_static_runtime_story_option("idle_travel_next_scene", "前往相邻场景", "story"),
|
||||
build_static_runtime_story_option(CONTINUE_ADVENTURE_FUNCTION_ID, "继续推进冒险", "story"),
|
||||
]
|
||||
}
|
||||
|
||||
pub(super) fn build_npc_runtime_story_option(
|
||||
function_id: &str,
|
||||
action_text: &str,
|
||||
npc_id: &str,
|
||||
action: &str,
|
||||
) -> RuntimeStoryOptionView {
|
||||
RuntimeStoryOptionView {
|
||||
interaction: Some(RuntimeStoryOptionInteraction::Npc {
|
||||
npc_id: npc_id.to_string(),
|
||||
action: action.to_string(),
|
||||
quest_id: None,
|
||||
}),
|
||||
..build_static_runtime_story_option(function_id, action_text, "npc")
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn build_npc_runtime_story_option_with_payload(
|
||||
function_id: &str,
|
||||
action_text: &str,
|
||||
npc_id: &str,
|
||||
action: &str,
|
||||
payload: Value,
|
||||
) -> RuntimeStoryOptionView {
|
||||
RuntimeStoryOptionView {
|
||||
payload: Some(payload),
|
||||
..build_npc_runtime_story_option(function_id, action_text, npc_id, action)
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn build_npc_runtime_story_option_with_quest(
|
||||
function_id: &str,
|
||||
action_text: &str,
|
||||
npc_id: &str,
|
||||
action: &str,
|
||||
quest_id: Option<String>,
|
||||
) -> RuntimeStoryOptionView {
|
||||
RuntimeStoryOptionView {
|
||||
interaction: Some(RuntimeStoryOptionInteraction::Npc {
|
||||
npc_id: npc_id.to_string(),
|
||||
action: action.to_string(),
|
||||
quest_id,
|
||||
}),
|
||||
..build_static_runtime_story_option(function_id, action_text, "npc")
|
||||
}
|
||||
}
|
||||
|
||||
/// 对齐 Node 旧 compat 入口顺序,在 NPC 交互态下统一补齐交易、赠礼、委托与招募入口。
|
||||
pub(super) fn build_active_npc_runtime_story_options(
|
||||
game_state: &Value,
|
||||
npc_id: &str,
|
||||
) -> Vec<RuntimeStoryOptionView> {
|
||||
let mut options = vec![
|
||||
build_npc_runtime_story_option("npc_chat", "继续交谈", npc_id, "chat"),
|
||||
build_npc_help_runtime_story_option(game_state, npc_id),
|
||||
build_npc_runtime_story_option("npc_spar", "点到为止切磋", npc_id, "spar"),
|
||||
build_npc_runtime_story_option("npc_fight", "与对方战斗", npc_id, "fight"),
|
||||
];
|
||||
|
||||
if current_npc_inventory_items(game_state)
|
||||
.iter()
|
||||
.any(|item| read_i32_field(item, "quantity").unwrap_or(0) > 0)
|
||||
{
|
||||
options.push(build_npc_runtime_story_option(
|
||||
"npc_trade",
|
||||
"交易",
|
||||
npc_id,
|
||||
"trade",
|
||||
));
|
||||
}
|
||||
|
||||
if has_giftable_player_inventory(game_state) {
|
||||
options.push(build_npc_runtime_story_option(
|
||||
"npc_gift",
|
||||
"赠送礼物",
|
||||
npc_id,
|
||||
"gift",
|
||||
));
|
||||
}
|
||||
|
||||
let active_quest = find_active_quest_for_issuer(game_state, npc_id);
|
||||
if let Some(active_quest) = active_quest {
|
||||
let can_turn_in = read_optional_string_field(active_quest, "status")
|
||||
.is_some_and(|status| status == "completed" || status == "ready_to_turn_in");
|
||||
if can_turn_in {
|
||||
options.push(build_npc_runtime_story_option_with_quest(
|
||||
"npc_quest_turn_in",
|
||||
&format!("向{}交付委托", current_encounter_name(game_state)),
|
||||
npc_id,
|
||||
"quest_turn_in",
|
||||
read_optional_string_field(active_quest, "id"),
|
||||
));
|
||||
}
|
||||
} else {
|
||||
options.push(build_npc_runtime_story_option(
|
||||
"npc_quest_accept",
|
||||
"接下委托",
|
||||
npc_id,
|
||||
"quest_accept",
|
||||
));
|
||||
}
|
||||
|
||||
if read_current_npc_affinity(game_state) >= 60
|
||||
&& !read_current_npc_state_bool_field(game_state, "recruited").unwrap_or(false)
|
||||
{
|
||||
options.push(build_npc_runtime_story_option(
|
||||
"npc_recruit",
|
||||
"邀请同行",
|
||||
npc_id,
|
||||
"recruit",
|
||||
));
|
||||
}
|
||||
|
||||
options.push(build_npc_runtime_story_option(
|
||||
"npc_leave",
|
||||
"离开当前角色",
|
||||
npc_id,
|
||||
"leave",
|
||||
));
|
||||
options
|
||||
}
|
||||
|
||||
pub(super) fn build_npc_help_runtime_story_option(
|
||||
game_state: &Value,
|
||||
npc_id: &str,
|
||||
) -> RuntimeStoryOptionView {
|
||||
if read_current_npc_state_bool_field(game_state, "helpUsed").unwrap_or(false) {
|
||||
return build_disabled_runtime_story_option(
|
||||
"npc_help",
|
||||
"请求援手",
|
||||
"npc",
|
||||
None,
|
||||
"当前 NPC 的一次性援手已经用完了。",
|
||||
None,
|
||||
);
|
||||
}
|
||||
build_npc_runtime_story_option("npc_help", "请求援手", npc_id, "help")
|
||||
}
|
||||
|
||||
pub(super) fn current_encounter_npc_quest_context(
|
||||
game_state: &Value,
|
||||
) -> Result<CurrentEncounterNpcQuestContext, String> {
|
||||
let encounter = read_object_field(game_state, "currentEncounter")
|
||||
.ok_or_else(|| "当前不在可结算的 NPC 委托态。".to_string())?;
|
||||
let kind = read_required_string_field(encounter, "kind")
|
||||
.ok_or_else(|| "当前不在可结算的 NPC 委托态。".to_string())?;
|
||||
if kind != "npc" {
|
||||
return Err("当前不在可结算的 NPC 委托态。".to_string());
|
||||
}
|
||||
|
||||
let npc_name = read_optional_string_field(encounter, "npcName")
|
||||
.or_else(|| read_optional_string_field(encounter, "name"))
|
||||
.unwrap_or_else(|| "当前角色".to_string());
|
||||
let npc_id = read_optional_string_field(encounter, "id").unwrap_or_else(|| npc_name.clone());
|
||||
|
||||
if resolve_current_encounter_npc_state(game_state, npc_id.as_str(), npc_name.as_str()).is_none()
|
||||
{
|
||||
return Err("当前 NPC 状态不存在,无法处理委托。".to_string());
|
||||
}
|
||||
|
||||
Ok(CurrentEncounterNpcQuestContext { npc_id, npc_name })
|
||||
}
|
||||
|
||||
pub(super) fn read_pending_quest_offer_context(
|
||||
current_story: Option<&Value>,
|
||||
npc_key: &str,
|
||||
) -> Option<PendingQuestOfferContext> {
|
||||
let current_story = current_story?;
|
||||
let npc_chat_state = read_object_field(current_story, "npcChatState")?;
|
||||
let pending_offer = read_object_field(npc_chat_state, "pendingQuestOffer")?;
|
||||
let quest = read_object_field(pending_offer, "quest")?.clone();
|
||||
let quest_id = read_optional_string_field(&quest, "id")?;
|
||||
let pending_npc_id = read_optional_string_field(npc_chat_state, "npcId");
|
||||
let issuer_npc_id = read_optional_string_field(&quest, "issuerNpcId");
|
||||
if pending_npc_id
|
||||
.as_deref()
|
||||
.is_some_and(|value| value != npc_key)
|
||||
{
|
||||
return None;
|
||||
}
|
||||
if issuer_npc_id
|
||||
.as_deref()
|
||||
.is_some_and(|value| value != npc_key)
|
||||
{
|
||||
return None;
|
||||
}
|
||||
|
||||
Some(PendingQuestOfferContext {
|
||||
dialogue: read_array_field(current_story, "dialogue")
|
||||
.into_iter()
|
||||
.cloned()
|
||||
.collect(),
|
||||
turn_count: read_i32_field(npc_chat_state, "turnCount").unwrap_or(0),
|
||||
custom_input_placeholder: read_optional_string_field(
|
||||
npc_chat_state,
|
||||
"customInputPlaceholder",
|
||||
)
|
||||
.unwrap_or_else(|| "输入你想对 TA 说的话".to_string()),
|
||||
quest,
|
||||
quest_id,
|
||||
intro_text: read_optional_string_field(pending_offer, "introText"),
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) fn build_quest_offer_dialogue_text(npc_name: &str, quest: &Value) -> String {
|
||||
let summary_text = read_optional_string_field(quest, "summary")
|
||||
.or_else(|| read_optional_string_field(quest, "description"))
|
||||
.unwrap_or_default();
|
||||
if summary_text.is_empty() {
|
||||
return format!(
|
||||
"{npc_name}沉吟了片刻,像是终于把真正想托付的事说了出来。如果你愿意,我想把眼前这件事正式交给你。"
|
||||
);
|
||||
}
|
||||
format!(
|
||||
"{npc_name}沉吟了片刻,像是终于把真正想托付的事说了出来。如果你愿意,我想把这件事正式交给你:{summary_text}"
|
||||
)
|
||||
}
|
||||
|
||||
pub(super) fn append_dialogue_turns(existing: &[Value], additions: Vec<Value>) -> Vec<Value> {
|
||||
let mut dialogue = existing.to_vec();
|
||||
dialogue.extend(additions);
|
||||
dialogue
|
||||
}
|
||||
|
||||
pub(super) fn build_pending_quest_offer_options(npc_id: &str) -> Vec<RuntimeStoryOptionView> {
|
||||
vec![
|
||||
build_npc_runtime_story_option_with_payload(
|
||||
"npc_chat_quest_offer_view",
|
||||
"查看任务",
|
||||
npc_id,
|
||||
"quest_offer_view",
|
||||
json!({
|
||||
"npcChatQuestOfferAction": "view"
|
||||
}),
|
||||
),
|
||||
build_npc_runtime_story_option_with_payload(
|
||||
"npc_chat_quest_offer_replace",
|
||||
"更换任务",
|
||||
npc_id,
|
||||
"quest_offer_replace",
|
||||
json!({
|
||||
"npcChatQuestOfferAction": "replace"
|
||||
}),
|
||||
),
|
||||
build_npc_runtime_story_option_with_payload(
|
||||
"npc_chat_quest_offer_abandon",
|
||||
"放弃任务",
|
||||
npc_id,
|
||||
"quest_offer_abandon",
|
||||
json!({
|
||||
"npcChatQuestOfferAction": "abandon"
|
||||
}),
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
pub(super) fn build_post_quest_offer_chat_options(npc_id: &str) -> Vec<RuntimeStoryOptionView> {
|
||||
vec![
|
||||
build_npc_runtime_story_option(
|
||||
"npc_chat",
|
||||
"那先继续聊聊你刚才没说完的部分",
|
||||
npc_id,
|
||||
"chat",
|
||||
),
|
||||
build_npc_runtime_story_option(
|
||||
"npc_chat",
|
||||
"除了委托,你对眼前局势还有什么判断",
|
||||
npc_id,
|
||||
"chat",
|
||||
),
|
||||
build_npc_runtime_story_option(
|
||||
"npc_chat",
|
||||
"先把这附近真正危险的地方说清楚",
|
||||
npc_id,
|
||||
"chat",
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
pub(super) fn build_post_quest_accept_chat_options(npc_id: &str) -> Vec<RuntimeStoryOptionView> {
|
||||
vec![
|
||||
build_npc_runtime_story_option("npc_chat", "这件事里你最担心哪一步", npc_id, "chat"),
|
||||
build_npc_runtime_story_option("npc_chat", "我回来时你最想先知道什么", npc_id, "chat"),
|
||||
build_npc_runtime_story_option(
|
||||
"npc_chat",
|
||||
"除了这份委托,你还想提醒我什么",
|
||||
npc_id,
|
||||
"chat",
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
pub(super) fn build_pending_quest_offer_story(
|
||||
dialogue: Vec<Value>,
|
||||
npc_id: &str,
|
||||
npc_name: &str,
|
||||
turn_count: i32,
|
||||
custom_input_placeholder: &str,
|
||||
pending_quest: Option<Value>,
|
||||
options: &[RuntimeStoryOptionView],
|
||||
) -> Value {
|
||||
json!({
|
||||
"text": dialogue
|
||||
.iter()
|
||||
.filter_map(|entry| read_optional_string_field(entry, "text"))
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n"),
|
||||
"options": options.iter().map(build_story_option_from_runtime_option).collect::<Vec<_>>(),
|
||||
"displayMode": "dialogue",
|
||||
"dialogue": dialogue,
|
||||
"streaming": false,
|
||||
"npcChatState": {
|
||||
"npcId": npc_id,
|
||||
"npcName": npc_name,
|
||||
"turnCount": turn_count,
|
||||
"customInputPlaceholder": custom_input_placeholder,
|
||||
"pendingQuestOffer": pending_quest.map(|quest| json!({ "quest": quest })),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) fn build_next_pending_quest_offer(
|
||||
game_state: &Value,
|
||||
npc_id: &str,
|
||||
npc_name: &str,
|
||||
previous_quest_id: Option<&str>,
|
||||
) -> Value {
|
||||
let next_id = if previous_quest_id.is_some_and(|id| id == "quest-bridge-offer") {
|
||||
"quest-bridge-replaced"
|
||||
} else {
|
||||
"quest-generated-replaced"
|
||||
};
|
||||
let title = if next_id == "quest-bridge-replaced" {
|
||||
"断桥夜巡"
|
||||
} else {
|
||||
"新的临时委托"
|
||||
};
|
||||
let scene_id = read_object_field(game_state, "currentScenePreset")
|
||||
.and_then(|scene| read_optional_string_field(scene, "id"));
|
||||
json!({
|
||||
"id": next_id,
|
||||
"issuerNpcId": npc_id,
|
||||
"issuerNpcName": npc_name,
|
||||
"sceneId": scene_id,
|
||||
"title": title,
|
||||
"description": format!("{title}的详细说明。"),
|
||||
"summary": format!("{title}的简要目标。"),
|
||||
"objective": {
|
||||
"kind": "talk_to_npc",
|
||||
"requiredCount": 1
|
||||
},
|
||||
"progress": 0,
|
||||
"status": "active",
|
||||
"reward": {
|
||||
"affinityBonus": 6,
|
||||
"currency": 30,
|
||||
"items": []
|
||||
},
|
||||
"rewardText": "完成后可以领取报酬。",
|
||||
"steps": [{
|
||||
"id": format!("{next_id}-step-1"),
|
||||
"title": "查清线索",
|
||||
"kind": "talk_to_npc",
|
||||
"requiredCount": 1,
|
||||
"progress": 0,
|
||||
"revealText": "先去断桥口附近把相关线索问清楚。",
|
||||
"completeText": "关键线索已经问清。"
|
||||
}],
|
||||
"activeStepId": format!("{next_id}-step-1")
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) fn find_active_quest_for_issuer<'a>(
|
||||
game_state: &'a Value,
|
||||
issuer_npc_id: &str,
|
||||
) -> Option<&'a Value> {
|
||||
read_array_field(game_state, "quests")
|
||||
.into_iter()
|
||||
.find(|quest| {
|
||||
read_optional_string_field(quest, "issuerNpcId").as_deref() == Some(issuer_npc_id)
|
||||
&& read_optional_string_field(quest, "status")
|
||||
.is_some_and(|status| status != "turned_in")
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) fn push_quest_record(game_state: &mut Value, quest: &Value) {
|
||||
let root = ensure_json_object(game_state);
|
||||
let quests = root
|
||||
.entry("quests".to_string())
|
||||
.or_insert_with(|| Value::Array(Vec::new()));
|
||||
if !quests.is_array() {
|
||||
*quests = Value::Array(Vec::new());
|
||||
}
|
||||
quests
|
||||
.as_array_mut()
|
||||
.expect("quests should be array")
|
||||
.push(quest.clone());
|
||||
}
|
||||
|
||||
pub(super) fn first_quest_reveal_text(quest: &Value) -> Option<String> {
|
||||
read_array_field(quest, "steps")
|
||||
.first()
|
||||
.and_then(|step| read_optional_string_field(step, "revealText"))
|
||||
}
|
||||
|
||||
pub(super) fn build_quest_accept_result_text(quest: &Value) -> String {
|
||||
let issuer_name =
|
||||
read_optional_string_field(quest, "issuerNpcName").unwrap_or_else(|| "对方".to_string());
|
||||
let title = read_optional_string_field(quest, "title").unwrap_or_else(|| "委托".to_string());
|
||||
format!("你正式接下了 {issuer_name} 的委托「{title}」,接下来可以开始推进任务目标。")
|
||||
}
|
||||
|
||||
pub(super) fn turn_in_quest_record(
|
||||
game_state: &mut Value,
|
||||
issuer_npc_id: &str,
|
||||
quest_id: &str,
|
||||
) -> Result<Value, String> {
|
||||
let root = ensure_json_object(game_state);
|
||||
let quests = root
|
||||
.entry("quests".to_string())
|
||||
.or_insert_with(|| Value::Array(Vec::new()));
|
||||
if !quests.is_array() {
|
||||
*quests = Value::Array(Vec::new());
|
||||
}
|
||||
let quests = quests.as_array_mut().expect("quests should be array");
|
||||
let Some(index) = quests.iter().position(|quest| {
|
||||
read_optional_string_field(quest, "id").as_deref() == Some(quest_id)
|
||||
&& read_optional_string_field(quest, "issuerNpcId").as_deref() == Some(issuer_npc_id)
|
||||
}) else {
|
||||
return Err("当前没有可交付的委托。".to_string());
|
||||
};
|
||||
|
||||
let mut turned_in = quests[index].clone();
|
||||
if read_optional_string_field(&turned_in, "status").as_deref() != Some("completed") {
|
||||
return Err("这份委托还没有达到可交付状态。".to_string());
|
||||
}
|
||||
if let Some(object) = turned_in.as_object_mut() {
|
||||
object.insert("status".to_string(), Value::String("turned_in".to_string()));
|
||||
object.insert("completionNotified".to_string(), Value::Bool(true));
|
||||
if let Some(steps) = object.get_mut("steps").and_then(Value::as_array_mut) {
|
||||
for step in steps.iter_mut() {
|
||||
let required_count = read_i32_field(step, "requiredCount").unwrap_or(0);
|
||||
if let Some(step_object) = step.as_object_mut() {
|
||||
step_object.insert("progress".to_string(), json!(required_count.max(0)));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
quests[index] = turned_in.clone();
|
||||
Ok(turned_in)
|
||||
}
|
||||
|
||||
pub(super) fn build_quest_turn_in_result_text(quest: &Value) -> String {
|
||||
let title = read_optional_string_field(quest, "title").unwrap_or_else(|| "委托".to_string());
|
||||
let reward_text = read_optional_string_field(quest, "rewardText")
|
||||
.unwrap_or_else(|| "报酬已经结清。".to_string());
|
||||
format!("你已经完成并交付了「{title}」。{reward_text}")
|
||||
}
|
||||
|
||||
pub(super) fn apply_quest_turn_in_rewards(game_state: &mut Value, quest: &Value) {
|
||||
let Some(reward) = read_field(quest, "reward") else {
|
||||
return;
|
||||
};
|
||||
|
||||
let currency = read_i32_field(reward, "currency").unwrap_or(0).max(0);
|
||||
if currency > 0 {
|
||||
add_player_currency(game_state, currency);
|
||||
}
|
||||
|
||||
let reward_items = read_array_field(reward, "items")
|
||||
.into_iter()
|
||||
.cloned()
|
||||
.collect::<Vec<_>>();
|
||||
if !reward_items.is_empty() {
|
||||
add_player_inventory_items(game_state, reward_items);
|
||||
}
|
||||
|
||||
let experience = read_i32_field(reward, "experience").unwrap_or(0).max(0);
|
||||
if experience > 0 {
|
||||
grant_player_progression_experience(game_state, experience, "quest");
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn build_legacy_current_story(
|
||||
story_text: &str,
|
||||
options: &[RuntimeStoryOptionView],
|
||||
) -> Value {
|
||||
json!({
|
||||
"text": story_text,
|
||||
"options": options.iter().map(build_story_option_from_runtime_option).collect::<Vec<_>>(),
|
||||
"streaming": false
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) fn read_story_text(current_story: Option<&Value>) -> Option<String> {
|
||||
current_story.and_then(|story| read_optional_string_field(story, "text"))
|
||||
}
|
||||
|
||||
pub(super) fn build_fallback_story_text(game_state: &Value) -> String {
|
||||
if read_bool_field(game_state, "inBattle").unwrap_or(false) {
|
||||
let encounter_name = read_object_field(game_state, "currentEncounter")
|
||||
.and_then(|encounter| read_optional_string_field(encounter, "npcName"))
|
||||
.unwrap_or_else(|| "眼前的敌人".to_string());
|
||||
return format!("战斗还没有结束,{encounter_name} 仍在逼你立刻做出下一步判断。");
|
||||
}
|
||||
|
||||
if let Some(encounter) = read_object_field(game_state, "currentEncounter")
|
||||
&& let Some(npc_name) = read_optional_string_field(encounter, "npcName")
|
||||
{
|
||||
return format!("{npc_name} 正在等你表态,当前局势已经可以继续推进。");
|
||||
}
|
||||
|
||||
"当前故事状态已经同步到兼容状态桥,可以继续推进这一轮运行时动作。".to_string()
|
||||
}
|
||||
@@ -0,0 +1,234 @@
|
||||
use super::*;
|
||||
|
||||
pub(super) fn resolve_pending_quest_offer_view_action(
|
||||
game_state: &mut Value,
|
||||
current_story: Option<&Value>,
|
||||
request: &RuntimeStoryActionRequest,
|
||||
) -> Result<StoryResolution, String> {
|
||||
let encounter = current_encounter_npc_quest_context(game_state)?;
|
||||
let pending_offer = read_pending_quest_offer_context(current_story, encounter.npc_id.as_str())
|
||||
.ok_or_else(|| "当前没有待处理的委托可查看。".to_string())?;
|
||||
Ok(StoryResolution {
|
||||
action_text: resolve_action_text(&format!("查看{}提出的委托", encounter.npc_name), request),
|
||||
result_text: pending_offer.intro_text.clone().unwrap_or_else(|| {
|
||||
build_quest_offer_dialogue_text(encounter.npc_name.as_str(), &pending_offer.quest)
|
||||
}),
|
||||
story_text: None,
|
||||
presentation_options: None,
|
||||
saved_current_story: None,
|
||||
patches: vec![],
|
||||
battle: None,
|
||||
toast: None,
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) fn resolve_pending_quest_offer_replace_action(
|
||||
game_state: &mut Value,
|
||||
current_story: Option<&Value>,
|
||||
request: &RuntimeStoryActionRequest,
|
||||
) -> Result<StoryResolution, String> {
|
||||
let encounter = current_encounter_npc_quest_context(game_state)?;
|
||||
let pending_offer = read_pending_quest_offer_context(current_story, encounter.npc_id.as_str())
|
||||
.ok_or_else(|| "当前没有待处理的委托可更换。".to_string())?;
|
||||
let next_quest = build_next_pending_quest_offer(
|
||||
game_state,
|
||||
encounter.npc_id.as_str(),
|
||||
encounter.npc_name.as_str(),
|
||||
Some(pending_offer.quest_id.as_str()),
|
||||
);
|
||||
let quest_text = build_quest_offer_dialogue_text(encounter.npc_name.as_str(), &next_quest);
|
||||
let dialogue = append_dialogue_turns(
|
||||
pending_offer.dialogue.as_slice(),
|
||||
vec![
|
||||
json!({
|
||||
"speaker": "player",
|
||||
"text": "能不能换一份更适合眼下局势的委托?"
|
||||
}),
|
||||
json!({
|
||||
"speaker": "npc",
|
||||
"speakerName": encounter.npc_name,
|
||||
"text": quest_text,
|
||||
}),
|
||||
],
|
||||
);
|
||||
let options = build_pending_quest_offer_options(encounter.npc_id.as_str());
|
||||
let saved_current_story = build_pending_quest_offer_story(
|
||||
dialogue,
|
||||
encounter.npc_id.as_str(),
|
||||
encounter.npc_name.as_str(),
|
||||
pending_offer.turn_count,
|
||||
pending_offer.custom_input_placeholder.as_str(),
|
||||
Some(next_quest.clone()),
|
||||
options.as_slice(),
|
||||
);
|
||||
|
||||
Ok(StoryResolution {
|
||||
action_text: resolve_action_text(&format!("请{}更换委托", encounter.npc_name), request),
|
||||
result_text: quest_text.clone(),
|
||||
story_text: Some(quest_text),
|
||||
presentation_options: Some(options),
|
||||
saved_current_story: Some(saved_current_story),
|
||||
patches: vec![],
|
||||
battle: None,
|
||||
toast: None,
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) fn resolve_pending_quest_offer_abandon_action(
|
||||
game_state: &mut Value,
|
||||
current_story: Option<&Value>,
|
||||
request: &RuntimeStoryActionRequest,
|
||||
) -> Result<StoryResolution, String> {
|
||||
let encounter = current_encounter_npc_quest_context(game_state)?;
|
||||
let pending_offer = read_pending_quest_offer_context(current_story, encounter.npc_id.as_str())
|
||||
.ok_or_else(|| "当前没有待处理的委托可放弃。".to_string())?;
|
||||
let npc_reply = format!(
|
||||
"{}点了点头,没有继续强求,只把这份委托暂时收了回去。",
|
||||
encounter.npc_name
|
||||
);
|
||||
let dialogue = append_dialogue_turns(
|
||||
pending_offer.dialogue.as_slice(),
|
||||
vec![
|
||||
json!({
|
||||
"speaker": "player",
|
||||
"text": "这件事我先不接,咱们还是先聊别的。"
|
||||
}),
|
||||
json!({
|
||||
"speaker": "npc",
|
||||
"speakerName": encounter.npc_name,
|
||||
"text": npc_reply,
|
||||
}),
|
||||
],
|
||||
);
|
||||
let options = build_post_quest_offer_chat_options(encounter.npc_id.as_str());
|
||||
let saved_current_story = build_pending_quest_offer_story(
|
||||
dialogue,
|
||||
encounter.npc_id.as_str(),
|
||||
encounter.npc_name.as_str(),
|
||||
pending_offer.turn_count,
|
||||
pending_offer.custom_input_placeholder.as_str(),
|
||||
None,
|
||||
options.as_slice(),
|
||||
);
|
||||
|
||||
Ok(StoryResolution {
|
||||
action_text: resolve_action_text(&format!("暂不接受{}的委托", encounter.npc_name), request),
|
||||
result_text: npc_reply.clone(),
|
||||
story_text: Some(npc_reply),
|
||||
presentation_options: Some(options),
|
||||
saved_current_story: Some(saved_current_story),
|
||||
patches: vec![],
|
||||
battle: None,
|
||||
toast: None,
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) fn resolve_pending_quest_accept_action(
|
||||
game_state: &mut Value,
|
||||
current_story: Option<&Value>,
|
||||
request: &RuntimeStoryActionRequest,
|
||||
) -> Result<StoryResolution, String> {
|
||||
let encounter = current_encounter_npc_quest_context(game_state)?;
|
||||
let pending_offer = read_pending_quest_offer_context(current_story, encounter.npc_id.as_str())
|
||||
.ok_or_else(|| "当前没有待处理的委托可接下。".to_string())?;
|
||||
if find_active_quest_for_issuer(game_state, encounter.npc_id.as_str()).is_some() {
|
||||
return Err("当前角色已经有未结清的委托。".to_string());
|
||||
}
|
||||
|
||||
let quest = pending_offer.quest.clone();
|
||||
push_quest_record(game_state, &quest);
|
||||
increment_runtime_stat(game_state, "questsAccepted", 1);
|
||||
write_current_npc_state_bool_field(game_state, "firstMeaningfulContactResolved", true);
|
||||
|
||||
let reply_text = first_quest_reveal_text(&quest)
|
||||
.map(|text| format!("那就拜托你了。{text}"))
|
||||
.unwrap_or_else(|| {
|
||||
format!(
|
||||
"那就拜托你了。{}",
|
||||
read_optional_string_field(&quest, "summary")
|
||||
.unwrap_or_else(|| "这份委托的关键要点我已经交给你。".to_string())
|
||||
)
|
||||
});
|
||||
let dialogue = append_dialogue_turns(
|
||||
pending_offer.dialogue.as_slice(),
|
||||
vec![
|
||||
json!({
|
||||
"speaker": "player",
|
||||
"text": "这件事我愿意接下,你把关键要点交给我。"
|
||||
}),
|
||||
json!({
|
||||
"speaker": "npc",
|
||||
"speakerName": encounter.npc_name,
|
||||
"text": reply_text,
|
||||
}),
|
||||
],
|
||||
);
|
||||
let options = build_post_quest_accept_chat_options(encounter.npc_id.as_str());
|
||||
let saved_current_story = build_pending_quest_offer_story(
|
||||
dialogue,
|
||||
encounter.npc_id.as_str(),
|
||||
encounter.npc_name.as_str(),
|
||||
pending_offer.turn_count,
|
||||
pending_offer.custom_input_placeholder.as_str(),
|
||||
None,
|
||||
options.as_slice(),
|
||||
);
|
||||
|
||||
Ok(StoryResolution {
|
||||
action_text: resolve_action_text(&format!("接下{}的委托", encounter.npc_name), request),
|
||||
result_text: build_quest_accept_result_text(&quest),
|
||||
story_text: Some(
|
||||
saved_current_story["text"]
|
||||
.as_str()
|
||||
.unwrap_or_default()
|
||||
.to_string(),
|
||||
),
|
||||
presentation_options: Some(options),
|
||||
saved_current_story: Some(saved_current_story),
|
||||
patches: vec![],
|
||||
battle: None,
|
||||
toast: None,
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) fn resolve_pending_quest_turn_in_action(
|
||||
game_state: &mut Value,
|
||||
request: &RuntimeStoryActionRequest,
|
||||
) -> Result<StoryResolution, String> {
|
||||
let encounter = current_encounter_npc_quest_context(game_state)?;
|
||||
let quest_id = request
|
||||
.action
|
||||
.payload
|
||||
.as_ref()
|
||||
.and_then(|payload| read_optional_string_field(payload, "questId"))
|
||||
.or_else(|| request.action.target_id.clone())
|
||||
.or_else(|| {
|
||||
find_active_quest_for_issuer(game_state, encounter.npc_id.as_str())
|
||||
.and_then(|quest| read_optional_string_field(quest, "id"))
|
||||
})
|
||||
.ok_or_else(|| "当前没有可交付的委托。".to_string())?;
|
||||
let turned_in = turn_in_quest_record(game_state, encounter.npc_id.as_str(), quest_id.as_str())?;
|
||||
let previous_affinity = read_current_npc_affinity(game_state);
|
||||
let affinity_bonus = read_field(&turned_in, "reward")
|
||||
.and_then(|reward| read_i32_field(reward, "affinityBonus"))
|
||||
.unwrap_or(0);
|
||||
let next_affinity = previous_affinity.saturating_add(affinity_bonus);
|
||||
write_current_npc_state_i32_field(game_state, "affinity", next_affinity);
|
||||
write_current_npc_state_bool_field(game_state, "firstMeaningfulContactResolved", true);
|
||||
apply_quest_turn_in_rewards(game_state, &turned_in);
|
||||
|
||||
Ok(StoryResolution {
|
||||
action_text: resolve_action_text(&format!("向{}交付委托", encounter.npc_name), request),
|
||||
result_text: build_quest_turn_in_result_text(&turned_in),
|
||||
story_text: None,
|
||||
presentation_options: None,
|
||||
saved_current_story: None,
|
||||
patches: vec![RuntimeStoryPatch::NpcAffinityChanged {
|
||||
npc_id: encounter.npc_id,
|
||||
previous_affinity,
|
||||
next_affinity,
|
||||
}],
|
||||
battle: None,
|
||||
toast: None,
|
||||
})
|
||||
}
|
||||
2165
server-rs/crates/api-server/src/runtime_story/compat/tests.rs
Normal file
2165
server-rs/crates/api-server/src/runtime_story/compat/tests.rs
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1,16 +1,26 @@
|
||||
use std::{error::Error, fmt};
|
||||
|
||||
#[cfg(test)]
|
||||
use std::{
|
||||
collections::HashMap,
|
||||
sync::{Arc, Mutex},
|
||||
};
|
||||
|
||||
use module_ai::{AiTaskService, InMemoryAiTaskStore};
|
||||
use module_auth::{
|
||||
AuthUserService, InMemoryAuthStore, PasswordEntryService, PhoneAuthService,
|
||||
RefreshSessionService, WechatAuthService, WechatAuthStateService,
|
||||
};
|
||||
use module_runtime::RuntimeSnapshotRecord;
|
||||
#[cfg(test)]
|
||||
use module_runtime::{SAVE_SNAPSHOT_VERSION, format_utc_micros};
|
||||
use platform_auth::{
|
||||
JwtConfig, JwtError, RefreshCookieConfig, RefreshCookieError, RefreshCookieSameSite,
|
||||
};
|
||||
use platform_llm::{LlmClient, LlmConfig, LlmError};
|
||||
use platform_oss::{OssClient, OssConfig, OssError};
|
||||
use spacetime_client::{SpacetimeClient, SpacetimeClientConfig};
|
||||
use serde_json::Value;
|
||||
use spacetime_client::{SpacetimeClient, SpacetimeClientConfig, SpacetimeClientError};
|
||||
|
||||
use crate::config::AppConfig;
|
||||
use crate::wechat_provider::{WechatProvider, build_wechat_provider};
|
||||
@@ -35,6 +45,9 @@ pub struct AppState {
|
||||
ai_task_service: AiTaskService,
|
||||
spacetime_client: SpacetimeClient,
|
||||
llm_client: Option<LlmClient>,
|
||||
#[cfg(test)]
|
||||
// 测试环境允许在未启动 SpacetimeDB 时,用内存快照兜底当前 runtime story 回归链。
|
||||
test_runtime_snapshot_store: Arc<Mutex<HashMap<String, RuntimeSnapshotRecord>>>,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
@@ -98,6 +111,8 @@ impl AppState {
|
||||
ai_task_service,
|
||||
spacetime_client,
|
||||
llm_client,
|
||||
#[cfg(test)]
|
||||
test_runtime_snapshot_store: Arc::new(Mutex::new(HashMap::new())),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -153,6 +168,162 @@ impl AppState {
|
||||
pub fn llm_client(&self) -> Option<&LlmClient> {
|
||||
self.llm_client.as_ref()
|
||||
}
|
||||
|
||||
pub async fn get_runtime_snapshot_record(
|
||||
&self,
|
||||
user_id: String,
|
||||
) -> Result<Option<RuntimeSnapshotRecord>, SpacetimeClientError> {
|
||||
match self
|
||||
.spacetime_client
|
||||
.get_runtime_snapshot(user_id.clone())
|
||||
.await
|
||||
{
|
||||
Ok(record) => {
|
||||
#[cfg(test)]
|
||||
if let Some(snapshot) = record.as_ref() {
|
||||
self.cache_test_runtime_snapshot(snapshot.clone());
|
||||
}
|
||||
Ok(record)
|
||||
}
|
||||
#[cfg(test)]
|
||||
Err(_) => Ok(self.read_test_runtime_snapshot(user_id.as_str())),
|
||||
#[cfg(not(test))]
|
||||
Err(error) => Err(error),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn put_runtime_snapshot_record(
|
||||
&self,
|
||||
user_id: String,
|
||||
saved_at_micros: i64,
|
||||
bottom_tab: String,
|
||||
game_state: Value,
|
||||
current_story: Option<Value>,
|
||||
updated_at_micros: i64,
|
||||
) -> Result<RuntimeSnapshotRecord, SpacetimeClientError> {
|
||||
match self
|
||||
.spacetime_client
|
||||
.put_runtime_snapshot(
|
||||
user_id.clone(),
|
||||
saved_at_micros,
|
||||
bottom_tab.clone(),
|
||||
game_state.clone(),
|
||||
current_story.clone(),
|
||||
updated_at_micros,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(record) => {
|
||||
#[cfg(test)]
|
||||
self.cache_test_runtime_snapshot(record.clone());
|
||||
Ok(record)
|
||||
}
|
||||
#[cfg(test)]
|
||||
Err(_) => {
|
||||
let snapshot = self.build_test_runtime_snapshot_record(
|
||||
user_id,
|
||||
saved_at_micros,
|
||||
bottom_tab,
|
||||
game_state,
|
||||
current_story,
|
||||
updated_at_micros,
|
||||
)?;
|
||||
self.cache_test_runtime_snapshot(snapshot.clone());
|
||||
Ok(snapshot)
|
||||
}
|
||||
#[cfg(not(test))]
|
||||
Err(error) => Err(error),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn delete_runtime_snapshot_record(
|
||||
&self,
|
||||
user_id: String,
|
||||
) -> Result<bool, SpacetimeClientError> {
|
||||
match self
|
||||
.spacetime_client
|
||||
.delete_runtime_snapshot(user_id.clone())
|
||||
.await
|
||||
{
|
||||
Ok(deleted) => {
|
||||
#[cfg(test)]
|
||||
if deleted {
|
||||
self.remove_test_runtime_snapshot(user_id.as_str());
|
||||
}
|
||||
Ok(deleted)
|
||||
}
|
||||
#[cfg(test)]
|
||||
Err(_) => Ok(self
|
||||
.remove_test_runtime_snapshot(user_id.as_str())
|
||||
.is_some()),
|
||||
#[cfg(not(test))]
|
||||
Err(error) => Err(error),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
impl AppState {
|
||||
fn cache_test_runtime_snapshot(&self, record: RuntimeSnapshotRecord) {
|
||||
self.test_runtime_snapshot_store
|
||||
.lock()
|
||||
.expect("test runtime snapshot store should lock")
|
||||
.insert(record.user_id.clone(), record);
|
||||
}
|
||||
|
||||
fn read_test_runtime_snapshot(&self, user_id: &str) -> Option<RuntimeSnapshotRecord> {
|
||||
self.test_runtime_snapshot_store
|
||||
.lock()
|
||||
.expect("test runtime snapshot store should lock")
|
||||
.get(user_id)
|
||||
.cloned()
|
||||
}
|
||||
|
||||
fn remove_test_runtime_snapshot(&self, user_id: &str) -> Option<RuntimeSnapshotRecord> {
|
||||
self.test_runtime_snapshot_store
|
||||
.lock()
|
||||
.expect("test runtime snapshot store should lock")
|
||||
.remove(user_id)
|
||||
}
|
||||
|
||||
fn build_test_runtime_snapshot_record(
|
||||
&self,
|
||||
user_id: String,
|
||||
saved_at_micros: i64,
|
||||
bottom_tab: String,
|
||||
game_state: Value,
|
||||
current_story: Option<Value>,
|
||||
updated_at_micros: i64,
|
||||
) -> Result<RuntimeSnapshotRecord, SpacetimeClientError> {
|
||||
let previous = self.read_test_runtime_snapshot(user_id.as_str());
|
||||
let game_state_json = serde_json::to_string(&game_state).map_err(|error| {
|
||||
SpacetimeClientError::Runtime(format!("测试快照 game_state 序列化失败: {error}"))
|
||||
})?;
|
||||
let current_story_json = current_story
|
||||
.as_ref()
|
||||
.map(serde_json::to_string)
|
||||
.transpose()
|
||||
.map_err(|error| {
|
||||
SpacetimeClientError::Runtime(format!("测试快照 current_story 序列化失败: {error}"))
|
||||
})?;
|
||||
|
||||
Ok(RuntimeSnapshotRecord {
|
||||
user_id,
|
||||
version: SAVE_SNAPSHOT_VERSION,
|
||||
saved_at: format_utc_micros(saved_at_micros),
|
||||
saved_at_micros,
|
||||
bottom_tab,
|
||||
game_state,
|
||||
current_story,
|
||||
game_state_json,
|
||||
current_story_json,
|
||||
created_at_micros: previous
|
||||
.as_ref()
|
||||
.map(|record| record.created_at_micros)
|
||||
.unwrap_or(updated_at_micros),
|
||||
updated_at_micros,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for AppStateInitError {
|
||||
|
||||
@@ -140,6 +140,7 @@ pub enum CustomWorldFieldError {
|
||||
MissingProfileId,
|
||||
MissingSessionId,
|
||||
MissingOwnerUserId,
|
||||
MissingAction,
|
||||
MissingWorldName,
|
||||
MissingDraftProfileJson,
|
||||
MissingProfilePayloadJson,
|
||||
@@ -153,7 +154,6 @@ pub enum CustomWorldFieldError {
|
||||
MissingMessageText,
|
||||
MissingOperationId,
|
||||
MissingPhaseLabel,
|
||||
MissingAction,
|
||||
InvalidProgressPercent,
|
||||
MissingCardId,
|
||||
MissingCardTitle,
|
||||
@@ -228,6 +228,61 @@ pub struct CustomWorldGalleryListResult {
|
||||
pub error_message: Option<String>,
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct CustomWorldPublishBlockerSnapshot {
|
||||
pub blocker_id: String,
|
||||
pub code: String,
|
||||
pub message: String,
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct CustomWorldPublishGateSnapshot {
|
||||
pub profile_id: String,
|
||||
pub blockers: Vec<CustomWorldPublishBlockerSnapshot>,
|
||||
pub blocker_count: u32,
|
||||
pub publish_ready: bool,
|
||||
pub can_enter_world: bool,
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct CustomWorldWorkSummarySnapshot {
|
||||
pub work_id: String,
|
||||
pub source_type: String,
|
||||
pub status: String,
|
||||
pub title: String,
|
||||
pub subtitle: String,
|
||||
pub summary: String,
|
||||
pub cover_image_src: Option<String>,
|
||||
pub cover_render_mode: Option<String>,
|
||||
pub cover_character_image_srcs_json: String,
|
||||
pub updated_at_micros: i64,
|
||||
pub published_at_micros: Option<i64>,
|
||||
pub stage: Option<RpgAgentStage>,
|
||||
pub stage_label: Option<String>,
|
||||
pub playable_npc_count: u32,
|
||||
pub landmark_count: u32,
|
||||
pub role_visual_ready_count: Option<u32>,
|
||||
pub role_animation_ready_count: Option<u32>,
|
||||
pub role_asset_summary_label: Option<String>,
|
||||
pub session_id: Option<String>,
|
||||
pub profile_id: Option<String>,
|
||||
pub can_resume: bool,
|
||||
pub can_enter_world: bool,
|
||||
pub blocker_count: u32,
|
||||
pub publish_ready: bool,
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct CustomWorldWorksListResult {
|
||||
pub ok: bool,
|
||||
pub items: Vec<CustomWorldWorkSummarySnapshot>,
|
||||
pub error_message: Option<String>,
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct CustomWorldAgentMessageSnapshot {
|
||||
@@ -274,6 +329,38 @@ pub struct CustomWorldDraftCardSnapshot {
|
||||
pub updated_at_micros: i64,
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct CustomWorldDraftCardDetailSectionSnapshot {
|
||||
pub section_id: String,
|
||||
pub label: String,
|
||||
pub value: String,
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct CustomWorldDraftCardDetailSnapshot {
|
||||
pub card_id: String,
|
||||
pub kind: RpgAgentDraftCardKind,
|
||||
pub title: String,
|
||||
pub sections: Vec<CustomWorldDraftCardDetailSectionSnapshot>,
|
||||
pub linked_ids_json: String,
|
||||
pub locked: bool,
|
||||
pub editable: bool,
|
||||
pub editable_section_ids_json: String,
|
||||
pub warning_messages_json: String,
|
||||
pub asset_status: Option<CustomWorldRoleAssetStatus>,
|
||||
pub asset_status_label: Option<String>,
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct CustomWorldDraftCardDetailResult {
|
||||
pub ok: bool,
|
||||
pub card: Option<CustomWorldDraftCardDetailSnapshot>,
|
||||
pub error_message: Option<String>,
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct CustomWorldAgentSessionSnapshot {
|
||||
@@ -434,61 +521,6 @@ pub struct CustomWorldWorksListInput {
|
||||
pub owner_user_id: String,
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct CustomWorldPublishBlockerSnapshot {
|
||||
pub blocker_id: String,
|
||||
pub code: String,
|
||||
pub message: String,
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct CustomWorldPublishGateSnapshot {
|
||||
pub profile_id: String,
|
||||
pub blockers: Vec<CustomWorldPublishBlockerSnapshot>,
|
||||
pub blocker_count: u32,
|
||||
pub publish_ready: bool,
|
||||
pub can_enter_world: bool,
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct CustomWorldWorkSummarySnapshot {
|
||||
pub work_id: String,
|
||||
pub source_type: String,
|
||||
pub status: String,
|
||||
pub title: String,
|
||||
pub subtitle: String,
|
||||
pub summary: String,
|
||||
pub cover_image_src: Option<String>,
|
||||
pub cover_render_mode: Option<String>,
|
||||
pub cover_character_image_srcs_json: String,
|
||||
pub updated_at_micros: i64,
|
||||
pub published_at_micros: Option<i64>,
|
||||
pub stage: Option<RpgAgentStage>,
|
||||
pub stage_label: Option<String>,
|
||||
pub playable_npc_count: u32,
|
||||
pub landmark_count: u32,
|
||||
pub role_visual_ready_count: Option<u32>,
|
||||
pub role_animation_ready_count: Option<u32>,
|
||||
pub role_asset_summary_label: Option<String>,
|
||||
pub session_id: Option<String>,
|
||||
pub profile_id: Option<String>,
|
||||
pub can_resume: bool,
|
||||
pub can_enter_world: bool,
|
||||
pub blocker_count: u32,
|
||||
pub publish_ready: bool,
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct CustomWorldWorksListResult {
|
||||
pub ok: bool,
|
||||
pub items: Vec<CustomWorldWorkSummarySnapshot>,
|
||||
pub error_message: Option<String>,
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct CustomWorldAgentCardDetailGetInput {
|
||||
@@ -497,38 +529,6 @@ pub struct CustomWorldAgentCardDetailGetInput {
|
||||
pub card_id: String,
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct CustomWorldDraftCardDetailSectionSnapshot {
|
||||
pub section_id: String,
|
||||
pub label: String,
|
||||
pub value: String,
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct CustomWorldDraftCardDetailSnapshot {
|
||||
pub card_id: String,
|
||||
pub kind: RpgAgentDraftCardKind,
|
||||
pub title: String,
|
||||
pub sections: Vec<CustomWorldDraftCardDetailSectionSnapshot>,
|
||||
pub linked_ids_json: String,
|
||||
pub locked: bool,
|
||||
pub editable: bool,
|
||||
pub editable_section_ids_json: String,
|
||||
pub warning_messages_json: String,
|
||||
pub asset_status: Option<CustomWorldRoleAssetStatus>,
|
||||
pub asset_status_label: Option<String>,
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct CustomWorldDraftCardDetailResult {
|
||||
pub ok: bool,
|
||||
pub card: Option<CustomWorldDraftCardDetailSnapshot>,
|
||||
pub error_message: Option<String>,
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct CustomWorldAgentActionExecuteInput {
|
||||
@@ -1101,11 +1101,7 @@ pub fn validate_custom_world_agent_action_execute_input(
|
||||
if input.action.trim().is_empty() {
|
||||
return Err(CustomWorldFieldError::MissingAction);
|
||||
}
|
||||
if let Some(payload_json) = input.payload_json.as_deref() {
|
||||
if !payload_json.trim().is_empty() {
|
||||
ensure_json_object(payload_json)?;
|
||||
}
|
||||
}
|
||||
ensure_optional_json_object(input.payload_json.as_deref())?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -1459,6 +1455,7 @@ impl fmt::Display for CustomWorldFieldError {
|
||||
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::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 不能为空")
|
||||
@@ -1490,7 +1487,6 @@ impl fmt::Display for CustomWorldFieldError {
|
||||
Self::MissingPhaseLabel => {
|
||||
f.write_str("custom_world_agent_operation.phase_label 不能为空")
|
||||
}
|
||||
Self::MissingAction => f.write_str("custom_world_agent_action.action 不能为空"),
|
||||
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 不能为空"),
|
||||
|
||||
11
server-rs/crates/module-runtime-story-compat/Cargo.toml
Normal file
11
server-rs/crates/module-runtime-story-compat/Cargo.toml
Normal file
@@ -0,0 +1,11 @@
|
||||
[package]
|
||||
name = "module-runtime-story-compat"
|
||||
edition.workspace = true
|
||||
version.workspace = true
|
||||
license.workspace = true
|
||||
|
||||
[dependencies]
|
||||
serde_json = "1"
|
||||
shared-contracts = { path = "../shared-contracts" }
|
||||
shared-kernel = { path = "../shared-kernel" }
|
||||
time = { version = "0.3", features = ["formatting"] }
|
||||
13
server-rs/crates/module-runtime-story-compat/README.md
Normal file
13
server-rs/crates/module-runtime-story-compat/README.md
Normal file
@@ -0,0 +1,13 @@
|
||||
# module-runtime-story-compat
|
||||
|
||||
`module-runtime-story-compat` 承接旧 `/api/runtime/story/*` 兼容桥中不依赖 HTTP / `AppState` 的核心类型与纯 helper。
|
||||
|
||||
当前首批迁入范围保持克制:
|
||||
|
||||
1. action 结算结果结构。
|
||||
2. action response 组装参数结构。
|
||||
3. NPC 委托上下文结构。
|
||||
4. functionId / 队伍上限常量。
|
||||
5. 少量只依赖 `serde_json::Value` 与 `shared-contracts` 的纯 helper。
|
||||
|
||||
后续再按 battle / forge / NPC / quest / presentation 的顺序,把已经拆好的 `api-server` 内部模块逐步迁入本 crate。
|
||||
814
server-rs/crates/module-runtime-story-compat/src/battle.rs
Normal file
814
server-rs/crates/module-runtime-story-compat/src/battle.rs
Normal file
@@ -0,0 +1,814 @@
|
||||
use serde_json::{Map, Value, json};
|
||||
|
||||
use shared_contracts::runtime_story::{
|
||||
RuntimeBattlePresentation, RuntimeStoryActionRequest, RuntimeStoryOptionView,
|
||||
RuntimeStoryPatch,
|
||||
};
|
||||
|
||||
use crate::{
|
||||
StoryResolution, append_active_build_buffs, build_status_patch, clear_encounter_only,
|
||||
clear_encounter_state, current_encounter_id, current_encounter_name_from_battle,
|
||||
ensure_json_object, first_hostile_npc_string_field, grant_player_progression_experience,
|
||||
increment_runtime_stat, read_array_field, read_field, read_i32_field, read_object_field,
|
||||
read_optional_string_field, remove_player_inventory_item, resolve_action_text,
|
||||
write_bool_field, write_current_encounter_i32_field, write_first_hostile_npc_i32_field,
|
||||
write_i32_field, write_null_field, write_string_field,
|
||||
};
|
||||
|
||||
/// 战斗 compat 纯结算链已经不依赖 HTTP / AppState。
|
||||
///
|
||||
/// 这里同时承接 battle action 的状态结算、资源恢复和战斗选项编译,
|
||||
/// 让 `api-server` 只保留 HTTP 外壳与最终响应拼装。
|
||||
struct BattleActionPlan {
|
||||
action_text: String,
|
||||
result_text: String,
|
||||
damage_dealt: i32,
|
||||
damage_taken: i32,
|
||||
heal: i32,
|
||||
mana_restore: i32,
|
||||
mana_cost: i32,
|
||||
cooldown_tick_turns: i32,
|
||||
cooldown_bonus_turns: i32,
|
||||
applied_skill_cooldown: Option<(String, i32)>,
|
||||
build_buffs: Vec<Value>,
|
||||
consumed_item_id: Option<String>,
|
||||
}
|
||||
|
||||
struct BattleSkillView {
|
||||
id: String,
|
||||
name: String,
|
||||
damage: i32,
|
||||
mana_cost: i32,
|
||||
cooldown_turns: i32,
|
||||
build_buffs: Vec<Value>,
|
||||
}
|
||||
|
||||
struct BattleInventoryUseProfile {
|
||||
hp_restore: i32,
|
||||
mana_restore: i32,
|
||||
cooldown_reduction: i32,
|
||||
build_buffs: Vec<Value>,
|
||||
}
|
||||
|
||||
struct BattleInventoryItemView {
|
||||
id: String,
|
||||
name: String,
|
||||
quantity: i32,
|
||||
use_profile: Option<BattleInventoryUseProfile>,
|
||||
}
|
||||
|
||||
pub fn resolve_battle_action(
|
||||
game_state: &mut Value,
|
||||
request: &RuntimeStoryActionRequest,
|
||||
function_id: &str,
|
||||
) -> Result<StoryResolution, String> {
|
||||
let target_id = current_encounter_id(game_state)
|
||||
.or_else(|| first_hostile_npc_string_field(game_state, "id"))
|
||||
.unwrap_or_else(|| "battle_target".to_string());
|
||||
let target_name = current_encounter_name_from_battle(game_state);
|
||||
let battle_mode = read_optional_string_field(game_state, "currentNpcBattleMode")
|
||||
.unwrap_or_else(|| "fight".to_string());
|
||||
|
||||
if function_id == "battle_escape_breakout" {
|
||||
clear_encounter_state(game_state);
|
||||
return Ok(StoryResolution {
|
||||
action_text: resolve_action_text("强行脱离战斗", request),
|
||||
result_text: "你抓住空隙强行脱离战斗,把这一轮危险先甩在身后。".to_string(),
|
||||
story_text: None,
|
||||
presentation_options: None,
|
||||
saved_current_story: None,
|
||||
patches: vec![
|
||||
RuntimeStoryPatch::BattleResolved {
|
||||
function_id: function_id.to_string(),
|
||||
target_id: Some(target_id.clone()),
|
||||
damage_dealt: Some(0),
|
||||
damage_taken: Some(0),
|
||||
outcome: "escaped".to_string(),
|
||||
},
|
||||
build_status_patch(game_state),
|
||||
RuntimeStoryPatch::EncounterChanged { encounter_id: None },
|
||||
],
|
||||
battle: Some(RuntimeBattlePresentation {
|
||||
target_id: Some(target_id),
|
||||
target_name: Some(target_name),
|
||||
damage_dealt: Some(0),
|
||||
damage_taken: Some(0),
|
||||
outcome: Some("escaped".to_string()),
|
||||
}),
|
||||
toast: Some("已脱离战斗".to_string()),
|
||||
});
|
||||
}
|
||||
|
||||
let plan = build_battle_action_plan(game_state, request, function_id)?;
|
||||
spend_player_mana(game_state, plan.mana_cost);
|
||||
restore_player_resource(game_state, plan.heal, plan.mana_restore);
|
||||
tick_player_skill_cooldowns(game_state, plan.cooldown_tick_turns);
|
||||
reduce_player_skill_cooldowns(game_state, plan.cooldown_bonus_turns);
|
||||
if let Some((skill_id, turns)) = plan.applied_skill_cooldown.as_ref() {
|
||||
set_player_skill_cooldown(game_state, skill_id.as_str(), *turns);
|
||||
}
|
||||
if !plan.build_buffs.is_empty() {
|
||||
append_active_build_buffs(game_state, plan.build_buffs.clone());
|
||||
}
|
||||
if let Some(item_id) = plan.consumed_item_id.as_ref() {
|
||||
remove_player_inventory_item(game_state, item_id.as_str(), 1);
|
||||
increment_runtime_stat(game_state, "itemsUsed", 1);
|
||||
}
|
||||
|
||||
apply_player_damage(game_state, plan.damage_taken);
|
||||
let target_hp = apply_target_damage(game_state, plan.damage_dealt);
|
||||
let outcome = if target_hp <= 0 {
|
||||
if battle_mode == "spar" {
|
||||
"spar_complete"
|
||||
} else {
|
||||
"victory"
|
||||
}
|
||||
} else {
|
||||
"ongoing"
|
||||
};
|
||||
|
||||
let victory_experience = if outcome == "victory" {
|
||||
battle_victory_experience_reward(game_state)
|
||||
} else {
|
||||
0
|
||||
};
|
||||
|
||||
if outcome != "ongoing" {
|
||||
write_bool_field(game_state, "inBattle", false);
|
||||
write_bool_field(game_state, "npcInteractionActive", false);
|
||||
write_null_field(game_state, "currentNpcBattleMode");
|
||||
write_string_field(
|
||||
game_state,
|
||||
"currentNpcBattleOutcome",
|
||||
if outcome == "spar_complete" {
|
||||
"spar_complete"
|
||||
} else {
|
||||
"fight_victory"
|
||||
},
|
||||
);
|
||||
if outcome == "victory" {
|
||||
clear_encounter_only(game_state);
|
||||
increment_runtime_stat(game_state, "hostileNpcsDefeated", 1);
|
||||
if victory_experience > 0 {
|
||||
grant_player_progression_experience(game_state, victory_experience, "hostile_npc");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let mut patches = vec![
|
||||
RuntimeStoryPatch::BattleResolved {
|
||||
function_id: function_id.to_string(),
|
||||
target_id: Some(target_id.clone()),
|
||||
damage_dealt: Some(plan.damage_dealt),
|
||||
damage_taken: Some(plan.damage_taken),
|
||||
outcome: outcome.to_string(),
|
||||
},
|
||||
build_status_patch(game_state),
|
||||
];
|
||||
if outcome == "victory" {
|
||||
patches.push(RuntimeStoryPatch::EncounterChanged { encounter_id: None });
|
||||
}
|
||||
|
||||
Ok(StoryResolution {
|
||||
action_text: resolve_action_text(plan.action_text.as_str(), request),
|
||||
result_text: if outcome == "ongoing" {
|
||||
plan.result_text
|
||||
} else if outcome == "spar_complete" {
|
||||
format!("{target_name} 收住了最后一击,这场切磋已经分出结果。")
|
||||
} else {
|
||||
format!("{target_name} 被你压制下去,眼前的战斗已经结束。")
|
||||
},
|
||||
story_text: None,
|
||||
presentation_options: None,
|
||||
saved_current_story: None,
|
||||
patches,
|
||||
battle: Some(RuntimeBattlePresentation {
|
||||
target_id: Some(target_id),
|
||||
target_name: Some(target_name),
|
||||
damage_dealt: Some(plan.damage_dealt),
|
||||
damage_taken: Some(plan.damage_taken),
|
||||
outcome: Some(outcome.to_string()),
|
||||
}),
|
||||
toast: battle_action_toast(function_id, request),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn restore_player_resource(game_state: &mut Value, hp_restore: i32, mana_restore: i32) {
|
||||
let max_hp = read_i32_field(game_state, "playerMaxHp")
|
||||
.unwrap_or(1)
|
||||
.max(1);
|
||||
let max_mana = read_i32_field(game_state, "playerMaxMana")
|
||||
.unwrap_or(0)
|
||||
.max(0);
|
||||
let hp = read_i32_field(game_state, "playerHp").unwrap_or(max_hp);
|
||||
let mana = read_i32_field(game_state, "playerMana").unwrap_or(max_mana);
|
||||
write_i32_field(game_state, "playerHp", (hp + hp_restore).clamp(0, max_hp));
|
||||
write_i32_field(
|
||||
game_state,
|
||||
"playerMana",
|
||||
(mana + mana_restore).clamp(0, max_mana),
|
||||
);
|
||||
}
|
||||
|
||||
pub fn build_battle_runtime_story_options(game_state: &Value) -> Vec<RuntimeStoryOptionView> {
|
||||
let mut options = vec![
|
||||
RuntimeStoryOptionView {
|
||||
detail_text: Some(build_basic_attack_detail_text(game_state)),
|
||||
..build_static_runtime_story_option("battle_attack_basic", "普通攻击", "combat")
|
||||
},
|
||||
RuntimeStoryOptionView {
|
||||
detail_text: Some("回血 12 / 回蓝 9 / 冷却 -1".to_string()),
|
||||
..build_static_runtime_story_option("battle_recover_breath", "恢复", "combat")
|
||||
},
|
||||
];
|
||||
|
||||
let preferred_item = pick_preferred_battle_inventory_item(game_state);
|
||||
if let Some(item) = preferred_item {
|
||||
let effect = item
|
||||
.use_profile
|
||||
.expect("preferred battle item must have use profile");
|
||||
options.push(build_runtime_story_option_with_payload(
|
||||
"inventory_use",
|
||||
&format!("使用物品:{}", item.name),
|
||||
"combat",
|
||||
Some(build_battle_item_summary(&effect)),
|
||||
json!({
|
||||
"itemId": item.id
|
||||
}),
|
||||
));
|
||||
} else {
|
||||
options.push(build_disabled_runtime_story_option(
|
||||
"inventory_use",
|
||||
"使用物品",
|
||||
"combat",
|
||||
Some("当前没有可直接结算的战斗消耗品".to_string()),
|
||||
"暂无可用物品",
|
||||
None,
|
||||
));
|
||||
}
|
||||
|
||||
options.extend(build_battle_skill_runtime_story_options(game_state));
|
||||
options.push(build_static_runtime_story_option(
|
||||
"battle_escape_breakout",
|
||||
"强行脱离战斗",
|
||||
"combat",
|
||||
));
|
||||
options
|
||||
}
|
||||
|
||||
fn spend_player_mana(game_state: &mut Value, mana_cost: i32) {
|
||||
if mana_cost <= 0 {
|
||||
return;
|
||||
}
|
||||
let mana = read_i32_field(game_state, "playerMana").unwrap_or(0);
|
||||
write_i32_field(game_state, "playerMana", (mana - mana_cost).max(0));
|
||||
}
|
||||
|
||||
fn apply_player_damage(game_state: &mut Value, damage: i32) {
|
||||
if damage <= 0 {
|
||||
return;
|
||||
}
|
||||
let hp = read_i32_field(game_state, "playerHp").unwrap_or(1);
|
||||
write_i32_field(game_state, "playerHp", (hp - damage).max(1));
|
||||
}
|
||||
|
||||
fn apply_target_damage(game_state: &mut Value, damage: i32) -> i32 {
|
||||
let target_hp = read_object_field(game_state, "currentEncounter")
|
||||
.and_then(|encounter| {
|
||||
read_i32_field(encounter, "hp")
|
||||
.or_else(|| read_i32_field(encounter, "currentHp"))
|
||||
.or_else(|| read_i32_field(encounter, "targetHp"))
|
||||
})
|
||||
.or_else(|| {
|
||||
read_array_field(game_state, "sceneHostileNpcs")
|
||||
.first()
|
||||
.and_then(|target| read_i32_field(target, "hp"))
|
||||
})
|
||||
.unwrap_or(24);
|
||||
let next_hp = target_hp - damage.max(0);
|
||||
write_current_encounter_i32_field(game_state, "hp", next_hp);
|
||||
write_first_hostile_npc_i32_field(game_state, "hp", next_hp);
|
||||
next_hp
|
||||
}
|
||||
|
||||
fn read_player_skills(game_state: &Value) -> Vec<BattleSkillView> {
|
||||
read_field(game_state, "playerCharacter")
|
||||
.map(|character| read_array_field(character, "skills"))
|
||||
.unwrap_or_default()
|
||||
.into_iter()
|
||||
.filter_map(|entry| {
|
||||
let id = read_optional_string_field(entry, "id")?;
|
||||
let name = read_optional_string_field(entry, "name").unwrap_or_else(|| id.clone());
|
||||
Some(BattleSkillView {
|
||||
id,
|
||||
name,
|
||||
damage: read_i32_field(entry, "damage").unwrap_or(14).max(0),
|
||||
mana_cost: read_i32_field(entry, "manaCost").unwrap_or(0).max(0),
|
||||
cooldown_turns: read_i32_field(entry, "cooldownTurns").unwrap_or(0).max(0),
|
||||
build_buffs: read_array_field(entry, "buildBuffs")
|
||||
.into_iter()
|
||||
.cloned()
|
||||
.collect(),
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn find_player_skill_by_id(game_state: &Value, skill_id: &str) -> Option<BattleSkillView> {
|
||||
read_player_skills(game_state)
|
||||
.into_iter()
|
||||
.find(|skill| skill.id == skill_id)
|
||||
}
|
||||
|
||||
fn build_basic_attack_detail_text(game_state: &Value) -> String {
|
||||
let strength = read_field(game_state, "playerCharacter")
|
||||
.and_then(|character| read_field(character, "attributes"))
|
||||
.and_then(|attributes| read_i32_field(attributes, "strength"))
|
||||
.unwrap_or(8);
|
||||
let agility = read_field(game_state, "playerCharacter")
|
||||
.and_then(|character| read_field(character, "attributes"))
|
||||
.and_then(|attributes| read_i32_field(attributes, "agility"))
|
||||
.unwrap_or(0);
|
||||
let preview_damage = ((strength * 85 + agility * 45) / 100).max(8);
|
||||
format!("不耗蓝 / 伤害 {preview_damage}")
|
||||
}
|
||||
|
||||
fn read_player_skill_cooldowns(game_state: &Value) -> std::collections::BTreeMap<String, i32> {
|
||||
read_object_field(game_state, "playerSkillCooldowns")
|
||||
.and_then(Value::as_object)
|
||||
.map(|cooldowns| {
|
||||
cooldowns
|
||||
.iter()
|
||||
.map(|(skill_id, turns)| {
|
||||
(
|
||||
skill_id.clone(),
|
||||
turns
|
||||
.as_i64()
|
||||
.and_then(|value| i32::try_from(value).ok())
|
||||
.unwrap_or(0)
|
||||
.max(0),
|
||||
)
|
||||
})
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
fn build_battle_skill_runtime_story_options(game_state: &Value) -> Vec<RuntimeStoryOptionView> {
|
||||
let cooldowns = read_player_skill_cooldowns(game_state);
|
||||
let player_mana = read_i32_field(game_state, "playerMana").unwrap_or(0);
|
||||
read_player_skills(game_state)
|
||||
.into_iter()
|
||||
.map(|skill| {
|
||||
let detail_text = Some(format!(
|
||||
"耗蓝 {} / 伤害 {} / 冷却 {}",
|
||||
skill.mana_cost.max(0),
|
||||
skill.damage.max(0),
|
||||
skill.cooldown_turns.max(0)
|
||||
));
|
||||
let payload = Some(json!({
|
||||
"skillId": skill.id
|
||||
}));
|
||||
let remaining_cooldown = cooldowns.get(skill.id.as_str()).copied().unwrap_or(0);
|
||||
if remaining_cooldown > 0 {
|
||||
return build_disabled_runtime_story_option(
|
||||
"battle_use_skill",
|
||||
&skill.name,
|
||||
"combat",
|
||||
detail_text,
|
||||
format!("冷却中,还需 {} 回合", remaining_cooldown).as_str(),
|
||||
payload,
|
||||
);
|
||||
}
|
||||
if skill.mana_cost > player_mana {
|
||||
return build_disabled_runtime_story_option(
|
||||
"battle_use_skill",
|
||||
&skill.name,
|
||||
"combat",
|
||||
detail_text,
|
||||
"灵力不足",
|
||||
payload,
|
||||
);
|
||||
}
|
||||
RuntimeStoryOptionView {
|
||||
detail_text,
|
||||
payload,
|
||||
..build_static_runtime_story_option("battle_use_skill", &skill.name, "combat")
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn tick_player_skill_cooldowns(game_state: &mut Value, turns: i32) {
|
||||
if turns <= 0 {
|
||||
return;
|
||||
}
|
||||
let root = ensure_json_object(game_state);
|
||||
let cooldowns = root
|
||||
.entry("playerSkillCooldowns".to_string())
|
||||
.or_insert_with(|| Value::Object(Map::new()));
|
||||
if !cooldowns.is_object() {
|
||||
*cooldowns = Value::Object(Map::new());
|
||||
}
|
||||
let cooldowns = cooldowns
|
||||
.as_object_mut()
|
||||
.expect("playerSkillCooldowns should be object");
|
||||
for value in cooldowns.values_mut() {
|
||||
let current = value
|
||||
.as_i64()
|
||||
.and_then(|number| i32::try_from(number).ok())
|
||||
.unwrap_or(0);
|
||||
*value = json!((current - turns).max(0));
|
||||
}
|
||||
}
|
||||
|
||||
fn reduce_player_skill_cooldowns(game_state: &mut Value, turns: i32) {
|
||||
if turns <= 0 {
|
||||
return;
|
||||
}
|
||||
tick_player_skill_cooldowns(game_state, turns);
|
||||
}
|
||||
|
||||
fn set_player_skill_cooldown(game_state: &mut Value, skill_id: &str, turns: i32) {
|
||||
let root = ensure_json_object(game_state);
|
||||
let cooldowns = root
|
||||
.entry("playerSkillCooldowns".to_string())
|
||||
.or_insert_with(|| Value::Object(Map::new()));
|
||||
if !cooldowns.is_object() {
|
||||
*cooldowns = Value::Object(Map::new());
|
||||
}
|
||||
cooldowns
|
||||
.as_object_mut()
|
||||
.expect("playerSkillCooldowns should be object")
|
||||
.insert(skill_id.to_string(), json!(turns.max(0)));
|
||||
}
|
||||
|
||||
fn read_player_inventory_items(game_state: &Value) -> Vec<BattleInventoryItemView> {
|
||||
read_array_field(game_state, "playerInventory")
|
||||
.into_iter()
|
||||
.filter_map(|entry| {
|
||||
let id = read_optional_string_field(entry, "id")?;
|
||||
let name = read_optional_string_field(entry, "name").unwrap_or_else(|| id.clone());
|
||||
let use_profile =
|
||||
read_field(entry, "useProfile").map(|profile| BattleInventoryUseProfile {
|
||||
hp_restore: read_i32_field(profile, "hpRestore").unwrap_or(0).max(0),
|
||||
mana_restore: read_i32_field(profile, "manaRestore").unwrap_or(0).max(0),
|
||||
cooldown_reduction: read_i32_field(profile, "cooldownReduction")
|
||||
.unwrap_or(0)
|
||||
.max(0),
|
||||
build_buffs: read_array_field(profile, "buildBuffs")
|
||||
.into_iter()
|
||||
.cloned()
|
||||
.collect(),
|
||||
});
|
||||
Some(BattleInventoryItemView {
|
||||
id,
|
||||
name,
|
||||
quantity: read_i32_field(entry, "quantity").unwrap_or(0).max(0),
|
||||
use_profile,
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn find_player_inventory_item(game_state: &Value, item_id: &str) -> Option<BattleInventoryItemView> {
|
||||
read_player_inventory_items(game_state)
|
||||
.into_iter()
|
||||
.find(|item| item.id == item_id)
|
||||
}
|
||||
|
||||
/// 旧前端一次只展示一个“推荐”战斗物品,这里继续用确定性打分,避免展示面漂移。
|
||||
fn pick_preferred_battle_inventory_item(game_state: &Value) -> Option<BattleInventoryItemView> {
|
||||
let has_cooling_skill = read_player_skill_cooldowns(game_state)
|
||||
.values()
|
||||
.any(|remaining| *remaining > 0);
|
||||
let player_hp = read_i32_field(game_state, "playerHp").unwrap_or(0);
|
||||
let player_max_hp = read_i32_field(game_state, "playerMaxHp")
|
||||
.unwrap_or(1)
|
||||
.max(1);
|
||||
let player_mana = read_i32_field(game_state, "playerMana").unwrap_or(0);
|
||||
let player_max_mana = read_i32_field(game_state, "playerMaxMana")
|
||||
.unwrap_or(1)
|
||||
.max(1);
|
||||
let hp_low = player_hp * 100 <= player_max_hp * 45;
|
||||
let mana_low = player_mana * 100 <= player_max_mana * 45;
|
||||
|
||||
read_player_inventory_items(game_state)
|
||||
.into_iter()
|
||||
.filter(|item| item.quantity > 0 && item.use_profile.is_some())
|
||||
.filter_map(|item| {
|
||||
let effect = item.use_profile.as_ref()?;
|
||||
let mut score = effect.build_buffs.len() as i32 * 8;
|
||||
score += effect.hp_restore * if hp_low { 3 } else { 1 };
|
||||
score += effect.mana_restore * if mana_low { 2 } else { 1 };
|
||||
score += effect.cooldown_reduction * if has_cooling_skill { 18 } else { 6 };
|
||||
Some((score, item))
|
||||
})
|
||||
.max_by(|left, right| {
|
||||
left.0
|
||||
.cmp(&right.0)
|
||||
.then_with(|| left.1.name.cmp(&right.1.name).reverse())
|
||||
})
|
||||
.map(|(_, item)| item)
|
||||
}
|
||||
|
||||
fn build_battle_item_summary(effect: &BattleInventoryUseProfile) -> String {
|
||||
let mut parts = Vec::new();
|
||||
if effect.hp_restore > 0 {
|
||||
parts.push(format!("回血 {}", effect.hp_restore));
|
||||
}
|
||||
if effect.mana_restore > 0 {
|
||||
parts.push(format!("回蓝 {}", effect.mana_restore));
|
||||
}
|
||||
if effect.cooldown_reduction > 0 {
|
||||
parts.push(format!("冷却 -{}", effect.cooldown_reduction));
|
||||
}
|
||||
if !effect.build_buffs.is_empty() {
|
||||
let buff_names = effect
|
||||
.build_buffs
|
||||
.iter()
|
||||
.filter_map(|buff| read_optional_string_field(buff, "name"))
|
||||
.collect::<Vec<_>>();
|
||||
if !buff_names.is_empty() {
|
||||
parts.push(format!("增益 {}", buff_names.join("、")));
|
||||
}
|
||||
}
|
||||
if parts.is_empty() {
|
||||
"立即结算一次物品效果".to_string()
|
||||
} else {
|
||||
parts.join(" / ")
|
||||
}
|
||||
}
|
||||
|
||||
fn build_static_runtime_story_option(
|
||||
function_id: &str,
|
||||
action_text: &str,
|
||||
scope: &str,
|
||||
) -> RuntimeStoryOptionView {
|
||||
RuntimeStoryOptionView {
|
||||
function_id: function_id.to_string(),
|
||||
action_text: action_text.to_string(),
|
||||
detail_text: None,
|
||||
scope: scope.to_string(),
|
||||
interaction: None,
|
||||
payload: None,
|
||||
disabled: None,
|
||||
reason: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn build_runtime_story_option_with_payload(
|
||||
function_id: &str,
|
||||
action_text: &str,
|
||||
scope: &str,
|
||||
detail_text: Option<String>,
|
||||
payload: Value,
|
||||
) -> RuntimeStoryOptionView {
|
||||
RuntimeStoryOptionView {
|
||||
detail_text,
|
||||
payload: Some(payload),
|
||||
..build_static_runtime_story_option(function_id, action_text, scope)
|
||||
}
|
||||
}
|
||||
|
||||
fn build_disabled_runtime_story_option(
|
||||
function_id: &str,
|
||||
action_text: &str,
|
||||
scope: &str,
|
||||
detail_text: Option<String>,
|
||||
reason: &str,
|
||||
payload: Option<Value>,
|
||||
) -> RuntimeStoryOptionView {
|
||||
RuntimeStoryOptionView {
|
||||
detail_text,
|
||||
payload,
|
||||
disabled: Some(true),
|
||||
reason: Some(reason.to_string()),
|
||||
..build_static_runtime_story_option(function_id, action_text, scope)
|
||||
}
|
||||
}
|
||||
|
||||
fn battle_victory_experience_reward(game_state: &Value) -> i32 {
|
||||
let hostile = read_array_field(game_state, "sceneHostileNpcs")
|
||||
.first()
|
||||
.copied()
|
||||
.or_else(|| read_field(game_state, "currentEncounter"));
|
||||
let explicit_reward = hostile
|
||||
.and_then(|entry| read_i32_field(entry, "experienceReward"))
|
||||
.unwrap_or(0)
|
||||
.max(0);
|
||||
if explicit_reward > 0 {
|
||||
return explicit_reward;
|
||||
}
|
||||
let level = hostile
|
||||
.and_then(|entry| read_field(entry, "levelProfile"))
|
||||
.and_then(|profile| read_i32_field(profile, "level"))
|
||||
.unwrap_or(1)
|
||||
.max(1);
|
||||
12 + 6 * (level - 1)
|
||||
}
|
||||
|
||||
fn battle_action_numbers(
|
||||
function_id: &str,
|
||||
) -> (i32, i32, i32, i32, i32, &'static str, &'static str) {
|
||||
match function_id {
|
||||
"battle_recover_breath" => (
|
||||
0,
|
||||
0,
|
||||
8,
|
||||
6,
|
||||
0,
|
||||
"恢复",
|
||||
"你先稳住呼吸,把状态从危险边缘拉回一点。",
|
||||
),
|
||||
"battle_use_skill" => (
|
||||
14,
|
||||
4,
|
||||
0,
|
||||
0,
|
||||
4,
|
||||
"施放技能",
|
||||
"你调动灵力打出一记更重的攻势,同时也承受了对方的反扑。",
|
||||
),
|
||||
"battle_all_in_crush" => (
|
||||
22,
|
||||
8,
|
||||
0,
|
||||
0,
|
||||
6,
|
||||
"全力压制",
|
||||
"你把这一轮节奏全部压上去,试图用最强硬的方式打穿对方防线。",
|
||||
),
|
||||
"battle_feint_step" => (
|
||||
6,
|
||||
2,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
"佯攻换位",
|
||||
"你用一次短促佯攻换开角度,虽然伤害有限,但避开了更重的反击。",
|
||||
),
|
||||
"battle_finisher_window" => (
|
||||
18,
|
||||
3,
|
||||
0,
|
||||
0,
|
||||
3,
|
||||
"抓住终结窗口",
|
||||
"你抓住破绽打出决定性的一击,战斗天平明显向你倾斜。",
|
||||
),
|
||||
"battle_guard_break" => (
|
||||
12,
|
||||
5,
|
||||
0,
|
||||
0,
|
||||
2,
|
||||
"破开防守",
|
||||
"你顶住压力破开对方防守,为后续行动争到更直接的窗口。",
|
||||
),
|
||||
"battle_probe_pressure" => (
|
||||
5,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
"试探压迫",
|
||||
"你没有贸然压上,而是用轻攻测试对方反应。",
|
||||
),
|
||||
_ => (
|
||||
10,
|
||||
4,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
"普通攻击",
|
||||
"你抓住当前窗口打出一记直接攻击,对方也立刻做出反击。",
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
fn build_battle_action_plan(
|
||||
game_state: &Value,
|
||||
request: &RuntimeStoryActionRequest,
|
||||
function_id: &str,
|
||||
) -> Result<BattleActionPlan, String> {
|
||||
if function_id == "battle_use_skill" {
|
||||
return build_skill_battle_action_plan(game_state, request);
|
||||
}
|
||||
if function_id == "inventory_use" {
|
||||
return build_inventory_use_battle_action_plan(game_state, request);
|
||||
}
|
||||
|
||||
let (damage_dealt, damage_taken, heal, mana_restore, mana_cost, action_text, result_text) =
|
||||
battle_action_numbers(function_id);
|
||||
Ok(BattleActionPlan {
|
||||
action_text: action_text.to_string(),
|
||||
result_text: result_text.to_string(),
|
||||
damage_dealt,
|
||||
damage_taken,
|
||||
heal,
|
||||
mana_restore,
|
||||
mana_cost,
|
||||
cooldown_tick_turns: 1,
|
||||
cooldown_bonus_turns: 0,
|
||||
applied_skill_cooldown: None,
|
||||
build_buffs: Vec::new(),
|
||||
consumed_item_id: None,
|
||||
})
|
||||
}
|
||||
|
||||
fn build_skill_battle_action_plan(
|
||||
game_state: &Value,
|
||||
request: &RuntimeStoryActionRequest,
|
||||
) -> Result<BattleActionPlan, String> {
|
||||
let payload = request
|
||||
.action
|
||||
.payload
|
||||
.as_ref()
|
||||
.ok_or_else(|| "battle_use_skill 缺少 skillId".to_string())?;
|
||||
let skill_id = read_optional_string_field(payload, "skillId")
|
||||
.ok_or_else(|| "battle_use_skill 缺少 skillId".to_string())?;
|
||||
let skill = find_player_skill_by_id(game_state, skill_id.as_str())
|
||||
.ok_or_else(|| format!("未找到技能:{skill_id}"))?;
|
||||
let cooldowns = read_player_skill_cooldowns(game_state);
|
||||
if cooldowns.get(skill_id.as_str()).copied().unwrap_or(0) > 0 {
|
||||
return Err(format!("{} 仍在冷却中", skill.name));
|
||||
}
|
||||
if skill.mana_cost > read_i32_field(game_state, "playerMana").unwrap_or(0) {
|
||||
return Err("当前灵力不足,无法执行这个战斗动作".to_string());
|
||||
}
|
||||
|
||||
Ok(BattleActionPlan {
|
||||
action_text: skill.name.clone(),
|
||||
result_text: format!("{} 命中了敌人,这一轮技能效果已经直接结算。", skill.name),
|
||||
damage_dealt: skill.damage.max(1),
|
||||
damage_taken: 4,
|
||||
heal: 0,
|
||||
mana_restore: 0,
|
||||
mana_cost: skill.mana_cost.max(0),
|
||||
cooldown_tick_turns: 1,
|
||||
cooldown_bonus_turns: 0,
|
||||
applied_skill_cooldown: Some((skill.id, skill.cooldown_turns.max(0))),
|
||||
build_buffs: skill.build_buffs,
|
||||
consumed_item_id: None,
|
||||
})
|
||||
}
|
||||
|
||||
fn build_inventory_use_battle_action_plan(
|
||||
game_state: &Value,
|
||||
request: &RuntimeStoryActionRequest,
|
||||
) -> Result<BattleActionPlan, String> {
|
||||
let payload = request
|
||||
.action
|
||||
.payload
|
||||
.as_ref()
|
||||
.ok_or_else(|| "inventory_use 缺少 itemId".to_string())?;
|
||||
let item_id = read_optional_string_field(payload, "itemId")
|
||||
.ok_or_else(|| "inventory_use 缺少 itemId".to_string())?;
|
||||
let item = find_player_inventory_item(game_state, item_id.as_str())
|
||||
.ok_or_else(|| "未找到可用于战斗结算的物品".to_string())?;
|
||||
if item.quantity <= 0 {
|
||||
return Err("未找到可用于战斗结算的物品".to_string());
|
||||
}
|
||||
if item.use_profile.is_none() {
|
||||
return Err(format!("{} 当前没有可直接结算的战斗效果", item.name));
|
||||
}
|
||||
let effect = item.use_profile.expect("use_profile should exist");
|
||||
if effect.hp_restore <= 0
|
||||
&& effect.mana_restore <= 0
|
||||
&& effect.cooldown_reduction <= 0
|
||||
&& effect.build_buffs.is_empty()
|
||||
{
|
||||
return Err(format!("{} 当前没有可直接结算的战斗效果", item.name));
|
||||
}
|
||||
|
||||
Ok(BattleActionPlan {
|
||||
action_text: format!("使用{}", item.name),
|
||||
result_text: format!("你立刻用下{},当前回合的物品效果已经生效。", item.name),
|
||||
damage_dealt: 0,
|
||||
damage_taken: 8,
|
||||
heal: effect.hp_restore.max(0),
|
||||
mana_restore: effect.mana_restore.max(0),
|
||||
mana_cost: 0,
|
||||
cooldown_tick_turns: 1,
|
||||
cooldown_bonus_turns: effect.cooldown_reduction.max(0),
|
||||
applied_skill_cooldown: None,
|
||||
build_buffs: effect.build_buffs,
|
||||
consumed_item_id: Some(item.id),
|
||||
})
|
||||
}
|
||||
|
||||
fn battle_action_toast(
|
||||
function_id: &str,
|
||||
request: &RuntimeStoryActionRequest,
|
||||
) -> Option<String> {
|
||||
if function_id != "inventory_use" {
|
||||
return None;
|
||||
}
|
||||
request
|
||||
.action
|
||||
.payload
|
||||
.as_ref()
|
||||
.and_then(|payload| read_optional_string_field(payload, "itemId"))
|
||||
.map(|_| "Build 增益已写回当前快照".to_string())
|
||||
}
|
||||
323
server-rs/crates/module-runtime-story-compat/src/core.rs
Normal file
323
server-rs/crates/module-runtime-story-compat/src/core.rs
Normal file
@@ -0,0 +1,323 @@
|
||||
use serde_json::{Map, Value, json};
|
||||
use shared_kernel::format_rfc3339;
|
||||
use time::OffsetDateTime;
|
||||
|
||||
/// Runtime story compat 的纯 JSON 快照工具层。
|
||||
///
|
||||
/// 这里不允许引入 HTTP、AppState 或持久化依赖,保证后续 battle/forge/npc/quest
|
||||
/// 规则迁入独立 crate 时可以继续复用同一批状态读写函数。
|
||||
pub fn clear_encounter_state(game_state: &mut Value) {
|
||||
clear_encounter_only(game_state);
|
||||
write_bool_field(game_state, "inBattle", false);
|
||||
write_bool_field(game_state, "npcInteractionActive", false);
|
||||
write_null_field(game_state, "currentNpcBattleMode");
|
||||
}
|
||||
|
||||
pub fn clear_encounter_only(game_state: &mut Value) {
|
||||
write_null_field(game_state, "currentEncounter");
|
||||
let root = ensure_json_object(game_state);
|
||||
root.insert("sceneHostileNpcs".to_string(), Value::Array(Vec::new()));
|
||||
}
|
||||
|
||||
pub fn append_story_history(game_state: &mut Value, action_text: &str, result_text: &str) {
|
||||
let root = ensure_json_object(game_state);
|
||||
let story_history = root
|
||||
.entry("storyHistory".to_string())
|
||||
.or_insert_with(|| Value::Array(Vec::new()));
|
||||
if !story_history.is_array() {
|
||||
*story_history = Value::Array(Vec::new());
|
||||
}
|
||||
let entries = story_history
|
||||
.as_array_mut()
|
||||
.expect("storyHistory should be array");
|
||||
entries.push(json!({
|
||||
"text": action_text,
|
||||
"historyRole": "action",
|
||||
}));
|
||||
entries.push(json!({
|
||||
"text": result_text,
|
||||
"historyRole": "result",
|
||||
}));
|
||||
}
|
||||
|
||||
pub fn increment_runtime_stat(game_state: &mut Value, key: &str, delta: i32) {
|
||||
let root = ensure_json_object(game_state);
|
||||
let stats = root
|
||||
.entry("runtimeStats".to_string())
|
||||
.or_insert_with(|| Value::Object(Map::new()));
|
||||
if !stats.is_object() {
|
||||
*stats = Value::Object(Map::new());
|
||||
}
|
||||
let stats = stats
|
||||
.as_object_mut()
|
||||
.expect("runtimeStats should be object");
|
||||
let previous = stats
|
||||
.get(key)
|
||||
.and_then(Value::as_i64)
|
||||
.and_then(|value| i32::try_from(value).ok())
|
||||
.unwrap_or(0);
|
||||
stats.insert(key.to_string(), json!((previous + delta).max(0)));
|
||||
}
|
||||
|
||||
pub fn add_player_currency(game_state: &mut Value, delta: i32) {
|
||||
let previous = read_i32_field(game_state, "playerCurrency").unwrap_or(0);
|
||||
write_i32_field(
|
||||
game_state,
|
||||
"playerCurrency",
|
||||
previous.saturating_add(delta.max(0)),
|
||||
);
|
||||
}
|
||||
|
||||
pub fn add_player_inventory_items(game_state: &mut Value, additions: Vec<Value>) {
|
||||
if additions.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
let root = ensure_json_object(game_state);
|
||||
let inventory = root
|
||||
.entry("playerInventory".to_string())
|
||||
.or_insert_with(|| Value::Array(Vec::new()));
|
||||
if !inventory.is_array() {
|
||||
*inventory = Value::Array(Vec::new());
|
||||
}
|
||||
let items = inventory
|
||||
.as_array_mut()
|
||||
.expect("playerInventory should be array");
|
||||
items.extend(additions);
|
||||
}
|
||||
|
||||
pub fn grant_player_progression_experience(game_state: &mut Value, amount: i32, source: &str) {
|
||||
if amount <= 0 {
|
||||
return;
|
||||
}
|
||||
|
||||
let root = ensure_json_object(game_state);
|
||||
let progression = root
|
||||
.entry("playerProgression".to_string())
|
||||
.or_insert_with(|| Value::Object(Map::new()));
|
||||
if !progression.is_object() {
|
||||
*progression = Value::Object(Map::new());
|
||||
}
|
||||
let progression = progression
|
||||
.as_object_mut()
|
||||
.expect("playerProgression should be object");
|
||||
let previous_total_xp = progression
|
||||
.get("totalXp")
|
||||
.and_then(Value::as_i64)
|
||||
.and_then(|value| i32::try_from(value).ok())
|
||||
.unwrap_or(0)
|
||||
.max(0);
|
||||
let next_total_xp = previous_total_xp.saturating_add(amount);
|
||||
let level = resolve_progression_level(next_total_xp);
|
||||
let current_level_xp = next_total_xp.saturating_sub(cumulative_xp_required(level));
|
||||
let xp_to_next_level = if level >= MAX_PLAYER_LEVEL {
|
||||
0
|
||||
} else {
|
||||
xp_to_next_level_for(level)
|
||||
};
|
||||
|
||||
progression.insert("level".to_string(), json!(level));
|
||||
progression.insert("currentLevelXp".to_string(), json!(current_level_xp.max(0)));
|
||||
progression.insert("totalXp".to_string(), json!(next_total_xp));
|
||||
progression.insert("xpToNextLevel".to_string(), json!(xp_to_next_level.max(0)));
|
||||
progression.insert("pendingLevelUps".to_string(), json!(0));
|
||||
progression.insert(
|
||||
"lastGrantedSource".to_string(),
|
||||
Value::String(source.to_string()),
|
||||
);
|
||||
}
|
||||
|
||||
pub const MAX_PLAYER_LEVEL: i32 = 20;
|
||||
|
||||
pub fn xp_to_next_level_for(level: i32) -> i32 {
|
||||
if level >= MAX_PLAYER_LEVEL {
|
||||
0
|
||||
} else {
|
||||
let scale = (level - 1).max(0);
|
||||
60 + 20 * scale + 8 * scale * scale
|
||||
}
|
||||
}
|
||||
|
||||
pub fn cumulative_xp_required(level: i32) -> i32 {
|
||||
let mut total = 0;
|
||||
let capped_level = level.clamp(1, MAX_PLAYER_LEVEL);
|
||||
for current_level in 1..capped_level {
|
||||
total += xp_to_next_level_for(current_level);
|
||||
}
|
||||
total
|
||||
}
|
||||
|
||||
pub fn resolve_progression_level(total_xp: i32) -> i32 {
|
||||
let normalized_total_xp = total_xp.max(0);
|
||||
let mut resolved_level = 1;
|
||||
for level in 2..=MAX_PLAYER_LEVEL {
|
||||
if normalized_total_xp < cumulative_xp_required(level) {
|
||||
break;
|
||||
}
|
||||
resolved_level = level;
|
||||
}
|
||||
resolved_level
|
||||
}
|
||||
|
||||
pub fn append_active_build_buffs(game_state: &mut Value, additions: Vec<Value>) {
|
||||
if additions.is_empty() {
|
||||
return;
|
||||
}
|
||||
let root = ensure_json_object(game_state);
|
||||
let buffs = root
|
||||
.entry("activeBuildBuffs".to_string())
|
||||
.or_insert_with(|| Value::Array(Vec::new()));
|
||||
if !buffs.is_array() {
|
||||
*buffs = Value::Array(Vec::new());
|
||||
}
|
||||
buffs
|
||||
.as_array_mut()
|
||||
.expect("activeBuildBuffs should be array")
|
||||
.extend(additions);
|
||||
}
|
||||
|
||||
pub fn remove_player_inventory_item(game_state: &mut Value, item_id: &str, quantity: i32) {
|
||||
if quantity <= 0 {
|
||||
return;
|
||||
}
|
||||
let root = ensure_json_object(game_state);
|
||||
let inventory = root
|
||||
.entry("playerInventory".to_string())
|
||||
.or_insert_with(|| Value::Array(Vec::new()));
|
||||
if !inventory.is_array() {
|
||||
*inventory = Value::Array(Vec::new());
|
||||
}
|
||||
let items = inventory
|
||||
.as_array_mut()
|
||||
.expect("playerInventory should be array");
|
||||
let Some(index) = items
|
||||
.iter()
|
||||
.position(|entry| read_optional_string_field(entry, "id").as_deref() == Some(item_id))
|
||||
else {
|
||||
return;
|
||||
};
|
||||
let current_quantity = read_i32_field(&items[index], "quantity")
|
||||
.unwrap_or(0)
|
||||
.max(0);
|
||||
let next_quantity = current_quantity - quantity;
|
||||
if next_quantity <= 0 {
|
||||
items.remove(index);
|
||||
return;
|
||||
}
|
||||
if let Some(item) = items[index].as_object_mut() {
|
||||
item.insert("quantity".to_string(), json!(next_quantity));
|
||||
}
|
||||
}
|
||||
|
||||
pub fn write_current_encounter_i32_field(game_state: &mut Value, key: &str, value: i32) {
|
||||
let root = ensure_json_object(game_state);
|
||||
let Some(encounter) = root.get_mut("currentEncounter") else {
|
||||
return;
|
||||
};
|
||||
if let Some(encounter) = encounter.as_object_mut() {
|
||||
encounter.insert(key.to_string(), json!(value));
|
||||
}
|
||||
}
|
||||
|
||||
pub fn write_first_hostile_npc_i32_field(game_state: &mut Value, key: &str, value: i32) {
|
||||
let root = ensure_json_object(game_state);
|
||||
let Some(hostiles) = root.get_mut("sceneHostileNpcs") else {
|
||||
return;
|
||||
};
|
||||
let Some(first) = hostiles.as_array_mut().and_then(|items| items.first_mut()) else {
|
||||
return;
|
||||
};
|
||||
if let Some(first) = first.as_object_mut() {
|
||||
first.insert(key.to_string(), json!(value));
|
||||
}
|
||||
}
|
||||
|
||||
pub fn first_hostile_npc_string_field(game_state: &Value, key: &str) -> Option<String> {
|
||||
read_array_field(game_state, "sceneHostileNpcs")
|
||||
.first()
|
||||
.and_then(|target| read_optional_string_field(target, key))
|
||||
}
|
||||
|
||||
pub fn read_runtime_session_id(game_state: &Value) -> Option<String> {
|
||||
read_optional_string_field(game_state, "runtimeSessionId")
|
||||
}
|
||||
|
||||
pub fn read_field<'a>(value: &'a Value, key: &str) -> Option<&'a Value> {
|
||||
value.as_object()?.get(key)
|
||||
}
|
||||
|
||||
pub fn read_object_field<'a>(value: &'a Value, key: &str) -> Option<&'a Value> {
|
||||
let field = read_field(value, key)?;
|
||||
field.is_object().then_some(field)
|
||||
}
|
||||
|
||||
pub fn read_array_field<'a>(value: &'a Value, key: &str) -> Vec<&'a Value> {
|
||||
read_field(value, key)
|
||||
.and_then(Value::as_array)
|
||||
.map(|items| items.iter().collect())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
pub fn read_required_string_field(value: &Value, key: &str) -> Option<String> {
|
||||
normalize_required_string(read_field(value, key)?.as_str()?)
|
||||
}
|
||||
|
||||
pub fn read_optional_string_field(value: &Value, key: &str) -> Option<String> {
|
||||
normalize_optional_string(read_field(value, key).and_then(Value::as_str))
|
||||
}
|
||||
|
||||
pub fn read_bool_field(value: &Value, key: &str) -> Option<bool> {
|
||||
read_field(value, key).and_then(Value::as_bool)
|
||||
}
|
||||
|
||||
pub fn read_i32_field(value: &Value, key: &str) -> Option<i32> {
|
||||
read_field(value, key)
|
||||
.and_then(Value::as_i64)
|
||||
.and_then(|number| i32::try_from(number).ok())
|
||||
}
|
||||
|
||||
pub fn read_u32_field(value: &Value, key: &str) -> Option<u32> {
|
||||
read_field(value, key)
|
||||
.and_then(Value::as_u64)
|
||||
.and_then(|number| u32::try_from(number).ok())
|
||||
}
|
||||
|
||||
pub fn write_i32_field(value: &mut Value, key: &str, field_value: i32) {
|
||||
ensure_json_object(value).insert(key.to_string(), json!(field_value));
|
||||
}
|
||||
|
||||
pub fn write_u32_field(value: &mut Value, key: &str, field_value: u32) {
|
||||
ensure_json_object(value).insert(key.to_string(), json!(field_value));
|
||||
}
|
||||
|
||||
pub fn write_bool_field(value: &mut Value, key: &str, field_value: bool) {
|
||||
ensure_json_object(value).insert(key.to_string(), Value::Bool(field_value));
|
||||
}
|
||||
|
||||
pub fn write_string_field(value: &mut Value, key: &str, field_value: &str) {
|
||||
ensure_json_object(value).insert(key.to_string(), Value::String(field_value.to_string()));
|
||||
}
|
||||
|
||||
pub fn write_null_field(value: &mut Value, key: &str) {
|
||||
ensure_json_object(value).insert(key.to_string(), Value::Null);
|
||||
}
|
||||
|
||||
pub fn ensure_json_object(value: &mut Value) -> &mut Map<String, Value> {
|
||||
if !value.is_object() {
|
||||
*value = Value::Object(Map::new());
|
||||
}
|
||||
value.as_object_mut().expect("value should be object")
|
||||
}
|
||||
|
||||
pub fn normalize_required_string(value: &str) -> Option<String> {
|
||||
let trimmed = value.trim();
|
||||
(!trimmed.is_empty()).then(|| trimmed.to_string())
|
||||
}
|
||||
|
||||
pub fn normalize_optional_string(value: Option<&str>) -> Option<String> {
|
||||
value.and_then(normalize_required_string)
|
||||
}
|
||||
|
||||
pub fn format_now_rfc3339() -> String {
|
||||
format_rfc3339(OffsetDateTime::now_utc()).unwrap_or_else(|_| "1970-01-01T00:00:00Z".to_string())
|
||||
}
|
||||
426
server-rs/crates/module-runtime-story-compat/src/forge.rs
Normal file
426
server-rs/crates/module-runtime-story-compat/src/forge.rs
Normal file
@@ -0,0 +1,426 @@
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::{
|
||||
equipment_slot_label, item_rarity_key, read_array_field, read_field, read_i32_field,
|
||||
read_inventory_item_name, read_optional_string_field, read_u32_field,
|
||||
remove_inventory_item_from_list, resolve_equipment_slot_for_item,
|
||||
};
|
||||
|
||||
/// 这批定义只服务 runtime story compat 的确定性锻造链。
|
||||
///
|
||||
/// 当前仍然保持旧快照态结算口径,不引入 HTTP / AppState / 持久化边界。
|
||||
pub(crate) struct ForgeRequirementDefinition {
|
||||
pub(crate) quantity: i32,
|
||||
pub(crate) matcher: ForgeRequirementMatcher,
|
||||
}
|
||||
|
||||
pub(crate) enum ForgeRequirementMatcher {
|
||||
Named(&'static str),
|
||||
AnyMaterial,
|
||||
}
|
||||
|
||||
pub(crate) struct ForgeRecipeDefinition {
|
||||
pub(crate) id: &'static str,
|
||||
pub(crate) name: &'static str,
|
||||
pub(crate) currency_cost: i32,
|
||||
pub(crate) requirements: Vec<ForgeRequirementDefinition>,
|
||||
}
|
||||
|
||||
pub(crate) struct ReforgeCostDefinition {
|
||||
pub(crate) currency_cost: i32,
|
||||
pub(crate) requirements: Vec<ForgeRequirementDefinition>,
|
||||
}
|
||||
|
||||
pub(crate) fn forge_recipe_definition(recipe_id: &str) -> Option<ForgeRecipeDefinition> {
|
||||
match recipe_id {
|
||||
"synthesis-refined-ingot" => Some(ForgeRecipeDefinition {
|
||||
id: "synthesis-refined-ingot",
|
||||
name: "压炼锭材",
|
||||
currency_cost: 18,
|
||||
requirements: vec![ForgeRequirementDefinition {
|
||||
quantity: 3,
|
||||
matcher: ForgeRequirementMatcher::AnyMaterial,
|
||||
}],
|
||||
}),
|
||||
"forge-duelist-blade" => Some(ForgeRecipeDefinition {
|
||||
id: "forge-duelist-blade",
|
||||
name: "锻造 百炼追风剑",
|
||||
currency_cost: 72,
|
||||
requirements: vec![
|
||||
ForgeRequirementDefinition {
|
||||
quantity: 2,
|
||||
matcher: ForgeRequirementMatcher::Named("精炼锭材"),
|
||||
},
|
||||
ForgeRequirementDefinition {
|
||||
quantity: 1,
|
||||
matcher: ForgeRequirementMatcher::Named("快剑精粹"),
|
||||
},
|
||||
],
|
||||
}),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn reforge_cost_definition(slot_id: Option<&str>) -> ReforgeCostDefinition {
|
||||
if slot_id == Some("relic") {
|
||||
return ReforgeCostDefinition {
|
||||
currency_cost: 52,
|
||||
requirements: vec![ForgeRequirementDefinition {
|
||||
quantity: 1,
|
||||
matcher: ForgeRequirementMatcher::Named("凝光纱"),
|
||||
}],
|
||||
};
|
||||
}
|
||||
ReforgeCostDefinition {
|
||||
currency_cost: 46,
|
||||
requirements: vec![ForgeRequirementDefinition {
|
||||
quantity: 1,
|
||||
matcher: ForgeRequirementMatcher::Named("精炼锭材"),
|
||||
}],
|
||||
}
|
||||
}
|
||||
|
||||
fn forge_requirement_matches(item: &Value, requirement: &ForgeRequirementDefinition) -> bool {
|
||||
match requirement.matcher {
|
||||
ForgeRequirementMatcher::Named(name) => {
|
||||
read_optional_string_field(item, "name").as_deref() == Some(name)
|
||||
}
|
||||
ForgeRequirementMatcher::AnyMaterial => {
|
||||
read_array_field(item, "tags")
|
||||
.into_iter()
|
||||
.filter_map(Value::as_str)
|
||||
.any(|tag| tag == "material")
|
||||
|| read_optional_string_field(item, "category")
|
||||
.is_some_and(|category| category.contains("材料"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn apply_forge_requirements_if_possible(
|
||||
inventory: &[Value],
|
||||
requirements: &[ForgeRequirementDefinition],
|
||||
) -> Option<Vec<Value>> {
|
||||
let mut next_inventory = inventory.to_vec();
|
||||
for requirement in requirements {
|
||||
let mut remaining = requirement.quantity.max(0);
|
||||
let snapshot = next_inventory.clone();
|
||||
for item in snapshot {
|
||||
if remaining <= 0 {
|
||||
break;
|
||||
}
|
||||
if !forge_requirement_matches(&item, requirement) {
|
||||
continue;
|
||||
}
|
||||
let item_id = read_optional_string_field(&item, "id")?;
|
||||
let item_quantity = read_i32_field(&item, "quantity").unwrap_or(0).max(0);
|
||||
let consumed = remaining.min(item_quantity);
|
||||
next_inventory =
|
||||
remove_inventory_item_from_list(next_inventory, item_id.as_str(), consumed);
|
||||
remaining -= consumed;
|
||||
}
|
||||
if remaining > 0 {
|
||||
return None;
|
||||
}
|
||||
}
|
||||
Some(next_inventory)
|
||||
}
|
||||
|
||||
pub fn build_runtime_material_item(
|
||||
game_state: &Value,
|
||||
name: &str,
|
||||
quantity: i32,
|
||||
tags: &[&str],
|
||||
rarity: &str,
|
||||
) -> Value {
|
||||
let mut all_tags = vec!["material".to_string()];
|
||||
all_tags.extend(tags.iter().map(|tag| (*tag).to_string()));
|
||||
json!({
|
||||
"id": generate_runtime_item_id(game_state, format!("forge-material:{name}").as_str()),
|
||||
"category": "材料",
|
||||
"name": name,
|
||||
"quantity": quantity.max(1),
|
||||
"rarity": rarity,
|
||||
"tags": all_tags,
|
||||
"buildProfile": {
|
||||
"role": "工巧",
|
||||
"tags": tags,
|
||||
"synergy": tags,
|
||||
"forgeRank": 0
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
pub fn build_runtime_equipment_item(
|
||||
game_state: &Value,
|
||||
name: &str,
|
||||
slot_id: &str,
|
||||
rarity: &str,
|
||||
description: &str,
|
||||
role: &str,
|
||||
tags: &[&str],
|
||||
synergy: &[&str],
|
||||
stat_profile: Value,
|
||||
) -> Value {
|
||||
let slot_tag = match slot_id {
|
||||
"weapon" => "weapon",
|
||||
"armor" => "armor",
|
||||
_ => "relic",
|
||||
};
|
||||
let mut next_tags = vec![slot_tag.to_string()];
|
||||
next_tags.extend(tags.iter().map(|tag| (*tag).to_string()));
|
||||
json!({
|
||||
"id": generate_runtime_item_id(game_state, format!("forge-equip:{name}").as_str()),
|
||||
"category": equipment_slot_label(slot_id),
|
||||
"name": name,
|
||||
"description": description,
|
||||
"quantity": 1,
|
||||
"rarity": rarity,
|
||||
"tags": next_tags,
|
||||
"equipmentSlotId": slot_id,
|
||||
"statProfile": stat_profile,
|
||||
"buildProfile": {
|
||||
"role": role,
|
||||
"tags": tags,
|
||||
"synergy": synergy,
|
||||
"forgeRank": 1
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn build_forge_recipe_result_item(
|
||||
game_state: &Value,
|
||||
recipe_id: &str,
|
||||
_world_type: Option<&str>,
|
||||
) -> Value {
|
||||
match recipe_id {
|
||||
"synthesis-refined-ingot" => {
|
||||
build_runtime_material_item(game_state, "精炼锭材", 1, &["工巧", "守御"], "rare")
|
||||
}
|
||||
"forge-duelist-blade" => build_runtime_equipment_item(
|
||||
game_state,
|
||||
"百炼追风剑",
|
||||
"weapon",
|
||||
"epic",
|
||||
"为快剑与追身构筑准备的锻造兵刃。",
|
||||
"快剑",
|
||||
&["快剑", "突进", "追击"],
|
||||
&["快剑", "突进", "追击"],
|
||||
json!({
|
||||
"maxManaBonus": 10,
|
||||
"outgoingDamageBonus": 0.20
|
||||
}),
|
||||
),
|
||||
_ => build_runtime_material_item(game_state, "临时锻造产物", 1, &["工巧"], "common"),
|
||||
}
|
||||
}
|
||||
|
||||
fn build_tag_essence_item(game_state: &Value, tag: &str) -> Value {
|
||||
build_runtime_material_item(
|
||||
game_state,
|
||||
format!("{tag}精粹").as_str(),
|
||||
1,
|
||||
&[tag, "工巧"],
|
||||
"rare",
|
||||
)
|
||||
}
|
||||
|
||||
pub(crate) fn build_dismantle_outputs(game_state: &Value, item: &Value) -> Option<Vec<Value>> {
|
||||
let slot_id = resolve_equipment_slot_for_item(item);
|
||||
if slot_id.is_none() && read_field(item, "buildProfile").is_none() {
|
||||
return None;
|
||||
}
|
||||
let rarity_scale = match item_rarity_key(item).as_str() {
|
||||
"legendary" => 5,
|
||||
"epic" => 4,
|
||||
"rare" => 3,
|
||||
"uncommon" => 2,
|
||||
_ => 1,
|
||||
};
|
||||
let mut outputs = Vec::new();
|
||||
match slot_id {
|
||||
Some("weapon") => outputs.push(build_runtime_material_item(
|
||||
game_state,
|
||||
"武器残片",
|
||||
rarity_scale,
|
||||
&["工巧", "重击"],
|
||||
"uncommon",
|
||||
)),
|
||||
Some("armor") => outputs.push(build_runtime_material_item(
|
||||
game_state,
|
||||
"甲片",
|
||||
rarity_scale,
|
||||
&["工巧", "守御"],
|
||||
"uncommon",
|
||||
)),
|
||||
Some("relic") => outputs.push(build_runtime_material_item(
|
||||
game_state,
|
||||
"灵饰碎片",
|
||||
rarity_scale,
|
||||
&["工巧", "法力"],
|
||||
"uncommon",
|
||||
)),
|
||||
_ => outputs.push(build_runtime_material_item(
|
||||
game_state,
|
||||
"零散材料",
|
||||
((rarity_scale + 1) / 2).max(1),
|
||||
&["工巧"],
|
||||
"uncommon",
|
||||
)),
|
||||
}
|
||||
|
||||
let mut build_tags = read_field(item, "buildProfile")
|
||||
.map(|profile| {
|
||||
let mut tags = read_array_field(profile, "tags")
|
||||
.into_iter()
|
||||
.filter_map(Value::as_str)
|
||||
.map(str::to_string)
|
||||
.collect::<Vec<_>>();
|
||||
if let Some(role) = read_optional_string_field(profile, "role") {
|
||||
tags.push(role);
|
||||
}
|
||||
tags
|
||||
})
|
||||
.unwrap_or_default();
|
||||
build_tags.sort();
|
||||
build_tags.dedup();
|
||||
let tag_limit = if item_rarity_key(item) == "legendary" {
|
||||
3
|
||||
} else {
|
||||
2
|
||||
};
|
||||
for tag in build_tags.into_iter().take(tag_limit) {
|
||||
outputs.push(build_tag_essence_item(game_state, tag.as_str()));
|
||||
}
|
||||
Some(outputs)
|
||||
}
|
||||
|
||||
pub(crate) fn build_reforged_item(game_state: &Value, item: &Value) -> Option<Value> {
|
||||
let slot_id = resolve_equipment_slot_for_item(item)?;
|
||||
let build_profile = read_field(item, "buildProfile")?;
|
||||
let mut next_tags = read_array_field(build_profile, "tags")
|
||||
.into_iter()
|
||||
.filter_map(Value::as_str)
|
||||
.map(str::to_string)
|
||||
.collect::<Vec<_>>();
|
||||
let extra_tag = match slot_id {
|
||||
"weapon" => "追击",
|
||||
"armor" => "护体",
|
||||
_ => "法力",
|
||||
};
|
||||
next_tags.push(extra_tag.to_string());
|
||||
next_tags.sort();
|
||||
next_tags.dedup();
|
||||
next_tags.truncate(3);
|
||||
|
||||
let source_name = read_inventory_item_name(item);
|
||||
let next_name = if source_name.contains('·') && source_name.contains("重铸") {
|
||||
source_name.clone()
|
||||
} else {
|
||||
format!("{source_name}·重铸")
|
||||
};
|
||||
let stat_profile = read_field(item, "statProfile");
|
||||
let outgoing_damage_bonus = stat_profile
|
||||
.and_then(|profile| read_field(profile, "outgoingDamageBonus"))
|
||||
.and_then(Value::as_f64)
|
||||
.unwrap_or(0.0);
|
||||
let incoming_damage_multiplier = stat_profile
|
||||
.and_then(|profile| read_field(profile, "incomingDamageMultiplier"))
|
||||
.and_then(Value::as_f64);
|
||||
let current_forge_rank = read_i32_field(build_profile, "forgeRank").unwrap_or(0);
|
||||
let mut tags = read_array_field(item, "tags")
|
||||
.into_iter()
|
||||
.filter_map(Value::as_str)
|
||||
.map(str::to_string)
|
||||
.collect::<Vec<_>>();
|
||||
tags.sort();
|
||||
tags.dedup();
|
||||
|
||||
Some(json!({
|
||||
"id": generate_runtime_item_id(game_state, format!("reforge:{source_name}").as_str()),
|
||||
"category": read_optional_string_field(item, "category")
|
||||
.unwrap_or_else(|| equipment_slot_label(slot_id).to_string()),
|
||||
"name": next_name,
|
||||
"description": read_optional_string_field(item, "description"),
|
||||
"quantity": 1,
|
||||
"rarity": item_rarity_key(item),
|
||||
"tags": tags,
|
||||
"equipmentSlotId": slot_id,
|
||||
"statProfile": {
|
||||
"maxHpBonus": stat_profile
|
||||
.and_then(|profile| read_i32_field(profile, "maxHpBonus"))
|
||||
.unwrap_or(0) + if slot_id == "armor" { 10 } else { 4 },
|
||||
"maxManaBonus": stat_profile
|
||||
.and_then(|profile| read_i32_field(profile, "maxManaBonus"))
|
||||
.unwrap_or(0) + if slot_id == "relic" { 10 } else { 4 },
|
||||
"outgoingDamageBonus": ((outgoing_damage_bonus + 0.03) * 1000.0).round() / 1000.0,
|
||||
"incomingDamageMultiplier": if let Some(multiplier) = incoming_damage_multiplier {
|
||||
(((multiplier - 0.03).max(0.72)) * 1000.0).round() / 1000.0
|
||||
} else if slot_id == "armor" {
|
||||
0.94
|
||||
} else {
|
||||
0.97
|
||||
}
|
||||
},
|
||||
"buildProfile": {
|
||||
"role": read_optional_string_field(build_profile, "role"),
|
||||
"tags": next_tags,
|
||||
"synergy": read_array_field(build_profile, "tags")
|
||||
.into_iter()
|
||||
.filter_map(Value::as_str)
|
||||
.map(str::to_string)
|
||||
.chain(std::iter::once(extra_tag.to_string()))
|
||||
.collect::<std::collections::BTreeSet<_>>()
|
||||
.into_iter()
|
||||
.collect::<Vec<_>>(),
|
||||
"forgeRank": current_forge_rank + 1
|
||||
}
|
||||
}))
|
||||
}
|
||||
|
||||
pub(crate) fn build_forge_success_text(
|
||||
action: &str,
|
||||
recipe_name: Option<&str>,
|
||||
source_item_name: Option<&str>,
|
||||
created_item_name: Option<&str>,
|
||||
output_names: &[String],
|
||||
currency_text: Option<String>,
|
||||
) -> String {
|
||||
match action {
|
||||
"craft" => format!(
|
||||
"你在工坊中完成了{},获得了{}{}。",
|
||||
recipe_name.unwrap_or("目标配方"),
|
||||
created_item_name.unwrap_or("目标物品"),
|
||||
currency_text
|
||||
.map(|text| format!(",并支付了{text}"))
|
||||
.unwrap_or_default()
|
||||
),
|
||||
"reforge" => format!(
|
||||
"你消耗材料重新淬炼了{},最终得到{}{}。",
|
||||
source_item_name.unwrap_or("目标物品"),
|
||||
created_item_name.unwrap_or("重铸产物"),
|
||||
currency_text
|
||||
.map(|text| format!(",并支付了{text}"))
|
||||
.unwrap_or_default()
|
||||
),
|
||||
_ => format!(
|
||||
"你拆解了{},回收出{}。",
|
||||
source_item_name.unwrap_or("目标物品"),
|
||||
output_names.join("、")
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn format_currency_text(value: i32, world_type: Option<&str>) -> String {
|
||||
let currency_name = match world_type {
|
||||
Some("XIANXIA") => "灵石",
|
||||
Some("WUXIA") => "铜钱",
|
||||
_ => "钱币",
|
||||
};
|
||||
format!("{value} {currency_name}")
|
||||
}
|
||||
|
||||
fn generate_runtime_item_id(game_state: &Value, prefix: &str) -> String {
|
||||
let version = read_u32_field(game_state, "runtimeActionVersion").unwrap_or(0);
|
||||
let inventory_len = read_array_field(game_state, "playerInventory").len();
|
||||
format!("{prefix}:{version}:{inventory_len}")
|
||||
}
|
||||
@@ -0,0 +1,217 @@
|
||||
use serde_json::Value;
|
||||
|
||||
use shared_contracts::runtime_story::RuntimeStoryActionRequest;
|
||||
|
||||
use crate::{
|
||||
StoryResolution, add_inventory_items_to_list, build_current_build_toast, current_world_type,
|
||||
ensure_inventory_action_available, find_player_inventory_entry, read_i32_field,
|
||||
read_inventory_item_name, read_optional_string_field, read_player_inventory_values,
|
||||
remove_inventory_item_from_list, resolve_action_text, resolve_equipment_slot_for_item,
|
||||
write_i32_field, write_player_inventory_values,
|
||||
};
|
||||
|
||||
use super::forge::{
|
||||
apply_forge_requirements_if_possible, build_dismantle_outputs, build_forge_recipe_result_item,
|
||||
build_forge_success_text, build_reforged_item, forge_recipe_definition, format_currency_text,
|
||||
reforge_cost_definition,
|
||||
};
|
||||
|
||||
/// 锻造动作编排已经不再依赖 `api-server` 的 HTTP 边界。
|
||||
///
|
||||
/// 这里继续沿用 compat 快照态结算,后续可直接被 `api-server` 外壳或真相态桥接层复用。
|
||||
pub fn resolve_forge_craft_action(
|
||||
game_state: &mut Value,
|
||||
request: &RuntimeStoryActionRequest,
|
||||
) -> Result<StoryResolution, String> {
|
||||
ensure_inventory_action_available(
|
||||
game_state,
|
||||
"缺少玩家角色,无法执行锻造配方。",
|
||||
"战斗中无法使用工坊。",
|
||||
)?;
|
||||
let recipe_id = request
|
||||
.action
|
||||
.payload
|
||||
.as_ref()
|
||||
.and_then(|payload| read_optional_string_field(payload, "recipeId"))
|
||||
.or_else(|| request.action.target_id.clone())
|
||||
.ok_or_else(|| "forge_craft 缺少 recipeId".to_string())?;
|
||||
let recipe = forge_recipe_definition(recipe_id.as_str())
|
||||
.ok_or_else(|| "未找到目标锻造配方。".to_string())?;
|
||||
let player_currency = read_i32_field(game_state, "playerCurrency").unwrap_or(0);
|
||||
if player_currency < recipe.currency_cost {
|
||||
return Err(format!("{} 当前材料或货币不足。", recipe.name));
|
||||
}
|
||||
let current_inventory = read_player_inventory_values(game_state);
|
||||
let consumed_inventory = apply_forge_requirements_if_possible(
|
||||
current_inventory.as_slice(),
|
||||
recipe.requirements.as_slice(),
|
||||
)
|
||||
.ok_or_else(|| format!("{} 当前材料或货币不足。", recipe.name))?;
|
||||
let created_item = build_forge_recipe_result_item(
|
||||
game_state,
|
||||
recipe.id,
|
||||
current_world_type(game_state).as_deref(),
|
||||
);
|
||||
let next_inventory =
|
||||
add_inventory_items_to_list(consumed_inventory, vec![created_item.clone()]);
|
||||
|
||||
write_i32_field(
|
||||
game_state,
|
||||
"playerCurrency",
|
||||
player_currency.saturating_sub(recipe.currency_cost),
|
||||
);
|
||||
write_player_inventory_values(game_state, next_inventory);
|
||||
|
||||
Ok(StoryResolution {
|
||||
action_text: resolve_action_text(
|
||||
&format!("制作{}", read_inventory_item_name(&created_item)),
|
||||
request,
|
||||
),
|
||||
result_text: build_forge_success_text(
|
||||
"craft",
|
||||
Some(recipe.name),
|
||||
None,
|
||||
Some(read_inventory_item_name(&created_item).as_str()),
|
||||
&[],
|
||||
Some(format_currency_text(
|
||||
recipe.currency_cost,
|
||||
current_world_type(game_state).as_deref(),
|
||||
)),
|
||||
),
|
||||
story_text: None,
|
||||
presentation_options: None,
|
||||
saved_current_story: None,
|
||||
patches: Vec::new(),
|
||||
battle: None,
|
||||
toast: Some(build_current_build_toast(game_state)),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn resolve_forge_dismantle_action(
|
||||
game_state: &mut Value,
|
||||
request: &RuntimeStoryActionRequest,
|
||||
) -> Result<StoryResolution, String> {
|
||||
ensure_inventory_action_available(
|
||||
game_state,
|
||||
"缺少玩家角色,无法执行拆解。",
|
||||
"战斗中无法执行拆解。",
|
||||
)?;
|
||||
let item_id = request
|
||||
.action
|
||||
.payload
|
||||
.as_ref()
|
||||
.and_then(|payload| read_optional_string_field(payload, "itemId"))
|
||||
.or_else(|| request.action.target_id.clone())
|
||||
.ok_or_else(|| "forge_dismantle 缺少 itemId".to_string())?;
|
||||
let item = find_player_inventory_entry(game_state, item_id.as_str())
|
||||
.cloned()
|
||||
.ok_or_else(|| "未找到可拆解的物品。".to_string())?;
|
||||
if read_i32_field(&item, "quantity").unwrap_or(0) <= 0 {
|
||||
return Err("未找到可拆解的物品。".to_string());
|
||||
}
|
||||
let outputs = build_dismantle_outputs(game_state, &item)
|
||||
.ok_or_else(|| format!("{} 当前不支持拆解。", read_inventory_item_name(&item)))?;
|
||||
let mut next_inventory = read_player_inventory_values(game_state);
|
||||
next_inventory = remove_inventory_item_from_list(next_inventory, item_id.as_str(), 1);
|
||||
next_inventory = add_inventory_items_to_list(next_inventory, outputs.clone());
|
||||
write_player_inventory_values(game_state, next_inventory);
|
||||
let output_names = outputs.iter().map(read_inventory_item_name).collect::<Vec<_>>();
|
||||
|
||||
Ok(StoryResolution {
|
||||
action_text: resolve_action_text(
|
||||
&format!("拆解{}", read_inventory_item_name(&item)),
|
||||
request,
|
||||
),
|
||||
result_text: build_forge_success_text(
|
||||
"dismantle",
|
||||
None,
|
||||
Some(read_inventory_item_name(&item).as_str()),
|
||||
None,
|
||||
output_names.as_slice(),
|
||||
None,
|
||||
),
|
||||
story_text: None,
|
||||
presentation_options: None,
|
||||
saved_current_story: None,
|
||||
patches: Vec::new(),
|
||||
battle: None,
|
||||
toast: Some(build_current_build_toast(game_state)),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn resolve_forge_reforge_action(
|
||||
game_state: &mut Value,
|
||||
request: &RuntimeStoryActionRequest,
|
||||
) -> Result<StoryResolution, String> {
|
||||
ensure_inventory_action_available(
|
||||
game_state,
|
||||
"缺少玩家角色,无法执行重铸。",
|
||||
"战斗中无法执行重铸。",
|
||||
)?;
|
||||
let item_id = request
|
||||
.action
|
||||
.payload
|
||||
.as_ref()
|
||||
.and_then(|payload| read_optional_string_field(payload, "itemId"))
|
||||
.or_else(|| request.action.target_id.clone())
|
||||
.ok_or_else(|| "forge_reforge 缺少 itemId".to_string())?;
|
||||
let item = find_player_inventory_entry(game_state, item_id.as_str())
|
||||
.cloned()
|
||||
.ok_or_else(|| "未找到可重铸的物品。".to_string())?;
|
||||
if read_i32_field(&item, "quantity").unwrap_or(0) <= 0 {
|
||||
return Err("未找到可重铸的物品。".to_string());
|
||||
}
|
||||
let slot_id = resolve_equipment_slot_for_item(&item);
|
||||
let reforge_cost = reforge_cost_definition(slot_id);
|
||||
let player_currency = read_i32_field(game_state, "playerCurrency").unwrap_or(0);
|
||||
if player_currency < reforge_cost.currency_cost {
|
||||
return Err(format!(
|
||||
"{} 当前不满足重铸条件。",
|
||||
read_inventory_item_name(&item)
|
||||
));
|
||||
}
|
||||
let reforged_item = build_reforged_item(game_state, &item)
|
||||
.ok_or_else(|| format!("{} 当前不满足重铸条件。", read_inventory_item_name(&item)))?;
|
||||
let base_inventory = remove_inventory_item_from_list(
|
||||
read_player_inventory_values(game_state),
|
||||
item_id.as_str(),
|
||||
1,
|
||||
);
|
||||
let consumed_inventory = apply_forge_requirements_if_possible(
|
||||
base_inventory.as_slice(),
|
||||
reforge_cost.requirements.as_slice(),
|
||||
)
|
||||
.ok_or_else(|| format!("{} 当前不满足重铸条件。", read_inventory_item_name(&item)))?;
|
||||
let next_inventory =
|
||||
add_inventory_items_to_list(consumed_inventory, vec![reforged_item.clone()]);
|
||||
write_player_inventory_values(game_state, next_inventory);
|
||||
write_i32_field(
|
||||
game_state,
|
||||
"playerCurrency",
|
||||
player_currency.saturating_sub(reforge_cost.currency_cost),
|
||||
);
|
||||
|
||||
Ok(StoryResolution {
|
||||
action_text: resolve_action_text(
|
||||
&format!("重铸{}", read_inventory_item_name(&item)),
|
||||
request,
|
||||
),
|
||||
result_text: build_forge_success_text(
|
||||
"reforge",
|
||||
None,
|
||||
Some(read_inventory_item_name(&item).as_str()),
|
||||
Some(read_inventory_item_name(&reforged_item).as_str()),
|
||||
&[],
|
||||
Some(format_currency_text(
|
||||
reforge_cost.currency_cost,
|
||||
current_world_type(game_state).as_deref(),
|
||||
)),
|
||||
),
|
||||
story_text: None,
|
||||
presentation_options: None,
|
||||
saved_current_story: None,
|
||||
patches: Vec::new(),
|
||||
battle: None,
|
||||
toast: Some(build_current_build_toast(game_state)),
|
||||
})
|
||||
}
|
||||
417
server-rs/crates/module-runtime-story-compat/src/game_state.rs
Normal file
417
server-rs/crates/module-runtime-story-compat/src/game_state.rs
Normal file
@@ -0,0 +1,417 @@
|
||||
use serde_json::{Map, Value, json};
|
||||
|
||||
use crate::{
|
||||
ensure_json_object, first_hostile_npc_string_field, read_array_field, read_bool_field,
|
||||
read_field, read_i32_field, read_object_field, read_optional_string_field, write_i32_field,
|
||||
};
|
||||
|
||||
/// 这批 helper 只负责 runtime story compat 的纯快照读写。
|
||||
///
|
||||
/// 目标是先把 encounter / inventory / equipment 的基础状态工具从 `api-server`
|
||||
/// 边界模块里收口出来,后续 battle / forge / equipment 规则迁移时直接复用。
|
||||
pub fn ensure_inventory_action_available(
|
||||
game_state: &Value,
|
||||
missing_character_message: &str,
|
||||
battle_locked_message: &str,
|
||||
) -> Result<(), String> {
|
||||
if read_field(game_state, "playerCharacter").is_none() {
|
||||
return Err(missing_character_message.to_string());
|
||||
}
|
||||
if read_bool_field(game_state, "inBattle").unwrap_or(false) {
|
||||
return Err(battle_locked_message.to_string());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn battle_mode_text(value: &str) -> &'static str {
|
||||
if value == "spar" { "切磋" } else { "战斗" }
|
||||
}
|
||||
|
||||
pub fn current_encounter_name(game_state: &Value) -> String {
|
||||
read_object_field(game_state, "currentEncounter")
|
||||
.and_then(|encounter| {
|
||||
read_optional_string_field(encounter, "npcName")
|
||||
.or_else(|| read_optional_string_field(encounter, "name"))
|
||||
})
|
||||
.unwrap_or_else(|| "对方".to_string())
|
||||
}
|
||||
|
||||
pub fn current_encounter_name_from_battle(game_state: &Value) -> String {
|
||||
read_object_field(game_state, "currentEncounter")
|
||||
.and_then(|encounter| {
|
||||
read_optional_string_field(encounter, "npcName")
|
||||
.or_else(|| read_optional_string_field(encounter, "name"))
|
||||
})
|
||||
.or_else(|| first_hostile_npc_string_field(game_state, "name"))
|
||||
.unwrap_or_else(|| "眼前的敌人".to_string())
|
||||
}
|
||||
|
||||
pub fn current_encounter_id(game_state: &Value) -> Option<String> {
|
||||
read_object_field(game_state, "currentEncounter")
|
||||
.and_then(|encounter| read_optional_string_field(encounter, "id"))
|
||||
}
|
||||
|
||||
pub fn find_player_inventory_entry<'a>(game_state: &'a Value, item_id: &str) -> Option<&'a Value> {
|
||||
read_array_field(game_state, "playerInventory")
|
||||
.into_iter()
|
||||
.find(|entry| read_optional_string_field(entry, "id").as_deref() == Some(item_id))
|
||||
}
|
||||
|
||||
pub fn read_player_inventory_values(game_state: &Value) -> Vec<Value> {
|
||||
read_array_field(game_state, "playerInventory")
|
||||
.into_iter()
|
||||
.cloned()
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub fn write_player_inventory_values(game_state: &mut Value, items: Vec<Value>) {
|
||||
ensure_json_object(game_state).insert("playerInventory".to_string(), Value::Array(items));
|
||||
}
|
||||
|
||||
pub fn read_inventory_item_name(item: &Value) -> String {
|
||||
read_optional_string_field(item, "name")
|
||||
.or_else(|| read_optional_string_field(item, "id"))
|
||||
.unwrap_or_else(|| "未命名物品".to_string())
|
||||
}
|
||||
|
||||
pub fn has_giftable_player_inventory(game_state: &Value) -> bool {
|
||||
read_array_field(game_state, "playerInventory")
|
||||
.into_iter()
|
||||
.any(|item| read_i32_field(item, "quantity").unwrap_or(0) > 0)
|
||||
}
|
||||
|
||||
pub fn clone_inventory_item_with_quantity(item: &Value, quantity: i32) -> Value {
|
||||
let mut next_item = item.clone();
|
||||
if let Some(entry) = next_item.as_object_mut() {
|
||||
entry.insert("quantity".to_string(), json!(quantity.max(1)));
|
||||
}
|
||||
next_item
|
||||
}
|
||||
|
||||
pub fn normalize_equipped_item(item: &Value) -> Value {
|
||||
clone_inventory_item_with_quantity(item, 1)
|
||||
}
|
||||
|
||||
pub fn add_inventory_items_to_list(mut base: Vec<Value>, additions: Vec<Value>) -> Vec<Value> {
|
||||
for addition in additions {
|
||||
let add_id = read_optional_string_field(&addition, "id");
|
||||
let add_quantity = read_i32_field(&addition, "quantity").unwrap_or(1).max(1);
|
||||
if let Some(add_id) = add_id {
|
||||
if let Some(existing) = base.iter_mut().find(|item| {
|
||||
read_optional_string_field(item, "id").as_deref() == Some(add_id.as_str())
|
||||
}) {
|
||||
let next_quantity =
|
||||
read_i32_field(existing, "quantity").unwrap_or(0).max(0) + add_quantity;
|
||||
if let Some(existing_object) = existing.as_object_mut() {
|
||||
existing_object.insert("quantity".to_string(), json!(next_quantity));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
}
|
||||
base.push(addition);
|
||||
}
|
||||
base
|
||||
}
|
||||
|
||||
pub fn remove_inventory_item_from_list(
|
||||
mut base: Vec<Value>,
|
||||
item_id: &str,
|
||||
quantity: i32,
|
||||
) -> Vec<Value> {
|
||||
if quantity <= 0 {
|
||||
return base;
|
||||
}
|
||||
let Some(index) = base
|
||||
.iter()
|
||||
.position(|entry| read_optional_string_field(entry, "id").as_deref() == Some(item_id))
|
||||
else {
|
||||
return base;
|
||||
};
|
||||
let current_quantity = read_i32_field(&base[index], "quantity").unwrap_or(0).max(0);
|
||||
let next_quantity = current_quantity - quantity;
|
||||
if next_quantity <= 0 {
|
||||
base.remove(index);
|
||||
return base;
|
||||
}
|
||||
if let Some(item) = base[index].as_object_mut() {
|
||||
item.insert("quantity".to_string(), json!(next_quantity));
|
||||
}
|
||||
base
|
||||
}
|
||||
|
||||
pub fn read_player_equipment_item(game_state: &Value, slot_id: &str) -> Option<Value> {
|
||||
read_field(game_state, "playerEquipment")
|
||||
.and_then(|equipment| read_field(equipment, slot_id))
|
||||
.filter(|item| !item.is_null())
|
||||
.cloned()
|
||||
}
|
||||
|
||||
pub fn write_player_equipment_item(game_state: &mut Value, slot_id: &str, item: Option<Value>) {
|
||||
let root = ensure_json_object(game_state);
|
||||
let equipment = root
|
||||
.entry("playerEquipment".to_string())
|
||||
.or_insert_with(|| {
|
||||
json!({
|
||||
"weapon": null,
|
||||
"armor": null,
|
||||
"relic": null,
|
||||
})
|
||||
});
|
||||
if !equipment.is_object() {
|
||||
*equipment = json!({
|
||||
"weapon": null,
|
||||
"armor": null,
|
||||
"relic": null,
|
||||
});
|
||||
}
|
||||
equipment
|
||||
.as_object_mut()
|
||||
.expect("playerEquipment should be object")
|
||||
.insert(slot_id.to_string(), item.unwrap_or(Value::Null));
|
||||
}
|
||||
|
||||
pub fn equipment_slot_label(slot_id: &str) -> &'static str {
|
||||
match slot_id {
|
||||
"weapon" => "武器",
|
||||
"armor" => "护甲",
|
||||
"relic" => "饰品",
|
||||
_ => "装备",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn normalize_equipment_slot_id(slot_id: &str) -> Option<&'static str> {
|
||||
let normalized = slot_id.trim().to_ascii_lowercase();
|
||||
match normalized.as_str() {
|
||||
"weapon" => Some("weapon"),
|
||||
"armor" => Some("armor"),
|
||||
"relic" | "accessory" => Some("relic"),
|
||||
_ => {
|
||||
// 兼容旧 payload 里直接传中文槽位名或物品类别文案的情况。
|
||||
if slot_id.contains("武器")
|
||||
|| slot_id.contains('剑')
|
||||
|| slot_id.contains('弓')
|
||||
|| slot_id.contains('刀')
|
||||
|| slot_id.contains("拳套")
|
||||
|| slot_id.contains("战刃")
|
||||
|| slot_id.contains('枪')
|
||||
|| slot_id.contains('刃')
|
||||
{
|
||||
return Some("weapon");
|
||||
}
|
||||
if slot_id.contains("护甲")
|
||||
|| slot_id.contains('甲')
|
||||
|| slot_id.contains("护臂")
|
||||
|| slot_id.contains('衣')
|
||||
|| slot_id.contains('袍')
|
||||
|| slot_id.contains('铠')
|
||||
{
|
||||
return Some("armor");
|
||||
}
|
||||
if slot_id.contains("饰品")
|
||||
|| slot_id.contains("护符")
|
||||
|| slot_id.contains("徽章")
|
||||
|| slot_id.contains('玉')
|
||||
|| slot_id.contains('珠')
|
||||
|| slot_id.contains('坠')
|
||||
|| slot_id.contains('铃')
|
||||
|| slot_id.contains('盘')
|
||||
|| slot_id.contains('令')
|
||||
|| slot_id.contains('匣')
|
||||
{
|
||||
return Some("relic");
|
||||
}
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn resolve_equipment_slot_for_item(item: &Value) -> Option<&'static str> {
|
||||
if let Some(slot_id) = read_optional_string_field(item, "equipmentSlotId") {
|
||||
return match slot_id.as_str() {
|
||||
"weapon" => Some("weapon"),
|
||||
"armor" => Some("armor"),
|
||||
"relic" => Some("relic"),
|
||||
_ => None,
|
||||
};
|
||||
}
|
||||
let tags = read_array_field(item, "tags")
|
||||
.into_iter()
|
||||
.filter_map(|tag| tag.as_str().map(|value| value.to_string()))
|
||||
.collect::<Vec<_>>();
|
||||
if tags.iter().any(|tag| tag == "weapon") {
|
||||
return Some("weapon");
|
||||
}
|
||||
if tags.iter().any(|tag| tag == "armor") {
|
||||
return Some("armor");
|
||||
}
|
||||
if tags.iter().any(|tag| tag == "relic") {
|
||||
return Some("relic");
|
||||
}
|
||||
let category_text = read_optional_string_field(item, "category").unwrap_or_default();
|
||||
let name_text = read_inventory_item_name(item);
|
||||
let mixed_text = format!("{category_text} {name_text}");
|
||||
if mixed_text.contains("武器") || mixed_text.contains("剑") || mixed_text.contains("刀") {
|
||||
return Some("weapon");
|
||||
}
|
||||
if mixed_text.contains("护甲") || mixed_text.contains("甲") || mixed_text.contains("袍") {
|
||||
return Some("armor");
|
||||
}
|
||||
if mixed_text.contains("饰品") || mixed_text.contains("护符") || mixed_text.contains("玉")
|
||||
{
|
||||
return Some("relic");
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
pub fn item_rarity_key(item: &Value) -> String {
|
||||
read_optional_string_field(item, "rarity").unwrap_or_else(|| "common".to_string())
|
||||
}
|
||||
|
||||
pub fn equipment_bonus_fallbacks(slot_id: &str, rarity: &str) -> (i32, i32, f64, f64) {
|
||||
match slot_id {
|
||||
"weapon" => {
|
||||
let outgoing = match rarity {
|
||||
"uncommon" => 0.10,
|
||||
"rare" => 0.14,
|
||||
"epic" => 0.20,
|
||||
"legendary" => 0.28,
|
||||
_ => 0.06,
|
||||
};
|
||||
(0, 0, outgoing, 1.0)
|
||||
}
|
||||
"armor" => {
|
||||
let hp = match rarity {
|
||||
"uncommon" => 22,
|
||||
"rare" => 32,
|
||||
"epic" => 44,
|
||||
"legendary" => 58,
|
||||
_ => 14,
|
||||
};
|
||||
let incoming = match rarity {
|
||||
"uncommon" => 0.94,
|
||||
"rare" => 0.90,
|
||||
"epic" => 0.86,
|
||||
"legendary" => 0.80,
|
||||
_ => 0.97,
|
||||
};
|
||||
(hp, 0, 0.0, incoming)
|
||||
}
|
||||
_ => {
|
||||
let mana = match rarity {
|
||||
"uncommon" => 18,
|
||||
"rare" => 28,
|
||||
"epic" => 40,
|
||||
"legendary" => 54,
|
||||
_ => 10,
|
||||
};
|
||||
let outgoing = match rarity {
|
||||
"uncommon" => 0.04,
|
||||
"rare" => 0.06,
|
||||
"epic" => 0.09,
|
||||
"legendary" => 0.12,
|
||||
_ => 0.02,
|
||||
};
|
||||
(0, mana, outgoing, 1.0)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn equipment_item_bonuses(item: &Value, slot_id: &str) -> (i32, i32, f64, f64) {
|
||||
let rarity = item_rarity_key(item);
|
||||
let fallback = equipment_bonus_fallbacks(slot_id, rarity.as_str());
|
||||
let stat_profile = read_field(item, "statProfile");
|
||||
let hp_bonus = stat_profile
|
||||
.and_then(|profile| read_i32_field(profile, "maxHpBonus"))
|
||||
.unwrap_or(fallback.0);
|
||||
let mana_bonus = stat_profile
|
||||
.and_then(|profile| read_i32_field(profile, "maxManaBonus"))
|
||||
.unwrap_or(fallback.1);
|
||||
let outgoing_bonus = stat_profile
|
||||
.and_then(|profile| read_field(profile, "outgoingDamageBonus"))
|
||||
.and_then(Value::as_f64)
|
||||
.unwrap_or(fallback.2);
|
||||
let incoming_multiplier = stat_profile
|
||||
.and_then(|profile| read_field(profile, "incomingDamageMultiplier"))
|
||||
.and_then(Value::as_f64)
|
||||
.unwrap_or(fallback.3);
|
||||
(hp_bonus, mana_bonus, outgoing_bonus, incoming_multiplier)
|
||||
}
|
||||
|
||||
pub fn read_equipment_total_bonuses(game_state: &Value) -> (i32, i32, f64, f64) {
|
||||
let equipment = read_field(game_state, "playerEquipment");
|
||||
let mut hp_bonus = 0;
|
||||
let mut mana_bonus = 0;
|
||||
let mut outgoing_bonus = 0.0;
|
||||
let mut incoming_multiplier = 1.0;
|
||||
for slot_id in ["weapon", "armor", "relic"] {
|
||||
let Some(item) = equipment.and_then(|value| read_field(value, slot_id)) else {
|
||||
continue;
|
||||
};
|
||||
if item.is_null() {
|
||||
continue;
|
||||
}
|
||||
let (slot_hp, slot_mana, slot_outgoing, slot_incoming) =
|
||||
equipment_item_bonuses(item, slot_id);
|
||||
hp_bonus += slot_hp;
|
||||
mana_bonus += slot_mana;
|
||||
outgoing_bonus += slot_outgoing;
|
||||
incoming_multiplier *= slot_incoming;
|
||||
}
|
||||
(hp_bonus, mana_bonus, outgoing_bonus, incoming_multiplier)
|
||||
}
|
||||
|
||||
pub fn apply_equipment_loadout_to_state(game_state: &mut Value) {
|
||||
let (hp_bonus, mana_bonus, _outgoing_bonus, _incoming_multiplier) =
|
||||
read_equipment_total_bonuses(game_state);
|
||||
let current_max_hp = read_i32_field(game_state, "playerMaxHp")
|
||||
.unwrap_or(1)
|
||||
.max(1);
|
||||
let current_max_mana = read_i32_field(game_state, "playerMaxMana")
|
||||
.unwrap_or(1)
|
||||
.max(1);
|
||||
let current_hp = read_i32_field(game_state, "playerHp").unwrap_or(current_max_hp);
|
||||
let base_max_hp = current_max_hp
|
||||
.saturating_sub(read_runtime_equipment_bonus_cache(game_state, "maxHpBonus"))
|
||||
.max(1);
|
||||
let base_max_mana = current_max_mana
|
||||
.saturating_sub(read_runtime_equipment_bonus_cache(
|
||||
game_state,
|
||||
"maxManaBonus",
|
||||
))
|
||||
.max(1);
|
||||
let next_max_hp = base_max_hp.saturating_add(hp_bonus).max(1);
|
||||
let next_max_mana = base_max_mana.saturating_add(mana_bonus).max(1);
|
||||
write_i32_field(game_state, "playerMaxHp", next_max_hp);
|
||||
write_i32_field(game_state, "playerHp", current_hp.min(next_max_hp));
|
||||
write_i32_field(game_state, "playerMaxMana", next_max_mana);
|
||||
write_i32_field(game_state, "playerMana", next_max_mana);
|
||||
write_runtime_equipment_bonus_cache(game_state, "maxHpBonus", hp_bonus);
|
||||
write_runtime_equipment_bonus_cache(game_state, "maxManaBonus", mana_bonus);
|
||||
}
|
||||
|
||||
pub fn read_runtime_equipment_bonus_cache(game_state: &Value, key: &str) -> i32 {
|
||||
read_field(game_state, "runtimeEquipmentBonusCache")
|
||||
.and_then(|cache| read_i32_field(cache, key))
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
pub fn write_runtime_equipment_bonus_cache(game_state: &mut Value, key: &str, value: i32) {
|
||||
let root = ensure_json_object(game_state);
|
||||
let cache = root
|
||||
.entry("runtimeEquipmentBonusCache".to_string())
|
||||
.or_insert_with(|| Value::Object(Map::new()));
|
||||
if !cache.is_object() {
|
||||
*cache = Value::Object(Map::new());
|
||||
}
|
||||
cache
|
||||
.as_object_mut()
|
||||
.expect("runtimeEquipmentBonusCache should be object")
|
||||
.insert(key.to_string(), json!(value));
|
||||
}
|
||||
|
||||
pub fn build_current_build_toast(game_state: &Value) -> String {
|
||||
let (_hp_bonus, _mana_bonus, outgoing_bonus, _incoming_multiplier) =
|
||||
read_equipment_total_bonuses(game_state);
|
||||
let build_multiplier = (1.0 + outgoing_bonus).max(1.0);
|
||||
format!("当前 Build 倍率 x{build_multiplier:.2}")
|
||||
}
|
||||
148
server-rs/crates/module-runtime-story-compat/src/lib.rs
Normal file
148
server-rs/crates/module-runtime-story-compat/src/lib.rs
Normal file
@@ -0,0 +1,148 @@
|
||||
use serde_json::Value;
|
||||
use shared_contracts::runtime_story::{
|
||||
RuntimeBattlePresentation, RuntimeStoryActionRequest, RuntimeStoryOptionView,
|
||||
RuntimeStoryPatch, RuntimeStorySnapshotPayload,
|
||||
};
|
||||
|
||||
pub mod battle;
|
||||
pub mod core;
|
||||
pub mod forge;
|
||||
pub mod forge_actions;
|
||||
pub mod game_state;
|
||||
pub mod npc_support;
|
||||
pub mod options;
|
||||
pub mod view_model;
|
||||
|
||||
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,
|
||||
ensure_json_object, first_hostile_npc_string_field, format_now_rfc3339,
|
||||
grant_player_progression_experience, increment_runtime_stat, normalize_optional_string,
|
||||
normalize_required_string, read_array_field, read_bool_field, read_field, read_i32_field,
|
||||
read_object_field, read_optional_string_field, read_required_string_field,
|
||||
read_runtime_session_id, read_u32_field, remove_player_inventory_item,
|
||||
resolve_progression_level, write_bool_field, write_current_encounter_i32_field,
|
||||
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 game_state::{
|
||||
add_inventory_items_to_list, apply_equipment_loadout_to_state, battle_mode_text,
|
||||
build_current_build_toast, clone_inventory_item_with_quantity, current_encounter_id,
|
||||
current_encounter_name, current_encounter_name_from_battle, ensure_inventory_action_available,
|
||||
equipment_bonus_fallbacks, equipment_item_bonuses, equipment_slot_label,
|
||||
find_player_inventory_entry, has_giftable_player_inventory, item_rarity_key,
|
||||
normalize_equipped_item, normalize_equipment_slot_id, read_equipment_total_bonuses,
|
||||
read_inventory_item_name, read_player_equipment_item, read_player_inventory_values,
|
||||
read_runtime_equipment_bonus_cache, remove_inventory_item_from_list,
|
||||
resolve_equipment_slot_for_item, write_player_equipment_item, write_player_inventory_values,
|
||||
write_runtime_equipment_bonus_cache,
|
||||
};
|
||||
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,
|
||||
};
|
||||
pub use npc_support::{
|
||||
build_npc_gift_result_text, npc_buyback_price, npc_purchase_price, recruit_companion_to_party,
|
||||
resolve_npc_gift_affinity_gain, trade_quantity_suffix,
|
||||
};
|
||||
pub use battle::{build_battle_runtime_story_options, resolve_battle_action, restore_player_resource};
|
||||
pub use options::{
|
||||
build_disabled_runtime_story_option, build_runtime_story_option_from_story_option,
|
||||
build_runtime_story_option_interaction, build_runtime_story_option_with_payload,
|
||||
build_static_runtime_story_option, build_story_option_from_runtime_option, infer_option_scope,
|
||||
};
|
||||
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<String>,
|
||||
pub presentation_options: Option<Vec<RuntimeStoryOptionView>>,
|
||||
pub saved_current_story: Option<Value>,
|
||||
pub patches: Vec<RuntimeStoryPatch>,
|
||||
pub battle: Option<RuntimeBattlePresentation>,
|
||||
pub toast: Option<String>,
|
||||
}
|
||||
|
||||
pub struct GeneratedStoryPayload {
|
||||
pub story_text: String,
|
||||
pub history_result_text: String,
|
||||
pub presentation_options: Vec<RuntimeStoryOptionView>,
|
||||
pub saved_current_story: Value,
|
||||
}
|
||||
|
||||
pub struct CurrentEncounterNpcQuestContext {
|
||||
pub npc_id: String,
|
||||
pub npc_name: String,
|
||||
}
|
||||
|
||||
pub struct PendingQuestOfferContext {
|
||||
pub dialogue: Vec<Value>,
|
||||
pub turn_count: i32,
|
||||
pub custom_input_placeholder: String,
|
||||
pub quest: Value,
|
||||
pub quest_id: String,
|
||||
pub intro_text: Option<String>,
|
||||
}
|
||||
|
||||
pub struct RuntimeStoryActionResponseParts {
|
||||
pub requested_session_id: String,
|
||||
pub server_version: u32,
|
||||
pub snapshot: RuntimeStorySnapshotPayload,
|
||||
pub action_text: String,
|
||||
pub result_text: String,
|
||||
pub story_text: String,
|
||||
pub options: Vec<RuntimeStoryOptionView>,
|
||||
pub patches: Vec<RuntimeStoryPatch>,
|
||||
pub toast: Option<String>,
|
||||
pub battle: Option<RuntimeBattlePresentation>,
|
||||
}
|
||||
|
||||
pub fn simple_story_resolution(
|
||||
game_state: &Value,
|
||||
action_text: String,
|
||||
result_text: &str,
|
||||
) -> StoryResolution {
|
||||
StoryResolution {
|
||||
action_text,
|
||||
result_text: result_text.to_string(),
|
||||
story_text: None,
|
||||
presentation_options: None,
|
||||
saved_current_story: None,
|
||||
patches: vec![build_status_patch(game_state)],
|
||||
battle: None,
|
||||
toast: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn resolve_action_text(default_text: &str, request: &RuntimeStoryActionRequest) -> String {
|
||||
request
|
||||
.action
|
||||
.payload
|
||||
.as_ref()
|
||||
.and_then(|payload| read_optional_string_field(payload, "optionText"))
|
||||
.unwrap_or_else(|| default_text.to_string())
|
||||
}
|
||||
|
||||
pub fn build_status_patch(game_state: &Value) -> RuntimeStoryPatch {
|
||||
RuntimeStoryPatch::StatusChanged {
|
||||
in_battle: read_bool_field(game_state, "inBattle").unwrap_or(false),
|
||||
npc_interaction_active: read_bool_field(game_state, "npcInteractionActive")
|
||||
.unwrap_or(false),
|
||||
current_npc_battle_mode: read_optional_string_field(game_state, "currentNpcBattleMode"),
|
||||
current_npc_battle_outcome: read_optional_string_field(
|
||||
game_state,
|
||||
"currentNpcBattleOutcome",
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn current_world_type(game_state: &Value) -> Option<String> {
|
||||
read_optional_string_field(game_state, "worldType")
|
||||
}
|
||||
216
server-rs/crates/module-runtime-story-compat/src/npc_support.rs
Normal file
216
server-rs/crates/module-runtime-story-compat/src/npc_support.rs
Normal file
@@ -0,0 +1,216 @@
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::{
|
||||
MAX_TASK5_COMPANIONS, ensure_json_object, item_rarity_key, normalize_required_string,
|
||||
read_array_field, read_i32_field, read_inventory_item_name, read_optional_string_field,
|
||||
};
|
||||
|
||||
pub fn resolve_npc_gift_affinity_gain(item: &Value) -> i32 {
|
||||
let rarity_score = match item_rarity_key(item).as_str() {
|
||||
"legendary" => 5,
|
||||
"epic" => 4,
|
||||
"rare" => 3,
|
||||
"uncommon" => 2,
|
||||
_ => 1,
|
||||
};
|
||||
let tags = read_array_field(item, "tags")
|
||||
.into_iter()
|
||||
.filter_map(|tag| tag.as_str().map(|value| value.to_string()))
|
||||
.collect::<Vec<_>>();
|
||||
let mana_bonus = if tags.iter().any(|tag| tag == "mana") {
|
||||
3
|
||||
} else {
|
||||
0
|
||||
};
|
||||
let healing_bonus = if tags.iter().any(|tag| tag == "healing") {
|
||||
3
|
||||
} else {
|
||||
0
|
||||
};
|
||||
(4 + rarity_score * 3 + mana_bonus + healing_bonus).min(24)
|
||||
}
|
||||
|
||||
pub fn build_npc_gift_result_text(
|
||||
npc_name: &str,
|
||||
item: &Value,
|
||||
affinity_gain: i32,
|
||||
next_affinity: i32,
|
||||
) -> String {
|
||||
let shift_text = if affinity_gain >= 12 {
|
||||
"态度一下子软化了许多"
|
||||
} else if affinity_gain >= 8 {
|
||||
"态度明显和缓下来"
|
||||
} else if affinity_gain >= 5 {
|
||||
"态度比先前亲近了一些"
|
||||
} else {
|
||||
"态度略微放松了些"
|
||||
};
|
||||
let affinity_text = if next_affinity >= 90 {
|
||||
"对你高度信赖,言谈间明显亲近,几乎已经把你当成自己人。"
|
||||
} else if next_affinity >= 60 {
|
||||
"对你已经建立起稳固信任,愿意进一步合作。"
|
||||
} else if next_affinity >= 30 {
|
||||
"对你的态度明显友善了许多,也更愿意正常交流。"
|
||||
} else if next_affinity >= 15 {
|
||||
"戒备开始松动,愿意试探性地配合你的节奏。"
|
||||
} else if next_affinity >= 0 {
|
||||
"仍保持明显距离,只会给出谨慎而有限的回应。"
|
||||
} else {
|
||||
"关系已经降到冰点,对你几乎不再保留善意。"
|
||||
};
|
||||
format!(
|
||||
"{}收下了{},{}。{}",
|
||||
npc_name,
|
||||
read_inventory_item_name(item),
|
||||
shift_text,
|
||||
affinity_text
|
||||
)
|
||||
}
|
||||
|
||||
fn inventory_item_value(item: &Value) -> i32 {
|
||||
if let Some(explicit_value) = read_i32_field(item, "value") {
|
||||
return explicit_value.max(8);
|
||||
}
|
||||
let rarity_base = match item_rarity_key(item).as_str() {
|
||||
"legendary" => 168,
|
||||
"epic" => 92,
|
||||
"rare" => 48,
|
||||
"uncommon" => 24,
|
||||
_ => 12,
|
||||
};
|
||||
let category = read_optional_string_field(item, "category").unwrap_or_default();
|
||||
let tags = read_array_field(item, "tags")
|
||||
.into_iter()
|
||||
.filter_map(|tag| tag.as_str().map(|value| value.to_string()))
|
||||
.collect::<Vec<_>>();
|
||||
let mut value = rarity_base;
|
||||
if tags.iter().any(|tag| tag == "weapon") {
|
||||
value += 14;
|
||||
}
|
||||
if tags.iter().any(|tag| tag == "armor") {
|
||||
value += 12;
|
||||
}
|
||||
if tags.iter().any(|tag| tag == "relic") {
|
||||
value += 16;
|
||||
}
|
||||
if tags.iter().any(|tag| tag == "mana") {
|
||||
value += 8;
|
||||
}
|
||||
if tags.iter().any(|tag| tag == "healing") {
|
||||
value += 8;
|
||||
}
|
||||
if tags.iter().any(|tag| tag == "material") {
|
||||
value += 4;
|
||||
}
|
||||
if category.contains("专属") {
|
||||
value += 10;
|
||||
}
|
||||
value.max(8)
|
||||
}
|
||||
|
||||
fn discount_tier_for_affinity(affinity: i32) -> i32 {
|
||||
if affinity >= 90 {
|
||||
3
|
||||
} else if affinity >= 60 {
|
||||
2
|
||||
} else if affinity >= 30 {
|
||||
1
|
||||
} else {
|
||||
0
|
||||
}
|
||||
}
|
||||
|
||||
pub fn npc_purchase_price(item: &Value, affinity: i32) -> i32 {
|
||||
let discount_multiplier = 1.0 - f64::from(discount_tier_for_affinity(affinity)) * 0.08;
|
||||
(f64::from(inventory_item_value(item)) * discount_multiplier)
|
||||
.round()
|
||||
.max(6.0) as i32
|
||||
}
|
||||
|
||||
pub fn npc_buyback_price(item: &Value, affinity: i32) -> i32 {
|
||||
let buyback_multiplier = 0.4 + f64::from(discount_tier_for_affinity(affinity)) * 0.06;
|
||||
(f64::from(inventory_item_value(item)) * buyback_multiplier)
|
||||
.round()
|
||||
.max(4.0) as i32
|
||||
}
|
||||
|
||||
pub fn trade_quantity_suffix(quantity: i32) -> String {
|
||||
if quantity > 1 {
|
||||
format!(" x{quantity}")
|
||||
} else {
|
||||
String::new()
|
||||
}
|
||||
}
|
||||
|
||||
fn add_companion_if_absent(
|
||||
game_state: &mut Value,
|
||||
npc_id: &str,
|
||||
character_id: Option<String>,
|
||||
joined_at_affinity: i32,
|
||||
) {
|
||||
let root = ensure_json_object(game_state);
|
||||
let companions = root
|
||||
.entry("companions".to_string())
|
||||
.or_insert_with(|| Value::Array(Vec::new()));
|
||||
if !companions.is_array() {
|
||||
*companions = Value::Array(Vec::new());
|
||||
}
|
||||
let items = companions
|
||||
.as_array_mut()
|
||||
.expect("companions should be array");
|
||||
if items
|
||||
.iter()
|
||||
.any(|item| read_optional_string_field(item, "npcId").is_some_and(|value| value == npc_id))
|
||||
{
|
||||
return;
|
||||
}
|
||||
items.push(json!({
|
||||
"npcId": npc_id,
|
||||
"characterId": character_id,
|
||||
"joinedAtAffinity": joined_at_affinity,
|
||||
}));
|
||||
}
|
||||
|
||||
fn remove_companion_by_npc_id(game_state: &mut Value, npc_id: &str) -> Option<Value> {
|
||||
let root = ensure_json_object(game_state);
|
||||
let companions = root
|
||||
.entry("companions".to_string())
|
||||
.or_insert_with(|| Value::Array(Vec::new()));
|
||||
if !companions.is_array() {
|
||||
*companions = Value::Array(Vec::new());
|
||||
}
|
||||
let items = companions
|
||||
.as_array_mut()
|
||||
.expect("companions should be array");
|
||||
let index = items.iter().position(|item| {
|
||||
read_optional_string_field(item, "npcId").is_some_and(|value| value == npc_id)
|
||||
})?;
|
||||
Some(items.remove(index))
|
||||
}
|
||||
|
||||
/// compat bridge 先只维护一个轻量队伍名单,继续复用旧前端的满员换队语义。
|
||||
pub fn recruit_companion_to_party(
|
||||
game_state: &mut Value,
|
||||
npc_id: &str,
|
||||
joined_at_affinity: i32,
|
||||
release_npc_id: Option<&str>,
|
||||
) -> Result<Option<String>, String> {
|
||||
let companion_count = read_array_field(game_state, "companions").len();
|
||||
if companion_count < MAX_TASK5_COMPANIONS {
|
||||
add_companion_if_absent(game_state, npc_id, None, joined_at_affinity);
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let Some(release_npc_id) = release_npc_id.and_then(normalize_required_string) else {
|
||||
return Err("队伍已满时必须明确指定一名离队同伴".to_string());
|
||||
};
|
||||
|
||||
let released_companion = remove_companion_by_npc_id(game_state, release_npc_id.as_str())
|
||||
.ok_or_else(|| "指定的离队同伴不存在,无法完成换队招募".to_string())?;
|
||||
let released_name = read_optional_string_field(&released_companion, "displayName")
|
||||
.or_else(|| read_optional_string_field(&released_companion, "name"))
|
||||
.or_else(|| read_optional_string_field(&released_companion, "npcName"))
|
||||
.unwrap_or(release_npc_id);
|
||||
add_companion_if_absent(game_state, npc_id, None, joined_at_affinity);
|
||||
Ok(Some(released_name))
|
||||
}
|
||||
126
server-rs/crates/module-runtime-story-compat/src/options.rs
Normal file
126
server-rs/crates/module-runtime-story-compat/src/options.rs
Normal file
@@ -0,0 +1,126 @@
|
||||
use serde_json::Value;
|
||||
|
||||
use shared_contracts::runtime_story::{
|
||||
RuntimeStoryOptionInteraction, RuntimeStoryOptionView,
|
||||
};
|
||||
|
||||
use crate::{
|
||||
read_bool_field, read_field, read_optional_string_field, read_required_string_field,
|
||||
};
|
||||
|
||||
/// 这批 helper 只负责 runtime story option 的纯 DTO 编译,不触碰 HTTP / AppState。
|
||||
pub fn infer_option_scope(function_id: &str) -> &'static str {
|
||||
if function_id.starts_with("battle_") || function_id == "inventory_use" {
|
||||
"combat"
|
||||
} else if function_id.starts_with("npc_") {
|
||||
"npc"
|
||||
} else {
|
||||
"story"
|
||||
}
|
||||
}
|
||||
|
||||
pub fn build_static_runtime_story_option(
|
||||
function_id: &str,
|
||||
action_text: &str,
|
||||
scope: &str,
|
||||
) -> RuntimeStoryOptionView {
|
||||
RuntimeStoryOptionView {
|
||||
function_id: function_id.to_string(),
|
||||
action_text: action_text.to_string(),
|
||||
detail_text: None,
|
||||
scope: scope.to_string(),
|
||||
interaction: None,
|
||||
payload: None,
|
||||
disabled: None,
|
||||
reason: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn build_runtime_story_option_with_payload(
|
||||
function_id: &str,
|
||||
action_text: &str,
|
||||
scope: &str,
|
||||
detail_text: Option<String>,
|
||||
payload: Value,
|
||||
) -> RuntimeStoryOptionView {
|
||||
RuntimeStoryOptionView {
|
||||
detail_text,
|
||||
payload: Some(payload),
|
||||
..build_static_runtime_story_option(function_id, action_text, scope)
|
||||
}
|
||||
}
|
||||
|
||||
pub fn build_disabled_runtime_story_option(
|
||||
function_id: &str,
|
||||
action_text: &str,
|
||||
scope: &str,
|
||||
detail_text: Option<String>,
|
||||
reason: &str,
|
||||
payload: Option<Value>,
|
||||
) -> RuntimeStoryOptionView {
|
||||
RuntimeStoryOptionView {
|
||||
detail_text,
|
||||
payload,
|
||||
disabled: Some(true),
|
||||
reason: Some(reason.to_string()),
|
||||
..build_static_runtime_story_option(function_id, action_text, scope)
|
||||
}
|
||||
}
|
||||
|
||||
pub fn build_runtime_story_option_from_story_option(
|
||||
value: &Value,
|
||||
) -> Option<RuntimeStoryOptionView> {
|
||||
let function_id = read_required_string_field(value, "functionId")?;
|
||||
let action_text = read_required_string_field(value, "actionText")
|
||||
.or_else(|| read_required_string_field(value, "text"))
|
||||
.unwrap_or_else(|| function_id.clone());
|
||||
|
||||
Some(RuntimeStoryOptionView {
|
||||
scope: infer_option_scope(function_id.as_str()).to_string(),
|
||||
detail_text: read_optional_string_field(value, "detailText"),
|
||||
interaction: build_runtime_story_option_interaction(read_field(value, "interaction")),
|
||||
payload: read_field(value, "runtimePayload")
|
||||
.or_else(|| read_field(value, "payload"))
|
||||
.cloned(),
|
||||
disabled: read_bool_field(value, "disabled"),
|
||||
reason: read_optional_string_field(value, "disabledReason")
|
||||
.or_else(|| read_optional_string_field(value, "reason")),
|
||||
function_id,
|
||||
action_text,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn build_runtime_story_option_interaction(
|
||||
value: Option<&Value>,
|
||||
) -> Option<RuntimeStoryOptionInteraction> {
|
||||
let interaction = value?;
|
||||
match read_required_string_field(interaction, "kind")?.as_str() {
|
||||
"npc" => Some(RuntimeStoryOptionInteraction::Npc {
|
||||
npc_id: read_required_string_field(interaction, "npcId")?,
|
||||
action: read_required_string_field(interaction, "action")?,
|
||||
quest_id: read_optional_string_field(interaction, "questId"),
|
||||
}),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn build_story_option_from_runtime_option(option: &RuntimeStoryOptionView) -> Value {
|
||||
serde_json::json!({
|
||||
"functionId": option.function_id,
|
||||
"actionText": option.action_text,
|
||||
"text": option.action_text,
|
||||
"detailText": option.detail_text,
|
||||
"visuals": {
|
||||
"playerAnimation": "idle",
|
||||
"playerMoveMeters": 0,
|
||||
"playerOffsetY": 0,
|
||||
"playerFacing": "right",
|
||||
"scrollWorld": false,
|
||||
"monsterChanges": []
|
||||
},
|
||||
"interaction": option.interaction,
|
||||
"runtimePayload": option.payload,
|
||||
"disabled": option.disabled,
|
||||
"disabledReason": option.reason,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
use serde_json::Value;
|
||||
|
||||
use shared_contracts::runtime_story::{
|
||||
RuntimeStoryCompanionViewModel, RuntimeStoryEncounterViewModel, RuntimeStoryOptionView,
|
||||
RuntimeStoryPlayerViewModel, RuntimeStoryStatusViewModel, RuntimeStoryViewModel,
|
||||
};
|
||||
|
||||
use crate::{
|
||||
read_array_field, read_bool_field, read_i32_field, read_object_field,
|
||||
read_optional_string_field, read_required_string_field,
|
||||
};
|
||||
|
||||
/// 运行时故事 view-model 只依赖快照 JSON 与共享 contract,可脱离 HTTP 层独立编译。
|
||||
pub fn build_runtime_story_view_model(
|
||||
game_state: &Value,
|
||||
options: &[RuntimeStoryOptionView],
|
||||
) -> RuntimeStoryViewModel {
|
||||
RuntimeStoryViewModel {
|
||||
player: RuntimeStoryPlayerViewModel {
|
||||
hp: read_i32_field(game_state, "playerHp").unwrap_or(0),
|
||||
max_hp: read_i32_field(game_state, "playerMaxHp").unwrap_or(1),
|
||||
mana: read_i32_field(game_state, "playerMana").unwrap_or(0),
|
||||
max_mana: read_i32_field(game_state, "playerMaxMana").unwrap_or(1),
|
||||
},
|
||||
encounter: build_runtime_story_encounter(game_state),
|
||||
companions: build_runtime_story_companions(game_state),
|
||||
available_options: options.to_vec(),
|
||||
status: RuntimeStoryStatusViewModel {
|
||||
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 build_runtime_story_companions(
|
||||
game_state: &Value,
|
||||
) -> Vec<RuntimeStoryCompanionViewModel> {
|
||||
read_array_field(game_state, "companions")
|
||||
.into_iter()
|
||||
.filter_map(|entry| {
|
||||
let npc_id = read_required_string_field(entry, "npcId")?;
|
||||
Some(RuntimeStoryCompanionViewModel {
|
||||
npc_id,
|
||||
character_id: read_optional_string_field(entry, "characterId"),
|
||||
joined_at_affinity: read_i32_field(entry, "joinedAtAffinity").unwrap_or(0),
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub fn build_runtime_story_encounter(
|
||||
game_state: &Value,
|
||||
) -> Option<RuntimeStoryEncounterViewModel> {
|
||||
let encounter = read_object_field(game_state, "currentEncounter")?;
|
||||
let npc_name = read_required_string_field(encounter, "npcName")
|
||||
.or_else(|| read_required_string_field(encounter, "name"))
|
||||
.unwrap_or_else(|| "当前遭遇".to_string());
|
||||
let encounter_id =
|
||||
read_required_string_field(encounter, "id").unwrap_or_else(|| npc_name.clone());
|
||||
let npc_state = resolve_current_encounter_npc_state(game_state, &encounter_id, &npc_name);
|
||||
|
||||
Some(RuntimeStoryEncounterViewModel {
|
||||
id: encounter_id,
|
||||
kind: read_required_string_field(encounter, "kind").unwrap_or_else(|| "npc".to_string()),
|
||||
npc_name,
|
||||
hostile: read_bool_field(encounter, "hostile").unwrap_or(false),
|
||||
affinity: npc_state.and_then(|state| read_i32_field(state, "affinity")),
|
||||
recruited: npc_state.and_then(|state| read_bool_field(state, "recruited")),
|
||||
interaction_active: read_bool_field(game_state, "npcInteractionActive").unwrap_or(false),
|
||||
battle_mode: read_optional_string_field(game_state, "currentNpcBattleMode"),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn resolve_current_encounter_npc_state<'a>(
|
||||
game_state: &'a Value,
|
||||
encounter_id: &str,
|
||||
npc_name: &str,
|
||||
) -> Option<&'a Value> {
|
||||
let npc_states = read_object_field(game_state, "npcStates")?;
|
||||
|
||||
npc_states
|
||||
.get(encounter_id)
|
||||
.or_else(|| npc_states.get(npc_name))
|
||||
}
|
||||
@@ -51,11 +51,35 @@
|
||||
5. `story-sessions/begin`
|
||||
6. `story-sessions/continue`
|
||||
|
||||
当前阶段新增 Stage6 `character visual` 兼容 DTO:
|
||||
|
||||
1. `assets/character-visual/generate`
|
||||
2. `assets/character-visual/jobs/:taskId`
|
||||
3. `assets/character-visual/publish`
|
||||
|
||||
当前阶段新增 Stage7 `character animation` 模板与导入兼容 DTO:
|
||||
|
||||
1. `assets/character-animation/templates`
|
||||
2. `assets/character-animation/import-video`
|
||||
|
||||
当前阶段新增 Stage8 `character workflow cache` 第一批兼容 DTO:
|
||||
|
||||
1. `assets/character-workflow-cache`
|
||||
2. `assets/character-workflow-cache/:characterId`
|
||||
|
||||
当前阶段新增 Stage9 `character animation` 主链兼容 DTO:
|
||||
|
||||
1. `assets/character-animation/generate`
|
||||
2. `assets/character-animation/jobs/:taskId`
|
||||
3. `assets/character-animation/publish`
|
||||
|
||||
当前阶段新增 Stage5 `runtime story` 兼容桥 DTO 基线:
|
||||
|
||||
1. `runtime/story/state/resolve` 请求 DTO
|
||||
2. `runtime/story/actions/resolve`、`runtime/story/initial`、`runtime/story/continue` 请求 DTO
|
||||
2. `RuntimeStoryActionResponse` 兼容响应 DTO
|
||||
3. `RuntimeStoryViewModel / presentation / patches / snapshot` 显式结构
|
||||
4. `RuntimeStoryAiResponse` 兼容响应 DTO
|
||||
|
||||
当前仍刻意未做:
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ use platform_oss::{
|
||||
OssObjectAccess, OssPostObjectFormFields, OssPostObjectResponse, OssSignedGetObjectUrlResponse,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
@@ -83,6 +84,289 @@ pub struct BindAssetObjectRequest {
|
||||
pub profile_id: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "kebab-case")]
|
||||
pub enum CharacterVisualSourceMode {
|
||||
TextToImage,
|
||||
ImageToImage,
|
||||
Upload,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CharacterVisualGenerateRequest {
|
||||
pub character_id: String,
|
||||
pub source_mode: CharacterVisualSourceMode,
|
||||
pub prompt_text: String,
|
||||
#[serde(default)]
|
||||
pub character_brief_text: Option<String>,
|
||||
#[serde(default)]
|
||||
pub reference_image_data_urls: Vec<String>,
|
||||
pub candidate_count: u32,
|
||||
pub image_model: String,
|
||||
pub size: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CharacterVisualDraftPayload {
|
||||
pub id: String,
|
||||
pub label: String,
|
||||
pub image_src: String,
|
||||
pub width: u32,
|
||||
pub height: u32,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CharacterVisualGenerateResponse {
|
||||
pub ok: bool,
|
||||
pub task_id: String,
|
||||
pub model: String,
|
||||
pub prompt: String,
|
||||
pub drafts: Vec<CharacterVisualDraftPayload>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum CharacterAssetJobStatusText {
|
||||
Queued,
|
||||
Running,
|
||||
Completed,
|
||||
Failed,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CharacterAssetJobStatusPayload {
|
||||
pub task_id: String,
|
||||
pub kind: String,
|
||||
pub status: CharacterAssetJobStatusText,
|
||||
pub character_id: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub animation: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub strategy: Option<String>,
|
||||
pub model: String,
|
||||
pub prompt: String,
|
||||
pub created_at: String,
|
||||
pub updated_at: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub result: Option<Value>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub error_message: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CharacterVisualPublishRequest {
|
||||
pub character_id: String,
|
||||
pub source_mode: CharacterVisualSourceMode,
|
||||
#[serde(default)]
|
||||
pub prompt_text: Option<String>,
|
||||
pub selected_preview_source: String,
|
||||
#[serde(default)]
|
||||
pub preview_sources: Vec<String>,
|
||||
pub width: u32,
|
||||
pub height: u32,
|
||||
#[serde(default)]
|
||||
pub update_character_override: Option<bool>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CharacterVisualPublishResponse {
|
||||
pub ok: bool,
|
||||
pub asset_id: String,
|
||||
pub portrait_path: String,
|
||||
pub override_map: Value,
|
||||
pub save_message: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CharacterAnimationTemplatePayload {
|
||||
pub id: String,
|
||||
pub label: String,
|
||||
pub animation: String,
|
||||
pub prompt_suffix: String,
|
||||
pub notes: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CharacterAnimationTemplatesResponse {
|
||||
pub ok: bool,
|
||||
pub templates: Vec<CharacterAnimationTemplatePayload>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CharacterAnimationImportVideoRequest {
|
||||
pub character_id: String,
|
||||
pub animation: String,
|
||||
pub video_source: String,
|
||||
#[serde(default)]
|
||||
pub source_label: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CharacterAnimationImportVideoResponse {
|
||||
pub ok: bool,
|
||||
pub imported_video_path: String,
|
||||
pub draft_id: String,
|
||||
pub save_message: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "kebab-case")]
|
||||
pub enum CharacterAnimationStrategy {
|
||||
ImageSequence,
|
||||
ImageToVideo,
|
||||
MotionTransfer,
|
||||
ReferenceToVideo,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CharacterAnimationGenerateRequest {
|
||||
pub character_id: String,
|
||||
pub strategy: CharacterAnimationStrategy,
|
||||
pub animation: String,
|
||||
pub prompt_text: String,
|
||||
#[serde(default)]
|
||||
pub character_brief_text: Option<String>,
|
||||
#[serde(default)]
|
||||
pub action_template_id: Option<String>,
|
||||
pub visual_source: String,
|
||||
#[serde(default)]
|
||||
pub reference_image_data_urls: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub reference_video_data_urls: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub last_frame_image_data_url: Option<String>,
|
||||
pub frame_count: u32,
|
||||
pub fps: u32,
|
||||
pub duration_seconds: u32,
|
||||
#[serde(rename = "loop")]
|
||||
pub loop_: bool,
|
||||
pub use_chroma_key: bool,
|
||||
pub resolution: String,
|
||||
pub ratio: String,
|
||||
pub image_sequence_model: String,
|
||||
pub video_model: String,
|
||||
pub reference_video_model: String,
|
||||
pub motion_transfer_model: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CharacterAnimationGenerateResponse {
|
||||
pub ok: bool,
|
||||
pub task_id: String,
|
||||
pub strategy: CharacterAnimationStrategy,
|
||||
pub model: String,
|
||||
pub prompt: String,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub image_sources: Vec<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub preview_video_path: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CharacterAnimationDraftPayload {
|
||||
pub frames_data_urls: Vec<String>,
|
||||
pub fps: u32,
|
||||
#[serde(rename = "loop")]
|
||||
pub loop_: bool,
|
||||
pub frame_width: u32,
|
||||
pub frame_height: u32,
|
||||
#[serde(default)]
|
||||
pub preview_video_path: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CharacterAnimationPublishRequest {
|
||||
pub character_id: String,
|
||||
pub visual_asset_id: String,
|
||||
pub animations: BTreeMap<String, CharacterAnimationDraftPayload>,
|
||||
#[serde(default)]
|
||||
pub update_character_override: Option<bool>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CharacterAnimationPublishResponse {
|
||||
pub ok: bool,
|
||||
pub animation_set_id: String,
|
||||
pub override_map: Value,
|
||||
pub animation_map: Value,
|
||||
pub save_message: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CharacterWorkflowCachePayload {
|
||||
pub character_id: String,
|
||||
pub visual_prompt_text: String,
|
||||
pub animation_prompt_text: String,
|
||||
pub visual_drafts: Vec<CharacterVisualDraftPayload>,
|
||||
pub selected_visual_draft_id: String,
|
||||
pub selected_animation: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub image_src: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub generated_visual_asset_id: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub generated_animation_set_id: Option<String>,
|
||||
#[serde(default)]
|
||||
pub animation_map: Option<Value>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub updated_at: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CharacterWorkflowCacheSaveRequest {
|
||||
pub character_id: String,
|
||||
#[serde(default)]
|
||||
pub visual_prompt_text: Option<String>,
|
||||
#[serde(default)]
|
||||
pub animation_prompt_text: Option<String>,
|
||||
#[serde(default)]
|
||||
pub visual_drafts: Vec<CharacterVisualDraftPayload>,
|
||||
#[serde(default)]
|
||||
pub selected_visual_draft_id: Option<String>,
|
||||
#[serde(default)]
|
||||
pub selected_animation: Option<String>,
|
||||
#[serde(default)]
|
||||
pub image_src: Option<String>,
|
||||
#[serde(default)]
|
||||
pub generated_visual_asset_id: Option<String>,
|
||||
#[serde(default)]
|
||||
pub generated_animation_set_id: Option<String>,
|
||||
#[serde(default)]
|
||||
pub animation_map: Option<Value>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CharacterWorkflowCacheGetResponse {
|
||||
pub ok: bool,
|
||||
pub cache: Option<CharacterWorkflowCachePayload>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CharacterWorkflowCacheSaveResponse {
|
||||
pub ok: bool,
|
||||
pub cache: CharacterWorkflowCachePayload,
|
||||
pub save_message: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CreateDirectUploadTicketResponse {
|
||||
@@ -358,4 +642,177 @@ mod tests {
|
||||
assert_eq!(payload["assetObject"]["accessPolicy"], json!("private"));
|
||||
assert_eq!(payload["assetObject"]["contentLength"], json!(1024));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn character_visual_source_mode_uses_legacy_kebab_case() {
|
||||
let payload = serde_json::to_value(CharacterVisualSourceMode::ImageToImage)
|
||||
.expect("source mode should serialize");
|
||||
|
||||
assert_eq!(payload, json!("image-to-image"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn character_visual_generate_response_keeps_legacy_shape() {
|
||||
let payload = serde_json::to_value(CharacterVisualGenerateResponse {
|
||||
ok: true,
|
||||
task_id: "visual_1".to_string(),
|
||||
model: "rust-svg-character-visual".to_string(),
|
||||
prompt: "角色提示词".to_string(),
|
||||
drafts: vec![CharacterVisualDraftPayload {
|
||||
id: "candidate-1".to_string(),
|
||||
label: "候选 1".to_string(),
|
||||
image_src: "/generated-character-drafts/hero/visual/visual_1/candidate-01.svg"
|
||||
.to_string(),
|
||||
width: 1024,
|
||||
height: 1024,
|
||||
}],
|
||||
})
|
||||
.expect("response should serialize");
|
||||
|
||||
assert_eq!(payload["ok"], json!(true));
|
||||
assert_eq!(payload["taskId"], json!("visual_1"));
|
||||
assert_eq!(
|
||||
payload["drafts"][0]["imageSrc"],
|
||||
json!("/generated-character-drafts/hero/visual/visual_1/candidate-01.svg")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn character_animation_templates_response_keeps_legacy_shape() {
|
||||
let payload = serde_json::to_value(CharacterAnimationTemplatesResponse {
|
||||
ok: true,
|
||||
templates: vec![CharacterAnimationTemplatePayload {
|
||||
id: "idle_loop".to_string(),
|
||||
label: "待机循环".to_string(),
|
||||
animation: "idle".to_string(),
|
||||
prompt_suffix: "保持呼吸感。".to_string(),
|
||||
notes: "默认待机模板。".to_string(),
|
||||
}],
|
||||
})
|
||||
.expect("response should serialize");
|
||||
|
||||
assert_eq!(payload["ok"], json!(true));
|
||||
assert_eq!(payload["templates"][0]["id"], json!("idle_loop"));
|
||||
assert_eq!(
|
||||
payload["templates"][0]["promptSuffix"],
|
||||
json!("保持呼吸感。")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn character_animation_import_video_response_keeps_legacy_shape() {
|
||||
let payload =
|
||||
serde_json::to_value(CharacterAnimationImportVideoResponse {
|
||||
ok: true,
|
||||
imported_video_path:
|
||||
"/generated-character-drafts/hero/animation/idle/import-1/reference.mp4"
|
||||
.to_string(),
|
||||
draft_id: "animation-import-1".to_string(),
|
||||
save_message: "参考视频已导入 OSS 草稿区。".to_string(),
|
||||
})
|
||||
.expect("response should serialize");
|
||||
|
||||
assert_eq!(
|
||||
payload["importedVideoPath"],
|
||||
json!("/generated-character-drafts/hero/animation/idle/import-1/reference.mp4")
|
||||
);
|
||||
assert_eq!(payload["draftId"], json!("animation-import-1"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn character_workflow_cache_response_keeps_legacy_shape() {
|
||||
let payload = serde_json::to_value(CharacterWorkflowCacheSaveResponse {
|
||||
ok: true,
|
||||
cache: CharacterWorkflowCachePayload {
|
||||
character_id: "hero".to_string(),
|
||||
visual_prompt_text: "主形象".to_string(),
|
||||
animation_prompt_text: "待机".to_string(),
|
||||
visual_drafts: vec![CharacterVisualDraftPayload {
|
||||
id: "draft-1".to_string(),
|
||||
label: "候选 1".to_string(),
|
||||
image_src: "/generated-character-drafts/hero/visual/job/candidate.svg"
|
||||
.to_string(),
|
||||
width: 1024,
|
||||
height: 1536,
|
||||
}],
|
||||
selected_visual_draft_id: "draft-1".to_string(),
|
||||
selected_animation: "idle".to_string(),
|
||||
image_src: Some("/generated-characters/hero/master.png".to_string()),
|
||||
generated_visual_asset_id: None,
|
||||
generated_animation_set_id: None,
|
||||
animation_map: Some(json!({ "idle": { "frames": 4 } })),
|
||||
updated_at: Some("2026-04-22T12:00:00Z".to_string()),
|
||||
},
|
||||
save_message: "角色形象生成缓存已更新。".to_string(),
|
||||
})
|
||||
.expect("response should serialize");
|
||||
|
||||
assert_eq!(payload["ok"], json!(true));
|
||||
assert_eq!(payload["cache"]["characterId"], json!("hero"));
|
||||
assert_eq!(
|
||||
payload["cache"]["visualDrafts"][0]["imageSrc"],
|
||||
json!("/generated-character-drafts/hero/visual/job/candidate.svg")
|
||||
);
|
||||
assert_eq!(payload["cache"]["animationMap"]["idle"]["frames"], json!(4));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn character_animation_strategy_uses_legacy_kebab_case() {
|
||||
let payload = serde_json::to_value(CharacterAnimationStrategy::MotionTransfer)
|
||||
.expect("strategy should serialize");
|
||||
|
||||
assert_eq!(payload, json!("motion-transfer"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn character_animation_generate_response_keeps_image_sequence_shape() {
|
||||
let payload = serde_json::to_value(CharacterAnimationGenerateResponse {
|
||||
ok: true,
|
||||
task_id: "animation_1".to_string(),
|
||||
strategy: CharacterAnimationStrategy::ImageSequence,
|
||||
model: "rust-svg-animation-sequence".to_string(),
|
||||
prompt: "待机动作".to_string(),
|
||||
image_sources: vec![
|
||||
"/generated-character-drafts/hero/animation/idle/job/frame-01.svg".to_string(),
|
||||
],
|
||||
preview_video_path: None,
|
||||
})
|
||||
.expect("response should serialize");
|
||||
|
||||
assert_eq!(payload["ok"], json!(true));
|
||||
assert_eq!(payload["taskId"], json!("animation_1"));
|
||||
assert_eq!(payload["strategy"], json!("image-sequence"));
|
||||
assert_eq!(
|
||||
payload["imageSources"][0],
|
||||
json!("/generated-character-drafts/hero/animation/idle/job/frame-01.svg")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn character_animation_publish_response_keeps_legacy_shape() {
|
||||
let payload = serde_json::to_value(CharacterAnimationPublishResponse {
|
||||
ok: true,
|
||||
animation_set_id: "animation-set-1".to_string(),
|
||||
override_map: json!({}),
|
||||
animation_map: json!({
|
||||
"idle": {
|
||||
"folder": "idle",
|
||||
"prefix": "frame",
|
||||
"frames": 2,
|
||||
"startFrame": 1,
|
||||
"extension": "svg",
|
||||
"basePath": "/generated-animations/hero/animation-set-1/idle",
|
||||
"frameWidth": 192,
|
||||
"frameHeight": 256,
|
||||
"fps": 8,
|
||||
"loop": true
|
||||
}
|
||||
}),
|
||||
save_message: "基础动作资源已写入 OSS 并绑定当前角色。".to_string(),
|
||||
})
|
||||
.expect("response should serialize");
|
||||
|
||||
assert_eq!(payload["animationSetId"], json!("animation-set-1"));
|
||||
assert_eq!(payload["animationMap"]["idle"]["frames"], json!(2));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,7 +4,8 @@ use serde_json::Value;
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct RuntimeStorySnapshotPayload {
|
||||
pub saved_at: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub saved_at: Option<String>,
|
||||
pub bottom_tab: String,
|
||||
pub game_state: Value,
|
||||
#[serde(default)]
|
||||
@@ -21,6 +22,72 @@ pub struct RuntimeStoryStateResolveRequest {
|
||||
pub snapshot: Option<RuntimeStorySnapshotPayload>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct RuntimeStoryChoiceAction {
|
||||
#[serde(rename = "type")]
|
||||
pub action_type: String,
|
||||
pub function_id: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub target_id: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub payload: Option<Value>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct RuntimeStoryActionRequest {
|
||||
pub session_id: String,
|
||||
#[serde(default)]
|
||||
pub client_version: Option<u32>,
|
||||
pub action: RuntimeStoryChoiceAction,
|
||||
#[serde(default)]
|
||||
pub snapshot: Option<RuntimeStorySnapshotPayload>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct RuntimeStoryAiRequestOptions {
|
||||
#[serde(default)]
|
||||
pub available_options: Vec<Value>,
|
||||
#[serde(default)]
|
||||
pub option_catalog: Vec<Value>,
|
||||
}
|
||||
|
||||
impl Default for RuntimeStoryAiRequestOptions {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
available_options: Vec::new(),
|
||||
option_catalog: Vec::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct RuntimeStoryAiRequest {
|
||||
pub world_type: String,
|
||||
pub character: Value,
|
||||
#[serde(default)]
|
||||
pub monsters: Vec<Value>,
|
||||
#[serde(default)]
|
||||
pub history: Vec<Value>,
|
||||
#[serde(default)]
|
||||
pub choice: String,
|
||||
pub context: Value,
|
||||
#[serde(default)]
|
||||
pub request_options: RuntimeStoryAiRequestOptions,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct RuntimeStoryAiResponse {
|
||||
pub story_text: String,
|
||||
pub options: Vec<Value>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub encounter: Option<Value>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct RuntimeStoryOptionView {
|
||||
@@ -49,10 +116,6 @@ pub enum RuntimeStoryOptionInteraction {
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
quest_id: Option<String>,
|
||||
},
|
||||
#[serde(rename_all = "camelCase")]
|
||||
Treasure {
|
||||
action: String,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
||||
@@ -197,30 +260,72 @@ mod tests {
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn runtime_story_state_resolve_request_uses_camel_case_fields() {
|
||||
let payload = serde_json::to_value(RuntimeStoryStateResolveRequest {
|
||||
fn runtime_story_state_resolve_request_accepts_missing_saved_at() {
|
||||
let payload: RuntimeStoryStateResolveRequest = serde_json::from_value(json!({
|
||||
"sessionId": "runtime-main",
|
||||
"clientVersion": 7,
|
||||
"snapshot": {
|
||||
"bottomTab": "adventure",
|
||||
"gameState": { "runtimeSessionId": "runtime-main" },
|
||||
"currentStory": { "text": "营地里的火光还没有熄灭。" }
|
||||
}
|
||||
}))
|
||||
.expect("payload should deserialize");
|
||||
|
||||
assert_eq!(payload.session_id, "runtime-main");
|
||||
assert_eq!(payload.client_version, Some(7));
|
||||
assert_eq!(
|
||||
payload.snapshot.expect("snapshot should exist").saved_at,
|
||||
None
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn runtime_story_action_request_uses_camel_case_fields() {
|
||||
let payload = serde_json::to_value(RuntimeStoryActionRequest {
|
||||
session_id: "runtime-main".to_string(),
|
||||
client_version: Some(7),
|
||||
client_version: Some(8),
|
||||
action: RuntimeStoryChoiceAction {
|
||||
action_type: "story_choice".to_string(),
|
||||
function_id: "npc_chat".to_string(),
|
||||
target_id: Some("npc_camp_firekeeper".to_string()),
|
||||
payload: Some(json!({ "optionText": "继续交谈" })),
|
||||
},
|
||||
snapshot: Some(RuntimeStorySnapshotPayload {
|
||||
saved_at: "2026-04-22T12:00:00.000Z".to_string(),
|
||||
saved_at: Some("2026-04-22T12:00:00.000Z".to_string()),
|
||||
bottom_tab: "adventure".to_string(),
|
||||
game_state: json!({ "runtimeSessionId": "runtime-main" }),
|
||||
current_story: Some(json!({ "text": "营地里的火光还没有熄灭。" })),
|
||||
current_story: None,
|
||||
}),
|
||||
})
|
||||
.expect("payload should serialize");
|
||||
|
||||
assert_eq!(payload["sessionId"], json!("runtime-main"));
|
||||
assert_eq!(payload["clientVersion"], json!(7));
|
||||
assert_eq!(payload["snapshot"]["savedAt"], json!("2026-04-22T12:00:00.000Z"));
|
||||
assert_eq!(payload["snapshot"]["bottomTab"], json!("adventure"));
|
||||
assert_eq!(payload["snapshot"]["gameState"]["runtimeSessionId"], json!("runtime-main"));
|
||||
assert_eq!(payload["clientVersion"], json!(8));
|
||||
assert_eq!(payload["action"]["type"], json!("story_choice"));
|
||||
assert_eq!(payload["action"]["functionId"], json!("npc_chat"));
|
||||
assert_eq!(payload["action"]["targetId"], json!("npc_camp_firekeeper"));
|
||||
assert_eq!(
|
||||
payload["snapshot"]["currentStory"]["text"],
|
||||
json!("营地里的火光还没有熄灭。")
|
||||
payload["snapshot"]["savedAt"],
|
||||
json!("2026-04-22T12:00:00.000Z")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn runtime_story_ai_request_defaults_optional_arrays() {
|
||||
let payload: RuntimeStoryAiRequest = serde_json::from_value(json!({
|
||||
"worldType": "martial",
|
||||
"character": { "name": "林迟" },
|
||||
"context": { "scene": "camp" }
|
||||
}))
|
||||
.expect("payload should deserialize");
|
||||
|
||||
assert_eq!(payload.world_type, "martial");
|
||||
assert!(payload.monsters.is_empty());
|
||||
assert!(payload.history.is_empty());
|
||||
assert!(payload.request_options.available_options.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn runtime_story_action_response_uses_camel_case_fields() {
|
||||
let payload = serde_json::to_value(RuntimeStoryActionResponse {
|
||||
@@ -297,7 +402,7 @@ mod tests {
|
||||
current_npc_battle_outcome: None,
|
||||
}],
|
||||
snapshot: RuntimeStorySnapshotPayload {
|
||||
saved_at: "2026-04-22T12:00:00.000Z".to_string(),
|
||||
saved_at: Some("2026-04-22T12:00:00.000Z".to_string()),
|
||||
bottom_tab: "adventure".to_string(),
|
||||
game_state: json!({ "runtimeSessionId": "runtime-main" }),
|
||||
current_story: Some(json!({
|
||||
|
||||
753
server-rs/crates/spacetime-module/src/ai/mod.rs
Normal file
753
server-rs/crates/spacetime-module/src/ai/mod.rs
Normal file
@@ -0,0 +1,753 @@
|
||||
#[spacetimedb::table(
|
||||
accessor = ai_task,
|
||||
index(accessor = by_ai_task_owner_user_id, btree(columns = [owner_user_id])),
|
||||
index(accessor = by_ai_task_status, btree(columns = [status])),
|
||||
index(accessor = by_ai_task_kind, btree(columns = [task_kind]))
|
||||
)]
|
||||
pub struct AiTask {
|
||||
#[primary_key]
|
||||
task_id: String,
|
||||
task_kind: AiTaskKind,
|
||||
owner_user_id: String,
|
||||
request_label: String,
|
||||
source_module: String,
|
||||
source_entity_id: Option<String>,
|
||||
request_payload_json: Option<String>,
|
||||
status: AiTaskStatus,
|
||||
failure_message: Option<String>,
|
||||
latest_text_output: Option<String>,
|
||||
latest_structured_payload_json: Option<String>,
|
||||
version: u32,
|
||||
created_at: Timestamp,
|
||||
started_at: Option<Timestamp>,
|
||||
completed_at: Option<Timestamp>,
|
||||
updated_at: Timestamp,
|
||||
}
|
||||
|
||||
#[spacetimedb::table(
|
||||
accessor = ai_task_stage,
|
||||
index(accessor = by_ai_task_stage_task_id, btree(columns = [task_id])),
|
||||
index(accessor = by_ai_task_stage_task_order, btree(columns = [task_id, stage_order]))
|
||||
)]
|
||||
pub struct AiTaskStage {
|
||||
#[primary_key]
|
||||
task_stage_id: String,
|
||||
task_id: String,
|
||||
stage_kind: AiTaskStageKind,
|
||||
label: String,
|
||||
detail: String,
|
||||
stage_order: u32,
|
||||
status: AiTaskStageStatus,
|
||||
text_output: Option<String>,
|
||||
structured_payload_json: Option<String>,
|
||||
warning_messages: Vec<String>,
|
||||
started_at: Option<Timestamp>,
|
||||
completed_at: Option<Timestamp>,
|
||||
}
|
||||
|
||||
#[spacetimedb::table(
|
||||
accessor = ai_text_chunk,
|
||||
index(accessor = by_ai_text_chunk_task_id, btree(columns = [task_id])),
|
||||
index(
|
||||
accessor = by_ai_text_chunk_task_stage_sequence,
|
||||
btree(columns = [task_id, stage_kind, sequence])
|
||||
)
|
||||
)]
|
||||
pub struct AiTextChunk {
|
||||
#[primary_key]
|
||||
text_chunk_row_id: String,
|
||||
chunk_id: String,
|
||||
task_id: String,
|
||||
stage_kind: AiTaskStageKind,
|
||||
sequence: u32,
|
||||
delta_text: String,
|
||||
created_at: Timestamp,
|
||||
}
|
||||
|
||||
#[spacetimedb::table(
|
||||
accessor = ai_result_reference,
|
||||
index(accessor = by_ai_result_reference_task_id, btree(columns = [task_id]))
|
||||
)]
|
||||
pub struct AiResultReference {
|
||||
#[primary_key]
|
||||
result_reference_row_id: String,
|
||||
result_ref_id: String,
|
||||
task_id: String,
|
||||
reference_kind: AiResultReferenceKind,
|
||||
reference_id: String,
|
||||
label: Option<String>,
|
||||
created_at: Timestamp,
|
||||
}
|
||||
|
||||
// AI 任务当前先固定成 private 真相表,后续由 Axum / platform-llm 再往外包一层 HTTP 与 SSE 协议。
|
||||
#[spacetimedb::reducer]
|
||||
pub fn create_ai_task(ctx: &ReducerContext, input: AiTaskCreateInput) -> Result<(), String> {
|
||||
create_ai_task_tx(ctx, input).map(|_| ())
|
||||
}
|
||||
|
||||
#[spacetimedb::procedure]
|
||||
pub fn create_ai_task_and_return(
|
||||
ctx: &mut ProcedureContext,
|
||||
input: AiTaskCreateInput,
|
||||
) -> AiTaskProcedureResult {
|
||||
match ctx.try_with_tx(|tx| create_ai_task_tx(tx, input.clone())) {
|
||||
Ok(task) => AiTaskProcedureResult {
|
||||
ok: true,
|
||||
task: Some(task),
|
||||
text_chunk: None,
|
||||
error_message: None,
|
||||
},
|
||||
Err(message) => AiTaskProcedureResult {
|
||||
ok: false,
|
||||
task: None,
|
||||
text_chunk: None,
|
||||
error_message: Some(message),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
#[spacetimedb::reducer]
|
||||
pub fn start_ai_task(ctx: &ReducerContext, input: AiTaskStartInput) -> Result<(), String> {
|
||||
start_ai_task_tx(ctx, input).map(|_| ())
|
||||
}
|
||||
|
||||
#[spacetimedb::reducer]
|
||||
pub fn start_ai_task_stage(
|
||||
ctx: &ReducerContext,
|
||||
input: AiTaskStageStartInput,
|
||||
) -> Result<(), String> {
|
||||
start_ai_task_stage_tx(ctx, input).map(|_| ())
|
||||
}
|
||||
|
||||
// 流式增量写入需要同步返回 chunk 与聚合后的任务快照,方便后续 Axum facade 直接复用。
|
||||
#[spacetimedb::procedure]
|
||||
pub fn append_ai_text_chunk_and_return(
|
||||
ctx: &mut ProcedureContext,
|
||||
input: AiTextChunkAppendInput,
|
||||
) -> AiTaskProcedureResult {
|
||||
match ctx.try_with_tx(|tx| append_ai_text_chunk_tx(tx, input.clone())) {
|
||||
Ok((task, text_chunk)) => AiTaskProcedureResult {
|
||||
ok: true,
|
||||
task: Some(task),
|
||||
text_chunk: Some(text_chunk),
|
||||
error_message: None,
|
||||
},
|
||||
Err(message) => AiTaskProcedureResult {
|
||||
ok: false,
|
||||
task: None,
|
||||
text_chunk: None,
|
||||
error_message: Some(message),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
#[spacetimedb::procedure]
|
||||
pub fn complete_ai_stage_and_return(
|
||||
ctx: &mut ProcedureContext,
|
||||
input: AiStageCompletionInput,
|
||||
) -> AiTaskProcedureResult {
|
||||
match ctx.try_with_tx(|tx| complete_ai_stage_tx(tx, input.clone())) {
|
||||
Ok(task) => AiTaskProcedureResult {
|
||||
ok: true,
|
||||
task: Some(task),
|
||||
text_chunk: None,
|
||||
error_message: None,
|
||||
},
|
||||
Err(message) => AiTaskProcedureResult {
|
||||
ok: false,
|
||||
task: None,
|
||||
text_chunk: None,
|
||||
error_message: Some(message),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
#[spacetimedb::procedure]
|
||||
pub fn attach_ai_result_reference_and_return(
|
||||
ctx: &mut ProcedureContext,
|
||||
input: AiResultReferenceInput,
|
||||
) -> AiTaskProcedureResult {
|
||||
match ctx.try_with_tx(|tx| attach_ai_result_reference_tx(tx, input.clone())) {
|
||||
Ok(task) => AiTaskProcedureResult {
|
||||
ok: true,
|
||||
task: Some(task),
|
||||
text_chunk: None,
|
||||
error_message: None,
|
||||
},
|
||||
Err(message) => AiTaskProcedureResult {
|
||||
ok: false,
|
||||
task: None,
|
||||
text_chunk: None,
|
||||
error_message: Some(message),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
#[spacetimedb::procedure]
|
||||
pub fn complete_ai_task_and_return(
|
||||
ctx: &mut ProcedureContext,
|
||||
input: AiTaskFinishInput,
|
||||
) -> AiTaskProcedureResult {
|
||||
match ctx.try_with_tx(|tx| complete_ai_task_tx(tx, input.clone())) {
|
||||
Ok(task) => AiTaskProcedureResult {
|
||||
ok: true,
|
||||
task: Some(task),
|
||||
text_chunk: None,
|
||||
error_message: None,
|
||||
},
|
||||
Err(message) => AiTaskProcedureResult {
|
||||
ok: false,
|
||||
task: None,
|
||||
text_chunk: None,
|
||||
error_message: Some(message),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
#[spacetimedb::procedure]
|
||||
pub fn fail_ai_task_and_return(
|
||||
ctx: &mut ProcedureContext,
|
||||
input: AiTaskFailureInput,
|
||||
) -> AiTaskProcedureResult {
|
||||
match ctx.try_with_tx(|tx| fail_ai_task_tx(tx, input.clone())) {
|
||||
Ok(task) => AiTaskProcedureResult {
|
||||
ok: true,
|
||||
task: Some(task),
|
||||
text_chunk: None,
|
||||
error_message: None,
|
||||
},
|
||||
Err(message) => AiTaskProcedureResult {
|
||||
ok: false,
|
||||
task: None,
|
||||
text_chunk: None,
|
||||
error_message: Some(message),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
#[spacetimedb::procedure]
|
||||
pub fn cancel_ai_task_and_return(
|
||||
ctx: &mut ProcedureContext,
|
||||
input: AiTaskCancelInput,
|
||||
) -> AiTaskProcedureResult {
|
||||
match ctx.try_with_tx(|tx| cancel_ai_task_tx(tx, input.clone())) {
|
||||
Ok(task) => AiTaskProcedureResult {
|
||||
ok: true,
|
||||
task: Some(task),
|
||||
text_chunk: None,
|
||||
error_message: None,
|
||||
},
|
||||
Err(message) => AiTaskProcedureResult {
|
||||
ok: false,
|
||||
task: None,
|
||||
text_chunk: None,
|
||||
error_message: Some(message),
|
||||
},
|
||||
}
|
||||
}
|
||||
fn create_ai_task_tx(
|
||||
ctx: &ReducerContext,
|
||||
input: AiTaskCreateInput,
|
||||
) -> Result<AiTaskSnapshot, String> {
|
||||
validate_task_create_input(&input).map_err(|error| error.to_string())?;
|
||||
|
||||
if ctx.db.ai_task().task_id().find(&input.task_id).is_some() {
|
||||
return Err("ai_task.task_id 已存在".to_string());
|
||||
}
|
||||
|
||||
let task_snapshot = build_ai_task_snapshot_from_create_input(&input);
|
||||
ctx.db.ai_task().insert(build_ai_task_row(&task_snapshot));
|
||||
replace_ai_task_stages(ctx, &task_snapshot.task_id, &task_snapshot.stages);
|
||||
|
||||
get_ai_task_snapshot_tx(ctx, &task_snapshot.task_id)
|
||||
}
|
||||
|
||||
fn start_ai_task_tx(
|
||||
ctx: &ReducerContext,
|
||||
input: AiTaskStartInput,
|
||||
) -> Result<AiTaskSnapshot, String> {
|
||||
let mut snapshot = get_ai_task_snapshot_tx(ctx, &input.task_id)?;
|
||||
ensure_ai_task_can_transition(snapshot.status)?;
|
||||
|
||||
snapshot.status = AiTaskStatus::Running;
|
||||
if snapshot.started_at_micros.is_none() {
|
||||
snapshot.started_at_micros = Some(input.started_at_micros);
|
||||
}
|
||||
snapshot.updated_at_micros = input.started_at_micros;
|
||||
snapshot.version += 1;
|
||||
|
||||
persist_ai_task_snapshot(ctx, &snapshot)?;
|
||||
Ok(snapshot)
|
||||
}
|
||||
|
||||
fn start_ai_task_stage_tx(
|
||||
ctx: &ReducerContext,
|
||||
input: AiTaskStageStartInput,
|
||||
) -> Result<AiTaskSnapshot, String> {
|
||||
let mut snapshot = get_ai_task_snapshot_tx(ctx, &input.task_id)?;
|
||||
ensure_ai_task_can_transition(snapshot.status)?;
|
||||
|
||||
let stage = snapshot
|
||||
.stages
|
||||
.iter_mut()
|
||||
.find(|stage| stage.stage_kind == input.stage_kind)
|
||||
.ok_or_else(|| "ai_task.stage 不存在".to_string())?;
|
||||
|
||||
snapshot.status = AiTaskStatus::Running;
|
||||
if snapshot.started_at_micros.is_none() {
|
||||
snapshot.started_at_micros = Some(input.started_at_micros);
|
||||
}
|
||||
stage.status = AiTaskStageStatus::Running;
|
||||
if stage.started_at_micros.is_none() {
|
||||
stage.started_at_micros = Some(input.started_at_micros);
|
||||
}
|
||||
snapshot.updated_at_micros = input.started_at_micros;
|
||||
snapshot.version += 1;
|
||||
|
||||
persist_ai_task_snapshot(ctx, &snapshot)?;
|
||||
Ok(snapshot)
|
||||
}
|
||||
|
||||
fn append_ai_text_chunk_tx(
|
||||
ctx: &ReducerContext,
|
||||
input: AiTextChunkAppendInput,
|
||||
) -> Result<(AiTaskSnapshot, AiTextChunkSnapshot), String> {
|
||||
if input.delta_text.trim().is_empty() {
|
||||
return Err("ai_text_chunk.delta_text 不能为空".to_string());
|
||||
}
|
||||
if input.sequence == 0 {
|
||||
return Err("ai_text_chunk.sequence 必须大于 0".to_string());
|
||||
}
|
||||
|
||||
let mut snapshot = get_ai_task_snapshot_tx(ctx, &input.task_id)?;
|
||||
ensure_ai_task_can_transition(snapshot.status)?;
|
||||
|
||||
let stage = snapshot
|
||||
.stages
|
||||
.iter_mut()
|
||||
.find(|stage| stage.stage_kind == input.stage_kind)
|
||||
.ok_or_else(|| "ai_task.stage 不存在".to_string())?;
|
||||
|
||||
let chunk = AiTextChunkSnapshot {
|
||||
chunk_id: generate_ai_text_chunk_id(input.created_at_micros, input.sequence),
|
||||
task_id: input.task_id.trim().to_string(),
|
||||
stage_kind: input.stage_kind,
|
||||
sequence: input.sequence,
|
||||
delta_text: input.delta_text.trim().to_string(),
|
||||
created_at_micros: input.created_at_micros,
|
||||
};
|
||||
ctx.db
|
||||
.ai_text_chunk()
|
||||
.insert(build_ai_text_chunk_row(&chunk));
|
||||
|
||||
let aggregated_text = collect_ai_stage_text_output(ctx, &chunk.task_id, chunk.stage_kind);
|
||||
|
||||
snapshot.status = AiTaskStatus::Running;
|
||||
if snapshot.started_at_micros.is_none() {
|
||||
snapshot.started_at_micros = Some(input.created_at_micros);
|
||||
}
|
||||
stage.status = AiTaskStageStatus::Running;
|
||||
if stage.started_at_micros.is_none() {
|
||||
stage.started_at_micros = Some(input.created_at_micros);
|
||||
}
|
||||
stage.text_output = aggregated_text.clone();
|
||||
snapshot.latest_text_output = aggregated_text;
|
||||
snapshot.updated_at_micros = input.created_at_micros;
|
||||
snapshot.version += 1;
|
||||
|
||||
persist_ai_task_snapshot(ctx, &snapshot)?;
|
||||
Ok((snapshot, chunk))
|
||||
}
|
||||
|
||||
fn complete_ai_stage_tx(
|
||||
ctx: &ReducerContext,
|
||||
input: AiStageCompletionInput,
|
||||
) -> Result<AiTaskSnapshot, String> {
|
||||
let mut snapshot = get_ai_task_snapshot_tx(ctx, &input.task_id)?;
|
||||
ensure_ai_task_can_transition(snapshot.status)?;
|
||||
|
||||
let stage = snapshot
|
||||
.stages
|
||||
.iter_mut()
|
||||
.find(|stage| stage.stage_kind == input.stage_kind)
|
||||
.ok_or_else(|| "ai_task.stage 不存在".to_string())?;
|
||||
|
||||
stage.status = AiTaskStageStatus::Completed;
|
||||
stage.completed_at_micros = Some(input.completed_at_micros);
|
||||
stage.text_output = normalize_optional_text(input.text_output.clone());
|
||||
stage.structured_payload_json = normalize_optional_text(input.structured_payload_json.clone());
|
||||
stage.warning_messages = normalize_string_list(input.warning_messages.clone());
|
||||
|
||||
snapshot.latest_text_output = stage.text_output.clone();
|
||||
snapshot.latest_structured_payload_json = stage.structured_payload_json.clone();
|
||||
snapshot.updated_at_micros = input.completed_at_micros;
|
||||
snapshot.version += 1;
|
||||
|
||||
persist_ai_task_snapshot(ctx, &snapshot)?;
|
||||
Ok(snapshot)
|
||||
}
|
||||
|
||||
fn attach_ai_result_reference_tx(
|
||||
ctx: &ReducerContext,
|
||||
input: AiResultReferenceInput,
|
||||
) -> Result<AiTaskSnapshot, String> {
|
||||
let reference_id = input.reference_id.trim().to_string();
|
||||
if reference_id.is_empty() {
|
||||
return Err("ai_result_reference.reference_id 不能为空".to_string());
|
||||
}
|
||||
|
||||
let mut snapshot = get_ai_task_snapshot_tx(ctx, &input.task_id)?;
|
||||
ensure_ai_task_can_transition(snapshot.status)?;
|
||||
|
||||
let reference = AiResultReferenceSnapshot {
|
||||
result_ref_id: generate_ai_result_ref_id(input.created_at_micros),
|
||||
task_id: input.task_id.trim().to_string(),
|
||||
reference_kind: input.reference_kind,
|
||||
reference_id,
|
||||
label: normalize_optional_text(input.label),
|
||||
created_at_micros: input.created_at_micros,
|
||||
};
|
||||
ctx.db
|
||||
.ai_result_reference()
|
||||
.insert(build_ai_result_reference_row(&reference));
|
||||
|
||||
snapshot.result_references.push(reference);
|
||||
snapshot.updated_at_micros = input.created_at_micros;
|
||||
snapshot.version += 1;
|
||||
|
||||
persist_ai_task_snapshot(ctx, &snapshot)?;
|
||||
Ok(snapshot)
|
||||
}
|
||||
|
||||
fn complete_ai_task_tx(
|
||||
ctx: &ReducerContext,
|
||||
input: AiTaskFinishInput,
|
||||
) -> Result<AiTaskSnapshot, String> {
|
||||
let mut snapshot = get_ai_task_snapshot_tx(ctx, &input.task_id)?;
|
||||
ensure_ai_task_can_transition(snapshot.status)?;
|
||||
|
||||
snapshot.status = AiTaskStatus::Completed;
|
||||
snapshot.completed_at_micros = Some(input.completed_at_micros);
|
||||
snapshot.updated_at_micros = input.completed_at_micros;
|
||||
snapshot.version += 1;
|
||||
|
||||
persist_ai_task_snapshot(ctx, &snapshot)?;
|
||||
Ok(snapshot)
|
||||
}
|
||||
|
||||
fn fail_ai_task_tx(
|
||||
ctx: &ReducerContext,
|
||||
input: AiTaskFailureInput,
|
||||
) -> Result<AiTaskSnapshot, String> {
|
||||
let failure_message = input.failure_message.trim().to_string();
|
||||
if failure_message.is_empty() {
|
||||
return Err("ai_task.failure_message 不能为空".to_string());
|
||||
}
|
||||
|
||||
let mut snapshot = get_ai_task_snapshot_tx(ctx, &input.task_id)?;
|
||||
ensure_ai_task_can_transition(snapshot.status)?;
|
||||
|
||||
snapshot.status = AiTaskStatus::Failed;
|
||||
snapshot.failure_message = Some(failure_message);
|
||||
snapshot.completed_at_micros = Some(input.completed_at_micros);
|
||||
snapshot.updated_at_micros = input.completed_at_micros;
|
||||
snapshot.version += 1;
|
||||
|
||||
persist_ai_task_snapshot(ctx, &snapshot)?;
|
||||
Ok(snapshot)
|
||||
}
|
||||
|
||||
fn cancel_ai_task_tx(
|
||||
ctx: &ReducerContext,
|
||||
input: AiTaskCancelInput,
|
||||
) -> Result<AiTaskSnapshot, String> {
|
||||
let mut snapshot = get_ai_task_snapshot_tx(ctx, &input.task_id)?;
|
||||
ensure_ai_task_can_transition(snapshot.status)?;
|
||||
|
||||
snapshot.status = AiTaskStatus::Cancelled;
|
||||
snapshot.completed_at_micros = Some(input.completed_at_micros);
|
||||
snapshot.updated_at_micros = input.completed_at_micros;
|
||||
snapshot.version += 1;
|
||||
|
||||
persist_ai_task_snapshot(ctx, &snapshot)?;
|
||||
Ok(snapshot)
|
||||
}
|
||||
|
||||
fn get_ai_task_snapshot_tx(ctx: &ReducerContext, task_id: &str) -> Result<AiTaskSnapshot, String> {
|
||||
let row = ctx
|
||||
.db
|
||||
.ai_task()
|
||||
.task_id()
|
||||
.find(&task_id.trim().to_string())
|
||||
.ok_or_else(|| "ai_task 不存在".to_string())?;
|
||||
|
||||
Ok(build_ai_task_snapshot_from_row(ctx, &row))
|
||||
}
|
||||
|
||||
fn persist_ai_task_snapshot(ctx: &ReducerContext, snapshot: &AiTaskSnapshot) -> Result<(), String> {
|
||||
ctx.db.ai_task().task_id().delete(&snapshot.task_id);
|
||||
ctx.db.ai_task().insert(build_ai_task_row(snapshot));
|
||||
replace_ai_task_stages(ctx, &snapshot.task_id, &snapshot.stages);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn replace_ai_task_stages(ctx: &ReducerContext, task_id: &str, stages: &[AiTaskStageSnapshot]) {
|
||||
let stage_ids = ctx
|
||||
.db
|
||||
.ai_task_stage()
|
||||
.iter()
|
||||
.filter(|row| row.task_id == task_id)
|
||||
.map(|row| row.task_stage_id.clone())
|
||||
.collect::<Vec<_>>();
|
||||
for stage_id in stage_ids {
|
||||
ctx.db.ai_task_stage().task_stage_id().delete(&stage_id);
|
||||
}
|
||||
|
||||
for stage in stages {
|
||||
ctx.db
|
||||
.ai_task_stage()
|
||||
.insert(build_ai_task_stage_row(task_id, stage));
|
||||
}
|
||||
}
|
||||
|
||||
fn collect_ai_stage_text_output(
|
||||
ctx: &ReducerContext,
|
||||
task_id: &str,
|
||||
stage_kind: AiTaskStageKind,
|
||||
) -> Option<String> {
|
||||
let mut chunks = ctx
|
||||
.db
|
||||
.ai_text_chunk()
|
||||
.iter()
|
||||
.filter(|row| row.task_id == task_id && row.stage_kind == stage_kind)
|
||||
.map(|row| build_ai_text_chunk_snapshot_from_row(&row))
|
||||
.collect::<Vec<_>>();
|
||||
chunks.sort_by_key(|chunk| chunk.sequence);
|
||||
|
||||
let aggregated = chunks
|
||||
.into_iter()
|
||||
.map(|chunk| chunk.delta_text)
|
||||
.collect::<Vec<_>>()
|
||||
.join("");
|
||||
if aggregated.trim().is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(aggregated)
|
||||
}
|
||||
}
|
||||
|
||||
fn ensure_ai_task_can_transition(status: AiTaskStatus) -> Result<(), String> {
|
||||
if matches!(
|
||||
status,
|
||||
AiTaskStatus::Completed | AiTaskStatus::Failed | AiTaskStatus::Cancelled
|
||||
) {
|
||||
Err("当前 ai_task 状态不允许执行该操作".to_string())
|
||||
} else {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
fn build_ai_task_snapshot_from_create_input(input: &AiTaskCreateInput) -> AiTaskSnapshot {
|
||||
AiTaskSnapshot {
|
||||
task_id: input.task_id.trim().to_string(),
|
||||
task_kind: input.task_kind,
|
||||
owner_user_id: input.owner_user_id.trim().to_string(),
|
||||
request_label: input.request_label.trim().to_string(),
|
||||
source_module: input.source_module.trim().to_string(),
|
||||
source_entity_id: normalize_optional_text(input.source_entity_id.clone()),
|
||||
request_payload_json: normalize_optional_text(input.request_payload_json.clone()),
|
||||
status: AiTaskStatus::Pending,
|
||||
failure_message: None,
|
||||
stages: input
|
||||
.stages
|
||||
.iter()
|
||||
.map(|stage| AiTaskStageSnapshot {
|
||||
stage_kind: stage.stage_kind,
|
||||
label: stage.label.trim().to_string(),
|
||||
detail: stage.detail.trim().to_string(),
|
||||
order: stage.order,
|
||||
status: AiTaskStageStatus::Pending,
|
||||
text_output: None,
|
||||
structured_payload_json: None,
|
||||
warning_messages: Vec::new(),
|
||||
started_at_micros: None,
|
||||
completed_at_micros: None,
|
||||
})
|
||||
.collect(),
|
||||
result_references: Vec::new(),
|
||||
latest_text_output: None,
|
||||
latest_structured_payload_json: None,
|
||||
version: INITIAL_AI_TASK_VERSION,
|
||||
created_at_micros: input.created_at_micros,
|
||||
started_at_micros: None,
|
||||
completed_at_micros: None,
|
||||
updated_at_micros: input.created_at_micros,
|
||||
}
|
||||
}
|
||||
|
||||
fn build_ai_task_row(snapshot: &AiTaskSnapshot) -> AiTask {
|
||||
AiTask {
|
||||
task_id: snapshot.task_id.clone(),
|
||||
task_kind: snapshot.task_kind,
|
||||
owner_user_id: snapshot.owner_user_id.clone(),
|
||||
request_label: snapshot.request_label.clone(),
|
||||
source_module: snapshot.source_module.clone(),
|
||||
source_entity_id: snapshot.source_entity_id.clone(),
|
||||
request_payload_json: snapshot.request_payload_json.clone(),
|
||||
status: snapshot.status,
|
||||
failure_message: snapshot.failure_message.clone(),
|
||||
latest_text_output: snapshot.latest_text_output.clone(),
|
||||
latest_structured_payload_json: snapshot.latest_structured_payload_json.clone(),
|
||||
version: snapshot.version,
|
||||
created_at: Timestamp::from_micros_since_unix_epoch(snapshot.created_at_micros),
|
||||
started_at: snapshot
|
||||
.started_at_micros
|
||||
.map(Timestamp::from_micros_since_unix_epoch),
|
||||
completed_at: snapshot
|
||||
.completed_at_micros
|
||||
.map(Timestamp::from_micros_since_unix_epoch),
|
||||
updated_at: Timestamp::from_micros_since_unix_epoch(snapshot.updated_at_micros),
|
||||
}
|
||||
}
|
||||
|
||||
fn build_ai_task_snapshot_from_row(ctx: &ReducerContext, row: &AiTask) -> AiTaskSnapshot {
|
||||
let mut stages = ctx
|
||||
.db
|
||||
.ai_task_stage()
|
||||
.iter()
|
||||
.filter(|stage| stage.task_id == row.task_id)
|
||||
.map(|stage| build_ai_task_stage_snapshot_from_row(&stage))
|
||||
.collect::<Vec<_>>();
|
||||
stages.sort_by_key(|stage| stage.order);
|
||||
|
||||
let mut result_references = ctx
|
||||
.db
|
||||
.ai_result_reference()
|
||||
.iter()
|
||||
.filter(|reference| reference.task_id == row.task_id)
|
||||
.map(|reference| build_ai_result_reference_snapshot_from_row(&reference))
|
||||
.collect::<Vec<_>>();
|
||||
result_references.sort_by_key(|reference| reference.created_at_micros);
|
||||
|
||||
AiTaskSnapshot {
|
||||
task_id: row.task_id.clone(),
|
||||
task_kind: row.task_kind,
|
||||
owner_user_id: row.owner_user_id.clone(),
|
||||
request_label: row.request_label.clone(),
|
||||
source_module: row.source_module.clone(),
|
||||
source_entity_id: row.source_entity_id.clone(),
|
||||
request_payload_json: row.request_payload_json.clone(),
|
||||
status: row.status,
|
||||
failure_message: row.failure_message.clone(),
|
||||
stages,
|
||||
result_references,
|
||||
latest_text_output: row.latest_text_output.clone(),
|
||||
latest_structured_payload_json: row.latest_structured_payload_json.clone(),
|
||||
version: row.version,
|
||||
created_at_micros: row.created_at.to_micros_since_unix_epoch(),
|
||||
started_at_micros: row
|
||||
.started_at
|
||||
.map(|value| value.to_micros_since_unix_epoch()),
|
||||
completed_at_micros: row
|
||||
.completed_at
|
||||
.map(|value| value.to_micros_since_unix_epoch()),
|
||||
updated_at_micros: row.updated_at.to_micros_since_unix_epoch(),
|
||||
}
|
||||
}
|
||||
|
||||
fn build_ai_task_stage_row(task_id: &str, snapshot: &AiTaskStageSnapshot) -> AiTaskStage {
|
||||
AiTaskStage {
|
||||
task_stage_id: generate_ai_task_stage_id(task_id, snapshot.stage_kind),
|
||||
task_id: task_id.to_string(),
|
||||
stage_kind: snapshot.stage_kind,
|
||||
label: snapshot.label.clone(),
|
||||
detail: snapshot.detail.clone(),
|
||||
stage_order: snapshot.order,
|
||||
status: snapshot.status,
|
||||
text_output: snapshot.text_output.clone(),
|
||||
structured_payload_json: snapshot.structured_payload_json.clone(),
|
||||
warning_messages: snapshot.warning_messages.clone(),
|
||||
started_at: snapshot
|
||||
.started_at_micros
|
||||
.map(Timestamp::from_micros_since_unix_epoch),
|
||||
completed_at: snapshot
|
||||
.completed_at_micros
|
||||
.map(Timestamp::from_micros_since_unix_epoch),
|
||||
}
|
||||
}
|
||||
|
||||
fn build_ai_task_stage_snapshot_from_row(row: &AiTaskStage) -> AiTaskStageSnapshot {
|
||||
AiTaskStageSnapshot {
|
||||
stage_kind: row.stage_kind,
|
||||
label: row.label.clone(),
|
||||
detail: row.detail.clone(),
|
||||
order: row.stage_order,
|
||||
status: row.status,
|
||||
text_output: row.text_output.clone(),
|
||||
structured_payload_json: row.structured_payload_json.clone(),
|
||||
warning_messages: row.warning_messages.clone(),
|
||||
started_at_micros: row
|
||||
.started_at
|
||||
.map(|value| value.to_micros_since_unix_epoch()),
|
||||
completed_at_micros: row
|
||||
.completed_at
|
||||
.map(|value| value.to_micros_since_unix_epoch()),
|
||||
}
|
||||
}
|
||||
|
||||
fn build_ai_text_chunk_row(snapshot: &AiTextChunkSnapshot) -> AiTextChunk {
|
||||
AiTextChunk {
|
||||
text_chunk_row_id: format!(
|
||||
"{}{}_{}_{}",
|
||||
AI_TEXT_CHUNK_ID_PREFIX,
|
||||
snapshot.task_id,
|
||||
snapshot.stage_kind.as_str(),
|
||||
snapshot.sequence
|
||||
),
|
||||
chunk_id: snapshot.chunk_id.clone(),
|
||||
task_id: snapshot.task_id.clone(),
|
||||
stage_kind: snapshot.stage_kind,
|
||||
sequence: snapshot.sequence,
|
||||
delta_text: snapshot.delta_text.clone(),
|
||||
created_at: Timestamp::from_micros_since_unix_epoch(snapshot.created_at_micros),
|
||||
}
|
||||
}
|
||||
|
||||
fn build_ai_text_chunk_snapshot_from_row(row: &AiTextChunk) -> AiTextChunkSnapshot {
|
||||
AiTextChunkSnapshot {
|
||||
chunk_id: row.chunk_id.clone(),
|
||||
task_id: row.task_id.clone(),
|
||||
stage_kind: row.stage_kind,
|
||||
sequence: row.sequence,
|
||||
delta_text: row.delta_text.clone(),
|
||||
created_at_micros: row.created_at.to_micros_since_unix_epoch(),
|
||||
}
|
||||
}
|
||||
|
||||
fn build_ai_result_reference_row(snapshot: &AiResultReferenceSnapshot) -> AiResultReference {
|
||||
AiResultReference {
|
||||
result_reference_row_id: format!(
|
||||
"{}{}_{}",
|
||||
AI_RESULT_REF_ID_PREFIX, snapshot.task_id, snapshot.result_ref_id
|
||||
),
|
||||
result_ref_id: snapshot.result_ref_id.clone(),
|
||||
task_id: snapshot.task_id.clone(),
|
||||
reference_kind: snapshot.reference_kind,
|
||||
reference_id: snapshot.reference_id.clone(),
|
||||
label: snapshot.label.clone(),
|
||||
created_at: Timestamp::from_micros_since_unix_epoch(snapshot.created_at_micros),
|
||||
}
|
||||
}
|
||||
|
||||
fn build_ai_result_reference_snapshot_from_row(
|
||||
row: &AiResultReference,
|
||||
) -> AiResultReferenceSnapshot {
|
||||
AiResultReferenceSnapshot {
|
||||
result_ref_id: row.result_ref_id.clone(),
|
||||
task_id: row.task_id.clone(),
|
||||
reference_kind: row.reference_kind,
|
||||
reference_id: row.reference_id.clone(),
|
||||
label: row.label.clone(),
|
||||
created_at_micros: row.created_at.to_micros_since_unix_epoch(),
|
||||
}
|
||||
}
|
||||
305
server-rs/crates/spacetime-module/src/asset_metadata/mod.rs
Normal file
305
server-rs/crates/spacetime-module/src/asset_metadata/mod.rs
Normal file
@@ -0,0 +1,305 @@
|
||||
#[spacetimedb::table(
|
||||
accessor = asset_object,
|
||||
index(accessor = by_bucket_object_key, btree(columns = [bucket, object_key]))
|
||||
)]
|
||||
pub struct AssetObject {
|
||||
#[primary_key]
|
||||
asset_object_id: String,
|
||||
// 正式对象定位固定拆成 bucket + object_key 两列,避免后续再从单字符串路径做 schema 拆分。
|
||||
bucket: String,
|
||||
object_key: String,
|
||||
access_policy: AssetObjectAccessPolicy,
|
||||
content_type: Option<String>,
|
||||
content_length: u64,
|
||||
content_hash: Option<String>,
|
||||
version: u32,
|
||||
source_job_id: Option<String>,
|
||||
owner_user_id: Option<String>,
|
||||
profile_id: Option<String>,
|
||||
entity_id: Option<String>,
|
||||
#[index(btree)]
|
||||
asset_kind: String,
|
||||
created_at: Timestamp,
|
||||
updated_at: Timestamp,
|
||||
}
|
||||
|
||||
#[spacetimedb::table(
|
||||
accessor = asset_entity_binding,
|
||||
index(accessor = by_entity_slot, btree(columns = [entity_kind, entity_id, slot])),
|
||||
index(accessor = by_asset_object_id, btree(columns = [asset_object_id]))
|
||||
)]
|
||||
pub struct AssetEntityBinding {
|
||||
#[primary_key]
|
||||
binding_id: String,
|
||||
asset_object_id: String,
|
||||
entity_kind: String,
|
||||
entity_id: String,
|
||||
slot: String,
|
||||
asset_kind: String,
|
||||
owner_user_id: Option<String>,
|
||||
profile_id: Option<String>,
|
||||
created_at: Timestamp,
|
||||
updated_at: Timestamp,
|
||||
}
|
||||
|
||||
// reducer 负责固定资产对象的正式写规则,供后续内部模块逻辑复用。
|
||||
#[spacetimedb::reducer]
|
||||
pub fn confirm_asset_object(
|
||||
ctx: &ReducerContext,
|
||||
input: AssetObjectUpsertInput,
|
||||
) -> Result<(), String> {
|
||||
upsert_asset_object(ctx, input).map(|_| ())
|
||||
}
|
||||
|
||||
// procedure 面向 Axum 同步确认接口,返回最终持久化后的对象记录,避免 HTTP 层再额外查询 private table。
|
||||
#[spacetimedb::procedure]
|
||||
pub fn confirm_asset_object_and_return(
|
||||
ctx: &mut ProcedureContext,
|
||||
input: AssetObjectUpsertInput,
|
||||
) -> AssetObjectProcedureResult {
|
||||
match ctx.try_with_tx(|tx| upsert_asset_object(tx, input.clone())) {
|
||||
Ok(record) => AssetObjectProcedureResult {
|
||||
ok: true,
|
||||
record: Some(record),
|
||||
error_message: None,
|
||||
},
|
||||
Err(message) => AssetObjectProcedureResult {
|
||||
ok: false,
|
||||
record: None,
|
||||
error_message: Some(message),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// reducer 负责把已确认对象绑定到实体槽位,强业务资产表稳定前先用通用绑定表承接关系。
|
||||
#[spacetimedb::reducer]
|
||||
pub fn bind_asset_object_to_entity(
|
||||
ctx: &ReducerContext,
|
||||
input: AssetEntityBindingInput,
|
||||
) -> Result<(), String> {
|
||||
upsert_asset_entity_binding(ctx, input).map(|_| ())
|
||||
}
|
||||
|
||||
// procedure 面向 Axum 同步绑定接口,返回最终绑定快照,避免 HTTP 层读取 private table。
|
||||
#[spacetimedb::procedure]
|
||||
pub fn bind_asset_object_to_entity_and_return(
|
||||
ctx: &mut ProcedureContext,
|
||||
input: AssetEntityBindingInput,
|
||||
) -> AssetEntityBindingProcedureResult {
|
||||
match ctx.try_with_tx(|tx| upsert_asset_entity_binding(tx, input.clone())) {
|
||||
Ok(record) => AssetEntityBindingProcedureResult {
|
||||
ok: true,
|
||||
record: Some(record),
|
||||
error_message: None,
|
||||
},
|
||||
Err(message) => AssetEntityBindingProcedureResult {
|
||||
ok: false,
|
||||
record: None,
|
||||
error_message: Some(message),
|
||||
},
|
||||
}
|
||||
}
|
||||
fn upsert_asset_object(
|
||||
ctx: &ReducerContext,
|
||||
input: AssetObjectUpsertInput,
|
||||
) -> Result<AssetObjectUpsertSnapshot, String> {
|
||||
validate_asset_object_fields(
|
||||
&input.bucket,
|
||||
&input.object_key,
|
||||
&input.asset_kind,
|
||||
input.version,
|
||||
)
|
||||
.map_err(|error| error.to_string())?;
|
||||
|
||||
let updated_at = Timestamp::from_micros_since_unix_epoch(input.updated_at_micros);
|
||||
// 这里先保持最小可发布实现:查重语义已经冻结,后续再把实现优化回组合索引扫描。
|
||||
let current = ctx
|
||||
.db
|
||||
.asset_object()
|
||||
.iter()
|
||||
.find(|row| row.bucket == input.bucket && row.object_key == input.object_key);
|
||||
|
||||
let snapshot = match current {
|
||||
Some(existing) => {
|
||||
ctx.db
|
||||
.asset_object()
|
||||
.asset_object_id()
|
||||
.delete(&existing.asset_object_id);
|
||||
let row = AssetObject {
|
||||
asset_object_id: existing.asset_object_id.clone(),
|
||||
bucket: input.bucket.clone(),
|
||||
object_key: input.object_key.clone(),
|
||||
access_policy: input.access_policy,
|
||||
content_type: input.content_type.clone(),
|
||||
content_length: input.content_length,
|
||||
content_hash: input.content_hash.clone(),
|
||||
version: input.version,
|
||||
source_job_id: input.source_job_id.clone(),
|
||||
owner_user_id: input.owner_user_id.clone(),
|
||||
profile_id: input.profile_id.clone(),
|
||||
entity_id: input.entity_id.clone(),
|
||||
asset_kind: input.asset_kind.clone(),
|
||||
created_at: existing.created_at,
|
||||
updated_at,
|
||||
};
|
||||
ctx.db.asset_object().insert(row);
|
||||
|
||||
AssetObjectUpsertSnapshot {
|
||||
asset_object_id: existing.asset_object_id,
|
||||
bucket: input.bucket,
|
||||
object_key: input.object_key,
|
||||
access_policy: input.access_policy,
|
||||
content_type: input.content_type,
|
||||
content_length: input.content_length,
|
||||
content_hash: input.content_hash,
|
||||
version: input.version,
|
||||
source_job_id: input.source_job_id,
|
||||
owner_user_id: input.owner_user_id,
|
||||
profile_id: input.profile_id,
|
||||
entity_id: input.entity_id,
|
||||
asset_kind: input.asset_kind,
|
||||
created_at_micros: existing.created_at.to_micros_since_unix_epoch(),
|
||||
updated_at_micros: input.updated_at_micros,
|
||||
}
|
||||
}
|
||||
None => {
|
||||
let created_at = updated_at;
|
||||
let row = AssetObject {
|
||||
asset_object_id: input.asset_object_id.clone(),
|
||||
bucket: input.bucket.clone(),
|
||||
object_key: input.object_key.clone(),
|
||||
access_policy: input.access_policy,
|
||||
content_type: input.content_type.clone(),
|
||||
content_length: input.content_length,
|
||||
content_hash: input.content_hash.clone(),
|
||||
version: input.version,
|
||||
source_job_id: input.source_job_id.clone(),
|
||||
owner_user_id: input.owner_user_id.clone(),
|
||||
profile_id: input.profile_id.clone(),
|
||||
entity_id: input.entity_id.clone(),
|
||||
asset_kind: input.asset_kind.clone(),
|
||||
created_at,
|
||||
updated_at,
|
||||
};
|
||||
ctx.db.asset_object().insert(row);
|
||||
|
||||
AssetObjectUpsertSnapshot {
|
||||
asset_object_id: input.asset_object_id,
|
||||
bucket: input.bucket,
|
||||
object_key: input.object_key,
|
||||
access_policy: input.access_policy,
|
||||
content_type: input.content_type,
|
||||
content_length: input.content_length,
|
||||
content_hash: input.content_hash,
|
||||
version: input.version,
|
||||
source_job_id: input.source_job_id,
|
||||
owner_user_id: input.owner_user_id,
|
||||
profile_id: input.profile_id,
|
||||
entity_id: input.entity_id,
|
||||
asset_kind: input.asset_kind,
|
||||
created_at_micros: input.updated_at_micros,
|
||||
updated_at_micros: input.updated_at_micros,
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
Ok(snapshot)
|
||||
}
|
||||
fn upsert_asset_entity_binding(
|
||||
ctx: &ReducerContext,
|
||||
input: AssetEntityBindingInput,
|
||||
) -> Result<AssetEntityBindingSnapshot, String> {
|
||||
validate_asset_entity_binding_fields(
|
||||
&input.binding_id,
|
||||
&input.asset_object_id,
|
||||
&input.entity_kind,
|
||||
&input.entity_id,
|
||||
&input.slot,
|
||||
&input.asset_kind,
|
||||
)
|
||||
.map_err(|error| error.to_string())?;
|
||||
|
||||
if ctx
|
||||
.db
|
||||
.asset_object()
|
||||
.asset_object_id()
|
||||
.find(&input.asset_object_id)
|
||||
.is_none()
|
||||
{
|
||||
return Err("asset_entity_binding.asset_object_id 对应的 asset_object 不存在".to_string());
|
||||
}
|
||||
|
||||
let updated_at = Timestamp::from_micros_since_unix_epoch(input.updated_at_micros);
|
||||
// 首版绑定按 entity_kind + entity_id + slot 幂等定位,后续访问量明确后再改为组合索引扫描。
|
||||
let current = ctx.db.asset_entity_binding().iter().find(|row| {
|
||||
row.entity_kind == input.entity_kind
|
||||
&& row.entity_id == input.entity_id
|
||||
&& row.slot == input.slot
|
||||
});
|
||||
|
||||
let snapshot = match current {
|
||||
Some(existing) => {
|
||||
ctx.db
|
||||
.asset_entity_binding()
|
||||
.binding_id()
|
||||
.delete(&existing.binding_id);
|
||||
let row = AssetEntityBinding {
|
||||
binding_id: existing.binding_id.clone(),
|
||||
asset_object_id: input.asset_object_id.clone(),
|
||||
entity_kind: input.entity_kind.clone(),
|
||||
entity_id: input.entity_id.clone(),
|
||||
slot: input.slot.clone(),
|
||||
asset_kind: input.asset_kind.clone(),
|
||||
owner_user_id: input.owner_user_id.clone(),
|
||||
profile_id: input.profile_id.clone(),
|
||||
created_at: existing.created_at,
|
||||
updated_at,
|
||||
};
|
||||
ctx.db.asset_entity_binding().insert(row);
|
||||
|
||||
AssetEntityBindingSnapshot {
|
||||
binding_id: existing.binding_id,
|
||||
asset_object_id: input.asset_object_id,
|
||||
entity_kind: input.entity_kind,
|
||||
entity_id: input.entity_id,
|
||||
slot: input.slot,
|
||||
asset_kind: input.asset_kind,
|
||||
owner_user_id: input.owner_user_id,
|
||||
profile_id: input.profile_id,
|
||||
created_at_micros: existing.created_at.to_micros_since_unix_epoch(),
|
||||
updated_at_micros: input.updated_at_micros,
|
||||
}
|
||||
}
|
||||
None => {
|
||||
let created_at = updated_at;
|
||||
let row = AssetEntityBinding {
|
||||
binding_id: input.binding_id.clone(),
|
||||
asset_object_id: input.asset_object_id.clone(),
|
||||
entity_kind: input.entity_kind.clone(),
|
||||
entity_id: input.entity_id.clone(),
|
||||
slot: input.slot.clone(),
|
||||
asset_kind: input.asset_kind.clone(),
|
||||
owner_user_id: input.owner_user_id.clone(),
|
||||
profile_id: input.profile_id.clone(),
|
||||
created_at,
|
||||
updated_at,
|
||||
};
|
||||
ctx.db.asset_entity_binding().insert(row);
|
||||
|
||||
AssetEntityBindingSnapshot {
|
||||
binding_id: input.binding_id,
|
||||
asset_object_id: input.asset_object_id,
|
||||
entity_kind: input.entity_kind,
|
||||
entity_id: input.entity_id,
|
||||
slot: input.slot,
|
||||
asset_kind: input.asset_kind,
|
||||
owner_user_id: input.owner_user_id,
|
||||
profile_id: input.profile_id,
|
||||
created_at_micros: input.updated_at_micros,
|
||||
updated_at_micros: input.updated_at_micros,
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
Ok(snapshot)
|
||||
}
|
||||
3254
server-rs/crates/spacetime-module/src/custom_world/mod.rs
Normal file
3254
server-rs/crates/spacetime-module/src/custom_world/mod.rs
Normal file
File diff suppressed because it is too large
Load Diff
29
server-rs/crates/spacetime-module/src/domain_types.rs
Normal file
29
server-rs/crates/spacetime-module/src/domain_types.rs
Normal file
@@ -0,0 +1,29 @@
|
||||
#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)]
|
||||
pub struct ResolveNpcBattleInteractionInput {
|
||||
pub npc_interaction: ResolveNpcInteractionInput,
|
||||
pub story_session_id: String,
|
||||
pub actor_user_id: String,
|
||||
pub battle_state_id: Option<String>,
|
||||
pub player_hp: i32,
|
||||
pub player_max_hp: i32,
|
||||
pub player_mana: i32,
|
||||
pub player_max_mana: i32,
|
||||
pub target_hp: i32,
|
||||
pub target_max_hp: i32,
|
||||
pub experience_reward: u32,
|
||||
pub reward_items: Vec<RuntimeItemRewardItemSnapshot>,
|
||||
}
|
||||
|
||||
// 输出同时返回 NPC 交互结果与 battle_state 快照,避免 Axum 再回头读取 private table。
|
||||
#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)]
|
||||
pub struct NpcBattleInteractionResult {
|
||||
pub interaction: module_npc::NpcInteractionResult,
|
||||
pub battle_state: BattleStateSnapshot,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)]
|
||||
pub struct NpcBattleInteractionProcedureResult {
|
||||
pub ok: bool,
|
||||
pub result: Option<NpcBattleInteractionResult>,
|
||||
pub error_message: Option<String>,
|
||||
}
|
||||
23
server-rs/crates/spacetime-module/src/entry.rs
Normal file
23
server-rs/crates/spacetime-module/src/entry.rs
Normal file
@@ -0,0 +1,23 @@
|
||||
// 当前阶段先落可发布的最小模块入口,后续再补对象确认、业务绑定与任务编排 reducer。
|
||||
#[spacetimedb::reducer(init)]
|
||||
pub fn init(_ctx: &ReducerContext) {
|
||||
log::info!(
|
||||
"spacetime-module 初始化完成,asset_object 已固定 bucket/object_key 双列主存储口径,runtime_setting 已固定默认音量={} 和默认主题={},battle_state 前缀={},战斗初始版本={},npc_state 前缀={},npc 招募阈值={},story_session 前缀={},story_event 前缀={},inventory_slot 前缀={},inventory_mutation 前缀={},quest_log 前缀={},treasure_record 前缀={},player_progression 与 chapter_progression 已接入成长真相表,M5 custom_world_profile/session/agent/gallery 首批表骨架已接入,默认对象 ID 前缀={},默认绑定 ID 前缀={},资产初始版本={},故事会话初始版本={}",
|
||||
DEFAULT_MUSIC_VOLUME,
|
||||
DEFAULT_PLATFORM_THEME.as_str(),
|
||||
BATTLE_STATE_ID_PREFIX,
|
||||
INITIAL_BATTLE_VERSION,
|
||||
NPC_STATE_ID_PREFIX,
|
||||
NPC_RECRUIT_AFFINITY_THRESHOLD,
|
||||
STORY_SESSION_ID_PREFIX,
|
||||
STORY_EVENT_ID_PREFIX,
|
||||
INVENTORY_SLOT_ID_PREFIX,
|
||||
INVENTORY_MUTATION_ID_PREFIX,
|
||||
QUEST_LOG_ID_PREFIX,
|
||||
TREASURE_RECORD_ID_PREFIX,
|
||||
ASSET_OBJECT_ID_PREFIX,
|
||||
ASSET_BINDING_ID_PREFIX,
|
||||
INITIAL_ASSET_OBJECT_VERSION,
|
||||
INITIAL_STORY_SESSION_VERSION
|
||||
);
|
||||
}
|
||||
2110
server-rs/crates/spacetime-module/src/gameplay/mod.rs
Normal file
2110
server-rs/crates/spacetime-module/src/gameplay/mod.rs
Normal file
File diff suppressed because it is too large
Load Diff
@@ -149,22 +149,6 @@ use spacetimedb::{ProcedureContext, ReducerContext, SpacetimeType, Table, Timest
|
||||
mod puzzle;
|
||||
|
||||
// 这层输入只服务 NPC 开战编排;普通聊天、援手、招募继续走已有 resolve_npc_interaction 接口。
|
||||
#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)]
|
||||
pub struct ResolveNpcBattleInteractionInput {
|
||||
pub npc_interaction: ResolveNpcInteractionInput,
|
||||
pub story_session_id: String,
|
||||
pub actor_user_id: String,
|
||||
pub battle_state_id: Option<String>,
|
||||
pub player_hp: i32,
|
||||
pub player_max_hp: i32,
|
||||
pub player_mana: i32,
|
||||
pub player_max_mana: i32,
|
||||
pub target_hp: i32,
|
||||
pub target_max_hp: i32,
|
||||
pub experience_reward: u32,
|
||||
pub reward_items: Vec<RuntimeItemRewardItemSnapshot>,
|
||||
}
|
||||
|
||||
// 输出同时返回 NPC 交互结果与 battle_state 快照,避免 Axum 再回头读取 private table。
|
||||
#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)]
|
||||
pub struct NpcBattleInteractionResult {
|
||||
|
||||
1288
server-rs/crates/spacetime-module/src/runtime/mod.rs
Normal file
1288
server-rs/crates/spacetime-module/src/runtime/mod.rs
Normal file
File diff suppressed because it is too large
Load Diff
80
server-rs/scripts/m7-preflight.ps1
Normal file
80
server-rs/scripts/m7-preflight.ps1
Normal file
@@ -0,0 +1,80 @@
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[Alias("h")]
|
||||
[switch]$Help,
|
||||
[switch]$RunSmoke,
|
||||
[switch]$RunSpacetimeBuild
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
function Write-Usage {
|
||||
@(
|
||||
'Usage:',
|
||||
' ./server-rs/scripts/m7-preflight.ps1',
|
||||
' ./server-rs/scripts/m7-preflight.ps1 -RunSmoke',
|
||||
' ./server-rs/scripts/m7-preflight.ps1 -RunSpacetimeBuild',
|
||||
'',
|
||||
'Notes:',
|
||||
' 1. Run M7 cutover preflight checks for Rust backend',
|
||||
' 2. Default checks are non-destructive and do not publish or clear SpacetimeDB data',
|
||||
' 3. -RunSmoke starts a temporary api-server and verifies /healthz contract',
|
||||
' 4. -RunSpacetimeBuild requires spacetime CLI and only builds the module'
|
||||
) -join [Environment]::NewLine
|
||||
}
|
||||
|
||||
if ($Help) {
|
||||
Write-Usage
|
||||
exit 0
|
||||
}
|
||||
|
||||
$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path
|
||||
$serverRsDir = Split-Path -Parent $scriptDir
|
||||
$repoRoot = Split-Path -Parent $serverRsDir
|
||||
$manifestPath = Join-Path $serverRsDir "Cargo.toml"
|
||||
$modulePath = Join-Path $serverRsDir "crates\spacetime-module"
|
||||
|
||||
if (-not (Test-Path $manifestPath)) {
|
||||
throw "Missing server-rs/Cargo.toml, cannot start M7 preflight."
|
||||
}
|
||||
|
||||
Write-Host "[m7:preflight] repo root: $repoRoot"
|
||||
Write-Host "[m7:preflight] server-rs: $serverRsDir"
|
||||
|
||||
Push-Location $serverRsDir
|
||||
try {
|
||||
Write-Host "[m7:preflight] step: cargo check -p spacetime-module"
|
||||
cargo check -p spacetime-module --manifest-path $manifestPath
|
||||
|
||||
Write-Host "[m7:preflight] step: cargo check -p api-server"
|
||||
cargo check -p api-server --manifest-path $manifestPath
|
||||
|
||||
Write-Host "[m7:preflight] step: cargo test -p shared-contracts"
|
||||
cargo test -p shared-contracts --manifest-path $manifestPath
|
||||
|
||||
if ($RunSpacetimeBuild) {
|
||||
$spacetimeCommand = Get-Command spacetime -ErrorAction SilentlyContinue
|
||||
if ($null -eq $spacetimeCommand) {
|
||||
throw "Missing spacetime CLI, cannot run spacetime build."
|
||||
}
|
||||
|
||||
Write-Host "[m7:preflight] step: spacetime build --debug"
|
||||
Push-Location $modulePath
|
||||
try {
|
||||
& $spacetimeCommand.Source build --debug
|
||||
}
|
||||
finally {
|
||||
Pop-Location
|
||||
}
|
||||
}
|
||||
}
|
||||
finally {
|
||||
Pop-Location
|
||||
}
|
||||
|
||||
if ($RunSmoke) {
|
||||
Write-Host "[m7:preflight] step: server-rs smoke"
|
||||
& (Join-Path $serverRsDir "scripts\smoke.ps1")
|
||||
}
|
||||
|
||||
Write-Host "[m7:preflight] all checks passed"
|
||||
Reference in New Issue
Block a user