From a87ea9f4d40f0d37fe038e3f1b362f0a143a06e7 Mon Sep 17 00:00:00 2001 From: Natalie Date: Sat, 27 Jun 2026 06:18:26 -0400 Subject: [PATCH] =?UTF-8?q?feat(@projects/@magic-civilization):=20?= =?UTF-8?q?=F0=9F=8C=85=20p3-29=20T3=20=E2=80=94=20Rust=20turn=20emits=20G?= =?UTF-8?q?oldenAgeStarted/Ended?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- src/simulator/api-gdext/src/replay.rs | 10 ++++ .../crates/mc-player-api/src/dispatch.rs | 10 ++-- src/simulator/crates/mc-replay/src/event.rs | 42 +++++++++++++ .../crates/mc-state/src/game_state.rs | 8 +++ .../crates/mc-turn/src/happiness_phase.rs | 60 ++++++++++++++++++- src/simulator/crates/mc-turn/src/processor.rs | 14 +++++ .../mc-turn/tests/event_collector_wiring.rs | 4 ++ 7 files changed, 143 insertions(+), 5 deletions(-) diff --git a/src/simulator/api-gdext/src/replay.rs b/src/simulator/api-gdext/src/replay.rs index 1ec7ac51..4caf51af 100644 --- a/src/simulator/api-gdext/src/replay.rs +++ b/src/simulator/api-gdext/src/replay.rs @@ -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); diff --git a/src/simulator/crates/mc-player-api/src/dispatch.rs b/src/simulator/crates/mc-player-api/src/dispatch.rs index 069dad1c..ca932ef4 100644 --- a/src/simulator/crates/mc-player-api/src/dispatch.rs +++ b/src/simulator/crates/mc-player-api/src/dispatch.rs @@ -993,10 +993,12 @@ fn translate_processor_events(events: &[mc_replay::TurnEvent]) -> Vec { | 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 diff --git a/src/simulator/crates/mc-replay/src/event.rs b/src/simulator/crates/mc-replay/src/event.rs index 97599017..d1fba3cc 100644 --- a/src/simulator/crates/mc-replay/src/event.rs +++ b/src/simulator/crates/mc-replay/src/event.rs @@ -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, 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. diff --git a/src/simulator/crates/mc-state/src/game_state.rs b/src/simulator/crates/mc-state/src/game_state.rs index 37341617..af822d15 100644 --- a/src/simulator/crates/mc-state/src/game_state.rs +++ b/src/simulator/crates/mc-state/src/game_state.rs @@ -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 diff --git a/src/simulator/crates/mc-turn/src/happiness_phase.rs b/src/simulator/crates/mc-turn/src/happiness_phase.rs index fa49959a..26328e2b 100644 --- a/src/simulator/crates/mc-turn/src/happiness_phase.rs +++ b/src/simulator/crates/mc-turn/src/happiness_phase.rs @@ -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() { diff --git a/src/simulator/crates/mc-turn/src/processor.rs b/src/simulator/crates/mc-turn/src/processor.rs index 42a0046e..2c5303a7 100644 --- a/src/simulator/crates/mc-turn/src/processor.rs +++ b/src/simulator/crates/mc-turn/src/processor.rs @@ -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. diff --git a/src/simulator/crates/mc-turn/tests/event_collector_wiring.rs b/src/simulator/crates/mc-turn/tests/event_collector_wiring.rs index e3da061a..c1aac435 100644 --- a/src/simulator/crates/mc-turn/tests/event_collector_wiring.rs +++ b/src/simulator/crates/mc-turn/tests/event_collector_wiring.rs @@ -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",