From 45b92de4447997956c91086d95a922fb48d62e70 Mon Sep 17 00:00:00 2001 From: Natalie Date: Tue, 12 May 2026 16:42:49 -0700 Subject: [PATCH] =?UTF-8?q?=F0=9F=90=9B=20fix(@projects/@magic-civilizatio?= =?UTF-8?q?n):=20=F0=9F=94=A7=20fix=20capital=20position=20clearing=20on?= =?UTF-8?q?=20last=20city=20capture?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Lilith Autocommit --- src/simulator/crates/mc-turn/src/processor.rs | 14 ++ .../tests/last_survivor_via_capture.rs | 165 ++++++++++++++++++ 2 files changed, 179 insertions(+) create mode 100644 src/simulator/crates/mc-turn/tests/last_survivor_via_capture.rs diff --git a/src/simulator/crates/mc-turn/src/processor.rs b/src/simulator/crates/mc-turn/src/processor.rs index 69675bbd..13b3bc0c 100644 --- a/src/simulator/crates/mc-turn/src/processor.rs +++ b/src/simulator/crates/mc-turn/src/processor.rs @@ -3037,6 +3037,20 @@ impl TurnProcessor { defender.city_ecology.swap_remove(city_idx); } + // Bug 5 fix: clear `capital_position` when the defender's + // original capital was the city just captured. Without this, + // a player who loses every city still has + // `capital_position = Some(initial_pos)`, which keeps + // `end_conditions::is_eliminated` false (it AND-requires + // `capital_position.is_none()` && `cities.is_empty()`). + // Result: `LastSurvivor` / domination never fires when the + // last city is captured. Matches the + // `eliminated_player_skipped_in_domination` test's manual + // setup at `victory.rs:798` (`p1.capital_position = None`). + if defender.capital_position == Some(pos) { + defender.capital_position = None; + } + // Transfer to attacker. let attacker = &mut state.players[attacker_pi]; attacker.cities.push(city); diff --git a/src/simulator/crates/mc-turn/tests/last_survivor_via_capture.rs b/src/simulator/crates/mc-turn/tests/last_survivor_via_capture.rs new file mode 100644 index 00000000..6ab02504 --- /dev/null +++ b/src/simulator/crates/mc-turn/tests/last_survivor_via_capture.rs @@ -0,0 +1,165 @@ +//! Bug 5 regression: when a player's last city is captured via the +//! `process_siege` capture-apply loop, the defender's `capital_position` +//! must be cleared so `end_conditions::is_eliminated` returns `true` and +//! `TurnEvent::GameOver { reason_kind: "last_survivor" }` fires. +//! +//! Pre-fix: `capital_position` was never cleared on capture, so +//! `is_eliminated` (which AND-requires `capital_position.is_none()` +//! && `cities.is_empty()`) stayed false forever and domination / +//! last-survivor never fired even after total annihilation. +//! +//! This test exercises the actual capture pipeline (3 attacker units +//! within 2 hexes of the defender's only city → `nearby_attackers >= 3` +//! → `process_siege` swap-removes the city), then asserts both: +//! • `defender.capital_position == None` after capture +//! • `TurnEvent::GameOver { reason_kind: "last_survivor", winner: 0 }` +//! emitted. + +use mc_ai::evaluator::ScoringWeights; +use mc_city::CityState; +use mc_replay::{ClanId, TurnEvent}; +use mc_turn::{ + GameState, MapUnit, PlayerState, TurnProcessor, VictoryConfig, +}; +use std::collections::BTreeMap; + +fn player_with_city_at(index: u8, pos: (i32, i32)) -> PlayerState { + let mut axes = BTreeMap::new(); + axes.insert("expansion".to_string(), 1u8); + + PlayerState { + player_index: index, + gold: 0, + cities: vec![CityState::starter()], + unit_upkeep: vec![], + strategic_axes: axes, + 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() + } +} + +fn disabled_victory_config() -> 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: None, + } +} + +#[test] +fn capturing_last_city_clears_capital_and_emits_last_survivor() { + // Two players. p1 has exactly one city (their capital). p0 has 3 + // units stacked on p1's city tile so the siege trigger fires + // immediately on the first turn step. + let p0_pos = (0, 0); + let p1_pos = (8, 0); + + let mut p0 = player_with_city_at(0, p0_pos); + let p1 = player_with_city_at(1, p1_pos); + + // Stack 3 p0 units on p1's only city → siege capture trigger fires + // (matches the `nearby_attackers >= 3` predicate in + // `processor::process_siege`). + for i in 0..3 { + p0.units.push(MapUnit { + id: 100 + i, + col: p1_pos.0, + row: p1_pos.1, + hp: 60, + max_hp: 60, + attack: 20, + defense: 5, + unit_id: "dwarf_warrior".to_string(), + ..Default::default() + }); + } + + let mut state = GameState { + turn: 1, + players: vec![p0, p1], + grid: None, + ..Default::default() + }; + + // Sanity: defender starts with a capital_position set. + assert_eq!(state.players[1].capital_position, Some(p1_pos)); + + let mut processor = TurnProcessor::new(500); + processor.victory_config = Some(disabled_victory_config()); + + let result = processor.step(&mut state); + + // Acceptance 1: the capture pipeline fired. + assert!( + result + .events_emitted + .iter() + .any(|e| matches!(e, TurnEvent::CityCaptured { .. })), + "expected CityCaptured event from siege capture; got: {:?}", + result.events_emitted + ); + + // Acceptance 2: defender lost their city AND their capital_position + // was cleared (Bug 5 fix). + assert!( + state.players[1].cities.is_empty(), + "defender must have 0 cities after capture" + ); + assert_eq!( + state.players[1].capital_position, None, + "Bug 5 fix: defender.capital_position must be cleared when the \ + captured city was the defender's capital" + ); + + // Acceptance 3: GameOver fires with last_survivor reason and winner=p0. + let game_over = result + .events_emitted + .iter() + .find(|e| matches!(e, TurnEvent::GameOver { .. })) + .expect("expected TurnEvent::GameOver after capturing last enemy city"); + + match game_over { + TurnEvent::GameOver { + winner, + reason_kind, + .. + } => { + assert_eq!( + reason_kind, "last_survivor", + "expected reason_kind=last_survivor, got {}", + reason_kind + ); + assert_eq!( + *winner, + Some(ClanId(0)), + "expected winner=p0 (ClanId(0)), got {:?}", + winner + ); + } + _ => unreachable!(), + } +}