Repository: Leafwing-Studios/Emergence (rust)

Issue:
Units should die of old age
Interestingly, this isn't just a "realism" argument.

Consider the current situation, where units only die when they run out of food. The population increases at a constant rate, and stabilizes when we run out of food for them.

This means: 
- managing the population levels is tricky and unituitive: equilibrium is not controlled by the number of unit spawners, but the rate of food production
- stockpiling food is impossible

If instead units die of old age (or stochastically over time), we will reach an equilibrium proportional to the rate of unit production instead!

As long as this equilibrium is below the equilbrium induced by hunger alone, we'll be able to stockpile food, and population control will be much easier.


Ideally, we probably want to support between 10 and 20 units per unit spawning structure, to provide granular (and localized) population control.

Pull request description:
Add old age mechanic
Fixes #704.

Also cleaned up some signal production code for structures and ghosts since it was interfering with testing.

Patch:
diff --git a/emergence_game/assets/manifests/base_game.structure_manifest.json b/emergence_game/assets/manifests/base_game.structure_manifest.json
index f1d1b6a..682d856 100644
--- a/emergence_game/assets/manifests/base_game.structure_manifest.json
+++ b/emergence_game/assets/manifests/base_game.structure_manifest.json
@@ -33,10 +33,10 @@
 					]
 				},
 				"energy_pool": {
-					"current": 50.0,
-					"max": 50.0,
-					"warning_threshold": 12.5,
-					"satiation_threshold": 37.5,
+					"current": 300.0,
+					"max": 300.0,
+					"warning_threshold": 50.0,
+					"satiation_threshold": 225.0,
 					"regen_per_second": -1.0
 				}
 			},
diff --git a/emergence_game/assets/manifests/base_game.unit_manifest.json b/emergence_game/assets/manifests/base_game.unit_manifest.json
index 37e604b..fc31b64 100644
--- a/emergence_game/assets/manifests/base_game.unit_manifest.json
+++ b/emergence_game/assets/manifests/base_game.unit_manifest.json
@@ -21,6 +21,7 @@
         "energy": 50.0
       },
       "max_impatience": 10,
