feat(@projects/@magic-civilization): expose building production queues via GD API

Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
Natalie 2026-05-06 22:10:53 -07:00
parent fc2f4316b6
commit 052d3a31e6
4 changed files with 123 additions and 16 deletions

View file

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

View file

@ -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<Dictionary> = 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 ──────────────────────────────────────────

View file

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

View file

@ -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<u32, String> = 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<u32> = 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}"