feat(@projects/@magic-civilization): 🎭 p3-29 T1 — Rust turn emits CultureResearched

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 <noreply@anthropic.com>
This commit is contained in:
Natalie 2026-06-27 06:03:47 -04:00
parent 6e3d9b2fd2
commit 74844f74d3
5 changed files with 70 additions and 3 deletions

View file

@ -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);

View file

@ -841,6 +841,15 @@ fn translate_processor_events(events: &[mc_replay::TurnEvent]) -> Vec<Event> {
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

View file

@ -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.

View file

@ -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<mc_replay::TurnEvent>,
) {
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);
}

View file

@ -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",