+      "max_age": 10.0,
       "wandering_behavior": {
         "wander_durations": [
           [
diff --git a/emergence_lib/src/construction/ghosts.rs b/emergence_lib/src/construction/ghosts.rs
index 0833b28..9cfb033 100644
--- a/emergence_lib/src/construction/ghosts.rs
+++ b/emergence_lib/src/construction/ghosts.rs
@@ -351,11 +351,13 @@ pub(super) fn ghost_structure_signals(
         With<Ghost>,
     >,
 ) {
-    // Ghosts that are ignored will slowly become more important to build.
     for (&structure_id, mut emitter, crafting_state, input_inventory, workers_present) in
         ghost_query.iter_mut()
     {
         if crafting_state.is_changed() {
+            // Reset any signals.
+            emitter.signals.clear();
+
             match *crafting_state {
                 CraftingState::NeedsInput => {
                     match input_inventory {
@@ -380,9 +382,6 @@ pub(super) fn ghost_structure_signals(
                     progress: _,
                     required: _,
                 } => {
-                    // Wipe out any pull signals as we've already got enough stuff.
-                    emitter.signals.clear();
-
                     if workers_present.needs_more() {
                         let workplace_id = WorkplaceId::structure(structure_id);
 
diff --git a/emergence_lib/src/signals.rs b/emergence_lib/src/signals.rs
index 2173eff..ca8ced2 100644
--- a/emergence_lib/src/signals.rs
+++ b/emergence_lib/src/signals.rs
@@ -19,7 +19,7 @@ use emergence_macros::IterableEnum;
 use itertools::Itertools;
 use leafwing_abilities::prelude::Pool;
 use rand::seq::SliceRandom;
-use std::ops::MulAssign;
+use std::ops::{Div, DivAssign, MulAssign};
 
 use crate::asset_management::manifest::Id;
 use crate::simulation::geometry::{Facing, MapGeometry, TilePos};
@@ -622,6 +622,20 @@ impl MulAssign<f32> for SignalStrength {
     }
 }
 
+impl Div<f32> for SignalStrength {
+    type Output = SignalStrength;
+
+    fn div(self, rhs: f32) -> Self::Output {
+        SignalStrength(self.0 / rhs)
+    }
+}
+
+impl DivAssign<f32> for SignalStrength {
+    fn div_assign(&mut self, rhs: f32) {
+        *self = *self / rhs
+    }
+}
+
 /// The component that causes a game object to emit a signal.
 ///
 /// This can change over time, and multiple signals may be emitted at once.
@@ -679,9 +693,10 @@ fn emit_signals(
         tile_pos: TilePos,
         emitter: &Emitter,
         modifier: &SignalModifier,
+        n_tiles: usize,
     ) {
         for (signal_type, signal_strength) in &emitter.signals {
-            let mut signal_strength = *signal_strength;
+            let mut signal_strength = *signal_strength / n_tiles as f32;
             signal_strength.apply_modifier(*modifier);
             signals.add_signal(*signal_type, tile_pos, signal_strength);
         }
@@ -697,16 +712,18 @@ fn emit_signals(
                     .footprint
                     .rotated(facing);
 
+                let n_tiles = footprint.set.len();
+
                 for tile_pos in footprint.in_world_space(center) {
                     let terrain_entity = map_geometry.get_terrain(tile_pos).unwrap();
                     let modifier = modifier_query.get(terrain_entity).unwrap();
-                    let cost = modifier.cost() * delta_time;
+                    let cost = modifier.cost() * delta_time / n_tiles as f32;
 
                     if intent_pool.current() >= cost {
                         intent_pool.expend(cost).unwrap();
                     }
 
-                    emit(&mut signals, tile_pos, emitter, modifier);
+                    emit(&mut signals, tile_pos, emitter, modifier, n_tiles);
                 }
             }
             None => {
@@ -718,7 +735,7 @@ fn emit_signals(
                     intent_pool.expend(cost).unwrap();
                 }
 
-                emit(&mut signals, center, emitter, modifier);
+                emit(&mut signals, center, emitter, modifier, 1);
             }
         }
     }
diff --git a/emergence_lib/src/simulation/generation.rs b/emergence_lib/src/simulation/generation.rs
index be37dfa..cde60a8 100644
--- a/emergence_lib/src/simulation/generation.rs
+++ b/emergence_lib/src/simulation/generation.rs
@@ -46,7 +46,7 @@ impl GenerationConfig {
     const MAP_RADIUS: u32 = 20;
 
     /// The number of ants in the default generation config
-    const N_ANT: usize = 5;
+    const N_ANT: usize = 12;
     /// The number of plants in the default generation config
     const N_PLANT: usize = 12;
     /// The number of fungi in the default generation config
diff --git a/emergence_lib/src/simulation/time.rs b/emergence_lib/src/simulation/time.rs
index e44b372..833fc7e 100644
--- a/emergence_lib/src/simulation/time.rs
+++ b/emergence_lib/src/simulation/time.rs
@@ -62,6 +62,11 @@ pub struct InGameTime {
 )]
 pub struct Days(pub f32);
 
+impl Days {
+    /// Represents a duration of 0 days.
+    pub const ZERO: Self = Days(0.);
+}
+
 impl Div<f32> for Days {
     type Output = Days;
 
@@ -101,6 +106,11 @@ impl InGameTime {
         // but 24 hour time begins at midnight.
         ((self.fraction_of_day() + 0.25) * 24.) % 24.
     }
+
+    /// Returns the configured number of seconds per day.
+    pub fn seconds_per_day(&self) -> f32 {
+        self.seconds_per_day
+    }
 }
 
 impl Display for InGameTime {
diff --git a/emergence_lib/src/ui/selection_details.rs b/emergence_lib/src/ui/selection_details.rs
index d33b5e9..3465f0f 100644
--- a/emergence_lib/src/ui/selection_details.rs
+++ b/emergence_lib/src/ui/selection_details.rs
@@ -421,6 +421,7 @@ fn get_details(
                 goal: unit_query_item.goal.clone(),
                 action: unit_query_item.action.clone(),
                 impatience_pool: unit_query_item.impatience_pool.clone(),
+                age: unit_query_item.age.clone(),
                 organism_details,
             })
         }
@@ -879,6 +880,7 @@ mod unit_details {
         terrain::terrain_manifest::TerrainManifest,
         units::{
             actions::CurrentAction,
+            age::Age,
             goals::Goal,
             hunger::Diet,
             impatience::ImpatiencePool,
@@ -906,6 +908,8 @@ mod unit_details {
         pub(super) action: &'static CurrentAction,
         /// How frustrated the unit is
         pub(super) impatience_pool: &'static ImpatiencePool,
+        /// The current and max age of this unit.
+        pub(super) age: &'static Age,
     }
 
     /// Detailed info about a given unit.
@@ -929,6 +933,8 @@ mod unit_details {
         pub(crate) organism_details: OrganismDetails,
         /// How frustrated the unit is
         pub(super) impatience_pool: ImpatiencePool,
+        /// The current and max age of this unit.
+        pub(super) age: Age,
     }
 
     impl UnitDetails {
@@ -953,6 +959,7 @@ mod unit_details {
             let organism_details = self
                 .organism_details
                 .display(structure_manifest, unit_manifest);
+            let age = &self.age;
 
             format!(
                 "Entity: {entity:?}
@@ -963,6 +970,7 @@ Holding: {held_item}
 Goal: {goal}
 Action: {action}
 Impatience: {impatience_pool}
+Age: {age}
 {organism_details}"
             )
         }
diff --git a/emergence_lib/src/units/age.rs b/emergence_lib/src/units/age.rs
new file mode 100644
index 0000000..1e5b7f0
--- /dev/null
+++ b/emergence_lib/src/units/age.rs
@@ -0,0 +1,74 @@
+//! Units should die of old age.
+//!
+//! As discussed in <https://github.com/Leafwing-Studios/Emergence/issues/704>,
+//! this enables players to more easily control their population size and stockpile food.
+
+use std::fmt::{Display, Formatter};
+
+use bevy::prelude::*;
+use rand::{rngs::ThreadRng, Rng};
+use serde::{Deserialize, Serialize};
+
+use crate::simulation::time::{Days, InGameTime};
+
+/// The age of a unit, in in-game days.
+#[derive(Component, Clone, Debug, PartialEq, Serialize, Deserialize)]
+pub struct Age {
+    /// The current age of the unit.
+    current: Days,
+    /// The maximum age of the unit.
+    max: Days,
+}
+
+impl Age {
+    /// Creates a new [`Age`] with the given maximum age.
+    pub fn newborn(max: Days) -> Self {
+        Self {
+            current: Days::ZERO,
+            max,
+        }
+    }
+
+    /// Creates a new [`Age`] with the given maximum age and a random current age.
+    pub fn randomized(rng: &mut ThreadRng, max: Days) -> Self {
+        Self {
+            current: Days(rng.gen::<f32>() * max.0),
+            max,
+        }
+    }
+
+    /// Returns the current age in [`Days`].
+    pub fn current(&self) -> Days {
+        self.current
+    }
+
+    /// Returns the maximum age in [`Days`].
+    pub fn max(&self) -> Days {
+        self.max
+    }
+}
+
+impl Display for Age {
+    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
+        write!(f, "{:.2}/{:.2} days", self.current.0, self.max.0)
+    }
+}
+
+/// Advances the age of all units by the elapsed time and kills them if they are too old.
+pub(super) fn aging(
+    mut commands: Commands,
+    fixed_time: Res<FixedTime>,
+    in_game_time: Res<InGameTime>,
+    mut query: Query<(&mut Age, Entity)>,
+) {
+    let delta_time = fixed_time.period.as_secs_f32();
+    let delta_days = Days(delta_time / in_game_time.seconds_per_day());
+
+    for (mut age, entity) in query.iter_mut() {
+        age.current += delta_days;
+
+        if age.current > age.max {
+            commands.entity(entity).despawn_recursive();
+        }
+    }
+}
diff --git a/emergence_lib/src/units/mod.rs b/emergence_lib/src/units/mod.rs
index 12c7979..f6c0ab4 100644
--- a/emergence_lib/src/units/mod.rs
+++ b/emergence_lib/src/units/mod.rs
@@ -19,6 +19,7 @@ use serde::{Deserialize, Serialize};
 
 use self::{
     actions::CurrentAction,
+    age::Age,
     goals::Goal,
     impatience::ImpatiencePool,
     item_interaction::UnitInventory,
@@ -29,6 +30,7 @@ use self::{
 use crate::organisms::OrganismBundle;
 
 pub(crate) mod actions;
+pub mod age;
 pub(crate) mod goals;
 pub mod hunger;
 pub(crate) mod impatience;
@@ -84,6 +86,8 @@ pub(crate) struct UnitBundle {
     held_item: UnitInventory,
     /// What signals is this unit emitting?
     emitter: Emitter,
+    /// The current and max age of the unit.
+    age: Age,
     /// Organism data
     organism_bundle: OrganismBundle,
     /// Makes units pickable
@@ -118,6 +122,7 @@ impl UnitBundle {
             emitter: Emitter {
                 signals: vec![(SignalType::Unit(unit_id), SignalStrength::new(1.))],
             },
+            age: Age::newborn(unit_data.max_age),
             organism_bundle: OrganismBundle::new(
                 unit_data.organism_variety.energy_pool,
                 unit_data.organism_variety.lifecycle,
@@ -146,6 +151,7 @@ impl UnitBundle {
         let scene_handle = unit_handles.scenes.get(&unit_id).unwrap();
         let mut energy_pool = unit_data.organism_variety.energy_pool;
         energy_pool.randomize(rng);
+        let age = Age::randomized(rng, unit_data.max_age);
 
         UnitBundle {
             unit_id,
@@ -158,6 +164,7 @@ impl UnitBundle {
             emitter: Emitter {
                 signals: vec![(SignalType::Unit(unit_id), SignalStrength::new(1.))],
             },
+            age,
             organism_bundle: OrganismBundle::new(energy_pool, unit_data.organism_variety.lifecycle),
             raycast_mesh: RaycastMesh::default(),
             mesh: unit_handles.picking_mesh.clone_weak(),
@@ -211,6 +218,7 @@ impl Plugin for UnitsPlugin {
                         .before(UnitSystem::ChooseNewAction)
                         // Make sure to overwrite any existing goal
                         .after(UnitSystem::ChooseGoal),
+                    age::aging,
                 )
                     .in_set(SimulationSet)
                     .in_schedule(CoreSchedule::FixedUpdate),
diff --git a/emergence_lib/src/units/unit_manifest.rs b/emergence_lib/src/units/unit_manifest.rs
index f97a204..02e5a4a 100644
--- a/emergence_lib/src/units/unit_manifest.rs
+++ b/emergence_lib/src/units/unit_manifest.rs
@@ -9,6 +9,7 @@ use serde::{Deserialize, Serialize};
 use crate::{
     asset_management::manifest::loader::IsRawManifest,
     organisms::{OrganismVariety, RawOrganismVariety},
+    simulation::time::Days,
     units::{hunger::Diet, WanderingBehavior},
 };
 
@@ -29,6 +30,8 @@ pub struct UnitData {
     pub diet: Diet,
     /// How much impatience this unit can accumulate before getting too frustrated and picking a new task.
     pub max_impatience: u8,
+    /// How long can this unit go without eating before it dies?
+    pub max_age: Days,
     /// How many actions will units of this type take while wandering before picking a new goal?
     ///
     /// This stores a [`WeightedIndex`](rand::distributions::WeightedIndex) to allow for multimodal distributions.
@@ -44,6 +47,8 @@ pub struct RawUnitData {
     pub diet: RawDiet,
     /// How much impatience this unit can accumulate before getting too frustrated and picking a new task.
     pub max_impatience: u8,
+    /// How long can this unit go without eating before it dies?
+    pub max_age: f32,
     /// How many actions will units of this type take while wandering before picking a new goal?
     ///
     /// This stores a [`WeightedIndex`](rand::distributions::WeightedIndex) to allow for multimodal distributions.
@@ -52,10 +57,17 @@ pub struct RawUnitData {
 
 impl From<RawUnitData> for UnitData {
     fn from(raw: RawUnitData) -> Self {
+        assert!(
+            raw.max_age > 0.0,
+            "Unit max age must be positive (got {})",
+            raw.max_age
+        );
+
         Self {
             organism_variety: raw.organism_variety.into(),
             diet: raw.diet.into(),
             max_impatience: raw.max_impatience,
+            max_age: Days(raw.max_age),
             wandering_behavior: raw.wandering_behavior,
         }
     }


Test patch:
diff --git a/emergence_lib/tests/serde_manifests.rs b/emergence_lib/tests/serde_manifests.rs
index 7862ac1..4eb370b 100644
--- a/emergence_lib/tests/serde_manifests.rs
+++ b/emergence_lib/tests/serde_manifests.rs
@@ -108,6 +108,7 @@ fn can_serialize_unit_manifest() {
                         (8, 0.2),
                         (16, 0.1),
                     ]),
+                    max_age: 10.,
                 },
             ),
             (
@@ -121,6 +122,7 @@ fn can_serialize_unit_manifest() {
                     diet: RawDiet::new("acacia_leaf", 0.),
                     max_impatience: 0,
                     wandering_behavior: WanderingBehavior::from_iter([(0, 0.7), (16, 0.1)]),
+                    max_age: 0.2,
                 },
             ),
         ]),