feat(@projects/@magic-civilization): 🗺️ p3-29 (2) — surface CityBordersExpanded as a turn event

Second p3-29 step-1 event: the Rust turn emits border expansion (process_culture now takes
&mut events, collects claimed tiles + flushes TurnEvent::CityBordersExpanded with clan/city/hex)
— single-source replacement for the GDScript turn's inline `city_border_expanded` signal.
Surfaced through all four TurnEvent consumers + tested (culture_expansion_claims_frontier_tiles
now asserts the event). Events-only → no state change → golden/combat unaffected. 2/3 step-1
events done (CityGrew, CityBordersExpanded); FloraSuccession next.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Natalie 2026-06-27 02:43:38 -04:00
parent f829d87e59
commit 841f741ed5
5 changed files with 60 additions and 7 deletions

View file

@ -210,6 +210,14 @@ fn event_to_dict(evt: &TurnEvent) -> Dictionary {
d.set("col", hex.q as i64);
d.set("row", hex.r as i64);
}
TurnEvent::CityBordersExpanded { turn, clan, city, hex } => {
d.set("kind", GString::from("CityBordersExpanded"));
d.set("turn", *turn as i64);
d.set("clan", clan.0 as i64);
d.set("city", GString::from(city.0.as_str()));
d.set("col", hex.q as i64);
d.set("row", hex.r as i64);
}
TurnEvent::GameOver { turn, winner, reason_kind, condition, resigned_clan } => {
d.set("kind", GString::from("GameOver"));
d.set("turn", *turn as i64);

View file

@ -979,9 +979,10 @@ fn translate_processor_events(events: &[mc_replay::TurnEvent]) -> Vec<Event> {
| mc_replay::TurnEvent::EnvelopeIntercepted { .. }
| mc_replay::TurnEvent::LinkSevered { .. }
| mc_replay::TurnEvent::LinkRestored { .. }
// p3-29: surfaced for replay + the live UI (city growth), not the
// wire protocol — drop here to keep the match exhaustive.
| mc_replay::TurnEvent::CityGrew { .. } => {}
// p3-29: surfaced for replay + the live UI (city growth / border
// expansion), not the wire protocol — drop here to keep it exhaustive.
| mc_replay::TurnEvent::CityGrew { .. }
| mc_replay::TurnEvent::CityBordersExpanded { .. } => {}
}
}
out

View file

