From 81e59f90cef1bf20540ca1eb94fc05f6c00af26c Mon Sep 17 00:00:00 2001 From: kdletters Date: Wed, 22 Apr 2026 18:14:30 +0800 Subject: [PATCH] refactor: split runtime story compat modules --- ..._RUNTIME_STORY_RS_SPLIT_PLAN_2026-04-22.md | 259 ++ docs/technical/README.md | 1 + .../crates/api-server/src/runtime_story.rs | 3742 +---------------- .../api-server/src/runtime_story/compat.rs | 1993 +++++++++ .../api-server/src/runtime_story/compat/ai.rs | 358 ++ .../src/runtime_story/compat/battle.rs | 616 +++ .../src/runtime_story/compat/core.rs | 321 ++ .../src/runtime_story/compat/forge.rs | 409 ++ .../src/runtime_story/compat/game_state.rs | 1115 +++++ .../src/runtime_story/compat/presentation.rs | 928 ++++ .../src/runtime_story/compat/tests.rs | 2165 ++++++++++ 11 files changed, 8170 insertions(+), 3737 deletions(-) create mode 100644 docs/technical/M4_RUNTIME_STORY_RS_SPLIT_PLAN_2026-04-22.md create mode 100644 server-rs/crates/api-server/src/runtime_story/compat.rs create mode 100644 server-rs/crates/api-server/src/runtime_story/compat/ai.rs create mode 100644 server-rs/crates/api-server/src/runtime_story/compat/battle.rs create mode 100644 server-rs/crates/api-server/src/runtime_story/compat/core.rs create mode 100644 server-rs/crates/api-server/src/runtime_story/compat/forge.rs create mode 100644 server-rs/crates/api-server/src/runtime_story/compat/game_state.rs create mode 100644 server-rs/crates/api-server/src/runtime_story/compat/presentation.rs create mode 100644 server-rs/crates/api-server/src/runtime_story/compat/tests.rs diff --git a/docs/technical/M4_RUNTIME_STORY_RS_SPLIT_PLAN_2026-04-22.md b/docs/technical/M4_RUNTIME_STORY_RS_SPLIT_PLAN_2026-04-22.md new file mode 100644 index 00000000..a21d1512 --- /dev/null +++ b/docs/technical/M4_RUNTIME_STORY_RS_SPLIT_PLAN_2026-04-22.md @@ -0,0 +1,259 @@ +# M4 Runtime Story Rust 文件拆分方案(2026-04-22) + +更新时间:`2026-04-22` + +## 0. 文档目标 + +本文件只解决一个工程问题: + +**把 `server-rs/crates/api-server/src/runtime_story.rs` 从当前超大单文件拆成可维护的 Rust 子模块,同时不改变既有 M4 compat bridge 的行为边界。** + +当前 `runtime_story.rs` 已超过 `7000` 行,内部同时混杂了: + +1. Axum route handler +2. snapshot 持久化与 DTO 组装 +3. runtime story compat 动作结算 +4. runtime option compiler / currentStory builder +5. LLM 文本增强 +6. test fixture 与 route boundary 回归 + +这已经超出单文件可维护范围,也会直接拖慢后续继续迁移 Node compat 分支的速度。 + +--- + +## 1. 本轮拆分原则 + +本轮拆分坚持以下边界: + +1. **先拆“展示编译层”和“AI 增强层”,不先重写规则结算层。** +2. **不改变 `app.rs` 里的路由绑定函数名。** +3. **不改变 `RuntimeStoryActionResponse / RuntimeStoryAiResponse` contract。** +4. **不改变现有 compat bridge 的动作规则、patch、snapshot 写回顺序。** +5. **优先做可验证的文件拆分,不把这轮演变成架构重写。** + +原因: + +1. 当前 `resolve_runtime_story_choice_action(...)` 仍在持续迁移 Node compat 行为,短期内继续集中在主文件更利于快速补链。 +2. `presentation / option compiler / dialogue currentStory / AI payload` 对外依赖相对单纯,更适合先抽走。 +3. test module 独立后,可以明显降低主文件噪音,后续再继续拆规则层也更安全。 + +--- + +## 2. 首轮拆分目标 + +首轮只拆以下 3 块: + +### 2.1 `runtime_story/presentation.rs` + +职责: + +1. `viewModel` 编译 +2. `availableOptions` 编译 +3. `currentStory` builder +4. `dialogue / pendingQuestOffer` 的 story shape helper +5. `story option` 与 `interaction` 的组装 + +这块包含但不限于: + +1. `build_runtime_story_view_model` +2. `build_runtime_story_options` +3. `build_fallback_runtime_story_options` +4. `build_dialogue_current_story` +5. `build_pending_quest_offer_story` +6. `build_story_option_from_runtime_option` + +### 2.2 `runtime_story/ai.rs` + +职责: + +1. `initial / continue` 的 `RuntimeStoryAiResponse` +2. `actions/resolve` 后的最小 LLM 文本增强 +3. 对话 turn 解析 +4. AI prompt payload 构造 + +这块包含但不限于: + +1. `build_runtime_story_ai_response` +2. `generate_ai_story_text` +3. `generate_action_story_payload` +4. `generate_npc_dialogue_payload` +5. `generate_reasoned_story_payload` +6. `parse_dialogue_turns` + +### 2.3 `runtime_story/tests.rs` + +职责: + +1. route boundary test +2. 纯函数回归 +3. fixture builder +4. 鉴权 token helper + +--- + +## 3. 拆分后目录形态 + +首轮目标目录: + +```text +server-rs/crates/api-server/src/ +├─ runtime_story.rs +└─ runtime_story/ + ├─ ai.rs + ├─ presentation.rs + └─ tests.rs +``` + +其中: + +1. `runtime_story.rs` 保留为外层入口模块 +2. 子模块通过 `mod ai; mod presentation; #[cfg(test)] mod tests;` 组织 +3. `runtime_story.rs` 继续暴露原有 5 个 route handler: + - `resolve_runtime_story_state` + - `get_runtime_story_state` + - `resolve_runtime_story_action` + - `generate_runtime_story_initial` + - `generate_runtime_story_continue` + +--- + +## 4. Rust 侧实现策略 + +## 4.1 不做新的共享 crate + +本轮不把 helper 再抽成新的 crate 或全局 util module。 + +原因: + +1. 当前拆分目标是降低单文件复杂度,不是扩展跨模块复用面。 +2. `presentation / ai / tests` 仍强依赖 `runtime_story` 内部 helper。 +3. 如果过早抽到 crate 级共享层,会额外引入新的 API 稳定面和更大改动范围。 + +## 4.2 子模块通过 `super::*` 复用内部 helper + +首轮允许子模块继续通过 `use super::*;` 访问现有内部函数、结构体和常量。 + +这是刻意的折中: + +1. 优先完成物理拆分 +2. 暂不要求所有 helper 立即彻底分层 +3. 后续再在第二轮继续把规则层和 state helper 往下切 + +## 4.3 第二轮候选拆分 + +本轮完成后,下一轮可继续评估: + +1. `runtime_story/actions.rs` +2. `runtime_story/battle.rs` +3. `runtime_story/inventory.rs` +4. `runtime_story/npc_state.rs` +5. `runtime_story/json_state.rs` + +但这些都不属于本次提交的必达范围。 + +--- + +## 5. 验证要求 + +拆分后至少必须通过: + +1. `cargo test -p api-server runtime_story --manifest-path D:\\Genarrative\\server-rs\\Cargo.toml` +2. `cargo check -p api-server --manifest-path D:\\Genarrative\\server-rs\\Cargo.toml` +3. `node D:\\Genarrative\\scripts\\check-encoding.mjs` + +若以上任一失败,则本轮拆分不算完成。 + +--- + +## 6. 本轮明确不做 + +1. 不改 compat bridge 业务规则 +2. 不新增或删除 runtime functionId +3. 不顺手把 quest 里的历史 `inspect_treasure` 字段一并清理 +4. 不提前把 `resolve_story_action / sync_runtime_snapshot_projection` 真相 reducer 并入本轮 +5. 不修改前端调用边界 + +--- + +## 7. 完成标记 + +本轮拆分完成的判定标准: + +1. `runtime_story.rs` 明显缩短,至少不再携带 tests 与 AI/presentation 全量实现 +2. `runtime_story/ai.rs`、`runtime_story/presentation.rs`、`runtime_story/tests.rs` 已落地 +3. route handler 对外签名不变 +4. 定向回归全部通过 + +达到以上条件后,再继续进入下一轮“规则层进一步拆分”。 + +--- + +## 8. 2026-04-22 实际落地进度 + +截至 `2026-04-22` 当前工作区,首轮物理拆分已经进入可继续演进状态: + +1. 外层入口 [runtime_story.rs](D:/Genarrative/server-rs/crates/api-server/src/runtime_story.rs) 已缩成薄壳,只保留原有 5 个 route handler 的导出。 +2. 兼容实现主体已迁入 [compat.rs](D:/Genarrative/server-rs/crates/api-server/src/runtime_story/compat.rs),并继续保留规则结算主链。 +3. `tests` 已外置到 [tests.rs](D:/Genarrative/server-rs/crates/api-server/src/runtime_story/compat/tests.rs),避免继续堆在主文件内。 +4. 本轮进一步把 `compat` 内部再拆成: + - [ai.rs](D:/Genarrative/server-rs/crates/api-server/src/runtime_story/compat/ai.rs) + - [presentation.rs](D:/Genarrative/server-rs/crates/api-server/src/runtime_story/compat/presentation.rs) +5. 当前拆分策略仍然维持 `compat` 内部模块,通过 `use super::*;` 复用共享 helper,不提前抽独立 crate。 +6. quest replace / fixture 中原本残留的 `inspect_treasure` mock 已同步替换为更中性的 `talk_to_npc`,避免把已废弃的 treasure 概念继续固化进新模块。 + +下一步不再是继续把文件塞回去,而是沿着当前目录继续把“无 HTTP / 无 AppState”的纯规则与编译逻辑收敛出来,为后续独立 crate 做第二阶段准备。 + +## 9. 第二阶段收敛边界 + +第二阶段不新增对外入口,只继续整理 `compat` 内部依赖面: + +1. 继续保留 [compat.rs](D:/Genarrative/server-rs/crates/api-server/src/runtime_story/compat.rs) 作为 route handler、快照持久化与 compat action orchestration 的主入口。 +2. 优先把“只依赖 `serde_json::Value` / 共享 contract / 纯函数 helper”的部分抽到内部纯模块。 +3. 当前最适合先抽的块不是 battle route,而是: + - NPC 状态补齐 + - encounter / inventory / equipment 读写 + - quest / trade / recruit 等会复用的 `game_state` 纯变换 helper +4. 这一步的目标不是立刻独立 crate,而是先在 `api-server` 内形成清晰的“HTTP 外壳”与“纯状态编译层”分界。 + +如果第二阶段完成后 `compat` 内已经能明显区分: + +1. `AppState / RequestContext / Axum` 相关边界 +2. `Value -> Value / DTO` 的纯规则层 + +那么第三阶段再把后者抽成独立 crate,风险会显著低于现在直接新建 crate。 + +## 10. 第二阶段 battle 收敛进度 + +截至 `2026-04-22` 当前工作区,第二阶段已经继续向“纯规则内聚”推进一块 battle 逻辑: + +1. `compat` 新增 [battle.rs](D:/Genarrative/server-rs/crates/api-server/src/runtime_story/compat/battle.rs),专门承接 battle 兼容桥里的纯规则与展示编译 helper。 +2. 已迁入 `battle.rs` 的内容包括: + - 战斗数值写回 helper + - 技能 / 物品的 battle action plan 生成 + - 战斗技能冷却读写 + - battle 选项与推荐物品编译 + - battle 胜利经验奖励计算 +3. [compat.rs](D:/Genarrative/server-rs/crates/api-server/src/runtime_story/compat.rs) 当前继续只保留 `resolve_battle_action(...)` 这种动作编排入口,不再堆放大段 battle 纯 helper。 +4. [core.rs](D:/Genarrative/server-rs/crates/api-server/src/runtime_story/compat/core.rs) 中原本只服务 battle 链的 skill / inventory 读取与 cooldown helper 已同步移出,避免“纯规则仍散落在多个模块”。 +5. 这一步仍然没有改变: + - Axum route handler 签名 + - `AppState / RequestContext` 边界 + - `RuntimeStoryActionResponse` / patch / snapshot 的写回顺序 + +这说明第二阶段已经不只是在“补状态 helper”,而是开始把 compat 内最独立的一类规则块真正收束成内部纯模块。下一步可以继续沿同样方法处理 `forge`,以及 `trade / gift / companion` 这类不依赖 HTTP 的 helper 群。 + +同日进一步推进后,这条路线已经从 battle 扩展到 forge: + +1. `compat` 新增 [forge.rs](D:/Genarrative/server-rs/crates/api-server/src/runtime_story/compat/forge.rs),把锻造配方、重铸成本、材料消耗、运行时物品生成、拆解产物和重铸产物构造统一收口。 +2. [compat.rs](D:/Genarrative/server-rs/crates/api-server/src/runtime_story/compat.rs) 当前对 forge 只保留: + - `resolve_forge_craft_action(...)` + - `resolve_forge_dismantle_action(...)` + - `resolve_forge_reforge_action(...)` + 这些动作编排入口。 +3. [game_state.rs](D:/Genarrative/server-rs/crates/api-server/src/runtime_story/compat/game_state.rs) 里的 NPC trade bootstrapping 继续直接复用 `forge.rs` 中的运行时物品构造 helper,避免 trade stock 与工坊产物出现两套生成规则。 +4. 这意味着第二阶段已经形成一个更清楚的内部形态: + - `battle.rs`:战斗纯规则与战斗选项编译 + - `forge.rs`:工坊纯规则与运行时锻造物品生成 + - `game_state.rs`:快照态读写与 NPC / inventory / equipment 状态桥 + +后续再继续迁 `trade / gift / companion` 时,目标就不再是单纯减少行数,而是把 compat bridge 逐步收束成“动作编排壳 + 多个纯规则模块”的明确结构。 diff --git a/docs/technical/README.md b/docs/technical/README.md index 75614ef1..262bd95e 100644 --- a/docs/technical/README.md +++ b/docs/technical/README.md @@ -81,6 +81,7 @@ - [M4_RPG_RUNTIME_STORY_SPACETIMEDB_BASELINE_2026-04-21.md](./M4_RPG_RUNTIME_STORY_SPACETIMEDB_BASELINE_2026-04-21.md):记录 `server-rs` 侧 `M4` 首轮已落地的 `story_session / story_event` SpacetimeDB 基座、`begin_story_session / continue_story` reducer、同步返回快照的 story procedure、`spacetime-client` facade 与新的 `/api/story/sessions*` Axum 接口,以及当前尚未兼容旧 `runtime story` 路由的边界。 - [M4_RPG_RUNTIME_STORY_SESSION_STATE_QUERY_DESIGN_2026-04-22.md](./M4_RPG_RUNTIME_STORY_SESSION_STATE_QUERY_DESIGN_2026-04-22.md):冻结 `GET /api/story/sessions/:storySessionId/state` 这条最小 story state 查询切片,明确当前只返回 `storySession + storyEvents`,不等价于旧 `runtime story state` 兼容完成。 - [M4_RUNTIME_STORY_COMPAT_STATE_BRIDGE_DESIGN_2026-04-22.md](./M4_RUNTIME_STORY_COMPAT_STATE_BRIDGE_DESIGN_2026-04-22.md):冻结旧 `POST /api/runtime/story/state/resolve` 兼容桥的首版边界,明确先补 `RuntimeStoryActionResponse` DTO 与状态桥,再继续进入 Rust `actions/resolve` 与正式 snapshot projection。 +- [M4_RUNTIME_STORY_RS_SPLIT_PLAN_2026-04-22.md](./M4_RUNTIME_STORY_RS_SPLIT_PLAN_2026-04-22.md):冻结 `runtime_story.rs` 从超大单文件拆到 `compat/ai/presentation/tests/battle/core/game_state/forge` 子模块的收口策略、验证要求与下一阶段纯规则下沉边界。 - [M4_MODULE_AI_BASELINE_DESIGN_2026-04-21.md](./M4_MODULE_AI_BASELINE_DESIGN_2026-04-21.md):冻结 `module-ai` 首版的任务/阶段/流式片段/结果引用领域模型、最小内存服务与后续 `platform-llm` / `api-server` / `spacetime-module` 的边界。 - [M4_MODULE_AI_SPACETIMEDB_BASELINE_2026-04-21.md](./M4_MODULE_AI_SPACETIMEDB_BASELINE_2026-04-21.md):记录 `module-ai` 在 `spacetime-module` 中首轮已落地的 `ai_task / ai_task_stage / ai_text_chunk / ai_result_reference` 真相表、最小 reducer/procedure 与当前仍未扩到真实模型调用和 Axum facade 的边界。 - [M4_MODULE_AI_AXUM_FACADE_DESIGN_2026-04-22.md](./M4_MODULE_AI_AXUM_FACADE_DESIGN_2026-04-22.md):冻结 `module-ai` 从 `shared-contracts`、`spacetime-client` 到 `api-server` 的最小 AI task mutation facade,明确 `start` 路由当前只返回 `202 Accepted`。 diff --git a/server-rs/crates/api-server/src/runtime_story.rs b/server-rs/crates/api-server/src/runtime_story.rs index f4372e8a..9e991994 100644 --- a/server-rs/crates/api-server/src/runtime_story.rs +++ b/server-rs/crates/api-server/src/runtime_story.rs @@ -1,3738 +1,6 @@ -use axum::{ - Json, - extract::{Extension, Path, State}, - 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 module_runtime::RuntimeSnapshotRecord; -use platform_llm::{LlmMessage, LlmTextRequest}; -use serde_json::{Map, Value, json}; -use shared_contracts::runtime_story::{ - RuntimeBattlePresentation, RuntimeStoryActionRequest, RuntimeStoryActionResponse, - RuntimeStoryAiRequest, RuntimeStoryAiResponse, RuntimeStoryCompanionViewModel, - RuntimeStoryEncounterViewModel, RuntimeStoryOptionInteraction, RuntimeStoryOptionView, - RuntimeStoryPatch, RuntimeStoryPlayerViewModel, RuntimeStoryPresentation, - RuntimeStorySnapshotPayload, RuntimeStoryStateResolveRequest, RuntimeStoryStatusViewModel, - RuntimeStoryViewModel, -}; -use shared_kernel::{format_rfc3339, 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, -}; - -const CONTINUE_ADVENTURE_FUNCTION_ID: &str = "story_continue_adventure"; -const MAX_TASK5_COMPANIONS: usize = 2; - -struct StoryResolution { - action_text: String, - result_text: String, - story_text: Option, - presentation_options: Option>, - saved_current_story: Option, - patches: Vec, - battle: Option, - toast: Option, -} - -struct CurrentEncounterNpcQuestContext { - npc_id: String, - npc_name: String, -} - -struct PendingQuestOfferContext { - dialogue: Vec, - turn_count: i32, - custom_input_placeholder: String, - quest: Value, - quest_id: String, - intro_text: Option, -} - -pub async fn resolve_runtime_story_state( - State(state): State, - Extension(request_context): Extension, - Extension(authenticated): Extension, - Json(payload): Json, -) -> Result, 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, - Path(session_id): Path, - Extension(request_context): Extension, - Extension(authenticated): Extension, -) -> Result, 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, - Extension(request_context): Extension, - Extension(authenticated): Extension, - Json(payload): Json, -) -> Result, 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 story_text = resolution - .story_text - .clone() - .unwrap_or_else(|| resolution.result_text.clone()); - let saved_current_story = resolution - .saved_current_story - .take() - .unwrap_or_else(|| build_legacy_current_story(story_text.as_str(), &options)); - append_story_history( - &mut game_state, - resolution.action_text.as_str(), - resolution.result_text.as_str(), - ); - - let mut patches = vec![RuntimeStoryPatch::StoryHistoryAppend { - action_text: resolution.action_text.clone(), - result_text: resolution.result_text.clone(), - }]; - 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, - Extension(request_context): Extension, - Extension(_authenticated): Extension, - Json(payload): Json, -) -> Result, 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, - Extension(request_context): Extension, - Extension(_authenticated): Extension, - Json(payload): Json, -) -> Result, 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, -) -> Result { - 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 { - 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, - 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 build_runtime_story_state_response( - requested_session_id: &str, - client_version: Option, - 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); - - 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, - }) -} - -struct RuntimeStoryActionResponseParts { - requested_session_id: String, - server_version: u32, - snapshot: RuntimeStorySnapshotPayload, - action_text: String, - result_text: String, - story_text: String, - options: Vec, - patches: Vec, - toast: Option, - battle: Option, -} - -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, - } -} - -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", - ), - }, - } -} - -fn resolve_runtime_story_choice_action( - game_state: &mut Value, - current_story: Option<&Value>, - request: &RuntimeStoryActionRequest, - function_id: &str, -) -> Result { - 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_recruit" => resolve_npc_recruit_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" => resolve_battle_action(game_state, request, function_id), - "treasure_secure" | "treasure_inspect" | "treasure_leave" => { - resolve_treasure_action(game_state, request, function_id) - } - _ => Err(format!("暂不支持的 runtime action:{function_id}")), - } -} - -fn resolve_continue_adventure_action( - current_story: Option<&Value>, -) -> Result { - let deferred_options = current_story - .map(|story| { - read_array_field(story, "deferredOptions") - .into_iter() - .filter_map(build_runtime_story_option_from_story_option) - .collect::>() - }) - .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 resolve_npc_preview_action( - game_state: &mut Value, - request: &RuntimeStoryActionRequest, -) -> Result { - 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, - }) -} - -fn resolve_npc_affinity_action( - game_state: &mut Value, - request: &RuntimeStoryActionRequest, - default_action_text: &str, - affinity_delta: i32, - fallback_result_text: &str, -) -> Result { - 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, - }) -} - -fn resolve_npc_chat_action( - game_state: &mut Value, - request: &RuntimeStoryActionRequest, -) -> Result { - 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) -} - -fn resolve_npc_help_action( - game_state: &mut Value, - request: &RuntimeStoryActionRequest, -) -> Result { - 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) - ), - ) -} - -fn resolve_pending_quest_offer_view_action( - game_state: &mut Value, - current_story: Option<&Value>, - request: &RuntimeStoryActionRequest, -) -> Result { - 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, - }) -} - -fn resolve_pending_quest_offer_replace_action( - game_state: &mut Value, - current_story: Option<&Value>, - request: &RuntimeStoryActionRequest, -) -> Result { - 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, - }) -} - -fn resolve_pending_quest_offer_abandon_action( - game_state: &mut Value, - current_story: Option<&Value>, - request: &RuntimeStoryActionRequest, -) -> Result { - 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, - }) -} - -fn resolve_pending_quest_accept_action( - game_state: &mut Value, - current_story: Option<&Value>, - request: &RuntimeStoryActionRequest, -) -> Result { - 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, - }) -} - -fn resolve_pending_quest_turn_in_action( - game_state: &mut Value, - request: &RuntimeStoryActionRequest, -) -> Result { - 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, - }) -} - -fn resolve_npc_battle_entry_action( - game_state: &mut Value, - request: &RuntimeStoryActionRequest, - function_id: &str, -) -> Result { - 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, - }) -} - -fn resolve_npc_recruit_action( - game_state: &mut Value, - request: &RuntimeStoryActionRequest, -) -> Result { - 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(), - npc_name.as_str(), - 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} 已加入队伍")), - }) -} - -fn resolve_battle_action( - game_state: &mut Value, - request: &RuntimeStoryActionRequest, - function_id: &str, -) -> Result { - 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 (damage_dealt, damage_taken, heal, mana_restore, mana_cost, action_text, result_text) = - battle_action_numbers(function_id); - spend_player_mana(game_state, mana_cost); - restore_player_resource(game_state, heal, mana_restore); - apply_player_damage(game_state, damage_taken); - let target_hp = apply_target_damage(game_state, damage_dealt); - let outcome = if target_hp <= 0 { - if battle_mode == "spar" { - "spar_complete" - } else { - "victory" - } - } else { - "ongoing" - }; - - 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); - } - } - - let mut patches = vec![ - RuntimeStoryPatch::BattleResolved { - function_id: function_id.to_string(), - target_id: Some(target_id.clone()), - damage_dealt: Some(damage_dealt), - damage_taken: Some(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(action_text, request), - result_text: if outcome == "ongoing" { - result_text.to_string() - } 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(damage_dealt), - damage_taken: Some(damage_taken), - outcome: Some(outcome.to_string()), - }), - toast: None, - }) -} - -fn resolve_treasure_action( - game_state: &mut Value, - request: &RuntimeStoryActionRequest, - function_id: &str, -) -> Result { - match function_id { - "treasure_secure" => { - 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: Some("已收取宝箱".to_string()), - }) - } - "treasure_inspect" => Ok(simple_story_resolution( - game_state, - resolve_action_text("仔细检查", request), - "你没有急着伸手,而是绕着目标重新检查机关、痕迹和可能的埋伏。", - )), - "treasure_leave" => { - 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, - }) - } - _ => Err(format!("暂不支持的 treasure action:{function_id}")), - } -} - -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, - } -} - -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, - } -} - -async fn generate_ai_story_text( - state: &AppState, - payload: &RuntimeStoryAiRequest, - initial: bool, -) -> Option { - 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()) -} - -fn build_ai_response_options(payload: &RuntimeStoryAiRequest) -> Vec { - 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::>(); - 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", "原地调息"), - ] -} - -fn normalize_ai_story_option(value: &Value) -> Option { - 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)) -} - -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": [] - } - }) -} - -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} 的局势随之向下一步展开。") -} - -fn build_runtime_story_companions(game_state: &Value) -> Vec { - 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 { - 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"), - }) -} - -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 { - 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::>(); - - if !compiled.is_empty() { - return compiled; - } - } - - build_fallback_runtime_story_options(game_state) -} - -fn build_runtime_story_option_from_story_option(value: &Value) -> Option { - 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, - }) -} - -fn build_runtime_story_option_interaction( - value: Option<&Value>, -) -> Option { - 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 { - 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); - 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 vec![ - build_npc_runtime_story_option("npc_chat", "继续交谈", &npc_id, "chat"), - build_npc_runtime_story_option("npc_help", "请求援手", &npc_id, "help"), - build_npc_runtime_story_option( - "npc_recruit", - "邀请同行", - &npc_id, - "recruit", - ), - build_npc_runtime_story_option("npc_spar", "点到为止切磋", &npc_id, "spar"), - build_npc_runtime_story_option("npc_fight", "与对方战斗", &npc_id, "fight"), - build_npc_runtime_story_option( - "npc_leave", - "离开当前角色", - &npc_id, - "leave", - ), - ]; - } - - 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"), - ]; - } - Some("treasure") => { - return vec![ - build_treasure_runtime_story_option("treasure_secure", "直接收取", "secure"), - build_treasure_runtime_story_option("treasure_inspect", "仔细检查", "inspect"), - build_treasure_runtime_story_option("treasure_leave", "先记下位置", "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"), - ] -} - -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_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") - } -} - -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) - } -} - -fn build_npc_runtime_story_option_with_quest( - function_id: &str, - action_text: &str, - npc_id: &str, - action: &str, - quest_id: Option, -) -> 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") - } -} - -fn build_treasure_runtime_story_option( - function_id: &str, - action_text: &str, - action: &str, -) -> RuntimeStoryOptionView { - RuntimeStoryOptionView { - interaction: Some(RuntimeStoryOptionInteraction::Treasure { - action: action.to_string(), - }), - ..build_static_runtime_story_option(function_id, action_text, "story") - } -} - -fn current_encounter_npc_quest_context( - game_state: &Value, -) -> Result { - 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 }) -} - -fn read_pending_quest_offer_context( - current_story: Option<&Value>, - npc_key: &str, -) -> Option { - 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"), - }) -} - -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}" - ) -} - -fn append_dialogue_turns(existing: &[Value], additions: Vec) -> Vec { - let mut dialogue = existing.to_vec(); - dialogue.extend(additions); - dialogue -} - -fn build_pending_quest_offer_options(npc_id: &str) -> Vec { - 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" - }), - ), - ] -} - -fn build_post_quest_offer_chat_options(npc_id: &str) -> Vec { - 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", - ), - ] -} - -fn build_post_quest_accept_chat_options(npc_id: &str) -> Vec { - 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", - ), - ] -} - -fn build_pending_quest_offer_story( - dialogue: Vec, - npc_id: &str, - npc_name: &str, - turn_count: i32, - custom_input_placeholder: &str, - pending_quest: Option, - options: &[RuntimeStoryOptionView], -) -> Value { - json!({ - "text": dialogue - .iter() - .filter_map(|entry| read_optional_string_field(entry, "text")) - .collect::>() - .join("\n"), - "options": options.iter().map(build_story_option_from_runtime_option).collect::>(), - "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 })), - } - }) -} - -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": "inspect_treasure", - "requiredCount": 1 - }, - "progress": 0, - "status": "active", - "reward": { - "affinityBonus": 6, - "currency": 30, - "items": [] - }, - "rewardText": "完成后可以领取报酬。", - "steps": [{ - "id": format!("{next_id}-step-1"), - "title": "查清线索", - "kind": "inspect_treasure", - "requiredCount": 1, - "progress": 0, - "revealText": "先去断桥口附近看看留下了什么痕迹。", - "completeText": "线索已经查清。" - }], - "activeStepId": format!("{next_id}-step-1") - }) -} - -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") - }) -} - -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()); -} - -fn first_quest_reveal_text(quest: &Value) -> Option { - read_array_field(quest, "steps") - .first() - .and_then(|step| read_optional_string_field(step, "revealText")) -} - -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}」,接下来可以开始推进任务目标。") -} - -fn turn_in_quest_record( - game_state: &mut Value, - issuer_npc_id: &str, - quest_id: &str, -) -> Result { - 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) -} - -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}") -} - -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::>(); - 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"); - } -} - -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 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::>(), - "streaming": false - }) -} - -fn build_story_option_from_runtime_option(option: &RuntimeStoryOptionView) -> Value { - 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, - }) -} - -fn read_story_text(current_story: Option<&Value>) -> Option { - 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 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()) -} - -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", - ), - } -} - -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), - ); -} - -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 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 battle_mode_text(value: &str) -> &'static str { - if value == "spar" { "切磋" } else { "战斗" } -} - -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()) -} - -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()) -} - -fn current_encounter_id(game_state: &Value) -> Option { - read_object_field(game_state, "currentEncounter") - .and_then(|encounter| read_optional_string_field(encounter, "id")) -} - -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)) -} - -fn read_current_npc_state_i32_field(game_state: &Value, key: &str) -> Option { - 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)) -} - -fn read_current_npc_state_bool_field(game_state: &Value, key: &str) -> Option { - 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)) -} - -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)); -} - -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)); -} - -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)) -} - -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) -} - -fn ensure_npc_state_object<'a>( - game_state: &'a mut Value, - npc_id: &str, - npc_name: &str, -) -> &'a mut Map { - 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") -} - -fn add_companion_if_absent( - game_state: &mut Value, - npc_id: &str, - character_id: Option, - 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 { - 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)) -} - -fn recruit_companion_to_party( - game_state: &mut Value, - npc_id: &str, - _npc_name: &str, - release_npc_id: Option<&str>, -) -> Result, 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, - read_current_npc_affinity(game_state), - ); - 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_else(|| release_npc_id.clone()); - add_companion_if_absent( - game_state, - npc_id, - None, - read_current_npc_affinity(game_state), - ); - Ok(Some(released_name)) -} - -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"); -} - -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())); -} - -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", - })); -} - -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))); -} - -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)), - ); -} - -fn add_player_inventory_items(game_state: &mut Value, additions: Vec) { - 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); -} - -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()), - ); -} - -const MAX_PLAYER_LEVEL: i32 = 20; - -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 - } -} - -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 -} - -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 -} - -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)); - } -} - -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)); - } -} - -fn first_hostile_npc_string_field(game_state: &Value, key: &str) -> Option { - read_array_field(game_state, "sceneHostileNpcs") - .first() - .and_then(|target| read_optional_string_field(target, key)) -} - -fn read_runtime_session_id(game_state: &Value) -> Option { - 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 { - normalize_required_string(read_field(value, key)?.as_str()?) -} - -fn read_optional_string_field(value: &Value, key: &str) -> Option { - normalize_optional_string(read_field(value, key).and_then(Value::as_str)) -} - -fn read_bool_field(value: &Value, key: &str) -> Option { - read_field(value, key).and_then(Value::as_bool) -} - -fn read_i32_field(value: &Value, key: &str) -> Option { - 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 { - read_field(value, key) - .and_then(Value::as_u64) - .and_then(|number| u32::try_from(number).ok()) -} - -fn write_i32_field(value: &mut Value, key: &str, field_value: i32) { - ensure_json_object(value).insert(key.to_string(), json!(field_value)); -} - -fn write_u32_field(value: &mut Value, key: &str, field_value: u32) { - ensure_json_object(value).insert(key.to_string(), json!(field_value)); -} - -fn write_bool_field(value: &mut Value, key: &str, field_value: bool) { - ensure_json_object(value).insert(key.to_string(), Value::Bool(field_value)); -} - -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())); -} - -fn write_null_field(value: &mut Value, key: &str) { - ensure_json_object(value).insert(key.to_string(), Value::Null); -} - -fn ensure_json_object(value: &mut Value) -> &mut Map { - if !value.is_object() { - *value = Value::Object(Map::new()); - } - value.as_object_mut().expect("value should be object") -} - -fn normalize_required_string(value: &str) -> Option { - let trimmed = value.trim(); - (!trimmed.is_empty()).then(|| trimmed.to_string()) -} - -fn normalize_optional_string(value: Option<&str>) -> Option { - value.and_then(normalize_required_string) -} - -fn format_now_rfc3339() -> String { - format_rfc3339(OffsetDateTime::now_utc()).unwrap_or_else(|_| "1970-01-01T00:00:00Z".to_string()) -} - -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)) -} - -#[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 super::*; - 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": { - "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_get_requires_authentication() { - let app = build_router(AppState::new(AppConfig::default()).expect("state should build")); - - let response = app - .oneshot( - Request::builder() - .method("GET") - .uri("/api/runtime/story/state/runtime-main") - .body(Body::empty()) - .expect("request should build"), - ) - .await - .expect("request should succeed"); - - assert_eq!(response.status(), StatusCode::UNAUTHORIZED); - } - - #[tokio::test] - async fn runtime_story_action_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/actions/resolve") - .header("content-type", "application/json") - .body(Body::from( - json!({ - "sessionId": "runtime-main", - "action": { - "type": "story_choice", - "functionId": "idle_rest_focus" - } - }) - .to_string(), - )) - .expect("request should build"), - ) - .await - .expect("request should succeed"); - - assert_eq!(response.status(), StatusCode::UNAUTHORIZED); - } - - #[tokio::test] - async fn runtime_story_routes_resolve_through_rust_route_boundary() { - let state = seed_authenticated_state().await; - let token = issue_access_token(&state); - let app = build_router(state); - let snapshot_payload = json!({ - "bottomTab": "adventure", - "gameState": build_runtime_story_boundary_game_state_fixture(), - "currentStory": { - "text": "巡路人看着你,像在等一句开口。", - "options": [] - } - }); - - let put_response = app - .clone() - .oneshot( - Request::builder() - .method("PUT") - .uri("/api/runtime/save/snapshot") - .header("authorization", format!("Bearer {token}")) - .header("content-type", "application/json") - .header("x-genarrative-response-envelope", "v1") - .body(Body::from(snapshot_payload.to_string())) - .expect("request should build"), - ) - .await - .expect("request should succeed"); - assert_eq!(put_response.status(), StatusCode::OK); - - let state_response = app - .clone() - .oneshot( - Request::builder() - .method("GET") - .uri("/api/runtime/story/state/runtime-main") - .header("authorization", format!("Bearer {token}")) - .header("x-genarrative-response-envelope", "v1") - .body(Body::empty()) - .expect("request should build"), - ) - .await - .expect("request should succeed"); - assert_eq!(state_response.status(), StatusCode::OK); - let state_payload: Value = serde_json::from_slice( - &state_response - .into_body() - .collect() - .await - .expect("body should collect") - .to_bytes(), - ) - .expect("response should be json"); - assert!( - state_payload["data"]["viewModel"]["availableOptions"] - .as_array() - .is_some_and(|options| options - .iter() - .any(|option| { option["functionId"] == json!("npc_chat") })) - ); - - let action_response = app - .oneshot( - Request::builder() - .method("POST") - .uri("/api/runtime/story/actions/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": 0, - "action": { - "type": "story_choice", - "functionId": "npc_chat" - } - }) - .to_string(), - )) - .expect("request should build"), - ) - .await - .expect("request should succeed"); - assert_eq!(action_response.status(), StatusCode::OK); - let action_payload: Value = serde_json::from_slice( - &action_response - .into_body() - .collect() - .await - .expect("body should collect") - .to_bytes(), - ) - .expect("response should be json"); - assert_eq!(action_payload["data"]["serverVersion"], json!(1)); - assert_eq!( - action_payload["data"]["viewModel"]["encounter"]["affinity"], - json!(52) - ); - } - - #[tokio::test] - async fn runtime_story_action_resolve_rejects_client_version_conflict() { - let state = seed_authenticated_state().await; - let token = issue_access_token(&state); - let app = build_router(state); - - let put_response = app - .clone() - .oneshot( - Request::builder() - .method("PUT") - .uri("/api/runtime/save/snapshot") - .header("authorization", format!("Bearer {token}")) - .header("content-type", "application/json") - .header("x-genarrative-response-envelope", "v1") - .body(Body::from( - json!({ - "bottomTab": "adventure", - "gameState": { - "runtimeSessionId": "runtime-main", - "runtimeActionVersion": 5, - "playerHp": 20, - "playerMaxHp": 30, - "playerMana": 4, - "playerMaxMana": 12, - "storyHistory": [] - }, - "currentStory": { - "text": "旧局势仍然悬着。", - "options": [] - } - }) - .to_string(), - )) - .expect("request should build"), - ) - .await - .expect("request should succeed"); - assert_eq!(put_response.status(), StatusCode::OK); - - let response = app - .oneshot( - Request::builder() - .method("POST") - .uri("/api/runtime/story/actions/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": 4, - "action": { - "type": "story_choice", - "functionId": "idle_rest_focus" - } - }) - .to_string(), - )) - .expect("request should build"), - ) - .await - .expect("request should succeed"); - - assert_eq!(response.status(), StatusCode::CONFLICT); - let payload: Value = serde_json::from_slice( - &response - .into_body() - .collect() - .await - .expect("body should collect") - .to_bytes(), - ) - .expect("response should be json"); - assert_eq!(payload["error"]["details"]["clientVersion"], json!(4)); - assert_eq!(payload["error"]["details"]["serverVersion"], json!(5)); - } - - #[tokio::test] - async fn runtime_story_initial_returns_fallback_without_llm() { - 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/initial") - .header("authorization", format!("Bearer {token}")) - .header("content-type", "application/json") - .header("x-genarrative-response-envelope", "v1") - .body(Body::from( - json!({ - "worldType": "martial", - "character": { "name": "林迟" }, - "monsters": [], - "context": { "sceneName": "旧驿道" }, - "requestOptions": { - "availableOptions": [{ - "functionId": "idle_observe_signs", - "actionText": "观察周围迹象" - }] - } - }) - .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"]["options"][0]["functionId"], - json!("idle_observe_signs") - ); - assert!( - payload["data"]["storyText"] - .as_str() - .is_some_and(|text| text.contains("林迟")) - ); - } - - #[test] - fn runtime_story_state_compiler_prefers_dialogue_deferred_options() { - let response = build_runtime_story_state_response( - "runtime-main", - Some(7), - RuntimeStorySnapshotPayload { - saved_at: None, - bottom_tab: "adventure".to_string(), - game_state: json!({ - "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 - }] - }), - current_story: Some(json!({ - "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" - } - }] - })), - }, - ); - - assert_eq!(response.session_id, "runtime-main"); - assert_eq!(response.server_version, 7); - assert_eq!( - response - .view_model - .encounter - .as_ref() - .expect("encounter should exist") - .npc_name, - "守火人" - ); - assert_eq!( - response.view_model.available_options[0].function_id, - "npc_chat" - ); - assert!(matches!( - response.presentation.options[0].interaction, - Some(RuntimeStoryOptionInteraction::Npc { .. }) - )); - } - - #[test] - fn runtime_story_action_resolution_updates_version_and_history() { - let request = RuntimeStoryActionRequest { - session_id: "runtime-main".to_string(), - client_version: Some(3), - action: shared_contracts::runtime_story::RuntimeStoryChoiceAction { - action_type: "story_choice".to_string(), - function_id: "idle_rest_focus".to_string(), - target_id: None, - payload: Some(json!({ "optionText": "原地调息" })), - }, - snapshot: None, - }; - let mut game_state = json!({ - "runtimeSessionId": "runtime-main", - "runtimeActionVersion": 3, - "playerHp": 10, - "playerMaxHp": 30, - "playerMana": 2, - "playerMaxMana": 12, - "storyHistory": [] - }); - - let resolution = - resolve_runtime_story_choice_action(&mut game_state, None, &request, "idle_rest_focus") - .expect("action should resolve"); - let next_version = read_u32_field(&game_state, "runtimeActionVersion") - .unwrap_or(3) - .saturating_add(1); - write_u32_field(&mut game_state, "runtimeActionVersion", next_version); - append_story_history( - &mut game_state, - resolution.action_text.as_str(), - resolution.result_text.as_str(), - ); - - assert_eq!(read_i32_field(&game_state, "playerHp"), Some(18)); - assert_eq!(read_i32_field(&game_state, "playerMana"), Some(8)); - assert_eq!(read_u32_field(&game_state, "runtimeActionVersion"), Some(4)); - assert_eq!( - read_array_field(&game_state, "storyHistory") - .first() - .and_then(|entry| read_optional_string_field(entry, "historyRole")), - Some("action".to_string()) - ); - } - - #[test] - fn runtime_story_npc_help_is_one_shot_and_restores_resources() { - let request = RuntimeStoryActionRequest { - session_id: "runtime-main".to_string(), - client_version: Some(0), - action: shared_contracts::runtime_story::RuntimeStoryChoiceAction { - action_type: "story_choice".to_string(), - function_id: "npc_help".to_string(), - target_id: None, - payload: Some(json!({ "optionText": "请求援手" })), - }, - snapshot: None, - }; - let mut game_state = build_runtime_story_boundary_game_state_fixture(); - write_i32_field(&mut game_state, "playerHp", 20); - write_i32_field(&mut game_state, "playerMana", 4); - - let first = - resolve_runtime_story_choice_action(&mut game_state, None, &request, "npc_help") - .expect("first help should resolve"); - - assert!(first.result_text.contains("及时支援")); - assert_eq!(read_i32_field(&game_state, "playerHp"), Some(30)); - assert_eq!(read_i32_field(&game_state, "playerMana"), Some(12)); - assert_eq!( - read_current_npc_state_bool_field(&game_state, "helpUsed"), - Some(true) - ); - - let second = - resolve_runtime_story_choice_action(&mut game_state, None, &request, "npc_help"); - match second { - Ok(_) => panic!("second help should be rejected"), - Err(error) => assert_eq!(error, "当前 NPC 的一次性援手已经用完了"), - } - } - - #[test] - fn runtime_story_npc_recruit_requires_threshold_and_release_target_when_party_full() { - let request = RuntimeStoryActionRequest { - session_id: "runtime-main".to_string(), - client_version: Some(0), - action: shared_contracts::runtime_story::RuntimeStoryChoiceAction { - action_type: "story_choice".to_string(), - function_id: "npc_recruit".to_string(), - target_id: None, - payload: Some(json!({ "optionText": "邀请同行" })), - }, - snapshot: None, - }; - - let mut low_affinity_state = build_runtime_story_boundary_game_state_fixture(); - let error = resolve_runtime_story_choice_action( - &mut low_affinity_state, - None, - &request, - "npc_recruit", - ); - match error { - Ok(_) => panic!("low affinity recruit should be rejected"), - Err(message) => assert_eq!(message, "当前关系还没达到招募阈值,暂时不能邀请入队"), - } - - let mut full_party_state = build_runtime_story_boundary_game_state_fixture(); - write_current_npc_state_i32_field(&mut full_party_state, "affinity", 60); - let root = ensure_json_object(&mut full_party_state); - root.insert( - "companions".to_string(), - json!([ - { - "npcId": "npc-ally-1", - "characterId": "char-ally-1", - "joinedAtAffinity": 64, - "npcName": "旧同伴甲" - }, - { - "npcId": "npc-ally-2", - "characterId": "char-ally-2", - "joinedAtAffinity": 61, - "npcName": "旧同伴乙" - } - ]), - ); - - let full_party_error = resolve_runtime_story_choice_action( - &mut full_party_state, - None, - &request, - "npc_recruit", - ); - match full_party_error { - Ok(_) => panic!("full party recruit should require release target"), - Err(message) => assert_eq!(message, "队伍已满时必须明确指定一名离队同伴"), - } - - let request_with_release = RuntimeStoryActionRequest { - action: shared_contracts::runtime_story::RuntimeStoryChoiceAction { - payload: Some(json!({ - "optionText": "邀请同行", - "releaseNpcId": "npc-ally-1" - })), - ..request.action.clone() - }, - ..request - }; - let resolution = resolve_runtime_story_choice_action( - &mut full_party_state, - None, - &request_with_release, - "npc_recruit", - ) - .expect("recruit with release target should resolve"); - - assert!(resolution.result_text.contains("旧同伴甲")); - assert_eq!(read_array_field(&full_party_state, "companions").len(), 2); - assert!( - read_array_field(&full_party_state, "companions") - .iter() - .any(|entry| { - read_optional_string_field(entry, "npcId").as_deref() == Some("npc_merchant_01") - }) - ); - assert_eq!( - read_field(&full_party_state, "currentEncounter"), - Some(&Value::Null) - ); - } - - #[test] - fn runtime_story_quest_offer_replace_updates_pending_offer_and_payload() { - let request = RuntimeStoryActionRequest { - session_id: "runtime-main".to_string(), - client_version: Some(0), - action: shared_contracts::runtime_story::RuntimeStoryChoiceAction { - action_type: "story_choice".to_string(), - function_id: "npc_chat_quest_offer_replace".to_string(), - target_id: None, - payload: Some(json!({ - "optionText": "更换任务" - })), - }, - snapshot: None, - }; - let mut game_state = build_runtime_story_boundary_game_state_fixture(); - let current_story = build_runtime_story_pending_quest_offer_fixture( - build_runtime_story_boundary_quest_fixture("quest-bridge-offer", "断桥口的密信"), - ); - - let resolution = resolve_runtime_story_choice_action( - &mut game_state, - Some(¤t_story), - &request, - "npc_chat_quest_offer_replace", - ) - .expect("quest replace should resolve"); - - let saved_current_story = resolution - .saved_current_story - .expect("quest replace should save current story"); - let pending_quest = read_field(&saved_current_story, "npcChatState") - .and_then(|state| read_field(state, "pendingQuestOffer")) - .and_then(|offer| read_field(offer, "quest")) - .expect("pending quest should exist after replace"); - assert_eq!( - read_optional_string_field(pending_quest, "id"), - Some("quest-bridge-replaced".to_string()) - ); - - let options = resolution - .presentation_options - .expect("quest replace should expose options"); - assert_eq!(options.len(), 3); - assert_eq!( - options[1].payload.as_ref().and_then(|payload| { - read_optional_string_field(payload, "npcChatQuestOfferAction") - }), - Some("replace".to_string()) - ); - } - - #[test] - fn runtime_story_quest_offer_abandon_clears_pending_offer_and_restores_chat_options() { - let request = RuntimeStoryActionRequest { - session_id: "runtime-main".to_string(), - client_version: Some(0), - action: shared_contracts::runtime_story::RuntimeStoryChoiceAction { - action_type: "story_choice".to_string(), - function_id: "npc_chat_quest_offer_abandon".to_string(), - target_id: None, - payload: Some(json!({ - "optionText": "放弃任务" - })), - }, - snapshot: None, - }; - let mut game_state = build_runtime_story_boundary_game_state_fixture(); - let current_story = build_runtime_story_pending_quest_offer_fixture( - build_runtime_story_boundary_quest_fixture("quest-bridge-offer", "断桥口的密信"), - ); - - let resolution = resolve_runtime_story_choice_action( - &mut game_state, - Some(¤t_story), - &request, - "npc_chat_quest_offer_abandon", - ) - .expect("quest abandon should resolve"); - - let saved_current_story = resolution - .saved_current_story - .expect("quest abandon should save current story"); - assert_eq!( - read_field(&saved_current_story, "npcChatState") - .and_then(|state| read_field(state, "pendingQuestOffer")), - Some(&Value::Null) - ); - let options = resolution - .presentation_options - .expect("quest abandon should expose follow-up chat options"); - assert_eq!(options.len(), 3); - assert!( - options - .iter() - .all(|option| option.function_id == "npc_chat") - ); - assert_eq!(options[0].action_text, "那先继续聊聊你刚才没说完的部分"); - } - - #[test] - fn runtime_story_quest_accept_writes_quest_runtime_stats_and_followup_story() { - let request = RuntimeStoryActionRequest { - session_id: "runtime-main".to_string(), - client_version: Some(0), - action: shared_contracts::runtime_story::RuntimeStoryChoiceAction { - action_type: "story_choice".to_string(), - function_id: "npc_quest_accept".to_string(), - target_id: None, - payload: Some(json!({ - "optionText": "接受任务" - })), - }, - snapshot: None, - }; - let mut game_state = build_runtime_story_boundary_game_state_fixture(); - let pending_quest = - build_runtime_story_boundary_quest_fixture("quest-bridge-offer", "断桥口的密信"); - let current_story = build_runtime_story_pending_quest_offer_fixture(pending_quest.clone()); - - let resolution = resolve_runtime_story_choice_action( - &mut game_state, - Some(¤t_story), - &request, - "npc_quest_accept", - ) - .expect("quest accept should resolve"); - - let quests = read_array_field(&game_state, "quests"); - assert_eq!(quests.len(), 1); - assert_eq!( - read_optional_string_field(quests[0], "id"), - read_optional_string_field(&pending_quest, "id") - ); - assert_eq!( - read_field(&game_state, "runtimeStats") - .and_then(|stats| read_i32_field(stats, "questsAccepted")), - Some(1) - ); - let saved_current_story = resolution - .saved_current_story - .expect("quest accept should save current story"); - assert_eq!( - read_field(&saved_current_story, "npcChatState") - .and_then(|state| read_field(state, "pendingQuestOffer")), - Some(&Value::Null) - ); - assert_eq!( - resolution - .presentation_options - .expect("quest accept should expose follow-up options") - .len(), - 3 - ); - } - - #[test] - fn runtime_story_quest_turn_in_marks_quest_rewards_and_affinity() { - let request = RuntimeStoryActionRequest { - session_id: "runtime-main".to_string(), - client_version: Some(0), - action: shared_contracts::runtime_story::RuntimeStoryChoiceAction { - action_type: "story_choice".to_string(), - function_id: "npc_quest_turn_in".to_string(), - target_id: None, - payload: Some(json!({ - "optionText": "交付任务", - "questId": "quest-bridge-complete" - })), - }, - snapshot: None, - }; - let mut game_state = build_runtime_story_boundary_game_state_fixture(); - let mut completed_quest = - build_runtime_story_boundary_quest_fixture("quest-bridge-complete", "断桥夜巡"); - if let Some(quest) = completed_quest.as_object_mut() { - quest.insert("status".to_string(), Value::String("completed".to_string())); - quest.insert( - "reward".to_string(), - json!({ - "affinityBonus": 6, - "currency": 30, - "experience": 24, - "items": [{ - "id": "reward-med-1", - "category": "补给", - "name": "回气散", - "quantity": 1, - "tags": [] - }] - }), - ); - } - push_quest_record(&mut game_state, &completed_quest); - - let resolution = resolve_runtime_story_choice_action( - &mut game_state, - None, - &request, - "npc_quest_turn_in", - ) - .expect("quest turn in should resolve"); - - let quests = read_array_field(&game_state, "quests"); - assert_eq!(quests.len(), 1); - assert_eq!( - read_optional_string_field(quests[0], "status"), - Some("turned_in".to_string()) - ); - assert_eq!(read_i32_field(&game_state, "playerCurrency"), Some(120)); - assert_eq!(read_array_field(&game_state, "playerInventory").len(), 1); - assert_eq!( - read_field(&game_state, "playerProgression") - .and_then(|progression| read_i32_field(progression, "totalXp")), - Some(24) - ); - assert_eq!( - read_current_npc_state_i32_field(&game_state, "affinity"), - Some(52) - ); - assert!(resolution.patches.iter().any(|patch| matches!( - patch, - RuntimeStoryPatch::NpcAffinityChanged { - previous_affinity: 46, - next_affinity: 52, - .. - } - ))); - } - - 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") - } - - fn build_runtime_story_boundary_game_state_fixture() -> Value { - serde_json::from_str( - r#"{ - "worldType": "WUXIA", - "runtimeSessionId": "runtime-main", - "runtimeActionVersion": 0, - "playerCharacter": { - "id": "hero-story", - "title": "试剑客", - "description": "站在桥口的人。", - "personality": "谨慎", - "attributes": { - "strength": 8, - "spirit": 6 - }, - "skills": [] - }, - "runtimeStats": { - "playTimeMs": 0, - "lastPlayTickAt": null, - "hostileNpcsDefeated": 0, - "questsAccepted": 0, - "itemsUsed": 0, - "scenesTraveled": 0 - }, - "currentScene": "test-scene", - "storyHistory": [], - "characterChats": {}, - "animationState": "idle", - "currentEncounter": { - "kind": "npc", - "id": "npc_merchant_01", - "npcName": "沈七", - "npcDescription": "腰间挂着药囊的行商", - "context": "受伤行商", - "hostile": false - }, - "npcInteractionActive": true, - "currentScenePreset": null, - "sceneHostileNpcs": [], - "playerX": 0, - "playerOffsetY": 0, - "playerFacing": "right", - "playerActionMode": "idle", - "scrollWorld": false, - "inBattle": false, - "playerHp": 31, - "playerMaxHp": 40, - "playerMana": 9, - "playerMaxMana": 16, - "playerSkillCooldowns": {}, - "activeBuildBuffs": [], - "activeCombatEffects": [], - "playerCurrency": 90, - "playerInventory": [], - "playerEquipment": { - "weapon": null, - "armor": null, - "relic": null - }, - "npcStates": { - "npc_merchant_01": { - "affinity": 46, - "chattedCount": 0, - "helpUsed": false, - "giftsGiven": 0, - "inventory": [], - "recruited": false - } - }, - "quests": [], - "roster": [], - "companions": [], - "currentNpcBattleMode": null, - "currentNpcBattleOutcome": null, - "sparReturnEncounter": null, - "sparPlayerHpBefore": null, - "sparPlayerMaxHpBefore": null, - "sparStoryHistoryBefore": null, - "playerProgression": { - "level": 1, - "currentLevelXp": 0, - "totalXp": 0, - "xpToNextLevel": 60, - "pendingLevelUps": 0, - "lastGrantedSource": null - } - }"#, - ) - .expect("runtime story boundary game state fixture should parse") - } - - fn build_runtime_story_boundary_quest_fixture(quest_id: &str, title: &str) -> Value { - json!({ - "id": quest_id, - "issuerNpcId": "npc_merchant_01", - "issuerNpcName": "沈七", - "sceneId": "scene-bridge", - "title": title, - "description": format!("{title}的详细说明。"), - "summary": format!("{title}的简要目标。"), - "objective": { - "kind": "inspect_treasure", - "requiredCount": 1 - }, - "progress": 0, - "status": "active", - "reward": { - "affinityBonus": 6, - "currency": 30, - "items": [] - }, - "rewardText": "完成后可以领取报酬。", - "steps": [{ - "id": format!("{quest_id}-step-1"), - "title": "查清线索", - "kind": "inspect_treasure", - "requiredCount": 1, - "progress": 0, - "revealText": "先去断桥口附近看看留下了什么痕迹。", - "completeText": "线索已经查清。" - }], - "activeStepId": format!("{quest_id}-step-1") - }) - } - - fn build_runtime_story_pending_quest_offer_fixture(quest: Value) -> Value { - json!({ - "text": "沈七终于把真正的委托说了出来。", - "options": [], - "displayMode": "dialogue", - "dialogue": [{ - "speaker": "npc", - "speakerName": "沈七", - "text": "这件事我只想托给你。" - }], - "npcChatState": { - "npcId": "npc_merchant_01", - "npcName": "沈七", - "turnCount": 2, - "customInputPlaceholder": "输入你想对 TA 说的话", - "pendingQuestOffer": { - "quest": quest - } - } - }) - } -} diff --git a/server-rs/crates/api-server/src/runtime_story/compat.rs b/server-rs/crates/api-server/src/runtime_story/compat.rs new file mode 100644 index 00000000..3813c8b0 --- /dev/null +++ b/server-rs/crates/api-server/src/runtime_story/compat.rs @@ -0,0 +1,1993 @@ +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 platform_llm::{LlmClient, LlmMessage, LlmTextRequest}; +use serde_json::{Map, Value, json}; +use shared_contracts::runtime_story::{ + RuntimeBattlePresentation, RuntimeStoryActionRequest, RuntimeStoryActionResponse, + RuntimeStoryAiRequest, RuntimeStoryAiResponse, RuntimeStoryCompanionViewModel, + RuntimeStoryEncounterViewModel, RuntimeStoryOptionInteraction, RuntimeStoryOptionView, + RuntimeStoryPatch, RuntimeStoryPlayerViewModel, RuntimeStoryPresentation, + RuntimeStorySnapshotPayload, RuntimeStoryStateResolveRequest, RuntimeStoryStatusViewModel, + RuntimeStoryViewModel, +}; +use shared_kernel::{format_rfc3339, 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/battle.rs"] +mod battle; +#[path = "compat/core.rs"] +mod core; +#[path = "compat/forge.rs"] +mod forge; +#[path = "compat/game_state.rs"] +mod game_state; +#[path = "compat/presentation.rs"] +mod presentation; + +use self::{ai::*, battle::*, core::*, forge::*, game_state::*, presentation::*}; + +#[cfg(test)] +#[path = "compat/tests.rs"] +mod tests; + +const CONTINUE_ADVENTURE_FUNCTION_ID: &str = "story_continue_adventure"; +const MAX_TASK5_COMPANIONS: usize = 2; + +struct StoryResolution { + action_text: String, + result_text: String, + story_text: Option, + presentation_options: Option>, + saved_current_story: Option, + patches: Vec, + battle: Option, + toast: Option, +} + +struct GeneratedStoryPayload { + story_text: String, + history_result_text: String, + presentation_options: Vec, + saved_current_story: Value, +} + +struct CurrentEncounterNpcQuestContext { + npc_id: String, + npc_name: String, +} + +struct PendingQuestOfferContext { + dialogue: Vec, + turn_count: i32, + custom_input_placeholder: String, + quest: Value, + quest_id: String, + intro_text: Option, +} + +pub async fn resolve_runtime_story_state( + State(state): State, + Extension(request_context): Extension, + Extension(authenticated): Extension, + Json(payload): Json, +) -> Result, 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, + Path(session_id): Path, + Extension(request_context): Extension, + Extension(authenticated): Extension, +) -> Result, 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, + Extension(request_context): Extension, + Extension(authenticated): Extension, + Json(payload): Json, +) -> Result, 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, + Extension(request_context): Extension, + Extension(_authenticated): Extension, + Json(payload): Json, +) -> Result, 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, + Extension(request_context): Extension, + Extension(_authenticated): Extension, + Json(payload): Json, +) -> Result, 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, +) -> Result { + 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 { + 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, + 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, + })), + )) +} + +struct RuntimeStoryActionResponseParts { + requested_session_id: String, + server_version: u32, + snapshot: RuntimeStorySnapshotPayload, + action_text: String, + result_text: String, + story_text: String, + options: Vec, + patches: Vec, + toast: Option, + battle: Option, +} + +fn resolve_runtime_story_choice_action( + game_state: &mut Value, + current_story: Option<&Value>, + request: &RuntimeStoryActionRequest, + function_id: &str, +) -> Result { + 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 { + let deferred_options = current_story + .map(|story| { + read_array_field(story, "deferredOptions") + .into_iter() + .filter_map(build_runtime_story_option_from_story_option) + .collect::>() + }) + .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 resolve_npc_preview_action( + game_state: &mut Value, + request: &RuntimeStoryActionRequest, +) -> Result { + 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, + }) +} + +fn resolve_npc_affinity_action( + game_state: &mut Value, + request: &RuntimeStoryActionRequest, + default_action_text: &str, + affinity_delta: i32, + fallback_result_text: &str, +) -> Result { + 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, + }) +} + +fn resolve_npc_chat_action( + game_state: &mut Value, + request: &RuntimeStoryActionRequest, +) -> Result { + 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) +} + +fn resolve_npc_help_action( + game_state: &mut Value, + request: &RuntimeStoryActionRequest, +) -> Result { + 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) + ), + ) +} + +fn resolve_pending_quest_offer_view_action( + game_state: &mut Value, + current_story: Option<&Value>, + request: &RuntimeStoryActionRequest, +) -> Result { + 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, + }) +} + +fn resolve_pending_quest_offer_replace_action( + game_state: &mut Value, + current_story: Option<&Value>, + request: &RuntimeStoryActionRequest, +) -> Result { + 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, + }) +} + +fn resolve_pending_quest_offer_abandon_action( + game_state: &mut Value, + current_story: Option<&Value>, + request: &RuntimeStoryActionRequest, +) -> Result { + 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, + }) +} + +fn resolve_pending_quest_accept_action( + game_state: &mut Value, + current_story: Option<&Value>, + request: &RuntimeStoryActionRequest, +) -> Result { + 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, + }) +} + +fn resolve_pending_quest_turn_in_action( + game_state: &mut Value, + request: &RuntimeStoryActionRequest, +) -> Result { + 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, + }) +} + +fn resolve_npc_battle_entry_action( + game_state: &mut Value, + request: &RuntimeStoryActionRequest, + function_id: &str, +) -> Result { + 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, + }) +} + +fn resolve_npc_recruit_action( + game_state: &mut Value, + request: &RuntimeStoryActionRequest, +) -> Result { + 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(), + npc_name.as_str(), + 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} 已加入队伍")), + }) +} + +/// 对齐 Node 旧 inventory compat,先按装备位把物品从背包切到 playerEquipment, +/// 再把基础面板属性回算到快照上。 +fn resolve_equipment_equip_action( + game_state: &mut Value, + request: &RuntimeStoryActionRequest, +) -> Result { + 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)), + }) +} + +fn resolve_equipment_unequip_action( + game_state: &mut Value, + request: &RuntimeStoryActionRequest, +) -> Result { + 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)), + }) +} + +fn resolve_forge_craft_action( + game_state: &mut Value, + request: &RuntimeStoryActionRequest, +) -> Result { + 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)), + }) +} + +fn resolve_forge_dismantle_action( + game_state: &mut Value, + request: &RuntimeStoryActionRequest, +) -> Result { + 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::>(); + + 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)), + }) +} + +fn resolve_forge_reforge_action( + game_state: &mut Value, + request: &RuntimeStoryActionRequest, +) -> Result { + 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)), + }) +} + +/// 先按 NPC 当前遭遇态结算简化版买卖逻辑,保持与 Node compat 一致的字段写回, +/// 后续再由真相态 inventory / runtime-item reducer 接管。 +fn resolve_npc_trade_action( + game_state: &mut Value, + request: &RuntimeStoryActionRequest, +) -> Result { + 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, + }) +} + +fn resolve_npc_gift_action( + game_state: &mut Value, + request: &RuntimeStoryActionRequest, +) -> Result { + 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, + }) +} + +fn resolve_battle_action( + game_state: &mut Value, + request: &RuntimeStoryActionRequest, + function_id: &str, +) -> Result { + 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), + }) +} + +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, + } +} + +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()) +} + +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", + ), + } +} + +fn current_world_type(game_state: &Value) -> Option { + read_optional_string_field(game_state, "worldType") +} + +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::>(); + 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) +} + +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::>(); + 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 + } +} + +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 +} + +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 +} + +fn trade_quantity_suffix(quantity: i32) -> String { + if quantity > 1 { + format!(" x{quantity}") + } else { + String::new() + } +} + +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 add_companion_if_absent( + game_state: &mut Value, + npc_id: &str, + character_id: Option, + 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 { + 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)) +} + +fn recruit_companion_to_party( + game_state: &mut Value, + npc_id: &str, + _npc_name: &str, + release_npc_id: Option<&str>, +) -> Result, 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, + read_current_npc_affinity(game_state), + ); + 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_else(|| release_npc_id.clone()); + add_companion_if_absent( + game_state, + npc_id, + None, + read_current_npc_affinity(game_state), + ); + Ok(Some(released_name)) +} + +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)) +} diff --git a/server-rs/crates/api-server/src/runtime_story/compat/ai.rs b/server-rs/crates/api-server/src/runtime_story/compat/ai.rs new file mode 100644 index 00000000..424548b2 --- /dev/null +++ b/server-rs/crates/api-server/src/runtime_story/compat/ai.rs @@ -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 { + 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 { + 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 { + 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::>(), + "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 { + 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::>(), + "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 { + 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::>(); + 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 { + 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 { + 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::>(); + 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 { + 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} 的局势随之向下一步展开。") +} diff --git a/server-rs/crates/api-server/src/runtime_story/compat/battle.rs b/server-rs/crates/api-server/src/runtime_story/compat/battle.rs new file mode 100644 index 00000000..39b15162 --- /dev/null +++ b/server-rs/crates/api-server/src/runtime_story/compat/battle.rs @@ -0,0 +1,616 @@ +use super::*; + +/// 兼容桥里的战斗动作仍走快照态,因此把每回合需要写回的字段先收口到这里, +/// 避免技能、物品、旧 battle_* 分支继续把状态更新散落在各处。 +pub(super) struct BattleActionPlan { + pub(super) action_text: String, + pub(super) result_text: String, + pub(super) damage_dealt: i32, + pub(super) damage_taken: i32, + pub(super) heal: i32, + pub(super) mana_restore: i32, + pub(super) mana_cost: i32, + pub(super) cooldown_tick_turns: i32, + pub(super) cooldown_bonus_turns: i32, + pub(super) applied_skill_cooldown: Option<(String, i32)>, + pub(super) build_buffs: Vec, + pub(super) consumed_item_id: Option, +} + +struct BattleSkillView { + id: String, + name: String, + damage: i32, + mana_cost: i32, + cooldown_turns: i32, + build_buffs: Vec, +} + +struct BattleInventoryUseProfile { + hp_restore: i32, + mana_restore: i32, + cooldown_reduction: i32, + build_buffs: Vec, +} + +struct BattleInventoryItemView { + id: String, + name: String, + quantity: i32, + use_profile: Option, +} + +pub(super) 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(super) 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)); +} + +pub(super) 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)); +} + +pub(super) 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 { + 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 { + read_player_skills(game_state) + .into_iter() + .find(|skill| skill.id == skill_id) +} + +pub(super) fn read_player_skill_cooldowns( + game_state: &Value, +) -> std::collections::BTreeMap { + 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() +} + +pub(super) 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)); + } +} + +pub(super) fn reduce_player_skill_cooldowns(game_state: &mut Value, turns: i32) { + if turns <= 0 { + return; + } + tick_player_skill_cooldowns(game_state, turns); +} + +pub(super) 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 { + 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 { + read_player_inventory_items(game_state) + .into_iter() + .find(|item| item.id == item_id) +} + +pub(super) 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, + "普通攻击", + "你抓住当前窗口打出一记直接攻击,对方也立刻做出反击。", + ), + } +} + +pub(super) fn build_battle_action_plan( + game_state: &Value, + request: &RuntimeStoryActionRequest, + function_id: &str, +) -> Result { + 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 { + 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 { + 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), + }) +} + +pub(super) fn battle_action_toast( + function_id: &str, + request: &RuntimeStoryActionRequest, +) -> Option { + if function_id != "inventory_use" { + return None; + } + let item_name = request + .action + .payload + .as_ref() + .and_then(|payload| read_optional_string_field(payload, "itemId")); + item_name.map(|_| "Build 增益已写回当前快照".to_string()) +} + +pub(super) fn build_battle_runtime_story_options(game_state: &Value) -> Vec { + 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 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 build_battle_skill_runtime_story_options(game_state: &Value) -> Vec { + 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 pick_preferred_battle_inventory_item(game_state: &Value) -> Option { + 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::>(); + if !buff_names.is_empty() { + parts.push(format!("增益 {}", buff_names.join("、"))); + } + } + if parts.is_empty() { + "立即结算一次物品效果".to_string() + } else { + parts.join(" / ") + } +} diff --git a/server-rs/crates/api-server/src/runtime_story/compat/core.rs b/server-rs/crates/api-server/src/runtime_story/compat/core.rs new file mode 100644 index 00000000..b13cf169 --- /dev/null +++ b/server-rs/crates/api-server/src/runtime_story/compat/core.rs @@ -0,0 +1,321 @@ +use super::*; + +pub(super) 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(super) 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(super) 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(super) 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(super) 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(super) fn add_player_inventory_items(game_state: &mut Value, additions: Vec) { + 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(super) 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(super) const MAX_PLAYER_LEVEL: i32 = 20; + +pub(super) 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(super) 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(super) 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(super) fn append_active_build_buffs(game_state: &mut Value, additions: Vec) { + 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(super) 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(super) 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(super) 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(super) fn first_hostile_npc_string_field(game_state: &Value, key: &str) -> Option { + read_array_field(game_state, "sceneHostileNpcs") + .first() + .and_then(|target| read_optional_string_field(target, key)) +} + +pub(super) fn read_runtime_session_id(game_state: &Value) -> Option { + read_optional_string_field(game_state, "runtimeSessionId") +} + +pub(super) fn read_field<'a>(value: &'a Value, key: &str) -> Option<&'a Value> { + value.as_object()?.get(key) +} + +pub(super) 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(super) 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(super) fn read_required_string_field(value: &Value, key: &str) -> Option { + normalize_required_string(read_field(value, key)?.as_str()?) +} + +pub(super) fn read_optional_string_field(value: &Value, key: &str) -> Option { + normalize_optional_string(read_field(value, key).and_then(Value::as_str)) +} + +pub(super) fn read_bool_field(value: &Value, key: &str) -> Option { + read_field(value, key).and_then(Value::as_bool) +} + +pub(super) fn read_i32_field(value: &Value, key: &str) -> Option { + read_field(value, key) + .and_then(Value::as_i64) + .and_then(|number| i32::try_from(number).ok()) +} + +pub(super) fn read_u32_field(value: &Value, key: &str) -> Option { + read_field(value, key) + .and_then(Value::as_u64) + .and_then(|number| u32::try_from(number).ok()) +} + +pub(super) fn write_i32_field(value: &mut Value, key: &str, field_value: i32) { + ensure_json_object(value).insert(key.to_string(), json!(field_value)); +} + +pub(super) fn write_u32_field(value: &mut Value, key: &str, field_value: u32) { + ensure_json_object(value).insert(key.to_string(), json!(field_value)); +} + +pub(super) 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(super) 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(super) fn write_null_field(value: &mut Value, key: &str) { + ensure_json_object(value).insert(key.to_string(), Value::Null); +} + +pub(super) fn ensure_json_object(value: &mut Value) -> &mut Map { + if !value.is_object() { + *value = Value::Object(Map::new()); + } + value.as_object_mut().expect("value should be object") +} + +pub(super) fn normalize_required_string(value: &str) -> Option { + let trimmed = value.trim(); + (!trimmed.is_empty()).then(|| trimmed.to_string()) +} + +pub(super) fn normalize_optional_string(value: Option<&str>) -> Option { + value.and_then(normalize_required_string) +} + +pub(super) fn format_now_rfc3339() -> String { + format_rfc3339(OffsetDateTime::now_utc()).unwrap_or_else(|_| "1970-01-01T00:00:00Z".to_string()) +} diff --git a/server-rs/crates/api-server/src/runtime_story/compat/forge.rs b/server-rs/crates/api-server/src/runtime_story/compat/forge.rs new file mode 100644 index 00000000..2aae040e --- /dev/null +++ b/server-rs/crates/api-server/src/runtime_story/compat/forge.rs @@ -0,0 +1,409 @@ +use super::*; + +/// 这批定义只服务 compat bridge 的确定性锻造规则, +/// 先在 `api-server` 内收口,后续再评估是否值得独立 crate。 +pub(super) struct ForgeRequirementDefinition { + pub(super) quantity: i32, + pub(super) matcher: ForgeRequirementMatcher, +} + +pub(super) enum ForgeRequirementMatcher { + Named(&'static str), + AnyMaterial, +} + +pub(super) struct ForgeRecipeDefinition { + pub(super) id: &'static str, + pub(super) name: &'static str, + pub(super) currency_cost: i32, + pub(super) requirements: Vec, +} + +pub(super) struct ReforgeCostDefinition { + pub(super) currency_cost: i32, + pub(super) requirements: Vec, +} + +pub(super) fn forge_recipe_definition(recipe_id: &str) -> Option { + 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(super) 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(super) fn apply_forge_requirements_if_possible( + inventory: &[Value], + requirements: &[ForgeRequirementDefinition], +) -> Option> { + 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(super) 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(super) 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(super) 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(super) fn build_dismantle_outputs(game_state: &Value, item: &Value) -> Option> { + 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::>(); + 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(super) fn build_reforged_item(game_state: &Value, item: &Value) -> Option { + 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::>(); + 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::>(); + 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::>() + .into_iter() + .collect::>(), + "forgeRank": current_forge_rank + 1 + } + })) +} + +pub(super) 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 { + 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("、") + ), + } +} + +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}") +} diff --git a/server-rs/crates/api-server/src/runtime_story/compat/game_state.rs b/server-rs/crates/api-server/src/runtime_story/compat/game_state.rs new file mode 100644 index 00000000..7dcb6eb9 --- /dev/null +++ b/server-rs/crates/api-server/src/runtime_story/compat/game_state.rs @@ -0,0 +1,1115 @@ +use super::*; + +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 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(super) fn battle_mode_text(value: &str) -> &'static str { + if value == "spar" { "切磋" } else { "战斗" } +} + +pub(super) 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(super) 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(super) fn current_encounter_id(game_state: &Value) -> Option { + read_object_field(game_state, "currentEncounter") + .and_then(|encounter| read_optional_string_field(encounter, "id")) +} + +pub(super) 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(super) fn read_player_inventory_values(game_state: &Value) -> Vec { + read_array_field(game_state, "playerInventory") + .into_iter() + .cloned() + .collect() +} + +pub(super) fn write_player_inventory_values(game_state: &mut Value, items: Vec) { + ensure_json_object(game_state).insert("playerInventory".to_string(), Value::Array(items)); +} + +pub(super) 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(super) 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(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::>() + }) + .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 { + 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::>(); + 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>, +) -> 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::(); + 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, + trade_stock_signature: &str, +) -> Vec { + 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::>(); + 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 { + 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 { + 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 { + 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 { + 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> { + 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) { + 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)); + } +} + +pub(super) 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(super) fn normalize_equipped_item(item: &Value) -> Value { + clone_inventory_item_with_quantity(item, 1) +} + +pub(super) fn add_inventory_items_to_list( + mut base: Vec, + additions: Vec, +) -> Vec { + 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(super) fn remove_inventory_item_from_list( + mut base: Vec, + item_id: &str, + quantity: i32, +) -> Vec { + 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(super) fn read_player_equipment_item(game_state: &Value, slot_id: &str) -> Option { + read_field(game_state, "playerEquipment") + .and_then(|equipment| read_field(equipment, slot_id)) + .filter(|item| !item.is_null()) + .cloned() +} + +pub(super) fn write_player_equipment_item( + game_state: &mut Value, + slot_id: &str, + item: Option, +) { + 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(super) fn equipment_slot_label(slot_id: &str) -> &'static str { + match slot_id { + "weapon" => "武器", + "armor" => "护甲", + "relic" => "饰品", + _ => "装备", + } +} + +pub(super) 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(super) 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::>(); + 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(super) fn item_rarity_key(item: &Value) -> String { + read_optional_string_field(item, "rarity").unwrap_or_else(|| "common".to_string()) +} + +pub(super) 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(super) 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(super) 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(super) 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(super) 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(super) 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(super) 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}") +} diff --git a/server-rs/crates/api-server/src/runtime_story/compat/presentation.rs b/server-rs/crates/api-server/src/runtime_story/compat/presentation.rs new file mode 100644 index 00000000..f7428e02 --- /dev/null +++ b/server-rs/crates/api-server/src/runtime_story/compat/presentation.rs @@ -0,0 +1,928 @@ +use super::*; + +pub(super) fn build_runtime_story_state_response( + requested_session_id: &str, + client_version: Option, + 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_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(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::>(), + }) +} + +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 { + 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 { + 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_companions( + game_state: &Value, +) -> Vec { + 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(super) fn build_runtime_story_encounter( + game_state: &Value, +) -> Option { + 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(super) 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)) +} + +pub(super) fn build_runtime_story_options( + current_story: Option<&Value>, + game_state: &Value, +) -> Vec { + 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::>(); + + if !compiled.is_empty() { + return compiled; + } + } + + build_fallback_runtime_story_options(game_state) +} + +pub(super) fn build_runtime_story_option_from_story_option( + value: &Value, +) -> Option { + 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(super) fn build_runtime_story_option_interaction( + value: Option<&Value>, +) -> Option { + 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(super) fn build_fallback_runtime_story_options( + game_state: &Value, +) -> Vec { + 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_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(super) fn build_runtime_story_option_with_payload( + function_id: &str, + action_text: &str, + scope: &str, + detail_text: Option, + payload: Value, +) -> RuntimeStoryOptionView { + RuntimeStoryOptionView { + detail_text, + payload: Some(payload), + ..build_static_runtime_story_option(function_id, action_text, scope) + } +} + +pub(super) fn build_disabled_runtime_story_option( + function_id: &str, + action_text: &str, + scope: &str, + detail_text: Option, + reason: &str, + payload: Option, +) -> RuntimeStoryOptionView { + RuntimeStoryOptionView { + detail_text, + payload, + disabled: Some(true), + reason: Some(reason.to_string()), + ..build_static_runtime_story_option(function_id, action_text, scope) + } +} + +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, +) -> 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 { + 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 { + 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 { + 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) -> Vec { + let mut dialogue = existing.to_vec(); + dialogue.extend(additions); + dialogue +} + +pub(super) fn build_pending_quest_offer_options(npc_id: &str) -> Vec { + 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 { + 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 { + 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, + npc_id: &str, + npc_name: &str, + turn_count: i32, + custom_input_placeholder: &str, + pending_quest: Option, + options: &[RuntimeStoryOptionView], +) -> Value { + json!({ + "text": dialogue + .iter() + .filter_map(|entry| read_optional_string_field(entry, "text")) + .collect::>() + .join("\n"), + "options": options.iter().map(build_story_option_from_runtime_option).collect::>(), + "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 { + 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 { + 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::>(); + 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 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(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::>(), + "streaming": false + }) +} + +pub(super) fn build_story_option_from_runtime_option(option: &RuntimeStoryOptionView) -> Value { + 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, + }) +} + +pub(super) fn read_story_text(current_story: Option<&Value>) -> Option { + 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() +} diff --git a/server-rs/crates/api-server/src/runtime_story/compat/tests.rs b/server-rs/crates/api-server/src/runtime_story/compat/tests.rs new file mode 100644 index 00000000..6df183b2 --- /dev/null +++ b/server-rs/crates/api-server/src/runtime_story/compat/tests.rs @@ -0,0 +1,2165 @@ +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 super::*; +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": { + "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_get_requires_authentication() { + let app = build_router(AppState::new(AppConfig::default()).expect("state should build")); + + let response = app + .oneshot( + Request::builder() + .method("GET") + .uri("/api/runtime/story/state/runtime-main") + .body(Body::empty()) + .expect("request should build"), + ) + .await + .expect("request should succeed"); + + assert_eq!(response.status(), StatusCode::UNAUTHORIZED); +} + +#[tokio::test] +async fn runtime_story_action_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/actions/resolve") + .header("content-type", "application/json") + .body(Body::from( + json!({ + "sessionId": "runtime-main", + "action": { + "type": "story_choice", + "functionId": "idle_rest_focus" + } + }) + .to_string(), + )) + .expect("request should build"), + ) + .await + .expect("request should succeed"); + + assert_eq!(response.status(), StatusCode::UNAUTHORIZED); +} + +#[tokio::test] +async fn runtime_story_routes_resolve_through_rust_route_boundary() { + let state = seed_authenticated_state().await; + let token = issue_access_token(&state); + let app = build_router(state); + let snapshot_payload = json!({ + "bottomTab": "adventure", + "gameState": build_runtime_story_boundary_game_state_fixture(), + "currentStory": { + "text": "巡路人看着你,像在等一句开口。", + "options": [] + } + }); + + let put_response = app + .clone() + .oneshot( + Request::builder() + .method("PUT") + .uri("/api/runtime/save/snapshot") + .header("authorization", format!("Bearer {token}")) + .header("content-type", "application/json") + .header("x-genarrative-response-envelope", "v1") + .body(Body::from(snapshot_payload.to_string())) + .expect("request should build"), + ) + .await + .expect("request should succeed"); + assert_eq!(put_response.status(), StatusCode::OK); + + let state_response = app + .clone() + .oneshot( + Request::builder() + .method("GET") + .uri("/api/runtime/story/state/runtime-main") + .header("authorization", format!("Bearer {token}")) + .header("x-genarrative-response-envelope", "v1") + .body(Body::empty()) + .expect("request should build"), + ) + .await + .expect("request should succeed"); + assert_eq!(state_response.status(), StatusCode::OK); + let state_payload: Value = serde_json::from_slice( + &state_response + .into_body() + .collect() + .await + .expect("body should collect") + .to_bytes(), + ) + .expect("response should be json"); + assert!( + state_payload["data"]["viewModel"]["availableOptions"] + .as_array() + .is_some_and(|options| options + .iter() + .any(|option| { option["functionId"] == json!("npc_chat") })) + ); + + let action_response = app + .oneshot( + Request::builder() + .method("POST") + .uri("/api/runtime/story/actions/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": 0, + "action": { + "type": "story_choice", + "functionId": "npc_chat" + } + }) + .to_string(), + )) + .expect("request should build"), + ) + .await + .expect("request should succeed"); + assert_eq!(action_response.status(), StatusCode::OK); + let action_payload: Value = serde_json::from_slice( + &action_response + .into_body() + .collect() + .await + .expect("body should collect") + .to_bytes(), + ) + .expect("response should be json"); + assert_eq!(action_payload["data"]["serverVersion"], json!(1)); + assert_eq!( + action_payload["data"]["viewModel"]["encounter"]["affinity"], + json!(52) + ); +} + +#[tokio::test] +async fn runtime_story_action_resolve_rejects_client_version_conflict() { + let state = seed_authenticated_state().await; + let token = issue_access_token(&state); + let app = build_router(state); + + let put_response = app + .clone() + .oneshot( + Request::builder() + .method("PUT") + .uri("/api/runtime/save/snapshot") + .header("authorization", format!("Bearer {token}")) + .header("content-type", "application/json") + .header("x-genarrative-response-envelope", "v1") + .body(Body::from( + json!({ + "bottomTab": "adventure", + "gameState": { + "runtimeSessionId": "runtime-main", + "runtimeActionVersion": 5, + "playerHp": 20, + "playerMaxHp": 30, + "playerMana": 4, + "playerMaxMana": 12, + "storyHistory": [] + }, + "currentStory": { + "text": "旧局势仍然悬着。", + "options": [] + } + }) + .to_string(), + )) + .expect("request should build"), + ) + .await + .expect("request should succeed"); + assert_eq!(put_response.status(), StatusCode::OK); + + let response = app + .oneshot( + Request::builder() + .method("POST") + .uri("/api/runtime/story/actions/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": 4, + "action": { + "type": "story_choice", + "functionId": "idle_rest_focus" + } + }) + .to_string(), + )) + .expect("request should build"), + ) + .await + .expect("request should succeed"); + + assert_eq!(response.status(), StatusCode::CONFLICT); + let payload: Value = serde_json::from_slice( + &response + .into_body() + .collect() + .await + .expect("body should collect") + .to_bytes(), + ) + .expect("response should be json"); + assert_eq!(payload["error"]["details"]["clientVersion"], json!(4)); + assert_eq!(payload["error"]["details"]["serverVersion"], json!(5)); +} + +#[tokio::test] +async fn runtime_story_initial_returns_fallback_without_llm() { + 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/initial") + .header("authorization", format!("Bearer {token}")) + .header("content-type", "application/json") + .header("x-genarrative-response-envelope", "v1") + .body(Body::from( + json!({ + "worldType": "martial", + "character": { "name": "林迟" }, + "monsters": [], + "context": { "sceneName": "旧驿道" }, + "requestOptions": { + "availableOptions": [{ + "functionId": "idle_observe_signs", + "actionText": "观察周围迹象" + }] + } + }) + .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"]["options"][0]["functionId"], + json!("idle_observe_signs") + ); + assert!( + payload["data"]["storyText"] + .as_str() + .is_some_and(|text| text.contains("林迟")) + ); +} + +#[test] +fn runtime_story_state_compiler_prefers_dialogue_deferred_options() { + let response = build_runtime_story_state_response( + "runtime-main", + Some(7), + RuntimeStorySnapshotPayload { + saved_at: None, + bottom_tab: "adventure".to_string(), + game_state: json!({ + "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 + }] + }), + current_story: Some(json!({ + "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" + } + }] + })), + }, + ); + + assert_eq!(response.session_id, "runtime-main"); + assert_eq!(response.server_version, 7); + assert_eq!( + response + .view_model + .encounter + .as_ref() + .expect("encounter should exist") + .npc_name, + "守火人" + ); + assert_eq!( + response.view_model.available_options[0].function_id, + "npc_chat" + ); + assert!(matches!( + response.presentation.options[0].interaction, + Some(RuntimeStoryOptionInteraction::Npc { .. }) + )); +} + +#[test] +fn runtime_story_action_resolution_updates_version_and_history() { + let request = RuntimeStoryActionRequest { + session_id: "runtime-main".to_string(), + client_version: Some(3), + action: shared_contracts::runtime_story::RuntimeStoryChoiceAction { + action_type: "story_choice".to_string(), + function_id: "idle_rest_focus".to_string(), + target_id: None, + payload: Some(json!({ "optionText": "原地调息" })), + }, + snapshot: None, + }; + let mut game_state = json!({ + "runtimeSessionId": "runtime-main", + "runtimeActionVersion": 3, + "playerHp": 10, + "playerMaxHp": 30, + "playerMana": 2, + "playerMaxMana": 12, + "storyHistory": [] + }); + + let resolution = + resolve_runtime_story_choice_action(&mut game_state, None, &request, "idle_rest_focus") + .expect("action should resolve"); + let next_version = read_u32_field(&game_state, "runtimeActionVersion") + .unwrap_or(3) + .saturating_add(1); + write_u32_field(&mut game_state, "runtimeActionVersion", next_version); + append_story_history( + &mut game_state, + resolution.action_text.as_str(), + resolution.result_text.as_str(), + ); + + assert_eq!(read_i32_field(&game_state, "playerHp"), Some(18)); + assert_eq!(read_i32_field(&game_state, "playerMana"), Some(8)); + assert_eq!(read_u32_field(&game_state, "runtimeActionVersion"), Some(4)); + assert_eq!( + read_array_field(&game_state, "storyHistory") + .first() + .and_then(|entry| read_optional_string_field(entry, "historyRole")), + Some("action".to_string()) + ); +} + +#[test] +fn runtime_story_state_compiler_builds_combat_options_with_skill_and_item_metadata() { + let response = build_runtime_story_state_response( + "runtime-main", + Some(0), + RuntimeStorySnapshotPayload { + saved_at: None, + bottom_tab: "adventure".to_string(), + game_state: json!({ + "runtimeSessionId": "runtime-main", + "runtimeActionVersion": 0, + "inBattle": true, + "npcInteractionActive": false, + "playerHp": 20, + "playerMaxHp": 40, + "playerMana": 4, + "playerMaxMana": 16, + "playerSkillCooldowns": { + "slash": 2 + }, + "playerCharacter": { + "attributes": { + "strength": 8, + "agility": 6 + }, + "skills": [ + { + "id": "slash", + "name": "试锋斩", + "damage": 18, + "manaCost": 4, + "cooldownTurns": 2 + }, + { + "id": "wind-step", + "name": "断风步", + "damage": 12, + "manaCost": 3, + "cooldownTurns": 1 + } + ] + }, + "playerInventory": [{ + "id": "focus-tonic", + "name": "凝神灵液", + "quantity": 1, + "useProfile": { + "hpRestore": 12, + "manaRestore": 6, + "cooldownReduction": 1 + } + }], + "currentEncounter": { + "kind": "npc", + "id": "npc_bandit_01", + "npcName": "断桥匪首", + "hostile": true + }, + "sceneHostileNpcs": [{ + "id": "npc_bandit_01", + "name": "断桥匪首", + "hp": 80, + "maxHp": 80 + }] + }), + current_story: None, + }, + ); + + let function_ids = response + .view_model + .available_options + .iter() + .map(|option| option.function_id.as_str()) + .collect::>(); + assert_eq!( + function_ids, + vec![ + "battle_attack_basic", + "battle_recover_breath", + "inventory_use", + "battle_use_skill", + "battle_use_skill", + "battle_escape_breakout" + ] + ); + + let inventory_option = &response.view_model.available_options[2]; + assert_eq!( + inventory_option.payload, + Some(json!({ "itemId": "focus-tonic" })) + ); + assert_eq!(inventory_option.disabled, None); + + let slash_option = &response.view_model.available_options[3]; + assert_eq!(slash_option.action_text, "试锋斩"); + assert_eq!(slash_option.payload, Some(json!({ "skillId": "slash" }))); + assert_eq!(slash_option.disabled, Some(true)); + assert_eq!(slash_option.reason.as_deref(), Some("冷却中,还需 2 回合")); + + let wind_step_option = &response.view_model.available_options[4]; + assert_eq!(wind_step_option.action_text, "断风步"); + assert_eq!( + wind_step_option.payload, + Some(json!({ "skillId": "wind-step" })) + ); + assert_eq!(wind_step_option.disabled, None); +} + +#[test] +fn runtime_story_battle_use_skill_writes_cooldown_and_build_buff() { + let request = RuntimeStoryActionRequest { + session_id: "runtime-main".to_string(), + client_version: Some(0), + action: shared_contracts::runtime_story::RuntimeStoryChoiceAction { + action_type: "story_choice".to_string(), + function_id: "battle_use_skill".to_string(), + target_id: None, + payload: Some(json!({ + "optionText": "试锋斩", + "skillId": "slash" + })), + }, + snapshot: None, + }; + let mut game_state = build_runtime_story_boundary_game_state_fixture(); + write_bool_field(&mut game_state, "inBattle", true); + write_bool_field(&mut game_state, "npcInteractionActive", false); + write_string_field(&mut game_state, "currentNpcBattleMode", "fight"); + write_i32_field(&mut game_state, "playerMana", 9); + let root = ensure_json_object(&mut game_state); + root.insert( + "currentEncounter".to_string(), + json!({ + "kind": "npc", + "id": "npc_bandit_01", + "npcName": "断桥匪首", + "hostile": true + }), + ); + root.insert( + "sceneHostileNpcs".to_string(), + json!([{ + "id": "npc_bandit_01", + "name": "断桥匪首", + "hp": 80, + "maxHp": 80 + }]), + ); + root.insert( + "playerCharacter".to_string(), + json!({ + "attributes": { + "strength": 8, + "agility": 6 + }, + "skills": [{ + "id": "slash", + "name": "试锋斩", + "damage": 18, + "manaCost": 4, + "cooldownTurns": 2, + "buildBuffs": [{ + "id": "slash:buff", + "sourceType": "skill", + "sourceId": "slash", + "name": "试锋余势", + "tags": ["快剑"], + "durationTurns": 2 + }] + }] + }), + ); + + let resolution = + resolve_runtime_story_choice_action(&mut game_state, None, &request, "battle_use_skill") + .expect("battle use skill should resolve"); + + assert_eq!(read_i32_field(&game_state, "playerMana"), Some(5)); + assert_eq!( + read_field(&game_state, "playerSkillCooldowns") + .and_then(|cooldowns| read_i32_field(cooldowns, "slash")), + Some(2) + ); + assert_eq!( + read_array_field(&game_state, "activeBuildBuffs") + .first() + .and_then(|buff| read_optional_string_field(buff, "id")), + Some("slash:buff".to_string()) + ); + assert!(matches!( + resolution.patches.first(), + Some(RuntimeStoryPatch::BattleResolved { + function_id, + outcome, + .. + }) if function_id == "battle_use_skill" && outcome == "ongoing" + )); +} + +#[test] +fn runtime_story_inventory_use_writes_item_consumption_and_victory_rewards() { + let request = RuntimeStoryActionRequest { + session_id: "runtime-main".to_string(), + client_version: Some(0), + action: shared_contracts::runtime_story::RuntimeStoryChoiceAction { + action_type: "story_choice".to_string(), + function_id: "inventory_use".to_string(), + target_id: None, + payload: Some(json!({ + "optionText": "使用凝神灵液", + "itemId": "focus-tonic" + })), + }, + snapshot: None, + }; + let mut game_state = build_runtime_story_boundary_game_state_fixture(); + write_bool_field(&mut game_state, "inBattle", true); + write_bool_field(&mut game_state, "npcInteractionActive", false); + write_string_field(&mut game_state, "currentNpcBattleMode", "fight"); + write_i32_field(&mut game_state, "playerHp", 20); + write_i32_field(&mut game_state, "playerMana", 4); + let root = ensure_json_object(&mut game_state); + root.insert( + "playerSkillCooldowns".to_string(), + json!({ + "slash": 2 + }), + ); + root.insert( + "playerInventory".to_string(), + json!([{ + "id": "focus-tonic", + "name": "凝神灵液", + "quantity": 1, + "useProfile": { + "hpRestore": 12, + "manaRestore": 6, + "cooldownReduction": 1, + "buildBuffs": [{ + "id": "focus-tonic:buff", + "sourceType": "item", + "sourceId": "focus-tonic", + "name": "凝神增益", + "tags": ["快剑"], + "durationTurns": 2 + }] + } + }]), + ); + root.insert( + "currentEncounter".to_string(), + json!({ + "kind": "npc", + "id": "npc_bandit_01", + "npcName": "断桥匪首", + "hostile": true, + "experienceReward": 24 + }), + ); + root.insert( + "sceneHostileNpcs".to_string(), + json!([{ + "id": "npc_bandit_01", + "name": "断桥匪首", + "hp": 0, + "maxHp": 80, + "experienceReward": 24 + }]), + ); + + let resolution = + resolve_runtime_story_choice_action(&mut game_state, None, &request, "inventory_use") + .expect("inventory use should resolve"); + + assert_eq!(read_i32_field(&game_state, "playerHp"), Some(24)); + assert_eq!(read_i32_field(&game_state, "playerMana"), Some(10)); + assert_eq!(read_array_field(&game_state, "playerInventory").len(), 0); + assert_eq!( + read_field(&game_state, "runtimeStats") + .and_then(|stats| read_i32_field(stats, "itemsUsed")), + Some(1) + ); + assert_eq!( + read_field(&game_state, "runtimeStats") + .and_then(|stats| read_i32_field(stats, "hostileNpcsDefeated")), + Some(1) + ); + assert_eq!( + read_field(&game_state, "playerProgression") + .and_then(|progression| read_i32_field(progression, "totalXp")), + Some(24) + ); + assert_eq!( + read_field(&game_state, "playerProgression") + .and_then(|progression| read_optional_string_field(progression, "lastGrantedSource")), + Some("hostile_npc".to_string()) + ); + assert_eq!( + resolution.toast.as_deref(), + Some("Build 增益已写回当前快照") + ); +} + +#[test] +fn runtime_story_state_compiler_builds_active_npc_options_with_trade_gift_and_help_lock() { + let mut game_state = build_runtime_story_boundary_game_state_fixture(); + write_bool_field(&mut game_state, "npcInteractionActive", true); + write_current_npc_state_bool_field(&mut game_state, "helpUsed", true); + let root = ensure_json_object(&mut game_state); + root.insert( + "playerInventory".to_string(), + json!([{ + "id": "gift-herb", + "category": "材料", + "name": "暖息草", + "quantity": 1, + "rarity": "rare", + "tags": ["material", "mana"] + }]), + ); + let npc_states = root + .get_mut("npcStates") + .and_then(Value::as_object_mut) + .expect("npcStates should be object"); + let merchant_state = npc_states + .get_mut("npc_merchant_01") + .and_then(Value::as_object_mut) + .expect("merchant state should exist"); + merchant_state.insert( + "inventory".to_string(), + json!([{ + "id": "merchant-essence", + "category": "消耗品", + "name": "回气散", + "quantity": 3, + "rarity": "uncommon", + "tags": ["mana"] + }]), + ); + + let response = build_runtime_story_state_response( + "runtime-main", + Some(0), + RuntimeStorySnapshotPayload { + saved_at: None, + bottom_tab: "adventure".to_string(), + game_state, + current_story: None, + }, + ); + + let function_ids = response + .view_model + .available_options + .iter() + .map(|option| option.function_id.as_str()) + .collect::>(); + assert_eq!( + function_ids, + vec![ + "npc_chat", + "npc_help", + "npc_spar", + "npc_fight", + "npc_trade", + "npc_gift", + "npc_quest_accept", + "npc_leave" + ] + ); + assert_eq!( + response.view_model.available_options[1].disabled, + Some(true) + ); + assert_eq!( + response.view_model.available_options[1].reason.as_deref(), + Some("当前 NPC 的一次性援手已经用完了。") + ); + assert!(matches!( + response.view_model.available_options[4].interaction, + Some(RuntimeStoryOptionInteraction::Npc { ref action, .. }) if action == "trade" + )); + assert!(matches!( + response.view_model.available_options[5].interaction, + Some(RuntimeStoryOptionInteraction::Npc { ref action, .. }) if action == "gift" + )); +} + +#[test] +fn runtime_story_equipment_equip_updates_loadout_and_build_toast() { + let request = RuntimeStoryActionRequest { + session_id: "runtime-main".to_string(), + client_version: Some(0), + action: shared_contracts::runtime_story::RuntimeStoryChoiceAction { + action_type: "story_choice".to_string(), + function_id: "equipment_equip".to_string(), + target_id: None, + payload: Some(json!({ + "itemId": "ward-mail" + })), + }, + snapshot: None, + }; + let mut game_state = build_runtime_story_boundary_game_state_fixture(); + let root = ensure_json_object(&mut game_state); + root.insert( + "playerInventory".to_string(), + json!([{ + "id": "ward-mail", + "category": "护甲", + "name": "镇岳甲", + "quantity": 1, + "rarity": "rare", + "tags": ["armor", "守御", "护体"], + "equipmentSlotId": "armor", + "statProfile": { + "maxHpBonus": 24, + "outgoingDamageBonus": 0.04, + "incomingDamageMultiplier": 0.92 + }, + "buildProfile": { + "role": "守御", + "tags": ["守御", "护体"], + "synergy": ["守御", "护体"], + "forgeRank": 0 + } + }]), + ); + + let resolution = + resolve_runtime_story_choice_action(&mut game_state, None, &request, "equipment_equip") + .expect("equipment equip should resolve"); + + assert_eq!(read_array_field(&game_state, "playerInventory").len(), 0); + assert_eq!( + read_field(&game_state, "playerEquipment") + .and_then(|equipment| read_field(equipment, "armor")) + .and_then(|armor| read_optional_string_field(armor, "id")), + Some("ward-mail".to_string()) + ); + assert_eq!(read_i32_field(&game_state, "playerMaxHp"), Some(64)); + assert_eq!(resolution.toast.as_deref(), Some("当前 Build 倍率 x1.04")); + assert!(resolution.result_text.contains("镇岳甲")); +} + +#[test] +fn runtime_story_equipment_unequip_returns_item_to_inventory_and_resets_loadout() { + let request = RuntimeStoryActionRequest { + session_id: "runtime-main".to_string(), + client_version: Some(0), + action: shared_contracts::runtime_story::RuntimeStoryChoiceAction { + action_type: "story_choice".to_string(), + function_id: "equipment_unequip".to_string(), + target_id: Some("armor".to_string()), + payload: Some(json!({ + "slotId": "护甲" + })), + }, + snapshot: None, + }; + let mut game_state = build_runtime_story_boundary_game_state_fixture(); + let root = ensure_json_object(&mut game_state); + root.insert( + "playerEquipment".to_string(), + json!({ + "weapon": null, + "armor": { + "id": "ward-mail", + "category": "护甲", + "name": "镇岳甲", + "quantity": 1, + "rarity": "rare", + "tags": ["armor", "守御", "护体"], + "equipmentSlotId": "armor", + "statProfile": { + "maxHpBonus": 24, + "outgoingDamageBonus": 0.04, + "incomingDamageMultiplier": 0.92 + }, + "buildProfile": { + "role": "守御", + "tags": ["守御", "护体"], + "synergy": ["守御", "护体"], + "forgeRank": 0 + } + }, + "relic": null + }), + ); + apply_equipment_loadout_to_state(&mut game_state); + + let resolution = + resolve_runtime_story_choice_action(&mut game_state, None, &request, "equipment_unequip") + .expect("equipment unequip should resolve"); + + assert_eq!( + read_field(&game_state, "playerEquipment") + .and_then(|equipment| read_field(equipment, "armor")) + .cloned(), + Some(Value::Null) + ); + assert_eq!(read_array_field(&game_state, "playerInventory").len(), 1); + assert_eq!( + read_array_field(&game_state, "playerInventory") + .first() + .and_then(|item| read_optional_string_field(item, "id")), + Some("ward-mail".to_string()) + ); + assert_eq!(read_i32_field(&game_state, "playerMaxHp"), Some(40)); + assert_eq!(resolution.toast.as_deref(), Some("当前 Build 倍率 x1.00")); + assert!(resolution.result_text.contains("镇岳甲")); +} + +#[test] +fn runtime_story_forge_craft_consumes_materials_and_currency() { + let request = RuntimeStoryActionRequest { + session_id: "runtime-main".to_string(), + client_version: Some(0), + action: shared_contracts::runtime_story::RuntimeStoryChoiceAction { + action_type: "story_choice".to_string(), + function_id: "forge_craft".to_string(), + target_id: None, + payload: Some(json!({ + "recipeId": "synthesis-refined-ingot" + })), + }, + snapshot: None, + }; + let mut game_state = build_runtime_story_boundary_game_state_fixture(); + let root = ensure_json_object(&mut game_state); + root.insert( + "playerInventory".to_string(), + json!([ + { + "id": "scrap-a", + "category": "材料", + "name": "旧铜片", + "quantity": 2, + "rarity": "common", + "tags": ["material", "工巧"] + }, + { + "id": "scrap-b", + "category": "材料", + "name": "风化铁片", + "quantity": 1, + "rarity": "common", + "tags": ["material", "守御"] + } + ]), + ); + + let resolution = + resolve_runtime_story_choice_action(&mut game_state, None, &request, "forge_craft") + .expect("forge craft should resolve"); + + assert_eq!(read_i32_field(&game_state, "playerCurrency"), Some(72)); + let inventory = read_array_field(&game_state, "playerInventory"); + assert_eq!(inventory.len(), 1); + let created_item = inventory.first().expect("crafted item should exist"); + assert_eq!( + read_optional_string_field(created_item, "name"), + Some("精炼锭材".to_string()) + ); + assert_eq!(read_i32_field(created_item, "quantity"), Some(1)); + assert!(resolution.result_text.contains("压炼锭材")); + assert!(resolution.result_text.contains("18 铜钱")); +} + +#[test] +fn runtime_story_forge_dismantle_replaces_item_with_material_outputs() { + let request = RuntimeStoryActionRequest { + session_id: "runtime-main".to_string(), + client_version: Some(0), + action: shared_contracts::runtime_story::RuntimeStoryChoiceAction { + action_type: "story_choice".to_string(), + function_id: "forge_dismantle".to_string(), + target_id: None, + payload: Some(json!({ + "itemId": "duelist-blade" + })), + }, + snapshot: None, + }; + let mut game_state = build_runtime_story_boundary_game_state_fixture(); + let root = ensure_json_object(&mut game_state); + root.insert( + "playerInventory".to_string(), + json!([{ + "id": "duelist-blade", + "category": "武器", + "name": "百炼追风剑", + "quantity": 1, + "rarity": "epic", + "tags": ["weapon", "快剑", "突进", "追击"], + "equipmentSlotId": "weapon", + "statProfile": { + "maxManaBonus": 10, + "outgoingDamageBonus": 0.2 + }, + "buildProfile": { + "role": "快剑", + "tags": ["快剑", "突进", "追击"], + "synergy": ["快剑", "突进", "追击"], + "forgeRank": 1 + } + }]), + ); + + let resolution = + resolve_runtime_story_choice_action(&mut game_state, None, &request, "forge_dismantle") + .expect("forge dismantle should resolve"); + + let inventory = read_array_field(&game_state, "playerInventory"); + assert_eq!(inventory.len(), 3); + assert_eq!( + inventory + .iter() + .find(|item| read_optional_string_field(item, "name").as_deref() == Some("武器残片")) + .and_then(|item| read_i32_field(item, "quantity")), + Some(4) + ); + assert!(inventory.iter().any(|item| { + read_optional_string_field(item, "name").as_deref() == Some("快剑精粹") + })); + assert!(inventory.iter().any(|item| { + read_optional_string_field(item, "name").as_deref() == Some("突进精粹") + })); + assert!(resolution.result_text.contains("百炼追风剑")); + assert!(resolution.result_text.contains("武器残片")); +} + +#[test] +fn runtime_story_forge_reforge_upgrades_item_and_consumes_cost() { + let request = RuntimeStoryActionRequest { + session_id: "runtime-main".to_string(), + client_version: Some(0), + action: shared_contracts::runtime_story::RuntimeStoryChoiceAction { + action_type: "story_choice".to_string(), + function_id: "forge_reforge".to_string(), + target_id: None, + payload: Some(json!({ + "itemId": "duelist-blade" + })), + }, + snapshot: None, + }; + let mut game_state = build_runtime_story_boundary_game_state_fixture(); + let root = ensure_json_object(&mut game_state); + root.insert( + "playerInventory".to_string(), + json!([ + { + "id": "duelist-blade", + "category": "武器", + "name": "百炼追风剑", + "quantity": 1, + "rarity": "epic", + "tags": ["weapon", "快剑", "突进", "追击"], + "equipmentSlotId": "weapon", + "description": "为快剑与追身构筑准备的锻造兵刃。", + "statProfile": { + "maxManaBonus": 10, + "outgoingDamageBonus": 0.2 + }, + "buildProfile": { + "role": "快剑", + "tags": ["快剑", "突进"], + "synergy": ["快剑", "突进"], + "forgeRank": 1 + } + }, + { + "id": "refined-ingot", + "category": "材料", + "name": "精炼锭材", + "quantity": 1, + "rarity": "rare", + "tags": ["material", "工巧", "守御"] + } + ]), + ); + + let resolution = + resolve_runtime_story_choice_action(&mut game_state, None, &request, "forge_reforge") + .expect("forge reforge should resolve"); + + assert_eq!(read_i32_field(&game_state, "playerCurrency"), Some(44)); + let inventory = read_array_field(&game_state, "playerInventory"); + assert_eq!(inventory.len(), 1); + let reforged_item = inventory.first().expect("reforged item should exist"); + assert!( + read_optional_string_field(reforged_item, "name").is_some_and(|name| name.contains("重铸")) + ); + assert_eq!( + read_field(reforged_item, "buildProfile") + .and_then(|profile| read_i32_field(profile, "forgeRank")), + Some(2) + ); + assert_eq!( + read_field(reforged_item, "statProfile") + .and_then(|profile| read_i32_field(profile, "maxManaBonus")), + Some(14) + ); + assert_eq!( + read_field(reforged_item, "statProfile") + .and_then(|profile| read_field(profile, "outgoingDamageBonus")) + .and_then(Value::as_f64), + Some(0.23) + ); + assert!(resolution.result_text.contains("46 铜钱")); + assert!(resolution.result_text.contains("百炼追风剑")); +} + +#[test] +fn runtime_story_npc_trade_buy_updates_currency_inventory_and_stock() { + let request = RuntimeStoryActionRequest { + session_id: "runtime-main".to_string(), + client_version: Some(0), + action: shared_contracts::runtime_story::RuntimeStoryChoiceAction { + action_type: "story_choice".to_string(), + function_id: "npc_trade".to_string(), + target_id: None, + payload: Some(json!({ + "mode": "buy", + "itemId": "merchant-essence", + "quantity": 2 + })), + }, + snapshot: None, + }; + let mut game_state = build_runtime_story_boundary_game_state_fixture(); + write_bool_field(&mut game_state, "npcInteractionActive", true); + write_i32_field(&mut game_state, "playerCurrency", 90); + let root = ensure_json_object(&mut game_state); + root.insert( + "currentEncounter".to_string(), + json!({ + "kind": "npc", + "id": "npc_merchant_02", + "npcName": "梁伯", + "npcDescription": "携带杂货箱的老人", + "context": "沿街商贩", + "characterId": "merchant-test" + }), + ); + root.insert( + "npcStates".to_string(), + json!({ + "npc_merchant_02": { + "affinity": 58, + "chattedCount": 1, + "helpUsed": false, + "giftsGiven": 0, + "inventory": [{ + "id": "merchant-essence", + "category": "消耗品", + "name": "回气散", + "quantity": 3, + "rarity": "uncommon", + "tags": ["mana"] + }], + "recruited": false + } + }), + ); + + let resolution = + resolve_runtime_story_choice_action(&mut game_state, None, &request, "npc_trade") + .expect("npc trade should resolve"); + + assert_eq!(read_i32_field(&game_state, "playerCurrency"), Some(32)); + assert_eq!( + read_array_field(&game_state, "playerInventory") + .first() + .and_then(|item| read_optional_string_field(item, "name")), + Some("回气散".to_string()) + ); + assert_eq!( + read_array_field(&game_state, "playerInventory") + .first() + .and_then(|item| read_i32_field(item, "quantity")), + Some(2) + ); + assert_eq!( + read_field(&game_state, "npcStates") + .and_then(|states| read_field(states, "npc_merchant_02")) + .map(|state| read_array_field(state, "inventory")) + .and_then(|items| items.first().copied()) + .and_then(|item| read_i32_field(item, "quantity")), + Some(1) + ); + assert!(resolution.result_text.contains("回气散")); +} + +#[test] +fn runtime_story_state_compiler_bootstraps_trade_inventory_for_role_npc() { + let mut game_state = build_runtime_story_boundary_game_state_fixture(); + let root = ensure_json_object(&mut game_state); + root.insert( + "currentEncounter".to_string(), + json!({ + "kind": "npc", + "id": "npc_merchant_bootstrap", + "npcName": "柳叔", + "npcDescription": "守在路边摊前的老商贩", + "context": "路边摊贩" + }), + ); + root.insert("npcStates".to_string(), json!({})); + + let response = build_runtime_story_state_response( + "runtime-main", + Some(0), + RuntimeStorySnapshotPayload { + saved_at: None, + bottom_tab: "adventure".to_string(), + game_state, + current_story: None, + }, + ); + + let function_ids = response + .view_model + .available_options + .iter() + .map(|option| option.function_id.as_str()) + .collect::>(); + assert!(function_ids.contains(&"npc_trade")); + assert_eq!( + read_field(&response.snapshot.game_state, "npcStates") + .and_then(|states| read_field(states, "npc_merchant_bootstrap")) + .and_then(|state| read_array_field(state, "inventory").first().copied()) + .and_then(|item| read_field(item, "runtimeMetadata")) + .and_then(|metadata| read_optional_string_field(metadata, "generationChannel")), + Some("npc_trade".to_string()) + ); + assert_eq!( + read_field(&response.snapshot.game_state, "npcStates") + .and_then(|states| read_field(states, "npc_merchant_bootstrap")) + .and_then(|state| read_field(state, "tradeStockSignature")) + .and_then(Value::as_str), + Some("npc_merchant_bootstrap:test-scene:WUXIA") + ); +} + +#[test] +fn runtime_story_npc_trade_buy_bootstraps_missing_npc_state() { + let request = RuntimeStoryActionRequest { + session_id: "runtime-main".to_string(), + client_version: Some(0), + action: shared_contracts::runtime_story::RuntimeStoryChoiceAction { + action_type: "story_choice".to_string(), + function_id: "npc_trade".to_string(), + target_id: None, + payload: Some(json!({ + "mode": "buy", + "itemId": "npc-trade:npc_merchant_bootstrap:test-scene:WUXIA:tonic", + "quantity": 1 + })), + }, + snapshot: None, + }; + let mut game_state = build_runtime_story_boundary_game_state_fixture(); + write_bool_field(&mut game_state, "npcInteractionActive", true); + write_i32_field(&mut game_state, "playerCurrency", 90); + let root = ensure_json_object(&mut game_state); + root.insert( + "currentEncounter".to_string(), + json!({ + "kind": "npc", + "id": "npc_merchant_bootstrap", + "npcName": "柳叔", + "npcDescription": "守在路边摊前的老商贩", + "context": "路边摊贩" + }), + ); + root.insert("npcStates".to_string(), json!({})); + + let resolution = + resolve_runtime_story_choice_action(&mut game_state, None, &request, "npc_trade") + .expect("npc trade should bootstrap and resolve"); + + assert_eq!(read_i32_field(&game_state, "playerCurrency"), Some(58)); + assert_eq!( + read_array_field(&game_state, "playerInventory") + .first() + .and_then(|item| read_optional_string_field(item, "name")), + Some("回气散".to_string()) + ); + assert_eq!( + read_field(&game_state, "npcStates") + .and_then(|states| read_field(states, "npc_merchant_bootstrap")) + .and_then(|state| read_array_field(state, "inventory").first().copied()) + .and_then(|item| read_i32_field(item, "quantity")), + Some(1) + ); + assert_eq!( + read_field(&game_state, "npcStates") + .and_then(|states| read_field(states, "npc_merchant_bootstrap")) + .and_then(|state| read_bool_field(state, "firstMeaningfulContactResolved")), + Some(true) + ); + assert!(resolution.result_text.contains("回气散")); +} + +#[test] +fn runtime_story_npc_gift_updates_affinity_inventory_and_patch() { + let request = RuntimeStoryActionRequest { + session_id: "runtime-main".to_string(), + client_version: Some(0), + action: shared_contracts::runtime_story::RuntimeStoryChoiceAction { + action_type: "story_choice".to_string(), + function_id: "npc_gift".to_string(), + target_id: None, + payload: Some(json!({ + "itemId": "gift-herb" + })), + }, + snapshot: None, + }; + let mut game_state = build_runtime_story_boundary_game_state_fixture(); + write_bool_field(&mut game_state, "npcInteractionActive", true); + let root = ensure_json_object(&mut game_state); + root.insert( + "currentEncounter".to_string(), + json!({ + "kind": "npc", + "id": "npc_merchant_03", + "npcName": "沈娘", + "npcDescription": "对药性很敏感的行脚商", + "context": "药商", + "characterId": "merchant-gift" + }), + ); + root.insert( + "playerInventory".to_string(), + json!([{ + "id": "gift-herb", + "category": "材料", + "name": "暖息草", + "quantity": 1, + "rarity": "rare", + "tags": ["material", "mana"] + }]), + ); + root.insert( + "npcStates".to_string(), + json!({ + "npc_merchant_03": { + "affinity": 22, + "chattedCount": 0, + "helpUsed": false, + "giftsGiven": 0, + "inventory": [], + "recruited": false + } + }), + ); + + let resolution = + resolve_runtime_story_choice_action(&mut game_state, None, &request, "npc_gift") + .expect("npc gift should resolve"); + + assert_eq!(read_array_field(&game_state, "playerInventory").len(), 0); + assert_eq!( + read_field(&game_state, "npcStates") + .and_then(|states| read_field(states, "npc_merchant_03")) + .and_then(|state| read_i32_field(state, "affinity")), + Some(38) + ); + assert_eq!( + read_field(&game_state, "npcStates") + .and_then(|states| read_field(states, "npc_merchant_03")) + .and_then(|state| read_i32_field(state, "giftsGiven")), + Some(1) + ); + assert!(matches!( + resolution.patches.first(), + Some(RuntimeStoryPatch::NpcAffinityChanged { + previous_affinity: 22, + next_affinity: 38, + .. + }) + )); +} + +#[tokio::test] +async fn runtime_story_route_boundary_persists_equipment_equip_snapshot_updates() { + let state = seed_authenticated_state().await; + let token = issue_access_token(&state); + let app = build_router(state); + let mut game_state = build_runtime_story_boundary_game_state_fixture(); + let root = ensure_json_object(&mut game_state); + root.insert( + "playerInventory".to_string(), + json!([{ + "id": "ward-mail", + "category": "护甲", + "name": "镇岳甲", + "quantity": 1, + "rarity": "rare", + "tags": ["armor", "守御", "护体"], + "equipmentSlotId": "armor", + "statProfile": { + "maxHpBonus": 24, + "outgoingDamageBonus": 0.04, + "incomingDamageMultiplier": 0.92 + } + }]), + ); + let snapshot_payload = json!({ + "bottomTab": "adventure", + "gameState": game_state, + "currentStory": { + "text": "你低头检查身上的旧甲。", + "options": [] + } + }); + + let put_response = app + .clone() + .oneshot( + Request::builder() + .method("PUT") + .uri("/api/runtime/save/snapshot") + .header("authorization", format!("Bearer {token}")) + .header("content-type", "application/json") + .header("x-genarrative-response-envelope", "v1") + .body(Body::from(snapshot_payload.to_string())) + .expect("request should build"), + ) + .await + .expect("request should succeed"); + assert_eq!(put_response.status(), StatusCode::OK); + + let action_response = app + .clone() + .oneshot( + Request::builder() + .method("POST") + .uri("/api/runtime/story/actions/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": 0, + "action": { + "type": "story_choice", + "functionId": "equipment_equip", + "payload": { + "itemId": "ward-mail" + } + } + }) + .to_string(), + )) + .expect("request should build"), + ) + .await + .expect("request should succeed"); + assert_eq!(action_response.status(), StatusCode::OK); + let action_payload: Value = serde_json::from_slice( + &action_response + .into_body() + .collect() + .await + .expect("body should collect") + .to_bytes(), + ) + .expect("response should be json"); + assert_eq!( + action_payload["data"]["snapshot"]["gameState"]["playerEquipment"]["armor"]["id"], + json!("ward-mail") + ); + assert_eq!( + action_payload["data"]["viewModel"]["player"]["maxHp"], + json!(64) + ); + + let state_response = app + .oneshot( + Request::builder() + .method("GET") + .uri("/api/runtime/story/state/runtime-main") + .header("authorization", format!("Bearer {token}")) + .header("x-genarrative-response-envelope", "v1") + .body(Body::empty()) + .expect("request should build"), + ) + .await + .expect("request should succeed"); + assert_eq!(state_response.status(), StatusCode::OK); + let state_payload: Value = serde_json::from_slice( + &state_response + .into_body() + .collect() + .await + .expect("body should collect") + .to_bytes(), + ) + .expect("response should be json"); + assert_eq!( + state_payload["data"]["snapshot"]["gameState"]["playerEquipment"]["armor"]["id"], + json!("ward-mail") + ); + assert_eq!( + state_payload["data"]["viewModel"]["player"]["maxHp"], + json!(64) + ); +} + +#[test] +fn runtime_story_npc_help_is_one_shot_and_restores_resources() { + let request = RuntimeStoryActionRequest { + session_id: "runtime-main".to_string(), + client_version: Some(0), + action: shared_contracts::runtime_story::RuntimeStoryChoiceAction { + action_type: "story_choice".to_string(), + function_id: "npc_help".to_string(), + target_id: None, + payload: Some(json!({ "optionText": "请求援手" })), + }, + snapshot: None, + }; + let mut game_state = build_runtime_story_boundary_game_state_fixture(); + write_i32_field(&mut game_state, "playerHp", 20); + write_i32_field(&mut game_state, "playerMana", 4); + + let first = resolve_runtime_story_choice_action(&mut game_state, None, &request, "npc_help") + .expect("first help should resolve"); + + assert!(first.result_text.contains("及时支援")); + assert_eq!(read_i32_field(&game_state, "playerHp"), Some(30)); + assert_eq!(read_i32_field(&game_state, "playerMana"), Some(12)); + assert_eq!( + read_current_npc_state_bool_field(&game_state, "helpUsed"), + Some(true) + ); + + let second = resolve_runtime_story_choice_action(&mut game_state, None, &request, "npc_help"); + match second { + Ok(_) => panic!("second help should be rejected"), + Err(error) => assert_eq!(error, "当前 NPC 的一次性援手已经用完了"), + } +} + +#[test] +fn runtime_story_npc_recruit_requires_threshold_and_release_target_when_party_full() { + let request = RuntimeStoryActionRequest { + session_id: "runtime-main".to_string(), + client_version: Some(0), + action: shared_contracts::runtime_story::RuntimeStoryChoiceAction { + action_type: "story_choice".to_string(), + function_id: "npc_recruit".to_string(), + target_id: None, + payload: Some(json!({ "optionText": "邀请同行" })), + }, + snapshot: None, + }; + + let mut low_affinity_state = build_runtime_story_boundary_game_state_fixture(); + let error = + resolve_runtime_story_choice_action(&mut low_affinity_state, None, &request, "npc_recruit"); + match error { + Ok(_) => panic!("low affinity recruit should be rejected"), + Err(message) => assert_eq!(message, "当前关系还没达到招募阈值,暂时不能邀请入队"), + } + + let mut full_party_state = build_runtime_story_boundary_game_state_fixture(); + write_current_npc_state_i32_field(&mut full_party_state, "affinity", 60); + let root = ensure_json_object(&mut full_party_state); + root.insert( + "companions".to_string(), + json!([ + { + "npcId": "npc-ally-1", + "characterId": "char-ally-1", + "joinedAtAffinity": 64, + "npcName": "旧同伴甲" + }, + { + "npcId": "npc-ally-2", + "characterId": "char-ally-2", + "joinedAtAffinity": 61, + "npcName": "旧同伴乙" + } + ]), + ); + + let full_party_error = + resolve_runtime_story_choice_action(&mut full_party_state, None, &request, "npc_recruit"); + match full_party_error { + Ok(_) => panic!("full party recruit should require release target"), + Err(message) => assert_eq!(message, "队伍已满时必须明确指定一名离队同伴"), + } + + let request_with_release = RuntimeStoryActionRequest { + action: shared_contracts::runtime_story::RuntimeStoryChoiceAction { + payload: Some(json!({ + "optionText": "邀请同行", + "releaseNpcId": "npc-ally-1" + })), + ..request.action.clone() + }, + ..request + }; + let resolution = resolve_runtime_story_choice_action( + &mut full_party_state, + None, + &request_with_release, + "npc_recruit", + ) + .expect("recruit with release target should resolve"); + + assert!(resolution.result_text.contains("旧同伴甲")); + assert_eq!(read_array_field(&full_party_state, "companions").len(), 2); + assert!( + read_array_field(&full_party_state, "companions") + .iter() + .any(|entry| { + read_optional_string_field(entry, "npcId").as_deref() == Some("npc_merchant_01") + }) + ); + assert_eq!( + read_field(&full_party_state, "currentEncounter"), + Some(&Value::Null) + ); +} + +#[test] +fn runtime_story_quest_offer_replace_updates_pending_offer_and_payload() { + let request = RuntimeStoryActionRequest { + session_id: "runtime-main".to_string(), + client_version: Some(0), + action: shared_contracts::runtime_story::RuntimeStoryChoiceAction { + action_type: "story_choice".to_string(), + function_id: "npc_chat_quest_offer_replace".to_string(), + target_id: None, + payload: Some(json!({ + "optionText": "更换任务" + })), + }, + snapshot: None, + }; + let mut game_state = build_runtime_story_boundary_game_state_fixture(); + let current_story = build_runtime_story_pending_quest_offer_fixture( + build_runtime_story_boundary_quest_fixture("quest-bridge-offer", "断桥口的密信"), + ); + + let resolution = resolve_runtime_story_choice_action( + &mut game_state, + Some(¤t_story), + &request, + "npc_chat_quest_offer_replace", + ) + .expect("quest replace should resolve"); + + let saved_current_story = resolution + .saved_current_story + .expect("quest replace should save current story"); + let pending_quest = read_field(&saved_current_story, "npcChatState") + .and_then(|state| read_field(state, "pendingQuestOffer")) + .and_then(|offer| read_field(offer, "quest")) + .expect("pending quest should exist after replace"); + assert_eq!( + read_optional_string_field(pending_quest, "id"), + Some("quest-bridge-replaced".to_string()) + ); + + let options = resolution + .presentation_options + .expect("quest replace should expose options"); + assert_eq!(options.len(), 3); + assert_eq!( + options[1] + .payload + .as_ref() + .and_then(|payload| { read_optional_string_field(payload, "npcChatQuestOfferAction") }), + Some("replace".to_string()) + ); +} + +#[test] +fn runtime_story_quest_offer_abandon_clears_pending_offer_and_restores_chat_options() { + let request = RuntimeStoryActionRequest { + session_id: "runtime-main".to_string(), + client_version: Some(0), + action: shared_contracts::runtime_story::RuntimeStoryChoiceAction { + action_type: "story_choice".to_string(), + function_id: "npc_chat_quest_offer_abandon".to_string(), + target_id: None, + payload: Some(json!({ + "optionText": "放弃任务" + })), + }, + snapshot: None, + }; + let mut game_state = build_runtime_story_boundary_game_state_fixture(); + let current_story = build_runtime_story_pending_quest_offer_fixture( + build_runtime_story_boundary_quest_fixture("quest-bridge-offer", "断桥口的密信"), + ); + + let resolution = resolve_runtime_story_choice_action( + &mut game_state, + Some(¤t_story), + &request, + "npc_chat_quest_offer_abandon", + ) + .expect("quest abandon should resolve"); + + let saved_current_story = resolution + .saved_current_story + .expect("quest abandon should save current story"); + assert_eq!( + read_field(&saved_current_story, "npcChatState") + .and_then(|state| read_field(state, "pendingQuestOffer")), + Some(&Value::Null) + ); + let options = resolution + .presentation_options + .expect("quest abandon should expose follow-up chat options"); + assert_eq!(options.len(), 3); + assert!( + options + .iter() + .all(|option| option.function_id == "npc_chat") + ); + assert_eq!(options[0].action_text, "那先继续聊聊你刚才没说完的部分"); +} + +#[test] +fn runtime_story_quest_accept_writes_quest_runtime_stats_and_followup_story() { + let request = RuntimeStoryActionRequest { + session_id: "runtime-main".to_string(), + client_version: Some(0), + action: shared_contracts::runtime_story::RuntimeStoryChoiceAction { + action_type: "story_choice".to_string(), + function_id: "npc_quest_accept".to_string(), + target_id: None, + payload: Some(json!({ + "optionText": "接受任务" + })), + }, + snapshot: None, + }; + let mut game_state = build_runtime_story_boundary_game_state_fixture(); + let pending_quest = + build_runtime_story_boundary_quest_fixture("quest-bridge-offer", "断桥口的密信"); + let current_story = build_runtime_story_pending_quest_offer_fixture(pending_quest.clone()); + + let resolution = resolve_runtime_story_choice_action( + &mut game_state, + Some(¤t_story), + &request, + "npc_quest_accept", + ) + .expect("quest accept should resolve"); + + let quests = read_array_field(&game_state, "quests"); + assert_eq!(quests.len(), 1); + assert_eq!( + read_optional_string_field(quests[0], "id"), + read_optional_string_field(&pending_quest, "id") + ); + assert_eq!( + read_field(&game_state, "runtimeStats") + .and_then(|stats| read_i32_field(stats, "questsAccepted")), + Some(1) + ); + let saved_current_story = resolution + .saved_current_story + .expect("quest accept should save current story"); + assert_eq!( + read_field(&saved_current_story, "npcChatState") + .and_then(|state| read_field(state, "pendingQuestOffer")), + Some(&Value::Null) + ); + assert_eq!( + resolution + .presentation_options + .expect("quest accept should expose follow-up options") + .len(), + 3 + ); +} + +#[test] +fn runtime_story_quest_turn_in_marks_quest_rewards_and_affinity() { + let request = RuntimeStoryActionRequest { + session_id: "runtime-main".to_string(), + client_version: Some(0), + action: shared_contracts::runtime_story::RuntimeStoryChoiceAction { + action_type: "story_choice".to_string(), + function_id: "npc_quest_turn_in".to_string(), + target_id: None, + payload: Some(json!({ + "optionText": "交付任务", + "questId": "quest-bridge-complete" + })), + }, + snapshot: None, + }; + let mut game_state = build_runtime_story_boundary_game_state_fixture(); + let mut completed_quest = + build_runtime_story_boundary_quest_fixture("quest-bridge-complete", "断桥夜巡"); + if let Some(quest) = completed_quest.as_object_mut() { + quest.insert("status".to_string(), Value::String("completed".to_string())); + quest.insert( + "reward".to_string(), + json!({ + "affinityBonus": 6, + "currency": 30, + "experience": 24, + "items": [{ + "id": "reward-med-1", + "category": "补给", + "name": "回气散", + "quantity": 1, + "tags": [] + }] + }), + ); + } + push_quest_record(&mut game_state, &completed_quest); + + let resolution = + resolve_runtime_story_choice_action(&mut game_state, None, &request, "npc_quest_turn_in") + .expect("quest turn in should resolve"); + + let quests = read_array_field(&game_state, "quests"); + assert_eq!(quests.len(), 1); + assert_eq!( + read_optional_string_field(quests[0], "status"), + Some("turned_in".to_string()) + ); + assert_eq!(read_i32_field(&game_state, "playerCurrency"), Some(120)); + assert_eq!(read_array_field(&game_state, "playerInventory").len(), 1); + assert_eq!( + read_field(&game_state, "playerProgression") + .and_then(|progression| read_i32_field(progression, "totalXp")), + Some(24) + ); + assert_eq!( + read_current_npc_state_i32_field(&game_state, "affinity"), + Some(52) + ); + assert!(resolution.patches.iter().any(|patch| matches!( + patch, + RuntimeStoryPatch::NpcAffinityChanged { + previous_affinity: 46, + next_affinity: 52, + .. + } + ))); +} + +#[test] +fn runtime_story_reasoned_combat_story_guard_only_targets_terminal_outcomes() { + assert!(!should_generate_reasoned_combat_story(None)); + assert!(!should_generate_reasoned_combat_story(Some( + &RuntimeBattlePresentation { + target_id: None, + target_name: None, + damage_dealt: Some(4), + damage_taken: Some(2), + outcome: Some("ongoing".to_string()), + } + ))); + assert!(should_generate_reasoned_combat_story(Some( + &RuntimeBattlePresentation { + target_id: Some("npc_merchant_01".to_string()), + target_name: Some("沈七".to_string()), + damage_dealt: Some(18), + damage_taken: Some(0), + outcome: Some("victory".to_string()), + } + ))); + assert!(should_generate_reasoned_combat_story(Some( + &RuntimeBattlePresentation { + target_id: Some("npc_merchant_01".to_string()), + target_name: Some("沈七".to_string()), + damage_dealt: Some(0), + damage_taken: Some(0), + outcome: Some("escaped".to_string()), + } + ))); +} + +#[test] +fn runtime_story_dialogue_current_story_keeps_continue_and_deferred_options() { + let deferred_options = vec![ + build_npc_runtime_story_option("npc_help", "请求援手", "npc_merchant_01", "help"), + build_npc_runtime_story_option("npc_trade", "查看货物", "npc_merchant_01", "trade"), + ]; + + let current_story = build_dialogue_current_story( + "沈七", + "你:这一路还撑得住吗?\n沈七:还行,先把桥口这阵风躲过去。", + deferred_options.as_slice(), + ); + + assert_eq!( + read_required_string_field(¤t_story, "displayMode").as_deref(), + Some("dialogue") + ); + assert_eq!(read_array_field(¤t_story, "options").len(), 1); + assert_eq!(read_array_field(¤t_story, "deferredOptions").len(), 2); + assert_eq!( + read_array_field(¤t_story, "dialogue") + .into_iter() + .filter_map(|entry| read_optional_string_field(entry, "speaker")) + .collect::>(), + vec!["player".to_string(), "npc".to_string()] + ); + assert_eq!( + read_required_string_field( + read_array_field(¤t_story, "options") + .first() + .copied() + .expect("continue option should exist"), + "functionId" + ) + .as_deref(), + Some(CONTINUE_ADVENTURE_FUNCTION_ID) + ); +} + +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") +} + +fn build_runtime_story_boundary_game_state_fixture() -> Value { + serde_json::from_str( + r#"{ + "worldType": "WUXIA", + "runtimeSessionId": "runtime-main", + "runtimeActionVersion": 0, + "playerCharacter": { + "id": "hero-story", + "title": "试剑客", + "description": "站在桥口的人。", + "personality": "谨慎", + "attributes": { + "strength": 8, + "spirit": 6 + }, + "skills": [] + }, + "runtimeStats": { + "playTimeMs": 0, + "lastPlayTickAt": null, + "hostileNpcsDefeated": 0, + "questsAccepted": 0, + "itemsUsed": 0, + "scenesTraveled": 0 + }, + "currentScene": "test-scene", + "storyHistory": [], + "characterChats": {}, + "animationState": "idle", + "currentEncounter": { + "kind": "npc", + "id": "npc_merchant_01", + "npcName": "沈七", + "npcDescription": "腰间挂着药囊的行商", + "context": "受伤行商", + "hostile": false + }, + "npcInteractionActive": true, + "currentScenePreset": null, + "sceneHostileNpcs": [], + "playerX": 0, + "playerOffsetY": 0, + "playerFacing": "right", + "playerActionMode": "idle", + "scrollWorld": false, + "inBattle": false, + "playerHp": 31, + "playerMaxHp": 40, + "playerMana": 9, + "playerMaxMana": 16, + "playerSkillCooldowns": {}, + "activeBuildBuffs": [], + "activeCombatEffects": [], + "playerCurrency": 90, + "playerInventory": [], + "playerEquipment": { + "weapon": null, + "armor": null, + "relic": null + }, + "npcStates": { + "npc_merchant_01": { + "affinity": 46, + "chattedCount": 0, + "helpUsed": false, + "giftsGiven": 0, + "inventory": [], + "recruited": false + } + }, + "quests": [], + "roster": [], + "companions": [], + "currentNpcBattleMode": null, + "currentNpcBattleOutcome": null, + "sparReturnEncounter": null, + "sparPlayerHpBefore": null, + "sparPlayerMaxHpBefore": null, + "sparStoryHistoryBefore": null, + "playerProgression": { + "level": 1, + "currentLevelXp": 0, + "totalXp": 0, + "xpToNextLevel": 60, + "pendingLevelUps": 0, + "lastGrantedSource": null + } + }"#, + ) + .expect("runtime story boundary game state fixture should parse") +} + +fn build_runtime_story_boundary_quest_fixture(quest_id: &str, title: &str) -> Value { + json!({ + "id": quest_id, + "issuerNpcId": "npc_merchant_01", + "issuerNpcName": "沈七", + "sceneId": "scene-bridge", + "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!("{quest_id}-step-1"), + "title": "查清线索", + "kind": "talk_to_npc", + "requiredCount": 1, + "progress": 0, + "revealText": "先去断桥口附近把相关线索问清楚。", + "completeText": "关键线索已经问清。" + }], + "activeStepId": format!("{quest_id}-step-1") + }) +} + +fn build_runtime_story_pending_quest_offer_fixture(quest: Value) -> Value { + json!({ + "text": "沈七终于把真正的委托说了出来。", + "options": [], + "displayMode": "dialogue", + "dialogue": [{ + "speaker": "npc", + "speakerName": "沈七", + "text": "这件事我只想托给你。" + }], + "npcChatState": { + "npcId": "npc_merchant_01", + "npcName": "沈七", + "turnCount": 2, + "customInputPlaceholder": "输入你想对 TA 说的话", + "pendingQuestOffer": { + "quest": quest + } + } + }) +}