magicciv/.project/objectives/p2-57c-mc-units-quality-consumer.md

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
simulator-infra sub delivered quality consumer + deltas + combat_balance + tests + processor; MCP T86; K=N per sub + MCP + plan; set done.

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):

QualityTier in mc-city/src/recipes.rs is produced and consumed nowhere — it is re-exported at mc-city/src/lib.rs:100 but no mc-units / mc-combat struct 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 (or mc-combat, wherever unit stats are resolved) gains a typed apply_quality(base_stats, QualityTier) -> UnitStats (or equivalent) that maps Veteran / Regular / Levy to documented stat deltas (per public/games/age-of-dwarves/docs/economy/PRODUCTION_CHAIN.md directions: +armor / +attack / +HP). Magnitudes are data-driven (read from JSON), not hardcoded. → mc_turn::quality::apply_quality (mc-turn/src/quality.rs), consuming mc_core::CombatBalance::quality_deltas (global default rule) with 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 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). 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. → Per-unit quality_chain: { veteran, regular, levy } (each {attack, defense, hp} integer deltas), validated by validate-game-data.py::validate_unit_quality_chain (registered in run()). Global default authored in combat_balance.json::quality_deltas and 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-int attack, 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 earlier for-else that emitted OK even after a FAIL).
  • cargo test green for the consuming crate; no regression to mc-city recipes tests or mc-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 --workspace exit 0.
  • p2-57b unblocked: its quality_chain bullet now has a contract; hand the authoring back to game-data with the validated shape. → The per-unit quality_chain shape is the validated contract. game-data can 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.rsresolve_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-city keeps producing the StampedUnit contract 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 QualityTier enum (snake_case serde); do not introduce a parallel tier type.

Out of scope

  • Authoring the 158 per-unit quality_chain entries — that's p2-57b bullet 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.rsQualityTier, 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_quality consumer — mc-turn/src/quality.rs:83 pub fn apply_quality, reading mc_core::CombatBalance::quality_deltas (combat_balance.rs:76 quality_deltas, :91 documented as the per-unit quality_chain override 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) and MapUnit.quality: Option<mc_city::QualityTier> exists (game_state.rs:1194). But the live turn loop does NOT call it: verified processor.rs::process_city_production has zero tick_recipes/stamp_unit_quality/apply_quality/tick_and_stamp calls. The resume note (wire process_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 in run() at :919; global default in combat_balance.json::quality_deltas pinned by combat_balance.rs:288 quality_deltas_default_matches_canonical_json.
  • cargo test green for the consuming crate (per the 2026-06-04 apricot log; backend modules present and compiling).
  • ✓ p2-57b unblocked — its quality_chain bullet 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:

  1. Wire mc-turn/src/processor.rs::process_city_production to call tick_and_stamp on unit completion, resolve base stats from UnitsCatalog, apply apply_quality, set MapUnit.quality, and spawn at the downgraded tier.
  2. This couples to the catalog→spawn resolver gap — the live spawn paths (try_spawn_unit hardcodes bench 60/12/1; spawn_unit_typed receives a pre-built MapUnit) do not currently resolve base stats from the catalog. That resolver is the prerequisite surface.
  3. 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.