1579 lines
60 KiB
Rust
1579 lines
60 KiB
Rust
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("不匹配"));
|
||
}
|
||
}
|