Compare commits
1 commit
main
...
worktree-a
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2055e415d9 |
5 changed files with 647 additions and 1 deletions
|
|
@ -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<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.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
259
src/simulator/crates/mc-turn/src/happiness_phase.rs
Normal file
259
src/simulator/crates/mc-turn/src/happiness_phase.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
325
src/simulator/crates/mc-turn/src/healing.rs
Normal file
325
src/simulator/crates/mc-turn/src/healing.rs
Normal 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"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue