//! NPC 应用编排。 //! //! 这里只返回关系变化、推荐动作和跨上下文事件,不直接写战斗表。 use crate::commands::{ NpcStateUpsertInput, ResolveNpcInteractionInput, ResolveNpcSocialActionInput, }; use crate::domain::*; use crate::errors::NpcStateFieldError; use serde::{Deserialize, Serialize}; use shared_kernel::{ normalize_optional_string as normalize_shared_optional_string, normalize_required_string, normalize_string_list as normalize_shared_string_list, }; #[cfg(feature = "spacetime-types")] use spacetimedb::SpacetimeType; #[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub struct NpcStateProcedureResult { pub ok: bool, pub record: Option, pub error_message: Option, } #[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub struct NpcInteractionResult { pub npc_state: NpcStateSnapshot, pub interaction_status: NpcInteractionStatus, pub action_text: String, pub result_text: String, pub story_text: Option, pub battle_mode: Option, pub encounter_closed: bool, pub affinity_changed: bool, pub previous_affinity: i32, pub next_affinity: i32, } #[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub struct NpcInteractionProcedureResult { pub ok: bool, pub result: Option, pub error_message: Option, } pub fn generate_npc_state_id(runtime_session_id: &str, npc_id: &str) -> String { format!( "{}{}:{}", NPC_STATE_ID_PREFIX, runtime_session_id.trim(), npc_id.trim() ) } pub fn build_relation_state(affinity: i32) -> NpcRelationState { NpcRelationState { affinity, stance: if affinity < 0 { NpcRelationStance::Hostile } else if affinity < 15 { NpcRelationStance::Guarded } else if affinity < 30 { NpcRelationStance::Neutral } else if affinity < NPC_RECRUIT_AFFINITY_THRESHOLD { NpcRelationStance::Cooperative } else { NpcRelationStance::Bonded }, } } pub fn build_initial_stance_profile( affinity: i32, recruited: bool, hostile: bool, role_text: Option<&str>, ) -> NpcStanceProfile { let recruited_bonus = if recruited { 14.0 } else { 0.0 }; let hostile_penalty = if hostile { 18.0 } else { 0.0 }; let current_conflict_tag = role_text.and_then(infer_conflict_tag); NpcStanceProfile { trust: clamp_stance_metric( 42.0 + affinity as f32 * 0.55 + recruited_bonus - hostile_penalty, ), warmth: clamp_stance_metric(36.0 + affinity as f32 * 0.5 + recruited_bonus), ideological_fit: clamp_stance_metric(48.0 + affinity as f32 * 0.25), fear_or_guard: clamp_stance_metric(62.0 - affinity as f32 * 0.55 + hostile_penalty), loyalty: clamp_stance_metric( 24.0 + affinity as f32 * 0.35 + if recruited { 26.0 } else { 0.0 }, ), current_conflict_tag, recent_approvals: Vec::new(), recent_disapprovals: Vec::new(), } } pub fn normalize_npc_state_snapshot( input: NpcStateUpsertInput, existing_created_at_micros: Option, ) -> Result { validate_required_identity_fields(&input.runtime_session_id, &input.npc_id, &input.npc_name)?; let affinity = input.affinity; let stance_profile = normalize_stance_profile( input.stance_profile, affinity, input.recruited, affinity < 0, None, ); let created_at_micros = existing_created_at_micros.unwrap_or(input.updated_at_micros); Ok(NpcStateSnapshot { npc_state_id: generate_npc_state_id(&input.runtime_session_id, &input.npc_id), runtime_session_id: normalize_required_string(input.runtime_session_id).unwrap_or_default(), npc_id: normalize_required_string(input.npc_id).unwrap_or_default(), npc_name: normalize_required_string(input.npc_name).unwrap_or_default(), affinity, relation_state: build_relation_state(affinity), help_used: input.help_used, chatted_count: input.chatted_count, gifts_given: input.gifts_given, recruited: input.recruited, trade_stock_signature: normalize_optional_value(input.trade_stock_signature), revealed_facts: normalize_string_list(input.revealed_facts), known_attribute_rumors: normalize_string_list(input.known_attribute_rumors), first_meaningful_contact_resolved: input.first_meaningful_contact_resolved, seen_backstory_chapter_ids: normalize_string_list(input.seen_backstory_chapter_ids), stance_profile, created_at_micros, updated_at_micros: input.updated_at_micros, }) } pub fn apply_npc_social_action( current: NpcStateSnapshot, input: ResolveNpcSocialActionInput, ) -> Result { validate_required_identity_fields(&input.runtime_session_id, &input.npc_id, &input.npc_name)?; let note = normalize_optional_value(input.note); let mut next = current; match input.action_kind { NpcSocialActionKind::Chat => { let affinity_gain = input .affinity_gain_override .unwrap_or_else(|| (6 - next.chatted_count as i32).max(2)); next.affinity += affinity_gain; next.chatted_count += 1; next.first_meaningful_contact_resolved = true; next.stance_profile = apply_story_choice_to_stance_profile( &next.stance_profile, input.action_kind, affinity_gain, next.recruited, note.as_deref(), ); } NpcSocialActionKind::Help => { if next.help_used { return Err(NpcStateFieldError::HelpAlreadyUsed); } let affinity_gain = input.affinity_gain_override.unwrap_or(4); next.affinity += affinity_gain; next.help_used = true; next.stance_profile = apply_story_choice_to_stance_profile( &next.stance_profile, input.action_kind, affinity_gain, next.recruited, note.as_deref(), ); } NpcSocialActionKind::Gift => { let affinity_gain = input.affinity_gain_override.unwrap_or(4); next.affinity += affinity_gain; next.gifts_given += 1; next.stance_profile = apply_story_choice_to_stance_profile( &next.stance_profile, input.action_kind, affinity_gain, next.recruited, note.as_deref(), ); } NpcSocialActionKind::Recruit => { if next.affinity < NPC_RECRUIT_AFFINITY_THRESHOLD { return Err(NpcStateFieldError::RecruitAffinityTooLow); } next.recruited = true; next.first_meaningful_contact_resolved = true; next.stance_profile = apply_story_choice_to_stance_profile( &next.stance_profile, input.action_kind, input.affinity_gain_override.unwrap_or(0), true, note.as_deref(), ); } NpcSocialActionKind::QuestAccept => { let affinity_gain = input.affinity_gain_override.unwrap_or(3); next.affinity += affinity_gain; next.stance_profile = apply_story_choice_to_stance_profile( &next.stance_profile, input.action_kind, affinity_gain, next.recruited, note.as_deref(), ); } } next.affinity = next.affinity.clamp(-100, 100); next.npc_name = normalize_required_string(input.npc_name).unwrap_or_default(); next.relation_state = build_relation_state(next.affinity); next.updated_at_micros = input.updated_at_micros; Ok(next) } pub fn resolve_npc_interaction( current: NpcStateSnapshot, input: ResolveNpcInteractionInput, ) -> Result { validate_required_identity_fields(&input.runtime_session_id, &input.npc_id, &input.npc_name)?; let interaction_function_id = normalize_optional_value(Some(input.interaction_function_id)) .ok_or(NpcStateFieldError::MissingInteractionFunctionId)?; if !is_supported_npc_interaction_function_id(&interaction_function_id) { return Err(NpcStateFieldError::UnsupportedInteractionFunctionId); } let previous_affinity = current.affinity; let mut next_state = current.clone(); let (interaction_status, action_text, result_text, story_text, battle_mode, encounter_closed) = match interaction_function_id.as_str() { NPC_PREVIEW_TALK_FUNCTION_ID => ( NpcInteractionStatus::Previewed, format!("转向{}", current.npc_name), format!( "你把注意力真正收回到{}身上,接下来可以围绕这名角色做正式交互了。", current.npc_name ), None, None, false, ), NPC_CHAT_FUNCTION_ID => { next_state = apply_npc_social_action( current, ResolveNpcSocialActionInput { runtime_session_id: input.runtime_session_id, npc_id: input.npc_id, npc_name: input.npc_name, action_kind: NpcSocialActionKind::Chat, affinity_gain_override: None, note: None, updated_at_micros: input.updated_at_micros, }, )?; ( NpcInteractionStatus::Dialogue, format!("继续和{}交谈", next_state.npc_name), format!( "{}愿意把话接下去,态度比刚才明显松动了一些。", next_state.npc_name ), Some(format!( "{}看起来已经愿意继续把话题往下接。", next_state.npc_name )), None, false, ) } NPC_HELP_FUNCTION_ID => { next_state = apply_npc_social_action( current, ResolveNpcSocialActionInput { runtime_session_id: input.runtime_session_id, npc_id: input.npc_id, npc_name: input.npc_name, action_kind: NpcSocialActionKind::Help, affinity_gain_override: None, note: None, updated_at_micros: input.updated_at_micros, }, )?; ( NpcInteractionStatus::Resolved, format!("向{}请求援手", next_state.npc_name), format!( "{}给了你一次及时支援,关系也顺势拉近了一点。", next_state.npc_name ), None, None, false, ) } NPC_RECRUIT_FUNCTION_ID => { next_state = apply_npc_social_action( current, ResolveNpcSocialActionInput { runtime_session_id: input.runtime_session_id, npc_id: input.npc_id, npc_name: input.npc_name, action_kind: NpcSocialActionKind::Recruit, affinity_gain_override: None, note: None, updated_at_micros: input.updated_at_micros, }, )?; ( NpcInteractionStatus::Recruited, format!("邀请{}加入队伍", next_state.npc_name), format!("{}接受了你的邀请。", next_state.npc_name), Some(format!( "{}已经明确接受了与你同行的关系。", next_state.npc_name )), None, true, ) } NPC_FIGHT_FUNCTION_ID => ( NpcInteractionStatus::BattlePending, format!("与{}正面开战", current.npc_name), format!( "{}已经不再保留余地,当前冲突正式转入战斗结算。", current.npc_name ), None, Some(NpcInteractionBattleMode::Fight), false, ), NPC_SPAR_FUNCTION_ID => ( NpcInteractionStatus::BattlePending, format!("与{}点到为止切磋", current.npc_name), format!( "{}摆开架势,准备和你来一场点到为止的切磋。", current.npc_name ), None, Some(NpcInteractionBattleMode::Spar), false, ), NPC_LEAVE_FUNCTION_ID => ( NpcInteractionStatus::Left, format!("离开{}", current.npc_name), format!( "你暂时没有继续和{}纠缠,把注意力重新拉回了前路。", current.npc_name ), None, None, true, ), _ => return Err(NpcStateFieldError::UnsupportedInteractionFunctionId), }; Ok(NpcInteractionResult { npc_state: next_state.clone(), interaction_status, action_text, result_text, story_text, battle_mode, encounter_closed, affinity_changed: previous_affinity != next_state.affinity, previous_affinity, next_affinity: next_state.affinity, }) } pub fn normalize_optional_value(value: Option) -> Option { normalize_shared_optional_string(value) } pub fn normalize_string_list(values: Vec) -> Vec { normalize_shared_string_list(values) } pub fn is_supported_npc_interaction_function_id(function_id: &str) -> bool { matches!( function_id, NPC_PREVIEW_TALK_FUNCTION_ID | NPC_CHAT_FUNCTION_ID | NPC_HELP_FUNCTION_ID | NPC_RECRUIT_FUNCTION_ID | NPC_FIGHT_FUNCTION_ID | NPC_SPAR_FUNCTION_ID | NPC_LEAVE_FUNCTION_ID ) } fn validate_required_identity_fields( runtime_session_id: &str, npc_id: &str, npc_name: &str, ) -> Result<(), NpcStateFieldError> { if normalize_required_string(runtime_session_id).is_none() { return Err(NpcStateFieldError::MissingRuntimeSessionId); } if normalize_required_string(npc_id).is_none() { return Err(NpcStateFieldError::MissingNpcId); } if normalize_required_string(npc_name).is_none() { return Err(NpcStateFieldError::MissingNpcName); } Ok(()) } fn normalize_stance_profile( stance_profile: Option, affinity: i32, recruited: bool, hostile: bool, role_text: Option<&str>, ) -> NpcStanceProfile { let Some(stance_profile) = stance_profile else { return build_initial_stance_profile(affinity, recruited, hostile, role_text); }; NpcStanceProfile { trust: clamp_stance_metric(stance_profile.trust as f32), warmth: clamp_stance_metric(stance_profile.warmth as f32), ideological_fit: clamp_stance_metric(stance_profile.ideological_fit as f32), fear_or_guard: clamp_stance_metric(stance_profile.fear_or_guard as f32), loyalty: clamp_stance_metric(stance_profile.loyalty as f32), current_conflict_tag: normalize_optional_value(stance_profile.current_conflict_tag), recent_approvals: trim_recent_notes(stance_profile.recent_approvals), recent_disapprovals: trim_recent_notes(stance_profile.recent_disapprovals), } } fn apply_story_choice_to_stance_profile( stance_profile: &NpcStanceProfile, action_kind: NpcSocialActionKind, affinity_gain: i32, recruited: bool, note: Option<&str>, ) -> NpcStanceProfile { let mut next = stance_profile.clone(); match action_kind { NpcSocialActionKind::Chat => { next.trust = clamp_stance_metric(next.trust as f32 + 6.0 + affinity_gain as f32 * 2.0); next.warmth = clamp_stance_metric(next.warmth as f32 + 4.0 + affinity_gain as f32 * 2.0); next.fear_or_guard = clamp_stance_metric(next.fear_or_guard as f32 - 5.0 - affinity_gain as f32); if affinity_gain >= 0 { push_recent_note( &mut next.recent_approvals, note.unwrap_or("你愿意先从眼前局势和试探开始说话。"), ); } else { push_recent_note( &mut next.recent_disapprovals, note.unwrap_or("这轮交流没能真正对上节奏。"), ); } } NpcSocialActionKind::Help => { next.trust = clamp_stance_metric(next.trust as f32 + 12.0); next.warmth = clamp_stance_metric(next.warmth as f32 + 6.0); next.fear_or_guard = clamp_stance_metric(next.fear_or_guard as f32 - 8.0); push_recent_note( &mut next.recent_approvals, note.unwrap_or("你在对方需要的时候搭了手。"), ); } NpcSocialActionKind::Gift => { next.trust = clamp_stance_metric(next.trust as f32 + 6.0 + affinity_gain as f32); next.warmth = clamp_stance_metric(next.warmth as f32 + 10.0 + affinity_gain as f32 * 2.0); next.fear_or_guard = clamp_stance_metric(next.fear_or_guard as f32 - 4.0); push_recent_note( &mut next.recent_approvals, note.unwrap_or("你给出的东西回应了对方眼下的处境。"), ); } NpcSocialActionKind::Recruit => { next.trust = clamp_stance_metric(next.trust as f32 + 8.0); next.warmth = clamp_stance_metric(next.warmth as f32 + 6.0); next.loyalty = clamp_stance_metric(next.loyalty as f32 + 18.0 + if recruited { 4.0 } else { 0.0 }); next.fear_or_guard = clamp_stance_metric(next.fear_or_guard as f32 - 10.0); push_recent_note( &mut next.recent_approvals, note.unwrap_or("你正式把对方纳入了同行关系。"), ); } NpcSocialActionKind::QuestAccept => { next.trust = clamp_stance_metric(next.trust as f32 + 7.0); next.ideological_fit = clamp_stance_metric(next.ideological_fit as f32 + 5.0); next.loyalty = clamp_stance_metric(next.loyalty as f32 + 4.0); push_recent_note( &mut next.recent_approvals, note.unwrap_or("你接住了对方主动交出来的事。"), ); } } next } fn infer_conflict_tag(value: &str) -> Option { let trimmed = value.trim(); if trimmed.is_empty() { None } else if trimmed.contains("旧案") || trimmed.contains("调查") || trimmed.contains("追查") { Some("旧案".to_string()) } else if trimmed.contains('守') || trimmed.contains('卫') || trimmed.contains('巡') { Some("守线".to_string()) } else if trimmed.contains('商') || trimmed.contains('摊') || trimmed.contains("军需") { Some("交易".to_string()) } else { None } } fn trim_recent_notes(values: Vec) -> Vec { let mut values = normalize_string_list(values); if values.len() > MAX_STANCE_NOTES { values = values.split_off(values.len() - MAX_STANCE_NOTES); } values } fn push_recent_note(target: &mut Vec, note: &str) { let trimmed = note.trim(); if trimmed.is_empty() { return; } target.push(trimmed.to_string()); if target.len() > MAX_STANCE_NOTES { let drain_len = target.len() - MAX_STANCE_NOTES; target.drain(0..drain_len); } } fn clamp_stance_metric(value: f32) -> u8 { value.round().clamp(0.0, 100.0) as u8 }