use bevy::ecs::entity::Entity;
use bevy::ecs::message::Message;
use crate::beliefs::{Knowledge, Opinions};
use crate::core::{AssessmentNorm, SocietySettings, Tuning};
use crate::defs::{DeedId, TopicId};
use crate::mind::{EmotionKind, Emotions, Memories, Memory, MemoryKind, Personality};
use crate::relationships::Ties;
use crate::rumor::{RumorBook, RumorId, plant_rumor};
pub const DISTRUSTED: f32 = 0.3;
#[derive(Message, Debug, Clone, PartialEq, Eq)]
pub struct NormViolated {
pub witness: Entity,
pub actor: Entity,
pub deed: String,
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub(crate) struct WitnessReaction {
pub(crate) approval: f32,
pub(crate) violated: bool,
}
pub(crate) fn trust_gain(tuning: &Tuning, conscientiousness: f32) -> f32 {
tuning.witness_gain * (0.5 + conscientiousness)
}
#[expect(
clippy::too_many_arguments,
reason = "one call per witnessed deed; the read/write split stays visible at the call site"
)]
pub(crate) fn witness_reaction(
ties: &mut Ties,
emotions: &mut Emotions,
memories: &mut Memories,
opinions: &Opinions,
personality: &Personality,
deed: DeedId,
deed_topic: TopicId,
harm: f32,
actor: Entity,
patient: Option<Entity>,
trust_in_patient: Option<f32>,
settings: &SocietySettings,
witnesses: usize,
now_days: f64,
) -> WitnessReaction {
let norm = opinions.value(usize::from(deed_topic.0)).clamp(-1.0, 1.0);
let approval = norm * harm.signum();
let seen = (witnesses.max(1) as f32).powf(settings.tuning.witness_dilution);
let gain = trust_gain(&settings.tuning, personality.conscientiousness) / seen;
let justified = patient.is_some()
&& settings.assessment == AssessmentNorm::Standing
&& harm < 0.0
&& trust_in_patient.is_some_and(|trust| trust < DISTRUSTED);
if !justified {
let tie = ties.entry(actor);
tie.trust = (tie.trust + gain * harm).clamp(0.0, 1.0);
}
let tie = ties.entry(actor);
tie.add_affinity(
settings.tuning.witness_affinity * approval,
&settings.tuning,
);
tie.respect = (tie.respect + 0.1 * approval).clamp(-1.0, 1.0);
if approval > 0.0 {
emotions.appraise(EmotionKind::Admiration, approval, Some(actor));
} else if approval < 0.0 {
emotions.appraise(EmotionKind::Reproach, -approval, Some(actor));
}
memories.record(
Memory {
day: now_days as f32,
kind: MemoryKind::Witnessed { deed, actor },
other: Some(actor),
valence: (approval * harm.abs()).clamp(-1.0, 1.0),
importance: harm.abs().clamp(0.0, 1.0),
},
now_days,
settings.memory_capacity,
);
WitnessReaction {
approval,
violated: approval < -0.5,
}
}
pub(crate) fn patient_reaction(
ties: &mut Ties,
emotions: &mut Emotions,
harm: f32,
gain: f32,
actor: Entity,
tuning: &Tuning,
) {
if harm > 0.0 {
emotions.appraise(EmotionKind::Gratitude, harm, Some(actor));
} else if harm < 0.0 {
emotions.appraise(EmotionKind::Anger, -harm, Some(actor));
}
let severity = if harm < 0.0 {
harm * tuning.negativity_ratio
} else {
harm
};
let tie = ties.entry(actor);
tie.trust = (tie.trust + gain * harm).clamp(0.0, 1.0);
tie.add_affinity(tuning.witness_affinity * severity, tuning);
}
pub(crate) fn actor_reaction(
emotions: &mut Emotions,
opinions: &Opinions,
deed_topic: TopicId,
patient: Entity,
) {
let own = opinions.value(usize::from(deed_topic.0));
if own > 0.2 {
emotions.appraise(EmotionKind::Pride, own, Some(patient));
} else if own < -0.2 {
emotions.appraise(EmotionKind::Shame, -own, Some(patient));
}
}
#[expect(
clippy::too_many_arguments,
reason = "the caller owns this state anyway; bundling it into a struct would hide the read/write split"
)]
pub(crate) fn spread_witnessed_rumor(
book: &mut RumorBook,
witnesses: &mut [(Entity, Knowledge)],
subject: Entity,
patient: Option<Entity>,
deed: DeedId,
harm: f32,
now_days: f32,
truth: bool,
knowledge_capacity: usize,
) -> Option<RumorId> {
let (origin, rest) = witnesses.split_first_mut()?;
let id = plant_rumor(
book,
&mut origin.1,
origin.0,
subject,
patient,
deed,
harm,
now_days,
truth,
knowledge_capacity,
);
for (_, knowledge) in rest.iter_mut() {
knowledge.insert(
crate::beliefs::Known {
rumor: id,
belief: 1.0,
spreader: true,
heard_day: now_days,
},
knowledge_capacity,
now_days,
);
}
if let Some(rumor) = book.get_mut(id) {
rumor.known_by += rest.len() as u32;
}
Some(id)
}
#[cfg(test)]
mod patient_tests {
use super::*;
fn victim_after(harm: f32, tuning: &Tuning) -> f32 {
let actor = Entity::from_raw_u32(7).expect("a valid entity index");
let mut ties = Ties::default();
let mut emotions = Emotions::default();
patient_reaction(&mut ties, &mut emotions, harm, 0.0, actor, tuning);
ties.get(actor).map_or(0.0, |t| t.affinity)
}
#[test]
fn an_injury_lowers_and_a_benefit_raises_the_victims_affinity() {
let t = Tuning::default();
assert!(victim_after(-0.2, &t) < 0.0);
assert!(victim_after(0.2, &t) > 0.0);
}
#[test]
fn a_benefit_is_unscaled_and_an_injury_is_scaled_by_the_ratio() {
let t = Tuning::default();
let benefit = victim_after(0.04, &t);
assert!((benefit - t.witness_affinity * 0.04).abs() < 1e-6);
let injury = victim_after(-0.04, &t);
assert!((injury / benefit + t.negativity_ratio).abs() < 1e-4);
}
#[test]
fn ratio_zero_switches_off_harm_and_one_makes_it_symmetric() {
let off = Tuning {
negativity_ratio: 0.0,
..Tuning::default()
};
assert_eq!(victim_after(-0.3, &off), 0.0);
let sym = Tuning {
negativity_ratio: 1.0,
..Tuning::default()
};
assert!((victim_after(-0.1, &sym) + victim_after(0.1, &sym)).abs() < 1e-6);
}
#[test]
fn the_write_respects_the_affinity_ceiling() {
let t = Tuning::default();
let actor = Entity::from_raw_u32(7).expect("a valid entity index");
let mut ties = Ties::default();
let mut emotions = Emotions::default();
for _ in 0..10 {
patient_reaction(&mut ties, &mut emotions, -0.9, 0.0, actor, &t);
}
let aff = ties.get(actor).map_or(0.0, |x| x.affinity);
assert!((aff + t.affinity_ceiling).abs() < 1e-6);
}
}