From 052d3a31e64ef7e1f6b14f7666969b7cd07bc00f Mon Sep 17 00:00:00 2001 From: Natalie Date: Wed, 6 May 2026 22:10:53 -0700 Subject: [PATCH] =?UTF-8?q?feat(@projects/@magic-civilization):=20?= =?UTF-8?q?=E2=9C=A8=20expose=20building=20production=20queues=20via=20GD?= =?UTF-8?q?=20API?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Lilith Autocommit --- src/game/engine/scenes/city/city_screen.gd | 4 +- src/simulator/api-gdext/src/lib.rs | 50 ++++++++++++++ .../crates/mc-ai/src/tactical/mod.rs | 20 ++++++ .../crates/mc-ai/src/tactical/production.rs | 65 ++++++++++++++----- 4 files changed, 123 insertions(+), 16 deletions(-) diff --git a/src/game/engine/scenes/city/city_screen.gd b/src/game/engine/scenes/city/city_screen.gd index 263dcd03..c03bb561 100644 --- a/src/game/engine/scenes/city/city_screen.gd +++ b/src/game/engine/scenes/city/city_screen.gd @@ -383,7 +383,9 @@ func _refresh_building_queues() -> void: if not _city is CityScript: return - var queues: Dictionary = _city.get("queues", {}) as Dictionary + if not _city._bridge.is_available(): + return + var queues: Dictionary = _city._bridge._gd_city.call("get_building_queues") as Dictionary if queues.is_empty(): return diff --git a/src/simulator/api-gdext/src/lib.rs b/src/simulator/api-gdext/src/lib.rs index 99c3927e..717dc298 100644 --- a/src/simulator/api-gdext/src/lib.rs +++ b/src/simulator/api-gdext/src/lib.rs @@ -2149,6 +2149,56 @@ impl GdCity { &capacity_cfg, ) } + + /// Returns all per-building production queues as a `Dictionary` keyed by + /// building id (String). Each value is a nested `Dictionary` with the shape: + /// + /// ```text + /// { + /// "items": Array[Dictionary] -- [{item_id, cost}, ...] + /// "production_points": int -- production invested in the head entry + /// } + /// ``` + /// + /// The shape matches the wire-format contract tested by + /// `test_p1_44c_per_building_ui.gd`. BTreeMap iteration order is + /// alphabetical by building id, preserving determinism across turns. + /// + /// `items[N].item_id` is the data-pack id string for the queued + /// `Queueable` (unit id / item id / wonder id). `items[N].cost` is the + /// full production cost (hammers). `production_points` reflects how many + /// hammers have already been invested in the head entry (0 when the queue + /// is empty or the head hasn't been worked yet). + #[func] + fn get_building_queues(&self) -> Dictionary { + use mc_city::Queueable; + + let mut out = Dictionary::new(); + for (building_id, queue) in self.inner.queues() { + let mut items: Array = Array::new(); + for entry in queue.entries() { + let item_id: String = match &entry.queueable { + Queueable::Unit { unit_id } => unit_id.to_string(), + Queueable::Item { item_id } => item_id.clone(), + Queueable::Wonder { wonder_id } => wonder_id.to_string(), + }; + let mut item_dict = Dictionary::new(); + item_dict.set("item_id", GString::from(&item_id)); + item_dict.set("cost", entry.production_cost as i64); + items.push(&item_dict); + } + let production_points: i64 = queue + .entries() + .first() + .map(|e| e.production_invested as i64) + .unwrap_or(0); + let mut bq_dict = Dictionary::new(); + bq_dict.set("items", items); + bq_dict.set("production_points", production_points); + out.set(GString::from(building_id.as_str()), bq_dict); + } + out + } } // ── Private helpers for GdCity ────────────────────────────────────────── diff --git a/src/simulator/crates/mc-ai/src/tactical/mod.rs b/src/simulator/crates/mc-ai/src/tactical/mod.rs index d847fdfa..1bd5a9f9 100644 --- a/src/simulator/crates/mc-ai/src/tactical/mod.rs +++ b/src/simulator/crates/mc-ai/src/tactical/mod.rs @@ -119,6 +119,9 @@ pub enum Action { }, /// Set `city_id`'s production queue head to `item_id` /// (building/unit/wonder data-pack id). + /// + /// Legacy single-queue variant — kept for replay file compat. New AI + /// production decisions emit [`Action::EnqueueBuild`] instead. (p1-44c) SetProduction { /// City identifier. city_id: u32, @@ -126,6 +129,23 @@ pub enum Action { /// `"building_forge"`). item_id: String, }, + /// Enqueue `item_id` onto the per-building queue of `building_origin` + /// inside `city_id`. Replaces the legacy `SetProduction` for all new AI + /// production decisions (p1-44c). + /// + /// `building_origin` is the building id whose queue should receive the + /// item (e.g. `"barracks"` for warrior, `"__city_center__"` for + /// constructions that have no producer-building gate). Matches + /// `mc_city::CITY_CENTER_QUEUE_ID` for the city-level slot. + EnqueueBuild { + /// City identifier. + city_id: u32, + /// Data-pack item/unit/wonder id to enqueue. + item_id: String, + /// Building queue to route into. `"__city_center__"` for items with + /// no producer-building requirement (buildings, wonders, ungated units). + building_origin: String, + }, /// Assign an unemployed citizen of `city_id` to work `tile_hex`. AssignCitizen { /// City identifier. diff --git a/src/simulator/crates/mc-ai/src/tactical/production.rs b/src/simulator/crates/mc-ai/src/tactical/production.rs index a7801a06..5ae81718 100644 --- a/src/simulator/crates/mc-ai/src/tactical/production.rs +++ b/src/simulator/crates/mc-ai/src/tactical/production.rs @@ -141,10 +141,11 @@ enum Posture { Steady, } -/// Emit `SetProduction` for each city belonging to `state.current_player` +/// Emit `EnqueueBuild` for each city belonging to `state.current_player` /// whose production queue is empty. Matches GDScript /// `for ci in player.cities.size(): if city.production_queue.is_empty(): -/// _decide_production(...)`. +/// _decide_production(...)`. Each action carries a `building_origin` so the +/// bridge routes it to the correct per-building queue. (p1-44c) /// /// RNG is threaded through but unconsumed today; the priority ladder is /// deterministic in state. Leaving the parameter lets future scoring noise @@ -195,14 +196,47 @@ pub(crate) fn decide_production( &state.building_catalog, _weights, ); - out.push(Action::SetProduction { + // Route to the producer building's queue when the item is a unit + // with a `requires_building` gate; otherwise use the city-center + // queue (buildings, wonders, ungated units). (p1-44c) + let building_origin = building_origin_for(&item, &state.unit_catalog); + out.push(Action::EnqueueBuild { city_id: city.id, item_id: item, + building_origin, }); } out } +/// Resolve the producer building queue id for a picked item. (p1-44c) +/// +/// When `item_id` matches a unit in the catalog that has a +/// `requires_building` gate, returns that building id so the action is +/// routed to the correct per-building queue. All other items (buildings, +/// wonders, ungated units) route to `"__city_center__"` — the city-level +/// construction slot (matches `mc_city::CITY_CENTER_QUEUE_ID`). +fn building_origin_for( + item_id: &str, + unit_catalog: &[super::state::TacticalUnitSpec], +) -> String { + for spec in unit_catalog { + if spec.id == item_id { + if let Some(ref bld) = spec.requires_building { + return bld.to_string(); + } + // Unit found but no building gate — city center. + return CITY_CENTER_QUEUE_ID.to_string(); + } + } + // Not a unit (building/wonder/worker) — always city center. + CITY_CENTER_QUEUE_ID.to_string() +} + +/// The city-level production slot id, matching `mc_city::CITY_CENTER_QUEUE_ID`. +/// Defined here to avoid adding `mc-city` as a dependency of `mc-ai`. (p1-44c) +const CITY_CENTER_QUEUE_ID: &str = "__city_center__"; + #[allow(clippy::too_many_arguments)] fn pick_for_city( city: &TacticalCity, @@ -855,8 +889,8 @@ mod tests { fn first_item(actions: &[Action]) -> &str { match &actions[0] { - Action::SetProduction { item_id, .. } => item_id, - a => panic!("expected SetProduction, got {a:?}"), + Action::EnqueueBuild { item_id, .. } => item_id, + a => panic!("expected EnqueueBuild, got {a:?}"), } } @@ -1166,7 +1200,7 @@ mod tests { #[test] fn cities_with_non_empty_queue_skipped() { // Two cities; one has a queue head ("warrior"), the other is empty. - // Only the empty-queue city should get a SetProduction. + // Only the empty-queue city should get an EnqueueBuild. let s = state( 0, 10, @@ -1183,7 +1217,7 @@ mod tests { let out = decide_production(&s, &weights(), &mut rng(), None); assert_eq!(out.len(), 1); match &out[0] { - Action::SetProduction { city_id, .. } => assert_eq!(*city_id, 20), + Action::EnqueueBuild { city_id, .. } => assert_eq!(*city_id, 20), a => panic!("unexpected: {a:?}"), } } @@ -1300,7 +1334,7 @@ mod tests { let out = decide_production(&s, &weights(), &mut rng(), None); for a in &out { match a { - Action::SetProduction { item_id, .. } => { + Action::EnqueueBuild { item_id, .. } => { assert_ne!( item_id, "forge", @@ -1403,7 +1437,7 @@ mod tests { assert_eq!(out.len(), 2); for a in &out { match a { - Action::SetProduction { item_id, .. } => { + Action::EnqueueBuild { item_id, .. } => { assert_eq!(item_id, "marketplace"); } other => panic!("unexpected: {other:?}"), @@ -1462,7 +1496,7 @@ mod tests { let mut items: std::collections::HashMap = Default::default(); for a in &out { match a { - Action::SetProduction { city_id, item_id } => { + Action::EnqueueBuild { city_id, item_id, .. } => { items.insert(*city_id, item_id.clone()); } o => panic!("unexpected: {o:?}"), @@ -1497,11 +1531,12 @@ mod tests { for (x, y) in a.iter().zip(b.iter()) { match (x, y) { ( - Action::SetProduction { city_id: ax, item_id: ai }, - Action::SetProduction { city_id: bx, item_id: bi }, + Action::EnqueueBuild { city_id: ax, item_id: ai, building_origin: ao }, + Action::EnqueueBuild { city_id: bx, item_id: bi, building_origin: bo }, ) => { assert_eq!(ax, bx); assert_eq!(ai, bi); + assert_eq!(ao, bo); } _ => panic!("unexpected variant"), } @@ -1511,7 +1546,7 @@ mod tests { #[test] fn real_city_ids_used_not_synthetic() { // Regression on the Task #6 re-open. city.id from TacticalCity - // must flow through to Action::SetProduction — no (slot * 10_000) + // must flow through to Action::EnqueueBuild — no (slot * 10_000) // synthesis. let s = state( 0, @@ -1530,7 +1565,7 @@ mod tests { let ids: Vec = out .iter() .map(|a| match a { - Action::SetProduction { city_id, .. } => *city_id, + Action::EnqueueBuild { city_id, .. } => *city_id, _ => unreachable!(), }) .collect(); @@ -1821,7 +1856,7 @@ mod tests { // Wonder gets +5.0 flat → outscores everything else, both cities pick it. for a in &out { match a { - Action::SetProduction { item_id, .. } => assert_eq!( + Action::EnqueueBuild { item_id, .. } => assert_eq!( item_id, "the_great_forge", "catalog scorer must pick the highest-scoring building from the \ FULL catalog (including wonders + non-ladder ids); got {item_id}"