feat(@projects/@magic-civilization): ✨ add game over event support
Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
parent
725906d103
commit
8f89b9cb78
6 changed files with 247 additions and 1 deletions
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
144
src/simulator/crates/mc-turn/src/end_conditions.rs
Normal file
144
src/simulator/crates/mc-turn/src/end_conditions.rs
Normal 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
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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};
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 ───────────────────────────────────────────────
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue