feat(@projects): ✨ expand ai to handle full 155-building catalog
Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
parent
1faf134c81
commit
322606cfe0
6 changed files with 180 additions and 15 deletions
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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::{
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue