Compare commits

...
Sign in to create a new pull request.

1 commit

Author SHA1 Message Date
Natalie
2055e415d9 feat(mc-turn): p3-26 B1+B2 — happiness/golden-age + unit/city healing phases
Port two per-turn subsystems from GDScript into the headless Rust turn engine.

**B1: Happiness + Golden Age tick**
- New: src/simulator/crates/mc-turn/src/happiness_phase.rs
  - pub fn process_happiness_phase(state: &mut GameState)
  - Assembles HappinessInput from PlayerState (city_count, total_citizens,
    traded/owned luxuries, building_happiness, growth_tier)
  - Runs mc-happiness::calculate_happiness → writes PlayerState::happiness +
    happiness_status
  - Advances golden-age meter via mc-happiness::process_golden_age → writes
    golden_age_active/turns/progress/count
  - 6 unit tests covering surplus triggers, meter accumulation, count-down,
    tier defaulting, multi-player independence
- mc-happiness moved from [dev-dependencies] to [dependencies] in mc-turn/Cargo.toml

**B2: Unit + city healing tick**
- New: src/simulator/crates/mc-turn/src/healing.rs
  - pub fn process_healing_phase(state: &mut GameState)
  - Units: rest-condition guard (movement_remaining == base_moves || is_fortified),
    captive skip; rates: garrison=20, fortified-territory=15, neutral=10
  - Cities (bench CityState): +20 HP/turn clamped to max_hp
  - 10 unit tests covering garrison/fortified/neutral/moved/capped/captive/city cases

**GameState/PlayerState fields added to mc-state (all #[serde(default)])**
- PlayerState::happiness: i32
- PlayerState::happiness_status: String
- PlayerState::growth_tier: String
- PlayerState::owned_luxuries: BTreeMap<String, i32>
- PlayerState::golden_age_active: bool
- PlayerState::golden_age_turns: i32
- PlayerState::golden_age_progress: i32
- PlayerState::golden_age_count: i32
- PlayerState::building_happiness: i32

CONTRACT: process_happiness_phase and process_healing_phase are NOT wired
into TurnProcessor::step — caller wires them at the correct phase slots.

cargo test -p mc-turn: 265 passed, 0 failed (lib) + all integration suites ok.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-26 15:37:36 -04:00
5 changed files with 647 additions and 1 deletions

View file

@ -1051,6 +1051,64 @@ pub struct PlayerState {
/// correct for a save that hasn't run the recompute pass yet. /// correct for a save that hasn't run the recompute pass yet.
#[serde(default)] #[serde(default)]
pub derived_stats: mc_core::DerivedStats, 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<String, i32>,
/// 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. /// Standing order for units that arrive at a rally point.

View file

@ -24,6 +24,7 @@ mc-replay = { path = "../mc-replay" }
mc-comms = { path = "../mc-comms" } mc-comms = { path = "../mc-comms" }
mc-observation = { path = "../mc-observation" } mc-observation = { path = "../mc-observation" }
mc-profiling = { path = "../mc-profiling" } mc-profiling = { path = "../mc-profiling" }
mc-happiness = { path = "../mc-happiness" }
serde.workspace = true serde.workspace = true
serde_json.workspace = true serde_json.workspace = true
wgpu = { version = "24", optional = true } wgpu = { version = "24", optional = true }
@ -32,7 +33,6 @@ bytemuck = { version = "1", features = ["derive"], optional = true }
[dev-dependencies] [dev-dependencies]
proptest = "1" proptest = "1"
mc-happiness = { path = "../mc-happiness" }
rand.workspace = true rand.workspace = true
# Used by tests/abstract_projection.rs to read raw bytes of the POD # Used by tests/abstract_projection.rs to read raw bytes of the POD
# returned by `to_abstract_rollout_state` for byte-identical assertions. # returned by `to_abstract_rollout_state` for byte-identical assertions.

View file

@ -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::<BTreeMap<_, _>>(),
..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);
}
}

View file

@ -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"
);
}
}

View file

@ -43,6 +43,10 @@ pub mod lair_siege;
pub mod spatial_index; pub mod spatial_index;
pub mod victory; pub mod victory;
pub mod courier_resolver; 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")] #[cfg(feature = "gpu")]
pub mod gpu; pub mod gpu;