feat(@projects/@magic-civilization): 🌅 p3-29 T3 — Rust turn emits GoldenAgeStarted/Ended

The live GDScript turn emitted `golden_age_started`/`golden_age_ended` inline;
the headless happiness phase flipped `golden_age_active` silently. Detect the
false→true / true→false edge in `process_happiness_phase`, buffer it into a new
transient `GameState.pending_golden_age_events` (registry-has-no-event-sink
pattern), and drain it in `step()` into `GoldenAgeStarted`/`GoldenAgeEnded`.
Both edges emitted since the live UI consumes both signals (event_bus.gd). No
wire surface — dispatch drops them; live UI reads the kind-tagged event_to_dict.

Verified headless: mc-replay 20/0 (golden_age_events_serde), mc-turn 291/0
(golden_age_start_edge_buffers_started_event +
golden_age_end_edge_buffers_ended_event + event_collector_wiring).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Natalie 2026-06-27 06:18:26 -04:00
parent 158ef4d1bd
commit a87ea9f4d4
7 changed files with 143 additions and 5 deletions

View file

@ -162,6 +162,16 @@ pub(crate) fn event_to_dict(evt: &TurnEvent) -> Dictionary {
d.set("col", hex.q as i64);
d.set("row", hex.r as i64);
}
TurnEvent::GoldenAgeStarted { turn, clan } => {
d.set("kind", GString::from("GoldenAgeStarted"));
d.set("turn", *turn as i64);
d.set("clan", clan.0 as i64);
}
TurnEvent::GoldenAgeEnded { turn, clan } => {
d.set("kind", GString::from("GoldenAgeEnded"));
d.set("turn", *turn as i64);
d.set("clan", clan.0 as i64);
}
TurnEvent::AmbientEncounterFired { turn, clan, hex, species, group_size } => {
d.set("kind", GString::from("AmbientEncounterFired"));
d.set("turn", *turn as i64);

View file

@ -993,10 +993,12 @@ fn translate_processor_events(events: &[mc_replay::TurnEvent]) -> Vec<Event> {
| mc_replay::TurnEvent::CityGrew { .. }
| mc_replay::TurnEvent::CityBordersExpanded { .. }
| mc_replay::TurnEvent::FloraSuccession { .. }
// p3-29 (T2): no wire `Event::UnitHealed` surface — consumed by the
// live UI via the kind-tagged `event_to_dict` dict, not this wire
// path. Drop here to keep the match exhaustive.
| mc_replay::TurnEvent::UnitHealed { .. } => {}
// p3-29 (T2/T3): no wire surface — consumed by the live UI via the
// kind-tagged `event_to_dict` dict, not this wire path. Drop here to
// keep the match exhaustive.
| mc_replay::TurnEvent::UnitHealed { .. }
| mc_replay::TurnEvent::GoldenAgeStarted { .. }
| mc_replay::TurnEvent::GoldenAgeEnded { .. } => {}
}
}
out

View file

