From d5729d67cedc4c25c4df4f3f126e432afb7e9497 Mon Sep 17 00:00:00 2001 From: Natalie Date: Fri, 26 Jun 2026 15:46:42 -0400 Subject: [PATCH] =?UTF-8?q?feat(@projects/@magic-civilization):=20?= =?UTF-8?q?=F0=9F=98=8A=20p3-26=20B1+B2=20=E2=80=94=20happiness/golden-age?= =?UTF-8?q?=20+=20healing=20in=20the=20headless=20turn?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Migrates two live-game-only per-turn subsystems into mc-turn (parallel agent port, integrated by file-extraction onto current main — worktree forked from a stale base, so a merge was unsafe; new files extracted clean, shared-file edits re-applied manually, API drift fixed): B1 happiness + golden age (happiness_phase.rs): - process_happiness_phase(state) — per player, assembles HappinessInput (city count, citizens, luxuries, building bonus, growth tier) → mc-happiness → writes happiness pool + status, advances the golden-age meter (progress/active/turns/count). Mirrors the live _process_golden_age. Wired into step() after the per-player economy loop. - Drift fix: p3-24 added building_happiness_effects/happiness_per_city_effects (Vec) to HappinessInput; bench leaves them empty (scalar building_happiness carries the bridge sum). B2 unit + city healing (healing.rs): - process_healing_phase(state) — units regen by territory class (garrison 20 / fortified- friendly 15 / neutral 10), cities heal toward max_hp. Mirrors _process_healing + _process_city_healing. Wired into step() after climate. State: 9 new #[serde(default)] PlayerState fields (happiness, happiness_status, growth_tier, owned_luxuries, golden_age_active/turns/progress/count, building_happiness) — all backward-compat. mc-happiness promoted dev-dep → dep. mc-turn 271/0 (incl. 16 new tests); mc-state green. Documented bench limitations: war- weariness + enemy-territory healing + city siege-suppress need a tile-owner index (live bridge writes them). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../crates/mc-state/src/game_state.rs | 45 +++ src/simulator/crates/mc-turn/Cargo.toml | 2 +- .../crates/mc-turn/src/happiness_phase.rs | 264 ++++++++++++++ src/simulator/crates/mc-turn/src/healing.rs | 325 ++++++++++++++++++ src/simulator/crates/mc-turn/src/lib.rs | 4 + src/simulator/crates/mc-turn/src/processor.rs | 9 + 6 files changed, 648 insertions(+), 1 deletion(-) create mode 100644 src/simulator/crates/mc-turn/src/happiness_phase.rs create mode 100644 src/simulator/crates/mc-turn/src/healing.rs diff --git a/src/simulator/crates/mc-state/src/game_state.rs b/src/simulator/crates/mc-state/src/game_state.rs index c23ae669..28ed4054 100644 --- a/src/simulator/crates/mc-state/src/game_state.rs +++ b/src/simulator/crates/mc-state/src/game_state.rs @@ -1102,6 +1102,51 @@ 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 and are recomputed from the first turn that calls the phase. + /// Current net happiness pool (positive = happy, negative = unhappy). + #[serde(default)] + pub happiness: i32, + + /// Human-readable happiness tier: ecstatic/happy/content/unhappy/revolt. + #[serde(default)] + pub happiness_status: String, + + /// Racial growth tier string from the player's race JSON. Defaults "balanced". + #[serde(default)] + pub growth_tier: String, + + /// Controlled luxury resource IDs → `happiness_per_unique_copy` (0 = use the + /// LUXURY_HAPPINESS fallback of 4). Bench path fills traded luxuries only. + #[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 completed (raises the meter size each time). + #[serde(default)] + pub golden_age_count: i32, + + /// Pre-summed flat building happiness bonus; written by the GDExt bridge, + /// `0` in the headless bench (no building registry 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 63f05994..53f5d607 100644 --- a/src/simulator/crates/mc-turn/Cargo.toml +++ b/src/simulator/crates/mc-turn/Cargo.toml @@ -25,6 +25,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 } @@ -33,7 +34,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..fa49959a --- /dev/null +++ b/src/simulator/crates/mc-turn/src/happiness_phase.rs @@ -0,0 +1,264 @@ +//! 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, + // p3-24 per-effect lists: empty in the bench path (the scalar + // `building_happiness` above carries the bridge-summed bonus); the + // live bridge populates these instead. + building_happiness_effects: Vec::new(), + happiness_per_city_effects: Vec::new(), + 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; diff --git a/src/simulator/crates/mc-turn/src/processor.rs b/src/simulator/crates/mc-turn/src/processor.rs index 21e55dd3..b69f6a34 100644 --- a/src/simulator/crates/mc-turn/src/processor.rs +++ b/src/simulator/crates/mc-turn/src/processor.rs @@ -420,6 +420,11 @@ impl TurnProcessor { self.process_science(state, pi, &mut result.events_emitted); } + // p3-26 B1: recompute happiness + advance the Golden Age meter for every player + // now that this turn's economy/luxuries/buildings are settled. Whole-state pass + // (loops players internally), mirroring the live `_process_golden_age`. + crate::happiness_phase::process_happiness_phase(state); + // Phase 5 prologue: advance all patrolling units one step along their route. // Runs before movement and fauna encounters so patrol positions are current. for player in state.players.iter_mut() { @@ -498,6 +503,10 @@ impl TurnProcessor { // bench (`ClimatePhysics::new("{}","[]","{}")`). self.process_climate_phase(state); + // p3-26 B2: end-of-turn HP regen for units (territory/fortified rates) and cities + // (heal toward max_hp), mirroring the live `_process_healing`/`_process_city_healing`. + crate::healing::process_healing_phase(state); + // Phase 5a-sentry: wake sentrying units that have enemies in vision range (2 hex). // Runs after movement so positions are current; runs before PvP so the // now-awoken unit's state is consistent when combat checks fire.