use bevy::prelude::*;
use bevy_emerge_rng::DetRng;
use serde::{Deserialize, Serialize};
use crate::core::require_core;
use crate::defs::SocietyDefs;
use crate::groups::{Memberships, shared_faction_bias};
use crate::model::{balance, recall};
use crate::{
Perception, PersonIndex, SocietyClock, SocietyRng, SocietySettings, SocietySystems, Tuning,
};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Reflect, Serialize, Deserialize)]
pub enum TieLabel {
#[default]
Stranger,
Acquaintance,
Friend,
Rival,
}
impl TieLabel {
pub fn classify(affinity: f32, familiarity: f32, tuning: &crate::Tuning) -> Self {
Self::classify_with_trust(affinity, familiarity, f32::MAX, tuning)
}
pub fn classify_with_trust(
affinity: f32,
familiarity: f32,
trust: f32,
tuning: &crate::Tuning,
) -> Self {
if affinity >= tuning.friend_affinity
&& familiarity >= tuning.friend_familiarity
&& trust >= tuning.friend_trust
{
Self::Friend
} else if affinity <= -tuning.rival_affinity && familiarity >= tuning.rival_familiarity {
Self::Rival
} else if familiarity < tuning.stranger_familiarity {
Self::Stranger
} else {
Self::Acquaintance
}
}
pub fn classify_from(
current: Self,
affinity: f32,
familiarity: f32,
trust: f32,
tuning: &crate::Tuning,
) -> Self {
let band = tuning
.label_hysteresis
.clamp(0.0, tuning.stranger_familiarity.max(0.0));
let promoted = Self::classify_with_trust(affinity, familiarity, trust, tuning);
if promoted != Self::Friend
&& current == Self::Friend
&& affinity >= tuning.friend_affinity - tuning.friend_band
&& trust >= tuning.friend_trust
&& familiarity >= tuning.friend_familiarity
{
return Self::Friend;
}
let rival_band = tuning.rival_band.clamp(0.0, tuning.rival_affinity.max(0.0));
if promoted != Self::Rival
&& current == Self::Rival
&& affinity <= -(tuning.rival_affinity - rival_band)
&& familiarity >= tuning.rival_familiarity
{
return Self::Rival;
}
if promoted != Self::Stranger {
return promoted;
}
match current {
Self::Acquaintance | Self::Friend | Self::Rival
if familiarity >= (tuning.stranger_familiarity - band).max(0.0) =>
{
current
}
_ => promoted,
}
}
pub fn script_name(self) -> &'static str {
match self {
Self::Stranger => "stranger",
Self::Acquaintance => "acquaintance",
Self::Friend => "friend",
Self::Rival => "rival",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Reflect)]
pub struct Tie {
pub target: Entity,
pub affinity: f32,
pub trust: f32,
pub respect: f32,
pub familiarity_peak: f32,
pub last_contact_day: f32,
pub label: TieLabel,
}
impl Tie {
pub fn is_close(&self, tuning: &Tuning) -> bool {
self.affinity >= tuning.friend_affinity && self.trust >= tuning.friend_trust
}
pub fn new(target: Entity) -> Self {
Self {
target,
affinity: 0.0,
trust: 0.5,
respect: 0.0,
familiarity_peak: 0.0,
last_contact_day: 0.0,
label: TieLabel::Stranger,
}
}
pub fn add_affinity(&mut self, delta: f32, tuning: &Tuning) {
let ceiling = tuning.affinity_ceiling.max(0.0);
self.affinity = (self.affinity + delta).clamp(-ceiling, ceiling);
}
pub fn familiarity(&self, now_days: f32, tuning: &Tuning) -> f32 {
recall::familiarity(
self.familiarity_peak,
now_days - self.last_contact_day,
tuning.familiarity_tau_days,
tuning.familiarity_decay_exp,
)
}
pub fn expose(&mut self, now_days: f32, dt_days: f32, bond_scale: f32, tuning: &Tuning) {
let f = self.familiarity(now_days, tuning);
self.familiarity_peak = recall::exposed_peak(f, tuning.exposure_per_day, dt_days);
self.last_contact_day = now_days;
let step =
tuning.mere_exposure_per_day * dt_days * (1.0 - self.affinity.abs()) * bond_scale;
self.add_affinity(step, tuning);
}
pub fn relax(&mut self, dt_days: f32, tuning: &Tuning) {
let relaxed =
balance::relax_toward_zero(self.affinity, tuning.affinity_relax_per_day * dt_days);
self.add_affinity(relaxed - self.affinity, tuning);
}
pub fn refresh_label(
&mut self,
now_days: f32,
tuning: &Tuning,
) -> Option<(TieLabel, TieLabel)> {
let next = TieLabel::classify_from(
self.label,
self.affinity,
self.familiarity(now_days, tuning),
self.trust,
tuning,
);
if next == self.label {
None
} else {
let old = self.label;
self.label = next;
Some((old, next))
}
}
}
#[derive(Component, Debug, Clone, Default, PartialEq, Reflect)]
pub struct Ties(pub Vec<Tie>);
impl Ties {
fn search(&self, target: Entity) -> Result<usize, usize> {
let bits = target.to_bits();
self.0.binary_search_by_key(&bits, |t| t.target.to_bits())
}
pub fn friend_count(&self, tuning: &Tuning) -> usize {
if tuning.friend_capacity <= 0.0 {
return 0;
}
self.0.iter().filter(|tie| tie.is_close(tuning)).count()
}
pub fn get(&self, target: Entity) -> Option<&Tie> {
self.search(target).ok().map(|i| &self.0[i])
}
pub fn get_mut(&mut self, target: Entity) -> Option<&mut Tie> {
match self.search(target) {
Ok(i) => Some(&mut self.0[i]),
Err(_) => None,
}
}
pub fn entry(&mut self, target: Entity) -> &mut Tie {
self.entry_with(target, 0.0)
}
pub fn entry_with(&mut self, target: Entity, affinity: f32) -> &mut Tie {
let index = match self.search(target) {
Ok(i) => i,
Err(i) => {
let mut tie = Tie::new(target);
tie.affinity = affinity;
self.0.insert(i, tie);
i
}
};
&mut self.0[index]
}
pub fn remove(&mut self, target: Entity) -> Option<Tie> {
match self.search(target) {
Ok(i) => Some(self.0.remove(i)),
Err(_) => None,
}
}
pub fn len(&self) -> usize {
self.0.len()
}
pub fn is_empty(&self) -> bool {
self.0.is_empty()
}
pub fn trim(&mut self, now_days: f32, tuning: &Tuning, max_ties: usize) {
self.trim_keeping(now_days, tuning, max_ties, |_| false);
}
pub fn trim_keeping(
&mut self,
now_days: f32,
tuning: &Tuning,
max_ties: usize,
frozen: impl Fn(Entity) -> bool,
) {
let live = self.0.iter().filter(|t| !frozen(t.target)).count();
if live <= max_ties {
return;
}
let excess = live - max_ties;
let mut ranked: Vec<(f32, u64)> = self
.0
.iter()
.filter(|t| !frozen(t.target))
.map(|t| (t.familiarity(now_days, tuning), t.target.to_bits()))
.collect();
ranked.sort_by(|a, b| a.0.total_cmp(&b.0).then(a.1.cmp(&b.1)));
let doomed: Vec<u64> = ranked.iter().take(excess).map(|&(_, bits)| bits).collect();
self.0
.retain(|t| frozen(t.target) || !doomed.contains(&t.target.to_bits()));
}
pub fn close_targets(&self, now_days: f32, tuning: &Tuning, close_ties: usize) -> Vec<Entity> {
let mut scored: Vec<(f32, u64, Entity)> = self
.0
.iter()
.map(|t| {
let score = t.affinity.max(0.0) * t.familiarity(now_days, tuning);
(score, t.target.to_bits(), t.target)
})
.collect();
scored.sort_by(|a, b| b.0.total_cmp(&a.0).then(a.1.cmp(&b.1)));
scored
.into_iter()
.take(close_ties)
.map(|(_, _, e)| e)
.collect()
}
}
#[derive(Message, Debug, Clone, Copy, PartialEq, Eq)]
pub struct TieLabelChanged {
pub from: Entity,
pub to: Entity,
pub old: TieLabel,
pub new: TieLabel,
}
pub(crate) fn expose_toward(
ties: &mut Ties,
target: Entity,
shared_bias: Option<f32>,
now_days: f32,
dt_days: f32,
friends: usize,
tuning: &Tuning,
) {
let tie = match shared_bias {
Some(bias) => ties.entry_with(target, bias),
None => ties.entry(target),
};
let scale = tuning.bond_scale(friends, tie.is_close(tuning));
tie.expose(now_days, dt_days, scale, tuning);
}
pub(crate) fn introduce_toward(ties: &mut Ties, target: Entity, now_days: f32, tuning: &Tuning) {
let tie = ties.entry(target);
let f = tie.familiarity(now_days, tuning);
tie.familiarity_peak = (f + tuning.introduction_familiarity * (1.0 - f)).clamp(0.0, 1.0);
tie.last_contact_day = now_days;
}
fn expose_contacts(
clock: Res<SocietyClock>,
settings: Res<SocietySettings>,
perception: Res<Perception>,
defs: Res<SocietyDefs>,
memberships: Query<&Memberships>,
mut ties: Query<&mut Ties>,
) {
let tuning = &settings.tuning;
let now = clock.now_days as f32;
let dt = clock.dt_days;
for (i, &entity) in perception.persons.iter().enumerate() {
let Ok(mine) = memberships.get(entity) else {
continue;
};
let friends = ties.get(entity).map_or(0, |t| t.friend_count(tuning));
for &j in &perception.contact[i] {
let Some(&other) = perception.persons.get(j as usize) else {
continue;
};
let bias = match memberships.get(other) {
Ok(theirs) => shared_faction_bias(mine, theirs, &defs),
Err(_) => None,
};
if let Ok(mut owned) = ties.get_mut(entity) {
expose_toward(&mut owned, other, bias, now, dt, friends, tuning);
}
}
}
}
fn relax_affinity(
clock: Res<SocietyClock>,
settings: Res<SocietySettings>,
index: Res<PersonIndex>,
deceased: Query<(), With<crate::Deceased>>,
mut ties: Query<&mut Ties>,
) {
let tuning = &settings.tuning;
for &(_, entity) in &index.sorted {
if deceased.get(entity).is_ok() {
continue;
}
if let Ok(mut owned) = ties.get_mut(entity) {
for tie in owned.0.iter_mut() {
if deceased.get(tie.target).is_ok() {
continue;
}
tie.relax(clock.dt_days, tuning);
}
}
}
}
fn balance_triads(
clock: Res<SocietyClock>,
settings: Res<SocietySettings>,
index: Res<PersonIndex>,
deceased: Query<(), With<crate::Deceased>>,
mut rng: ResMut<SocietyRng>,
mut ties: Query<&mut Ties>,
) {
let n = index.sorted.len();
if n == 0 {
return;
}
let tuning = &settings.tuning;
let dt = clock.dt_days;
for _ in 0..tuning.balance_samples_per_tick {
let a = index.sorted[rng.0.below(n)].1;
let picked = {
let Ok(a_ties) = ties.get(a) else {
continue;
};
let len = a_ties.len();
if len < 2 {
continue;
}
let b = a_ties.0[rng.0.below(len)];
let c = a_ties.0[rng.0.below(len)];
if b.target == c.target {
continue;
}
let Ok(b_ties) = ties.get(b.target) else {
continue;
};
let Some(bc) = b_ties.get(c.target) else {
continue;
};
(b.affinity, c.target, bc.affinity)
};
let (ab, c_target, bc) = picked;
let delta = balance::triad_delta(tuning.balance_rate_per_day, dt, ab, bc);
if deceased.get(a).is_ok() || deceased.get(c_target).is_ok() {
continue;
}
if let Ok(mut owned) = ties.get_mut(a)
&& let Some(ac) = owned.get_mut(c_target)
{
ac.add_affinity(delta, tuning);
}
}
}
fn balance_dyads(
clock: Res<SocietyClock>,
settings: Res<SocietySettings>,
index: Res<PersonIndex>,
deceased: Query<(), With<crate::Deceased>>,
mut ties: Query<&mut Ties>,
) {
let tuning = &settings.tuning;
if tuning.dyad_balance_per_day <= 0.0 {
return;
}
let dt = clock.dt_days;
for &(_, a) in &index.sorted {
if deceased.get(a).is_ok() {
continue;
}
let Ok(a_ties) = ties.get(a) else {
continue;
};
let edges: Vec<(Entity, f32)> = a_ties
.0
.iter()
.filter(|tie| a.to_bits() < tie.target.to_bits())
.map(|tie| (tie.target, tie.affinity))
.collect();
for (b, ab) in edges {
if deceased.get(b).is_ok() {
continue;
}
let Some(ba) = ties.get(b).ok().and_then(|t| t.get(a)).map(|t| t.affinity) else {
continue;
};
let delta = balance::dyad_delta(tuning.dyad_balance_per_day, dt, ab, ba);
if delta == 0.0 {
continue;
}
if let Ok(mut owned) = ties.get_mut(a)
&& let Some(tie) = owned.get_mut(b)
{
tie.add_affinity(delta, tuning);
}
if let Ok(mut owned) = ties.get_mut(b)
&& let Some(tie) = owned.get_mut(a)
{
tie.add_affinity(-delta, tuning);
}
}
}
}
fn trim_ties(
clock: Res<SocietyClock>,
settings: Res<SocietySettings>,
index: Res<PersonIndex>,
deceased: Query<(), With<crate::Deceased>>,
mut ties: Query<&mut Ties>,
) {
let now = clock.now_days as f32;
for &(_, entity) in &index.sorted {
if let Ok(mut owned) = ties.get_mut(entity) {
owned.trim_keeping(now, &settings.tuning, settings.max_ties, |target| {
deceased.get(entity).is_ok() || deceased.get(target).is_ok()
});
}
}
}
pub(crate) fn refresh_labels(
clock: Res<SocietyClock>,
settings: Res<SocietySettings>,
index: Res<PersonIndex>,
deceased: Query<(), With<crate::Deceased>>,
mut ties: Query<&mut Ties>,
mut changed: MessageWriter<TieLabelChanged>,
) {
let now = clock.now_days as f32;
for &(_, entity) in &index.sorted {
if deceased.get(entity).is_ok() {
continue;
}
let Ok(mut owned) = ties.get_mut(entity) else {
continue;
};
for tie in owned.0.iter_mut() {
if deceased.get(tie.target).is_ok() {
continue;
}
if let Some((old, new)) = tie.refresh_label(now, &settings.tuning) {
changed.write(TieLabelChanged {
from: entity,
to: tie.target,
old,
new,
});
}
}
}
}
pub struct RelationshipsPlugin;
impl Plugin for RelationshipsPlugin {
fn build(&self, app: &mut App) {
app.register_type::<TieLabel>()
.register_type::<Tie>()
.register_type::<Ties>()
.add_message::<TieLabelChanged>()
.add_systems(
FixedUpdate,
(
expose_contacts,
relax_affinity,
balance_triads,
balance_dyads,
trim_ties,
refresh_labels,
)
.chain()
.in_set(SocietySystems::Relate),
);
}
fn finish(&self, app: &mut App) {
require_core(app, "RelationshipsPlugin");
}
}
#[cfg(test)]
mod tests {
use super::*;
fn e(bits: u64) -> Entity {
Entity::from_bits(bits)
}
#[test]
fn labels_follow_the_d4_thresholds() {
let tuning = Tuning::default();
assert_eq!(TieLabel::classify(0.5, 0.5, &tuning), TieLabel::Friend);
assert_eq!(TieLabel::classify(-0.5, 0.5, &tuning), TieLabel::Rival);
assert_eq!(TieLabel::classify(0.0, 0.05, &tuning), TieLabel::Stranger);
assert_eq!(
TieLabel::classify(0.2, 0.5, &tuning),
TieLabel::Acquaintance
);
assert_eq!(
TieLabel::classify(0.5, 0.2, &tuning),
TieLabel::Acquaintance
);
}
#[test]
fn entries_stay_sorted_by_target_bits() {
let mut ties = Ties::default();
for bits in [30_u64, 10, 20] {
ties.entry(e(bits));
}
let order: Vec<u64> = ties.0.iter().map(|t| t.target.to_bits()).collect();
assert_eq!(order, vec![10, 20, 30]);
}
#[test]
fn trim_drops_least_familiar_first() {
let tuning = Tuning::default();
let mut ties = Ties::default();
ties.entry(e(1)).familiarity_peak = 0.9;
ties.entry(e(2)).familiarity_peak = 0.1;
ties.entry(e(3)).familiarity_peak = 0.5;
ties.trim(0.0, &tuning, 2);
let kept: Vec<u64> = ties.0.iter().map(|t| t.target.to_bits()).collect();
assert_eq!(kept, vec![1, 3]);
}
#[test]
fn label_change_is_reported_once() {
let tuning = Tuning::default();
let mut tie = Tie::new(e(1));
tie.affinity = 0.5;
tie.familiarity_peak = 0.5;
assert_eq!(
tie.refresh_label(0.0, &tuning),
Some((TieLabel::Stranger, TieLabel::Friend))
);
assert_eq!(tie.refresh_label(0.0, &tuning), None);
}
}