From 74844f74d3d47417c9d3f99f9a1b67da4cfd9ff1 Mon Sep 17 00:00:00 2001 From: Natalie Date: Sat, 27 Jun 2026 06:03:47 -0400 Subject: [PATCH] =?UTF-8?q?feat(@projects/@magic-civilization):=20?= =?UTF-8?q?=F0=9F=8E=AD=20p3-29=20T1=20=E2=80=94=20Rust=20turn=20emits=20C?= =?UTF-8?q?ultureResearched?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The live GDScript turn emitted `culture_researched` inline; the headless Rust turn dropped tradition completions. Emit a `TurnEvent::CultureResearched` at the completion site in `process_culture_research` (single-source per Rail-1), translate it to the existing `wire::Event::CultureResearched` in dispatch (not dropped), and surface it as a kind-tagged dict in `event_to_dict` so the live turn_manager will receive it at the Rail-1 swap. Threaded the events sink into the phase + both call sites. Verified headless: mc-replay 18/0 (culture_researched_serde), mc-turn 287/0 (event_collector_wiring), mc-player-api 138/0. Co-Authored-By: Claude Opus 4.8 --- src/simulator/api-gdext/src/replay.rs | 6 ++++ .../crates/mc-player-api/src/dispatch.rs | 9 +++++ src/simulator/crates/mc-replay/src/event.rs | 35 +++++++++++++++++++ src/simulator/crates/mc-turn/src/processor.rs | 21 +++++++++-- .../mc-turn/tests/event_collector_wiring.rs | 2 ++ 5 files changed, 70 insertions(+), 3 deletions(-) diff --git a/src/simulator/api-gdext/src/replay.rs b/src/simulator/api-gdext/src/replay.rs index e7d96a26..711da034 100644 --- a/src/simulator/api-gdext/src/replay.rs +++ b/src/simulator/api-gdext/src/replay.rs @@ -147,6 +147,12 @@ pub(crate) fn event_to_dict(evt: &TurnEvent) -> Dictionary { d.set("clan", clan.0 as i64); d.set("tech", GString::from(tech.0.as_str())); } + TurnEvent::CultureResearched { turn, clan, tradition } => { + d.set("kind", GString::from("CultureResearched")); + d.set("turn", *turn as i64); + d.set("clan", clan.0 as i64); + d.set("tradition", GString::from(tradition.as_str())); + } 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 7e86764b..d4a13e4c 100644 --- a/src/simulator/crates/mc-player-api/src/dispatch.rs +++ b/src/simulator/crates/mc-player-api/src/dispatch.rs @@ -841,6 +841,15 @@ fn translate_processor_events(events: &[mc_replay::TurnEvent]) -> Vec { player: clan.0 as PlayerId, }); } + // p3-29 (T1): the Rust turn now emits tradition completions; the + // wire `Event::CultureResearched` already exists, so translate + // (not drop) so the live UI / replay surface receives it. + mc_replay::TurnEvent::CultureResearched { clan, tradition, .. } => { + out.push(Event::CultureResearched { + tradition_id: tradition.clone(), + player: clan.0 as PlayerId, + }); + } // p2-67 Bug 4: surface non-wonder building completions emitted // from `process_city_production`'s `Queueable::Item` branch. // The wire `Event::CityBuildingCompleted` already exists for diff --git a/src/simulator/crates/mc-replay/src/event.rs b/src/simulator/crates/mc-replay/src/event.rs index 31f743df..a089d852 100644 --- a/src/simulator/crates/mc-replay/src/event.rs +++ b/src/simulator/crates/mc-replay/src/event.rs @@ -148,6 +148,18 @@ pub enum TurnEvent { /// Tier after. to_tier: i32, }, + /// p3-29 (T1): a clan completed a culture tradition (single-source + /// replacement for the GDScript turn's inline `culture_researched` + /// signal). `wire::Event::CultureResearched` already exists, so dispatch + /// translates this rather than dropping it. + CultureResearched { + /// Turn the event fired on. + turn: u32, + /// Clan that completed the tradition. + clan: ClanId, + /// Tradition node now unlocked. + tradition: String, + }, /// A wonder finished construction. WonderBuilt { /// Turn the event fired on. @@ -543,6 +555,7 @@ impl TurnEvent { | Self::CityGrew { turn, .. } | Self::CityBordersExpanded { turn, .. } | Self::FloraSuccession { turn, .. } + | Self::CultureResearched { turn, .. } | Self::WonderBuilt { turn, .. } | Self::WarDeclared { turn, .. } | Self::PeaceSigned { turn, .. } @@ -688,6 +701,28 @@ mod tests { assert_eq!(decoded, events); } + /// p3-29 (T1): verify `CultureResearched` survives a JSON + bincode + /// serde round-trip and `turn()` returns its turn. + #[test] + fn culture_researched_serde() { + let ev = TurnEvent::CultureResearched { + turn: 7, + clan: ClanId(2), + tradition: "oral_tradition".into(), + }; + assert_eq!(ev.turn(), 7); + + 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 bytes = bincode::serde::encode_to_vec(&ev, cfg).expect("encode"); + let (decoded, _): (TurnEvent, usize) = + bincode::serde::decode_from_slice(&bytes, cfg).expect("decode"); + assert_eq!(decoded, ev); + } + /// 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-turn/src/processor.rs b/src/simulator/crates/mc-turn/src/processor.rs index b2ba5b04..87ac6560 100644 --- a/src/simulator/crates/mc-turn/src/processor.rs +++ b/src/simulator/crates/mc-turn/src/processor.rs @@ -435,7 +435,7 @@ impl TurnProcessor { self.try_found_city(state, pi, &mut result.events_emitted); self.try_spawn_unit(state, pi, &mut result); self.process_culture(state, pi, &mut result.events_emitted); - self.process_culture_research(state, pi); + self.process_culture_research(state, pi, &mut result.events_emitted); self.process_science(state, pi, &mut result.events_emitted); } @@ -1247,12 +1247,18 @@ impl TurnProcessor { // the flat `researching_tradition` / `culture_research_progress` / // `researched_traditions` fields so GDScript and bench reads stay // in sync. - fn process_culture_research(&self, state: &mut GameState, pi: usize) { + fn process_culture_research( + &self, + state: &mut GameState, + pi: usize, + events: &mut Vec, + ) { let web = match self.culture_web_parsed.as_ref() { Some(w) => w, None => return, }; + let turn = state.turn; 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; @@ -1282,6 +1288,15 @@ impl TurnProcessor { let result = pc.add_science(gained, web); match result { mc_culture::CultureResearchResult::Completed { tech_id, .. } => { + // p3-29 (T1): single-source replacement for the GDScript turn's + // inline `culture_researched` signal. Emit at the completion site + // that already decided it — dispatch translates to the existing + // `wire::Event::CultureResearched`. + events.push(mc_replay::TurnEvent::CultureResearched { + turn, + clan: mc_replay::ClanId(pi as u32), + tradition: tech_id.clone(), + }); player.researched_traditions.insert(tech_id); player.researching_tradition = pc.current_research().unwrap_or("").to_string(); player.culture_research_progress = pc.research_progress(); @@ -2713,7 +2728,7 @@ impl TurnProcessor { self.try_found_city(state, pi, &mut result.events_emitted); self.try_spawn_unit(state, pi, &mut result); self.process_culture(state, pi, &mut result.events_emitted); - self.process_culture_research(state, pi); + self.process_culture_research(state, pi, &mut result.events_emitted); self.process_science(state, pi, &mut result.events_emitted); } 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 de3fa119..6bf91610 100644 --- a/src/simulator/crates/mc-turn/tests/event_collector_wiring.rs +++ b/src/simulator/crates/mc-turn/tests/event_collector_wiring.rs @@ -209,6 +209,7 @@ fn ten_turn_run_emits_each_wired_variant() { TurnEvent::CityGrew { .. } => "CityGrew", TurnEvent::CityBordersExpanded { .. } => "CityBordersExpanded", TurnEvent::FloraSuccession { .. } => "FloraSuccession", + TurnEvent::CultureResearched { .. } => "CultureResearched", TurnEvent::CityFounded { .. } => "CityFounded", TurnEvent::WonderBuilt { .. } => "WonderBuilt", TurnEvent::CityCaptured { .. } => "CityCaptured", @@ -328,6 +329,7 @@ fn events_emitted_appears_on_turn_result() { TurnEvent::CityGrew { .. } => "CityGrew", TurnEvent::CityBordersExpanded { .. } => "CityBordersExpanded", TurnEvent::FloraSuccession { .. } => "FloraSuccession", + TurnEvent::CultureResearched { .. } => "CultureResearched", TurnEvent::CityFounded { .. } => "CityFounded", TurnEvent::WonderBuilt { .. } => "WonderBuilt", TurnEvent::CityCaptured { .. } => "CityCaptured",