From 6e4c9416016b899adc8dab0b143550c438ac916c Mon Sep 17 00:00:00 2001 From: kdletters Date: Wed, 22 Apr 2026 18:35:40 +0800 Subject: [PATCH] refactor: extract runtime story action modules --- ..._RUNTIME_STORY_RS_SPLIT_PLAN_2026-04-22.md | 25 + docs/technical/README.md | 2 +- .../api-server/src/runtime_story/compat.rs | 1087 +---------------- .../runtime_story/compat/battle_actions.rs | 137 +++ .../runtime_story/compat/equipment_actions.rs | 106 ++ .../src/runtime_story/compat/forge_actions.rs | 201 +++ .../src/runtime_story/compat/npc_actions.rs | 398 ++++++ .../src/runtime_story/compat/quest_actions.rs | 234 ++++ 8 files changed, 1117 insertions(+), 1073 deletions(-) create mode 100644 server-rs/crates/api-server/src/runtime_story/compat/battle_actions.rs create mode 100644 server-rs/crates/api-server/src/runtime_story/compat/equipment_actions.rs create mode 100644 server-rs/crates/api-server/src/runtime_story/compat/forge_actions.rs create mode 100644 server-rs/crates/api-server/src/runtime_story/compat/npc_actions.rs create mode 100644 server-rs/crates/api-server/src/runtime_story/compat/quest_actions.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 index a3b770bc..5a3608d7 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 @@ -272,3 +272,28 @@ server-rs/crates/api-server/src/ - 少量尚未迁出的共享 glue code 这为后续把“无 HTTP / 无 `AppState`”的剩余 glue code 再往下收,提供了更明确的拆分方向。 + +第二阶段继续推进到 action resolver 编排后,当前又新增动作编排模块: + +1. [battle_actions.rs](D:/Genarrative/server-rs/crates/api-server/src/runtime_story/compat/battle_actions.rs)。 +2. [equipment_actions.rs](D:/Genarrative/server-rs/crates/api-server/src/runtime_story/compat/equipment_actions.rs)。 +3. [forge_actions.rs](D:/Genarrative/server-rs/crates/api-server/src/runtime_story/compat/forge_actions.rs)。 +4. [npc_actions.rs](D:/Genarrative/server-rs/crates/api-server/src/runtime_story/compat/npc_actions.rs)。 +5. [quest_actions.rs](D:/Genarrative/server-rs/crates/api-server/src/runtime_story/compat/quest_actions.rs)。 + +已迁入的内容包括: + +1. `battle_*` +2. `equipment_equip / equipment_unequip` +3. `forge_craft / forge_dismantle / forge_reforge` +4. `npc_preview_talk / npc_chat / npc_help / npc_fight / npc_spar` +5. `npc_trade / npc_gift / npc_recruit` +6. `npc_chat_quest_offer_view` +7. `npc_chat_quest_offer_replace` +8. `npc_chat_quest_offer_abandon` +9. `npc_quest_accept` +10. `npc_quest_turn_in` + +这组 resolver 虽然仍是 action orchestration,但已经不依赖 HTTP / `AppState`,只依赖快照 `Value`、当前故事 `currentStory`、共享 DTO 与内部 helper,因此适合先作为 `api-server` 内部模块沉淀。 + +迁移后 [compat.rs](D:/Genarrative/server-rs/crates/api-server/src/runtime_story/compat.rs) 对这些动作只保留 functionId 分发、快照桥接与少量共享 glue code,不再承载 battle / equipment / forge / NPC / quest 的具体结算细节。 diff --git a/docs/technical/README.md b/docs/technical/README.md index 62378c22..6f5d17f5 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/npc_support` 子模块的收口策略、验证要求与下一阶段纯规则下沉边界。 +- [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/*_actions` 子模块的收口策略、验证要求与下一阶段纯规则下沉边界。 - [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 d36a53c7..267c85ec 100644 --- a/server-rs/crates/api-server/src/runtime_story/compat.rs +++ b/server-rs/crates/api-server/src/runtime_story/compat.rs @@ -32,18 +32,32 @@ use crate::{ mod ai; #[path = "compat/battle.rs"] mod battle; +#[path = "compat/battle_actions.rs"] +mod battle_actions; #[path = "compat/core.rs"] mod core; +#[path = "compat/equipment_actions.rs"] +mod equipment_actions; #[path = "compat/forge.rs"] mod forge; +#[path = "compat/forge_actions.rs"] +mod forge_actions; #[path = "compat/game_state.rs"] mod game_state; #[path = "compat/npc_support.rs"] mod npc_support; +#[path = "compat/npc_actions.rs"] +mod npc_actions; #[path = "compat/presentation.rs"] mod presentation; +#[path = "compat/quest_actions.rs"] +mod quest_actions; -use self::{ai::*, battle::*, core::*, forge::*, game_state::*, npc_support::*, presentation::*}; +use self::{ + ai::*, battle::*, battle_actions::*, core::*, equipment_actions::*, forge::*, + forge_actions::*, game_state::*, npc_actions::*, npc_support::*, presentation::*, + quest_actions::*, +}; #[cfg(test)] #[path = "compat/tests.rs"] @@ -636,1077 +650,6 @@ fn resolve_continue_adventure_action( }) } -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, diff --git a/server-rs/crates/api-server/src/runtime_story/compat/battle_actions.rs b/server-rs/crates/api-server/src/runtime_story/compat/battle_actions.rs new file mode 100644 index 00000000..f60fdd3f --- /dev/null +++ b/server-rs/crates/api-server/src/runtime_story/compat/battle_actions.rs @@ -0,0 +1,137 @@ +use super::*; + +pub(super) 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), + }) +} diff --git a/server-rs/crates/api-server/src/runtime_story/compat/equipment_actions.rs b/server-rs/crates/api-server/src/runtime_story/compat/equipment_actions.rs new file mode 100644 index 00000000..9af4b622 --- /dev/null +++ b/server-rs/crates/api-server/src/runtime_story/compat/equipment_actions.rs @@ -0,0 +1,106 @@ +use super::*; + +/// 对齐 Node 旧 inventory compat,先按装备位把物品从背包切到 playerEquipment, +/// 再把基础面板属性回算到快照上。 +pub(super) 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)), + }) +} + +pub(super) 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)), + }) +} diff --git a/server-rs/crates/api-server/src/runtime_story/compat/forge_actions.rs b/server-rs/crates/api-server/src/runtime_story/compat/forge_actions.rs new file mode 100644 index 00000000..16d0acc9 --- /dev/null +++ b/server-rs/crates/api-server/src/runtime_story/compat/forge_actions.rs @@ -0,0 +1,201 @@ +use super::*; + +pub(super) 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)), + }) +} + +pub(super) 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)), + }) +} + +pub(super) 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)), + }) +} diff --git a/server-rs/crates/api-server/src/runtime_story/compat/npc_actions.rs b/server-rs/crates/api-server/src/runtime_story/compat/npc_actions.rs new file mode 100644 index 00000000..410a60e8 --- /dev/null +++ b/server-rs/crates/api-server/src/runtime_story/compat/npc_actions.rs @@ -0,0 +1,398 @@ +use super::*; + +pub(super) 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, + }) +} + +pub(super) fn resolve_npc_affinity_action( + game_state: &mut Value, + request: &RuntimeStoryActionRequest, + default_action_text: &str, + affinity_delta: i32, + fallback_result_text: &str, +) -> Result { + write_bool_field(game_state, "npcInteractionActive", true); + let affinity_patch = adjust_current_npc_affinity(game_state, affinity_delta).map( + |(npc_id, previous_affinity, next_affinity)| RuntimeStoryPatch::NpcAffinityChanged { + npc_id, + previous_affinity, + next_affinity, + }, + ); + let mut patches = Vec::new(); + if let Some(patch) = affinity_patch { + patches.push(patch); + } + patches.push(build_status_patch(game_state)); + + Ok(StoryResolution { + action_text: resolve_action_text(default_action_text, request), + result_text: fallback_result_text.to_string(), + story_text: None, + presentation_options: None, + saved_current_story: None, + patches, + battle: None, + toast: None, + }) +} + +pub(super) fn resolve_npc_chat_action( + game_state: &mut Value, + request: &RuntimeStoryActionRequest, +) -> Result { + let chatted_count = read_current_npc_state_i32_field(game_state, "chattedCount").unwrap_or(0); + let affinity_gain = (6 - chatted_count).max(2); + let result_text = format!( + "{} 愿意把话接下去,态度比刚才明显松动了一些。当前关系推进了 {} 点。", + current_encounter_name(game_state), + affinity_gain + ); + let mut resolution = resolve_npc_affinity_action( + game_state, + request, + "继续交谈", + affinity_gain, + result_text.as_str(), + )?; + write_current_npc_state_i32_field(game_state, "chattedCount", chatted_count.saturating_add(1)); + write_current_npc_state_bool_field(game_state, "firstMeaningfulContactResolved", true); + resolution.action_text = format!("继续和{}交谈", current_encounter_name(game_state)); + Ok(resolution) +} + +pub(super) fn resolve_npc_help_action( + game_state: &mut Value, + request: &RuntimeStoryActionRequest, +) -> Result { + if read_current_npc_state_bool_field(game_state, "helpUsed").unwrap_or(false) { + return Err("当前 NPC 的一次性援手已经用完了".to_string()); + } + + restore_player_resource(game_state, 10, 8); + write_current_npc_state_bool_field(game_state, "helpUsed", true); + resolve_npc_affinity_action( + game_state, + request, + &format!("向{}请求援手", current_encounter_name(game_state)), + 4, + &format!( + "{} 给了你一次及时支援,你的状态暂时稳住了,关系也顺势拉近了一点。", + current_encounter_name(game_state) + ), + ) +} + +pub(super) fn resolve_npc_battle_entry_action( + game_state: &mut Value, + request: &RuntimeStoryActionRequest, + function_id: &str, +) -> Result { + let npc_id = current_encounter_id(game_state).unwrap_or_else(|| "npc_current".to_string()); + let npc_name = current_encounter_name(game_state); + let battle_mode = if function_id == "npc_spar" { + "spar" + } else { + "fight" + }; + write_bool_field(game_state, "inBattle", true); + write_bool_field(game_state, "npcInteractionActive", false); + write_string_field(game_state, "currentBattleNpcId", npc_id.as_str()); + write_string_field(game_state, "currentNpcBattleMode", battle_mode); + write_null_field(game_state, "currentNpcBattleOutcome"); + + Ok(StoryResolution { + action_text: resolve_action_text( + if battle_mode == "spar" { + "点到为止切磋" + } else { + "与对方战斗" + }, + request, + ), + result_text: format!( + "{npc_name} 已经进入{}节奏,下一步必须按战斗动作结算。", + battle_mode_text(battle_mode) + ), + story_text: None, + presentation_options: None, + saved_current_story: None, + patches: vec![build_status_patch(game_state)], + battle: Some(RuntimeBattlePresentation { + target_id: Some(npc_id), + target_name: Some(npc_name), + damage_dealt: None, + damage_taken: None, + outcome: Some("ongoing".to_string()), + }), + toast: None, + }) +} + +pub(super) fn resolve_npc_recruit_action( + game_state: &mut Value, + request: &RuntimeStoryActionRequest, +) -> Result { + 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} 已加入队伍")), + }) +} + +/// 先按 NPC 当前遭遇态结算简化版买卖逻辑,保持与 Node compat 一致的字段写回, +/// 后续再由真相态 inventory / runtime-item reducer 接管。 +pub(super) 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, + }) +} + +pub(super) 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, + }) +} diff --git a/server-rs/crates/api-server/src/runtime_story/compat/quest_actions.rs b/server-rs/crates/api-server/src/runtime_story/compat/quest_actions.rs new file mode 100644 index 00000000..f5ced359 --- /dev/null +++ b/server-rs/crates/api-server/src/runtime_story/compat/quest_actions.rs @@ -0,0 +1,234 @@ +use super::*; + +pub(super) fn resolve_pending_quest_offer_view_action( + game_state: &mut Value, + current_story: Option<&Value>, + request: &RuntimeStoryActionRequest, +) -> Result { + let encounter = current_encounter_npc_quest_context(game_state)?; + let pending_offer = read_pending_quest_offer_context(current_story, encounter.npc_id.as_str()) + .ok_or_else(|| "当前没有待处理的委托可查看。".to_string())?; + Ok(StoryResolution { + action_text: resolve_action_text(&format!("查看{}提出的委托", encounter.npc_name), request), + result_text: pending_offer.intro_text.clone().unwrap_or_else(|| { + build_quest_offer_dialogue_text(encounter.npc_name.as_str(), &pending_offer.quest) + }), + story_text: None, + presentation_options: None, + saved_current_story: None, + patches: vec![], + battle: None, + toast: None, + }) +} + +pub(super) fn resolve_pending_quest_offer_replace_action( + game_state: &mut Value, + current_story: Option<&Value>, + request: &RuntimeStoryActionRequest, +) -> Result { + let encounter = current_encounter_npc_quest_context(game_state)?; + let pending_offer = read_pending_quest_offer_context(current_story, encounter.npc_id.as_str()) + .ok_or_else(|| "当前没有待处理的委托可更换。".to_string())?; + let next_quest = build_next_pending_quest_offer( + game_state, + encounter.npc_id.as_str(), + encounter.npc_name.as_str(), + Some(pending_offer.quest_id.as_str()), + ); + let quest_text = build_quest_offer_dialogue_text(encounter.npc_name.as_str(), &next_quest); + let dialogue = append_dialogue_turns( + pending_offer.dialogue.as_slice(), + vec![ + json!({ + "speaker": "player", + "text": "能不能换一份更适合眼下局势的委托?" + }), + json!({ + "speaker": "npc", + "speakerName": encounter.npc_name, + "text": quest_text, + }), + ], + ); + let options = build_pending_quest_offer_options(encounter.npc_id.as_str()); + let saved_current_story = build_pending_quest_offer_story( + dialogue, + encounter.npc_id.as_str(), + encounter.npc_name.as_str(), + pending_offer.turn_count, + pending_offer.custom_input_placeholder.as_str(), + Some(next_quest.clone()), + options.as_slice(), + ); + + Ok(StoryResolution { + action_text: resolve_action_text(&format!("请{}更换委托", encounter.npc_name), request), + result_text: quest_text.clone(), + story_text: Some(quest_text), + presentation_options: Some(options), + saved_current_story: Some(saved_current_story), + patches: vec![], + battle: None, + toast: None, + }) +} + +pub(super) fn resolve_pending_quest_offer_abandon_action( + game_state: &mut Value, + current_story: Option<&Value>, + request: &RuntimeStoryActionRequest, +) -> Result { + let encounter = current_encounter_npc_quest_context(game_state)?; + let pending_offer = read_pending_quest_offer_context(current_story, encounter.npc_id.as_str()) + .ok_or_else(|| "当前没有待处理的委托可放弃。".to_string())?; + let npc_reply = format!( + "{}点了点头,没有继续强求,只把这份委托暂时收了回去。", + encounter.npc_name + ); + let dialogue = append_dialogue_turns( + pending_offer.dialogue.as_slice(), + vec![ + json!({ + "speaker": "player", + "text": "这件事我先不接,咱们还是先聊别的。" + }), + json!({ + "speaker": "npc", + "speakerName": encounter.npc_name, + "text": npc_reply, + }), + ], + ); + let options = build_post_quest_offer_chat_options(encounter.npc_id.as_str()); + let saved_current_story = build_pending_quest_offer_story( + dialogue, + encounter.npc_id.as_str(), + encounter.npc_name.as_str(), + pending_offer.turn_count, + pending_offer.custom_input_placeholder.as_str(), + None, + options.as_slice(), + ); + + Ok(StoryResolution { + action_text: resolve_action_text(&format!("暂不接受{}的委托", encounter.npc_name), request), + result_text: npc_reply.clone(), + story_text: Some(npc_reply), + presentation_options: Some(options), + saved_current_story: Some(saved_current_story), + patches: vec![], + battle: None, + toast: None, + }) +} + +pub(super) fn resolve_pending_quest_accept_action( + game_state: &mut Value, + current_story: Option<&Value>, + request: &RuntimeStoryActionRequest, +) -> Result { + let encounter = current_encounter_npc_quest_context(game_state)?; + let pending_offer = read_pending_quest_offer_context(current_story, encounter.npc_id.as_str()) + .ok_or_else(|| "当前没有待处理的委托可接下。".to_string())?; + if find_active_quest_for_issuer(game_state, encounter.npc_id.as_str()).is_some() { + return Err("当前角色已经有未结清的委托。".to_string()); + } + + let quest = pending_offer.quest.clone(); + push_quest_record(game_state, &quest); + increment_runtime_stat(game_state, "questsAccepted", 1); + write_current_npc_state_bool_field(game_state, "firstMeaningfulContactResolved", true); + + let reply_text = first_quest_reveal_text(&quest) + .map(|text| format!("那就拜托你了。{text}")) + .unwrap_or_else(|| { + format!( + "那就拜托你了。{}", + read_optional_string_field(&quest, "summary") + .unwrap_or_else(|| "这份委托的关键要点我已经交给你。".to_string()) + ) + }); + let dialogue = append_dialogue_turns( + pending_offer.dialogue.as_slice(), + vec![ + json!({ + "speaker": "player", + "text": "这件事我愿意接下,你把关键要点交给我。" + }), + json!({ + "speaker": "npc", + "speakerName": encounter.npc_name, + "text": reply_text, + }), + ], + ); + let options = build_post_quest_accept_chat_options(encounter.npc_id.as_str()); + let saved_current_story = build_pending_quest_offer_story( + dialogue, + encounter.npc_id.as_str(), + encounter.npc_name.as_str(), + pending_offer.turn_count, + pending_offer.custom_input_placeholder.as_str(), + None, + options.as_slice(), + ); + + Ok(StoryResolution { + action_text: resolve_action_text(&format!("接下{}的委托", encounter.npc_name), request), + result_text: build_quest_accept_result_text(&quest), + story_text: Some( + saved_current_story["text"] + .as_str() + .unwrap_or_default() + .to_string(), + ), + presentation_options: Some(options), + saved_current_story: Some(saved_current_story), + patches: vec![], + battle: None, + toast: None, + }) +} + +pub(super) fn resolve_pending_quest_turn_in_action( + game_state: &mut Value, + request: &RuntimeStoryActionRequest, +) -> Result { + 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, + }) +}