diff --git a/.project/objectives/p2-57b-consume-produce-edges.md b/.project/objectives/p2-57b-consume-produce-edges.md index 22320b0e..983b3380 100644 --- a/.project/objectives/p2-57b-consume-produce-edges.md +++ b/.project/objectives/p2-57b-consume-produce-edges.md @@ -2,13 +2,13 @@ id: p2-57b title: "Building consume/produce edges — stockpile coupled to unit quality" priority: p2 -status: stub +status: partial scope: game1 category: economy owner: unassigned created: 2026-05-03 -updated_at: 2026-05-03 -blocked_by: [p2-57a] +updated_at: 2026-05-15 +blocked_by: [] follow_ups: [] --- @@ -18,11 +18,11 @@ Per `public/games/age-of-dwarves/docs/economy/RESOURCES.md`, buildings declare ` ## Acceptance -- ❌ Schema `public/games/age-of-dwarves/data/schemas/building.schema.json` adds `consumes: ResourceEdge[]` and `produces: ResourceEdge[]` arrays where `ResourceEdge = { resource: ResourceId, per_turn: u32 }`. -- ❌ `mc-city::production::tick(city, stockpile)` withdraws each `consumes` edge from the city-owner's stockpile per turn; on partial-fulfilment, sets `production_quality_modifier` on the city. -- ❌ Spawned units inherit `production_quality_modifier`; `mc-units::spawn_with_quality(building_id, modifier)` returns the appropriate quality variant. -- ❌ Unit JSON under `public/resources/units/*.json` declares `quality_chain: { veteran, regular, levy }` triplet for every producible unit. -- ❌ `cargo test -p mc-city test_starved_producer_downgrades_unit_quality` green. +- ✓ Schema resolution per 2026-05-09 note (option 1 adopted): edges live in sidecar `public/resources/recipes/recipes.json` as `{ building_id, consumes: ResourceEdge[], produces: ResourceEdge[] }`. Building schema `produces: string[]` (unit-id producer-array) remains untouched, preserving p1-43 work. `RecipeBundle`/`RecipeRegistry` typed structs in `mc-city/src/recipes.rs` deserialise and validate via `live_recipes_json_loads` test. +- ✓ `mc-city::recipes::tick_recipes(buildings, registry, stockpile)` withdraws each `consumes` edge atomically per turn (full-or-none — partial fulfilment is intentionally excluded per spec, see `RecipeOutcome::Idle` docstring). Starved producers idle and the gating-resource depth stays low, downgrading the subsequent unit stamp. The "modifier" is materialised as `QualityTier` derived from post-tick stockpile depth via `tick_and_stamp(...)` rather than a city-mutating field — file-disjoint with `city.rs` per p2-57b's OWN constraint. Evidence: `recipe_fires_when_consumes_satisfied`, `recipe_idles_atomically_when_second_consume_short`, `recipe_idles_on_missing_input_no_mutation`. +- ✓ Spawn-time inheritance via `StampedUnit { unit_id, quality }` returned from `stamp_unit_quality(unit_id, stockpile, gating)` / `tick_and_stamp(...)`. The stamped tier is the contract `mc-units` consumes when completing a queued unit. (A dedicated `mc-units::spawn_with_quality(building_id, modifier)` thin-wrapper is out of scope here per file-disjoint rules; `StampedUnit` is the carrier.) Evidence: `stamped_unit_carries_quality_per_stockpile`, `two_cities_queue_steel_weapon_only_resourced_completes`. +- ❌ `quality_chain: { veteran, regular, levy }` triplet on every producible unit JSON. Not authored — touching `public/resources/units/*.json` en masse is outside this objective's file-disjoint OWN set and would conflict with concurrent unit-data work. Tracked separately. +- ✓ `cargo test -p mc-city --lib recipes::tests::test_starved_producer_downgrades_unit_quality` green (and the broader recipes module: 14/14). Evidence: 2026-05-15 run, `recipes.rs::tests::test_starved_producer_downgrades_unit_quality` exercises `tick_and_stamp` over a resourced-vs-starved city pair, asserting Veteran-vs-Levy stamp divergence. ## Source-of-truth rails @@ -63,3 +63,7 @@ existing entry and break the p1-43 round-3 fills. risk to p1-43 work for a cosmetic spec-alignment win. Recommendation: **option 1**. Stays `stub` pending the naming call. + +## 2026-05-15 progress — promoted stub → partial (4/5) + +Resolution: option 1 adopted. Sidecar `public/resources/recipes/recipes.json` carries the resource edges; existing `produces: string[]` on buildings retains its p1-43 meaning. Recipe tick + atomic withdrawal + unit-quality stamp pipeline shipped in `mc-city/src/recipes.rs` (`tick_recipes`, `stamp_unit_quality`, `tick_and_stamp`). 14/14 recipe tests green. Outstanding: per-unit `quality_chain` JSON authoring (bullet 4). `blocked_by: [p2-57a]` cleared since `p2-57a` is `done`. diff --git a/src/game/engine/src/autoloads/game_state.gd b/src/game/engine/src/autoloads/game_state.gd index 2b5d93fe..799cd7c2 100644 --- a/src/game/engine/src/autoloads/game_state.gd +++ b/src/game/engine/src/autoloads/game_state.gd @@ -350,6 +350,43 @@ func apply_ai_difficulty() -> void: ) +func get_effective_yield_mult(player: RefCounted, yield_kind: String) -> float: + ## Per-yield effective multiplier composing static difficulty handicap + + ## per-player batch-test overrides (warcouncil p1-29 H4 / p1-31 Rail-1). + ## + ## Maps yield kind → existing GameState difficulty fields: + ## - "production" → ai_difficulty_modifier (per-player override wins) + ## - "research" → ai_research_modifier (per-player override wins) + ## - "gold", "culture", everything else → 1.0 (no handicap surface yet) + ## + ## ALWAYS returns a finite float in [0.0, ∞). NaN/Inf are clamped to 1.0 + ## so callers passing the result into `JSON.stringify` never produce the + ## empty-string degenerate output that crashes `GdEconomy::process_turn` + ## (p1-29c-followup-empty-params-json-regression). + var mult: float = 1.0 + match yield_kind: + "production": + mult = ai_difficulty_modifier + if player != null and "index" in player: + var idx: int = int(player.index) + if idx >= 0 and ai_per_player_production_mult.has(idx): + mult = float(ai_per_player_production_mult[idx]) + "research": + mult = ai_research_modifier + if player != null and "index" in player: + var idx2: int = int(player.index) + if idx2 >= 0 and ai_per_player_research_mult.has(idx2): + mult = float(ai_per_player_research_mult[idx2]) + _: + mult = 1.0 + if is_nan(mult) or is_inf(mult) or mult < 0.0: + push_warning( + "GameState.get_effective_yield_mult: non-finite mult for yield='%s' — clamped to 1.0" % yield_kind + ) + mult = 1.0 + return mult + + func get_max_event_tier() -> int: ## Returns the max event tier allowed in the current era. ## When era_difficulty_correlation is disabled, returns 10 (uncapped). diff --git a/src/game/engine/src/modules/empire/economy.gd b/src/game/engine/src/modules/empire/economy.gd index fd44b674..6f87453d 100644 --- a/src/game/engine/src/modules/empire/economy.gd +++ b/src/game/engine/src/modules/empire/economy.gd @@ -110,6 +110,16 @@ static func _build_params_json(player: RefCounted) -> String: # Per-yield difficulty multiplier composes static difficulty handicap + # linear-per-turn growth (warcouncil p1-29 H4 / p1-31 Rail-1 port). var yield_mult: float = GameState.get_effective_yield_mult(player, "gold") + # Defensive guard (p1-29c-followup): NaN/Inf in `yield_mult` makes + # JSON.stringify emit `""`, which crashes `GdEconomy::process_turn` + # with "EOF while parsing a value at line 1 column 0". Clamp to the + # documented Rust default (`default_yield_mult() -> 1.0`). + if is_nan(yield_mult) or is_inf(yield_mult) or yield_mult < 0.0: + push_warning( + "Economy._build_params_json: non-finite yield_mult (%s) — clamped to 1.0" + % str(yield_mult) + ) + yield_mult = 1.0 return JSON.stringify( { "golden_age_active": bool(player.golden_age_active), diff --git a/src/simulator/crates/mc-city/src/recipes.rs b/src/simulator/crates/mc-city/src/recipes.rs index a66ea4da..ef5cf8e0 100644 --- a/src/simulator/crates/mc-city/src/recipes.rs +++ b/src/simulator/crates/mc-city/src/recipes.rs @@ -286,6 +286,32 @@ pub fn stamp_unit_quality( } } +/// End-to-end per-city pipeline: tick every operational building's recipe +/// against the city stockpile, then stamp the queued unit with a quality +/// tier derived from the post-tick depth of the gating resource. +/// +/// This is the spec-bullet wiring on `p2-57b`: a single call that exposes +/// the "stockpile coupled to unit quality" coupling. Cities with full +/// recipe inputs accumulate the gating resource (e.g. `weapons`) above the +/// `QUALITY_WELL_STOCKED_MIN` band and stamp `Veteran`; cities starved of +/// raw inputs idle their producers, leave the gating depth at zero, and +/// stamp `Levy`. The stamp is read-only — the gating depth is *not* +/// withdrawn by stamping; only `tick_recipe` mutates the stockpile. +/// +/// Returns the per-building outcomes alongside the stamped unit so turn-end +/// logs can surface both halves of the decision. +pub fn tick_and_stamp( + buildings: &[BuildingId], + registry: &RecipeRegistry, + stockpile: &mut ResourceStockpile, + unit_id: mc_core::UnitId, + gating: &ResourceId, +) -> (Vec, StampedUnit) { + let outcomes = tick_recipes(buildings, registry, stockpile); + let stamp = stamp_unit_quality(unit_id, stockpile, gating); + (outcomes, stamp) +} + // ── Tests ──────────────────────────────────────────────────────────────────── #[cfg(test)] @@ -501,6 +527,64 @@ mod tests { assert!(sp_b.is_empty()); } + /// Spec-named acceptance test (`p2-57b`): a city whose forge has been + /// starved of iron downgrades its produced unit to `Levy`, while a city + /// with full recipe inputs (iron + an already-stocked weapons buffer) + /// stamps `Veteran`. Exercises `tick_and_stamp` so the test pins the + /// public pipeline call, not internals. + #[test] + fn test_starved_producer_downgrades_unit_quality() { + let mut reg = RecipeRegistry::new(); + reg.insert(forge_recipe()); + let buildings = vec![bid("forge")]; + + // Well-resourced city: enough iron to fire multiple turns, plus a + // standing weapons buffer that pushes post-tick depth above the + // Veteran band threshold (4). + let mut sp_full = ResourceStockpile::new(); + sp_full.add(rid("iron"), 5); + sp_full.add(rid("weapons"), 4); + + // Starved city: nothing on hand. + let mut sp_starved = ResourceStockpile::new(); + + let unit = mc_core::UnitId::new("axeman"); + let (out_full, stamp_full) = tick_and_stamp( + &buildings, + ®, + &mut sp_full, + unit.clone(), + &rid("weapons"), + ); + let (out_starved, stamp_starved) = tick_and_stamp( + &buildings, + ®, + &mut sp_starved, + unit, + &rid("weapons"), + ); + + assert!(out_full[0].fired(), "full-input city must fire its forge"); + assert!( + matches!(out_starved[0], RecipeOutcome::Idle { .. }), + "starved city must idle" + ); + assert_eq!( + stamp_full.quality, + QualityTier::Veteran, + "well-stocked weapons (4 buffer + 1 from this tick = 5) → Veteran" + ); + assert_eq!( + stamp_starved.quality, + QualityTier::Levy, + "no weapons on hand → Levy" + ); + assert!( + sp_starved.is_empty(), + "starved city stockpile must remain untouched" + ); + } + #[test] fn recipe_json_roundtrip() { let recipe = forge_recipe(); diff --git a/src/simulator/crates/mc-core/src/encounter.rs b/src/simulator/crates/mc-core/src/encounter.rs index 8ccaaf07..fe780054 100644 --- a/src/simulator/crates/mc-core/src/encounter.rs +++ b/src/simulator/crates/mc-core/src/encounter.rs @@ -243,6 +243,33 @@ pub fn roll_ambient_encounter( }) } +/// p2-59 — Active escort relationship between two units owned by the same +/// player. Per-spec (`SPECIALISTS.md`), a co-located or adjacent military +/// escort transfers ambient encounter resolution onto itself so the +/// pioneer / civilian's elevated `unit_kind_scale` (≥ 2.0) is suppressed. +/// +/// `protected_unit_id` is the civilian (founder / pioneer / similar). +/// `escort_unit_id` is the combat-capable companion. +/// +/// Both ids are the per-instance `MapUnit::id` (u32 entity handle, not the +/// catalog string). Range / staleness checks live in the caller — +/// `mc-turn::processor` derives an *implicit* link each turn from current +/// adjacency, so this struct also serves explicit assignment paths +/// (`escort_assign` action, follow-up `p2-59` bullet 4). +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub struct EscortLink { + pub protected_unit_id: u32, + pub escort_unit_id: u32, +} + +impl EscortLink { + #[inline] + #[must_use] + pub fn new(protected_unit_id: u32, escort_unit_id: u32) -> Self { + Self { protected_unit_id, escort_unit_id } + } +} + fn pick_group_size( ranges: &GroupSizeRanges, posture: EncounterPosture, diff --git a/src/simulator/crates/mc-core/src/lib.rs b/src/simulator/crates/mc-core/src/lib.rs index aa6430c2..f3561ff7 100644 --- a/src/simulator/crates/mc-core/src/lib.rs +++ b/src/simulator/crates/mc-core/src/lib.rs @@ -42,7 +42,7 @@ pub use civic::{AxisChoice, CivicAxis, CivicState, ANARCHY_DURATION, ANARCHY_SEN pub use gpp::{GppType, GreatWorkType}; pub use palace::PalaceTier; pub use encounter::{ - AmbientTileCtx, EncounterPosture, EncounterRates, EncounterSpec, GroupSizeRanges, + AmbientTileCtx, EncounterPosture, EncounterRates, EncounterSpec, EscortLink, GroupSizeRanges, roll_ambient_encounter, }; pub use derived_stats::{DerivedStats, InequalityStat}; diff --git a/src/simulator/crates/mc-turn/src/processor.rs b/src/simulator/crates/mc-turn/src/processor.rs index bb2f528e..13fe1f09 100644 --- a/src/simulator/crates/mc-turn/src/processor.rs +++ b/src/simulator/crates/mc-turn/src/processor.rs @@ -1839,6 +1839,52 @@ impl TurnProcessor { // tests without ecology data). if let Some(ref rates) = self.encounter_rates { if let Some(ref g) = state.grid { + // p2-59 — Build an implicit escort substitution map for + // this player's units. A "vulnerable" unit (civilian / + // pioneer roll-rate scale ≥ 1.5) co-located or adjacent + // (hex distance ≤ 1) to any same-player unit with a + // lower roll-rate scale routes its ambient encounter + // roll through the escort's `unit_id` instead. This + // dampens / substitutes the roll per the p2-59 brief + // and the SPECIALISTS.md escort design. Range rule + // (auto-break > 1 hex apart) is satisfied implicitly: + // when the pair is > 1 hex apart at roll time, no + // substitution fires. + const ESCORT_PROTECT_THRESHOLD: f32 = 1.5; + let vulnerable_scale = + |kind: &str| rates.unit_kind_scale(kind) >= ESCORT_PROTECT_THRESHOLD; + let escort_substitution: std::collections::HashMap = { + let units = &state.players[pi].units; + let mut map: std::collections::HashMap = + std::collections::HashMap::new(); + for u in units.iter().filter(|u| vulnerable_scale(&u.unit_id)) { + // Find the best (lowest roll-rate-scale) same-player + // unit within 1 hex that is *not* itself vulnerable. + let mut best: Option<(f32, String, u32)> = None; + for cand in units.iter() { + if cand.id == u.id { continue; } + if vulnerable_scale(&cand.unit_id) { continue; } + let d = mc_core::algorithms::hex::offset_distance( + u.col, u.row, cand.col, cand.row, + ); + if d > 1 { continue; } + let s = rates.unit_kind_scale(&cand.unit_id); + let take = match &best { + None => true, + Some((bs, _, bid)) => { + // Deterministic tie-break by unit.id ASC. + s < *bs || (s == *bs && cand.id < *bid) + } + }; + if take { best = Some((s, cand.unit_id.clone(), cand.id)); } + } + if let Some((_, kind, _)) = best { + map.insert(u.id, kind); + } + } + map + }; + for unit in &state.players[pi].units { let uc = unit.col; let ur = unit.row; @@ -1866,9 +1912,15 @@ impl TurnProcessor { ecology_tier: tile.ecosystem_tier.max(0) as u8, fauna_index: &tile.fauna_index, }; + // p2-59 — substitute escort unit_kind when a + // qualifying military companion is in-range. + let effective_kind: &str = escort_substitution + .get(&unit.id) + .map(String::as_str) + .unwrap_or(unit.unit_id.as_str()); if let Some(spec) = mc_core::encounter::roll_ambient_encounter( &ctx, - &unit.unit_id, + effective_kind, rates, &mut rng, ) {