use bevy::ecs::query::QueryData;
use bevy::prelude::*;
use bevy::transform::components::Transform;
use bevy_emerge_rng::DetRng;
use crate::Tuning;
use crate::core::{Perception, PersonIndex, SocietyClock, SocietySettings};
use crate::defs::{ActivityId, ActivityTarget, Signal, SocietyDefs, Term};
use crate::groups::Memberships;
use crate::mind::{Emotions, Mood, Needs, Personality};
use crate::relationships::Ties;
use crate::rumor::RumorBook;
#[derive(Reflect, Debug, Clone, Copy, PartialEq)]
pub enum DoingTarget {
Itself,
Point(Vec2),
Place(Entity),
Person(Entity),
}
#[derive(Component, Reflect, Debug, Clone, PartialEq)]
#[reflect(Component)]
pub struct Doing {
pub activity: ActivityId,
pub target: DoingTarget,
pub started_day: f64,
pub arrived_day: Option<f64>,
}
impl Doing {
pub fn is_travelling(&self) -> bool {
self.arrived_day.is_none()
}
pub fn label(&self, defs: &crate::defs::SocietyDefs) -> String {
let base = defs.activity_name(self.activity);
if self.is_travelling() {
format!("travelling:{base}")
} else {
base.to_owned()
}
}
}
#[derive(Component, Reflect, Debug, Clone, Copy, PartialEq, Default)]
#[reflect(Component)]
pub struct Destination(pub Vec2);
#[derive(Component, Debug, Clone, Copy, PartialEq)]
pub(crate) struct TravelProgress {
pub(crate) best: f32,
pub(crate) improved_day: f64,
}
#[derive(Component, Debug, Clone, Default, PartialEq)]
pub(crate) struct FailedPlaces(pub(crate) Vec<(Entity, f64)>);
#[derive(Component, Debug, Clone, Default, PartialEq)]
pub(crate) struct RecentHarm(pub(crate) Vec<(Entity, f64)>);
#[derive(Component, Debug, Clone, Default, PartialEq)]
pub(crate) struct Rebuffed(pub(crate) Vec<(Entity, f64)>);
#[derive(Message, Debug, Clone, PartialEq, Eq)]
pub struct InteractionResolved {
pub actor: Entity,
pub target: Entity,
pub interaction: String,
pub accepted: bool,
pub refused_by_rule: bool,
pub winner: Option<Entity>,
}
#[derive(Message, Debug, Clone, PartialEq, Eq)]
pub struct ActivityStarted {
pub person: Entity,
pub activity: String,
pub target: Option<Entity>,
}
#[derive(Message, Debug, Clone, PartialEq, Eq)]
pub struct ActivityEnded {
pub person: Entity,
pub activity: String,
pub completed: bool,
}
#[derive(Resource, Debug, Clone, Default)]
pub struct InteractQueue(pub(crate) Vec<crate::interact::Interact>);
#[derive(Resource, Debug, Clone, Default)]
pub struct ArrivalQueue(pub(crate) Vec<crate::interact::Interact>);
fn queue_interacts(
mut reader: MessageReader<crate::interact::Interact>,
mut queue: Option<ResMut<InteractQueue>>,
) {
let Some(queue) = queue.as_mut() else {
return;
};
for message in reader.read() {
queue.0.push(message.clone());
}
}
pub struct BehaviorPlugin;
impl Plugin for BehaviorPlugin {
fn build(&self, app: &mut App) {
app.register_type::<Doing>()
.register_type::<DoingTarget>()
.register_type::<Destination>()
.init_resource::<InteractQueue>()
.init_resource::<ArrivalQueue>()
.init_resource::<crate::rumor::RumorBook>()
.add_message::<InteractionResolved>()
.add_message::<ActivityStarted>()
.add_message::<ActivityEnded>()
.add_message::<crate::interact::Interact>()
.add_message::<crate::interact::SocietyBark>()
.add_message::<crate::script::ScriptError>()
.add_message::<crate::rumor::RumorLearned>()
.add_message::<crate::reputation::NormViolated>()
.add_systems(
FixedUpdate,
queue_interacts.in_set(crate::SocietySystems::Clock),
)
.init_resource::<EndRequests>()
.add_message::<TravelFailed>()
.add_message::<PersonDied>()
.init_resource::<DeathQueue>()
.add_observer(on_deceased_added)
.add_message::<EndActivity>()
.add_message::<ReportDeed>()
.init_resource::<DeedReportQueue>()
.add_systems(
FixedUpdate,
queue_reported_deeds.in_set(crate::SocietySystems::Clock),
)
.add_systems(
FixedUpdate,
queue_end_requests.in_set(crate::SocietySystems::Clock),
)
.add_systems(
FixedUpdate,
(end_activities, decide_activities)
.chain()
.in_set(crate::SocietySystems::Decide),
)
.add_systems(
FixedUpdate,
walk_toward_destination.after(crate::SocietySystems::Rules),
)
.add_systems(
FixedUpdate,
announce_deaths
.in_set(crate::SocietySystems::Clock)
.before(crate::mind::apply_appraisals),
)
.add_systems(
FixedUpdate,
(
arrive_and_satisfy,
resolve_interactions,
resolve_reported_deeds,
)
.chain()
.in_set(crate::SocietySystems::Interact),
)
.add_systems(
FixedUpdate,
activity_barks.in_set(crate::SocietySystems::Interact),
)
.add_systems(
FixedUpdate,
contact_gossip.in_set(crate::SocietySystems::Relate),
)
.add_systems(
FixedUpdate,
report_to_triggers
.after(crate::SocietySystems::Interact)
.before(crate::SocietySystems::Rules),
);
}
fn finish(&self, app: &mut App) {
crate::core::require_core(app, "BehaviorPlugin");
let settings = app.world().resource::<SocietySettings>().clone();
let defs = app.world().resource::<SocietyDefs>();
warn_short_activities(defs, &settings);
#[cfg(not(feature = "fields"))]
{
warn_unscored_fields(defs);
}
warn_short_travel_reach(&settings);
warn_walk_outlasts_activity(defs, &settings);
warn_unbalanced_needs(defs);
warn_unmade_goods(defs);
app.add_systems(Startup, warn_unreachable_places);
}
}
fn warn_unbalanced_needs(defs: &SocietyDefs) {
let mut starving: Vec<String> = Vec::new();
for (index, need) in defs.needs.iter().enumerate() {
let Some(best) = defs
.activities
.iter()
.filter_map(|activity| {
let rate = activity
.satisfies
.iter()
.find(|(slot, _)| usize::from(slot.0) == index)
.map(|(_, rate)| *rate)?;
Some((activity.name.as_str(), rate * activity.duration_hours))
})
.fold(None::<(&str, f32)>, |best, candidate| match best {
Some((_, amount)) if amount >= candidate.1 => best,
_ => Some(candidate),
})
else {
starving.push(format!("{} (no activity satisfies it)", need.name));
continue;
};
if best.1 <= 0.0 {
starving.push(format!("{} ({} supplies nothing)", need.name, best.0));
continue;
}
let uses_per_day = need.decay_per_day / best.1;
if uses_per_day > MAX_USES_PER_DAY {
starving.push(format!(
"{} (best source {} pays {:.2} per use against {:.2}/day decay, so {:.2} uses a day)",
need.name, best.0, best.1, need.decay_per_day, uses_per_day
));
}
}
if starving.is_empty() {
return;
}
warn!(
"bevy_society: {} needs cannot be met by a plausible number of activities: {}. A need whose \
best source supplies less than a quarter of its daily decay will starve whatever the choice \
rule does.",
starving.len(),
starving.join("; ")
);
}
const MAX_USES_PER_DAY: f32 = 4.0;
fn warn_unmade_goods(defs: &SocietyDefs) {
let mut unmade: Vec<String> = Vec::new();
for (index, good) in defs.goods.iter().enumerate() {
let made = defs.activities.iter().any(|activity| {
activity
.produces
.iter()
.any(|&(slot, amount)| usize::from(slot.0) == index && amount > 0.0)
});
if made {
continue;
}
let used = defs
.activities
.iter()
.filter(|activity| {
activity
.consumes
.iter()
.any(|&(slot, _)| usize::from(slot.0) == index)
})
.count();
if used > 0 {
unmade.push(format!(
"{} ({used} activit{} consume it, none produces it)",
good,
if used == 1 { "y" } else { "ies" }
));
}
}
if unmade.is_empty() {
return;
}
warn!(
"bevy_society: {} good{} no activity produces: {}. A `consumes` that nothing makes can never \
be met, so the activities that pay `satisfies` for it will pay nothing and the store will stay \
empty for the whole run. Add a `produces:` to some activity, or merge in the pack that makes \
it.",
unmade.len(),
if unmade.len() == 1 { "" } else { "s" },
unmade.join("; ")
);
}
fn warn_short_travel_reach(settings: &SocietySettings) {
if settings.locomotion != crate::Locomotion::Builtin {
return;
}
let Some(timeout_hours) = settings.tuning.travel_timeout_hours else {
return;
};
let seconds_per_tick = f64::from(settings.tick_secs);
let day_seconds = f64::from(settings.seconds_per_day);
if seconds_per_tick <= 0.0 || day_seconds <= 0.0 {
return;
}
let ticks_available = f64::from(timeout_hours) / 24.0 / (seconds_per_tick / day_seconds);
let speed = 1.4_f64;
let metres = speed * seconds_per_tick * ticks_available;
if metres >= MIN_WALK_METRES {
return;
}
warn!(
"bevy_society: a travel_timeout_hours of {} at seconds_per_day {} allows {:.0} ticks, which at \
{speed} m/s is only {:.1} metres of walking — less than {MIN_WALK_METRES:.0} m. People whose \
activity is further away will always time out, and the needs those activities satisfy will \
never be met. Raise travel_timeout_hours or shorten the walks.",
timeout_hours, settings.seconds_per_day, ticks_available, metres
);
}
fn warn_walk_outlasts_activity(defs: &SocietyDefs, settings: &SocietySettings) {
if settings.locomotion != crate::Locomotion::Builtin {
return;
}
let day_seconds = f64::from(settings.seconds_per_day);
if day_seconds <= 0.0 {
return;
}
let durations: Vec<f64> = defs
.activities
.iter()
.enumerate()
.filter(|(index, _)| *index != usize::from(crate::defs::IDLE_ACTIVITY.0))
.map(|(_, activity)| f64::from(activity.duration_hours))
.collect();
if durations.is_empty() {
return;
}
let mean = durations.iter().sum::<f64>() / durations.len() as f64;
let speed = 1.4_f64;
let walk_hours = MIN_WALK_METRES / speed / day_seconds * 24.0;
if walk_hours <= mean {
return;
}
warn!(
"bevy_society: at seconds_per_day {} a {MIN_WALK_METRES:.0} m walk costs {walk_hours:.2} game-hours, \
longer than the mean activity ({mean:.2}h) — people will spend most of the day walking to \
things that are over before they arrive, and the needs those activities satisfy will lag. This is \
the coupling `needs-decision/travel-time-in-two-clocks.md` records: walking is in real time and \
durations are in game time. Lengthen the activities, raise seconds_per_day, or move the places \
closer together.",
settings.seconds_per_day
);
}
const MIN_WALK_METRES: f64 = 10.0;
fn warn_unreachable_places(
settings: Res<SocietySettings>,
places: Query<(Entity, &Name, &Transform), With<crate::core::Place>>,
homes: Query<&crate::Home>,
) {
if settings.locomotion != crate::Locomotion::Builtin {
return;
}
let Some(timeout_hours) = settings.tuning.travel_timeout_hours else {
return;
};
let seconds_per_tick = f64::from(settings.tick_secs);
let day_seconds = f64::from(settings.seconds_per_day);
if seconds_per_tick <= 0.0 || day_seconds <= 0.0 {
return;
}
let ticks_available = f64::from(timeout_hours) / 24.0 / (seconds_per_tick / day_seconds);
let speed = 1.4_f64;
let reachable = speed * seconds_per_tick * ticks_available;
let plane = settings.plane;
let mut worst = 0.0_f32;
let mut worst_pair = (String::new(), String::new());
for (_, name, transform) in &places {
for (_, other_name, other) in &places {
let here = plane.project(transform.translation);
let there = plane.project(other.translation);
let distance = here.distance(there);
if distance > worst {
worst = distance;
worst_pair = (name.as_str().to_owned(), other_name.as_str().to_owned());
}
}
}
let mut residents: std::collections::BTreeMap<Entity, usize> =
std::collections::BTreeMap::new();
for home in &homes {
*residents.entry(home.0).or_insert(0) += 1;
}
let body_diameter = 0.7_f32;
for (entity, name, _) in &places {
let Some(&count) = residents.get(&entity) else {
continue;
};
let seats = (settings.interaction_radius / body_diameter).powi(2);
if (count as f32) <= seats || count < 3 {
continue;
}
let needed = body_diameter * (count as f32).sqrt();
warn!(
"bevy_society: '{place}' is home to {count} people, but an arrival disc of radius {radius} seats \
about {seats}. They all go to bed at the same hour, so the queue behind the disc times out and \
their sleeps are interrupted. Give them more room: raise `interaction_radius` to about {needed:.1} \
or spread the residents over more homes.",
place = name.as_str(),
radius = settings.interaction_radius,
seats = seats as usize,
needed = needed,
);
}
if worst <= reachable as f32 || places.iter().len() < 2 {
return;
}
warn!(
"bevy_society: a travel_timeout_hours of {timeout_hours} at seconds_per_day {} buys {reachable:.1} metres of walking, but the farthest pair of places is '{a}' -> '{b}' at {worst:.1} m. Every walk between them times out before arrival, so the activities that need one end interrupted and the needs they satisfy are never met. Raise travel_timeout_hours to at least {needed:.1}, or move the places closer.",
settings.seconds_per_day,
a = worst_pair.0,
b = worst_pair.1,
needed = f64::from(worst) * 24.0 / (speed * day_seconds),
);
}
#[cfg_attr(feature = "fields", allow(dead_code))]
fn warn_unscored_fields(defs: &SocietyDefs) {
let mut offenders: Vec<&str> = Vec::new();
for (name, signals) in defs.activity_signals() {
let reads_a_field = signals
.iter()
.any(|signal| matches!(signal, crate::defs::Signal::Field(_)));
let walks_a_gradient = defs.activity_targets_gradient(name);
if reads_a_field || walks_a_gradient {
offenders.push(name);
}
}
if offenders.is_empty() {
return;
}
warn!(
"bevy_society: {} definitions read social fields (`Field` considerations or a `Gradient` \
target) but the `fields` feature is off, so no grid exists and those activities will never be \
chosen. Enable `fields`, or remove the definitions. See `docs/loop/needs-decision/` — {}",
offenders.len(),
offenders.join(", ")
);
}
fn warn_short_activities(defs: &SocietyDefs, settings: &SocietySettings) {
let tick_hours = f64::from(settings.tick_secs) / f64::from(settings.seconds_per_day) * 24.0;
if tick_hours <= 0.0 {
return;
}
let mut degenerate: Vec<String> = Vec::new();
for (index, activity) in defs.activities.iter().enumerate() {
if index == usize::from(crate::defs::IDLE_ACTIVITY.0) {
continue;
}
let ticks = f64::from(activity.duration_hours) / tick_hours;
if ticks < 2.0 {
degenerate.push(format!(
"{} ({:.1}h = {:.1} ticks)",
activity.name, activity.duration_hours, ticks
));
}
}
if degenerate.is_empty() {
return;
}
warn!(
"bevy_society: {} activities are shorter than two society ticks at seconds_per_day {} \
(one tick is {:.3} game-hours), so their people will re-decide every tick: {}",
degenerate.len(),
settings.seconds_per_day,
tick_hours,
degenerate.join(", ")
);
}
#[allow(clippy::too_many_arguments)]
fn arrive_and_satisfy(
clock: Res<SocietyClock>,
settings: Res<SocietySettings>,
defs: Res<SocietyDefs>,
index: Res<PersonIndex>,
perception: Res<Perception>,
transforms: Query<&GlobalTransform>,
mut commands: Commands,
mut people: Query<(&mut Doing, &mut Needs)>,
stocks: Query<&crate::Stock>,
mut arrivals: Option<ResMut<ArrivalQueue>>,
mut progress: Query<&mut TravelProgress>,
) {
let now = clock.now_days;
let dt_hours = f64::from(clock.dt_days) * 24.0;
let radius = settings.interaction_radius;
let plane = settings.plane;
let mut arrived_at: Vec<Entity> = people
.iter()
.filter_map(|(doing, _)| match (doing.target, doing.arrived_day) {
(DoingTarget::Place(place), Some(_)) => Some(place),
_ => None,
})
.collect();
arrived_at.sort_unstable();
for (slot, &(_, person)) in index.sorted.iter().enumerate() {
let Ok((mut doing, mut needs)) = people.get_mut(person) else {
continue;
};
let Some(spec) = defs.activities.get(usize::from(doing.activity.0)).cloned() else {
continue;
};
let here = perception.pos.get(slot).copied().unwrap_or(Vec2::ZERO);
if doing.arrived_day.is_none() {
let Some(target_pos) = target_position(
doing.target,
person,
here,
&transforms,
&index,
plane,
radius,
) else {
continue;
};
if let DoingTarget::Person(_) = doing.target {
commands.entity(person).insert(Destination(target_pos));
}
if settings.tuning.stall_timeout_hours.is_some() {
let distance = here.distance(target_pos);
match progress.get_mut(person) {
Ok(mut seen) => {
if distance < seen.best - settings.tuning.stall_epsilon {
seen.best = distance;
seen.improved_day = now;
}
}
Err(_) => {
commands.entity(person).insert(TravelProgress {
best: distance,
improved_day: now,
});
}
}
}
let crowd = match doing.target {
DoingTarget::Place(place) => {
let arrived = arrived_at.partition_point(|&e| e <= place)
- arrived_at.partition_point(|&e| e < place);
let in_disc = perception
.pos
.iter()
.filter(|p| p.distance(target_pos) <= radius)
.count();
arrived.max(in_disc)
}
_ => 0,
};
let allowed = radius + settings.tuning.crowded_arrival_growth * (crowd as f32).sqrt();
if here.distance(target_pos) > allowed {
continue;
}
doing.arrived_day = Some(now);
commands
.entity(person)
.remove::<(Destination, TravelProgress)>();
if let Some(arrivals) = arrivals.as_mut()
&& let Some(interaction) = spec.interaction
{
arrivals.0.push(crate::interact::Interact {
actor: person,
target: match doing.target {
DoingTarget::Person(other) => other,
_ => person,
},
interaction: defs.interaction_name(interaction).to_owned(),
});
}
}
if doing.arrived_day.is_some() {
let factor = match stocks.get(person).ok() {
Some(stock) if !spec.consumes.is_empty() => spec
.consumes
.iter()
.map(|&(good, asked)| {
if asked <= 0.0 {
1.0
} else {
(stock.get(good) / asked).min(1.0)
}
})
.fold(f32::INFINITY, f32::min),
_ => 1.0,
};
for &(need, delta) in &spec.satisfies {
if let Some(level) = needs.0.get_mut(usize::from(need.0)) {
let gain = f64::from(delta) as f32 * dt_hours as f32 * factor;
*level = (*level + gain).clamp(0.0, 1.0);
}
}
}
}
}
fn target_position(
target: DoingTarget,
_actor: Entity,
actor_pos: Vec2,
transforms: &Query<&GlobalTransform>,
index: &PersonIndex,
plane: crate::SocietyPlane,
interaction_radius: f32,
) -> Option<Vec2> {
let of = |entity: Entity| -> Option<Vec2> {
let _ = index;
transforms
.get(entity)
.ok()
.map(|transform| plane.project(transform.translation()))
};
match target {
DoingTarget::Itself | DoingTarget::Point(_) => Some(actor_pos),
DoingTarget::Place(place) => of(place),
DoingTarget::Person(other) => {
let there = of(other)?;
let toward = (actor_pos - there).normalize_or_zero();
Some(there + toward * 0.75 * interaction_radius)
}
}
}
#[cfg(feature = "steering")]
type NotSteered = Without<crate::SocialMover>;
#[cfg(not(feature = "steering"))]
type NotSteered = ();
fn walk_toward_destination(
settings: Res<SocietySettings>,
time: Res<Time<Fixed>>,
mut walkers: Query<(&Destination, &mut Transform), NotSteered>,
) {
if settings.locomotion != crate::Locomotion::Builtin {
return;
}
let step = (settings.tuning.walk_speed * time.delta_secs()) as f64;
if step <= 0.0 {
return;
}
let plane = settings.plane;
for (destination, mut transform) in &mut walkers {
let mut here = plane.project(transform.translation);
let toward = destination.0 - here;
let distance = toward.length();
if distance <= f32::EPSILON {
continue;
}
let advanced = if f64::from(distance) <= step {
distance
} else {
step as f32
};
here += toward / distance * advanced;
transform.translation = match plane {
crate::SocietyPlane::XZ => Vec3::new(here.x, transform.translation.y, here.y),
crate::SocietyPlane::XY => Vec3::new(here.x, here.y, transform.translation.z),
};
}
}
#[derive(Message, Debug, Clone, PartialEq)]
pub struct ReportDeed {
pub actor: Entity,
pub patient: Option<Entity>,
pub deed: String,
pub witnesses: Vec<Entity>,
}
#[derive(Resource, Debug, Clone, Default)]
pub(crate) struct DeedReportQueue(pub(crate) Vec<ReportDeed>);
fn queue_reported_deeds(
mut reports: MessageReader<ReportDeed>,
mut queue: ResMut<DeedReportQueue>,
) {
for report in reports.read() {
queue.0.push(report.clone());
}
}
#[derive(Message, Debug, Clone, Copy, PartialEq, Eq)]
pub struct EndActivity {
pub person: Entity,
}
#[derive(Message, Debug, Clone, Copy, PartialEq, Eq)]
pub struct PersonDied {
pub person: Entity,
}
#[derive(Resource, Debug, Clone, Default)]
pub(crate) struct DeathQueue(pub(crate) Vec<Entity>);
fn on_deceased_added(add: On<Add<crate::Deceased>>, mut queue: ResMut<DeathQueue>) {
queue.0.push(add.entity);
}
#[allow(clippy::too_many_arguments)]
fn announce_deaths(
clock: Res<SocietyClock>,
settings: Res<SocietySettings>,
index: Res<PersonIndex>,
deceased: Query<(), With<crate::Deceased>>,
mut queue: ResMut<DeathQueue>,
mut died: MessageWriter<PersonDied>,
mut triggers: Option<ResMut<crate::rules::TriggerQueue>>,
mut appraisals: MessageWriter<crate::Appraise>,
mut memories: Query<&mut crate::Memories>,
ties: Query<&Ties>,
) {
let deaths = std::mem::take(&mut queue.0);
if deaths.is_empty() {
return;
}
let now = clock.now_days;
let mut ordered: Vec<(u32, Entity)> = deaths
.into_iter()
.filter_map(|entity| {
index
.sorted
.iter()
.find(|(_, e)| *e == entity)
.map(|(id, _)| (id.0, entity))
})
.collect();
ordered.sort_unstable();
ordered.dedup();
for (_, person) in ordered {
died.write(PersonDied { person });
if let Some(triggers) = triggers.as_mut() {
let actor = index
.sorted
.iter()
.find(|(_, e)| *e == person)
.map_or(-1, |(id, _)| i64::from(id.0));
triggers
.0
.push(crate::rules::TriggerEvent::Deceased { actor });
}
for &(_, other) in &index.sorted {
if other == person || deceased.get(other).is_ok() {
continue;
}
let Ok(their_ties) = ties.get(other) else {
continue;
};
let Some(tie) = their_ties.get(person) else {
continue;
};
let Some(intensity) = crate::absence::closeness(tie, &settings.tuning) else {
continue;
};
appraisals.write(crate::Appraise {
person: other,
kind: crate::EmotionKind::Distress,
intensity,
toward: Some(person),
});
if let Ok(mut theirs) = memories.get_mut(other) {
theirs.record(
crate::Memory {
day: now as f32,
kind: crate::MemoryKind::Custom("bereaved".to_owned()),
other: Some(person),
valence: -intensity,
importance: 1.0,
},
now,
settings.memory_capacity,
);
}
}
}
}
#[derive(Message, Debug, Clone, Copy, PartialEq, Eq)]
pub struct TravelFailed {
pub person: Entity,
}
#[derive(Resource, Debug, Clone, Default)]
pub(crate) struct EndRequests(pub(crate) Vec<Entity>);
fn queue_end_requests(
mut travel_failed: MessageReader<TravelFailed>,
mut end_activity: MessageReader<EndActivity>,
mut requests: ResMut<EndRequests>,
) {
for failed in travel_failed.read() {
requests.0.push(failed.person);
}
for asked in end_activity.read() {
requests.0.push(asked.person);
}
}
#[allow(clippy::too_many_arguments)]
#[derive(bevy::ecs::system::SystemParam)]
struct ActivityQueries<'w, 's> {
doing: Query<'w, 's, &'static Doing>,
progress: Query<'w, 's, &'static TravelProgress>,
failed: Query<'w, 's, &'static FailedPlaces>,
dormant: Query<'w, 's, (), With<crate::Dormant>>,
}
#[allow(clippy::too_many_arguments)]
fn end_activities(
clock: Res<SocietyClock>,
settings: Res<SocietySettings>,
defs: Res<SocietyDefs>,
index: Res<PersonIndex>,
perception: Res<Perception>,
mut commands: Commands,
queries: ActivityQueries,
mut ended: MessageWriter<ActivityEnded>,
mut requests: ResMut<EndRequests>,
) {
let ActivityQueries {
doing: people,
progress,
dormant,
failed,
} = queries;
let now = clock.now_days;
let timeout_hours = settings.tuning.travel_timeout_hours.map(f64::from);
let stall_hours = settings.tuning.stall_timeout_hours.map(f64::from);
let mut endings: Vec<(Entity, String, bool)> = Vec::new();
let cancelled: std::collections::BTreeSet<u32> = std::mem::take(&mut requests.0)
.into_iter()
.filter_map(|entity| {
index
.sorted
.iter()
.find(|(_, e)| *e == entity)
.map(|(id, _)| id.0)
})
.collect();
for id in &cancelled {
let Some(&(person_id, person)) = index.sorted.iter().find(|(p, _)| p.0 == *id) else {
continue;
};
let _ = person_id;
let Ok(doing) = people.get(person) else {
continue;
};
if let Some(spec) = defs.activities.get(usize::from(doing.activity.0)) {
endings.push((person, spec.name.clone(), false));
}
}
for &(_, person) in &index.sorted {
let Ok(doing) = people.get(person) else {
continue;
};
let Some(spec) = defs.activities.get(usize::from(doing.activity.0)) else {
continue;
};
if endings.iter().any(|(ended, _, _)| *ended == person) {
continue;
}
let suspended = dormant.get(person).is_ok()
|| match doing.target {
DoingTarget::Person(target) => dormant.get(target).is_ok(),
_ => false,
}
|| (doing.arrived_day.is_none()
&& match doing.target {
DoingTarget::Person(target) => {
people.get(target).is_ok_and(|theirs| is_unavailable(&defs, theirs))
}
_ => false,
});
if suspended {
endings.push((person, spec.name.clone(), false));
continue;
}
let (finished, completed) = match doing.arrived_day {
None => {
let timed_out = timeout_hours
.is_some_and(|hours| (now - doing.started_day) * 24.0 > hours)
|| stall_hours.is_some_and(|hours| {
progress
.get(person)
.is_ok_and(|seen| (now - seen.improved_day) * 24.0 > hours)
});
let gone = match doing.target {
DoingTarget::Person(target) => !perception.persons.contains(&target),
_ => false,
};
(timed_out || gone, false)
}
Some(arrived) => {
let done = (now - arrived) * 24.0 >= f64::from(spec.duration_hours);
if done {
(true, true)
} else if let DoingTarget::Person(target) = doing.target {
(!in_perception(&perception, &index, person, target), false)
} else {
(false, false)
}
}
};
if finished {
endings.push((person, spec.name.clone(), completed));
}
}
let memory_days = settings
.tuning
.failed_place_memory_hours
.map(|hours| f64::from(hours) / 24.0);
for (person, activity, completed) in endings {
if let (Some(days), false, Ok(doing)) = (memory_days, completed, people.get(person))
&& doing.arrived_day.is_none()
&& dormant.get(person).is_err()
&& let DoingTarget::Place(place) = doing.target
{
let mut memory = failed.get(person).cloned().unwrap_or_default();
memory
.0
.retain(|(avoided, until)| *avoided != place && *until > now);
memory.0.push((place, now + days));
commands.entity(person).insert(memory);
}
ended.write(ActivityEnded {
person,
activity,
completed,
});
commands
.entity(person)
.remove::<(Doing, Destination, TravelProgress)>();
}
}
fn in_perception(
perception: &Perception,
index: &PersonIndex,
person: Entity,
target: Entity,
) -> bool {
let Some(from) = index.sorted.iter().position(|(_, e)| *e == person) else {
return false;
};
let Some(to) = index.sorted.iter().position(|(_, e)| *e == target) else {
return false;
};
perception
.near
.get(from)
.is_some_and(|list| list.iter().any(|(j, _)| *j as usize == to))
}
struct PlaceView {
entity: Entity,
tags: Vec<String>,
capacity: u32,
used: u32,
position: Vec2,
}
struct Decision {
position: Vec2,
needs: Vec<f32>,
signals: Vec<f32>,
personality: Personality,
mood: [f32; 3],
emotions: Emotions,
ties: Vec<crate::relationships::Tie>,
near: Vec<(Entity, Vec2)>,
place_positions: Vec<(Entity, Vec2)>,
own_factions: Vec<crate::FactionId>,
close_ties: Vec<Entity>,
close_doing: Vec<(Entity, u16)>,
hour: f32,
week: f32,
body_hour: f32,
perception_radius: f32,
stock: crate::Stock,
near_stock: Vec<(Entity, crate::Stock)>,
trade_reserve: f32,
trade_margin: f32,
}
#[derive(QueryData)]
#[query_data(mutable)]
struct Decider<'w> {
ties: &'w Ties,
memberships: &'w crate::Memberships,
needs: &'w Needs,
signals: &'w crate::mind::Signals,
personality: &'w Personality,
mood: &'w Mood,
emotions: &'w Emotions,
home: Option<&'w crate::Home>,
bound: Option<&'w crate::BoundPlaces>,
chronotype: Option<&'w crate::Chronotype>,
}
struct ScriptContext<'a> {
defs: &'a SocietyDefs,
snapshot: &'a std::sync::Arc<crate::snapshot::SocietySnapshot>,
engine: &'a crate::script::ScriptEngine,
actor: i64,
target: i64,
}
#[derive(Debug, Clone)]
struct Candidate {
activity: u16,
targets: Vec<DoingTarget>,
weight: f32,
score: f32,
}
#[derive(bevy::ecs::system::SystemParam)]
struct DecisionQueries<'w, 's> {
people: Query<'w, 's, Decider<'static>>,
places: Query<
'w,
's,
(
Entity,
&'static crate::Place,
&'static crate::PlaceTags,
&'static Transform,
),
>,
doing: Query<'w, 's, &'static Doing>,
failed: Query<'w, 's, &'static FailedPlaces>,
harmed: Query<'w, 's, &'static RecentHarm>,
rebuffed: Query<'w, 's, &'static Rebuffed>,
}
fn is_unavailable(defs: &SocietyDefs, doing: &Doing) -> bool {
doing.arrived_day.is_some()
&& defs
.activities
.get(usize::from(doing.activity.0))
.is_some_and(|spec| spec.unavailable)
}
fn activity_is_harmful(defs: &SocietyDefs, spec: &crate::defs::ActivitySpec) -> bool {
spec.interaction
.and_then(|id| defs.interactions.get(usize::from(id.0)))
.and_then(|interaction| interaction.deed)
.and_then(|deed| defs.deeds.get(usize::from(deed.0)))
.is_some_and(|deed| deed.harm < 0.0)
}
#[allow(clippy::too_many_arguments)]
fn decide_activities(
clock: Res<SocietyClock>,
settings: Res<SocietySettings>,
defs: Res<SocietyDefs>,
perception: Res<Perception>,
index: Res<PersonIndex>,
mut rng: ResMut<crate::SocietyRng>,
mut commands: Commands,
world: DecisionQueries,
marks: Query<(Has<crate::Autonomous>, Has<crate::Dormant>)>,
mut started: MessageWriter<ActivityStarted>,
engine: Option<Res<crate::script::ScriptEngine>>,
cell: Res<crate::snapshot::SnapshotCell>,
fields: Option<Res<FieldGrids>>,
mut trace: Option<ResMut<crate::trace::DecisionTrace>>,
mut warned_bindings: Local<std::collections::BTreeSet<(u32, String)>>,
stocks: Query<&crate::Stock>,
) {
let DecisionQueries {
people,
places,
doing,
failed,
harmed,
rebuffed,
} = world;
use bevy_emerge_rng::DetRng;
type Scored = (String, f32, bool, u8);
let mut recorded: Vec<(Entity, Vec<Scored>, Vec<f32>)> = Vec::new();
let now = clock.now_days;
let tuning = &settings.tuning;
let reads_stores = defs.activities.iter().any(|activity| {
activity
.considerations
.iter()
.any(|term| matches!(term.signal, Signal::Surplus(_)))
});
let any_unavailable = defs.activities.iter().any(|activity| activity.unavailable);
let unavailable_slots: Vec<bool> = if any_unavailable {
index
.sorted
.iter()
.map(|&(_, entity)| {
doing
.get(entity)
.is_ok_and(|theirs| is_unavailable(&defs, theirs))
})
.collect()
} else {
Vec::new()
};
let tier_cutoff = tuning.tier_cutoff;
let hour = clock.hour();
let week = clock.week_position(settings.start_weekday);
let mut used: Vec<(Entity, u32)> = Vec::new();
for doing in &doing {
if let DoingTarget::Place(place) = doing.target {
match used.iter_mut().find(|(p, _)| *p == place) {
Some((_, n)) => *n += 1,
None => used.push((place, 1)),
}
}
}
let mut place_views: Vec<PlaceView> = places
.iter()
.map(|(entity, place, tags, transform)| PlaceView {
entity,
tags: tags.0.clone(),
capacity: place.capacity,
used: used
.iter()
.find(|(p, _)| *p == entity)
.map_or(0, |(_, n)| *n),
position: settings.plane.project(transform.translation),
})
.collect();
place_views.sort_by_key(|p| p.entity.to_bits());
let all_doing: Vec<(Entity, u16)> = index
.sorted
.iter()
.filter_map(|&(_, entity)| doing.get(entity).ok().map(|d| (entity, d.activity.0)))
.collect();
let snapshot = cell.0.clone();
let mut positions: Vec<(Entity, Vec2)> = place_views
.iter()
.map(|view| (view.entity, view.position))
.chain(
index
.sorted
.iter()
.enumerate()
.filter_map(|(slot, (_, entity))| {
perception.pos.get(slot).map(|at| (*entity, *at))
}),
)
.collect();
positions.sort_by_key(|(e, _)| e.to_bits());
let position_of = |entity: Entity| {
positions
.binary_search_by_key(&entity.to_bits(), |(e, _)| e.to_bits())
.ok()
.map(|i| positions[i].1)
};
let mut plans: Vec<(
Entity,
ActivityId,
DoingTarget,
bool,
String,
Option<Entity>,
)> = Vec::new();
for (slot, &(_, person)) in index.sorted.iter().enumerate() {
let (autonomous, dormant) = marks.get(person).unwrap_or((false, false));
if !autonomous || dormant || doing.get(person).is_ok() {
continue;
}
let Ok(view) = people.get(person) else {
continue;
};
let (ties, memberships, needs, signals, personality, mood, emotions, home, bound) = (
view.ties,
view.memberships,
view.needs,
view.signals,
view.personality,
view.mood,
view.emotions,
view.home,
view.bound,
);
let position = perception.pos.get(slot).copied().unwrap_or(Vec2::ZERO);
let mut near: Vec<(Entity, Vec2)> = Vec::new();
let mut near_ids: Vec<u32> = Vec::new();
if let Some(list) = perception.near.get(slot) {
for (j, _) in list {
let j = *j as usize;
let (Some(other), Some(pos)) = (
perception.persons.get(j).copied(),
perception.pos.get(j).copied(),
) else {
continue;
};
if other == person {
continue;
}
if unavailable_slots.get(j).copied().unwrap_or(false) {
continue;
}
near.push((other, pos));
near_ids.push(index.sorted.get(j).map_or(u32::MAX, |(id, _)| id.0));
}
}
let place_positions: Vec<(Entity, Vec2)> = place_views
.iter()
.map(|view| (view.entity, view.position))
.collect();
let close = ties.close_targets(now as f32, tuning, settings.close_ties);
let own_factions = memberships.0.clone();
let decision = Decision {
own_factions: own_factions.clone(),
place_positions: place_positions.clone(),
position,
needs: needs.0.clone(),
signals: signals.0.clone(),
personality: *personality,
mood: mood.as_array(),
emotions: emotions.clone(),
ties: ties.0.clone(),
near: near.clone(),
close_ties: close.clone(),
close_doing: all_doing
.iter()
.filter(|(entity, _)| close.contains(entity))
.copied()
.collect(),
hour,
body_hour: view.chronotype.copied().unwrap_or_default().body_hour(hour),
week,
perception_radius: settings.perception_radius,
trade_reserve: tuning.trade_reserve,
trade_margin: tuning.trade_margin,
stock: stocks.get(person).cloned().unwrap_or_default(),
near_stock: if reads_stores {
near.iter()
.filter_map(|(entity, _)| {
stocks.get(*entity).ok().cloned().map(|s| (*entity, s))
})
.collect()
} else {
Vec::new()
},
};
let actor_id = index.sorted.get(slot).map_or(-1, |(id, _)| i64::from(id.0));
let mut near_lookup: Vec<(u64, u32)> = near
.iter()
.zip(&near_ids)
.map(|((entity, _), id)| (entity.to_bits(), *id))
.collect();
near_lookup.sort_by_key(|(bits, _)| *bits);
let script_ready = match (&engine, &snapshot) {
(Some(engine), Some(snapshot)) => Some((snapshot, engine)),
_ => None,
};
if let Some(bindings) = bound {
let actor_id = index
.sorted
.iter()
.find(|(_, e)| *e == person)
.map(|(id, _)| id.0)
.unwrap_or(u32::MAX);
for (name, entity) in &bindings.0 {
if place_views.iter().any(|place| place.entity == *entity) {
continue;
}
if !warned_bindings.insert((actor_id, name.clone())) {
continue;
}
warn!(
"bevy_society: person {actor_id} has \"{name}\" bound to {entity:?}, which is not a \
place; the activity has no target"
);
}
}
let mut best: Option<(u8, Vec<Candidate>)> = None;
let mut all_scored: Vec<(String, f32, u8)> = Vec::new();
for (i, spec) in defs.activities.iter().enumerate() {
if i == 0 {
continue;
}
let mut targets = targets_for(
&spec.target,
position,
&near,
&place_views,
home.map(|h| h.0),
bound,
&own_factions,
fields.as_deref(),
&mut rng.0,
);
if let Ok(memory) = failed.get(person) {
targets.retain(|target| match target {
DoingTarget::Place(place) => !memory
.0
.iter()
.any(|(avoided, until)| avoided == place && *until > now),
_ => true,
});
}
if tuning.retaliation_refractory_hours.is_some()
&& activity_is_harmful(&defs, spec)
&& let Ok(memory) = harmed.get(person)
{
targets.retain(|target| match target {
DoingTarget::Person(other) => !memory
.0
.iter()
.any(|(spared, until)| spared == other && *until > now),
_ => true,
});
}
if tuning.rebuff_memory_hours.is_some()
&& !activity_is_harmful(&defs, spec)
&& let Ok(memory) = rebuffed.get(person)
{
targets.retain(|target| match target {
DoingTarget::Person(other) => !memory
.0
.iter()
.any(|(refused_by, until)| refused_by == other && *until > now),
_ => true,
});
}
let mut best_for_activity: Option<(f32, Vec<DoingTarget>)> = None;
for target in targets {
let target_id = match target {
DoingTarget::Person(other) => near_lookup
.binary_search_by_key(&other.to_bits(), |(bits, _)| *bits)
.map_or(-1, |at| i64::from(near_lookup[at].1)),
_ => -1,
};
let script_ref = script_ready.map(|(snapshot, engine)| ScriptContext {
defs: &defs,
snapshot,
engine,
actor: actor_id,
target: target_id,
});
let mut score = score_option(
&spec.considerations,
&decision,
target,
i as u16,
script_ref.as_ref(),
fields.as_deref(),
&defs.skills,
);
score *= crowding_price(target, &place_views, tuning.crowding_penalty);
if score <= 0.0 {
continue;
}
match &mut best_for_activity {
Some((best, ties)) if score > *best + f32::EPSILON => {
*best = score;
*ties = vec![target];
}
Some((best, ties)) if (score - *best).abs() <= f32::EPSILON => {
ties.push(target);
}
Some(_) => {}
None => best_for_activity = Some((score, vec![target])),
}
}
let Some((score, targets)) = best_for_activity else {
continue;
};
let candidate = Candidate {
activity: i as u16,
targets,
weight: spec.weight,
score,
};
let rank = spec.rank_for(&needs.0);
if trace.is_some() {
all_scored.push((
defs.activity_name(ActivityId(candidate.activity))
.to_owned(),
candidate.score,
rank,
));
}
match &mut best {
Some((tier, list)) if *tier == rank => list.push(candidate),
Some((tier, list)) if rank > *tier => {
*tier = rank;
*list = vec![candidate];
}
Some(_) => {}
None => best = Some((rank, vec![candidate])),
}
}
if trace.is_some() {
let mut scored: Vec<Scored> = all_scored
.into_iter()
.map(|(name, score, rank)| (name, score, false, rank))
.collect();
scored.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
recorded.push((person, scored, decision.signals.clone()));
}
let picked = best.and_then(|(_, list)| {
let top = list.iter().map(|c| c.score).fold(0.0_f32, f32::max);
let floor = top * tier_cutoff;
let viable: Vec<&Candidate> = list.iter().filter(|c| c.score >= floor).collect();
let total: f32 = viable.iter().map(|c| c.weight * c.score).sum();
if total <= 0.0 {
return None;
}
let mut roll = rng.0.unit() as f32 * total;
let mut chosen = (*viable.last()?).clone();
for candidate in &viable {
roll -= candidate.weight * candidate.score;
if roll <= 0.0 {
chosen = (*candidate).clone();
break;
}
}
let target = match chosen.targets.first() {
Some(DoingTarget::Place(_) | DoingTarget::Point(_)) => chosen
.targets
.iter()
.min_by(|a, b| {
let da = place_distance(**a, position, &place_positions);
let db = place_distance(**b, position, &place_positions);
da.partial_cmp(&db).unwrap_or(std::cmp::Ordering::Equal)
})
.copied()
.unwrap_or(chosen.targets[0]),
_ => {
let index = rng.0.below(chosen.targets.len().max(1));
*chosen.targets.get(index)?
}
};
Some((chosen.activity, target))
});
if trace.is_some()
&& let Some((_, scored, _)) = recorded.iter_mut().find(|(p, _, _)| *p == person)
{
let chosen = picked.map(|(activity, _)| activity);
if scored.is_empty() {
scored.push((
defs.activity_name(crate::defs::IDLE_ACTIVITY).to_owned(),
1.0,
true,
0,
));
} else {
for (name, _, is_chosen, _) in scored.iter_mut() {
*is_chosen = defs.activity_id(name).map(|id| id.0) == chosen;
}
}
}
match picked {
Some((activity, target)) => {
let activity = ActivityId(activity);
let name = defs.activity_name(activity).to_owned();
plans.push((
person,
activity,
target,
false,
name,
match target {
DoingTarget::Person(other) => Some(other),
_ => None,
},
));
}
None => plans.push((
person,
crate::defs::IDLE_ACTIVITY,
DoingTarget::Itself,
true,
defs.activity_name(crate::defs::IDLE_ACTIVITY).to_owned(),
None,
)),
}
}
if let Some(trace) = trace.as_mut() {
for (person, scored, signals) in recorded {
trace.push(crate::trace::Decision {
tick: clock.tick,
person,
name: None,
signals,
options: scored
.into_iter()
.map(
|(activity, score, chosen, rank)| crate::trace::ScoredOption {
activity,
score,
chosen,
rank,
},
)
.collect(),
});
}
}
for (person, activity, target, in_place, name, person_target) in plans {
let mut entity = commands.entity(person);
entity.insert(Doing {
activity,
target,
started_day: now,
arrived_day: in_place.then_some(now),
});
entity.remove::<TravelProgress>();
match target {
DoingTarget::Point(point) => {
entity.insert(Destination(point));
}
DoingTarget::Place(place) => {
if let Some(at) = position_of(place) {
entity.insert(Destination(at));
}
}
DoingTarget::Person(other) => {
if let (Some(theirs), Some(mine)) = (position_of(other), position_of(person)) {
let toward = (mine - theirs).normalize_or_zero();
entity.insert(Destination(
theirs + toward * 0.75 * settings.interaction_radius,
));
}
}
DoingTarget::Itself => {}
}
started.write(ActivityStarted {
person,
activity: name,
target: person_target,
});
}
}
impl ScriptContext<'_> {
fn eval(
&self,
id: crate::script::ScriptId,
decision: &Decision,
target: DoingTarget,
) -> Option<f32> {
let _ = (decision, target);
let bindings = crate::script::Bindings {
actor: self.actor,
target: self.target,
..crate::script::Bindings::default()
};
match self
.engine
.eval_f32(&self.defs.scripts, id, self.snapshot, bindings)
{
Ok(value) => Some(value.clamp(0.0, 1.0)),
Err(err) => {
error!(
"bevy_society: script {} failed: {err}",
crate::script::ScriptEngine::owner(&self.defs.scripts, id)
);
None
}
}
}
}
fn crowding_price(target: DoingTarget, places: &[PlaceView], penalty: f32) -> f32 {
if penalty <= 0.0 {
return 1.0;
}
let DoingTarget::Place(entity) = target else {
return 1.0;
};
let Some(place) = places.iter().find(|place| place.entity == entity) else {
return 1.0;
};
if place.used < place.capacity {
return 1.0;
}
(1.0 - penalty).clamp(0.0, 1.0)
}
#[allow(clippy::too_many_arguments)]
fn targets_for(
target: &ActivityTarget,
position: Vec2,
near: &[(Entity, Vec2)],
places: &[PlaceView],
home: Option<Entity>,
bound: Option<&crate::BoundPlaces>,
own_factions: &[crate::FactionId],
fields: Option<&FieldGrids>,
rng: &mut rand_chacha::ChaCha8Rng,
) -> Vec<DoingTarget> {
use bevy_emerge_rng::DetRng;
match target {
ActivityTarget::Itself => vec![DoingTarget::Itself],
ActivityTarget::Home => home.map_or_else(Vec::new, |h| vec![DoingTarget::Place(h)]),
ActivityTarget::Wander(radius) => {
let r = radius * (rng.unit() as f32).sqrt();
let angle = std::f32::consts::TAU * rng.unit() as f32;
vec![DoingTarget::Point(
position + Vec2::new(r * angle.cos(), r * angle.sin()),
)]
}
ActivityTarget::Place(tag) => places
.iter()
.filter(|place| place.capacity > 0 && place.tags.iter().any(|t| t == tag))
.map(|place| DoingTarget::Place(place.entity))
.collect(),
ActivityTarget::Bound(name) => bound
.and_then(|bindings| bindings.get(name))
.filter(|entity| {
places
.iter()
.any(|place| place.entity == *entity && place.capacity > 0)
})
.map_or_else(Vec::new, |entity| vec![DoingTarget::Place(entity)]),
ActivityTarget::Person => near.iter().map(|(e, _)| DoingTarget::Person(*e)).collect(),
ActivityTarget::Gradient(input, toward) => fields
.and_then(|grids| grids.gradient_target(*input, own_factions, position, *toward))
.map_or_else(Vec::new, |point| vec![DoingTarget::Point(point)]),
}
}
#[cfg(feature = "fields")]
type FieldGrids = crate::SocialFields;
#[cfg(not(feature = "fields"))]
#[derive(bevy::prelude::Resource, Default)]
pub struct NoFields;
#[cfg(not(feature = "fields"))]
impl NoFields {
fn world_to_cell(&self, _at: Vec2) -> bevy::math::IVec2 {
bevy::math::IVec2::ZERO
}
fn field_value(
&self,
_input: crate::FieldInput,
_own: &[crate::FactionId],
_cell: bevy::math::IVec2,
) -> f32 {
0.0
}
fn gradient_target(
&self,
_input: crate::FieldInput,
_own: &[crate::FactionId],
_pos: Vec2,
_toward: bool,
) -> Option<Vec2> {
None
}
}
#[cfg(not(feature = "fields"))]
type FieldGrids = NoFields;
fn place_distance(target: DoingTarget, from: Vec2, places: &[(Entity, Vec2)]) -> f32 {
let of = |entity: Entity| {
places
.iter()
.find(|(place, _)| *place == entity)
.map(|(_, at)| *at)
};
match target {
DoingTarget::Place(place) => of(place).map_or(f32::MAX, |at| from.distance(at)),
DoingTarget::Point(point) => from.distance(point),
DoingTarget::Person(_) | DoingTarget::Itself => 0.0,
}
}
fn score_option(
considerations: &[Term],
decision: &Decision,
target: DoingTarget,
activity: u16,
scripts: Option<&ScriptContext<'_>>,
fields: Option<&FieldGrids>,
skills: &[crate::defs::SkillGroup],
) -> f32 {
let n = considerations.len();
if n == 0 {
return 1.0;
}
let tie = match target {
DoingTarget::Person(other) => decision.ties.iter().find(|t| t.target == other).copied(),
_ => None,
};
let there = if considerations
.iter()
.any(|term| matches!(term.signal, Signal::Distance))
{
match target {
DoingTarget::Person(other) => decision
.near
.iter()
.find(|(e, _)| *e == other)
.map(|(_, p)| *p),
DoingTarget::Place(place) => decision
.place_positions
.iter()
.find(|(e, _)| *e == place)
.map(|(_, p)| *p),
DoingTarget::Point(_) | DoingTarget::Itself => None,
}
} else {
None
};
let mut product = 1.0_f32;
for term in considerations {
let s = term.curve.eval(signal_value(
term.signal,
decision,
target,
tie,
there,
activity,
scripts,
fields,
skills,
));
let compensated = s + (1.0 - s) * (1.0 - 1.0 / n as f32) * s;
product *= compensated;
}
product
}
#[allow(clippy::too_many_arguments)]
fn signal_value(
signal: Signal,
decision: &Decision,
target: DoingTarget,
tie: Option<crate::relationships::Tie>,
there: Option<Vec2>,
activity: u16,
scripts: Option<&ScriptContext<'_>>,
fields: Option<&FieldGrids>,
skills: &[crate::defs::SkillGroup],
) -> f32 {
let value = match signal {
Signal::Need(id) => decision
.needs
.get(usize::from(id.0))
.copied()
.unwrap_or(0.0),
Signal::Host(id) => decision
.signals
.get(usize::from(id.0))
.copied()
.unwrap_or(0.0),
Signal::Hour => decision.hour / 24.0,
Signal::BodyHour => decision.body_hour / 24.0,
Signal::Week => decision.week / f32::from(crate::model::DAYS_PER_WEEK),
Signal::Pleasure => (decision.mood[0] + 1.0) / 2.0,
Signal::Arousal => (decision.mood[1] + 1.0) / 2.0,
Signal::Dominance => (decision.mood[2] + 1.0) / 2.0,
Signal::Trait(which) => decision.personality.value(which),
Signal::Emotion(kind) => decision.emotions.intensity_of(kind),
Signal::Affinity => (tie.map_or(0.0, |t| t.affinity) + 1.0) / 2.0,
Signal::Trust => tie.map_or(0.5, |t| t.trust),
Signal::Respect => (tie.map_or(0.0, |t| t.respect) + 1.0) / 2.0,
Signal::Familiarity => match target {
DoingTarget::Person(_) => tie.map_or(0.0, |t| t.familiarity_peak),
_ => 0.0,
},
Signal::Distance => there.map_or(1.0, |p| {
(decision.position.distance(p) / decision.perception_radius).min(1.0)
}),
Signal::Surplus(id) => {
let actor_holds = decision.stock.get(id);
let target_holds = match target {
DoingTarget::Person(other) => decision
.near_stock
.iter()
.find(|(e, _)| *e == other)
.map(|(_, stock)| stock.get(id)),
_ => None,
};
target_holds.map_or(0.0, |theirs| {
let gap = crate::economy::value_of(theirs) - crate::economy::value_of(actor_holds);
if actor_holds <= decision.trade_reserve || gap < decision.trade_margin {
0.0
} else {
gap.min(1.0)
}
})
}
Signal::Stock(id) => decision.stock.get(id),
Signal::Skill(id) => skills.get(usize::from(id.0)).map_or(0.0, |group| {
crate::skill_level(&decision.personality, *group)
}),
Signal::SocialProof => {
if decision.close_ties.is_empty() {
0.0
} else {
let following = decision
.close_ties
.iter()
.filter(|t| {
decision
.close_doing
.iter()
.any(|(e, a)| e == *t && *a == activity)
})
.count();
following as f32 / decision.close_ties.len() as f32
}
}
Signal::Script(id) => match scripts {
Some(context) => context.eval(id, decision, target).unwrap_or(0.0),
None => 0.0,
},
Signal::Field(input) => match fields {
Some(grids) => {
let cell = grids.world_to_cell(decision.position);
grids.field_value(input, &decision.own_factions, cell)
}
None => 0.0,
},
};
value.clamp(0.0, 1.0)
}
fn resolve_interactions(world: &mut World) {
let (now, settings, defs, perception, index, due) = {
let now = world.resource::<SocietyClock>().now_days;
let settings = world.resource::<SocietySettings>().clone();
let defs = world.resource::<SocietyDefs>().clone();
let perception = world.resource::<Perception>().clone();
let index = world.resource::<PersonIndex>().sorted.clone();
let mut due: Vec<crate::interact::Interact> = {
let mut arrivals = world.remove_resource::<ArrivalQueue>().unwrap_or_default();
std::mem::take(&mut arrivals.0)
};
let mut queue = world.remove_resource::<InteractQueue>().unwrap_or_default();
due.extend(std::mem::take(&mut queue.0));
world.insert_resource(queue);
world.insert_resource(ArrivalQueue::default());
(now, settings, defs, perception, index, due)
};
for request in due {
exchange(world, &request, &defs, &settings, &perception, &index, now);
}
}
#[allow(clippy::too_many_arguments)]
fn exchange(
world: &mut World,
request: &crate::interact::Interact,
defs: &SocietyDefs,
settings: &SocietySettings,
perception: &Perception,
index: &[(crate::PersonId, Entity)],
now: f64,
) {
let (actor, target) = (request.actor, request.target);
let Some(id) = defs.interaction_id(&request.interaction) else {
error!(
"bevy_society: Interact names unknown interaction '{}'; ignored",
request.interaction
);
return;
};
let Some(spec) = defs.interactions.get(usize::from(id.0)).cloned() else {
return;
};
if world.get::<Ties>(actor).is_none() || world.get::<Ties>(target).is_none() {
return;
}
if world.get::<crate::Dormant>(actor).is_some() || world.get::<crate::Dormant>(target).is_some()
{
return;
}
if let Some(requires) = spec.requires
&& !condition_holds(world, defs, requires, actor, target)
{
world.write_message(InteractionResolved {
actor,
target,
interaction: spec.name.clone(),
accepted: false,
refused_by_rule: true,
winner: None,
});
return;
}
if let Some(hours) = settings.tuning.retaliation_refractory_hours
&& spec
.deed
.and_then(|d| defs.deeds.get(usize::from(d.0)))
.is_some_and(|deed| deed.harm < 0.0)
{
let mut harmed = world.get::<RecentHarm>(actor).cloned().unwrap_or_default();
harmed
.0
.retain(|(other, until)| *other != target && *until > now);
harmed.0.push((target, now + f64::from(hours) / 24.0));
world.entity_mut(actor).insert(harmed);
}
let target_ties = world.get::<Ties>(target).cloned().unwrap_or_default();
let target_personality = world
.get::<Personality>(target)
.copied()
.unwrap_or_default();
let target_mood = world.get::<Mood>(target).copied().unwrap_or_default();
let toward_actor = target_ties
.get(actor)
.copied()
.unwrap_or_else(|| crate::relationships::Tie::new(actor));
let shared_faction = {
let mine = world.get::<Memberships>(actor).cloned().unwrap_or_default();
let theirs = world
.get::<Memberships>(target)
.cloned()
.unwrap_or_default();
mine.shares_with(&theirs)
};
let accept = &spec.accept;
let probability = (accept.base
+ accept.affinity * toward_actor.affinity
+ accept.trust * (toward_actor.trust - 0.5)
+ accept.agreeableness * (target_personality.agreeableness - 0.5)
+ accept.pleasure * target_mood.pleasure
+ if shared_faction { accept.ingroup } else { 0.0 })
.clamp(0.02, 0.98);
let roll = world.resource_mut::<crate::SocietyRng>().0.unit() as f32;
let accepted = roll < probability;
let effects = if accepted {
&spec.on_accept
} else {
&spec.on_reject
};
apply_effects(world, effects, actor, target, &spec.name, now);
let mut winner = None;
if accepted {
influence(world, &spec, actor, target, &settings.tuning);
if spec.gossip {
tell(world, actor, target, settings, now);
}
if spec.contest {
winner = contest(world, actor, target, &settings.tuning, perception, index);
}
if let Some(deed) = spec.deed {
let seen_by: Vec<Entity> = index
.iter()
.position(|(_, e)| *e == actor)
.and_then(|slot| perception.near.get(slot))
.map(|list| {
list.iter()
.filter_map(|(j, _)| perception.persons.get(*j as usize).copied())
.filter(|w| *w != actor && *w != target)
.collect()
})
.unwrap_or_default();
witness_deed(world, deed, actor, target, defs, settings, now, &seen_by);
}
close_the_triad(world, actor, target, perception, index, settings, now);
if !shared_faction
&& spec.deed.is_none_or(|d| {
defs.deeds
.get(usize::from(d.0))
.is_none_or(|s| s.harm >= 0.0)
})
{
intergroup(world, actor, target, &settings.tuning);
}
} else {
if let Some(mut ties) = world.get_mut::<Ties>(actor) {
let tie = ties.entry(target);
tie.add_affinity(
-settings.tuning.rejection_affinity_penalty,
&settings.tuning,
);
}
if let Some(hours) = settings.tuning.rebuff_memory_hours
&& !spec
.deed
.and_then(|d| defs.deeds.get(usize::from(d.0)))
.is_some_and(|deed| deed.harm < 0.0)
{
let mut refused = world.get::<Rebuffed>(actor).cloned().unwrap_or_default();
refused
.0
.retain(|(other, until)| *other != target && *until > now);
refused.0.push((target, now + f64::from(hours) / 24.0));
world.entity_mut(actor).insert(refused);
}
if effects.actor_emotion.is_none() && settings.tuning.rejection_distress > 0.0 {
world.write_message(crate::mind::Appraise {
person: actor,
kind: crate::mind::EmotionKind::Distress,
intensity: settings.tuning.rejection_distress,
toward: Some(target),
});
}
}
let harm = if accepted {
spec.deed
.and_then(|d| defs.deeds.get(usize::from(d.0)))
.map_or(0.0, |spec| spec.harm)
} else {
0.0
};
let magnitude = 0.3 + harm.abs();
let actor_valence = if accepted { magnitude } else { -magnitude };
let target_valence = if harm < 0.0 {
-magnitude
} else if accepted {
magnitude
} else {
-magnitude
};
record(
world,
actor,
target,
id,
accepted,
true,
actor_valence,
effects.importance,
now,
);
record(
world,
target,
actor,
id,
accepted,
false,
target_valence,
effects.importance,
now,
);
expose(world, actor, target, settings, now);
expose(world, target, actor, settings, now);
world.write_message(InteractionResolved {
actor,
target,
interaction: spec.name.clone(),
accepted,
refused_by_rule: false,
winner,
});
bark(world, &spec, actor, target, accepted, settings);
}
fn apply_effects(
world: &mut World,
effects: &crate::defs::EffectsSpec,
actor: Entity,
target: Entity,
_interaction: &str,
_now: f64,
) {
if !effects.actor_gives.is_empty() || !effects.target_gives.is_empty() {
let ceiling = world
.get_resource::<crate::SocietySettings>()
.map_or_else(Default::default, |settings| settings.tuning.stock_ceiling);
for (from, to, list) in [
(actor, target, &effects.actor_gives),
(target, actor, &effects.target_gives),
] {
for &(good, amount) in list {
let taken = world
.get_mut::<crate::Stock>(from)
.map_or(0.0, |mut stock| stock.take(good, amount));
if taken > 0.0
&& let Some(mut theirs) = world.get_mut::<crate::Stock>(to)
{
theirs.add(good, taken, ceiling);
}
}
}
}
let tuning = world
.get_resource::<SocietySettings>()
.map_or_else(Default::default, |settings| settings.tuning.clone());
for (owner, other, delta) in [
(actor, target, effects.actor_to_target),
(target, actor, effects.target_to_actor),
] {
let Some(mut ties) = world.get_mut::<Ties>(owner) else {
continue;
};
let friends = ties.friend_count(&tuning);
let tie = ties.entry(other);
let scale = if delta.affinity > 0.0 {
tuning.bond_scale(friends, tie.is_close(&tuning))
} else {
1.0
};
tie.add_affinity(delta.affinity * scale, &tuning);
tie.trust = (tie.trust + delta.trust).clamp(0.0, 1.0);
tie.respect = (tie.respect + delta.respect).clamp(-1.0, 1.0);
if delta.familiarity != 0.0 {
tie.familiarity_peak = (tie.familiarity_peak + delta.familiarity).clamp(0.0, 1.0);
}
}
for (person, emotion) in [
(actor, effects.actor_emotion),
(target, effects.target_emotion),
] {
if let Some((kind, intensity)) = emotion {
world.write_message(crate::mind::Appraise {
person,
kind,
intensity,
toward: Some(if person == actor { target } else { actor }),
});
}
}
for (person, deltas) in [
(actor, &effects.actor_needs),
(target, &effects.target_needs),
] {
let Some(mut needs) = world.get_mut::<Needs>(person) else {
continue;
};
for &(need, delta) in deltas {
if let Some(level) = needs.0.get_mut(usize::from(need.0)) {
*level = (*level + delta).clamp(0.0, 1.0);
}
}
}
if let Some(script) = effects.script {
run_effect(world, script, actor, target);
}
}
fn influence(
world: &mut World,
spec: &crate::defs::InteractionSpec,
actor: Entity,
target: Entity,
tuning: &Tuning,
) {
if spec.influence.is_empty() {
return;
}
let Some(a_opinions) = world.get::<crate::Opinions>(actor).cloned() else {
return;
};
let Some(b_opinions) = world.get::<crate::Opinions>(target).cloned() else {
return;
};
let a_openness = world.get::<Personality>(actor).map_or(0.5, |p| p.openness);
let b_openness = world.get::<Personality>(target).map_or(0.5, |p| p.openness);
let a_values = crate::beliefs::snapshot_values(&a_opinions, &spec.influence);
let b_values = crate::beliefs::snapshot_values(&b_opinions, &spec.influence);
for (holder, other, openness, other_values) in [
(actor, target, a_openness, b_values),
(target, actor, b_openness, a_values),
] {
let (Some(mut opinions), Some(mut ties)) = (
world.get::<crate::Opinions>(holder).cloned(),
world.get::<Ties>(holder).cloned(),
) else {
continue;
};
crate::beliefs::exchange_opinions(
&mut opinions,
&mut ties,
openness,
other,
&other_values,
tuning,
);
if let Some(mut slot) = world.get_mut::<crate::Opinions>(holder) {
*slot = opinions;
}
if let Some(mut slot) = world.get_mut::<Ties>(holder) {
*slot = ties;
}
}
}
fn tell(world: &mut World, teller: Entity, listener: Entity, settings: &SocietySettings, now: f64) {
let Some(teller_knowledge) = world.get::<crate::Knowledge>(teller).cloned() else {
return;
};
let trust_in_teller = world
.get::<Ties>(listener)
.and_then(|ties| ties.get(teller).map(|tie| tie.trust))
.unwrap_or(0.5);
let close = world
.get::<Ties>(listener)
.map(|ties| ties.close_targets(now as f32, &settings.tuning, settings.close_ties))
.unwrap_or_default();
let Some(mut listener_knowledge) = world.get::<crate::Knowledge>(listener).cloned() else {
return;
};
let Some(mut listener_ties) = world.get::<Ties>(listener).cloned() else {
return;
};
let mut listener_emotions = world.get::<Emotions>(listener).cloned().unwrap_or_default();
let mut listener_memories = world
.get::<crate::mind::Memories>(listener)
.cloned()
.unwrap_or_default();
let mut book = world
.remove_resource::<crate::RumorBook>()
.unwrap_or_default();
let outcome = crate::rumor::gossip(
&mut book,
&teller_knowledge,
&mut listener_knowledge,
&mut listener_ties,
&mut listener_emotions,
&mut listener_memories,
listener,
trust_in_teller,
&close,
settings,
now as f32,
);
if let Some(mut slot) = world.get_mut::<crate::Knowledge>(listener) {
*slot = listener_knowledge;
}
if let Some(mut slot) = world.get_mut::<Ties>(listener) {
*slot = listener_ties;
}
if let Some(mut slot) = world.get_mut::<Emotions>(listener) {
*slot = listener_emotions;
}
if let Some(mut slot) = world.get_mut::<crate::mind::Memories>(listener) {
*slot = listener_memories;
}
if let crate::rumor::Gossip::Stifled(id) = outcome
&& let Some(mut knowledge) = world.get_mut::<crate::Knowledge>(teller)
&& let Some(known) = knowledge.get_mut(id)
{
known.spreader = false;
}
if let crate::rumor::Gossip::Learned { rumor, .. } = outcome {
world.write_message(crate::rumor::RumorLearned {
person: listener,
rumor,
from: Some(teller),
});
}
world.insert_resource(book);
}
fn contest(
world: &mut World,
actor: Entity,
target: Entity,
tuning: &Tuning,
perception: &Perception,
index: &[(crate::PersonId, Entity)],
) -> Option<Entity> {
let dominance = |world: &World, e: Entity| world.get::<Mood>(e).map_or(0.0, |m| m.dominance);
let standing =
|world: &World, e: Entity| world.get::<crate::Status>(e).map_or(0.0, |s| s.standing);
let a_dom = dominance(world, actor);
let a_stand = standing(world, actor);
let t_dom = dominance(world, target);
let t_stand = standing(world, target);
let mut a_ties = world.get::<Ties>(actor).cloned()?;
let mut t_ties = world.get::<Ties>(target).cloned()?;
let mut a_emotions = world.get::<Emotions>(actor).cloned()?;
let mut t_emotions = world.get::<Emotions>(target).cloned()?;
let resolved = crate::groups::resolve_contest(
&mut world.resource_mut::<crate::SocietyRng>().0,
tuning.contest_steepness,
crate::groups::Contestant {
entity: actor,
dominance: a_dom,
standing: a_stand,
ties: &mut a_ties,
emotions: &mut a_emotions,
},
crate::groups::Contestant {
entity: target,
dominance: t_dom,
standing: t_stand,
ties: &mut t_ties,
emotions: &mut t_emotions,
},
);
if let Some(mut ties) = world.get_mut::<Ties>(actor) {
*ties = a_ties;
}
if let Some(mut ties) = world.get_mut::<Ties>(target) {
*ties = t_ties;
}
if let Some(mut emotions) = world.get_mut::<Emotions>(actor) {
*emotions = a_emotions;
}
if let Some(mut emotions) = world.get_mut::<Emotions>(target) {
*emotions = t_emotions;
}
let actor_slot = index.iter().position(|(_, e)| *e == actor)?;
let witnesses: Vec<Entity> = perception
.near
.get(actor_slot)
.map(|list| {
list.iter()
.filter_map(|(j, _)| perception.persons.get(*j as usize).copied())
.filter(|w| *w != actor && *w != target)
.collect()
})
.unwrap_or_default();
for watcher in witnesses {
if let Some(mut ties) = world.get_mut::<Ties>(watcher) {
crate::groups::witness_contest(&mut ties, resolved);
}
}
Some(resolved)
}
#[allow(clippy::too_many_arguments)]
fn witness_deed(
world: &mut World,
deed: crate::defs::DeedId,
actor: Entity,
patient: Entity,
defs: &SocietyDefs,
settings: &SocietySettings,
now: f64,
witnesses: &[Entity],
) {
let Some(deed_spec) = defs.deeds.get(usize::from(deed.0)).cloned() else {
return;
};
#[cfg(feature = "fields")]
if deed_spec.harm != 0.0
&& let Some(transform) = world.get::<GlobalTransform>(actor)
{
world.write_message(crate::fields::Commotion {
at: settings.plane.project(transform.translation()),
amount: deed_spec.harm.abs(),
});
}
let mut approvals: Vec<(Entity, f32)> = Vec::new();
let watcher_count = witnesses.len();
for &watcher in witnesses {
let opinions = world
.get::<crate::Opinions>(watcher)
.cloned()
.unwrap_or_default();
let personality = world
.get::<Personality>(watcher)
.copied()
.unwrap_or_default();
let Some(mut ties) = world.get::<Ties>(watcher).cloned() else {
continue;
};
let mut emotions = world.get::<Emotions>(watcher).cloned().unwrap_or_default();
let mut memories = world
.get::<crate::mind::Memories>(watcher)
.cloned()
.unwrap_or_default();
let trust_in_patient = ties.get(patient).map(|tie| tie.trust);
let reaction = crate::reputation::witness_reaction(
&mut ties,
&mut emotions,
&mut memories,
&opinions,
&personality,
deed,
deed_spec.topic,
deed_spec.harm,
actor,
Some(patient),
trust_in_patient,
settings,
watcher_count,
now,
);
if let Some(mut slot) = world.get_mut::<Ties>(watcher) {
*slot = ties;
}
if let Some(mut slot) = world.get_mut::<Emotions>(watcher) {
*slot = emotions;
}
if let Some(mut slot) = world.get_mut::<crate::mind::Memories>(watcher) {
*slot = memories;
}
if reaction.violated {
world.write_message(crate::reputation::NormViolated {
witness: watcher,
actor,
deed: deed_spec.name.clone(),
});
}
approvals.push((watcher, reaction.approval));
}
let gain = {
let conscientiousness = world
.get::<Personality>(patient)
.map_or(0.5, |p| p.conscientiousness);
crate::reputation::trust_gain(&settings.tuning, conscientiousness)
};
if let Some(mut ties) = world.get::<Ties>(patient).cloned() {
let mut emotions = world.get::<Emotions>(patient).cloned().unwrap_or_default();
crate::reputation::patient_reaction(
&mut ties,
&mut emotions,
deed_spec.harm,
gain,
actor,
&settings.tuning,
);
if let Some(mut slot) = world.get_mut::<Ties>(patient) {
*slot = ties;
}
if let Some(mut slot) = world.get_mut::<Emotions>(patient) {
*slot = emotions;
}
}
if let Some(opinions) = world.get::<crate::Opinions>(actor).cloned()
&& let Some(mut emotions) = world.get::<Emotions>(actor).cloned()
{
crate::reputation::actor_reaction(&mut emotions, &opinions, deed_spec.topic, patient);
if let Some(mut slot) = world.get_mut::<Emotions>(actor) {
*slot = emotions;
}
}
let heavy = deed_spec.harm.abs() >= settings.tuning.rumor_threshold;
let strong = approvals.iter().any(|(_, a)| a.abs() >= 0.5);
if (heavy || strong) && !approvals.is_empty() {
let mut book = world
.remove_resource::<crate::RumorBook>()
.unwrap_or_default();
let mut known: Vec<(Entity, crate::Knowledge)> = Vec::new();
for (watcher, _) in &approvals {
if let Some(knowledge) = world.get::<crate::Knowledge>(*watcher) {
known.push((*watcher, knowledge.clone()));
}
}
let id = crate::reputation::spread_witnessed_rumor(
&mut book,
&mut known,
actor,
Some(patient),
deed,
deed_spec.harm,
now as f32,
true,
world
.get_resource::<crate::core::SocietySettings>()
.map_or(64, |s| s.knowledge_capacity),
);
for (watcher, knowledge) in known {
if let Some(mut slot) = world.get_mut::<crate::Knowledge>(watcher) {
*slot = knowledge;
}
}
if let Some(rumor) = id {
for (watcher, _) in &approvals {
world.write_message(crate::rumor::RumorLearned {
person: *watcher,
rumor,
from: None,
});
}
}
world.insert_resource(book);
}
}
fn resolve_reported_deeds(world: &mut World) {
let (now, defs, reports, index) = {
let now = world.resource::<SocietyClock>().now_days;
let defs = world.resource::<SocietyDefs>().clone();
let index = world.resource::<PersonIndex>().sorted.clone();
let mut queue = world
.remove_resource::<DeedReportQueue>()
.unwrap_or_default();
let reports = std::mem::take(&mut queue.0);
world.insert_resource(queue);
(now, defs, reports, index)
};
if reports.is_empty() {
return;
}
let settings = world.resource::<SocietySettings>().clone();
for report in reports {
let Some(deed) = defs.deed_id(&report.deed) else {
warn!(
"bevy_society: ReportDeed names unknown deed '{}'; ignored",
report.deed
);
continue;
};
let mut witnesses: Vec<Entity> = Vec::new();
let mut not_people = 0usize;
for &entity in &report.witnesses {
if entity == report.actor || Some(entity) == report.patient {
continue;
}
if !index.iter().any(|(_, e)| *e == entity) {
not_people += 1;
continue;
}
if !witnesses.contains(&entity) {
witnesses.push(entity);
}
}
if not_people > 0 {
warn!(
"bevy_society: ReportDeed for '{}' names {not_people} witness(es) that are not persons; \
dropped",
report.deed
);
}
if witnesses.is_empty() {
continue;
}
witnesses.sort_unstable_by_key(|entity| {
index
.iter()
.find(|(_, e)| e == entity)
.map_or(u32::MAX, |(id, _)| id.0)
});
let patient = report.patient.unwrap_or(report.actor);
witness_deed(
world,
deed,
report.actor,
patient,
&defs,
&settings,
now,
&witnesses,
);
if let Some(patient) = report.patient
&& let Some(spec) = defs.deeds.get(usize::from(deed.0))
&& spec.harm < 0.0
&& let Some(mut memories) = world.get_mut::<crate::mind::Memories>(patient)
{
let magnitude = 0.3 + spec.harm.abs();
memories.record(
crate::mind::Memory {
day: now as f32,
kind: crate::mind::MemoryKind::Suffered {
deed,
actor: report.actor,
},
other: Some(report.actor),
valence: -magnitude.min(1.0),
importance: magnitude.min(1.0),
},
now,
settings.memory_capacity,
);
}
}
}
fn close_the_triad(
world: &mut World,
actor: Entity,
target: Entity,
perception: &Perception,
index: &[(crate::PersonId, Entity)],
settings: &SocietySettings,
now: f64,
) {
for (holder, other) in [(actor, target), (target, actor)] {
let (Some(slot), Some(other_slot)) = (
index.iter().position(|(_, e)| *e == holder),
index.iter().position(|(_, e)| *e == other),
) else {
continue;
};
let in_contact = perception
.contact
.get(slot)
.is_some_and(|list| list.iter().any(|j| *j as usize == other_slot));
if !in_contact {
continue;
}
let Some(ties) = world.get::<Ties>(holder).cloned() else {
continue;
};
let close = ties.close_targets(now as f32, &settings.tuning, settings.close_ties);
for friend in close {
if friend == other {
continue;
}
if let Some(mut friend_ties) = world.get_mut::<Ties>(friend) {
crate::relationships::introduce_toward(
&mut friend_ties,
other,
now as f32,
&settings.tuning,
);
}
}
}
}
fn intergroup(world: &mut World, actor: Entity, target: Entity, tuning: &Tuning) {
let Some(mut a_ties) = world.get::<Ties>(actor).cloned() else {
return;
};
let Some(mut t_ties) = world.get::<Ties>(target).cloned() else {
return;
};
{
let mut a_to_t = *a_ties.entry(target);
let mut t_to_a = *t_ties.entry(actor);
crate::groups::intergroup_contact(&mut a_to_t, &mut t_to_a, tuning);
*a_ties.entry(target) = a_to_t;
*t_ties.entry(actor) = t_to_a;
}
if let Some(mut ties) = world.get_mut::<Ties>(actor) {
*ties = a_ties;
}
if let Some(mut ties) = world.get_mut::<Ties>(target) {
*ties = t_ties;
}
}
#[allow(clippy::too_many_arguments)]
fn record(
world: &mut World,
person: Entity,
other: Entity,
interaction: crate::defs::InteractionId,
accepted: bool,
initiated: bool,
valence: f32,
importance: f32,
now: f64,
) {
let capacity = world.resource::<SocietySettings>().memory_capacity;
if let Some(mut memories) = world.get_mut::<crate::mind::Memories>(person) {
memories.record(
crate::mind::Memory {
day: now as f32,
kind: crate::mind::MemoryKind::Interaction {
id: interaction,
accepted,
initiated,
},
other: Some(other),
valence: valence.clamp(-1.0, 1.0),
importance: importance.clamp(0.0, 1.0),
},
now,
capacity,
);
}
}
fn expose(world: &mut World, holder: Entity, other: Entity, settings: &SocietySettings, now: f64) {
let bias = {
let mine = world
.get::<Memberships>(holder)
.cloned()
.unwrap_or_default();
let theirs = world.get::<Memberships>(other).cloned().unwrap_or_default();
let defs = world.resource::<SocietyDefs>().clone();
crate::groups::shared_faction_bias(&mine, &theirs, &defs)
};
let dt = world_dt(world);
let tuning = settings.tuning.clone();
if let Some(mut ties) = world.get_mut::<Ties>(holder) {
let friends = ties.friend_count(&tuning);
crate::relationships::expose_toward(
&mut ties, other, bias, now as f32, dt, friends, &tuning,
);
}
}
fn world_dt(world: &World) -> f32 {
world.resource::<SocietyClock>().dt_days
}
fn bark(
world: &mut World,
spec: &crate::defs::InteractionSpec,
actor: Entity,
target: Entity,
accepted: bool,
settings: &SocietySettings,
) {
let chance = settings.bark_chance;
let actor_roll = world.resource_mut::<crate::SocietyRng>().0.unit() as f32;
if actor_roll < chance && !spec.barks.actor.is_empty() {
let index = world
.resource_mut::<crate::SocietyRng>()
.0
.below(spec.barks.actor.len());
let line = spec.barks.actor[index].clone();
say(world, actor, target, &line);
}
let lines = if accepted {
spec.barks.accepted.clone()
} else {
spec.barks.rejected.clone()
};
let target_roll = world.resource_mut::<crate::SocietyRng>().0.unit() as f32;
if target_roll < chance && !lines.is_empty() {
let index = world
.resource_mut::<crate::SocietyRng>()
.0
.below(lines.len());
let line = lines[index].clone();
say(world, target, actor, &line);
}
}
fn say(world: &mut World, speaker: Entity, target: Entity, template: &str) {
let name_of = |entity: Entity| {
world
.get::<Name>(entity)
.map_or_else(|| "someone".to_owned(), |n| n.as_str().to_owned())
};
let place = match world.get::<Doing>(speaker).map(|d| d.target) {
Some(DoingTarget::Place(place)) => world
.get::<Name>(place)
.map_or_else(|| "somewhere".to_owned(), |n| n.as_str().to_owned()),
_ => "somewhere".to_owned(),
};
let text = template
.replace("{self}", &name_of(speaker))
.replace("{target}", &name_of(target))
.replace("{place}", &place);
let feeling = world.get::<Emotions>(speaker).map_or(
crate::interact::Feeling::Neutral,
crate::interact::Feeling::from_emotions,
);
world.write_message(crate::interact::SocietyBark {
speaker,
text,
thought: false,
feeling,
});
}
fn condition_holds(
world: &mut World,
defs: &SocietyDefs,
condition: crate::script::ScriptId,
actor: Entity,
target: Entity,
) -> bool {
let Some(snapshot) = world
.get_resource::<crate::snapshot::SnapshotCell>()
.and_then(|cell| cell.0.clone())
else {
return false;
};
let engine = match world.remove_resource::<crate::script::ScriptEngine>() {
Some(engine) => engine,
None => return false,
};
let bindings = crate::script::Bindings {
actor: person_id(world, actor),
target: person_id(world, target),
..crate::script::Bindings::default()
};
let outcome = engine.eval_bool(&defs.scripts, condition, &snapshot, bindings);
world.insert_resource(engine);
match outcome {
Ok(holds) => holds,
Err(err) => {
let owner = crate::script::ScriptEngine::owner(&defs.scripts, condition).to_owned();
error!("bevy_society: script {owner} failed: {err}");
world.write_message(crate::script::ScriptError {
owner,
message: err,
});
false
}
}
}
fn run_effect(world: &mut World, script: crate::script::ScriptId, actor: Entity, target: Entity) {
let Some(defs) = world.get_resource::<SocietyDefs>().cloned() else {
return;
};
let Some(snapshot) = world
.get_resource::<crate::snapshot::SnapshotCell>()
.and_then(|cell| cell.0.clone())
else {
return;
};
let Some(engine) = world.remove_resource::<crate::script::ScriptEngine>() else {
return;
};
let bindings = crate::script::Bindings {
actor: person_id(world, actor),
target: person_id(world, target),
..crate::script::Bindings::default()
};
let outcome = engine.run(&defs.scripts, script, &snapshot, bindings);
world.insert_resource(engine);
match outcome {
Ok(actions) => {
if let Some(mut pending) = world.get_resource_mut::<crate::core::PendingActions>() {
pending.0.extend(actions);
}
}
Err(err) => {
let owner = crate::script::ScriptEngine::owner(&defs.scripts, script).to_owned();
error!("bevy_society: script {owner} failed: {err}");
world.write_message(crate::script::ScriptError {
owner,
message: err,
});
}
}
}
fn person_id(world: &World, entity: Entity) -> i64 {
world
.get_resource::<PersonIndex>()
.and_then(|index| {
index
.sorted
.iter()
.find(|(_, e)| *e == entity)
.map(|(id, _)| i64::from(id.0))
})
.unwrap_or(-1)
}
fn activity_barks(
mut starts: MessageReader<ActivityStarted>,
defs: Res<SocietyDefs>,
settings: Res<SocietySettings>,
mut rng: ResMut<crate::SocietyRng>,
names: Query<&Name>,
doing: Query<&Doing>,
mut barks: MessageWriter<crate::interact::SocietyBark>,
) {
for start in starts.read() {
let Some(spec) = defs.activity_id(&start.activity) else {
continue;
};
let Some(activity) = defs.activities.get(usize::from(spec.0)) else {
continue;
};
if activity.barks.is_empty() || (rng.0.unit() as f32) >= settings.bark_chance {
continue;
}
let line = activity.barks[rng.0.below(activity.barks.len())].clone();
let of = |entity: Entity| {
names
.get(entity)
.map_or_else(|_| "someone".to_owned(), |n| n.as_str().to_owned())
};
let place = doing
.get(start.person)
.ok()
.and_then(|d| match d.target {
DoingTarget::Place(place) => names.get(place).ok(),
_ => None,
})
.map_or_else(|| "somewhere".to_owned(), |n| n.as_str().to_owned());
let mut text = line
.replace("{self}", &of(start.person))
.replace("{place}", &place);
if let Some(target) = start.target {
text = text.replace("{target}", &of(target));
}
barks.write(crate::interact::SocietyBark {
speaker: start.person,
text,
thought: true,
feeling: crate::interact::Feeling::from_emotions(&crate::mind::Emotions::default()),
});
}
}
fn report_to_triggers(
mut exchanges: MessageReader<InteractionResolved>,
mut activities: MessageReader<ActivityStarted>,
defs: Option<Res<SocietyDefs>>,
index: Option<Res<PersonIndex>>,
mut queue: Option<ResMut<crate::rules::TriggerQueue>>,
) {
let (Some(defs), Some(index), Some(queue)) = (defs, index, queue.as_mut()) else {
for _ in exchanges.read() {}
for _ in activities.read() {}
return;
};
let id_of = |entity: Entity| {
index
.sorted
.iter()
.find(|(_, e)| *e == entity)
.map_or(-1, |(id, _)| i64::from(id.0))
};
for exchange in exchanges.read() {
let Some(id) = defs.interaction_id(&exchange.interaction) else {
continue;
};
queue.0.push(crate::rules::TriggerEvent::Interaction {
interaction: id,
actor: id_of(exchange.actor),
target: id_of(exchange.target),
accepted: exchange.accepted,
});
}
for activity in activities.read() {
let Some(id) = defs.activity_id(&activity.activity) else {
continue;
};
queue.0.push(crate::rules::TriggerEvent::Activity {
activity: id,
actor: id_of(activity.person),
target: activity.target.map_or(-1, id_of),
});
}
}
#[derive(bevy::ecs::system::SystemParam)]
struct ContactGossipWorld<'w> {
clock: Res<'w, SocietyClock>,
settings: Res<'w, SocietySettings>,
perception: Res<'w, Perception>,
index: Res<'w, PersonIndex>,
book: Res<'w, RumorBook>,
defs: Res<'w, SocietyDefs>,
queue: Option<ResMut<'w, InteractQueue>>,
}
fn contact_gossip(
world: ContactGossipWorld,
knowledge: Query<&crate::Knowledge>,
mut ties: Query<&mut Ties>,
) {
let ContactGossipWorld {
clock,
settings,
perception,
index,
book,
defs,
queue,
} = world;
let Some(mut queue) = queue else {
return;
};
let Some(gossip_id) = defs.interaction_id("gossip") else {
return;
};
let tuning = &settings.tuning;
let now = clock.now_days as f32;
for (i, &(_, entity)) in index.sorted.iter().enumerate() {
let Ok(mine) = knowledge.get(entity) else {
continue;
};
let Some(contacts) = perception.contact.get(i) else {
continue;
};
for &j in contacts {
let Some(&other) = perception.persons.get(j as usize) else {
continue;
};
let Ok(theirs) = knowledge.get(other) else {
continue;
};
let telling = mine.best_telling(now, |rumor| {
let entry = book.get(rumor)?;
if !entry.alive || entry.subject == other || theirs.knows(rumor) {
return None;
}
Some(entry.valence)
});
if telling.is_none() {
continue;
}
let Ok(mut owned) = ties.get_mut(entity) else {
continue;
};
let Some(tie) = owned.get_mut(other) else {
continue;
};
if now - tie.last_contact_day < tuning.contact_gossip_cooldown_days {
continue;
}
tie.last_contact_day = now;
queue.0.push(crate::interact::Interact {
actor: entity,
target: other,
interaction: "gossip".to_owned(),
});
let _ = gossip_id;
}
}
}
#[cfg(test)]
mod crowding_tests {
use super::*;
fn place(capacity: u32, used: u32) -> PlaceView {
PlaceView {
entity: Entity::from_bits(7),
tags: Vec::new(),
capacity,
used,
position: Vec2::ZERO,
}
}
#[test]
fn a_place_with_room_is_free_and_a_full_one_is_priced() {
let views = [place(4, 3)];
let target = DoingTarget::Place(Entity::from_bits(7));
assert_eq!(
crowding_price(target, &views, 0.6),
1.0,
"three of four seats is a place with room: no price"
);
assert!(
(crowding_price(
DoingTarget::Place(Entity::from_bits(7)),
&[place(4, 4)],
0.6
) - 0.4)
.abs()
< 1.0e-6,
"a full place must cost the penalty"
);
}
#[test]
fn the_price_is_a_step_and_never_a_wall() {
let target = DoingTarget::Place(Entity::from_bits(7));
let full = crowding_price(target, &[place(2, 12)], 0.6);
assert!(
(full - 0.4).abs() < 1.0e-6,
"twelve people at two seats costs the same as two: {full}"
);
assert!(
crowding_price(target, &[place(2, 12)], 0.99) > 0.0,
"a price below 1.0 must leave the option reachable, however full the place"
);
}
#[test]
fn a_zero_penalty_changes_nothing() {
for target in [
DoingTarget::Place(Entity::from_bits(7)),
DoingTarget::Place(Entity::from_bits(9)),
DoingTarget::Itself,
DoingTarget::Point(Vec2::new(1.0, 2.0)),
] {
assert_eq!(crowding_price(target, &[place(1, 99)], 0.0), 1.0);
}
}
#[test]
fn a_non_place_target_is_free() {
let views = [place(1, 1)];
for target in [DoingTarget::Itself, DoingTarget::Point(Vec2::new(3.0, 4.0))] {
assert_eq!(crowding_price(target, &views, 0.9), 1.0);
}
}
}