Close DDD cleanup and tests-support closure

This commit is contained in:
2026-04-30 16:15:05 +08:00
parent 7ab0933f6d
commit fd08262bf0
81 changed files with 8415 additions and 6662 deletions

View File

@@ -1,3 +1,555 @@
//! NPC 应用编排过渡落位
//! 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<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>,
}
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
}

View File

@@ -1,3 +1,51 @@
//! NPC 写入命令过渡落位
//! NPC 写入命令。
//!
//! 用于表达聊天、帮助、送礼、招募、开战和切磋等输入。
use crate::domain::{NpcSocialActionKind, NpcStanceProfile};
use serde::{Deserialize, Serialize};
#[cfg(feature = "spacetime-types")]
use spacetimedb::SpacetimeType;
#[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,
}

View File

@@ -1,4 +1,99 @@
//! NPC 领域模型过渡落位
//! NPC 领域模型。
//!
//! 后续迁移 NPC 状态、关系、好感、招募和互动规则时,只保留社交领域变化;
//! 战斗初始化和跨表事务由外层编排。
//! 本文件只承载 NPC 聚合状态、关系、立场和交互值对象;对话文本、战斗初始化和任务联动由外层应用服务或 adapter 编排。
use serde::{Deserialize, Serialize};
#[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,
}

View File

@@ -1,3 +1,38 @@
//! NPC 领域错误过渡落位
//! NPC 领域错误。
//!
//! 错误只表达互动规则失败,例如状态不允许、好感不足或目标非法。
use std::{error::Error, fmt};
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum NpcStateFieldError {
MissingRuntimeSessionId,
MissingNpcId,
MissingNpcName,
MissingInteractionFunctionId,
HelpAlreadyUsed,
RecruitAffinityTooLow,
UnsupportedInteractionFunctionId,
}
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 {}

View File

@@ -1,3 +1,51 @@
//! NPC 领域事件过渡落位
//! NPC 领域事件。
//!
//! 用于表达好感变化、关系变化、NPC 被招募和战斗请求产生等事实。
use crate::domain::{NpcInteractionBattleMode, NpcRelationStance, NpcSocialActionKind};
use serde::{Deserialize, Serialize};
#[cfg(feature = "spacetime-types")]
use spacetimedb::SpacetimeType;
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub enum NpcDomainEvent {
RelationChanged(NpcRelationChangedEvent),
SocialActionResolved(NpcSocialActionResolvedEvent),
RecruitResolved(NpcRecruitResolvedEvent),
BattleRequested(NpcBattleRequestedEvent),
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct NpcRelationChangedEvent {
pub npc_state_id: String,
pub previous_affinity: i32,
pub next_affinity: i32,
pub next_stance: NpcRelationStance,
pub occurred_at_micros: i64,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct NpcSocialActionResolvedEvent {
pub npc_state_id: String,
pub action_kind: NpcSocialActionKind,
pub occurred_at_micros: i64,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct NpcRecruitResolvedEvent {
pub npc_state_id: String,
pub recruited: bool,
pub occurred_at_micros: i64,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct NpcBattleRequestedEvent {
pub npc_state_id: String,
pub battle_mode: NpcInteractionBattleMode,
pub occurred_at_micros: i64,
}

View File

@@ -4,722 +4,11 @@ mod domain;
mod errors;
mod events;
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 {}
pub use application::*;
pub use commands::*;
pub use domain::*;
pub use errors::*;
pub use events::*;
#[cfg(test)]
mod tests {