@ -175,6 +175,23 @@ pub enum TurnEvent {
/// Hex the unit healed on.
hex: TileCoord,
},
/// p3-29 (T3): a clan entered a Golden Age (the happiness phase's
/// `golden_age_active` flipped false→true). Single-source replacement for
/// the GDScript turn's inline `golden_age_started` signal.
GoldenAgeStarted {
/// Turn the event fired on.
turn: u32,
/// Clan that entered the Golden Age.
clan: ClanId,
},
/// p3-29 (T3): a clan's Golden Age ended (`golden_age_active` flipped
/// true→false). Single-source replacement for `golden_age_ended`.
GoldenAgeEnded {
/// Turn the event fired on.
turn: u32,
/// Clan whose Golden Age ended.
clan: ClanId,
},
/// A wonder finished construction.
WonderBuilt {
/// Turn the event fired on.
@ -572,6 +589,8 @@ impl TurnEvent {
| Self::FloraSuccession { turn, .. }
| Self::CultureResearched { turn, .. }
| Self::UnitHealed { turn, .. }
| Self::GoldenAgeStarted { turn, .. }
| Self::GoldenAgeEnded { turn, .. }
| Self::WonderBuilt { turn, .. }
| Self::WarDeclared { turn, .. }
| Self::PeaceSigned { turn, .. }
@ -763,6 +782,29 @@ mod tests {
assert_eq!(decoded, ev);
}
/// p3-29 (T3): verify `GoldenAgeStarted`/`GoldenAgeEnded` survive a JSON +
/// bincode serde round-trip and `turn()` returns their turn.
#[test]
fn golden_age_events_serde() {
let started = TurnEvent::GoldenAgeStarted { turn: 11, clan: ClanId(0) };
let ended = TurnEvent::GoldenAgeEnded { turn: 21, clan: ClanId(0) };
assert_eq!(started.turn(), 11);
assert_eq!(ended.turn(), 21);
for ev in [&started, &ended] {
let json = serde_json::to_string(ev).expect("serialize");
let back: TurnEvent = serde_json::from_str(&json).expect("deserialize");
assert_eq!(&back, ev);
}
let cfg = bincode::config::standard();
let events = vec![started, ended];
let bytes = bincode::serde::encode_to_vec(&events, cfg).expect("encode");
let (decoded, _): (Vec<TurnEvent>, usize) =
bincode::serde::decode_from_slice(&bytes, cfg).expect("decode");
assert_eq!(decoded, events);
}
/// p2-55: verify that `UnitCaptured`, `UnitRansomOffered`, and
/// `CivilianDestroyed` survive a JSON serde round-trip, and that
/// `turn()` returns the correct value for each.

View file

@ -447,6 +447,14 @@ pub struct GameState {
/// `#[serde(skip)]` — cleared/drained every turn.
#[serde(skip)]
pub pending_heal_events: Vec<(usize, u32, i32, i32, i32)>,
/// p3-29 (T3): transient buffer of golden-age transitions `(player_index,
/// started)` the happiness phase produced this turn — `started == true` for
/// a false→true edge (GoldenAgeStarted), `false` for true→false
/// (GoldenAgeEnded). `step()` drains it into the matching turn events
/// (same registry-has-no-event-sink buffer pattern as the heal buffer).
/// `#[serde(skip)]` — cleared/drained every turn.
#[serde(skip)]
pub pending_golden_age_events: Vec<(usize, bool)>,
/// p3-26 B3: improvement definitions (`id → {build_turns, food, production}`),
/// boot-loaded from `public/resources/improvements/*.json`. `#[serde(skip)]`
/// static content like the other catalogs; drives both the build-tick

View file

@ -53,7 +53,12 @@ use crate::game_state::GameState;
pub fn process_happiness_phase(state: &mut GameState) {
let config = HappinessConfig::default();
for player in &mut state.players {
// p3-29 (T3): record golden-age edges here, push into the transient buffer
// after the loop releases its `&mut state.players` borrow. `step()` drains
// it into GoldenAgeStarted/Ended events.
let mut golden_edges: Vec<(usize, bool)> = Vec::new();
for (pi, player) in state.players.iter_mut().enumerate() {
let total_citizens: i32 = player
.cities
.iter()
@ -86,6 +91,7 @@ pub fn process_happiness_phase(state: &mut GameState) {
player.happiness = breakdown.total;
player.happiness_status = breakdown.status;
let was_active = player.golden_age_active;
let mut ga_state = GoldenAgeState {
golden_age_active: player.golden_age_active,
golden_age_turns: player.golden_age_turns,
@ -97,7 +103,13 @@ pub fn process_happiness_phase(state: &mut GameState) {
player.golden_age_turns = ga_state.golden_age_turns;
player.golden_age_progress = ga_state.golden_age_progress;
player.golden_age_count = ga_state.golden_age_count;
if ga_state.golden_age_active != was_active {
golden_edges.push((pi, ga_state.golden_age_active));
}
}
state.pending_golden_age_events.extend(golden_edges);
}
#[cfg(test)]
@ -185,6 +197,52 @@ mod tests {
assert_eq!(player.golden_age_count, 1);
}
/// p3-29 (T3): the false→true golden-age edge buffers exactly one
/// `(player, started=true)` entry for `step()` to drain into GoldenAgeStarted.
#[test]
fn golden_age_start_edge_buffers_started_event() {
let mut state = GameState::default();
let mut p = player_with(
1,
1,
&[("diamond", 4), ("emerald", 4), ("ruby", 4), ("jade", 4)],
"balanced",
);
p.golden_age_progress = 95; // surplus pushes meter past 100 this tick
state.players.push(p);
process_happiness_phase(&mut state);
assert!(state.players[0].golden_age_active);
assert_eq!(
state.pending_golden_age_events,
vec![(0usize, true)],
"false→true edge buffers one started event"
);
}
/// p3-29 (T3): the true→false edge (Golden Age expiring) buffers one
/// `(player, started=false)` entry → GoldenAgeEnded; a still-active Golden
/// Age buffers nothing (no edge).
#[test]
fn golden_age_end_edge_buffers_ended_event() {
let mut state = GameState::default();
let mut p = player_with(1, 1, &[], "balanced");
p.golden_age_active = true;
p.golden_age_turns = 1; // expires this tick → true→false edge
p.golden_age_count = 1;
state.players.push(p);
process_happiness_phase(&mut state);
assert!(!state.players[0].golden_age_active, "golden age expired this tick");
assert_eq!(
state.pending_golden_age_events,
vec![(0usize, false)],
"true→false edge buffers one ended event"
);
}
/// An active Golden Age ticks down each turn.
#[test]
fn active_golden_age_counts_down() {

View file

@ -558,6 +558,20 @@ impl TurnProcessor {
}
}
// p3-29 (T3): drain the happiness phase's golden-age edge buffer into
// GoldenAgeStarted/Ended events.
if !state.pending_golden_age_events.is_empty() {
let turn_now = state.turn;
for (pi, started) in state.pending_golden_age_events.drain(..) {
let clan = mc_replay::ClanId(pi as u32);
result.events_emitted.push(if started {
mc_replay::TurnEvent::GoldenAgeStarted { turn: turn_now, clan }
} else {
mc_replay::TurnEvent::GoldenAgeEnded { turn: turn_now, clan }
});
}
}
// Phase 5a-sentry: wake sentrying units that have enemies in vision range (2 hex).
// Runs after movement so positions are current; runs before PvP so the
// now-awoken unit's state is consistent when combat checks fire.

View file

@ -211,6 +211,8 @@ fn ten_turn_run_emits_each_wired_variant() {
TurnEvent::FloraSuccession { .. } => "FloraSuccession",
TurnEvent::CultureResearched { .. } => "CultureResearched",
TurnEvent::UnitHealed { .. } => "UnitHealed",
TurnEvent::GoldenAgeStarted { .. } => "GoldenAgeStarted",
TurnEvent::GoldenAgeEnded { .. } => "GoldenAgeEnded",
TurnEvent::CityFounded { .. } => "CityFounded",
TurnEvent::WonderBuilt { .. } => "WonderBuilt",
TurnEvent::CityCaptured { .. } => "CityCaptured",
@ -332,6 +334,8 @@ fn events_emitted_appears_on_turn_result() {
TurnEvent::FloraSuccession { .. } => "FloraSuccession",
TurnEvent::CultureResearched { .. } => "CultureResearched",
TurnEvent::UnitHealed { .. } => "UnitHealed",
TurnEvent::GoldenAgeStarted { .. } => "GoldenAgeStarted",
TurnEvent::GoldenAgeEnded { .. } => "GoldenAgeEnded",
TurnEvent::CityFounded { .. } => "CityFounded",
TurnEvent::WonderBuilt { .. } => "WonderBuilt",
TurnEvent::CityCaptured { .. } => "CityCaptured",