feat(victory): increase grace turns to 100 for war council

Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
Natalie 2026-04-26 20:28:17 -07:00
parent 80c8504f7e
commit 3fcc9d2c30
3 changed files with 52 additions and 3 deletions

View file

@ -18,7 +18,16 @@ const CityScript: GDScript = preload("res://engine/src/entities/city.gd")
## degenerate maps (one player eliminated before the game has had time to
## develop) are not interesting and skew metrics — give every game at least
## this much runway before a winner can be declared.
const VICTORY_GRACE_TURNS: int = 10
##
## Bumped 2026-04-26 from 10 → 100 (warcouncil p1-29 H2). Round 1 evidence
## (`.local/iter/p1-29-{hard,insane}-20260426_194{937,939}/`) showed 7-8/10
## games end T48-T200 via early-domination even with 2-3× research_mult,
## blocking the user's tier-10-by-T200 target. T100 floor gives every game
## ≥T100 of mid-game tech development before a domination victory can fire,
## composing with H1's research-speed bumps. Score victory still fires only
## at turn-limit so the bimodal failure (T100 instant + T500 stalemate) is
## the warning signal to watch for.
const VICTORY_GRACE_TURNS: int = 100
## Fallback turn limit if GameState.game_settings.turn_limit is missing.
## Real limit is read from game_settings["turn_limit"] at check time so the

View file

