feat(@projects/@magic-civilization): ✨ add turn limit victory config
Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
parent
8f89b9cb78
commit
9ddc350a94
4 changed files with 253 additions and 13 deletions
|
|
@ -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<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,
|
||||
});
|
||||
}
|
||||
// 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 },
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
||||
|
|
|
|||
226
src/simulator/crates/mc-turn/tests/game_over_event.rs
Normal file
226
src/simulator/crates/mc-turn/tests/game_over_event.rs
Normal file
|
|
@ -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<u32>) -> 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"
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue