feat(@projects/@magic-civilization): ✨ add hybrid merge ai production bridge
Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
parent
052d3a31e6
commit
fa70bc2c79
6 changed files with 218 additions and 0 deletions
|
|
@ -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.**
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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:?}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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)`
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue