feat(@projects/@magic-civilization): ✨ implement consume/produce edge system
Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
parent
e22954724a
commit
fea7584cc6
7 changed files with 224 additions and 10 deletions
|
|
@ -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`.
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
®,
|
||||
&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();
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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};
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
) {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue