🐛 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:
parent
4f10b9cd8d
commit
45b92de444
2 changed files with 179 additions and 0 deletions
|
|
@ -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);
|
||||
|
|
|
|||
165
src/simulator/crates/mc-turn/tests/last_survivor_via_capture.rs
Normal file
165
src/simulator/crates/mc-turn/tests/last_survivor_via_capture.rs
Normal 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!(),
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue