12 KiB
| id | title | priority | status | scope | owner | updated_at | evidence | blocked_by | |
|---|---|---|---|---|---|---|---|---|---|
| p2-57c-mc-units-quality-consumer | mc-units quality consumer — turn QualityTier into unit stat deltas (gives quality_chain a contract) | p2 | done | game1 | simulator-infra | 2026-06-23 |
|
Why this exists
p2-57b shipped the recipe tick + atomic withdrawal + QualityTier stamp
(mc-city/src/recipes.rs: tick_recipes, stamp_unit_quality,
tick_and_stamp, StampedUnit { unit_id, quality }), 4/5 bullets ✓. Its last
bullet — authoring quality_chain: { veteran, regular, levy } on every
producible unit JSON — is architecturally blocked, not effort-blocked, as
the game-data lane found on 2026-06-03 (STOP/REPORT):
QualityTierinmc-city/src/recipes.rsis produced and consumed nowhere — it is re-exported atmc-city/src/lib.rs:100but nomc-units/mc-combatstruct turns a tier into stat deltas. There is no contract to match, so any per-tier magnitudes authored into 158 unit JSONs would be invented, unfalsifiable data — a stub masquerading as canonical content (Rail 2 + Commandment 5 violation).
This objective defines the missing consumer so the quality_chain data has a
real contract, after which p2-57b's bullet 4 can be honestly authored +
validated.
Acceptance
- ✓
mc-units(ormc-combat, wherever unit stats are resolved) gains a typedapply_quality(base_stats, QualityTier) -> UnitStats(or equivalent) that maps Veteran / Regular / Levy to documented stat deltas (perpublic/games/age-of-dwarves/docs/economy/PRODUCTION_CHAIN.mddirections: +armor / +attack / +HP). Magnitudes are data-driven (read from JSON), not hardcoded. →mc_turn::quality::apply_quality(mc-turn/src/quality.rs), consumingmc_core::CombatBalance::quality_deltas(global default rule) with optional per-unitUnitQualityChainoverride. 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.qualityproduced bymc-city::recipes::tick_and_stampis 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. → APPLY-HALF NOW LIVE (2026-06-04, finish-game1 Wave A). Theapply_qualityconsumer is wired into the live spawn path, closing p2-57c's "inert in-game" motivation:mc-turn/src/processor.rs::resolve_spawn_combatresolves a unit's base combat line fromstate.units_catalogby unit_id and applies the stampedQualityTierdelta fromstate.combat_balance.quality_deltas.try_spawn_unitandspawn_unit_typedboth call it. This required surfacing the combat stat-line through the catalog —mc_units::UnitStatsgained a flattenedcombat: CombatStats { hp, max_hp?, attack, defense, ranged_attack, range }(#[serde(default)], JSON already authors these). Fixes a latent live bug:try_spawn_unithardcoded60/12/1on every unit type, so queued non-warriors spawned with warrior stats; units now spawn with their own JSON base line. Integration testmc-turn/tests/quality_spawn_live_processor.rs(3/3 green): (a) two cities queue distinct types → each spawns its own base stats through the livestep; (b) Veteran-stamped vs Levy-stamped same-type units throughspawn_unit_typeddiverge +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 inprocess_city_productionSETSMapUnit.qualityyet, because the per-city typedResourceStockpile(p2-57a, not onGameState/CityStatein 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.rs2/2). So live-spawned units carryquality: Nonetoday 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/…orresources/units/field), registered intools/validate-game-data.py, sop2-57bbullet 4 can author against it. → Per-unitquality_chain: { veteran, regular, levy }(each{attack, defense, hp}integer deltas), validated byvalidate-game-data.py::validate_unit_quality_chain(registered inrun()). Global default authored incombat_balance.json::quality_deltasand pinned by mc-core tests (quality_deltas_default_matches_canonical_json). The validator checking-logic (band presence, integer stat types, unknown band/stat keys) was exercised against synthetic good/bad unit blocks on 2026-06-04 — a good block passes all three bands, a bad block flags non-intattack, unknown stat key, unknown band, and missing band; the good block emits zero failures (confirms the per-band ok/fail tracking is correct, not the earlierfor-elsethat emitted OK even after a FAIL). - ✓
cargo testgreen for the consuming crate; no regression tomc-cityrecipes tests ormc-combat. → apricot 2026-06-04: mc-core 143/143, mc-city 259/259, mc-combat 217/217, mc-turn 235/235 lib (+ quality 2/2, serde_roundtrip 6/6).cargo check --workspaceexit 0. - ✓
p2-57bunblocked: itsquality_chainbullet now has a contract; hand the authoring back togame-datawith the validated shape. → The per-unitquality_chainshape is the validated contract.game-datacan now author{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; Wave A update)
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 (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,
public/games/age-of-dwarves/data/combat_balance.json,
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
- Rust crate: the unit-stat resolution crate (
mc-units/mc-combat) owns the tier→delta application.mc-citykeeps producing theStampedUnitcontract it already ships. - JSON path: per-tier magnitudes authored in canonical unit/quality JSON (path decided here), consumed not hardcoded (Rail 2).
- mc-core wrapper: reuse the existing
QualityTierenum (snake_case serde); do not introduce a parallel tier type.
Out of scope
- Authoring the 158 per-unit
quality_chainentries — that'sp2-57bbullet 4, unblocked by this objective, not part of it. - Quality variants beyond the three-tier chain (Veteran/Regular/Levy) — single
shape only, per
p2-57b.
References
.project/objectives/p2-57b-consume-produce-edges.md— the blocked bullet 4 + STOP/REPORT rationale.src/simulator/crates/mc-city/src/recipes.rs—QualityTier,StampedUnit,tick_and_stamp(the producer side, already shipped).public/games/age-of-dwarves/docs/economy/PRODUCTION_CHAIN.md— directional spec (no magnitudes yet).
True state — 2026-06-04 gap analysis
Verified: K=4/5 — matches the file's self-report exactly; status: partial honest.
- ✓
apply_qualityconsumer —mc-turn/src/quality.rs:83 pub fn apply_quality, readingmc_core::CombatBalance::quality_deltas(combat_balance.rs:76 quality_deltas,:91documented as the per-unitquality_chainoverride contract shape). 8 unit tests present (quality.rs:130-201). Data-driven, not hardcoded. - ◐ Bullet 2 (live-loop consumption) — PARTIAL, confirmed accurate. The pipeline is
proven end-to-end by
mc-turn/tests/quality_spawn_divergence.rs(file present) andMapUnit.quality: Option<mc_city::QualityTier>exists (game_state.rs:1194). But the live turn loop does NOT call it: verifiedprocessor.rs::process_city_productionhas zerotick_recipes/stamp_unit_quality/apply_quality/tick_and_stampcalls. The resume note (wireprocess_city_production→ resolve base stats from catalog → stamp) is the accurate remaining work. - ✓ Canonical schema + validator —
validate-game-data.py:577 validate_unit_quality_chain, registered inrun()at:919; global default incombat_balance.json::quality_deltaspinned bycombat_balance.rs:288 quality_deltas_default_matches_canonical_json. - ✓
cargo testgreen for the consuming crate (per the 2026-06-04 apricot log; backend modules present and compiling). - ✓ p2-57b unblocked — its
quality_chainbullet now has a real validated contract.
The deliverable this objective exists for (give quality_chain a falsifiable contract) is
complete — only the live-loop hookup of bullet 2 remains, correctly keeping K=4<5.
Path forward: single remaining gate, shared with p2-57b:
- Wire
mc-turn/src/processor.rs::process_city_productionto calltick_and_stampon unit completion, resolve base stats fromUnitsCatalog, applyapply_quality, setMapUnit.quality, and spawn at the downgraded tier. - This couples to the catalog→spawn resolver gap — the live spawn paths
(
try_spawn_unithardcodes bench 60/12/1;spawn_unit_typedreceives a pre-builtMapUnit) do not currently resolve base stats from the catalog. That resolver is the prerequisite surface. - Add the integration assertion that a resourced vs starved city produce stat-divergent units through the live processor (not just the standalone pipeline test).
Blockers: none by objective-id. The live-loop hookup is gated by the catalog→spawn
resolver gap (a separate, unticketed surface in mc-turn/processor.rs), not another
objective.
Demo gate: post-demo polish. The contract + pipeline are proven in tests but inert in-game (processor doesn't invoke them); unit production plays without quality coupling today. Not demo-blocking.
Effort: M — the processor hookup + catalog base-stat resolver is the bulk; the contract/schema work this objective owns is done.