Files
Genarrative/server-rs/crates/module-runtime-story/src/session_action.rs

1579 lines
60 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
use serde_json::{Map, Value, json};
use shared_contracts::{
runtime_story::{
RuntimeBattlePresentation, RuntimeStoryActionRequest, RuntimeStoryChoiceAction,
RuntimeStoryOptionView, RuntimeStoryPatch, RuntimeStoryPresentation, RuntimeStoryViewModel,
},
story::{ResolveStoryRuntimeActionRequest, StoryRuntimeSnapshotPayload},
};
use crate::{
CONTINUE_ADVENTURE_FUNCTION_ID, StoryResolution, add_inventory_items_to_list,
append_story_history, apply_equipment_loadout_to_state, build_battle_runtime_story_options,
build_current_build_toast, build_npc_gift_result_text,
build_runtime_story_option_from_story_option, build_runtime_story_view_model,
build_static_runtime_story_option, build_status_patch, build_story_option_from_runtime_option,
clear_encounter_state, clone_inventory_item_with_quantity, current_encounter_name,
ensure_json_object, find_player_inventory_entry, normalize_equipment_slot_id,
normalize_required_string, npc_buyback_price, npc_purchase_price,
project_story_engine_after_action, read_array_field, read_bool_field, read_field,
read_i32_field, read_inventory_item_name, read_object_field, read_optional_string_field,
read_player_equipment_item, read_player_inventory_values, read_runtime_session_id,
read_u32_field, recruit_companion_to_party, remove_inventory_item_from_list,
resolve_action_text, resolve_battle_action, resolve_equipment_slot_for_item,
resolve_forge_craft_action, resolve_forge_dismantle_action, resolve_forge_reforge_action,
resolve_npc_gift_affinity_gain, restore_player_resource, simple_story_resolution,
write_bool_field, write_i32_field, write_null_field, write_player_equipment_item,
write_player_inventory_values, write_runtime_npc_interaction_view, write_string_field,
write_u32_field,
};
const NPC_RECRUIT_AFFINITY: i32 = 60;
pub struct StoryRuntimeActionResolveInput {
pub story_session_id: String,
pub runtime_session_id: String,
pub snapshot: StoryRuntimeSnapshotPayload,
pub request: ResolveStoryRuntimeActionRequest,
}
#[derive(Debug)]
pub struct StoryRuntimeActionResolveOutput {
pub server_version: u32,
pub narrative_text: String,
pub choice_function_id: Option<String>,
pub view_model: RuntimeStoryViewModel,
pub presentation: RuntimeStoryPresentation,
pub patches: Vec<RuntimeStoryPatch>,
pub snapshot: StoryRuntimeSnapshotPayload,
}
/// 结算 story-session scoped runtime action。
///
/// 中文注释:该函数不访问 HTTP、AppState 或 SpacetimeDB只消费服务端快照并输出下一版快照。
pub fn resolve_story_runtime_action(
input: StoryRuntimeActionResolveInput,
) -> Result<StoryRuntimeActionResolveOutput, String> {
validate_action_request(&input.request)?;
let requested_story_session_id = normalize_required_string(input.story_session_id.as_str())
.ok_or_else(|| "storySessionId 不能为空".to_string())?;
let requested_runtime_session_id = normalize_required_string(input.runtime_session_id.as_str())
.ok_or_else(|| "runtimeSessionId 不能为空".to_string())?;
let function_id = normalize_required_string(input.request.function_id.as_str())
.ok_or_else(|| "action.functionId 不能为空".to_string())?;
let mut snapshot = input.snapshot;
let current_snapshot_runtime_id =
read_runtime_session_id(&snapshot.game_state).ok_or_else(|| {
"runtime snapshot 缺少 runtimeSessionId无法执行 story action".to_string()
})?;
if current_snapshot_runtime_id != requested_runtime_session_id {
return Err("runtime snapshot 与 story session 不匹配".to_string());
}
ensure_json_object(&mut snapshot.game_state).insert(
"storySessionId".to_string(),
Value::String(requested_story_session_id),
);
let previous_game_state = snapshot.game_state.clone();
let current_story_before = snapshot.current_story.clone();
let mut game_state = snapshot.game_state.clone();
let request =
runtime_action_request_from_story_request(&requested_runtime_session_id, input.request);
let mut resolution = resolve_runtime_story_choice_action(
&mut game_state,
current_story_before.as_ref(),
&request,
function_id.as_str(),
)?;
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_runtime_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 history_result_text = resolution.result_text.clone();
let saved_current_story = resolution
.saved_current_story
.take()
.unwrap_or_else(|| build_current_story(story_text.as_str(), &options));
append_story_history(
&mut game_state,
resolution.action_text.as_str(),
history_result_text.as_str(),
);
project_story_engine_after_action(
&previous_game_state,
&mut game_state,
resolution.action_text.as_str(),
history_result_text.as_str(),
function_id.as_str(),
resolution
.battle
.as_ref()
.and_then(|battle| battle.outcome.as_deref()),
);
let mut patches = vec![RuntimeStoryPatch::StoryHistoryAppend {
action_text: resolution.action_text.clone(),
result_text: history_result_text.clone(),
}];
patches.extend(resolution.patches);
snapshot.saved_at = None;
snapshot.game_state = game_state;
snapshot.current_story = Some(saved_current_story);
write_runtime_npc_interaction_view(&mut snapshot.game_state);
let view_model = build_runtime_story_view_model(&snapshot.game_state, &options);
let presentation = RuntimeStoryPresentation {
action_text: resolution.action_text,
result_text: resolution.result_text,
story_text: story_text.clone(),
options,
toast: resolution.toast,
battle: resolution.battle,
};
Ok(StoryRuntimeActionResolveOutput {
server_version,
narrative_text: story_text,
choice_function_id: Some(function_id),
view_model,
presentation,
patches,
snapshot,
})
}
fn validate_action_request(request: &ResolveStoryRuntimeActionRequest) -> Result<(), String> {
if normalize_required_string(request.function_id.as_str()).is_none() {
return Err("action.functionId 不能为空".to_string());
}
if normalize_required_string(request.action_text.as_str()).is_none() {
return Err("actionText 不能为空".to_string());
}
Ok(())
}
fn runtime_action_request_from_story_request(
runtime_session_id: &str,
request: ResolveStoryRuntimeActionRequest,
) -> RuntimeStoryActionRequest {
let mut payload = request.payload.unwrap_or_else(|| json!({}));
if let Some(object) = payload.as_object_mut() {
object
.entry("optionText".to_string())
.or_insert_with(|| Value::String(request.action_text.clone()));
} else {
payload = json!({ "optionText": request.action_text });
}
RuntimeStoryActionRequest {
session_id: runtime_session_id.to_string(),
client_version: request.client_version,
action: RuntimeStoryChoiceAction {
action_type: "story_choice".to_string(),
function_id: request.function_id,
target_id: request.target_id,
payload: Some(payload),
},
}
}
fn resolve_runtime_story_choice_action(
game_state: &mut Value,
current_story: Option<&Value>,
request: &RuntimeStoryActionRequest,
function_id: &str,
) -> Result<StoryResolution, String> {
match function_id {
CONTINUE_ADVENTURE_FUNCTION_ID => resolve_continue_adventure_action(current_story),
"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),
"你把呼吸慢下来重新稳住节奏,生命和灵力都回上来一点。",
))
}
"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_preview_talk" => resolve_npc_preview_talk_action(game_state, request),
"npc_chat" | "story_opening_camp_dialogue" => resolve_npc_chat_action(game_state, request),
"npc_help" => resolve_npc_help_action(game_state, request),
"npc_recruit" => resolve_npc_recruit_action(game_state, request),
"npc_trade" => resolve_npc_trade_action(game_state, request),
"npc_gift" => resolve_npc_gift_action(game_state, request),
"npc_fight" | "npc_spar" => {
resolve_npc_battle_start_action(game_state, request, function_id)
}
"npc_chat_quest_offer_view" => resolve_npc_quest_offer_view_action(game_state, request),
"npc_chat_quest_offer_replace" => {
resolve_npc_quest_offer_replace_action(game_state, request)
}
"npc_chat_quest_offer_abandon" => {
resolve_npc_quest_offer_abandon_action(game_state, request)
}
"npc_quest_accept" => resolve_npc_quest_accept_action(current_story, game_state, request),
"npc_quest_turn_in" => resolve_npc_quest_turn_in_action(game_state, request),
"equipment_equip" => resolve_equipment_equip_action(game_state, request),
"equipment_unequip" => resolve_equipment_unequip_action(game_state, request),
"forge_craft" => resolve_forge_craft_action(game_state, request),
"forge_dismantle" => resolve_forge_dismantle_action(game_state, request),
"forge_reforge" => resolve_forge_reforge_action(game_state, request),
"battle_attack_basic"
| "battle_use_skill"
| "battle_all_in_crush"
| "battle_escape_breakout"
| "battle_feint_step"
| "battle_finisher_window"
| "battle_guard_break"
| "battle_probe_pressure"
| "battle_recover_breath"
| "inventory_use" => resolve_battle_action(game_state, request, function_id),
_ => Err(format!("暂不支持的 runtime action{function_id}")),
}
}
fn resolve_continue_adventure_action(
current_story: Option<&Value>,
) -> Result<StoryResolution, String> {
let deferred_options = current_story
.map(|story| {
read_array_field(story, "deferredOptions")
.into_iter()
.filter_map(build_runtime_story_option_from_story_option)
.collect::<Vec<_>>()
})
.unwrap_or_default();
let options = (!deferred_options.is_empty()).then_some(deferred_options);
Ok(StoryResolution {
action_text: "继续推进冒险".to_string(),
result_text: "你没有把节奏停下来,而是顺着当前局势继续向前推进了这一段故事。".to_string(),
story_text: None,
presentation_options: options,
saved_current_story: None,
patches: Vec::new(),
battle: None,
toast: None,
})
}
fn resolve_npc_preview_talk_action(
game_state: &mut Value,
request: &RuntimeStoryActionRequest,
) -> Result<StoryResolution, String> {
let npc = current_npc_context(game_state)?;
write_bool_field(game_state, "npcInteractionActive", true);
let state = ensure_npc_state(game_state, npc.id.as_str(), npc.name.as_str());
let previous_affinity = read_i32_field(state, "affinity").unwrap_or(0);
increment_npc_state_i32_field(state, "chattedCount", 1);
write_bool_field(state, "firstMeaningfulContactResolved", true);
adjust_npc_affinity_state(state, 2, "npc_chat");
let next_affinity = read_i32_field(state, "affinity").unwrap_or(previous_affinity);
write_runtime_npc_interaction_view(game_state);
Ok(StoryResolution {
action_text: resolve_action_text(&format!("转向{}", npc.name), request),
result_text: format!(
"你把注意力转向 {name},这一轮交流已经正式接上。",
name = npc.name
),
story_text: None,
presentation_options: Some(build_npc_interaction_options(npc.id.as_str())),
saved_current_story: None,
patches: vec![
RuntimeStoryPatch::NpcAffinityChanged {
npc_id: npc.id,
previous_affinity,
next_affinity,
},
build_status_patch(game_state),
],
battle: None,
toast: Some("已进入角色交互".to_string()),
})
}
fn resolve_npc_chat_action(
game_state: &mut Value,
request: &RuntimeStoryActionRequest,
) -> Result<StoryResolution, String> {
let npc = current_npc_context(game_state)?;
write_bool_field(game_state, "npcInteractionActive", true);
let state = ensure_npc_state(game_state, npc.id.as_str(), npc.name.as_str());
let previous_affinity = read_i32_field(state, "affinity").unwrap_or(0);
increment_npc_state_i32_field(state, "chattedCount", 1);
write_bool_field(state, "firstMeaningfulContactResolved", true);
adjust_npc_affinity_state(state, 4, "npc_chat");
let next_affinity = read_i32_field(state, "affinity").unwrap_or(previous_affinity);
write_runtime_npc_interaction_view(game_state);
Ok(StoryResolution {
action_text: resolve_action_text(&format!("{}继续交谈", npc.name), request),
result_text: format!(
"{name} 接住了你的话头,态度比刚才更松动了一些。",
name = npc.name
),
story_text: None,
presentation_options: Some(build_npc_interaction_options(npc.id.as_str())),
saved_current_story: None,
patches: vec![
RuntimeStoryPatch::NpcAffinityChanged {
npc_id: npc.id,
previous_affinity,
next_affinity,
},
build_status_patch(game_state),
],
battle: None,
toast: Some(format!("{} 好感 +4", npc.name)),
})
}
fn resolve_npc_help_action(
game_state: &mut Value,
request: &RuntimeStoryActionRequest,
) -> Result<StoryResolution, String> {
let npc = current_npc_context(game_state)?;
write_bool_field(game_state, "npcInteractionActive", true);
let state = ensure_npc_state(game_state, npc.id.as_str(), npc.name.as_str());
let previous_affinity = read_i32_field(state, "affinity").unwrap_or(0);
let help_used = read_bool_field(state, "helpUsed").unwrap_or(false);
if help_used {
return Err(format!("{} 本轮已经帮过你。", npc.name));
}
write_bool_field(state, "helpUsed", true);
adjust_npc_affinity_state(state, 8, "npc_help");
let next_affinity = read_i32_field(state, "affinity").unwrap_or(previous_affinity);
Ok(StoryResolution {
action_text: resolve_action_text(&format!("请求{}援手", npc.name), request),
result_text: format!("{name} 衡量片刻后点头帮你搭了一把手。", name = npc.name),
story_text: None,
presentation_options: Some(build_npc_interaction_options(npc.id.as_str())),
saved_current_story: None,
patches: vec![
RuntimeStoryPatch::NpcAffinityChanged {
npc_id: npc.id,
previous_affinity,
next_affinity,
},
build_status_patch(game_state),
],
battle: None,
toast: Some(format!("{} 好感 +8", npc.name)),
})
}
fn resolve_npc_recruit_action(
game_state: &mut Value,
request: &RuntimeStoryActionRequest,
) -> Result<StoryResolution, String> {
let npc = current_npc_context(game_state)?;
write_bool_field(game_state, "npcInteractionActive", true);
let state = ensure_npc_state(game_state, npc.id.as_str(), npc.name.as_str());
let affinity = read_i32_field(state, "affinity").unwrap_or(0);
if affinity < NPC_RECRUIT_AFFINITY {
return Err(format!("{} 当前好感不足,暂时不能招募入队。", npc.name));
}
let release_npc_id = request
.action
.payload
.as_ref()
.and_then(|payload| read_optional_string_field(payload, "releaseNpcId"));
let released_name = recruit_companion_to_party(
game_state,
npc.id.as_str(),
affinity,
release_npc_id.as_deref(),
)?;
let state = ensure_npc_state(game_state, npc.id.as_str(), npc.name.as_str());
write_bool_field(state, "recruited", true);
adjust_npc_stance_for_action(state, "npc_recruit", 0, true);
let release_text = released_name
.map(|name| format!("{name} 暂时离队"))
.unwrap_or_default();
Ok(StoryResolution {
action_text: resolve_action_text(&format!("邀请{}入队", npc.name), request),
result_text: format!("{} 正式答应与你同行{release_text}。", npc.name),
story_text: None,
presentation_options: Some(build_npc_interaction_options(npc.id.as_str())),
saved_current_story: None,
patches: vec![build_status_patch(game_state)],
battle: None,
toast: Some(format!("{} 已加入队伍", npc.name)),
})
}
fn resolve_npc_trade_action(
game_state: &mut Value,
request: &RuntimeStoryActionRequest,
) -> Result<StoryResolution, String> {
let npc = current_npc_context(game_state)?;
let payload = request
.action
.payload
.as_ref()
.ok_or_else(|| "npc_trade 缺少 payload".to_string())?;
let mode = read_optional_string_field(payload, "mode")
.ok_or_else(|| "npc_trade 缺少 mode".to_string())?;
let item_id = read_optional_string_field(payload, "itemId")
.ok_or_else(|| "npc_trade 缺少 itemId".to_string())?;
let quantity = read_i32_field(payload, "quantity").unwrap_or(1).max(1);
let affinity = {
let state = ensure_npc_state(game_state, npc.id.as_str(), npc.name.as_str());
read_i32_field(state, "affinity").unwrap_or(0)
};
let (item_name, total_price, result_text) = if mode == "buy" {
let item = {
let state = ensure_npc_state(game_state, npc.id.as_str(), npc.name.as_str());
find_npc_inventory_item(state, item_id.as_str())
.ok_or_else(|| "NPC 库存中没有目标物品。".to_string())?
};
let stock = read_i32_field(&item, "quantity").unwrap_or(0).max(0);
if stock < quantity {
return Err("NPC 库存不足。".to_string());
}
let unit_price = npc_purchase_price(&item, affinity);
let total_price = unit_price.saturating_mul(quantity);
let currency = read_i32_field(game_state, "playerCurrency")
.unwrap_or(0)
.max(0);
if currency < total_price {
return Err("当前钱币不足。".to_string());
}
let item_name = read_inventory_item_name(&item);
let next_npc_inventory = {
let state = ensure_npc_state(game_state, npc.id.as_str(), npc.name.as_str());
remove_inventory_item_from_list(
read_npc_inventory_values(state),
item_id.as_str(),
quantity,
)
};
let next_player_inventory = add_inventory_items_to_list(
read_player_inventory_values(game_state),
vec![clone_inventory_item_with_quantity(&item, quantity)],
);
write_i32_field(game_state, "playerCurrency", currency - total_price);
write_player_inventory_values(game_state, next_player_inventory);
let state = ensure_npc_state(game_state, npc.id.as_str(), npc.name.as_str());
write_npc_inventory_values(state, next_npc_inventory);
(
item_name.clone(),
total_price,
format!(
"你从 {} 手中买下{}{},花费 {}",
npc.name,
item_name,
trade_quantity_suffix_local(quantity),
total_price
),
)
} else if mode == "sell" {
let item = find_player_inventory_entry(game_state, item_id.as_str())
.cloned()
.ok_or_else(|| "背包里没有目标物品。".to_string())?;
let owned = read_i32_field(&item, "quantity").unwrap_or(0).max(0);
if owned < quantity {
return Err("背包数量不足。".to_string());
}
let unit_price = npc_buyback_price(&item, affinity);
let total_price = unit_price.saturating_mul(quantity);
let item_name = read_inventory_item_name(&item);
let currency = read_i32_field(game_state, "playerCurrency")
.unwrap_or(0)
.max(0);
let next_player_inventory = remove_inventory_item_from_list(
read_player_inventory_values(game_state),
item_id.as_str(),
quantity,
);
let next_npc_inventory = {
let state = ensure_npc_state(game_state, npc.id.as_str(), npc.name.as_str());
add_inventory_items_to_list(
read_npc_inventory_values(state),
vec![clone_inventory_item_with_quantity(&item, quantity)],
)
};
write_i32_field(game_state, "playerCurrency", currency + total_price);
write_player_inventory_values(game_state, next_player_inventory);
let state = ensure_npc_state(game_state, npc.id.as_str(), npc.name.as_str());
write_npc_inventory_values(state, next_npc_inventory);
(
item_name.clone(),
total_price,
format!(
"你把{}{}交给 {},换回 {}",
item_name,
trade_quantity_suffix_local(quantity),
npc.name,
total_price
),
)
} else {
return Err("npc_trade.mode 只支持 buy 或 sell".to_string());
};
write_runtime_npc_interaction_view(game_state);
Ok(StoryResolution {
action_text: resolve_action_text(&format!("{}交易", npc.name), request),
result_text,
story_text: None,
presentation_options: Some(build_npc_interaction_options(npc.id.as_str())),
saved_current_story: None,
patches: vec![build_status_patch(game_state)],
battle: None,
toast: Some(format!("{item_name} 交易完成,金额 {total_price}")),
})
}
fn resolve_npc_gift_action(
game_state: &mut Value,
request: &RuntimeStoryActionRequest,
) -> Result<StoryResolution, String> {
let npc = current_npc_context(game_state)?;
let item_id = request
.action
.payload
.as_ref()
.and_then(|payload| read_optional_string_field(payload, "itemId"))
.ok_or_else(|| "npc_gift 缺少 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());
}
write_player_inventory_values(
game_state,
remove_inventory_item_from_list(
read_player_inventory_values(game_state),
item_id.as_str(),
1,
),
);
let state = ensure_npc_state(game_state, npc.id.as_str(), npc.name.as_str());
let previous_affinity = read_i32_field(state, "affinity").unwrap_or(0);
let affinity_gain = resolve_npc_gift_affinity_gain(&item);
increment_npc_state_i32_field(state, "giftsGiven", 1);
adjust_npc_affinity_state(state, affinity_gain, "npc_gift");
let next_affinity = read_i32_field(state, "affinity").unwrap_or(previous_affinity);
let result_text =
build_npc_gift_result_text(npc.name.as_str(), &item, affinity_gain, next_affinity);
write_runtime_npc_interaction_view(game_state);
Ok(StoryResolution {
action_text: resolve_action_text(
&format!("赠送{}", read_inventory_item_name(&item)),
request,
),
result_text,
story_text: None,
presentation_options: Some(build_npc_interaction_options(npc.id.as_str())),
saved_current_story: None,
patches: vec![
RuntimeStoryPatch::NpcAffinityChanged {
npc_id: npc.id,
previous_affinity,
next_affinity,
},
build_status_patch(game_state),
],
battle: None,
toast: Some(format!("{} 好感 +{}", npc.name, affinity_gain)),
})
}
fn resolve_npc_battle_start_action(
game_state: &mut Value,
request: &RuntimeStoryActionRequest,
function_id: &str,
) -> Result<StoryResolution, String> {
let npc = current_npc_context(game_state)?;
let mode = if function_id == "npc_spar" {
"spar"
} else {
"fight"
};
let target_hp = if mode == "spar" {
12
} else {
read_i32_field(&npc.encounter, "targetMaxHp")
.or_else(|| read_i32_field(&npc.encounter, "maxHp"))
.unwrap_or_else(|| {
80 + read_npc_affinity(game_state, npc.id.as_str(), npc.name.as_str()).max(0)
})
.max(12)
};
let mut encounter = npc.encounter.clone();
write_bool_field(&mut encounter, "hostile", true);
write_i32_field(&mut encounter, "hp", target_hp);
write_i32_field(&mut encounter, "maxHp", target_hp);
let hostile = json!({
"id": format!("npc-opponent-{}", npc.id),
"name": npc.name,
"action": if mode == "spar" { "抱拳行礼,准备点到为止地切磋武艺" } else { "摆开架势,随时准备出手" },
"description": read_optional_string_field(&encounter, "npcDescription").unwrap_or_default(),
"animation": "idle",
"xMeters": read_number_field(&encounter, "xMeters").unwrap_or(3.2),
"yOffset": 0,
"facing": "left",
"attackRange": 1.8,
"speed": 7,
"hp": target_hp,
"maxHp": target_hp,
"renderKind": "npc",
"levelProfile": read_field(&encounter, "levelProfile").cloned(),
"experienceReward": read_i32_field(&encounter, "experienceReward").unwrap_or(0).max(0),
"encounter": encounter
});
ensure_json_object(game_state).insert("currentEncounter".to_string(), encounter);
ensure_json_object(game_state).insert("sceneHostileNpcs".to_string(), json!([hostile]));
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", mode);
write_null_field(game_state, "currentNpcBattleOutcome");
Ok(StoryResolution {
action_text: resolve_action_text(
if mode == "spar" {
"点到为止切磋"
} else {
"与对方战斗"
},
request,
),
result_text: if mode == "spar" {
format!("{} 与你拉开架势,这场切磋正式开始。", npc.name)
} else {
format!("{} 已经进入战斗姿态,你必须立刻应对。", npc.name)
},
story_text: None,
presentation_options: Some(build_battle_runtime_story_options(game_state)),
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: Some(0),
damage_taken: Some(0),
outcome: Some("ongoing".to_string()),
}),
toast: Some(
if mode == "spar" {
"切磋开始"
} else {
"战斗开始"
}
.to_string(),
),
})
}
fn resolve_npc_quest_offer_view_action(
game_state: &mut Value,
request: &RuntimeStoryActionRequest,
) -> Result<StoryResolution, String> {
let npc = current_npc_context(game_state)?;
Ok(StoryResolution {
action_text: resolve_action_text("查看委托", request),
result_text: format!("你把 {} 提到的委托细节重新梳理了一遍。", npc.name),
story_text: None,
presentation_options: Some(build_npc_interaction_options(npc.id.as_str())),
saved_current_story: None,
patches: vec![build_status_patch(game_state)],
battle: None,
toast: None,
})
}
fn resolve_npc_quest_offer_replace_action(
game_state: &mut Value,
request: &RuntimeStoryActionRequest,
) -> Result<StoryResolution, String> {
let npc = current_npc_context(game_state)?;
let state = ensure_npc_state(game_state, npc.id.as_str(), npc.name.as_str());
adjust_npc_affinity_state(state, 2, "npc_chat");
Ok(StoryResolution {
action_text: resolve_action_text("更换委托", request),
result_text: format!(
"{} 收回原本的委托,换成了一件更适合你眼下处境的事。",
npc.name
),
story_text: None,
presentation_options: Some(build_npc_interaction_options(npc.id.as_str())),
saved_current_story: None,
patches: vec![build_status_patch(game_state)],
battle: None,
toast: Some("委托已更换".to_string()),
})
}
fn resolve_npc_quest_offer_abandon_action(
game_state: &mut Value,
request: &RuntimeStoryActionRequest,
) -> Result<StoryResolution, String> {
let npc = current_npc_context(game_state)?;
Ok(StoryResolution {
action_text: resolve_action_text("暂不接下委托", request),
result_text: format!(
"你暂时没有接下 {} 提出的委托,对方也没有继续强求。",
npc.name
),
story_text: None,
presentation_options: Some(build_npc_interaction_options(npc.id.as_str())),
saved_current_story: None,
patches: vec![build_status_patch(game_state)],
battle: None,
toast: Some("已暂缓委托".to_string()),
})
}
fn resolve_npc_quest_accept_action(
current_story: Option<&Value>,
game_state: &mut Value,
request: &RuntimeStoryActionRequest,
) -> Result<StoryResolution, String> {
let npc = current_npc_context(game_state)?;
let quest = request
.action
.payload
.as_ref()
.and_then(|payload| read_field(payload, "quest").cloned())
.or_else(|| pending_quest_offer_quest(current_story))
.unwrap_or_else(|| build_fallback_npc_quest(&npc));
upsert_runtime_quest(game_state, quest);
let state = ensure_npc_state(game_state, npc.id.as_str(), npc.name.as_str());
let previous_affinity = read_i32_field(state, "affinity").unwrap_or(0);
adjust_npc_affinity_state(state, 5, "npc_quest_accept");
let next_affinity = read_i32_field(state, "affinity").unwrap_or(previous_affinity);
increment_runtime_stat_local(game_state, "questsAccepted", 1);
Ok(StoryResolution {
action_text: resolve_action_text(&format!("接下{}的委托", npc.name), request),
result_text: format!(
"你接下了 {} 交出的委托,这条线索已经写入当前任务。",
npc.name
),
story_text: None,
presentation_options: Some(build_npc_interaction_options(npc.id.as_str())),
saved_current_story: None,
patches: vec![
RuntimeStoryPatch::NpcAffinityChanged {
npc_id: npc.id,
previous_affinity,
next_affinity,
},
build_status_patch(game_state),
],
battle: None,
toast: Some("已接取委托".to_string()),
})
}
fn resolve_npc_quest_turn_in_action(
game_state: &mut Value,
request: &RuntimeStoryActionRequest,
) -> Result<StoryResolution, String> {
let npc = current_npc_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(|| active_quest_id_for_npc(game_state, npc.id.as_str(), npc.name.as_str()))
.ok_or_else(|| "未找到可交付的委托。".to_string())?;
mark_quest_turned_in(game_state, quest_id.as_str())?;
let state = ensure_npc_state(game_state, npc.id.as_str(), npc.name.as_str());
let previous_affinity = read_i32_field(state, "affinity").unwrap_or(0);
adjust_npc_affinity_state(state, 10, "npc_help");
let next_affinity = read_i32_field(state, "affinity").unwrap_or(previous_affinity);
Ok(StoryResolution {
action_text: resolve_action_text("交付委托", request),
result_text: format!("你把委托结果交给 {},这件事暂时收束。", npc.name),
story_text: None,
presentation_options: Some(build_npc_interaction_options(npc.id.as_str())),
saved_current_story: None,
patches: vec![
RuntimeStoryPatch::NpcAffinityChanged {
npc_id: npc.id,
previous_affinity,
next_affinity,
},
build_status_patch(game_state),
],
battle: None,
toast: Some("委托已交付".to_string()),
})
}
fn resolve_equipment_equip_action(
game_state: &mut Value,
request: &RuntimeStoryActionRequest,
) -> Result<StoryResolution, 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())?;
if read_i32_field(&item, "quantity").unwrap_or(0) <= 0 {
return Err("背包里没有目标装备。".to_string());
}
let slot_id =
resolve_equipment_slot_for_item(&item).ok_or_else(|| "该物品不能装备。".to_string())?;
let previous_item = read_player_equipment_item(game_state, slot_id);
let mut next_inventory = remove_inventory_item_from_list(
read_player_inventory_values(game_state),
item_id.as_str(),
1,
);
if let Some(previous_item) = previous_item {
next_inventory = add_inventory_items_to_list(next_inventory, vec![previous_item]);
}
write_player_inventory_values(game_state, next_inventory);
write_player_equipment_item(
game_state,
slot_id,
Some(clone_inventory_item_with_quantity(&item, 1)),
);
apply_equipment_loadout_to_state(game_state);
Ok(StoryResolution {
action_text: resolve_action_text(
&format!("装备{}", read_inventory_item_name(&item)),
request,
),
result_text: format!(
"你换上了{},当前 Build 已经重新结算。",
read_inventory_item_name(&item)
),
story_text: None,
presentation_options: None,
saved_current_story: None,
patches: vec![build_status_patch(game_state)],
battle: None,
toast: Some(build_current_build_toast(game_state)),
})
}
fn resolve_equipment_unequip_action(
game_state: &mut Value,
request: &RuntimeStoryActionRequest,
) -> Result<StoryResolution, String> {
if read_bool_field(game_state, "inBattle").unwrap_or(false) {
return Err("战斗中无法更换装备。".to_string());
}
let slot_id = request
.action
.payload
.as_ref()
.and_then(|payload| read_optional_string_field(payload, "slotId"))
.or_else(|| request.action.target_id.clone())
.and_then(|value| normalize_equipment_slot_id(value.as_str()).map(str::to_string))
.ok_or_else(|| "equipment_unequip 缺少合法 slotId".to_string())?;
let item = read_player_equipment_item(game_state, slot_id.as_str())
.ok_or_else(|| "目标装备槽当前为空。".to_string())?;
write_player_equipment_item(game_state, slot_id.as_str(), None);
write_player_inventory_values(
game_state,
add_inventory_items_to_list(read_player_inventory_values(game_state), vec![item.clone()]),
);
apply_equipment_loadout_to_state(game_state);
Ok(StoryResolution {
action_text: resolve_action_text(
&format!("卸下{}", read_inventory_item_name(&item)),
request,
),
result_text: format!(
"你卸下了{},当前 Build 已经重新结算。",
read_inventory_item_name(&item)
),
story_text: None,
presentation_options: None,
saved_current_story: None,
patches: vec![build_status_patch(game_state)],
battle: None,
toast: Some(build_current_build_toast(game_state)),
})
}
#[derive(Clone, Debug)]
struct CurrentNpcContext {
id: String,
name: String,
encounter: Value,
}
fn current_npc_context(game_state: &Value) -> Result<CurrentNpcContext, String> {
let encounter = read_object_field(game_state, "currentEncounter")
.ok_or_else(|| "当前没有可交互角色。".to_string())?;
let name = read_optional_string_field(encounter, "npcName")
.or_else(|| read_optional_string_field(encounter, "name"))
.unwrap_or_else(|| "当前角色".to_string());
let id = read_optional_string_field(encounter, "id").unwrap_or_else(|| name.clone());
Ok(CurrentNpcContext {
id,
name,
encounter: encounter.clone(),
})
}
fn ensure_npc_state<'a>(game_state: &'a mut Value, npc_id: &str, npc_name: &str) -> &'a mut Value {
let root = ensure_json_object(game_state);
let npc_states = root
.entry("npcStates".to_string())
.or_insert_with(|| Value::Object(Map::new()));
if !npc_states.is_object() {
*npc_states = Value::Object(Map::new());
}
let states = npc_states
.as_object_mut()
.expect("npcStates should be object");
let state_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()
};
states
.entry(state_key)
.or_insert_with(|| build_default_npc_state(npc_name))
}
fn build_default_npc_state(npc_name: &str) -> Value {
let _ = npc_name;
let affinity = 18;
json!({
"affinity": affinity,
"chattedCount": 0,
"helpUsed": false,
"giftsGiven": 0,
"inventory": [],
"recruited": false,
"relationState": relation_state_value(affinity),
"revealedFacts": [],
"knownAttributeRumors": [],
"firstMeaningfulContactResolved": false,
"seenBackstoryChapterIds": [],
"tradeStockSignature": Value::Null,
"stanceProfile": initial_stance_profile_value(affinity, false, false, "")
})
}
fn read_npc_affinity(game_state: &mut Value, npc_id: &str, npc_name: &str) -> i32 {
let state = ensure_npc_state(game_state, npc_id, npc_name);
read_i32_field(state, "affinity").unwrap_or(0)
}
fn adjust_npc_affinity_state(state: &mut Value, delta: i32, action: &str) {
let previous = read_i32_field(state, "affinity").unwrap_or(0);
let next = previous.saturating_add(delta).clamp(-40, 100);
write_i32_field(state, "affinity", next);
ensure_json_object(state).insert("relationState".to_string(), relation_state_value(next));
adjust_npc_stance_for_action(state, action, delta, false);
}
fn relation_state_value(affinity: i32) -> Value {
json!({
"affinity": affinity,
"stance": relation_stance_key(affinity)
})
}
fn relation_stance_key(affinity: i32) -> &'static str {
if affinity < 0 {
"hostile"
} else if affinity < 15 {
"guarded"
} else if affinity < 30 {
"neutral"
} else if affinity < 60 {
"cooperative"
} else {
"bonded"
}
}
fn initial_stance_profile_value(
affinity: i32,
recruited: bool,
hostile: bool,
role_text: &str,
) -> Value {
json!({
"trust": clamp_stance_metric(42 + affinity * 55 / 100 + if recruited { 14 } else { 0 } - if hostile { 18 } else { 0 }),
"warmth": clamp_stance_metric(36 + affinity / 2 + if recruited { 14 } else { 0 }),
"ideologicalFit": clamp_stance_metric(48 + affinity / 4),
"fearOrGuard": clamp_stance_metric(62 - affinity * 55 / 100 + if hostile { 18 } else { 0 }),
"loyalty": clamp_stance_metric(24 + affinity * 35 / 100 + if recruited { 26 } else { 0 }),
"currentConflictTag": infer_current_conflict_tag(role_text),
"recentApprovals": [],
"recentDisapprovals": []
})
}
fn infer_current_conflict_tag(role_text: &str) -> Value {
if role_text.contains("旧案") || role_text.contains("调查") || role_text.contains("追查")
{
json!("旧案")
} else if role_text.contains('守') || role_text.contains('卫') || role_text.contains('巡') {
json!("守线")
} else if role_text.contains('商') || role_text.contains('摊') || role_text.contains("军需")
{
json!("交易")
} else {
Value::Null
}
}
fn adjust_npc_stance_for_action(
state: &mut Value,
action: &str,
affinity_gain: i32,
recruited: bool,
) {
let affinity = read_i32_field(state, "affinity").unwrap_or(0);
let role_text = read_optional_string_field(state, "role").unwrap_or_default();
let stance = ensure_json_object(state)
.entry("stanceProfile".to_string())
.or_insert_with(|| {
initial_stance_profile_value(affinity, recruited, affinity < 0, role_text.as_str())
});
if !stance.is_object() {
*stance =
initial_stance_profile_value(affinity, recruited, affinity < 0, role_text.as_str());
}
let stance = stance
.as_object_mut()
.expect("stanceProfile should be object");
match action {
"npc_chat" => {
bump_stance_metric(stance, "trust", 6 + affinity_gain * 2);
bump_stance_metric(stance, "warmth", 4 + affinity_gain * 2);
bump_stance_metric(stance, "fearOrGuard", -5 - affinity_gain);
append_stance_note(
stance,
"recentApprovals",
"你愿意先从眼前局势和试探开始说话。",
);
}
"npc_help" => {
bump_stance_metric(stance, "trust", 12);
bump_stance_metric(stance, "warmth", 6);
bump_stance_metric(stance, "fearOrGuard", -8);
append_stance_note(stance, "recentApprovals", "你在对方需要的时候搭了手。");
}
"npc_gift" => {
bump_stance_metric(stance, "trust", 6 + affinity_gain);
bump_stance_metric(stance, "warmth", 10 + affinity_gain * 2);
bump_stance_metric(stance, "fearOrGuard", -4);
append_stance_note(
stance,
"recentApprovals",
"你给出的东西回应了对方眼下的处境。",
);
}
"npc_recruit" => {
bump_stance_metric(stance, "trust", 8);
bump_stance_metric(stance, "warmth", 6);
bump_stance_metric(stance, "loyalty", 18);
bump_stance_metric(stance, "fearOrGuard", -10);
append_stance_note(stance, "recentApprovals", "你正式把对方纳入了同行关系。");
}
"npc_quest_accept" => {
bump_stance_metric(stance, "trust", 7);
bump_stance_metric(stance, "ideologicalFit", 5);
bump_stance_metric(stance, "loyalty", 4);
append_stance_note(stance, "recentApprovals", "你接住了对方主动交出来的事。");
}
_ => {}
}
}
fn bump_stance_metric(stance: &mut Map<String, Value>, key: &str, delta: i32) {
let current = stance
.get(key)
.and_then(Value::as_i64)
.and_then(|value| i32::try_from(value).ok())
.unwrap_or(50);
stance.insert(key.to_string(), json!(clamp_stance_metric(current + delta)));
}
fn clamp_stance_metric(value: i32) -> i32 {
value.clamp(0, 100)
}
fn append_stance_note(stance: &mut Map<String, Value>, key: &str, note: &str) {
let mut notes = stance
.get(key)
.and_then(Value::as_array)
.cloned()
.unwrap_or_default();
notes.push(Value::String(note.to_string()));
let keep_from = notes.len().saturating_sub(3);
stance.insert(
key.to_string(),
Value::Array(notes.into_iter().skip(keep_from).collect()),
);
}
fn increment_npc_state_i32_field(state: &mut Value, key: &str, delta: i32) {
let previous = read_i32_field(state, key).unwrap_or(0);
write_i32_field(state, key, previous.saturating_add(delta).max(0));
}
fn build_npc_interaction_options(npc_id: &str) -> Vec<RuntimeStoryOptionView> {
vec![
build_npc_option("npc_chat", "继续交谈", npc_id, "chat"),
build_npc_option("npc_help", "请求援手", npc_id, "help"),
build_npc_option("npc_trade", "交易", npc_id, "trade"),
build_npc_option("npc_gift", "赠礼", npc_id, "gift"),
build_npc_option("npc_recruit", "邀请入队", npc_id, "recruit"),
build_npc_option("npc_spar", "点到为止切磋", npc_id, "spar"),
build_npc_option("npc_fight", "与对方战斗", npc_id, "fight"),
build_npc_option("npc_leave", "离开当前角色", npc_id, "leave"),
]
}
fn find_npc_inventory_item(state: &Value, item_id: &str) -> Option<Value> {
read_array_field(state, "inventory")
.into_iter()
.find(|item| read_optional_string_field(item, "id").as_deref() == Some(item_id))
.cloned()
}
fn read_npc_inventory_values(state: &Value) -> Vec<Value> {
read_array_field(state, "inventory")
.into_iter()
.cloned()
.collect()
}
fn write_npc_inventory_values(state: &mut Value, items: Vec<Value>) {
ensure_json_object(state).insert("inventory".to_string(), Value::Array(items));
}
fn trade_quantity_suffix_local(quantity: i32) -> String {
if quantity > 1 {
format!(" x{quantity}")
} else {
String::new()
}
}
fn read_number_field(value: &Value, key: &str) -> Option<f64> {
read_field(value, key).and_then(Value::as_f64)
}
fn pending_quest_offer_quest(current_story: Option<&Value>) -> Option<Value> {
current_story
.and_then(|story| read_object_field(story, "npcChatState"))
.and_then(|chat| read_object_field(chat, "pendingQuestOffer"))
.and_then(|offer| read_field(offer, "quest"))
.cloned()
}
fn build_fallback_npc_quest(npc: &CurrentNpcContext) -> Value {
json!({
"id": format!("quest:npc:{}:fallback", npc.id),
"issuerNpcId": npc.id,
"issuerNpcName": npc.name,
"title": format!("{}的委托", npc.name),
"description": format!("处理 {} 交出的眼前事务。", npc.name),
"summary": format!("完成 {} 的委托。", npc.name),
"objective": {
"kind": "talk_to_npc",
"targetNpcId": npc.id,
"requiredCount": 1
},
"progress": 0,
"status": "active",
"reward": {
"affinityBonus": 10,
"currency": 60,
"experience": 30,
"items": []
}
})
}
fn upsert_runtime_quest(game_state: &mut Value, quest: Value) {
let quest_id = read_optional_string_field(&quest, "id").unwrap_or_else(|| {
format!(
"quest:runtime:{}",
read_array_field(game_state, "quests").len() + 1
)
});
let mut quest = quest;
if let Some(object) = quest.as_object_mut() {
object.insert("id".to_string(), Value::String(quest_id.clone()));
object
.entry("status".to_string())
.or_insert_with(|| Value::String("active".to_string()));
}
let root = ensure_json_object(game_state);
let quests = root
.entry("quests".to_string())
.or_insert_with(|| Value::Array(Vec::new()));
if !quests.is_array() {
*quests = Value::Array(Vec::new());
}
let items = quests.as_array_mut().expect("quests should be array");
if let Some(existing) = items
.iter_mut()
.find(|item| read_optional_string_field(item, "id").as_deref() == Some(quest_id.as_str()))
{
*existing = quest;
} else {
items.push(quest);
}
}
fn active_quest_id_for_npc(game_state: &Value, npc_id: &str, npc_name: &str) -> Option<String> {
read_array_field(game_state, "quests")
.into_iter()
.find(|quest| {
let status = read_optional_string_field(quest, "status").unwrap_or_default();
let issuer_matches = read_optional_string_field(quest, "issuerNpcId").as_deref()
== Some(npc_id)
|| read_optional_string_field(quest, "issuerNpcName").as_deref() == Some(npc_name);
issuer_matches && status != "turned_in" && status != "failed" && status != "expired"
})
.and_then(|quest| read_optional_string_field(quest, "id"))
}
fn mark_quest_turned_in(game_state: &mut Value, quest_id: &str) -> Result<(), String> {
let root = ensure_json_object(game_state);
let quests = root
.entry("quests".to_string())
.or_insert_with(|| Value::Array(Vec::new()));
let Some(items) = quests.as_array_mut() else {
return Err("当前任务列表非法。".to_string());
};
let Some(quest) = items
.iter_mut()
.find(|quest| read_optional_string_field(quest, "id").as_deref() == Some(quest_id))
else {
return Err("未找到可交付的委托。".to_string());
};
ensure_json_object(quest).insert("status".to_string(), Value::String("turned_in".to_string()));
Ok(())
}
fn increment_runtime_stat_local(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.saturating_add(delta).max(0)),
);
}
fn build_current_story(story_text: &str, options: &[RuntimeStoryOptionView]) -> Value {
json!({
"text": story_text,
"options": options.iter().map(build_story_option_from_runtime_option).collect::<Vec<_>>(),
"streaming": false
})
}
pub fn build_runtime_story_options(
current_story: Option<&Value>,
game_state: &Value,
) -> Vec<RuntimeStoryOptionView> {
if let Some(story) = current_story {
let prefers_deferred = read_field(story, "displayMode")
.and_then(Value::as_str)
.is_some_and(|value| value == "dialogue")
&& !read_array_field(story, "deferredOptions").is_empty();
let source = if prefers_deferred {
read_array_field(story, "deferredOptions")
} else {
read_array_field(story, "options")
};
let compiled = source
.into_iter()
.filter_map(build_runtime_story_option_from_story_option)
.collect::<Vec<_>>();
if !compiled.is_empty() {
return compiled;
}
}
build_fallback_runtime_story_options(game_state)
}
fn build_fallback_runtime_story_options(game_state: &Value) -> Vec<RuntimeStoryOptionView> {
if read_bool_field(game_state, "inBattle").unwrap_or(false) {
return build_battle_runtime_story_options(game_state);
}
if let Some(encounter) = read_object_field(game_state, "currentEncounter") {
let npc_id = read_optional_string_field(encounter, "id")
.or_else(|| read_optional_string_field(encounter, "npcName"))
.unwrap_or_else(|| "npc_current".to_string());
if read_bool_field(game_state, "npcInteractionActive").unwrap_or(false) {
return vec![
build_npc_option("npc_chat", "继续交谈", &npc_id, "chat"),
build_npc_option("npc_help", "请求援手", &npc_id, "help"),
build_npc_option("npc_spar", "点到为止切磋", &npc_id, "spar"),
build_npc_option("npc_fight", "与对方战斗", &npc_id, "fight"),
build_npc_option("npc_leave", "离开当前角色", &npc_id, "leave"),
];
}
return vec![
build_npc_option("npc_preview_talk", "转向眼前角色", &npc_id, "chat"),
build_npc_option("npc_fight", "与对方战斗", &npc_id, "fight"),
build_npc_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(CONTINUE_ADVENTURE_FUNCTION_ID, "继续推进冒险", "story"),
]
}
fn build_npc_option(
function_id: &str,
action_text: &str,
npc_id: &str,
action: &str,
) -> RuntimeStoryOptionView {
RuntimeStoryOptionView {
interaction: Some(
shared_contracts::runtime_story::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 fn build_runtime_story_state_response_parts(
requested_session_id: &str,
client_version: Option<u32>,
mut snapshot: StoryRuntimeSnapshotPayload,
) -> (
u32,
RuntimeStoryViewModel,
RuntimeStoryPresentation,
Vec<RuntimeStoryPatch>,
StoryRuntimeSnapshotPayload,
) {
write_runtime_npc_interaction_view(&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);
let view_model = build_runtime_story_view_model(&snapshot.game_state, &options);
let presentation = RuntimeStoryPresentation {
action_text: String::new(),
result_text: String::new(),
story_text,
options,
toast: None,
battle: None::<RuntimeBattlePresentation>,
};
let _ = session_id;
(
server_version,
view_model,
presentation,
Vec::new(),
snapshot,
)
}
fn read_story_text(current_story: Option<&Value>) -> Option<String> {
current_story.and_then(|story| read_optional_string_field(story, "text"))
}
fn build_fallback_story_text(game_state: &Value) -> String {
if read_bool_field(game_state, "inBattle").unwrap_or(false) {
let encounter_name = read_object_field(game_state, "currentEncounter")
.and_then(|encounter| read_optional_string_field(encounter, "npcName"))
.unwrap_or_else(|| "眼前的敌人".to_string());
return format!("战斗还没有结束,{encounter_name} 仍在逼你立刻做出下一步判断。");
}
if let Some(encounter) = read_object_field(game_state, "currentEncounter")
&& let Some(npc_name) = read_optional_string_field(encounter, "npcName")
{
return format!("{npc_name} 正在等你表态,当前局势已经可以继续推进。");
}
"当前故事状态已经同步,可以继续推进这一轮运行时动作。".to_string()
}
#[cfg(test)]
mod tests {
use serde_json::json;
use super::*;
#[test]
fn story_session_scoped_action_updates_snapshot_version() {
let output = resolve_story_runtime_action(StoryRuntimeActionResolveInput {
story_session_id: "storysess-1".to_string(),
runtime_session_id: "runtime-1".to_string(),
snapshot: StoryRuntimeSnapshotPayload {
saved_at: None,
bottom_tab: "adventure".to_string(),
game_state: json!({
"runtimeSessionId": "runtime-1",
"runtimeActionVersion": 1,
"playerHp": 30,
"playerMaxHp": 40,
"playerMana": 10,
"playerMaxMana": 20,
"playerCurrency": 0,
"playerInventory": [],
"playerEquipment": { "weapon": null, "armor": null, "relic": null },
"inBattle": false,
"npcInteractionActive": false,
"storyHistory": []
}),
current_story: None,
},
request: ResolveStoryRuntimeActionRequest {
story_session_id: "storysess-1".to_string(),
client_version: Some(1),
function_id: "idle_rest_focus".to_string(),
action_text: "原地调息".to_string(),
target_id: None,
payload: None,
},
})
.expect("action should resolve");
assert_eq!(output.server_version, 2);
assert_eq!(
output.snapshot.game_state["storySessionId"],
json!("storysess-1")
);
assert_eq!(output.snapshot.game_state["runtimeActionVersion"], json!(2));
assert_eq!(output.presentation.action_text, "原地调息");
assert!(output.snapshot.current_story.is_some());
}
#[test]
fn story_session_scoped_action_rejects_runtime_session_mismatch() {
let error = resolve_story_runtime_action(StoryRuntimeActionResolveInput {
story_session_id: "storysess-1".to_string(),
runtime_session_id: "runtime-expected".to_string(),
snapshot: StoryRuntimeSnapshotPayload {
saved_at: None,
bottom_tab: "adventure".to_string(),
game_state: json!({ "runtimeSessionId": "runtime-other" }),
current_story: None,
},
request: ResolveStoryRuntimeActionRequest {
story_session_id: "storysess-1".to_string(),
client_version: None,
function_id: "idle_call_out".to_string(),
action_text: "主动出声试探".to_string(),
target_id: None,
payload: None,
},
})
.expect_err("mismatch should fail");
assert!(error.contains("不匹配"));
}
}