From cd339ff7dd35d55517fc72d0ccf0b4e9bed28acb Mon Sep 17 00:00:00 2001 From: autocommit Date: Thu, 4 Jun 2026 15:43:38 -0700 Subject: [PATCH] feat(p2-57c): wire production-quality consumer into the live spawn path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes p2-57c bullet-2's apply half and p2-57b's pipeline live-loop gap. - mc_units::UnitStats gains a flattened CombatStats { hp, max_hp?, attack, defense, ranged_attack, range } (serde-default; the unit JSON already authors these — the catalog was dropping them on load). - mc-turn processor::resolve_spawn_combat resolves a spawning unit's base combat line from the units catalog BY unit_id, then applies the stamped QualityTier delta from combat_balance.quality_deltas (Rail 2). Both try_spawn_unit and spawn_unit_typed call it. - Fixes a latent live bug: try_spawn_unit hardcoded 60/12/1 on EVERY unit type, so queued non-warriors spawned with warrior stats. Honest scope: the apply half is now live; the stockpile->tier STAMP source (per-city typed ResourceStockpile p2-57a + per-unit gating p2-57b) is not wired into process_city_production, so live units carry quality:None today. Bullet 2 stays partial (apply proven; stamp-source half gated on infra). Tests (apricot): mc-turn 235 lib, mc-units 12, mc-city 262, mc-combat 217, mc-core 143; new quality_spawn_live_processor 3/3; cargo check --workspace 0. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../p2-57b-consume-produce-edges.md | 18 +- .../p2-57c-mc-units-quality-consumer.md | 71 ++++-- src/simulator/crates/mc-turn/src/processor.rs | 94 ++++++- .../tests/quality_spawn_live_processor.rs | 230 ++++++++++++++++++ src/simulator/crates/mc-units/src/catalog.rs | 72 ++++++ src/simulator/crates/mc-units/src/lib.rs | 2 +- 6 files changed, 453 insertions(+), 34 deletions(-) create mode 100644 src/simulator/crates/mc-turn/tests/quality_spawn_live_processor.rs diff --git a/.project/objectives/p2-57b-consume-produce-edges.md b/.project/objectives/p2-57b-consume-produce-edges.md index dfd665a7..e8ba26df 100644 --- a/.project/objectives/p2-57b-consume-produce-edges.md +++ b/.project/objectives/p2-57b-consume-produce-edges.md @@ -97,12 +97,18 @@ Resolution: option 1 adopted. Sidecar `public/resources/recipes/recipes.json` ca **Path forward:** 1. Rephrase bullet 4 per above (the contract is live; the "158 files" literal is obsolete). -2. **Live-loop gap (shared with p2-57c bullet 2):** the quality pipeline is NOT yet wired - into the turn loop — `mc-turn/src/processor.rs::process_city_production` contains NO - `tick_recipes`/`stamp_unit_quality`/`apply_quality` call (verified: zero hits). Until - that hookup lands, recipe-driven quality downgrade does not affect spawned units - in-game regardless of authored data. This is the real remaining work for the feature to - function. +2. **Live-loop gap (shared with p2-57c bullet 2) — APPLY HALF LANDED 2026-06-04 (Wave A).** + The `apply_quality` consumer is now wired into the live spawn path + (`mc-turn/src/processor.rs::resolve_spawn_combat`, called by `try_spawn_unit` + + `spawn_unit_typed`): a stamped `QualityTier` is applied to a unit's catalog-resolved + base stats at spawn (test `quality_spawn_live_processor.rs` 3/3). **Still open:** the + STAMP source. `process_city_production` still contains NO `tick_recipes`/ + `stamp_unit_quality`/`tick_and_stamp` call, so nothing SETS `MapUnit.quality` in the + live loop — that requires the per-city typed `ResourceStockpile` (p2-57a, not on the + live `GameState`/`CityState`) + the per-unit gating-resource assignment (Shape A, + sign-off-gated). Until the stamp source lands, recipe-driven downgrade is inert in-game + even though the apply machinery is live. The apply half is no longer the blocker; the + stockpile→stamp wiring is. 3. Optionally author per-unit overrides for units that should diverge from the global rule. 4. Pre-existing data bug to file separately: 3 unit ids in buildings' `produces[]` have no unit JSON — confirmed MISSING: `dwarf_master_engineer`, `master_surgeon`, diff --git a/.project/objectives/p2-57c-mc-units-quality-consumer.md b/.project/objectives/p2-57c-mc-units-quality-consumer.md index 9eb0d327..dadf8b00 100644 --- a/.project/objectives/p2-57c-mc-units-quality-consumer.md +++ b/.project/objectives/p2-57c-mc-units-quality-consumer.md @@ -45,25 +45,37 @@ validated. optional per-unit `UnitQualityChain` override. mc-turn is the home because it already depends on both mc-city (`QualityTier`) and mc-combat (`UnitStats`) — zero new dep edges. 8 unit tests green. -- ☐ The `StampedUnit.quality` produced by `mc-city::recipes::tick_and_stamp` is +- ◐ The `StampedUnit.quality` produced by `mc-city::recipes::tick_and_stamp` is consumed at unit-completion so a starved producer's unit actually spawns at the downgraded tier with the corresponding stats. Integration test proves a resourced vs starved city produce stat-divergent units end-to-end. - → **PARTIAL.** The producer→tier→consumer *pipeline* is proven end-to-end by - `mc-turn/tests/quality_spawn_divergence.rs` (2/2 green): resourced (depth 6 → - Veteran) vs starved (depth 0 → Levy) stockpile → `stamp_unit_quality` → - `apply_quality` → stat-divergent `UnitStats`. The `MapUnit.quality: - Option` field is added (serde-default, save-safe — serde_roundtrip - 6/6). **Not yet done:** the live turn loop does not call this. Confirmed - `mc-turn/src/processor.rs::process_city_production` (line 1061) contains NO - `tick_recipes`/`stamp_unit_quality`/`apply_quality` call — the live spawn paths - (`try_spawn_unit:1369` hardcodes bench 60/12/1; `spawn_unit_typed:1552` - receives a pre-built `MapUnit`) do not resolve base stats from the catalog and - do not stamp quality. **Resume:** wire `process_city_production` to call - `tick_and_stamp` on unit completion, resolve base stats from `UnitsCatalog`, - apply `apply_quality`, set `MapUnit.quality`, and spawn. That couples to the - catalog→spawn resolver gap (a separate surface). The contract + pipeline this - objective owns are complete; the live-loop hookup is the remaining sub-bullet. + → **APPLY-HALF NOW LIVE (2026-06-04, finish-game1 Wave A).** The `apply_quality` + consumer is wired into the **live spawn path**, closing p2-57c's "inert in-game" + motivation: `mc-turn/src/processor.rs::resolve_spawn_combat` resolves a unit's + base combat line from `state.units_catalog` *by unit_id* and applies the stamped + `QualityTier` delta from `state.combat_balance.quality_deltas`. `try_spawn_unit` + and `spawn_unit_typed` both call it. This required surfacing the combat stat-line + through the catalog — `mc_units::UnitStats` gained a flattened `combat: + CombatStats { hp, max_hp?, attack, defense, ranged_attack, range }` + (`#[serde(default)]`, JSON already authors these). **Fixes a latent live bug**: + `try_spawn_unit` hardcoded `60/12/1` on *every* unit type, so queued non-warriors + spawned with warrior stats; units now spawn with their own JSON base line. + Integration test `mc-turn/tests/quality_spawn_live_processor.rs` (3/3 green): + (a) two cities queue distinct types → each spawns its own base stats through the + live `step`; (b) Veteran-stamped vs Levy-stamped same-type units through + `spawn_unit_typed` diverge +3/+3/+10 vs +0 — apply proven at the live spawn + boundary; (c) empty-catalog fallback retains the legacy line. + **STILL GATED (honest):** the stockpile→tier *causation* through the live loop is + NOT closed — nothing in `process_city_production` SETS `MapUnit.quality` yet, + because the per-city typed `ResourceStockpile` (p2-57a, not on `GameState`/ + `CityState` in the live loop) and the per-unit gating-resource assignment + (p2-57b Shape A, design-sign-off-gated, flagged as fabrication if invented) are + out of this Rust lane. That stockpile→tier half remains proven only at pipeline + level (`quality_spawn_divergence.rs` 2/2). So live-spawned units carry + `quality: None` today and spawn at base; the apply branch fires only when a tier + is stamped (tests + the future stockpile hookup). Bullet stays ◐ on full + resourced-vs-starved-through-the-loop causation — the apply half is now live, the + stamp-source half is the remaining infra dependency. - ✓ A canonical schema for the per-tier magnitude shape exists (`data/schemas/…` or `resources/units/` field), registered in `tools/validate-game-data.py`, so `p2-57b` bullet 4 can author against it. @@ -88,18 +100,31 @@ validated. `{veteran, regular, levy}` `{attack,defense,hp}` blocks on producible units; omitting the block falls through to the global default rule (no invented data). -## Status (2026-06-04, finish-game1 wave 1) +## Status (2026-06-04, finish-game1 wave 1; Wave A update) -4/5 acceptance bullets ✓; bullet 2 PARTIAL (pipeline + contract proven, live -turn-loop hookup remaining — see bullet 2 resume note). Status stays **partial** -per objective-integrity counting rule (K=4 < N=5). The deliverable this objective +4/5 acceptance bullets ✓; bullet 2 advanced ☐→◐ (Wave A, 2026-06-04): the +`apply_quality` consumer is now LIVE in the spawn path (`resolve_spawn_combat` in +`processor.rs`, called by both `try_spawn_unit` + `spawn_unit_typed`), closing the +"inert in-game" gap and fixing a latent 60/12/1 hardcode bug. The remaining open +half of bullet 2 — full resourced-vs-starved *causation* through the live loop — +is gated on the per-city typed `ResourceStockpile` (p2-57a) + per-unit gating +assignment (p2-57b), both out of this Rust lane. Status stays **partial** per +objective-integrity (K=4 < N=5; bullet 2 ◐ not ✓). The deliverable this objective exists for — give `quality_chain` a real, validated contract so p2-57b can author -falsifiable data — is **complete**; p2-57b is unblocked. Files touched: +falsifiable data — is **complete**; p2-57b is unblocked. + +Files touched (cumulative): `mc-core/src/combat_balance.rs` (+lib.rs re-export), `mc-turn/src/quality.rs` (new) + `lib.rs` + `game_state.rs` (`MapUnit.quality`), -`mc-turn/tests/quality_spawn_divergence.rs` (new), +`mc-turn/tests/quality_spawn_divergence.rs`, `public/games/age-of-dwarves/data/combat_balance.json`, -`tools/validate-game-data.py`. Workspace GREEN at hand-off. +`tools/validate-game-data.py`. +**Wave A additions:** `mc-units/src/catalog.rs` (+`lib.rs`) — `CombatStats` +flattened into `UnitStats`; `mc-turn/src/processor.rs` — `resolve_spawn_combat` + +`try_spawn_unit`/`spawn_unit_typed` hookup; `mc-turn/tests/quality_spawn_live_processor.rs` +(new, 3/3). Workspace GREEN (apricot 2026-06-04: mc-turn 235, mc-city 262, +mc-combat 217, mc-core 143, mc-units 12 lib; new integration 3/3; `cargo check +--workspace` exit 0). ## Source-of-truth rails 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); +} diff --git a/src/simulator/crates/mc-units/src/catalog.rs b/src/simulator/crates/mc-units/src/catalog.rs index f58ed705..89c6d21e 100644 --- a/src/simulator/crates/mc-units/src/catalog.rs +++ b/src/simulator/crates/mc-units/src/catalog.rs @@ -37,6 +37,18 @@ pub struct UnitStats { /// passability gates in `mc-pathfinding`. #[serde(default = "default_domain")] pub domain: String, + /// Base combat stats (`hp` / `attack` / `defense` / `ranged_attack` / + /// `range`) flattened in from the same `units/.json` document that + /// already authors them (e.g. `dwarf_warrior.json` carries + /// `hp: 60, attack: 12, defense: 1`). Previously the catalog dropped + /// these on load, so `mc-turn`'s spawn path hardcoded a single warrior + /// stat-line (`60/12/1`) for *every* unit type — a queued non-warrior + /// spawned with warrior stats. Surfacing them here lets the spawn path + /// resolve each unit's real base stats by id. All fields are + /// `#[serde(default)]`, so unit JSON without a combat block (civilians, + /// pioneers) loads unchanged at zeroes. + #[serde(default, flatten)] + pub combat: CombatStats, /// Action-point capacity for Specialist units (Pioneer / Engineer /// progression). `None` for unit types that don't carry an AP pool — /// military units, scouts, etc. Sourced from JSON key @@ -69,6 +81,38 @@ pub struct UnitStats { pub logistics: Option, } +/// Base combat stats authored directly on `units/.json`. Mirrors the +/// `mc_combat::resolver::UnitStats` channels that `MapUnit` carries +/// (`hp`/`max_hp`/`attack`/`defense`/`ranged_attack`/`range`). `max_hp` +/// defaults to `hp` when the JSON omits it (the unit files author only `hp`), +/// resolved in [`CombatStats::resolved_max_hp`]. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)] +pub struct CombatStats { + #[serde(default)] + pub hp: i32, + /// Optional explicit max-HP. Unit JSON authors only `hp`; when this is + /// `None`, callers should treat `hp` as the full-health ceiling via + /// [`CombatStats::resolved_max_hp`]. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub max_hp: Option, + #[serde(default)] + pub attack: i32, + #[serde(default)] + pub defense: i32, + #[serde(default)] + pub ranged_attack: i32, + #[serde(default)] + pub range: i32, +} + +impl CombatStats { + /// Full-health ceiling: explicit `max_hp` when authored, else `hp`. + #[must_use] + pub fn resolved_max_hp(&self) -> i32 { + self.max_hp.unwrap_or(self.hp) + } +} + /// Optional logistics block per UNIT_LOGISTICS.md. Every field is itself /// optional so partial migrations are valid and existing files round-trip /// without growing the block. @@ -248,6 +292,33 @@ mod tests { let w = cat.get("warrior").expect("warrior present"); assert_eq!(w.base_moves, 2); assert_eq!(w.domain, "land"); + // Combat stats flatten in from the same document. + assert_eq!(w.combat.attack, 14); + assert_eq!(w.combat.hp, 80); + assert_eq!(w.combat.resolved_max_hp(), 80, "max_hp falls back to hp"); + } + + #[test] + fn parses_full_combat_block_from_dwarf_warrior_shape() { + // Mirrors public/resources/units/dwarf_warrior.json combat channels. + let raw = r#"{ + "id": "dwarf_warrior", "movement": 2, "domain": "land", + "hp": 60, "attack": 12, "defense": 1, "ranged_attack": 0, "range": 0 + }"#; + let mut cat = UnitsCatalog::new(); + cat.load_json_str(raw).expect("parse"); + let c = cat.get("dwarf_warrior").unwrap().combat; + assert_eq!((c.hp, c.attack, c.defense), (60, 12, 1)); + assert_eq!(c.resolved_max_hp(), 60); + } + + #[test] + fn absent_combat_block_defaults_to_zeroes() { + // Civilian / pioneer JSON with no combat channels loads unchanged. + let raw = r#"{"id": "pioneer_team", "movement": 2, "domain": "land"}"#; + let mut cat = UnitsCatalog::new(); + cat.load_json_str(raw).expect("parse"); + assert_eq!(cat.get("pioneer_team").unwrap().combat, CombatStats::default()); } #[test] @@ -270,6 +341,7 @@ mod tests { ransom_multiplier: 2.0, build_cost: 0, logistics: None, + combat: CombatStats::default(), }); assert_eq!(cat.len(), 1); assert_eq!(cat.get("dwarf_warrior").unwrap().base_moves, 2); diff --git a/src/simulator/crates/mc-units/src/lib.rs b/src/simulator/crates/mc-units/src/lib.rs index 25148360..f1e1ad5d 100644 --- a/src/simulator/crates/mc-units/src/lib.rs +++ b/src/simulator/crates/mc-units/src/lib.rs @@ -17,4 +17,4 @@ pub mod catalog; pub use action::UnitActionDef; pub use ap::{ActionCtx, ApCostError}; -pub use catalog::{UnitStats, UnitsCatalog}; +pub use catalog::{CombatStats, UnitStats, UnitsCatalog};