diff --git a/src/simulator/crates/mc-state/src/game_state.rs b/src/simulator/crates/mc-state/src/game_state.rs index af80c4e1..4218dfc9 100644 --- a/src/simulator/crates/mc-state/src/game_state.rs +++ b/src/simulator/crates/mc-state/src/game_state.rs @@ -1051,6 +1051,64 @@ pub struct PlayerState { /// correct for a save that hasn't run the recompute pass yet. #[serde(default)] pub derived_stats: mc_core::DerivedStats, + + // ── p3-26 B1: Happiness + Golden Age (headless turn) ───────────────── + // + // These fields mirror the GDScript Player fields of the same name so that + // `mc-turn::happiness_phase::process_happiness_phase` can operate entirely + // in Rust without a GDScript round-trip. `#[serde(default)]` keeps all + // pre-p3-26 saves loading: happiness/golden-age start at the safe neutral + // values (happiness=0, no active golden age) and are recomputed from the + // first turn that calls `process_happiness_phase`. + + /// Current net happiness pool (positive = happy, negative = unhappy). + /// Recomputed every turn by `process_happiness_phase`. + #[serde(default)] + pub happiness: i32, + + /// Human-readable label for the current happiness tier: + /// `"ecstatic"` / `"happy"` / `"content"` / `"unhappy"` / `"revolt"`. + #[serde(default)] + pub happiness_status: String, + + /// Racial growth tier string loaded from the player's race JSON + /// (`growth_tier` field in `races.json`). Defaults to `"balanced"`. + #[serde(default)] + pub growth_tier: String, + + /// Tile- and trade-sourced luxury resource IDs the player currently + /// controls, keyed by luxury ID. Each value is the + /// `happiness_per_unique_copy` from the deposit JSON (0 = use the + /// `LUXURY_HAPPINESS` fallback of 4). Populated by the GDExt bridge + /// at game start and updated each turn when tiles change hands. + /// In the headless bench path this is populated by `process_trade_phase` + /// (traded luxuries) and left empty for tile-based luxuries — the bench + /// does not run a tile-ownership index. + #[serde(default)] + pub owned_luxuries: BTreeMap, + + /// True while a Golden Age is in progress. + #[serde(default)] + pub golden_age_active: bool, + + /// Remaining turns in the current Golden Age. 0 when inactive. + #[serde(default)] + pub golden_age_turns: i32, + + /// Accumulated happiness surplus toward the next Golden Age. + #[serde(default)] + pub golden_age_progress: i32, + + /// Number of Golden Ages this player has completed (raises the meter size + /// for subsequent golden ages by `GOLDEN_AGE_METER_INCREASE` each). + #[serde(default)] + pub golden_age_count: i32, + + /// Pre-summed flat building happiness bonus for this player. Written by the + /// GDExt bridge each turn from building effect summation; `0` in the headless + /// bench (no building registry available at that level). + #[serde(default)] + pub building_happiness: i32, } /// Standing order for units that arrive at a rally point. diff --git a/src/simulator/crates/mc-turn/Cargo.toml b/src/simulator/crates/mc-turn/Cargo.toml index 31f47772..2fee7323 100644 --- a/src/simulator/crates/mc-turn/Cargo.toml +++ b/src/simulator/crates/mc-turn/Cargo.toml @@ -24,6 +24,7 @@ mc-replay = { path = "../mc-replay" } mc-comms = { path = "../mc-comms" } mc-observation = { path = "../mc-observation" } mc-profiling = { path = "../mc-profiling" } +mc-happiness = { path = "../mc-happiness" } serde.workspace = true serde_json.workspace = true wgpu = { version = "24", optional = true } @@ -32,7 +33,6 @@ bytemuck = { version = "1", features = ["derive"], optional = true } [dev-dependencies] proptest = "1" -mc-happiness = { path = "../mc-happiness" } rand.workspace = true # Used by tests/abstract_projection.rs to read raw bytes of the POD # returned by `to_abstract_rollout_state` for byte-identical assertions. diff --git a/src/simulator/crates/mc-turn/src/happiness_phase.rs b/src/simulator/crates/mc-turn/src/happiness_phase.rs new file mode 100644 index 00000000..07c0a2cb --- /dev/null +++ b/src/simulator/crates/mc-turn/src/happiness_phase.rs @@ -0,0 +1,259 @@ +//! p3-26 B1 — Happiness + Golden Age tick for the headless turn processor. +//! +//! Mirrors `happiness.gd::process_turn` + `turn_processor.gd::_process_golden_age`. +//! All simulation logic lives here; GDScript only gathers inputs (Rail-1). +//! +//! # Phase ordering +//! +//! Happiness is phase 6 in the canonical turn sequence: +//! 1. Food → growth check +//! 2. Production → queue progress, unit/building completion +//! 3. Gold → income minus upkeep; deficit disbanding +//! 4. Science → current tech accumulation +//! 5. Culture → border expansion check +//! 6. **Happiness → global pool update, Golden Age check** ← this module +//! 7. Mana → pool accumulation (magic-dev owns this) +//! 8. Victory → check all conditions +//! 9. Era progression → milestone check +//! +//! # Headless bench limitations +//! +//! The full GDScript path collects tile-sourced luxury IDs by walking each +//! city's `owned_tiles` against a `GameMap`. The headless turn has no +//! per-tile ownership index, so `PlayerState::owned_luxuries` is only +//! populated by the GDExt bridge (live game) or by the trade phase +//! (`traded_luxuries` ⊆ owned_luxuries after `process_trade_phase`). +//! Building happiness effects (`building_happiness_effects`, +//! `happiness_per_city_effects`) are likewise written by the bridge; both +//! default to empty so the headless bench runs with zero building bonus — +//! a safe neutral, not a silent error. +//! +//! Units-in-enemy-territory weariness requires tile ownership data that is +//! also absent in the headless path. War weariness defaults to 0 in the +//! bench; the live game bridge sets `units_in_enemy_territory` directly. + +use mc_happiness::{calculate_happiness, process_golden_age, GoldenAgeState, HappinessConfig, HappinessInput}; + +use crate::game_state::GameState; + +/// Process the happiness + golden-age tick for every player. +/// +/// For each `PlayerState`: +/// 1. Assembles a [`HappinessInput`] from the player's city count, total +/// population, owned luxuries, building effects, and growth tier. +/// 2. Calls [`calculate_happiness`] (mc-happiness) to get the new pool total +/// and status label, then writes them back to `PlayerState::happiness` / +/// `happiness_status`. +/// 3. Advances the golden-age meter via [`process_golden_age`], updating +/// `golden_age_active`, `golden_age_turns`, `golden_age_progress`, and +/// `golden_age_count`. +/// +/// This function does **not** call `step()` or any other phase — the parent +/// wires the call at the correct phase-6 slot in the turn sequence. +pub fn process_happiness_phase(state: &mut GameState) { + let config = HappinessConfig::default(); + + for player in &mut state.players { + let total_citizens: i32 = player + .cities + .iter() + .map(|c| i32::try_from(c.population).unwrap_or(i32::MAX)) + .sum(); + + let input = HappinessInput { + city_count: i32::try_from(player.cities.len()).unwrap_or(i32::MAX), + total_citizens, + // War weariness: headless bench has no tile-owner index; the live + // bridge writes this directly. Default 0 is safe — no weariness + // penalty in the bench path. + units_in_enemy_territory: 0, + // Pre-summed building bonus written by the GDExt bridge; 0 in bench. + building_happiness: player.building_happiness, + owned_luxuries: player.owned_luxuries.clone(), + growth_tier: if player.growth_tier.is_empty() { + "balanced".to_string() + } else { + player.growth_tier.clone() + }, + }; + + let breakdown = calculate_happiness(&input, &config); + player.happiness = breakdown.total; + player.happiness_status = breakdown.status; + + let mut ga_state = GoldenAgeState { + golden_age_active: player.golden_age_active, + golden_age_turns: player.golden_age_turns, + golden_age_progress: player.golden_age_progress, + golden_age_count: player.golden_age_count, + }; + process_golden_age(breakdown.total, &mut ga_state, &config); + player.golden_age_active = ga_state.golden_age_active; + player.golden_age_turns = ga_state.golden_age_turns; + player.golden_age_progress = ga_state.golden_age_progress; + player.golden_age_count = ga_state.golden_age_count; + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::game_state::{GameState, PlayerState}; + use mc_city::CityState; + use std::collections::BTreeMap; + + /// Build a minimal `PlayerState` with the given city/citizen counts and luxuries. + fn player_with( + cities: usize, + pop_each: u32, + luxuries: &[(&str, i32)], + growth_tier: &str, + ) -> PlayerState { + let mut p = PlayerState { + growth_tier: growth_tier.to_string(), + owned_luxuries: luxuries + .iter() + .map(|&(id, v)| (id.to_string(), v)) + .collect::>(), + ..PlayerState::default() + }; + for _ in 0..cities { + let mut c = CityState::default(); + c.population = pop_each; + p.cities.push(c); + } + p + } + + /// A player with surplus happiness (via many luxuries) accumulates golden-age progress. + #[test] + fn luxury_surplus_advances_golden_age_meter() { + let mut state = GameState::default(); + // balanced tier, 1 city, pop=1: city_unhappiness=3, citizen_unhappiness=1 + // base_unhappiness=4; luxuries: 4 * 4 = 16; total = 12 (happy) + let p = player_with( + 1, + 1, + &[("diamond", 4), ("emerald", 4), ("ruby", 4), ("jade", 4)], + "balanced", + ); + state.players.push(p); + + process_happiness_phase(&mut state); + + let player = &state.players[0]; + assert!( + player.happiness > 0, + "surplus luxuries should yield positive happiness, got {}", + player.happiness + ); + assert!( + player.golden_age_progress > 0, + "positive happiness should advance golden-age meter, got {}", + player.golden_age_progress + ); + assert!( + !player.golden_age_active, + "one turn of surplus should not immediately trigger a golden age" + ); + } + + /// A player with enough accumulated happiness surplus triggers a Golden Age. + #[test] + fn golden_age_triggers_when_meter_full() { + let mut state = GameState::default(); + // Preload progress to 95 so surplus of 12 pushes past meter=100. + let mut p = player_with( + 1, + 1, + &[("diamond", 4), ("emerald", 4), ("ruby", 4), ("jade", 4)], + "balanced", + ); + p.golden_age_progress = 95; + state.players.push(p); + + process_happiness_phase(&mut state); + + let player = &state.players[0]; + assert!(player.golden_age_active, "meter overflow should trigger golden age"); + assert_eq!(player.golden_age_turns, 10, "golden age lasts GOLDEN_AGE_DURATION=10 turns"); + assert_eq!(player.golden_age_count, 1); + } + + /// An active Golden Age ticks down each turn. + #[test] + fn active_golden_age_counts_down() { + let mut state = GameState::default(); + let mut p = player_with(1, 1, &[], "balanced"); + p.golden_age_active = true; + p.golden_age_turns = 3; + p.golden_age_count = 1; + state.players.push(p); + + process_happiness_phase(&mut state); + + let player = &state.players[0]; + assert!(player.golden_age_active, "golden age still active after one tick"); + assert_eq!(player.golden_age_turns, 2); + } + + /// A player with many cities and no luxuries is unhappy; status is "unhappy" or "revolt". + #[test] + fn no_luxuries_many_cities_gives_unhappy_status() { + let mut state = GameState::default(); + // 5 cities, pop=3 each, no luxuries — balanced tier. + // city_unhappiness = 5*3 = 15; citizen_unhappiness = 15*1 = 15; total = -30. + let p = player_with(5, 3, &[], "balanced"); + state.players.push(p); + + process_happiness_phase(&mut state); + + let player = &state.players[0]; + assert!( + player.happiness < 0, + "no luxuries + many cities should be unhappy, got {}", + player.happiness + ); + assert!( + player.happiness_status == "unhappy" || player.happiness_status == "revolt", + "status should be unhappy/revolt, got '{}'", + player.happiness_status + ); + assert!( + player.golden_age_progress == 0, + "negative happiness must not advance golden-age meter" + ); + } + + /// Multiple players are processed independently. + #[test] + fn multiple_players_processed_independently() { + let mut state = GameState::default(); + state.players.push(player_with(1, 1, &[("silk", 4)], "balanced")); + state.players.push(player_with(4, 5, &[], "concentrated")); + + process_happiness_phase(&mut state); + + // Player 0 has some luxury: likely happy or at least less negative. + // Player 1 (concentrated, 4 cities) is very unhappy. + let h0 = state.players[0].happiness; + let h1 = state.players[1].happiness; + assert!(h0 > h1, "luxury player ({h0}) should be happier than no-luxury heavy-city player ({h1})"); + } + + /// `growth_tier` defaults to "balanced" when the field is empty. + #[test] + fn empty_growth_tier_defaults_to_balanced() { + let mut state = GameState::default(); + // growth_tier left as "" (default) + let p = player_with(1, 1, &[], ""); + state.players.push(p); + + // Should not panic; balanced tier should be applied. + process_happiness_phase(&mut state); + + let player = &state.players[0]; + // city=1 (balanced 1.0 mult) + pop=1 (1.0 mult) = 1 + 3 = 4 base_unhappiness; no luxuries → -4 + assert_eq!(player.happiness, -4); + } +} diff --git a/src/simulator/crates/mc-turn/src/healing.rs b/src/simulator/crates/mc-turn/src/healing.rs new file mode 100644 index 00000000..1b466b8a --- /dev/null +++ b/src/simulator/crates/mc-turn/src/healing.rs @@ -0,0 +1,325 @@ +//! p3-26 B2 — Unit and city healing tick for the headless turn processor. +//! +//! Mirrors `turn_processor.gd::_process_healing` and `_process_city_healing` +//! (plus `TurnProcessorHelpersScript::_get_healing_rate`). +//! +//! # Healing rules (live GDScript source) +//! +//! **Units** heal at the end of a turn if and only if: +//! - `hp < max_hp` (already at full health → skip) +//! - The unit did not move this turn (`movement_remaining == base_moves`) +//! AND `is_fortified` acts as the additional eligibility signal in the +//! headless path (the live game also gates on `!unit.has_attacked`, but +//! `MapUnit` carries no `has_attacked` field; the headless convention is +//! that units with full movement remaining + fortified are treated as resting). +//! +//! Heal rates (from `_get_healing_rate`): +//! - Garrisoned in a friendly city tile: **20 HP base** + building garrison bonus. +//! In the headless bench the building bonus is 0 (no BuildingDef registry). +//! - Standing on any other friendly-player tile (approximated in the headless +//! path as: fortified + within 2 hexes of a friendly city centre): **15 HP**. +//! - Neutral territory (tile not owned or no grid): **10 HP**. +//! - Enemy territory: **5 HP**. +//! +//! In the headless bench, tile ownership is not tracked per-hex. The +//! territory classification falls back to: +//! 1. Unit hex matches a friendly `city_positions` entry → garrison. +//! 2. Unit is fortified → treat as friendly territory (15 HP). +//! 3. Otherwise → neutral territory (10 HP). +//! Enemy-territory penalty (5 HP) requires a tile-owner index not available +//! in the headless path and is therefore not applied here; it is applied by the +//! live GDExt bridge which has access to `GameMap`. +//! +//! **Cities** heal at 20 HP/turn up to `max_hp`. The bench `CityState` has no +//! `last_attacked_turn` tracking so the live game's siege-suppress window +//! (no heal within 3 turns of taking damage) is not applied here — the full +//! siege suppress lives in `mc_city::City::heal_per_turn` on the `City` struct +//! used by the GDExt bridge. The bench approximation (always heal when below +//! max) is acceptable for balance purposes. + +use crate::game_state::GameState; + +/// Garrison healing rate (HP/turn) for a unit standing on a friendly city tile. +const HEAL_GARRISON: i32 = 20; + +/// Heal rate for a unit in friendly territory (not garrisoned). +const HEAL_FRIENDLY: i32 = 15; + +/// Heal rate for a unit in neutral territory (headless default when territory +/// cannot be determined from tile ownership). +const HEAL_NEUTRAL: i32 = 10; + +/// Per-turn heal amount for bench cities. Mirrors the live game's +/// `CityState::heal_per_turn` rate; the siege-suppress window is not applied +/// in the bench path (bench `CityState` has no `last_attacked_turn` field). +const CITY_HEAL_PER_TURN: i32 = 20; + +/// Process per-turn healing for all units and cities of every player. +/// +/// **Units**: a unit heals when it meets the rest condition (full movement +/// remaining or fortified) and has HP below max. Heal amount depends on +/// territory (see module-level docs). +/// +/// **Cities**: heals at [`CITY_HEAL_PER_TURN`] HP/turn up to `max_hp`. +/// +/// This function does **not** call `step()` or any other phase — the parent +/// wires the call at the correct slot in the turn sequence. +pub fn process_healing_phase(state: &mut GameState) { + for player in &mut state.players { + // Snapshot city positions for garrison detection; `player` is borrowed + // mutably below for units, so we can't hold a reference to + // `player.city_positions` at the same time. + let city_positions: std::collections::HashSet<(i32, i32)> = + player.city_positions.iter().copied().collect(); + + // ── Unit healing ────────────────────────────────────────────────── + for unit in &mut player.units { + if unit.hp <= 0 || unit.hp >= unit.max_hp { + // Dead or already full health — skip. + continue; + } + + // Rest condition: unit must not have moved or attacked this turn. + // In the headless path the authoritative signal is `movement_remaining + // == base_moves` (unit took no move action). Captive units have + // movement_remaining=0 but also should not heal since they are pinned + // by a ransom offer — the `captive_of.is_some()` guard covers that. + if unit.captive_of.is_some() { + continue; + } + let at_full_movement = unit.base_moves == 0 + || unit.movement_remaining >= unit.base_moves; + + // Units that moved do not heal unless fortified. Fortified units + // are actively resting in prepared positions and heal regardless of + // movement consumed. + let can_heal = at_full_movement || unit.is_fortified; + if !can_heal { + continue; + } + + let heal_amount = unit_heal_rate(unit.col, unit.row, &city_positions, unit.is_fortified); + unit.hp = (unit.hp + heal_amount).min(unit.max_hp); + } + + // ── City healing ────────────────────────────────────────────────── + for city in &mut player.cities { + if city.hp > 0 && city.hp < city.max_hp { + city.hp = (city.hp + CITY_HEAL_PER_TURN).min(city.max_hp); + } + } + } +} + +/// Compute the healing rate (HP) for a unit at `(col, row)` in the headless bench. +/// +/// # Classification (headless approximation) +/// +/// - `(col, row)` is in `city_positions` → garrison (20 HP). +/// - `is_fortified` → friendly territory (15 HP). +/// - Otherwise → neutral territory (10 HP). +/// +/// The live game adds an enemy-territory branch (5 HP) that requires a +/// tile-owner index not available here. +fn unit_heal_rate( + col: i32, + row: i32, + city_positions: &std::collections::HashSet<(i32, i32)>, + is_fortified: bool, +) -> i32 { + if city_positions.contains(&(col, row)) { + return HEAL_GARRISON; + } + if is_fortified { + return HEAL_FRIENDLY; + } + HEAL_NEUTRAL +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::game_state::{GameState, MapUnit, PlayerState}; + use mc_city::CityState; + + fn state_with_player(player: PlayerState) -> GameState { + let mut state = GameState::default(); + state.players.push(player); + state + } + + fn unit_at(col: i32, row: i32, hp: i32, max_hp: i32) -> MapUnit { + MapUnit { + col, + row, + hp, + max_hp, + base_moves: 2, + movement_remaining: 2, // full movement = resting + ..MapUnit::default() + } + } + + // ── Unit healing tests ──────────────────────────────────────────────── + + /// A resting unit in a friendly city garrison heals at 20 HP/turn. + #[test] + fn unit_in_garrison_heals_at_garrison_rate() { + let mut p = PlayerState { + city_positions: vec![(3, 4)], + ..PlayerState::default() + }; + p.units.push(unit_at(3, 4, 50, 100)); + + let mut state = state_with_player(p); + process_healing_phase(&mut state); + + assert_eq!(state.players[0].units[0].hp, 70, "garrison heal = 20 HP"); + } + + /// A resting fortified unit NOT in a city heals at 15 HP/turn. + #[test] + fn fortified_unit_outside_city_heals_at_friendly_rate() { + let mut p = PlayerState::default(); + let mut unit = unit_at(1, 1, 40, 100); + unit.is_fortified = true; + p.units.push(unit); + + let mut state = state_with_player(p); + process_healing_phase(&mut state); + + assert_eq!(state.players[0].units[0].hp, 55, "fortified = 15 HP"); + } + + /// A resting non-fortified unit outside any city heals at 10 HP/turn. + #[test] + fn resting_unit_in_neutral_territory_heals_at_neutral_rate() { + let mut p = PlayerState::default(); + p.units.push(unit_at(7, 7, 30, 100)); + + let mut state = state_with_player(p); + process_healing_phase(&mut state); + + assert_eq!(state.players[0].units[0].hp, 40, "neutral territory = 10 HP"); + } + + /// A unit that moved this turn (`movement_remaining < base_moves` and not + /// fortified) does NOT heal. + #[test] + fn unit_that_moved_does_not_heal() { + let mut p = PlayerState::default(); + let mut unit = unit_at(5, 5, 50, 100); + unit.movement_remaining = 0; // spent all movement + unit.is_fortified = false; + p.units.push(unit); + + let mut state = state_with_player(p); + process_healing_phase(&mut state); + + assert_eq!(state.players[0].units[0].hp, 50, "moved unit must not heal"); + } + + /// A unit at full HP does not heal (no overflow). + #[test] + fn unit_at_full_hp_is_skipped() { + let mut p = PlayerState::default(); + p.units.push(unit_at(0, 0, 100, 100)); + + let mut state = state_with_player(p); + process_healing_phase(&mut state); + + assert_eq!(state.players[0].units[0].hp, 100, "full-hp unit must stay at max"); + } + + /// Healing is clamped at max_hp (no overflow beyond full health). + #[test] + fn healing_clamped_at_max_hp() { + let mut p = PlayerState { + city_positions: vec![(0, 0)], + ..PlayerState::default() + }; + // 5 HP below max in a garrison (20 HP heal). + p.units.push(unit_at(0, 0, 95, 100)); + + let mut state = state_with_player(p); + process_healing_phase(&mut state); + + assert_eq!( + state.players[0].units[0].hp, 100, + "healing must be clamped to max_hp" + ); + } + + /// A captive unit does not heal. + #[test] + fn captive_unit_does_not_heal() { + let mut p = PlayerState { + city_positions: vec![(1, 1)], + ..PlayerState::default() + }; + let mut unit = unit_at(1, 1, 30, 100); + unit.captive_of = Some(2); + p.units.push(unit); + + let mut state = state_with_player(p); + process_healing_phase(&mut state); + + assert_eq!(state.players[0].units[0].hp, 30, "captive unit must not heal"); + } + + // ── City healing tests ──────────────────────────────────────────────── + + /// A damaged city heals at CITY_HEAL_PER_TURN (20 HP) each turn. + /// + /// Note: bench `CityState` has no siege-suppress window; use the full + /// `mc_city::City::heal_per_turn` for that behaviour in the GDExt path. + #[test] + fn damaged_city_heals_per_turn() { + let mut p = PlayerState::default(); + let mut city = CityState::default(); + city.hp = 80; + city.max_hp = 200; + p.cities.push(city); + + let mut state = state_with_player(p); + process_healing_phase(&mut state); + + assert_eq!( + state.players[0].cities[0].hp, 100, + "city heals {CITY_HEAL_PER_TURN} HP/turn" + ); + } + + /// A city at max HP does not overheal. + #[test] + fn full_hp_city_is_not_overhealed() { + let mut p = PlayerState::default(); + let mut city = CityState::default(); + city.hp = 200; + city.max_hp = 200; + p.cities.push(city); + + let mut state = state_with_player(p); + process_healing_phase(&mut state); + + assert_eq!(state.players[0].cities[0].hp, 200, "full-hp city must not exceed max"); + } + + /// Healing is clamped at max_hp even when the heal amount would exceed it. + #[test] + fn city_healing_clamped_at_max_hp() { + let mut p = PlayerState::default(); + let mut city = CityState::default(); + city.hp = 195; // 5 below max; heal=20 → would go to 215, must clamp to 200 + city.max_hp = 200; + p.cities.push(city); + + let mut state = state_with_player(p); + process_healing_phase(&mut state); + + assert_eq!( + state.players[0].cities[0].hp, 200, + "city heal must be clamped to max_hp" + ); + } +} diff --git a/src/simulator/crates/mc-turn/src/lib.rs b/src/simulator/crates/mc-turn/src/lib.rs index 88bb47dd..35495b19 100644 --- a/src/simulator/crates/mc-turn/src/lib.rs +++ b/src/simulator/crates/mc-turn/src/lib.rs @@ -43,6 +43,10 @@ pub mod lair_siege; pub mod spatial_index; pub mod victory; pub mod courier_resolver; +/// p3-26 B1 — Happiness + Golden Age tick. +pub mod happiness_phase; +/// p3-26 B2 — Unit + city healing tick. +pub mod healing; #[cfg(feature = "gpu")] pub mod gpu;