diff --git a/.project/objectives/p1-59-hybrid-merged-structures.md b/.project/objectives/p1-59-hybrid-merged-structures.md index 079ee20c..c14377b3 100644 --- a/.project/objectives/p1-59-hybrid-merged-structures.md +++ b/.project/objectives/p1-59-hybrid-merged-structures.md @@ -198,3 +198,21 @@ Phase-B Rust simulation landed in cycle 33: **Remaining**: proof screenshot (requires GDExt .so build on apricot with Phase B + C code). Phase C GDScript is complete; headless GUT validation gated on apricot GDExt rebuild. **Status: partial** — proof screenshot pending. + +## Cycle 36 (2026-05-07) — Phase C close attempt + +Cycle 36 verified: +- apricot reachable (`ssh apricot echo OK` → OK). +- `cargo check --workspace` clean on plum (dev host) with all Phase C code present. +- GUT integration test `test_p1_59_merge_end_to_end.gd` has 4 tests authored (gdlint clean per cycle 34 notes). +- No proof scene at `src/game/engine/scenes/tests/proof_hybrid_merge.tscn` — file does not exist. + +**Cannot flip to `status: done`.** Per `phase-gate-protocol.md`, a proof scene must render all claimed features and the screenshot must be reviewed in-conversation. Since p1-59 is explicitly an out-of-scope (post-EA) objective, the remaining work is: + +1. Create `src/game/engine/scenes/tests/proof_hybrid_merge.tscn` — renders a city screen with merge_panel populated by two dummy prerequisite buildings, confirms the hybrid merge button appears, tech-gated row is greyed out. +2. Capture screenshot via `tools/screenshot.sh`, SCP to `$SCREENSHOT_HOST`, review in-conversation. +3. Flip status to `done` after screenshot approval. + +Alternatively: if the project lead decides p1-59 (post-EA) does not require a visual proof — only headless GUT — they may explicitly override the phase-gate requirement and flip to done directly. This decision is out of scope for the coordinator. + +**Status remains: partial.** diff --git a/src/game/engine/src/modules/ai/ai_turn_bridge_dispatch.gd b/src/game/engine/src/modules/ai/ai_turn_bridge_dispatch.gd index b70d5033..bd329518 100644 --- a/src/game/engine/src/modules/ai/ai_turn_bridge_dispatch.gd +++ b/src/game/engine/src/modules/ai/ai_turn_bridge_dispatch.gd @@ -35,6 +35,8 @@ static func dispatch_action( return dispatch_found_city(fields, player, index_maps, city_name) "SetProduction": return dispatch_set_production(fields, index_maps) + "EnqueueBuild": + return dispatch_enqueue_build(fields, index_maps) "AssignCitizen": return false "PromotionPicked": @@ -219,3 +221,22 @@ static func dispatch_set_production(fields: Dictionary, index_maps: Dictionary) city.production_progress = 0 return true return false + + +## p1-44c — per-building queue dispatch. Routes the item into the correct +## per-building queue on the city's Rust bridge. Falls back to the legacy +## dispatch_set_production path when the bridge is unavailable. +static func dispatch_enqueue_build(fields: Dictionary, index_maps: Dictionary) -> bool: + var city: RefCounted = resolve_city(int(fields.get("city_id", -1)), index_maps) + if city == null: + return false + var item_id: String = String(fields.get("item_id", "")) + var building_origin: String = String(fields.get("building_origin", "")) + if item_id.is_empty(): + return false + if city._bridge.is_available() and not building_origin.is_empty(): + # Route to the specific building queue via the Rust bridge. + city._bridge._gd_city.call("enqueue_to_building", building_origin, item_id) + return true + # Bridge unavailable — fall through to the legacy single-queue path. + return dispatch_set_production(fields, index_maps) diff --git a/src/simulator/api-gdext/src/lib.rs b/src/simulator/api-gdext/src/lib.rs index 717dc298..f9f25f58 100644 --- a/src/simulator/api-gdext/src/lib.rs +++ b/src/simulator/api-gdext/src/lib.rs @@ -1592,6 +1592,58 @@ impl GdCity { /// /// `available_resources` is the set of strategic-resource ids the owning /// player currently controls via worked city tiles. Items with a + /// Enqueue a unit or building item directly onto a named building queue + /// without stockpile validation. Used by the AI dispatch path (p1-44c) to + /// route items into per-building queues. The production cost is looked up + /// from the item registry; items not in the registry are silently dropped. + /// + /// `building_id` must match one of the city's queues (or + /// `"__city_center__"` for the city-center slot). Unknown building ids + /// create a new queue entry (the city's `queues` BTreeMap accepts any key). + #[func] + fn enqueue_to_building(&mut self, building_id: GString, item_id: GString) { + use mc_city::{BuildingQueue, QueueEntry, Queueable, CITY_CENTER_QUEUE_ID}; + use mc_core::{BuildingId, ProductionOrigin, UnitId}; + + let bid = building_id.to_string(); + let iid = item_id.to_string(); + + // Determine production cost and queueable variant from the item registry. + let (queueable, cost) = if let Some(def) = self.item_registry.get(&iid) { + ( + Queueable::Item { item_id: iid.clone() }, + def.production_cost, + ) + } else { + // Treat as unit (AI enqueues units by id; cost may be 0 if registry + // not populated — the production tick handles zero-cost completion). + ( + Queueable::Unit { unit_id: UnitId::new(iid.clone()) }, + 0, + ) + }; + + let origin = if bid == CITY_CENTER_QUEUE_ID || bid == "__city_center__" { + ProductionOrigin::CityCenter + } else { + ProductionOrigin::Building(BuildingId::new(bid.clone())) + }; + + let entry = QueueEntry { + queueable, + production_cost: cost, + production_invested: 0, + origin, + }; + + // Push onto the named queue (creates it if absent — BTreeMap semantics). + self.inner + .queues_mut() + .entry(bid) + .or_insert_with(BuildingQueue::new) + .push(entry); + } + /// `requires_resource` that is not in this set are rejected with a /// "requires strategic resource …" error — mirrors the GDScript-side /// buildable filter so a caller cannot bypass the gate by going straight diff --git a/src/simulator/crates/mc-ai/src/tactical/production.rs b/src/simulator/crates/mc-ai/src/tactical/production.rs index 5ae81718..1873c09a 100644 --- a/src/simulator/crates/mc-ai/src/tactical/production.rs +++ b/src/simulator/crates/mc-ai/src/tactical/production.rs @@ -1976,4 +1976,120 @@ mod tests { let out = decide_production(&s, &weights(), &mut rng(), None); assert_eq!(first_item(&out), ids::WORKER); } + + // ── p1-44c: per-building queue routing ─────────────────────────────── + + /// building_origin_for returns the producer building when the unit has a + /// requires_building gate, and CITY_CENTER_QUEUE_ID otherwise. + #[test] + fn production_per_building_unit_routes_to_producer_building() { + use super::super::state::TacticalUnitSpec; + use mc_core::BuildingId; + let catalog = vec![ + TacticalUnitSpec { + id: "warrior".into(), + tier: 1, + tech_required: None, + unit_type: "melee".into(), + requires_resource: None, + race_required: None, + clan_affinity: vec![], + archetype: None, + requires_building: None, // ungated + }, + TacticalUnitSpec { + id: "archer".into(), + tier: 2, + tech_required: None, + unit_type: "ranged".into(), + requires_resource: None, + race_required: None, + clan_affinity: vec![], + archetype: None, + requires_building: Some(BuildingId::new("barracks")), + }, + ]; + // ungated unit → city center + let origin_warrior = building_origin_for("warrior", &catalog); + assert_eq!( + origin_warrior, CITY_CENTER_QUEUE_ID, + "ungated unit must route to city center queue" + ); + // gated unit → producer building + let origin_archer = building_origin_for("archer", &catalog); + assert_eq!( + origin_archer, "barracks", + "barracks-gated unit must route to barracks queue" + ); + // non-unit item (building) → city center + let origin_forge = building_origin_for("forge", &catalog); + assert_eq!( + origin_forge, CITY_CENTER_QUEUE_ID, + "building construction must route to city center queue" + ); + } + + /// decide_production emits EnqueueBuild with correct building_origin. + #[test] + fn production_per_building_action_carries_building_origin() { + use super::super::state::TacticalUnitSpec; + use mc_core::BuildingId; + // Single city, no army, turn 10 → early-mil-floor fires → picks warrior. + // warrior has no requires_building → building_origin == CITY_CENTER_QUEUE_ID. + let s = state( + 0, + 10, + vec![player(0, "ironhold", Vec::new(), vec![city(1, (0, 0), 1, &[], &[], true)])], + ); + let out = decide_production(&s, &weights(), &mut rng(), None); + assert_eq!(out.len(), 1); + match &out[0] { + Action::EnqueueBuild { city_id, item_id, building_origin } => { + assert_eq!(*city_id, 1); + // warrior is the early-mil-floor choice + assert_eq!(item_id, ids::WARRIOR); + // no requires_building on warrior → city center + assert_eq!(building_origin, CITY_CENTER_QUEUE_ID); + } + a => panic!("expected EnqueueBuild, got {a:?}"), + } + + // Now test with a unit that HAS a requires_building gate. + // Build a catalog with one gated unit (archer → barracks). + let mut s2 = state( + 0, + 200, // past early-mil-floor cutoff + vec![player( + 0, + "ironhold", + (1..=5).map(|i| warrior(i, (1, 1))).collect(), // meet mil floor + vec![city(2, (0, 0), 2, &["barracks"], &[], true)], + )], + ); + // Override unit catalog with one gated unit to force the branch. + s2.unit_catalog = vec![TacticalUnitSpec { + id: "archer".into(), + tier: 1, + tech_required: None, + unit_type: "ranged".into(), + requires_resource: None, + race_required: None, + clan_affinity: vec![], + archetype: None, + requires_building: Some(BuildingId::new("barracks")), + }]; + s2.building_catalog = ladder_catalog(); + let out2 = decide_production(&s2, &weights(), &mut rng(), None); + assert!(!out2.is_empty()); + // Whatever item is chosen, its building_origin must be CITY_CENTER or a real building id. + match &out2[0] { + Action::EnqueueBuild { building_origin, .. } => { + assert!( + !building_origin.is_empty(), + "building_origin must not be empty" + ); + } + a => panic!("expected EnqueueBuild, got {a:?}"), + } + } } diff --git a/src/simulator/crates/mc-ai/src/tactical/state.rs b/src/simulator/crates/mc-ai/src/tactical/state.rs index 0ddef998..f05d4742 100644 --- a/src/simulator/crates/mc-ai/src/tactical/state.rs +++ b/src/simulator/crates/mc-ai/src/tactical/state.rs @@ -672,6 +672,11 @@ mod tests { city_id: 10, item_id: "forge".into(), }, + Action::EnqueueBuild { + city_id: 10, + item_id: "warrior".into(), + building_origin: "barracks".into(), + }, Action::AssignCitizen { city_id: 10, tile_hex: (1, 0), diff --git a/src/simulator/crates/mc-city/src/city.rs b/src/simulator/crates/mc-city/src/city.rs index f46d086a..683371df 100644 --- a/src/simulator/crates/mc-city/src/city.rs +++ b/src/simulator/crates/mc-city/src/city.rs @@ -453,6 +453,12 @@ impl City { &self.queues } + /// Mutable access to the full queues map. Used by the GDExt bridge to + /// directly push entries from the AI dispatch path. (p1-44c) + pub fn queues_mut(&mut self) -> &mut BTreeMap { + &mut self.queues + } + /// Apply `total_production` across every non-empty queue using a /// deterministic equal split. Empty queues are skipped (no production /// "wasted"); the remaining queues each receive `floor(total / N)`