feat(p2-57c): wire production-quality consumer into the live spawn path

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) <noreply@anthropic.com>
This commit is contained in:
autocommit 2026-06-04 15:43:38 -07:00
parent b8e1c6b24c
commit cd339ff7dd
6 changed files with 453 additions and 34 deletions

View file

@ -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`,

View file

@ -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<QualityTier>` 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

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

View file

@ -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/<id>.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<UnitLogistics>,
}
/// Base combat stats authored directly on `units/<id>.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<i32>,
#[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);

View file

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