Files
Genarrative/server-rs/crates/module-npc/src/lib.rs
kdletters cbc27bad4a
Some checks failed
CI / verify (push) Has been cancelled
init with react+axum+spacetimedb
2026-04-26 18:06:23 +08:00

924 lines
33 KiB
Rust

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<String>,
pub recent_approvals: Vec<String>,
pub recent_disapprovals: Vec<String>,
}
#[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<String>,
pub revealed_facts: Vec<String>,
pub known_attribute_rumors: Vec<String>,
pub first_meaningful_contact_resolved: bool,
pub seen_backstory_chapter_ids: Vec<String>,
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<String>,
pub revealed_facts: Vec<String>,
pub known_attribute_rumors: Vec<String>,
pub first_meaningful_contact_resolved: bool,
pub seen_backstory_chapter_ids: Vec<String>,
pub stance_profile: Option<NpcStanceProfile>,
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<i32>,
pub note: Option<String>,
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<String>,
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<NpcStateSnapshot>,
pub error_message: Option<String>,
}
#[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<String>,
pub battle_mode: Option<NpcInteractionBattleMode>,
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<NpcInteractionResult>,
pub error_message: Option<String>,
}
#[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<i64>,
) -> Result<NpcStateSnapshot, NpcStateFieldError> {
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<NpcStateSnapshot, NpcStateFieldError> {
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<NpcInteractionResult, NpcStateFieldError> {
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<String>) -> Option<String> {
normalize_shared_optional_string(value)
}
pub fn normalize_string_list(values: Vec<String>) -> Vec<String> {
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<NpcStanceProfile>,
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<String> {
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<String>) -> Vec<String> {
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<String>, 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);
}
}