feat(@projects/@magic-civilization): ✨ expose building production queues via GD API
Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
parent
fc2f4316b6
commit
052d3a31e6
4 changed files with 123 additions and 16 deletions
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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 ──────────────────────────────────────────
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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}"
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue