From 55ee242d31f251c93ac8714d49f4203f5a86147d Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 10 Feb 2026 03:03:56 +0000 Subject: [PATCH] fix: game end condition not triggering when last player remains Two bugs prevented the game from ending when only one player survived: 1. Victory condition used entity queries to count players, but buildings are immediately destroyed on death. If eliminated players had no surviving units, they disappeared from entity queries, causing players.size to be 1 and preventing victory from ever being declared. Fixed by using the stable playerTeams map from game setup instead. 2. AICoordinator continued running combat tactics for eliminated AI players (zero buildings path), causing their surviving units to actively fight and potentially kill the remaining player. Fixed by tracking eliminated players via game:playerEliminated events and skipping their updates entirely. Also fixed handleSurrender to exclude already-eliminated players when determining the winner. https://claude.ai/code/session_01AXNWK1n2KziPhLXqMJqX8Q --- src/engine/systems/GameStateSystem.ts | 25 +- src/engine/systems/ai/AICoordinator.ts | 15 + tests/engine/systems/GameStateSystem.test.ts | 364 +++++++++++++++++++ 3 files changed, 388 insertions(+), 16 deletions(-) create mode 100644 tests/engine/systems/GameStateSystem.test.ts diff --git a/src/engine/systems/GameStateSystem.ts b/src/engine/systems/GameStateSystem.ts index b9252323..6036a5ad 100644 --- a/src/engine/systems/GameStateSystem.ts +++ b/src/engine/systems/GameStateSystem.ts @@ -204,16 +204,13 @@ export class GameStateSystem extends System { // Skip victory conditions in battle simulator mode if (isBattleSimulatorMode()) return; - const players = new Set(); + // Use the stable set of game participants (from game setup), not current entities. + // Entity-based counting is unreliable: buildings are immediately destroyed on death, + // so eliminated players with no surviving units would disappear from entity queries, + // causing players.size to drop to 1 and preventing victory from ever being declared. + const players = new Set(this.playerTeams.keys()); const playersWithBuildings = new Set(); - // Collect all players - const allEntities = this.world.getEntitiesWith('Selectable'); - for (const entity of allEntities) { - const selectable = entity.get('Selectable')!; - players.add(selectable.playerId); - } - // Check which players still have complete buildings (not blueprints) const buildings = this.world.getEntitiesWith('Building', 'Selectable', 'Health'); for (const entity of buildings) { @@ -290,14 +287,10 @@ export class GameStateSystem extends System { } private handleSurrender(playerId: string): void { - const players = new Set(); - const allEntities = this.world.getEntitiesWith('Selectable'); - for (const entity of allEntities) { - const selectable = entity.get('Selectable')!; - players.add(selectable.playerId); - } - - const remainingPlayers = [...players].filter((p) => p !== playerId); + const players = new Set(this.playerTeams.keys()); + const remainingPlayers = [...players].filter( + (p) => p !== playerId && !this.eliminatedPlayers.has(p) + ); if (remainingPlayers.length === 1) { this.declareVictory(remainingPlayers[0], playerId, 'surrender'); } diff --git a/src/engine/systems/ai/AICoordinator.ts b/src/engine/systems/ai/AICoordinator.ts index 2a311973..a648b010 100644 --- a/src/engine/systems/ai/AICoordinator.ts +++ b/src/engine/systems/ai/AICoordinator.ts @@ -317,6 +317,9 @@ export class AICoordinator extends System { /** Reactive defense tick tracking per player */ private lastReactiveDefenseTick: Map = new Map(); + /** Players eliminated from the game (lost all buildings) - stop AI operations */ + private eliminatedPlayers: Set = new Set(); + // Subsystems private economyManager: AIEconomyManager; private buildOrderExecutor: AIBuildOrderExecutor; @@ -400,6 +403,14 @@ export class AICoordinator extends System { } ); + // Stop AI operations for eliminated players + this.game.eventBus.on('game:playerEliminated', (data: { playerId: string }) => { + if (this.aiPlayers.has(data.playerId)) { + this.eliminatedPlayers.add(data.playerId); + debugAI.log(`[AICoordinator] ${data.playerId} eliminated - AI operations stopped`); + } + }); + // Track research completion this.game.eventBus.on( 'research:complete', @@ -1236,6 +1247,7 @@ export class AICoordinator extends System { // This ensures defense response isn't gated by the 40-tick medium delay if (currentTick % 10 === 0) { for (const [, ai] of this.aiPlayers) { + if (this.eliminatedPlayers.has(ai.playerId)) continue; if (this.tacticsManager.isUnderAttack(ai)) { if (ai.state === 'attacking' && ai.activeAttackOperation) { // During active attacks, trigger reactive defense for home units @@ -1250,6 +1262,9 @@ export class AICoordinator extends System { } for (const [playerId, ai] of this.aiPlayers) { + // Skip eliminated players - their units become inert + if (this.eliminatedPlayers.has(playerId)) continue; + const actionDelay = this.getActionDelay(ai.difficulty); if (currentTick - ai.lastActionTick < actionDelay) continue; diff --git a/tests/engine/systems/GameStateSystem.test.ts b/tests/engine/systems/GameStateSystem.test.ts new file mode 100644 index 00000000..8bc71608 --- /dev/null +++ b/tests/engine/systems/GameStateSystem.test.ts @@ -0,0 +1,364 @@ +import { describe, it, expect } from 'vitest'; + +/** + * GameStateSystem Victory Condition Tests + * + * Tests the game end condition logic to ensure: + * 1. Victory is declared when one team remains with buildings + * 2. Victory is declared even when eliminated players have no entities in the world + * 3. Draw is declared when all players are eliminated simultaneously + * 4. FFA and team game modes both work correctly + * 5. Surrender correctly identifies the winner among remaining players + */ + +type TeamNumber = 0 | 1 | 2 | 3 | 4; + +interface VictoryCheckResult { + type: 'victory' | 'draw' | 'none'; + winner?: string; + loser?: string; + eliminatedThisTick?: string[]; +} + +/** + * Mirror of checkVictoryConditions logic from GameStateSystem. + * Uses playerTeams (stable game participants) instead of entity queries. + */ +function checkVictoryConditions( + playerTeams: Map, + playersWithBuildings: Set, + eliminatedPlayers: Set +): VictoryCheckResult { + const players = new Set(playerTeams.keys()); + const newlyEliminated: string[] = []; + + // Check for newly eliminated players + for (const playerId of players) { + if (!playersWithBuildings.has(playerId) && !eliminatedPlayers.has(playerId)) { + eliminatedPlayers.add(playerId); + newlyEliminated.push(playerId); + } + } + + // Group active players by team + const teamsWithActivePlayers = new Map(); + let ffaIndex = -1; + + for (const playerId of playersWithBuildings) { + const team = playerTeams.get(playerId) ?? 0; + if (team === 0) { + teamsWithActivePlayers.set(ffaIndex--, [playerId]); + } else { + const existing = teamsWithActivePlayers.get(team) ?? []; + existing.push(playerId); + teamsWithActivePlayers.set(team, existing); + } + } + + // Victory condition + if (teamsWithActivePlayers.size === 1 && players.size > 1) { + const [, teamPlayers] = [...teamsWithActivePlayers.entries()][0]; + const winner = teamPlayers[0]; + const losers = [...players].filter((p) => !teamPlayers.includes(p)); + return { + type: 'victory', + winner, + loser: losers[0] ?? 'none', + eliminatedThisTick: newlyEliminated, + }; + } else if (teamsWithActivePlayers.size === 0 && players.size > 0) { + return { type: 'draw', eliminatedThisTick: newlyEliminated }; + } + + return { type: 'none', eliminatedThisTick: newlyEliminated }; +} + +/** + * Mirror of handleSurrender logic from GameStateSystem. + * Uses playerTeams and eliminatedPlayers instead of entity queries. + */ +function handleSurrender( + surrenderingPlayer: string, + playerTeams: Map, + eliminatedPlayers: Set +): { winner: string } | null { + const players = new Set(playerTeams.keys()); + const remainingPlayers = [...players].filter( + (p) => p !== surrenderingPlayer && !eliminatedPlayers.has(p) + ); + if (remainingPlayers.length === 1) { + return { winner: remainingPlayers[0] }; + } + return null; +} + +describe('GameStateSystem', () => { + describe('Victory conditions with stable player set', () => { + it('declares victory when one FFA player has buildings and others do not', () => { + const playerTeams = new Map([ + ['player1', 0], + ['player2', 0], + ['player3', 0], + ]); + const playersWithBuildings = new Set(['player1']); + const eliminatedPlayers = new Set(); + + const result = checkVictoryConditions(playerTeams, playersWithBuildings, eliminatedPlayers); + + expect(result.type).toBe('victory'); + expect(result.winner).toBe('player1'); + }); + + it('declares victory even when eliminated players have zero entities in world', () => { + // This is the core bug fix: previously players were counted from entities, + // so eliminated players with no surviving units wouldn't be in the set, + // causing players.size to be 1 and preventing victory + const playerTeams = new Map([ + ['player1', 0], + ['player2', 0], + ]); + // player2 has no buildings AND no units (entity completely gone) + // With the old code, players.size would be 1 (only player1's entities exist) + // With the fix, players.size is 2 (from playerTeams) + const playersWithBuildings = new Set(['player1']); + const eliminatedPlayers = new Set(); + + const result = checkVictoryConditions(playerTeams, playersWithBuildings, eliminatedPlayers); + + expect(result.type).toBe('victory'); + expect(result.winner).toBe('player1'); + }); + + it('does not declare victory when multiple FFA players have buildings', () => { + const playerTeams = new Map([ + ['player1', 0], + ['player2', 0], + ['player3', 0], + ]); + const playersWithBuildings = new Set(['player1', 'player3']); + const eliminatedPlayers = new Set(); + + const result = checkVictoryConditions(playerTeams, playersWithBuildings, eliminatedPlayers); + + expect(result.type).toBe('none'); + }); + + it('declares draw when all players lose buildings simultaneously', () => { + const playerTeams = new Map([ + ['player1', 0], + ['player2', 0], + ]); + const playersWithBuildings = new Set(); + const eliminatedPlayers = new Set(); + + const result = checkVictoryConditions(playerTeams, playersWithBuildings, eliminatedPlayers); + + expect(result.type).toBe('draw'); + }); + + it('does not declare victory in single-player game', () => { + const playerTeams = new Map([['player1', 0]]); + const playersWithBuildings = new Set(['player1']); + const eliminatedPlayers = new Set(); + + const result = checkVictoryConditions(playerTeams, playersWithBuildings, eliminatedPlayers); + + expect(result.type).toBe('none'); + }); + + it('tracks newly eliminated players', () => { + const playerTeams = new Map([ + ['player1', 0], + ['player2', 0], + ['player3', 0], + ]); + const playersWithBuildings = new Set(['player1']); + const eliminatedPlayers = new Set(); + + const result = checkVictoryConditions(playerTeams, playersWithBuildings, eliminatedPlayers); + + expect(result.eliminatedThisTick).toContain('player2'); + expect(result.eliminatedThisTick).toContain('player3'); + expect(eliminatedPlayers.has('player2')).toBe(true); + expect(eliminatedPlayers.has('player3')).toBe(true); + }); + + it('does not re-eliminate already eliminated players', () => { + const playerTeams = new Map([ + ['player1', 0], + ['player2', 0], + ['player3', 0], + ]); + const playersWithBuildings = new Set(['player1']); + const eliminatedPlayers = new Set(['player2']); // Already eliminated + + const result = checkVictoryConditions(playerTeams, playersWithBuildings, eliminatedPlayers); + + // Only player3 is newly eliminated + expect(result.eliminatedThisTick).toEqual(['player3']); + }); + }); + + describe('Team game victory conditions', () => { + it('declares victory when one team remains in a 2v2', () => { + const playerTeams = new Map([ + ['player1', 1], + ['player2', 1], + ['player3', 2], + ['player4', 2], + ]); + // Team 1 has buildings, team 2 does not + const playersWithBuildings = new Set(['player1', 'player2']); + const eliminatedPlayers = new Set(); + + const result = checkVictoryConditions(playerTeams, playersWithBuildings, eliminatedPlayers); + + expect(result.type).toBe('victory'); + expect(result.winner).toBe('player1'); + }); + + it('declares victory even when only one teammate has buildings', () => { + const playerTeams = new Map([ + ['player1', 1], + ['player2', 1], + ['player3', 2], + ['player4', 2], + ]); + // Only player1 on team 1 has buildings, but team 1 still wins + const playersWithBuildings = new Set(['player1']); + const eliminatedPlayers = new Set(); + + const result = checkVictoryConditions(playerTeams, playersWithBuildings, eliminatedPlayers); + + expect(result.type).toBe('victory'); + expect(result.winner).toBe('player1'); + }); + + it('does not declare victory when both teams have buildings', () => { + const playerTeams = new Map([ + ['player1', 1], + ['player2', 1], + ['player3', 2], + ['player4', 2], + ]); + const playersWithBuildings = new Set(['player1', 'player3']); + const eliminatedPlayers = new Set(); + + const result = checkVictoryConditions(playerTeams, playersWithBuildings, eliminatedPlayers); + + expect(result.type).toBe('none'); + }); + }); + + describe('Surrender handling with stable player set', () => { + it('identifies winner when last opponent surrenders', () => { + const playerTeams = new Map([ + ['player1', 0], + ['player2', 0], + ]); + const eliminatedPlayers = new Set(); + + const result = handleSurrender('player2', playerTeams, eliminatedPlayers); + + expect(result).not.toBeNull(); + expect(result!.winner).toBe('player1'); + }); + + it('does not declare winner when multiple opponents remain', () => { + const playerTeams = new Map([ + ['player1', 0], + ['player2', 0], + ['player3', 0], + ]); + const eliminatedPlayers = new Set(); + + const result = handleSurrender('player2', playerTeams, eliminatedPlayers); + + expect(result).toBeNull(); + }); + + it('correctly identifies winner when some players already eliminated', () => { + const playerTeams = new Map([ + ['player1', 0], + ['player2', 0], + ['player3', 0], + ]); + const eliminatedPlayers = new Set(['player3']); // Already eliminated + + const result = handleSurrender('player2', playerTeams, eliminatedPlayers); + + expect(result).not.toBeNull(); + expect(result!.winner).toBe('player1'); + }); + }); + + describe('AI elimination stops operations', () => { + it('eliminated AI player ID is tracked and can be checked', () => { + // Mirrors the AICoordinator's eliminatedPlayers Set behavior + const eliminatedPlayers = new Set(); + const aiPlayers = new Map([ + ['player2', { playerId: 'player2' }], + ['player3', { playerId: 'player3' }], + ]); + + // Simulate game:playerEliminated event for player2 + const eliminatedPlayerId = 'player2'; + if (aiPlayers.has(eliminatedPlayerId)) { + eliminatedPlayers.add(eliminatedPlayerId); + } + + // In the update loop, eliminated players should be skipped + const processedPlayers: string[] = []; + for (const [playerId] of aiPlayers) { + if (eliminatedPlayers.has(playerId)) continue; + processedPlayers.push(playerId); + } + + expect(processedPlayers).toEqual(['player3']); + expect(processedPlayers).not.toContain('player2'); + }); + + it('non-AI players being eliminated does not affect AI tracking', () => { + const eliminatedPlayers = new Set(); + const aiPlayers = new Map([['player2', { playerId: 'player2' }]]); + + // Human player1 eliminated - not in aiPlayers + const eliminatedPlayerId = 'player1'; + if (aiPlayers.has(eliminatedPlayerId)) { + eliminatedPlayers.add(eliminatedPlayerId); + } + + expect(eliminatedPlayers.size).toBe(0); + + // AI player should still be processed + const processedPlayers: string[] = []; + for (const [playerId] of aiPlayers) { + if (eliminatedPlayers.has(playerId)) continue; + processedPlayers.push(playerId); + } + + expect(processedPlayers).toEqual(['player2']); + }); + + it('multiple AI players can be eliminated independently', () => { + const eliminatedPlayers = new Set(); + const aiPlayers = new Map([ + ['player2', { playerId: 'player2' }], + ['player3', { playerId: 'player3' }], + ['player4', { playerId: 'player4' }], + ]); + + // Eliminate player2 and player4 + eliminatedPlayers.add('player2'); + eliminatedPlayers.add('player4'); + + const processedPlayers: string[] = []; + for (const [playerId] of aiPlayers) { + if (eliminatedPlayers.has(playerId)) continue; + processedPlayers.push(playerId); + } + + expect(processedPlayers).toEqual(['player3']); + }); + }); +});