924 lines
33 KiB
Rust
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);
|
|
}
|
|
}
|