feat(@projects/@magic-civilization): add turn limit victory config

Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
Natalie 2026-05-08 20:53:53 -07:00
parent 8f89b9cb78
commit 9ddc350a94
4 changed files with 253 additions and 13 deletions

View file

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

View file

@ -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,

View file

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

View 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"
);
}