feat(@projects/@magic-civilization): implement consume/produce edge system

Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
Natalie 2026-05-15 07:21:18 -07:00
parent e22954724a
commit fea7584cc6
7 changed files with 224 additions and 10 deletions

View file

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

View file

@ -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).

View file

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

View file

@ -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<RecipeOutcome>, 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,
&reg,
&mut sp_full,
unit.clone(),
&rid("weapons"),
);
let (out_starved, stamp_starved) = tick_and_stamp(
&buildings,
&reg,
&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();

View file

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

View file

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

View file

@ -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<u32, String> = {
let units = &state.players[pi].units;
let mut map: std::collections::HashMap<u32, String> =
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,
) {