use std::{error::Error, fmt}; 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; pub const NPC_STATE_ID_PREFIX: &str = "npcstate_"; pub const MAX_STANCE_NOTES: usize = 3; pub const NPC_RECRUIT_AFFINITY_THRESHOLD: i32 = 60; pub const NPC_PREVIEW_TALK_FUNCTION_ID: &str = "npc_preview_talk"; pub const NPC_CHAT_FUNCTION_ID: &str = "npc_chat"; pub const NPC_HELP_FUNCTION_ID: &str = "npc_help"; pub const NPC_RECRUIT_FUNCTION_ID: &str = "npc_recruit"; pub const NPC_FIGHT_FUNCTION_ID: &str = "npc_fight"; pub const NPC_SPAR_FUNCTION_ID: &str = "npc_spar"; pub const NPC_LEAVE_FUNCTION_ID: &str = "npc_leave"; #[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] pub enum NpcRelationStance { Hostile, Guarded, Neutral, Cooperative, Bonded, } #[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] pub enum NpcSocialActionKind { Chat, Help, Gift, Recruit, QuestAccept, } #[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] pub enum NpcInteractionStatus { Previewed, Dialogue, Resolved, Recruited, BattlePending, Left, } #[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] pub enum NpcInteractionBattleMode { Fight, Spar, } #[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub struct NpcRelationState { pub affinity: i32, pub stance: NpcRelationStance, } #[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub struct NpcStanceProfile { pub trust: u8, pub warmth: u8, pub ideological_fit: u8, pub fear_or_guard: u8, pub loyalty: u8, pub current_conflict_tag: Option, pub recent_approvals: Vec, pub recent_disapprovals: Vec, } #[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub struct NpcStateSnapshot { pub npc_state_id: String, pub runtime_session_id: String, pub npc_id: String, pub npc_name: String, pub affinity: i32, pub relation_state: NpcRelationState, pub help_used: bool, pub chatted_count: u32, pub gifts_given: u32, pub recruited: bool, pub trade_stock_signature: Option, pub revealed_facts: Vec, pub known_attribute_rumors: Vec, pub first_meaningful_contact_resolved: bool, pub seen_backstory_chapter_ids: Vec, pub stance_profile: NpcStanceProfile, pub created_at_micros: i64, pub updated_at_micros: i64, } #[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub struct NpcStateUpsertInput { pub runtime_session_id: String, pub npc_id: String, pub npc_name: String, pub affinity: i32, pub help_used: bool, pub chatted_count: u32, pub gifts_given: u32, pub recruited: bool, pub trade_stock_signature: Option, pub revealed_facts: Vec, pub known_attribute_rumors: Vec, pub first_meaningful_contact_resolved: bool, pub seen_backstory_chapter_ids: Vec, pub stance_profile: Option, pub updated_at_micros: i64, } #[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub struct ResolveNpcSocialActionInput { pub runtime_session_id: String, pub npc_id: String, pub npc_name: String, pub action_kind: NpcSocialActionKind, pub affinity_gain_override: Option, pub note: Option, pub updated_at_micros: i64, } #[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub struct ResolveNpcInteractionInput { pub runtime_session_id: String, pub npc_id: String, pub npc_name: String, pub interaction_function_id: String, pub release_npc_id: Option, pub updated_at_micros: i64, } #[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, } #[derive(Clone, Debug, PartialEq, Eq)] pub enum NpcStateFieldError { MissingRuntimeSessionId, MissingNpcId, MissingNpcName, MissingInteractionFunctionId, HelpAlreadyUsed, RecruitAffinityTooLow, UnsupportedInteractionFunctionId, } 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 } impl fmt::Display for NpcStateFieldError { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match self { Self::MissingRuntimeSessionId => f.write_str("npc_state.runtime_session_id 不能为空"), Self::MissingNpcId => f.write_str("npc_state.npc_id 不能为空"), Self::MissingNpcName => f.write_str("npc_state.npc_name 不能为空"), Self::MissingInteractionFunctionId => { f.write_str("resolve_npc_interaction.interaction_function_id 不能为空") } Self::HelpAlreadyUsed => f.write_str("npc_state.help_used 已经消耗,不能重复援手"), Self::RecruitAffinityTooLow => { f.write_str("npc_state.affinity 未达到招募阈值,不能执行招募动作") } Self::UnsupportedInteractionFunctionId => { f.write_str("resolve_npc_interaction.interaction_function_id 当前不受支持") } } } } impl Error for NpcStateFieldError {} #[cfg(test)] mod tests { use super::*; fn build_base_state() -> NpcStateSnapshot { normalize_npc_state_snapshot( NpcStateUpsertInput { runtime_session_id: "runtime_001".to_string(), npc_id: "npc_001".to_string(), npc_name: "宁霜".to_string(), affinity: 18, help_used: false, chatted_count: 0, gifts_given: 0, recruited: false, trade_stock_signature: None, revealed_facts: vec![], known_attribute_rumors: vec![], first_meaningful_contact_resolved: false, seen_backstory_chapter_ids: vec![], stance_profile: None, updated_at_micros: 10, }, None, ) .expect("base npc state should be valid") } #[test] fn relation_state_uses_expected_thresholds() { assert_eq!(build_relation_state(-1).stance, NpcRelationStance::Hostile); assert_eq!(build_relation_state(0).stance, NpcRelationStance::Guarded); assert_eq!(build_relation_state(15).stance, NpcRelationStance::Neutral); assert_eq!( build_relation_state(30).stance, NpcRelationStance::Cooperative ); assert_eq!(build_relation_state(60).stance, NpcRelationStance::Bonded); } #[test] fn normalize_npc_state_snapshot_builds_primary_fields() { let snapshot = build_base_state(); assert_eq!(snapshot.npc_state_id, "npcstate_runtime_001:npc_001"); assert_eq!(snapshot.relation_state.stance, NpcRelationStance::Neutral); assert_eq!(snapshot.created_at_micros, 10); assert_eq!(snapshot.updated_at_micros, 10); } #[test] fn chat_action_increases_affinity_and_marks_first_contact() { let next = apply_npc_social_action( build_base_state(), ResolveNpcSocialActionInput { runtime_session_id: "runtime_001".to_string(), npc_id: "npc_001".to_string(), npc_name: "宁霜".to_string(), action_kind: NpcSocialActionKind::Chat, affinity_gain_override: None, note: None, updated_at_micros: 20, }, ) .expect("chat should succeed"); assert_eq!(next.affinity, 24); assert_eq!(next.chatted_count, 1); assert!(next.first_meaningful_contact_resolved); assert_eq!(next.updated_at_micros, 20); } #[test] fn help_action_rejects_second_use() { let used_state = NpcStateSnapshot { help_used: true, ..build_base_state() }; let error = apply_npc_social_action( used_state, ResolveNpcSocialActionInput { runtime_session_id: "runtime_001".to_string(), npc_id: "npc_001".to_string(), npc_name: "宁霜".to_string(), action_kind: NpcSocialActionKind::Help, affinity_gain_override: None, note: None, updated_at_micros: 20, }, ) .expect_err("help should fail once used"); assert_eq!(error, NpcStateFieldError::HelpAlreadyUsed); } #[test] fn recruit_requires_threshold() { let error = apply_npc_social_action( build_base_state(), ResolveNpcSocialActionInput { runtime_session_id: "runtime_001".to_string(), npc_id: "npc_001".to_string(), npc_name: "宁霜".to_string(), action_kind: NpcSocialActionKind::Recruit, affinity_gain_override: None, note: None, updated_at_micros: 20, }, ) .expect_err("recruit should require threshold"); assert_eq!(error, NpcStateFieldError::RecruitAffinityTooLow); } #[test] fn recruit_marks_state_when_affinity_is_high_enough() { let recruitable = NpcStateSnapshot { affinity: 66, relation_state: build_relation_state(66), ..build_base_state() }; let next = apply_npc_social_action( recruitable, ResolveNpcSocialActionInput { runtime_session_id: "runtime_001".to_string(), npc_id: "npc_001".to_string(), npc_name: "宁霜".to_string(), action_kind: NpcSocialActionKind::Recruit, affinity_gain_override: None, note: None, updated_at_micros: 20, }, ) .expect("recruit should succeed"); assert!(next.recruited); assert!(next.first_meaningful_contact_resolved); } #[test] fn resolve_preview_talk_keeps_affinity_unchanged() { let result = resolve_npc_interaction( build_base_state(), ResolveNpcInteractionInput { runtime_session_id: "runtime_001".to_string(), npc_id: "npc_001".to_string(), npc_name: "宁霜".to_string(), interaction_function_id: NPC_PREVIEW_TALK_FUNCTION_ID.to_string(), release_npc_id: None, updated_at_micros: 20, }, ) .expect("preview talk should succeed"); assert_eq!(result.interaction_status, NpcInteractionStatus::Previewed); assert!(!result.affinity_changed); assert_eq!(result.previous_affinity, 18); assert_eq!(result.next_affinity, 18); } #[test] fn resolve_chat_updates_npc_state_and_returns_dialogue_status() { let result = resolve_npc_interaction( build_base_state(), ResolveNpcInteractionInput { runtime_session_id: "runtime_001".to_string(), npc_id: "npc_001".to_string(), npc_name: "宁霜".to_string(), interaction_function_id: NPC_CHAT_FUNCTION_ID.to_string(), release_npc_id: None, updated_at_micros: 20, }, ) .expect("chat interaction should succeed"); assert_eq!(result.interaction_status, NpcInteractionStatus::Dialogue); assert!(result.affinity_changed); assert_eq!(result.next_affinity, 24); assert!(result.npc_state.first_meaningful_contact_resolved); } #[test] fn resolve_fight_returns_battle_pending_without_affinity_change() { let result = resolve_npc_interaction( build_base_state(), ResolveNpcInteractionInput { runtime_session_id: "runtime_001".to_string(), npc_id: "npc_001".to_string(), npc_name: "宁霜".to_string(), interaction_function_id: NPC_FIGHT_FUNCTION_ID.to_string(), release_npc_id: None, updated_at_micros: 20, }, ) .expect("fight interaction should succeed"); assert_eq!( result.interaction_status, NpcInteractionStatus::BattlePending ); assert_eq!(result.battle_mode, Some(NpcInteractionBattleMode::Fight)); assert!(!result.affinity_changed); } }