@ -121,6 +121,18 @@ pub enum TurnEvent {
/// Hex the city sits on, for the viewer to centre on.
hex: TileCoord,
},
/// p3-29: a city expanded its borders, claiming a new tile (single-source
/// replacement for the GDScript turn's inline `city_border_expanded` signal).
CityBordersExpanded {
/// Turn the event fired on.
turn: u32,
/// Owning clan.
clan: ClanId,
/// Synthesised city name (`city_<pi>_<idx>`).
city: CityName,
/// The newly-claimed tile.
hex: TileCoord,
},
/// A wonder finished construction.
WonderBuilt {
/// Turn the event fired on.
@ -514,6 +526,7 @@ impl TurnEvent {
| Self::UnitCreated { turn, .. }
| Self::CityBuildingCompleted { turn, .. }
| Self::CityGrew { turn, .. }
| Self::CityBordersExpanded { turn, .. }
| Self::WonderBuilt { turn, .. }
| Self::WarDeclared { turn, .. }
| Self::PeaceSigned { turn, .. }

View file

@ -434,7 +434,7 @@ impl TurnProcessor {
self.process_city_production(state, pi, &mut result.events_emitted);
self.try_found_city(state, pi, &mut result.events_emitted);
self.try_spawn_unit(state, pi, &mut result);
self.process_culture(state, pi);
self.process_culture(state, pi, &mut result.events_emitted);
self.process_culture_research(state, pi);
self.process_science(state, pi, &mut result.events_emitted);
}
@ -1129,7 +1129,12 @@ impl TurnProcessor {
}
}
fn process_culture(&self, state: &mut GameState, pi: usize) {
fn process_culture(
&self,
state: &mut GameState,
pi: usize,
events: &mut Vec<mc_replay::TurnEvent>,
) {
// Grid dims read before the &mut player borrow (needed for in-bounds
// candidate filtering during border expansion).
let (gw, gh) = state
@ -1137,6 +1142,10 @@ impl TurnProcessor {
.as_ref()
.map(|g| (g.width, g.height))
.unwrap_or((0, 0));
let turn = state.turn;
// p3-29: tiles claimed this turn (city_idx, tile), flushed as
// `TurnEvent::CityBordersExpanded` so the live UI + replay see expansion.
let mut expanded: Vec<(usize, (i32, i32))> = Vec::new();
let player = &mut state.players[pi];
let culture_axis = *player.strategic_axes.get("culture").unwrap_or(&2);
let per_city_yield = culture_axis as f64 * Self::BENCH_CULTURE_PER_AXIS_POINT;
@ -1197,11 +1206,22 @@ impl TurnProcessor {
ot.push(pick);
claimed.insert(pick);
player.culture_pool.consume_expansion(ci);
expanded.push((ci, pick));
}
}
}
player.culture_total = player.culture_pool.culture_total;
// p3-29: flush border-expansion events (player no longer borrowed below).
for (ci, tile) in expanded {
events.push(mc_replay::TurnEvent::CityBordersExpanded {
turn,
clan: mc_replay::ClanId(pi as u32),
city: mc_replay::CityName(format!("city_{}_{}", pi, ci)),
hex: mc_replay::TileCoord::new(tile.0, tile.1),
});
}
}
// ── Phase 1b': Culture-research accumulation ────────────────────────
@ -2677,7 +2697,7 @@ impl TurnProcessor {
self.process_city_production(state, pi, &mut result.events_emitted);
self.try_found_city(state, pi, &mut result.events_emitted);
self.try_spawn_unit(state, pi, &mut result);
self.process_culture(state, pi);
self.process_culture(state, pi, &mut result.events_emitted);
self.process_culture_research(state, pi);
self.process_science(state, pi, &mut result.events_emitted);
}
@ -5837,10 +5857,19 @@ mod tests {
state.players.push(p);
let processor = TurnProcessor::new(1);
let mut events: Vec<mc_replay::TurnEvent> = Vec::new();
for _ in 0..80 {
processor.process_culture(&mut state, 0);
processor.process_culture(&mut state, 0, &mut events);
}
// p3-29: each border claim surfaces a CityBordersExpanded event.
assert!(
events
.iter()
.any(|e| matches!(e, mc_replay::TurnEvent::CityBordersExpanded { .. })),
"CityBordersExpanded must fire when borders expand; got {events:?}"
);
let owned = &state.players[0].cities[0].owned_tiles;
assert!(
owned.contains(&(5, 5)),

View file

@ -207,6 +207,7 @@ fn ten_turn_run_emits_each_wired_variant() {
.map(|e| match e {
TurnEvent::CityBuildingCompleted { .. } => "CityBuildingCompleted",
TurnEvent::CityGrew { .. } => "CityGrew",
TurnEvent::CityBordersExpanded { .. } => "CityBordersExpanded",
TurnEvent::CityFounded { .. } => "CityFounded",
TurnEvent::WonderBuilt { .. } => "WonderBuilt",
TurnEvent::CityCaptured { .. } => "CityCaptured",
@ -324,6 +325,7 @@ fn events_emitted_appears_on_turn_result() {
.map(|e| match e {
TurnEvent::CityBuildingCompleted { .. } => "CityBuildingCompleted",
TurnEvent::CityGrew { .. } => "CityGrew",
TurnEvent::CityBordersExpanded { .. } => "CityBordersExpanded",
TurnEvent::CityFounded { .. } => "CityFounded",
TurnEvent::WonderBuilt { .. } => "WonderBuilt",
TurnEvent::CityCaptured { .. } => "CityCaptured",