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("col", hex.q as i64);
d.set("row", hex.r 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 } => { TurnEvent::GameOver { turn, winner, reason_kind, condition, resigned_clan } => {
d.set("kind", GString::from("GameOver")); d.set("kind", GString::from("GameOver"));
d.set("turn", *turn as i64); 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::EnvelopeIntercepted { .. }
| mc_replay::TurnEvent::LinkSevered { .. } | mc_replay::TurnEvent::LinkSevered { .. }
| mc_replay::TurnEvent::LinkRestored { .. } | mc_replay::TurnEvent::LinkRestored { .. }
// p3-29: surfaced for replay + the live UI (city growth), not the // p3-29: surfaced for replay + the live UI (city growth / border
// wire protocol — drop here to keep the match exhaustive. // expansion), not the wire protocol — drop here to keep it exhaustive.
| mc_replay::TurnEvent::CityGrew { .. } => {} | mc_replay::TurnEvent::CityGrew { .. }
| mc_replay::TurnEvent::CityBordersExpanded { .. } => {}
} }
} }
out out

View file

@ -121,6 +121,18 @@ pub enum TurnEvent {
/// Hex the city sits on, for the viewer to centre on. /// Hex the city sits on, for the viewer to centre on.
hex: TileCoord, 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. /// A wonder finished construction.
WonderBuilt { WonderBuilt {
/// Turn the event fired on. /// Turn the event fired on.
@ -514,6 +526,7 @@ impl TurnEvent {
| Self::UnitCreated { turn, .. } | Self::UnitCreated { turn, .. }
| Self::CityBuildingCompleted { turn, .. } | Self::CityBuildingCompleted { turn, .. }
| Self::CityGrew { turn, .. } | Self::CityGrew { turn, .. }
| Self::CityBordersExpanded { turn, .. }
| Self::WonderBuilt { turn, .. } | Self::WonderBuilt { turn, .. }
| Self::WarDeclared { turn, .. } | Self::WarDeclared { turn, .. }
| Self::PeaceSigned { turn, .. } | Self::PeaceSigned { turn, .. }

View file

@ -434,7 +434,7 @@ impl TurnProcessor {
self.process_city_production(state, pi, &mut result.events_emitted); self.process_city_production(state, pi, &mut result.events_emitted);
self.try_found_city(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.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_culture_research(state, pi);
self.process_science(state, pi, &mut result.events_emitted); 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 // Grid dims read before the &mut player borrow (needed for in-bounds
// candidate filtering during border expansion). // candidate filtering during border expansion).
let (gw, gh) = state let (gw, gh) = state
@ -1137,6 +1142,10 @@ impl TurnProcessor {
.as_ref() .as_ref()
.map(|g| (g.width, g.height)) .map(|g| (g.width, g.height))
.unwrap_or((0, 0)); .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 player = &mut state.players[pi];
let culture_axis = *player.strategic_axes.get("culture").unwrap_or(&2); 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; let per_city_yield = culture_axis as f64 * Self::BENCH_CULTURE_PER_AXIS_POINT;
@ -1197,11 +1206,22 @@ impl TurnProcessor {
ot.push(pick); ot.push(pick);
claimed.insert(pick); claimed.insert(pick);
player.culture_pool.consume_expansion(ci); player.culture_pool.consume_expansion(ci);
expanded.push((ci, pick));
} }
} }
} }
player.culture_total = player.culture_pool.culture_total; 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 ──────────────────────── // ── Phase 1b': Culture-research accumulation ────────────────────────
@ -2677,7 +2697,7 @@ impl TurnProcessor {
self.process_city_production(state, pi, &mut result.events_emitted); self.process_city_production(state, pi, &mut result.events_emitted);
self.try_found_city(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.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_culture_research(state, pi);
self.process_science(state, pi, &mut result.events_emitted); self.process_science(state, pi, &mut result.events_emitted);
} }
@ -5837,10 +5857,19 @@ mod tests {
state.players.push(p); state.players.push(p);
let processor = TurnProcessor::new(1); let processor = TurnProcessor::new(1);
let mut events: Vec<mc_replay::TurnEvent> = Vec::new();
for _ in 0..80 { 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; let owned = &state.players[0].cities[0].owned_tiles;
assert!( assert!(
owned.contains(&(5, 5)), owned.contains(&(5, 5)),

View file

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