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:
parent
b8e1c6b24c
commit
cd339ff7dd
6 changed files with 453 additions and 34 deletions
|
|
@ -97,12 +97,18 @@ Resolution: option 1 adopted. Sidecar `public/resources/recipes/recipes.json` ca
|
||||||
|
|
||||||
**Path forward:**
|
**Path forward:**
|
||||||
1. Rephrase bullet 4 per above (the contract is live; the "158 files" literal is obsolete).
|
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
|
2. **Live-loop gap (shared with p2-57c bullet 2) — APPLY HALF LANDED 2026-06-04 (Wave A).**
|
||||||
into the turn loop — `mc-turn/src/processor.rs::process_city_production` contains NO
|
The `apply_quality` consumer is now wired into the live spawn path
|
||||||
`tick_recipes`/`stamp_unit_quality`/`apply_quality` call (verified: zero hits). Until
|
(`mc-turn/src/processor.rs::resolve_spawn_combat`, called by `try_spawn_unit` +
|
||||||
that hookup lands, recipe-driven quality downgrade does not affect spawned units
|
`spawn_unit_typed`): a stamped `QualityTier` is applied to a unit's catalog-resolved
|
||||||
in-game regardless of authored data. This is the real remaining work for the feature to
|
base stats at spawn (test `quality_spawn_live_processor.rs` 3/3). **Still open:** the
|
||||||
function.
|
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.
|
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
|
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`,
|
unit JSON — confirmed MISSING: `dwarf_master_engineer`, `master_surgeon`,
|
||||||
|
|
|
||||||
|
|
@ -45,25 +45,37 @@ validated.
|
||||||
optional per-unit `UnitQualityChain` override. mc-turn is the home because it
|
optional per-unit `UnitQualityChain` override. mc-turn is the home because it
|
||||||
already depends on both mc-city (`QualityTier`) and mc-combat (`UnitStats`) —
|
already depends on both mc-city (`QualityTier`) and mc-combat (`UnitStats`) —
|
||||||
zero new dep edges. 8 unit tests green.
|
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
|
consumed at unit-completion so a starved producer's unit actually spawns at the
|
||||||
downgraded tier with the corresponding stats. Integration test proves a
|
downgraded tier with the corresponding stats. Integration test proves a
|
||||||
resourced vs starved city produce stat-divergent units end-to-end.
|
resourced vs starved city produce stat-divergent units end-to-end.
|
||||||
→ **PARTIAL.** The producer→tier→consumer *pipeline* is proven end-to-end by
|
→ **APPLY-HALF NOW LIVE (2026-06-04, finish-game1 Wave A).** The `apply_quality`
|
||||||
`mc-turn/tests/quality_spawn_divergence.rs` (2/2 green): resourced (depth 6 →
|
consumer is wired into the **live spawn path**, closing p2-57c's "inert in-game"
|
||||||
Veteran) vs starved (depth 0 → Levy) stockpile → `stamp_unit_quality` →
|
motivation: `mc-turn/src/processor.rs::resolve_spawn_combat` resolves a unit's
|
||||||
`apply_quality` → stat-divergent `UnitStats`. The `MapUnit.quality:
|
base combat line from `state.units_catalog` *by unit_id* and applies the stamped
|
||||||
Option<QualityTier>` field is added (serde-default, save-safe — serde_roundtrip
|
`QualityTier` delta from `state.combat_balance.quality_deltas`. `try_spawn_unit`
|
||||||
6/6). **Not yet done:** the live turn loop does not call this. Confirmed
|
and `spawn_unit_typed` both call it. This required surfacing the combat stat-line
|
||||||
`mc-turn/src/processor.rs::process_city_production` (line 1061) contains NO
|
through the catalog — `mc_units::UnitStats` gained a flattened `combat:
|
||||||
`tick_recipes`/`stamp_unit_quality`/`apply_quality` call — the live spawn paths
|
CombatStats { hp, max_hp?, attack, defense, ranged_attack, range }`
|
||||||
(`try_spawn_unit:1369` hardcodes bench 60/12/1; `spawn_unit_typed:1552`
|
(`#[serde(default)]`, JSON already authors these). **Fixes a latent live bug**:
|
||||||
receives a pre-built `MapUnit`) do not resolve base stats from the catalog and
|
`try_spawn_unit` hardcoded `60/12/1` on *every* unit type, so queued non-warriors
|
||||||
do not stamp quality. **Resume:** wire `process_city_production` to call
|
spawned with warrior stats; units now spawn with their own JSON base line.
|
||||||
`tick_and_stamp` on unit completion, resolve base stats from `UnitsCatalog`,
|
Integration test `mc-turn/tests/quality_spawn_live_processor.rs` (3/3 green):
|
||||||
apply `apply_quality`, set `MapUnit.quality`, and spawn. That couples to the
|
(a) two cities queue distinct types → each spawns its own base stats through the
|
||||||
catalog→spawn resolver gap (a separate surface). The contract + pipeline this
|
live `step`; (b) Veteran-stamped vs Levy-stamped same-type units through
|
||||||
objective owns are complete; the live-loop hookup is the remaining sub-bullet.
|
`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
|
- ✓ A canonical schema for the per-tier magnitude shape exists
|
||||||
(`data/schemas/…` or `resources/units/` field), registered in
|
(`data/schemas/…` or `resources/units/` field), registered in
|
||||||
`tools/validate-game-data.py`, so `p2-57b` bullet 4 can author against it.
|
`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;
|
`{veteran, regular, levy}` `{attack,defense,hp}` blocks on producible units;
|
||||||
omitting the block falls through to the global default rule (no invented data).
|
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
|
4/5 acceptance bullets ✓; bullet 2 advanced ☐→◐ (Wave A, 2026-06-04): the
|
||||||
turn-loop hookup remaining — see bullet 2 resume note). Status stays **partial**
|
`apply_quality` consumer is now LIVE in the spawn path (`resolve_spawn_combat` in
|
||||||
per objective-integrity counting rule (K=4 < N=5). The deliverable this objective
|
`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
|
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`
|
`mc-core/src/combat_balance.rs` (+lib.rs re-export), `mc-turn/src/quality.rs`
|
||||||
(new) + `lib.rs` + `game_state.rs` (`MapUnit.quality`),
|
(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`,
|
`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
|
## Source-of-truth rails
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1366,6 +1366,66 @@ impl TurnProcessor {
|
||||||
|
|
||||||
// ── Phase 4: Unit production ───────────────────────────────────────────
|
// ── 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) {
|
fn try_spawn_unit(&self, state: &mut GameState, pi: usize, result: &mut TurnResult) {
|
||||||
use mc_city::Queueable;
|
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];
|
let player = &mut state.players[pi];
|
||||||
player.cities[city_idx].production_stored -= cost;
|
player.cities[city_idx].production_stored -= cost;
|
||||||
// If the city was explicitly queueing a unit, the queue head
|
// If the city was explicitly queueing a unit, the queue head
|
||||||
|
|
@ -1512,10 +1583,10 @@ impl TurnProcessor {
|
||||||
id: uid,
|
id: uid,
|
||||||
col: pos.0,
|
col: pos.0,
|
||||||
row: pos.1,
|
row: pos.1,
|
||||||
hp: 60,
|
hp: u_hp,
|
||||||
max_hp: 60,
|
max_hp: u_max_hp,
|
||||||
attack: 12,
|
attack: u_atk,
|
||||||
defense: 1,
|
defense: u_def,
|
||||||
is_fortified: false,
|
is_fortified: false,
|
||||||
is_sentrying: false,
|
is_sentrying: false,
|
||||||
unit_id: unit_kind.clone(),
|
unit_id: unit_kind.clone(),
|
||||||
|
|
@ -1581,10 +1652,25 @@ impl TurnProcessor {
|
||||||
.get(city_idx)
|
.get(city_idx)
|
||||||
.copied()
|
.copied()
|
||||||
.unwrap_or((0, 0));
|
.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];
|
let player = &mut state.players[pi];
|
||||||
player.cities[city_idx].production_stored -= cost;
|
player.cities[city_idx].production_stored -= cost;
|
||||||
debit_resources(requires, &mut player.strategic_ledger);
|
debit_resources(requires, &mut player.strategic_ledger);
|
||||||
let mut unit = stats;
|
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.id = uid;
|
||||||
unit.unit_id = unit_id.to_string();
|
unit.unit_id = unit_id.to_string();
|
||||||
unit.held_resources = requires.to_vec();
|
unit.held_resources = requires.to_vec();
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
|
@ -37,6 +37,18 @@ pub struct UnitStats {
|
||||||
/// passability gates in `mc-pathfinding`.
|
/// passability gates in `mc-pathfinding`.
|
||||||
#[serde(default = "default_domain")]
|
#[serde(default = "default_domain")]
|
||||||
pub domain: String,
|
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
|
/// Action-point capacity for Specialist units (Pioneer / Engineer
|
||||||
/// progression). `None` for unit types that don't carry an AP pool —
|
/// progression). `None` for unit types that don't carry an AP pool —
|
||||||
/// military units, scouts, etc. Sourced from JSON key
|
/// military units, scouts, etc. Sourced from JSON key
|
||||||
|
|
@ -69,6 +81,38 @@ pub struct UnitStats {
|
||||||
pub logistics: Option<UnitLogistics>,
|
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 logistics block per UNIT_LOGISTICS.md. Every field is itself
|
||||||
/// optional so partial migrations are valid and existing files round-trip
|
/// optional so partial migrations are valid and existing files round-trip
|
||||||
/// without growing the block.
|
/// without growing the block.
|
||||||
|
|
@ -248,6 +292,33 @@ mod tests {
|
||||||
let w = cat.get("warrior").expect("warrior present");
|
let w = cat.get("warrior").expect("warrior present");
|
||||||
assert_eq!(w.base_moves, 2);
|
assert_eq!(w.base_moves, 2);
|
||||||
assert_eq!(w.domain, "land");
|
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]
|
#[test]
|
||||||
|
|
@ -270,6 +341,7 @@ mod tests {
|
||||||
ransom_multiplier: 2.0,
|
ransom_multiplier: 2.0,
|
||||||
build_cost: 0,
|
build_cost: 0,
|
||||||
logistics: None,
|
logistics: None,
|
||||||
|
combat: CombatStats::default(),
|
||||||
});
|
});
|
||||||
assert_eq!(cat.len(), 1);
|
assert_eq!(cat.len(), 1);
|
||||||
assert_eq!(cat.get("dwarf_warrior").unwrap().base_moves, 2);
|
assert_eq!(cat.get("dwarf_warrior").unwrap().base_moves, 2);
|
||||||
|
|
|
||||||
|
|
@ -17,4 +17,4 @@ pub mod catalog;
|
||||||
|
|
||||||
pub use action::UnitActionDef;
|
pub use action::UnitActionDef;
|
||||||
pub use ap::{ActionCtx, ApCostError};
|
pub use ap::{ActionCtx, ApCostError};
|
||||||
pub use catalog::{UnitStats, UnitsCatalog};
|
pub use catalog::{CombatStats, UnitStats, UnitsCatalog};
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue