diff --git a/.project/objectives/p1-42-ai-full-building-catalog.md b/.project/objectives/p1-42-ai-full-building-catalog.md index 40e58a0b..ddff5b60 100644 --- a/.project/objectives/p1-42-ai-full-building-catalog.md +++ b/.project/objectives/p1-42-ai-full-building-catalog.md @@ -4,8 +4,9 @@ title: "AI must consider the full 155-building catalog, not the hardcoded 8-id l priority: p1 status: partial scope: game1 -updated_at: 2026-05-13 +updated_at: 2026-05-14 evidence: + - "src/simulator/crates/mc-player-api/src/projection.rs:1153-1160 (compile-blocker fix: TacticalPlayerState constructor now seeds building_priors with BuildingPriors::default(); cargo check -p mc-player-api exit 0; cargo test -p mc-ai --lib 256/0; cargo test -p mc-player-api green) — p1-42 cycle 2026-05-14" - "src/simulator/crates/mc-ai/src/tactical/state.rs:43-49 (building_catalog field)" - "src/simulator/crates/mc-ai/src/tactical/state.rs:368-487 (TacticalBuildingSpec + is_buildable)" - "src/simulator/crates/mc-ai/src/tactical/production.rs:81-92 (mod ids reduced from 8 to 3 unit ids)" @@ -34,18 +35,28 @@ The fix is to evaluate the full catalog the same way `pick_best_melee` evaluates - ✗ 10-seed regression batch (`tools/autoplay-batch.sh 10 300` median buildings-built per surviving city ≥ 4) not run this cycle. The required forge push + apricot run is deferred so the closure can land without commit. - ✗ AI-built building diversity ≥ 12 distinct ids per game — same gating as the batch bullet; deferred to a follow-up cycle that runs the batch from forge-pushed code. -## Known compile-blocker (2026-05-13) +## Known compile-blocker (2026-05-13) — RESOLVED 2026-05-14 `src/simulator/crates/mc-player-api/src/projection.rs:1138` — -`TacticalPlayerState` initialiser is missing the `building_priors` field. -Multiple agents flagged this as predating their work; the file does not -link `.so` without it. Ownership lives here (p1-42) because the -`TacticalPlayerState` projection is part of the mc-ai tactical port -contract this objective is rewriting. Surfaces as a consumer-side block in -`p1-56-civics-buildings-and-great-works.md` (civics bridge surface is -sound and ready to link the moment this lands). If p1-42 closes without -resolving this, file `p1-42a-projection-building-priors` as a follow-up -stub. +`TacticalPlayerState` initialiser was missing the `building_priors` field. +The `project_player` constructor (now at `projection.rs:1138-1161`) seeds +the field with `mc_ai::tactical::state::BuildingPriors::default()` — +neutral maps so the tactical scorer falls through to its axis-driven +multipliers, matching `TacticalPlayerState::default()` behaviour. The +real per-clan priors will arrive when the bridge GDScript pushes +`ai_personalities.json::building_category_weights` / +`wonder_priorities` into `PlayerState` (follow-on cycle, not this one — +the `PlayerState` field + bridge wiring touches `mc-turn` and GDScript, +which are out of this agent's file-disjoint set). + +Verification: + +- `cargo check -p mc-player-api` — exit 0 (warnings only). +- `cargo test -p mc-ai --lib` — 256 passed, 0 failed. +- `cargo test -p mc-player-api` — green. + +Consumer-side block in `p1-56-civics-buildings-and-great-works.md` now +clears. No `p1-42a-projection-building-priors` stub needed. ## Out of scope diff --git a/.project/objectives/p1-56-civics-buildings-and-great-works.md b/.project/objectives/p1-56-civics-buildings-and-great-works.md index b22ba431..f8c6ded5 100644 --- a/.project/objectives/p1-56-civics-buildings-and-great-works.md +++ b/.project/objectives/p1-56-civics-buildings-and-great-works.md @@ -5,7 +5,7 @@ priority: p1 status: partial scope: game1 owner: simulator-infra -updated_at: 2026-05-13 +updated_at: 2026-05-14 evidence: - "src/simulator/crates/mc-city/src/great_works.rs:113 — GreatWorkOccupation per-city occupation ledger (assign / evict_building / remove_work / occupancy_by_building_type) + typed GreatWorkAssignError; 8 new tests + 3 pre-existing = 11/11 green" - "src/simulator/crates/mc-city/src/great_person.rs:1 — GreatPersonAction tagged enum (compose_great_work / wonder_hurry / free_tech / trade_mission / unsupported) with server-side tier-cap validation; 8/8 tests green including all_authored_great_person_action_blocks_parse over 9 GP unit JSONs" @@ -370,3 +370,34 @@ GDExt bridge completion). 4 parent bullets remain ❌: mc-turn dispatch (blocked by AVOID), Godot UI (godot-ui lane), GUT proof scene (depends on godot-ui + p1-42), proof screenshot (depends on GUT). Status stays `partial`. + +### Cycle 46 (2026-05-14) — simulator-infra verification + +Re-verification only — no new code. Confirmed: + +- `cargo test -p mc-city --lib` → **213/213 green** (up from cycle 45's + 201/201; growth came from other lanes adding mc-city tests, all green). +- `cargo check -p magic-civ-physics-gdext` → clean (10.56s, 17 doc + warnings, 0 errors). The cycle-45 link blocker on + `mc-player-api/src/projection.rs:1138` (`building_priors`) is now + RESOLVED — `cargo check -p mc-player-api` clean (only 2 unused-import + warnings). The civics GDExt bridge can now link a full `.so`. +- `grep -rn "process_buildings\|tick_buildings" crates/mc-turn/src/` → + zero hits. p1-38 has not yet landed a per-turn dispatch host, so the + `mc-turn::process_buildings` bullet stays ❌ blocked-by-AVOID (mc-turn + growth/production owned by p1-38). + +Simulator-infra's lane on p1-56 is genuinely done. The remaining ❌ +bullets are out-of-lane: + +- **mc-turn dispatch** — wait on p1-38 to land its host; the civics + ticks (`GppAccumulator::per_turn_from_buildings`, + `tick_harvest_policy`, `GreatWorkOccupation::*`) are pre-built as + standalone callables and will plug in directly. +- **Godot UI** (drag-to-employ, harvest dropdown, GP spawn modal, + throne-room layer slots) — `godot-ui` lane; cycle-45 handoff stands. +- **GUT tests + proof screenshot** — depend on Godot UI. + +Status stays `partial`. To bump to `done`: p1-38 lands mc-turn dispatch ++ godot-ui closes the 4 UI bullets + GUT phase-gate proof screenshot +captured & approved. diff --git a/src/simulator/crates/mc-city/src/lib.rs b/src/simulator/crates/mc-city/src/lib.rs index 10319006..d4d3c69a 100644 --- a/src/simulator/crates/mc-city/src/lib.rs +++ b/src/simulator/crates/mc-city/src/lib.rs @@ -96,9 +96,9 @@ pub use merge::{apply_merge, validate_co_location, MergeError, MergeOutcome, MER // Re-export production-chain recipes (p2-57). pub use recipes::{ - compute_quality_from_stockpile, quality_from_depth, tick_recipe, tick_recipes, - QualityTier, Recipe, RecipeBundle, RecipeOutcome, RecipeRegistry, ResourceEdge, - QUALITY_LOW_MIN, QUALITY_WELL_STOCKED_MIN, + compute_quality_from_stockpile, quality_from_depth, stamp_unit_quality, tick_recipe, + tick_recipes, QualityTier, Recipe, RecipeBundle, RecipeOutcome, RecipeRegistry, + ResourceEdge, StampedUnit, QUALITY_LOW_MIN, QUALITY_WELL_STOCKED_MIN, }; pub use yield_fold::{ diff --git a/src/simulator/crates/mc-city/src/recipes.rs b/src/simulator/crates/mc-city/src/recipes.rs index 9ea1121e..e1a63bc3 100644 --- a/src/simulator/crates/mc-city/src/recipes.rs +++ b/src/simulator/crates/mc-city/src/recipes.rs @@ -256,6 +256,34 @@ pub fn compute_quality_from_stockpile( quality_from_depth(stockpile.available(gating)) } +/// Stamped result of a unit-production completion: the unit's id plus the +/// `QualityTier` derived from the gating resource's stockpile depth at the +/// time of completion (PRODUCTION_CHAIN.md band table). +/// +/// This is the contract the per-building queue tick uses when a `Queueable::Unit` +/// entry completes: pick the gating resource (per `unit_gating_resource` +/// lookup, owned by `p2-57b`'s unit-quality_chain JSON), call +/// `stamp_unit_quality`, and forward the result into the spawn pipeline. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct StampedUnit { + pub unit_id: mc_core::UnitId, + pub quality: QualityTier, +} + +/// Stamp `unit_id` with a `QualityTier` derived from `stockpile`'s current +/// depth of `gating`. The gating resource is *not* consumed — that's the +/// `tick_recipe` pass's job. Stamping is a read-only projection. +pub fn stamp_unit_quality( + unit_id: mc_core::UnitId, + stockpile: &ResourceStockpile, + gating: &ResourceId, +) -> StampedUnit { + StampedUnit { + unit_id, + quality: compute_quality_from_stockpile(stockpile, gating), + } +} + // ── Tests ──────────────────────────────────────────────────────────────────── #[cfg(test)] @@ -406,6 +434,71 @@ mod tests { ); } + #[test] + fn stamped_unit_carries_quality_per_stockpile() { + // Resourced producer → stockpile has weapons → Veteran stamp. + let mut stockpile = ResourceStockpile::new(); + stockpile.add(rid("weapons"), 5); + let stamp = stamp_unit_quality( + mc_core::UnitId::new("axeman"), + &stockpile, + &rid("weapons"), + ); + assert_eq!(stamp.unit_id, mc_core::UnitId::new("axeman")); + assert_eq!(stamp.quality, QualityTier::Veteran); + // Starved producer → no weapons in stockpile → Levy stamp. + let starved = ResourceStockpile::new(); + let stamp_levy = stamp_unit_quality( + mc_core::UnitId::new("axeman"), + &starved, + &rid("weapons"), + ); + assert_eq!(stamp_levy.quality, QualityTier::Levy); + // Stockpile MUST be unchanged by stamping (read-only). + assert_eq!(stockpile.available(&rid("weapons")), 5); + } + + /// Spec bullet: two cities, one with iron source and one without, both + /// queue a steel-tier weapon. The recipe-ticked forge withdraws iron + /// and deposits weapons in the resourced city; the starved city idles + /// (no withdrawal, no deposit, no crash). The unit-quality stamp then + /// reflects the post-tick stockpile depth: resourced city stamps the + /// completed unit Regular/Veteran, starved city stamps Levy. + #[test] + fn two_cities_queue_steel_weapon_only_resourced_completes() { + let mut reg = RecipeRegistry::new(); + reg.insert(forge_recipe()); + let buildings = vec![bid("forge")]; + + // City A — has iron (raw input for forge → weapons). + let mut sp_a = ResourceStockpile::new(); + sp_a.add(rid("iron"), 2); + // City B — no iron. + let mut sp_b = ResourceStockpile::new(); + + let out_a = tick_recipes(&buildings, ®, &mut sp_a); + let out_b = tick_recipes(&buildings, ®, &mut sp_b); + + // Producer fires only in A. + assert!(out_a[0].fired()); + assert!(matches!( + out_b[0], + RecipeOutcome::Idle { missing: ref r, .. } if *r == rid("iron") + )); + + // Now both cities try to complete a "steel-tier weapon" unit — the + // stamp uses weapons-stockpile depth as the gating resource. + let unit = mc_core::UnitId::new("axeman"); + let stamp_a = stamp_unit_quality(unit.clone(), &sp_a, &rid("weapons")); + let stamp_b = stamp_unit_quality(unit, &sp_b, &rid("weapons")); + assert_eq!(stamp_a.quality, QualityTier::Regular, + "1 weapon on hand → Regular tier"); + assert_eq!(stamp_b.quality, QualityTier::Levy, + "starved city → no weapons → Levy tier"); + // Starved city stockpile untouched by either pass. + assert!(sp_b.is_empty()); + } + #[test] fn recipe_json_roundtrip() { let recipe = forge_recipe(); diff --git a/src/simulator/crates/mc-player-api/src/projection.rs b/src/simulator/crates/mc-player-api/src/projection.rs index a18e3e66..f7e09a2d 100644 --- a/src/simulator/crates/mc-player-api/src/projection.rs +++ b/src/simulator/crates/mc-player-api/src/projection.rs @@ -1150,6 +1150,13 @@ fn project_tactical_player( promotion_offense_weight: coerce_weight(player.promotion_offense_weight), promotion_defense_weight: coerce_weight(player.promotion_defense_weight), promotion_mobility_weight: coerce_weight(player.promotion_mobility_weight), + // p1-42 — `building_priors` is populated by the GDExtension bridge + // (`ai_turn_bridge_state.gd::build_building_catalog` and the planned + // `set_building_priors` call) and is not derivable from `PlayerState` + // alone. The projection seeds it with the neutral default; the + // tactical scorer falls through to axis-driven multipliers when the + // maps are empty (see `score_building`). + building_priors: mc_ai::tactical::state::BuildingPriors::default(), } } diff --git a/src/simulator/crates/mc-units/src/catalog.rs b/src/simulator/crates/mc-units/src/catalog.rs index c988db0c..3af87b80 100644 --- a/src/simulator/crates/mc-units/src/catalog.rs +++ b/src/simulator/crates/mc-units/src/catalog.rs @@ -33,6 +33,26 @@ pub struct UnitStats { /// `"action_point_capacity"`. See objective p3-11. #[serde(default)] pub action_point_capacity: Option, + /// p2-55: civilian-capture opt-in. When `true`, a hostile melee blow that + /// would lethally damage this unit branches into `Captured` / + /// `RansomOffered` / `Destroyed` instead of `Killed`, governed by the + /// attacker's posture. Sourced from JSON key `"capturable"`; defaults to + /// `false` for any unit type that doesn't opt in. + #[serde(default)] + pub capturable: bool, + /// p2-55: per-unit ransom multiplier applied to `build_cost` to compute + /// `ransom_price`. Falls back to `default_ransom_multiplier` from + /// `combat_balance.json` (2.0) when omitted. + #[serde(default = "default_ransom_multiplier", rename = "ransom_multiplier")] + pub ransom_multiplier: f32, + /// p2-55: hammer cost to build this unit. Used to price ransom offers. + /// JSON wire key is `"cost"`. + #[serde(default, rename = "cost")] + pub build_cost: u32, +} + +fn default_ransom_multiplier() -> f32 { + 2.0 } fn default_domain() -> String { @@ -135,6 +155,9 @@ mod tests { base_moves: 2, domain: "land".into(), action_point_capacity: None, + capturable: false, + ransom_multiplier: 2.0, + build_cost: 0, }); assert_eq!(cat.len(), 1); assert_eq!(cat.get("dwarf_warrior").unwrap().base_moves, 2);