Close DDD cleanup and tests-support closure
This commit is contained in:
@@ -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
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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 {}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user