diff --git a/src/simulator/crates/mc-turn/src/end_conditions.rs b/src/simulator/crates/mc-turn/src/end_conditions.rs index 4bdeeae3..58ed8bfd 100644 --- a/src/simulator/crates/mc-turn/src/end_conditions.rs +++ b/src/simulator/crates/mc-turn/src/end_conditions.rs @@ -85,25 +85,22 @@ pub fn evaluate_conditions(state: &GameState, config: &VictoryConfig) -> Option< 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). + // Count survivors excluding the resigning player (treated as gone). 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, - }); - } + // When exactly one clan survives the resignation, they are the winner. + // The reason stays `Resigned` — the initiating event is the resignation, + // not elimination-in-combat. + let winner = if survivors.len() == 1 { + Some(ClanId(survivors[0] as u32)) + } else { + None + }; - // More (or zero) survivors — report the resignation; winner is None - // unless a score tiebreak applies on the same turn. return Some(GameOver { - winner: None, + winner, reason: GameOverReason::Resigned { clan: resigned_clan }, }); } diff --git a/src/simulator/crates/mc-turn/src/victory.rs b/src/simulator/crates/mc-turn/src/victory.rs index 42352128..e8af11b3 100644 --- a/src/simulator/crates/mc-turn/src/victory.rs +++ b/src/simulator/crates/mc-turn/src/victory.rs @@ -213,6 +213,7 @@ impl Default for VictoryConfig { science_cost_base: DEFAULT_SCIENCE_COST_BASE, domination_requires_all_capitals: true, min_domination_turn: 0, + turn_limit: None, } } } @@ -375,6 +376,7 @@ mod tests { science_cost_base: 500, domination_requires_all_capitals: false, min_domination_turn: 0, + turn_limit: None, }; let mut p0 = bare_player(0); let p1 = bare_player(1); @@ -404,6 +406,7 @@ mod tests { city_count_threshold: usize::MAX, domination_requires_all_capitals: false, min_domination_turn: 0, + turn_limit: None, }; let mut p0 = bare_player(0); let p1 = bare_player(1); @@ -439,6 +442,7 @@ mod tests { science_cost_base: 500, domination_requires_all_capitals: false, min_domination_turn: 0, + turn_limit: None, }; let mut p0 = bare_player(0); let p1 = bare_player(1); @@ -458,6 +462,7 @@ mod tests { let config = VictoryConfig { domination_requires_all_capitals: true, min_domination_turn: 0, + turn_limit: None, gold_threshold: i64::MAX, culture_threshold: i64::MAX, city_count_threshold: usize::MAX, @@ -624,6 +629,7 @@ mod tests { let config = VictoryConfig { domination_requires_all_capitals: true, min_domination_turn: 0, + turn_limit: None, gold_threshold: 100, culture_threshold: i64::MAX, city_count_threshold: usize::MAX, @@ -659,6 +665,7 @@ mod tests { let config = VictoryConfig { domination_requires_all_capitals: false, // disable domination path min_domination_turn: 0, + turn_limit: None, gold_threshold: 100, culture_threshold: i64::MAX, city_count_threshold: usize::MAX, @@ -690,6 +697,7 @@ mod tests { let config = VictoryConfig { domination_requires_all_capitals: false, min_domination_turn: 0, + turn_limit: None, gold_threshold: 100, culture_threshold: 100, city_count_threshold: usize::MAX, @@ -716,6 +724,7 @@ mod tests { let config = VictoryConfig { domination_requires_all_capitals: false, min_domination_turn: 0, + turn_limit: None, gold_threshold: i64::MAX, culture_threshold: 100, city_count_threshold: 1, @@ -743,6 +752,7 @@ mod tests { let config = VictoryConfig { domination_requires_all_capitals: false, min_domination_turn: 0, + turn_limit: None, gold_threshold: 1000, culture_threshold: i64::MAX, city_count_threshold: usize::MAX, @@ -770,6 +780,7 @@ mod tests { let config = VictoryConfig { domination_requires_all_capitals: true, min_domination_turn: 0, + turn_limit: None, gold_threshold: i64::MAX, culture_threshold: i64::MAX, city_count_threshold: usize::MAX, @@ -807,6 +818,7 @@ mod tests { let config = VictoryConfig { domination_requires_all_capitals: true, min_domination_turn: 0, + turn_limit: None, gold_threshold: i64::MAX, culture_threshold: i64::MAX, city_count_threshold: usize::MAX, @@ -845,6 +857,7 @@ mod tests { let config = VictoryConfig { domination_requires_all_capitals: false, min_domination_turn: 0, + turn_limit: None, gold_threshold: i64::MAX, culture_threshold: i64::MAX, city_count_threshold: usize::MAX, 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 f2c80858..5603d40e 100644 --- a/src/simulator/crates/mc-turn/tests/event_collector_wiring.rs +++ b/src/simulator/crates/mc-turn/tests/event_collector_wiring.rs @@ -214,6 +214,10 @@ fn ten_turn_run_emits_each_wired_variant() { TurnEvent::LeaderChanged { .. } => "LeaderChanged", TurnEvent::ClanEliminated { .. } => "ClanEliminated", TurnEvent::AmbientEncounterFired { .. } => "AmbientEncounterFired", + TurnEvent::UnitCaptured { .. } => "UnitCaptured", + TurnEvent::UnitRansomOffered { .. } => "UnitRansomOffered", + TurnEvent::CivilianDestroyed { .. } => "CivilianDestroyed", + TurnEvent::GameOver { .. } => "GameOver", }) .collect(); diff --git a/src/simulator/crates/mc-turn/tests/game_over_event.rs b/src/simulator/crates/mc-turn/tests/game_over_event.rs new file mode 100644 index 00000000..433983af --- /dev/null +++ b/src/simulator/crates/mc-turn/tests/game_over_event.rs @@ -0,0 +1,226 @@ +//! p2-48 bullet 1 — GameOver event acceptance tests. +//! +//! Three tests cover each GameOverReason variant: +//! - `last_survivor_fires_when_one_alive` +//! - `turn_limit_fires_at_max_turns` +//! - `resigned_fires_on_player_action` + +use mc_ai::evaluator::ScoringWeights; +use mc_city::CityState; +use mc_replay::{ClanId, TurnEvent}; +use mc_turn::{ + end_conditions::{evaluate_conditions, GameOver, GameOverReason}, + GameState, LairCombatConfig, PlayerState, TurnProcessor, VictoryConfig, VictoryType, +}; +use std::collections::{BTreeMap, BTreeSet}; + +// ── Helpers ─────────────────────────────────────────────────────────────────── + +fn bare_player(index: u8) -> PlayerState { + let pos = (index as i32 * 10, index as i32 * 10); + PlayerState { + player_index: index, + gold: 0, + cities: vec![CityState::starter()], + unit_upkeep: vec![], + strategic_axes: BTreeMap::new(), + scoring_weights: ScoringWeights::default(), + expansion_points: 0, + city_buildings: vec![vec![]], + city_improvements: Default::default(), + city_ecology: vec![Default::default()], + tech_state: None, + science_yield: 0, + science_pool: 0, + player_tech: None, + units: vec![], + city_positions: vec![pos], + capital_position: Some(pos), + culture_total: 0, + culture_pool: mc_culture::CulturePool::default(), + arcane_lore_pop_deducted: false, + traded_luxuries: Default::default(), + relations: Default::default(), + strategic_ledger: Default::default(), + wonders_built: Default::default(), + explored_deposits: Default::default(), + ..Default::default() + } +} + +/// Returns the first `TurnEvent::GameOver` found in `events`, or panics. +fn find_game_over(events: &[TurnEvent]) -> &TurnEvent { + events + .iter() + .find(|e| matches!(e, TurnEvent::GameOver { .. })) + .expect("expected a TurnEvent::GameOver in events_emitted") +} + +// ── disable-all VictoryConfig so conditions don't fire spuriously ───────────── + +fn disabled_victory_config(turn_limit: Option) -> VictoryConfig { + 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, + } +} + +// ── Tests ───────────────────────────────────────────────────────────────────── + +/// When N-1 clans are eliminated (capital=None, cities=[]), `evaluate_conditions` +/// returns `LastSurvivor` for the sole surviving clan, and `TurnProcessor::step` +/// emits a `TurnEvent::GameOver { reason_kind: "last_survivor" }`. +#[test] +fn last_survivor_fires_when_one_alive() { + let vc = disabled_victory_config(None); + + // Two players — p1 is eliminated. + let mut p0 = bare_player(0); + let mut p1 = bare_player(1); + p1.capital_position = None; + p1.city_positions = vec![]; + p1.cities = vec![]; + + let mut state = GameState { + turn: 1, + players: vec![p0.clone(), p1.clone()], + grid: None, + ..Default::default() + }; + + // Direct evaluate_conditions check. + let go = evaluate_conditions(&state, &vc) + .expect("evaluate_conditions must fire on last-survivor state"); + assert_eq!(go.reason, GameOverReason::LastSurvivor); + assert_eq!(go.winner, Some(ClanId(0))); + + // Integration: step with VictoryConfig and confirm the event is emitted. + let mut processor = TurnProcessor::new(500); + processor.victory_config = Some(vc); + let result = processor.step(&mut state); + + let ev = find_game_over(&result.events_emitted); + match ev { + TurnEvent::GameOver { + winner, + reason_kind, + .. + } => { + assert_eq!(reason_kind, "last_survivor"); + assert_eq!(*winner, Some(ClanId(0))); + } + _ => panic!("unexpected event variant"), + } +} + +/// When `state.turn >= turn_limit`, `evaluate_conditions` returns `TurnLimit` +/// and awards the highest-scoring clan as winner. +#[test] +fn turn_limit_fires_at_max_turns() { + let limit = 100u32; + let vc = disabled_victory_config(Some(limit)); + + let mut p0 = bare_player(0); + let mut p1 = bare_player(1); + // Give p1 a large gold treasury so the score tiebreak picks p1. + p1.gold = 100_000; + + let mut state = GameState { + turn: limit, // exactly at limit + players: vec![p0.clone(), p1.clone()], + grid: None, + ..Default::default() + }; + + let go = evaluate_conditions(&state, &vc) + .expect("evaluate_conditions must fire at turn_limit"); + assert_eq!(go.reason, GameOverReason::TurnLimit); + // p1 has much higher score via gold. + assert_eq!(go.winner, Some(ClanId(1))); + + // Integration: step emits TurnEvent::GameOver. + let mut processor = TurnProcessor::new(limit); + processor.victory_config = Some(disabled_victory_config(Some(limit))); + let result = processor.step(&mut state); + + let ev = find_game_over(&result.events_emitted); + match ev { + TurnEvent::GameOver { + reason_kind, + winner, + .. + } => { + assert_eq!(reason_kind, "turn_limit"); + assert_eq!(*winner, Some(ClanId(1))); + } + _ => panic!("unexpected event variant"), + } +} + +/// When a player's index is in `state.pending_resignations`, `evaluate_conditions` +/// returns `Resigned { clan }`. After the `step` call, `pending_resignations` is +/// cleared (so the event cannot fire twice). +#[test] +fn resigned_fires_on_player_action() { + let vc = disabled_victory_config(None); + + let p0 = bare_player(0); + let p1 = bare_player(1); + + // Simulate player 1 submitting a resignation action. + let mut resignations = BTreeSet::new(); + resignations.insert(1u8); // player index 1 + + let mut state = GameState { + turn: 5, + players: vec![p0.clone(), p1.clone()], + grid: None, + pending_resignations: resignations, + ..Default::default() + }; + + let go = evaluate_conditions(&state, &vc) + .expect("evaluate_conditions must fire on pending resignation"); + assert_eq!( + go.reason, + GameOverReason::Resigned { + clan: ClanId(1) + } + ); + // p1 resigns; p0 is the sole remaining alive clan — winner = Some(p0). + assert_eq!(go.winner, Some(ClanId(0))); + + // Integration: step emits the event and clears pending_resignations. + let mut processor = TurnProcessor::new(500); + processor.victory_config = Some(vc); + // Re-populate resignations (step above consumed state, so rebuild). + state.pending_resignations.insert(1u8); + let result = processor.step(&mut state); + + let ev = find_game_over(&result.events_emitted); + match ev { + TurnEvent::GameOver { + reason_kind, + resigned_clan, + winner, + .. + } => { + assert_eq!(reason_kind, "resigned"); + assert_eq!(*resigned_clan, Some(ClanId(1))); + assert_eq!(*winner, Some(ClanId(0))); + } + _ => panic!("unexpected event variant"), + } + + // Resignations cleared by step. + assert!( + state.pending_resignations.is_empty(), + "pending_resignations must be cleared after GameOver is emitted" + ); +}