feat(@projects/@magic-civilization): add hybrid merge ai production bridge

Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
Natalie 2026-05-06 22:16:48 -07:00
parent 052d3a31e6
commit fa70bc2c79
6 changed files with 218 additions and 0 deletions

View file

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

View file

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

View file

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

View file

@ -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:?}"),
}
}
}

View file

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

View file

@ -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<String, BuildingQueue> {
&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)`