diff --git a/src/game/engine/src/modules/victory/victory_manager.gd b/src/game/engine/src/modules/victory/victory_manager.gd index c1450ae6..4ed9ad92 100644 --- a/src/game/engine/src/modules/victory/victory_manager.gd +++ b/src/game/engine/src/modules/victory/victory_manager.gd @@ -50,14 +50,30 @@ func _resolved_max_turns() -> int: func check_all(_game_map: RefCounted) -> void: if _game_over: return - if GameState.turn_number < VICTORY_GRACE_TURNS: + + # Elimination ALWAYS fires regardless of grace — once a player is the only + # one alive, the game is structurally over (eliminated players can't + # recover). Forcing surviving players to keep playing past elimination + # until the grace turn just stalls games (warcouncil p1-29 H2 v1 bug: + # T42 elimination + 100-turn grace → games stalled in_progress until + # safety_timeout). Elimination victories are inherently a sign that + # rush-domination already won; no game-development purpose served by + # delay. + var elim_winner: int = _check_elimination_winner() + if elim_winner >= 0: + _game_over = true + EventBus.victory_achieved.emit(elim_winner, "domination") return - var winner_index: int = _check_domination() - if winner_index >= 0: - _game_over = true - EventBus.victory_achieved.emit(winner_index, "domination") - return + # Capture-all-capitals victory IS gated by grace turns. Slows + # rush-domination so games reach mid-game tech development before a + # captor can secure all capitals. + if GameState.turn_number >= VICTORY_GRACE_TURNS: + var winner_index: int = _check_capture_winner() + if winner_index >= 0: + _game_over = true + EventBus.victory_achieved.emit(winner_index, "domination") + return # Score fallback: at max turns, award the highest-scoring player. if GameState.turn_number >= _resolved_max_turns(): @@ -68,9 +84,10 @@ func check_all(_game_map: RefCounted) -> void: return -## Domination: a player owns every opponent's original capital, OR is the -## last player with at least one city (elimination fallback). -func _check_domination() -> int: +## Capture-all-capitals: a player owns every opponent's original capital. +## Gated by VICTORY_GRACE_TURNS — early-game capture wins before mid-game +## tech development are blocked. +func _check_capture_winner() -> int: var capital_owner_by_player: Dictionary = {} # player_index → current city.owner for player: Variant in GameState.players: if not player is PlayerScript: @@ -94,8 +111,12 @@ func _check_domination() -> int: break if owns_all_other_capitals and candidate.cities.size() > 0: return idx + return -1 - # Elimination fallback. + +## Elimination: only one player has cities or a living founder. Always +## eligible regardless of grace — see check_all() docstring. +func _check_elimination_winner() -> int: var alive_players: Array[int] = [] for player: Variant in GameState.players: if not player is PlayerScript: @@ -111,6 +132,15 @@ func _check_domination() -> int: return -1 +## Legacy combined check kept for any external callers that referenced the +## previous API. Delegates to the split functions; respects no grace. +func _check_domination() -> int: + var capture_winner: int = _check_capture_winner() + if capture_winner >= 0: + return capture_winner + return _check_elimination_winner() + + func _has_living_founder(player: RefCounted) -> bool: for unit: Variant in player.units: if unit is UnitScript and unit.is_alive() and unit.can_found_city: