From 8f89b9cb78a866b6907212ff3a4bdf162b1c2e94 Mon Sep 17 00:00:00 2001 From: Natalie Date: Fri, 8 May 2026 20:47:54 -0700 Subject: [PATCH] =?UTF-8?q?feat(@projects/@magic-civilization):=20?= =?UTF-8?q?=E2=9C=A8=20add=20game=20over=20event=20support?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Lilith Autocommit --- src/simulator/crates/mc-replay/src/event.rs | 33 +++- .../crates/mc-turn/src/end_conditions.rs | 144 ++++++++++++++++++ .../crates/mc-turn/src/game_state.rs | 7 + src/simulator/crates/mc-turn/src/lib.rs | 2 + src/simulator/crates/mc-turn/src/processor.rs | 54 +++++++ src/simulator/crates/mc-turn/src/victory.rs | 8 + 6 files changed, 247 insertions(+), 1 deletion(-) create mode 100644 src/simulator/crates/mc-turn/src/end_conditions.rs diff --git a/src/simulator/crates/mc-replay/src/event.rs b/src/simulator/crates/mc-replay/src/event.rs index 798d8ce7..a4f02a15 100644 --- a/src/simulator/crates/mc-replay/src/event.rs +++ b/src/simulator/crates/mc-replay/src/event.rs @@ -191,6 +191,36 @@ pub enum TurnEvent { /// Catalog kind of the destroyed unit (e.g. `"worker"`). unit_kind: UnitKind, }, + /// p2-48: the game has ended. Emitted at most once per game, at the tail + /// of `TurnProcessor::step` when `end_conditions::evaluate_conditions` + /// returns `Some`. + /// + /// # Cross-crate payload encoding + /// + /// `mc-replay` must not depend on `mc-turn` (dep-direction constraint). + /// `GameOverReason` and `VictoryType` therefore remain in `mc-turn`; the + /// cross-crate payload uses stable strings: + /// + /// - `reason_kind` — one of `"last_survivor"`, `"condition_met"`, + /// `"turn_limit"`, `"resigned"`. + /// - `condition` — `Some(VictoryType::as_str())` when `reason_kind` is + /// `"condition_met"`, `None` otherwise. + /// - `resigned_clan` — `Some(ClanId)` when `reason_kind` is `"resigned"`, + /// `None` otherwise. + GameOver { + /// Turn the event fired on. + turn: u32, + /// The winning clan, if unambiguous. + winner: Option, + /// Stable reason string (see variant docs). + reason_kind: String, + /// `VictoryType::as_str()` when `reason_kind == "condition_met"`. + #[serde(default, skip_serializing_if = "Option::is_none")] + condition: Option, + /// Which clan resigned when `reason_kind == "resigned"`. + #[serde(default, skip_serializing_if = "Option::is_none")] + resigned_clan: Option, + }, } impl TurnEvent { @@ -213,7 +243,8 @@ impl TurnEvent { | Self::AmbientEncounterFired { turn, .. } | Self::UnitCaptured { turn, .. } | Self::UnitRansomOffered { turn, .. } - | Self::CivilianDestroyed { turn, .. } => turn, + | Self::CivilianDestroyed { turn, .. } + | Self::GameOver { turn, .. } => turn, } } } diff --git a/src/simulator/crates/mc-turn/src/end_conditions.rs b/src/simulator/crates/mc-turn/src/end_conditions.rs new file mode 100644 index 00000000..4bdeeae3 --- /dev/null +++ b/src/simulator/crates/mc-turn/src/end_conditions.rs @@ -0,0 +1,144 @@ +//! End-of-game condition evaluation — p2-48 bullet 1. +//! +//! `evaluate_conditions` is the single call site for "is the game over?" at +//! the tail of `TurnProcessor::step`. It wraps `victory::check_victory_at_turn` +//! for the `ConditionMet` case and adds explicit `LastSurvivor`, `TurnLimit`, +//! and `Resigned` detection that the per-condition `VictoryConfig` cannot +//! express on its own. +//! +//! # Dep-graph note +//! +//! `GameOverReason` carries `VictoryType` (mc-turn-internal) for the +//! `ConditionMet` variant — this is fine. The cross-crate `mc-replay::TurnEvent` +//! representation uses string fields so the dep direction is never inverted. + +use mc_replay::ClanId; +use serde::{Deserialize, Serialize}; + +use crate::game_state::GameState; +use crate::victory::{check_score_victory, check_victory_at_turn, VictoryConfig, VictoryType}; + +// ── Public types ───────────────────────────────────────────────────────────── + +/// Why the game ended. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub enum GameOverReason { + /// All other clans have been eliminated — exactly one remains. + LastSurvivor, + /// A specific victory condition from `VictoryConfig` was met. + ConditionMet { condition: VictoryType }, + /// The turn counter exceeded `VictoryConfig::turn_limit` (if set). + /// The winner is the highest-scoring player (Score tiebreak). + TurnLimit, + /// A player explicitly resigned. The `clan` field identifies who resigned. + /// `winner` on the resulting `GameOver` is `None` unless only one other + /// clan was left alive (which transitions to `LastSurvivor` on the same + /// turn). + Resigned { clan: ClanId }, +} + +/// The game-over payload emitted as a `TurnEvent::GameOver` via `events_emitted`. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct GameOver { + /// The winning clan, or `None` when the outcome is ambiguous (e.g. a + /// simultaneous resignation with no surviving majority) or when the + /// `Resigned` variant is the primary reason and other clans remain. + pub winner: Option, + /// Reason the game ended. + pub reason: GameOverReason, +} + +// ── Core logic ──────────────────────────────────────────────────────────────── + +/// Returns `true` if the player at `pi` is completely eliminated. +/// +/// Mirrors the "already-eliminated" predicate used in +/// `victory::check_victory_at_turn` for the domination branch: +/// a player with `capital_position = None` AND no cities remaining is out. +#[inline] +fn is_eliminated(state: &GameState, pi: usize) -> bool { + let p = &state.players[pi]; + p.capital_position.is_none() && p.cities.is_empty() +} + +/// Count clans that are still alive (not eliminated). +#[inline] +fn alive_count(state: &GameState) -> usize { + (0..state.players.len()) + .filter(|&pi| !is_eliminated(state, pi)) + .count() +} + +/// Evaluate all end conditions for the current turn. Called at the tail of +/// `TurnProcessor::step` after the standard victory check sets `result.winner`. +/// +/// Priority order (matches objective spec §"acceptance gate"): +/// 1. Resigned — a pending resignation is drained first; the resigned player +/// may trigger a `LastSurvivor` on the remaining player in the same call. +/// 2. LastSurvivor — only one alive clan. +/// 3. ConditionMet — `check_victory_at_turn` finds a winner. +/// 4. TurnLimit — turn ≥ `config.turn_limit` (score tiebreak). +/// +/// Returns `None` when none of the above conditions are met. +pub fn evaluate_conditions(state: &GameState, config: &VictoryConfig) -> Option { + // ── 1. Resigned ─────────────────────────────────────────────────────────── + if let Some(resigned_idx) = state.pending_resignations.iter().next().copied() { + let resigned_clan = ClanId(resigned_idx as u32); + + // After resignation, count who is left (the resigning player is treated + // as eliminated for the "last survivor" check). + let survivors: Vec = (0..state.players.len()) + .filter(|&pi| pi != resigned_idx as usize && !is_eliminated(state, pi)) + .collect(); + + if survivors.len() == 1 { + // Resignation collapses to a LastSurvivor. + let winner_clan = ClanId(survivors[0] as u32); + return Some(GameOver { + winner: Some(winner_clan), + reason: GameOverReason::LastSurvivor, + }); + } + + // More (or zero) survivors — report the resignation; winner is None + // unless a score tiebreak applies on the same turn. + return Some(GameOver { + winner: None, + reason: GameOverReason::Resigned { clan: resigned_clan }, + }); + } + + // ── 2. LastSurvivor ─────────────────────────────────────────────────────── + let n_alive = alive_count(state); + if state.players.len() > 1 && n_alive == 1 { + let winner_idx = (0..state.players.len()) + .find(|&pi| !is_eliminated(state, pi)) + .expect("alive_count == 1 guarantees a non-eliminated player"); + return Some(GameOver { + winner: Some(ClanId(winner_idx as u32)), + reason: GameOverReason::LastSurvivor, + }); + } + + // ── 3. ConditionMet ─────────────────────────────────────────────────────── + if let Some((wi, vt)) = check_victory_at_turn(&state.players, config, state.turn) { + return Some(GameOver { + winner: Some(ClanId(wi as u32)), + reason: GameOverReason::ConditionMet { condition: vt }, + }); + } + + // ── 4. TurnLimit ───────────────────────────────────────────────────────── + if let Some(limit) = config.turn_limit { + if state.turn >= limit { + let winner = check_score_victory(&state.players) + .map(|(wi, _)| ClanId(wi as u32)); + return Some(GameOver { + winner, + reason: GameOverReason::TurnLimit, + }); + } + } + + None +} diff --git a/src/simulator/crates/mc-turn/src/game_state.rs b/src/simulator/crates/mc-turn/src/game_state.rs index c0b62b51..972ede90 100644 --- a/src/simulator/crates/mc-turn/src/game_state.rs +++ b/src/simulator/crates/mc-turn/src/game_state.rs @@ -337,6 +337,13 @@ pub struct GameState { /// across saves. #[serde(skip)] pub pending_capture_events: PendingCaptureEvents, + /// p2-48: player indices that submitted a resignation action this turn. + /// Drained by `end_conditions::evaluate_conditions` at turn-end. The field + /// uses `u8` player indices (matching `PlayerState::player_index`) to stay + /// consistent with the rest of `GameState`. `#[serde(default)]` so old + /// save files (without this field) deserialize cleanly. + #[serde(default)] + pub pending_resignations: BTreeSet, } /// p2-55: scratch staging for capture / ransom / destroy events fired during diff --git a/src/simulator/crates/mc-turn/src/lib.rs b/src/simulator/crates/mc-turn/src/lib.rs index 53766e00..ea648842 100644 --- a/src/simulator/crates/mc-turn/src/lib.rs +++ b/src/simulator/crates/mc-turn/src/lib.rs @@ -24,6 +24,7 @@ pub mod capture; pub mod ransom; pub mod building_action_handlers; pub mod chronicle; +pub mod end_conditions; pub mod formation_move; pub mod patrol; pub mod game_state; @@ -74,5 +75,6 @@ pub use spatial_index::LairIndexCsr; // can drain `TurnResult::events_emitted` without taking a direct mc-replay // dependency. The same TurnEvent enum is the single sink — there is no // parallel GDScript or bench event log. +pub use end_conditions::{evaluate_conditions, GameOver, GameOverReason}; pub use mc_replay::{TurnEvent, TurnEventCollector}; pub use victory::{VictoryConfig, VictoryType}; diff --git a/src/simulator/crates/mc-turn/src/processor.rs b/src/simulator/crates/mc-turn/src/processor.rs index ad35a4b4..30d45f7a 100644 --- a/src/simulator/crates/mc-turn/src/processor.rs +++ b/src/simulator/crates/mc-turn/src/processor.rs @@ -455,6 +455,60 @@ impl TurnProcessor { result.winner = crate::victory::check_score_victory(&state.players); } + // p2-48: GameOver event — evaluate end conditions and emit a + // TurnEvent::GameOver when the game is over. Uses victory_config when + // present; falls back to an ad-hoc config derived from max_turns for + // the legacy bench path that doesn't set victory_config. + { + let vc_owned; + let vc: &crate::victory::VictoryConfig = match &self.victory_config { + Some(vc) => vc, + None => { + // Legacy path: build a minimal config that expresses only + // the turn-limit the processor already checks above. All + // condition thresholds are set to impossible values so only + // TurnLimit and LastSurvivor can fire. + vc_owned = crate::victory::VictoryConfig { + city_count_threshold: usize::MAX, + gold_threshold: i64::MAX, + culture_threshold: i64::MAX, + science_techs_required: vec![], + science_cost_base: 0, + domination_requires_all_capitals: false, + min_domination_turn: 0, + turn_limit: Some(self.max_turns), + }; + &vc_owned + } + }; + + if let Some(go) = crate::end_conditions::evaluate_conditions(state, vc) { + use crate::end_conditions::GameOverReason; + let (reason_kind, condition, resigned_clan) = match &go.reason { + GameOverReason::LastSurvivor => ("last_survivor".to_string(), None, None), + GameOverReason::ConditionMet { condition } => ( + "condition_met".to_string(), + Some(condition.as_str().to_string()), + None, + ), + GameOverReason::TurnLimit => ("turn_limit".to_string(), None, None), + GameOverReason::Resigned { clan } => { + ("resigned".to_string(), None, Some(*clan)) + } + }; + result.events_emitted.push(mc_replay::TurnEvent::GameOver { + turn: state.turn, + winner: go.winner, + reason_kind, + condition, + resigned_clan, + }); + // Drain pending resignations so they don't fire again on the + // next call (though in practice the game ends here). + state.pending_resignations.clear(); + } + } + // ── Derived stats recompute (SINGLE SITE — all future derived scalars land here) ── // // Must run last so all phase mutations (economy, siege, science, culture, diff --git a/src/simulator/crates/mc-turn/src/victory.rs b/src/simulator/crates/mc-turn/src/victory.rs index 9f9ed922..42352128 100644 --- a/src/simulator/crates/mc-turn/src/victory.rs +++ b/src/simulator/crates/mc-turn/src/victory.rs @@ -157,6 +157,14 @@ pub struct VictoryConfig { /// `victories.json` (without this field) deserializes to `0`. #[serde(default)] pub min_domination_turn: u32, + /// Turn at which the game ends regardless of other conditions. When + /// `Some(n)`, `evaluate_conditions` fires `GameOverReason::TurnLimit` on + /// turn `n` (or any turn `> n`) and awards the highest-scoring clan. `None` + /// means no turn cap — the game runs until a condition is met or all but + /// one clan is eliminated. `#[serde(default)]` so existing config files + /// without this field deserialize to `None`. + #[serde(default)] + pub turn_limit: Option, } // ── Default victory thresholds ───────────────────────────────────────────────