feat(@projects/@magic-civilization): add game over event support

Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
Natalie 2026-05-08 20:47:54 -07:00
parent 725906d103
commit 8f89b9cb78
6 changed files with 247 additions and 1 deletions

View file

@ -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<ClanId>,
/// 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<String>,
/// Which clan resigned when `reason_kind == "resigned"`.
#[serde(default, skip_serializing_if = "Option::is_none")]
resigned_clan: Option<ClanId>,
},
}
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,
}
}
}

View file

@ -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<ClanId>,
/// 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<GameOver> {
// ── 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<usize> = (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
}

View file

@ -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<u8>,
}
/// p2-55: scratch staging for capture / ransom / destroy events fired during

View file

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

View file

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

View file

@ -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<u32>,
}
// ── Default victory thresholds ───────────────────────────────────────────────