🐛 fix(@projects/@magic-civilization): 🔧 fix capital position clearing on last city capture

Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
Natalie 2026-05-12 16:42:49 -07:00
parent 4f10b9cd8d
commit 45b92de444
2 changed files with 179 additions and 0 deletions

View file

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

View file

@ -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!(),
}
}