@ -327,7 +327,10 @@ impl TurnProcessor {
// Phase 7: victory check — use full VictoryConfig when available,
// otherwise fall back to simple city-count check.
if let Some(ref vc) = self.victory_config {
if let Some((wi, vt)) = crate::victory::check_victory(&state.players, vc) {
// Pass `state.turn` so `vc.min_domination_turn` (warcouncil p1-29 H2)
// can gate the domination branch in early turns. Other victory
// types ignore the turn parameter.
if let Some((wi, vt)) = crate::victory::check_victory_at_turn(&state.players, vc, state.turn) {
result.winner = Some((wi, vt));
}
} else {

View file

@ -147,6 +147,16 @@ pub struct VictoryConfig {
/// When true, domination requires controlling *every* opponent capital.
/// When false, domination requires eliminating all opponents (no cities).
pub domination_requires_all_capitals: bool,
/// Earliest turn at which a domination victory can fire. `0` means no
/// floor (legacy behavior). When `> 0`, `check_victory` returns `None`
/// for the domination branch on any turn `< min_domination_turn` even if
/// the capital-capture condition is met. Score, gold, culture, science
/// victories are unaffected. Designed to prevent rush-domination from
/// cutting games short before mid-game tech development can happen
/// (warcouncil p1-29 H2). `#[serde(default)]` so existing
/// `victories.json` (without this field) deserializes to `0`.
#[serde(default)]
pub min_domination_turn: u32,
}
// ── Default victory thresholds ───────────────────────────────────────────────
@ -194,6 +204,7 @@ impl Default for VictoryConfig {
],
science_cost_base: DEFAULT_SCIENCE_COST_BASE,
domination_requires_all_capitals: true,
min_domination_turn: 0,
}
}
}
@ -208,9 +219,23 @@ impl Default for VictoryConfig {
pub fn check_victory(
players: &[PlayerState],
config: &VictoryConfig,
) -> Option<(u8, VictoryType)> {
check_victory_at_turn(players, config, u32::MAX)
}
/// Like [`check_victory`] but takes the current turn so that
/// `config.min_domination_turn` can gate the domination branch. Other victory
/// types ignore the turn parameter. Existing call sites that don't track the
/// turn use [`check_victory`] which passes `u32::MAX` (no floor effect).
pub fn check_victory_at_turn(
players: &[PlayerState],
config: &VictoryConfig,
turn: u32,
) -> Option<(u8, VictoryType)> {
// Domination: a player controls all other players' original capitals.
if config.domination_requires_all_capitals && players.len() > 1 {
// Skip the entire branch if a min-turn floor is set and we haven't
// reached it yet (warcouncil p1-29 H2). Other victory types still fire.
if config.domination_requires_all_capitals && players.len() > 1 && turn >= config.min_domination_turn {
let capitals: Vec<Option<(i32, i32)>> = players
.iter()
.map(|p| p.capital_position)
@ -341,6 +366,7 @@ mod tests {
science_techs_required: vec![],
science_cost_base: 500,
domination_requires_all_capitals: false,
min_domination_turn: 0,
};
let mut p0 = bare_player(0);
let p1 = bare_player(1);
@ -369,6 +395,7 @@ mod tests {
culture_threshold: i64::MAX,
city_count_threshold: usize::MAX,
domination_requires_all_capitals: false,
min_domination_turn: 0,
};
let mut p0 = bare_player(0);
let p1 = bare_player(1);
@ -403,6 +430,7 @@ mod tests {
science_techs_required: vec![],
science_cost_base: 500,
domination_requires_all_capitals: false,
min_domination_turn: 0,
};
let mut p0 = bare_player(0);
let p1 = bare_player(1);
@ -421,6 +449,7 @@ mod tests {
fn domination_requires_controlling_all_enemy_capitals() {
let config = VictoryConfig {
domination_requires_all_capitals: true,
min_domination_turn: 0,
gold_threshold: i64::MAX,
culture_threshold: i64::MAX,
city_count_threshold: usize::MAX,
@ -586,6 +615,7 @@ mod tests {
// domination wins because it's checked first.
let config = VictoryConfig {
domination_requires_all_capitals: true,
min_domination_turn: 0,
gold_threshold: 100,
culture_threshold: i64::MAX,
city_count_threshold: usize::MAX,
@ -620,6 +650,7 @@ mod tests {
// before Economic → p1 should win.
let config = VictoryConfig {
domination_requires_all_capitals: false, // disable domination path
min_domination_turn: 0,
gold_threshold: 100,
culture_threshold: i64::MAX,
city_count_threshold: usize::MAX,
@ -650,6 +681,7 @@ mod tests {
fn priority_order_economic_beats_culture_across_players() {
let config = VictoryConfig {
domination_requires_all_capitals: false,
min_domination_turn: 0,
gold_threshold: 100,
culture_threshold: 100,
city_count_threshold: usize::MAX,
@ -675,6 +707,7 @@ mod tests {
fn priority_order_culture_beats_city_count_across_players() {
let config = VictoryConfig {
domination_requires_all_capitals: false,
min_domination_turn: 0,
gold_threshold: i64::MAX,
culture_threshold: 100,
city_count_threshold: 1,
@ -701,6 +734,7 @@ mod tests {
fn same_condition_ties_break_to_lower_index() {
let config = VictoryConfig {
domination_requires_all_capitals: false,
min_domination_turn: 0,
gold_threshold: 1000,
culture_threshold: i64::MAX,
city_count_threshold: usize::MAX,
@ -727,6 +761,7 @@ mod tests {
fn eliminated_player_skipped_in_domination() {
let config = VictoryConfig {
domination_requires_all_capitals: true,
min_domination_turn: 0,
gold_threshold: i64::MAX,
culture_threshold: i64::MAX,
city_count_threshold: usize::MAX,
@ -763,6 +798,7 @@ mod tests {
fn eliminated_player_cannot_win_domination() {
let config = VictoryConfig {
domination_requires_all_capitals: true,
min_domination_turn: 0,
gold_threshold: i64::MAX,
culture_threshold: i64::MAX,
city_count_threshold: usize::MAX,
@ -800,6 +836,7 @@ mod tests {
fn empty_science_techs_required_does_not_auto_grant() {
let config = VictoryConfig {
domination_requires_all_capitals: false,
min_domination_turn: 0,
gold_threshold: i64::MAX,
culture_threshold: i64::MAX,
city_count_threshold: usize::MAX,