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 index a21d1512..a3b770bc 100644 --- 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 @@ -257,3 +257,18 @@ server-rs/crates/api-server/src/ - `game_state.rs`:快照态读写与 NPC / inventory / equipment 状态桥 后续再继续迁 `trade / gift / companion` 时,目标就不再是单纯减少行数,而是把 compat bridge 逐步收束成“动作编排壳 + 多个纯规则模块”的明确结构。 + +在此基础上,同日又继续把 NPC 交互侧的一批纯 helper 收到独立模块: + +1. `compat` 新增 [npc_support.rs](D:/Genarrative/server-rs/crates/api-server/src/runtime_story/compat/npc_support.rs)。 +2. 已迁入的内容包括: + - 赠礼好感收益与赠礼结果文本 + - 交易价格、折扣档位、货币文本、数量后缀 + - 队伍招募与满员换队 helper +3. [compat.rs](D:/Genarrative/server-rs/crates/api-server/src/runtime_story/compat.rs) 现在对 `npc_trade / npc_gift / npc_recruit` 仍只保留动作编排,不再承担底层价格计算和队伍变换逻辑。 +4. 到这一步,`compat.rs` 的主要职责已经更接近: + - route handler / snapshot bridge + - action orchestration + - 少量尚未迁出的共享 glue code + +这为后续把“无 HTTP / 无 `AppState`”的剩余 glue code 再往下收,提供了更明确的拆分方向。 diff --git a/docs/technical/README.md b/docs/technical/README.md index 262bd95e..62378c22 100644 --- a/docs/technical/README.md +++ b/docs/technical/README.md @@ -81,7 +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_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/npc_support` 子模块的收口策略、验证要求与下一阶段纯规则下沉边界。 - [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/compat.rs b/server-rs/crates/api-server/src/runtime_story/compat.rs index 3813c8b0..d36a53c7 100644 --- a/server-rs/crates/api-server/src/runtime_story/compat.rs +++ b/server-rs/crates/api-server/src/runtime_story/compat.rs @@ -38,10 +38,12 @@ mod core; mod forge; #[path = "compat/game_state.rs"] mod game_state; +#[path = "compat/npc_support.rs"] +mod npc_support; #[path = "compat/presentation.rs"] mod presentation; -use self::{ai::*, battle::*, core::*, forge::*, game_state::*, presentation::*}; +use self::{ai::*, battle::*, core::*, forge::*, game_state::*, npc_support::*, presentation::*}; #[cfg(test)] #[path = "compat/tests.rs"] @@ -1748,234 +1750,6 @@ 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"), diff --git a/server-rs/crates/api-server/src/runtime_story/compat/npc_support.rs b/server-rs/crates/api-server/src/runtime_story/compat/npc_support.rs new file mode 100644 index 00000000..7ffd4f07 --- /dev/null +++ b/server-rs/crates/api-server/src/runtime_story/compat/npc_support.rs @@ -0,0 +1,230 @@ +use super::*; + +pub(super) 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) +} + +pub(super) 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 + } +} + +pub(super) fn npc_purchase_price(item: &Value, affinity: i32) -> i32 { + let discount_multiplier = 1.0 - f64::from(discount_tier_for_affinity(affinity)) * 0.08; + (f64::from(inventory_item_value(item)) * discount_multiplier) + .round() + .max(6.0) as i32 +} + +pub(super) fn npc_buyback_price(item: &Value, affinity: i32) -> i32 { + let buyback_multiplier = 0.4 + f64::from(discount_tier_for_affinity(affinity)) * 0.06; + (f64::from(inventory_item_value(item)) * buyback_multiplier) + .round() + .max(4.0) as i32 +} + +pub(super) fn trade_quantity_suffix(quantity: i32) -> String { + if quantity > 1 { + format!(" x{quantity}") + } else { + String::new() + } +} + +pub(super) 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)) +} + +/// compat bridge 先只维护一个轻量队伍名单,继续复用旧前端的满员换队语义。 +pub(super) 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)) +}