feat(mc-turn): Introduce quality-based entity spawning logic in the Processor struct with new methods, update GameState support, and add comprehensive tests for edge cases

Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
autocommit 2026-06-04 15:39:37 -07:00
parent ec26b905bf
commit 4988842c6c
3 changed files with 325 additions and 9 deletions

View file

@ -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<mc_ai::tactical::state::TacticalUnitSpec>,
pub ai_unit_catalog: Vec<mc_core::tactical_types::TacticalUnitSpec>,
/// 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<mc_ai::tactical::state::TacticalBuildingSpec>,
pub ai_building_catalog: Vec<mc_core::tactical_types::TacticalBuildingSpec>,
/// 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`.

View file

@ -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/<id>.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<mc_city::QualityTier>,
) -> (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();

View file

@ -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/<id>.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<Queueable>) -> PlayerState {
let mut axes: BTreeMap<String, u8> = 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);
}