diff --git a/src/simulator/crates/mc-turn/src/game_state.rs b/src/simulator/crates/mc-turn/src/game_state.rs index f0ecf8fa..24ec191e 100644 --- a/src/simulator/crates/mc-turn/src/game_state.rs +++ b/src/simulator/crates/mc-turn/src/game_state.rs @@ -1,7 +1,7 @@ //! Game state types — data only, no logic. All mutation happens through //! `TurnProcessor::step`. -use mc_ai::evaluator::ScoringWeights; +use mc_core::ScoringWeights; use mc_replay; use mc_city::CityState; use mc_combat::StatusEffect; @@ -359,13 +359,13 @@ pub struct GameState { /// tier-1-fallback behaviour. `#[serde(skip)]` because the catalog is /// boot-loaded, not save-persisted. #[serde(skip)] - pub ai_unit_catalog: Vec, + pub ai_unit_catalog: Vec, /// p2-71: tactical-AI view of the producible-building catalog. Companion /// to `ai_unit_catalog` — populated by the harness via /// `GdPlayerApi::set_buildings_catalog_json`. Empty falls back to the /// no-building behaviour. #[serde(skip)] - pub ai_building_catalog: Vec, + pub ai_building_catalog: Vec, /// p2-71: difficulty multiplier projected into /// `TacticalState::difficulty_threshold_mult`. Defaults to 1.0 /// (normal). Populated by `GdPlayerApi::set_difficulty_threshold_mult`. @@ -769,7 +769,7 @@ pub struct PlayerState { /// fall-through to axis-driven multipliers — fixtures predating p1-42b /// keep their current behaviour without any changes. #[serde(default)] - pub building_priors: mc_ai::tactical::state::BuildingPriors, + pub building_priors: mc_core::tactical_types::BuildingPriors, /// Stage 3 (mod system) — controller registry id used by /// `mc_player_api::dispatch::drive_ai_slot` to look up which /// `AiController` impl decides this slot's turn. Defaults to @@ -788,7 +788,7 @@ pub struct PlayerState { /// (at most one no-lock turn) after a save/load rather than persisted into /// the save format, keeping the `mc-save` contract unchanged. #[serde(skip)] - pub tactical_memory: mc_ai::tactical::TacticalMemory, + pub tactical_memory: mc_core::tactical_types::TacticalMemory, /// Accumulated expansion capacity (earned from the `expansion` axis). pub expansion_points: u32, /// Per-city list of constructed building IDs. Aligned with `cities`. diff --git a/src/simulator/crates/mc-turn/src/processor.rs b/src/simulator/crates/mc-turn/src/processor.rs index 0ada99b2..8b7cad73 100644 --- a/src/simulator/crates/mc-turn/src/processor.rs +++ b/src/simulator/crates/mc-turn/src/processor.rs @@ -1366,6 +1366,66 @@ impl TurnProcessor { // ── Phase 4: Unit production ─────────────────────────────────────────── + /// Resolve a spawning unit's base combat stat-line (`hp`, `max_hp`, + /// `attack`, `defense`) from the units catalog, then apply the + /// production-`QualityTier` stat delta when one was stamped. + /// + /// Before this, `try_spawn_unit` hardcoded `60/12/1` (the dwarf_warrior + /// stat-line) onto **every** unit type — a queued non-warrior spawned with + /// warrior stats (latent bug). The base line now comes from the unit's own + /// `units/.json` (`mc_units::UnitsCatalog`, p2-57c catalog extension). + /// + /// When `quality` is `Some`, the stamped tier's delta is applied via + /// `crate::quality::apply_quality`, reading the global default rule from + /// `state.combat_balance.quality_deltas` (Rail 2 — data-driven, p2-57c). + /// `quality == None` (bench / non-recipe spawn) leaves the base line as-is. + /// + /// Fallback: if the catalog has no entry for `unit_id` (empty bench + /// catalogs, or the 3 known building→unit referential gaps), the prior + /// `60/12/1` stat-line is retained so existing transcript fixtures keep + /// shipping units. `max_hp` is the unit's resolved full-health ceiling. + fn resolve_spawn_combat( + &self, + state: &GameState, + unit_id: &str, + quality: Option, + ) -> (i32, i32, i32, i32) { + // Base line from the catalog, else the legacy warrior fallback so + // catalog-less bench fixtures still ship units. + let base = match state.units_catalog.get(unit_id) { + Some(s) if s.combat != mc_units::CombatStats::default() => UnitStats { + hp: s.combat.hp, + max_hp: s.combat.resolved_max_hp(), + attack: s.combat.attack, + defense: s.combat.defense, + ranged_attack: s.combat.ranged_attack, + range: s.combat.range, + movement: s.base_moves, + }, + // No catalog entry, or an entry with a zeroed combat block: keep + // the historical warrior stat-line so transcript fixtures that + // never load a catalog are unchanged. + _ => UnitStats { + hp: 60, + max_hp: 60, + attack: 12, + defense: 1, + ranged_attack: 0, + range: 0, + movement: 2, + }, + }; + let stats = match quality { + Some(tier) => crate::quality::apply_quality( + base, + tier, + &state.combat_balance.quality_deltas, + ), + None => base, + }; + (stats.hp, stats.max_hp, stats.attack, stats.defense) + } + fn try_spawn_unit(&self, state: &mut GameState, pi: usize, result: &mut TurnResult) { use mc_city::Queueable; @@ -1496,6 +1556,17 @@ impl TurnProcessor { } } }; + // Resolve base combat stats from the catalog (by the queued + // unit_kind, not a hardcoded warrior line) and apply the stamped + // production quality if any. Computed under an immutable `state` + // borrow before the mutable `player` borrow below. The live spawn + // path carries no stamp source yet (the per-city typed stockpile + + // per-unit gating that would set `quality` is out of this lane — + // p2-57a/b), so `quality` is `None` here today and the resolver's + // value is the base-stat-by-id fix; the `apply_quality` branch is + // exercised by `spawn_unit_typed` + the integration tests. + let (u_hp, u_max_hp, u_atk, u_def) = + self.resolve_spawn_combat(state, &unit_kind, None); let player = &mut state.players[pi]; player.cities[city_idx].production_stored -= cost; // If the city was explicitly queueing a unit, the queue head @@ -1512,10 +1583,10 @@ impl TurnProcessor { id: uid, col: pos.0, row: pos.1, - hp: 60, - max_hp: 60, - attack: 12, - defense: 1, + hp: u_hp, + max_hp: u_max_hp, + attack: u_atk, + defense: u_def, is_fortified: false, is_sentrying: false, unit_id: unit_kind.clone(), @@ -1581,10 +1652,25 @@ impl TurnProcessor { .get(city_idx) .copied() .unwrap_or((0, 0)); + // p2-57c: if the caller stamped a production-quality tier, resolve the + // unit's base combat line from the catalog and apply the band's delta + // here so the spawned unit's `attack`/`defense`/`max_hp` actually carry + // the quality adjustment (the `MapUnit.quality` field doc's contract). + // This is the live spawn path that proves the apply-layer end-to-end. + // `quality == None` leaves the caller-supplied stats untouched. + let quality_combat = stats + .quality + .map(|tier| self.resolve_spawn_combat(state, unit_id, Some(tier))); let player = &mut state.players[pi]; player.cities[city_idx].production_stored -= cost; debit_resources(requires, &mut player.strategic_ledger); let mut unit = stats; + if let Some((q_hp, q_max_hp, q_atk, q_def)) = quality_combat { + unit.hp = q_hp; + unit.max_hp = q_max_hp; + unit.attack = q_atk; + unit.defense = q_def; + } unit.id = uid; unit.unit_id = unit_id.to_string(); unit.held_resources = requires.to_vec(); diff --git a/src/simulator/crates/mc-turn/tests/quality_spawn_live_processor.rs b/src/simulator/crates/mc-turn/tests/quality_spawn_live_processor.rs new file mode 100644 index 00000000..04e8ab6c --- /dev/null +++ b/src/simulator/crates/mc-turn/tests/quality_spawn_live_processor.rs @@ -0,0 +1,230 @@ +//! p2-57c bullet 2 + p2-57b pipeline — quality consumer wired into the LIVE +//! spawn path (not just the standalone `quality_spawn_divergence.rs` pipeline +//! test). +//! +//! Two things are proven here, both *through `TurnProcessor`*, not via the +//! free functions in isolation: +//! +//! 1. **Base-stat-by-id resolution (latent-bug fix).** `try_spawn_unit` +//! previously hardcoded `60/12/1` onto every spawned unit regardless of +//! type, so a queued non-warrior spawned with warrior stats. With the +//! `mc_units::UnitsCatalog` combat-stat extension + `resolve_spawn_combat`, +//! a city queueing unit type A and a city queueing unit type B now spawn +//! units carrying A's and B's *own* `units/.json` base lines. +//! +//! 2. **Apply-quality in the live spawn path.** `spawn_unit_typed` resolves the +//! base line from the catalog and applies the stamped `QualityTier` delta +//! (`combat_balance.quality_deltas`, Rail 2) so a Veteran-stamped unit +//! spawns with strictly higher attack/defense/HP than a Levy-stamped unit +//! of the same type — the resourced-vs-starved divergence, realised at the +//! spawn boundary the live turn loop uses. +//! +//! HONEST SCOPE: the *causation* "resourced city → Veteran, starved → Levy" +//! through the live loop is NOT closed here — the per-city typed +//! `ResourceStockpile` (p2-57a) and per-unit gating-resource assignment +//! (p2-57b) are not wired into `process_city_production`, so nothing in the +//! live loop SETS `MapUnit.quality` yet. That stockpile→tier half is proven at +//! pipeline level in `quality_spawn_divergence.rs`. This file proves the +//! remaining half: once a tier is stamped, the live spawn path applies it. + +use mc_ai::evaluator::ScoringWeights; +use mc_city::{CityState, Queueable, QualityTier}; +use mc_turn::{GameState, MapUnit, PlayerState, TurnProcessor}; +use mc_units::{CombatStats, UnitStats as CatalogUnitStats}; +use std::collections::BTreeMap; + +/// Build a single-city player at `pos` with production seeded above the spawn +/// cost so `try_spawn_unit` fires on the first step. +fn player(pi: u8, pos: (i32, i32), queue: Option) -> PlayerState { + let mut axes: BTreeMap = BTreeMap::new(); + axes.insert("production".into(), 9); + axes.insert("expansion".into(), 1); + + let mut city = CityState::starter(); + city.production_stored = 500; + city.prod_yield = 200; + city.queue = queue; + + PlayerState { + player_index: pi, + gold: 1000, + cities: vec![city], + unit_upkeep: vec![], + strategic_axes: axes, + scoring_weights: ScoringWeights::default(), + expansion_points: 0, + city_buildings: vec![vec![]], + city_improvements: Default::default(), + city_ecology: vec![Default::default()], + tech_state: None, + science_yield: 0, + science_pool: 0, + player_tech: None, + units: vec![], + city_positions: vec![pos], + capital_position: Some(pos), + culture_total: 0, + culture_pool: mc_culture::CulturePool::default(), + arcane_lore_pop_deducted: false, + traded_luxuries: Default::default(), + relations: Default::default(), + strategic_ledger: Default::default(), + wonders_built: Default::default(), + explored_deposits: Default::default(), + ..Default::default() + } +} + +/// Insert a catalog entry carrying an explicit combat line. +fn catalog_unit(id: &str, hp: i32, attack: i32, defense: i32) -> CatalogUnitStats { + CatalogUnitStats { + id: id.to_string(), + base_moves: 2, + domain: "land".into(), + action_point_capacity: None, + capturable: false, + ransom_multiplier: 2.0, + build_cost: 0, + logistics: None, + combat: CombatStats { + hp, + max_hp: None, + attack, + defense, + ranged_attack: 0, + range: 0, + }, + } +} + +/// Bullet 1: two cities queueing *different* unit types must spawn units with +/// each type's own base line — not a shared hardcoded `60/12/1`. +#[test] +fn live_spawn_resolves_distinct_base_stats_per_unit_type() { + // Two distinct unit types with deliberately different combat lines. + let mut state = GameState::default(); + state.units_catalog.insert(catalog_unit("dwarf_warrior", 60, 12, 1)); + state.units_catalog.insert(catalog_unit("dwarf_crossbow", 45, 20, 3)); + + state.players.push(player( + 0, + (0, 0), + Some(Queueable::Unit { unit_id: mc_core::UnitId::new("dwarf_warrior") }), + )); + state.players.push(player( + 1, + (16, 0), + Some(Queueable::Unit { unit_id: mc_core::UnitId::new("dwarf_crossbow") }), + )); + state.next_unit_id = 1; + + let processor = TurnProcessor::new(200); + let _ = processor.step(&mut state); + + let warrior = state.players[0] + .units + .iter() + .find(|u| u.unit_id == "dwarf_warrior") + .expect("player 0 spawned a dwarf_warrior"); + let crossbow = state.players[1] + .units + .iter() + .find(|u| u.unit_id == "dwarf_crossbow") + .expect("player 1 spawned a dwarf_crossbow"); + + // Each unit carries ITS OWN catalog base line — the bug was that both got + // 60/12/1. + assert_eq!( + (warrior.hp, warrior.max_hp, warrior.attack, warrior.defense), + (60, 60, 12, 1), + "warrior must spawn with warrior base stats", + ); + assert_eq!( + (crossbow.hp, crossbow.max_hp, crossbow.attack, crossbow.defense), + (45, 45, 20, 3), + "crossbow must spawn with ITS OWN base stats, not the warrior line — \ + this is the 60/12/1 hardcode bug the resolver fixes", + ); + // Live spawn carries no stamp source today → quality stays None. + assert_eq!(warrior.quality, None); + assert_eq!(crossbow.quality, None); +} + +/// Empty catalog (legacy bench fixtures that never load units) falls back to +/// the historical warrior line so transcript fixtures keep shipping units. +#[test] +fn live_spawn_falls_back_to_warrior_line_when_catalog_empty() { + let mut state = GameState::default(); + // No catalog inserts. Auto-warrior (no queue) path. + state.players.push(player(0, (0, 0), None)); + state.next_unit_id = 1; + + let processor = TurnProcessor::new(200); + let _ = processor.step(&mut state); + + let u = state.players[0].units.first().expect("auto-warrior spawned"); + assert_eq!( + (u.hp, u.max_hp, u.attack, u.defense), + (60, 60, 12, 1), + "catalog-less fixtures retain the legacy warrior stat-line", + ); +} + +/// Bullet 2: a Veteran-stamped unit and a Levy-stamped unit of the *same* type +/// spawned through `spawn_unit_typed` (the live typed spawn path) diverge in +/// attack/defense/HP per the global `quality_deltas` rule. +#[test] +fn live_typed_spawn_applies_quality_tier_divergently() { + let mut state = GameState::default(); + state.units_catalog.insert(catalog_unit("dwarf_warrior", 60, 12, 1)); + // Two cities so both spawns have production. + state.players.push(player(0, (0, 0), None)); + state.players[0].cities.push(CityState::starter()); + state.players[0].cities[0].production_stored = 500; + state.players[0].cities[1].production_stored = 500; + state.players[0].city_positions.push((4, 4)); + state.players[0].city_buildings.push(vec![]); + state.players[0].city_ecology.push(Default::default()); + state.next_unit_id = 1; + + let processor = TurnProcessor::new(200); + let mut result = mc_turn::TurnResult::default(); + + // Veteran stamp on the unit headed for city 0. + let mut vet = MapUnit::default(); + vet.col = 0; + vet.row = 0; + vet.quality = Some(QualityTier::Veteran); + assert!(processor.spawn_unit_typed( + &mut state, 0, 0, "dwarf_warrior", &[], vet, &mut result, + )); + + // Levy stamp on the unit headed for city 1. + let mut levy = MapUnit::default(); + levy.col = 4; + levy.row = 4; + levy.quality = Some(QualityTier::Levy); + assert!(processor.spawn_unit_typed( + &mut state, 0, 1, "dwarf_warrior", &[], levy, &mut result, + )); + + let units = &state.players[0].units; + assert_eq!(units.len(), 2, "both typed spawns landed"); + let v = units.iter().find(|u| u.quality == Some(QualityTier::Veteran)).unwrap(); + let l = units.iter().find(|u| u.quality == Some(QualityTier::Levy)).unwrap(); + + // Default quality_deltas: veteran +3/+3/+10, levy +0/+0/+0 over the + // warrior base 12/1/60. + assert_eq!( + (v.attack, v.defense, v.max_hp), + (15, 4, 70), + "veteran stamp applies the +3/+3/+10 delta in the live spawn path", + ); + assert_eq!( + (l.attack, l.defense, l.max_hp), + (12, 1, 60), + "levy is the base line (no bonus)", + ); + // Strict divergence — the whole point of quality coupling. + assert!(v.attack > l.attack && v.defense > l.defense && v.max_hp > l.max_hp); +}