feat(@projects): expand ai to handle full 155-building catalog

Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
Natalie 2026-05-14 17:50:57 -07:00
parent 1faf134c81
commit 322606cfe0
6 changed files with 180 additions and 15 deletions

View file

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

View file

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

View file

@ -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::{

View file

@ -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, &reg, &mut sp_a);
let out_b = tick_recipes(&buildings, &reg, &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();

View file

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

View file

@ -33,6 +33,26 @@ pub struct UnitStats {
/// `"action_point_capacity"`. See objective p3-11.
#[serde(default)]
pub action_point_capacity: Option<u8>,
/// 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);