From e427f1c68d7279bb535d4b791816380c92475d45 Mon Sep 17 00:00:00 2001 From: sokol Date: Sat, 21 Feb 2026 21:01:57 +0300 Subject: [PATCH] Fix AI bot turn chain for multiple bots - prevent race conditions --- public/ai-bot.js | 93 +++++++------ public/game.js | 101 +++++++++++---- test/ai-bot.test.js | 308 ++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 435 insertions(+), 67 deletions(-) diff --git a/public/ai-bot.js b/public/ai-bot.js index aa777af..f652188 100644 --- a/public/ai-bot.js +++ b/public/ai-bot.js @@ -17,50 +17,63 @@ export class AIBot { * Execute AI turn - makes ONE move then ends turn */ async playTurn() { - console.log(`AI Player ${this.playerId} thinking...`); - - // Get all player cells - const playerCells = this.map.getPlayerCells(this.playerId); - - console.log(`AI Player ${this.playerId} has ${playerCells.length} cells`); + console.log(`[AI-BOT P${this.playerId}] === Turn started ===`); + console.log(`[AI-BOT P${this.playerId}] Thinking...`); - if (playerCells.length === 0) { - console.log(`AI Player ${this.playerId} has no cells, ending turn`); + try { + // Get all player cells + const playerCells = this.map.getPlayerCells(this.playerId); + + console.log(`[AI-BOT P${this.playerId}] Has ${playerCells.length} cells`); + + if (playerCells.length === 0) { + console.log(`[AI-BOT P${this.playerId}] No cells, ending turn`); + await this.wait(this.thinkingTime); + this.gameUI.endTurn(); + return; + } + + // Find all possible moves + const moves = this.findPossibleMoves(playerCells); + + console.log(`[AI-BOT P${this.playerId}] Found ${moves.length} possible moves`); + + if (moves.length === 0) { + // No moves available, end turn + console.log(`[AI-BOT P${this.playerId}] No valid moves, ending turn`); + await this.wait(this.thinkingTime); + this.gameUI.endTurn(); + return; + } + + // Sort moves by priority (attack > expand > reinforce) + moves.sort((a, b) => this.movePriority(b) - this.movePriority(a)); + + // Execute best move + const bestMove = moves[0]; + console.log(`[AI-BOT P${this.playerId}] Selected move: from (${bestMove.from.q},${bestMove.from.r}) to (${bestMove.to.q},${bestMove.to.r}), type=${bestMove.type}, attackStr=${bestMove.attackStrength}, defStr=${bestMove.defenseStrength}`); + + // Wait for thinking time + await this.wait(this.thinkingTime); + + // Execute the move + this.gameUI.selectedCell = bestMove.from; + this.gameUI.currentTarget = bestMove.to; + this.gameUI.executeAttack(); + + console.log(`[AI-BOT P${this.playerId}] Move executed`); + + // End turn after executing move + console.log(`[AI-BOT P${this.playerId}] Calling endTurn()`); this.gameUI.endTurn(); - return; - } - - // Find all possible moves - const moves = this.findPossibleMoves(playerCells); - - console.log(`AI Player ${this.playerId} found ${moves.length} possible moves`); - - if (moves.length === 0) { - // No moves available, end turn - console.log(`AI Player ${this.playerId} has no moves, ending turn`); + + console.log(`[AI-BOT P${this.playerId}] === Turn completed ===`); + } catch (error) { + console.error(`[AI-BOT P${this.playerId}] Error during turn:`, error); + // Still end turn on error to prevent game from getting stuck this.gameUI.endTurn(); - return; + throw error; // Re-throw so caller knows there was an error } - - // Sort moves by priority (attack > expand > reinforce) - moves.sort((a, b) => this.movePriority(b) - this.movePriority(a)); - - // Execute best move - const bestMove = moves[0]; - console.log(`AI Player ${this.playerId} selected move: from (${bestMove.from.q},${bestMove.from.r}) to (${bestMove.to.q},${bestMove.to.r}), type=${bestMove.type}`); - - // Wait for thinking time - await this.wait(this.thinkingTime); - - // Execute the move directly without using executeMove helper - this.gameUI.selectedCell = bestMove.from; - this.gameUI.currentTarget = bestMove.to; - this.gameUI.executeAttack(); - - console.log(`AI Player ${this.playerId} executed move`); - - // End turn after executing move - this.gameUI.endTurn(); } /** diff --git a/public/game.js b/public/game.js index ceeae9b..6bb826d 100644 --- a/public/game.js +++ b/public/game.js @@ -54,10 +54,11 @@ class GameUI { this.gamePhase = 'movement'; this.hasMoved = false; this.isAIThinking = false; - + this.isProcessingTurn = false; // Prevent re-entrancy during turn processing + this.offsetX = 0; this.offsetY = 0; - + this.init(); } @@ -115,7 +116,8 @@ class GameUI { this.gamePhase = 'movement'; this.hasMoved = false; this.isAIThinking = false; - + this.isProcessingTurn = false; + // Initialize AI bots AFTER map is created this.aiBots = {}; for (let i = 1; i <= this.playerCount; i++) { @@ -123,16 +125,17 @@ class GameUI { this.aiBots[i] = new AIBot(i, this.map, this); } } - + // Initialize starting positions for all players this.initializePlayers(); - + this.centerMap(); this.render(); this.createPlayerCards(); this.updateUI(); this.log(`New game started with ${this.playerCount} players!`); - + console.log(`[GAME] New game started with ${this.playerCount} players`); + // Start first player's turn (AI if needed) this.checkAndRunAITurn(); } @@ -515,38 +518,82 @@ class GameUI { } async endTurn() { - if (this.gamePhase !== 'movement') return; - // Remove isAIThinking check - AI needs to call endTurn() after its move + if (this.gamePhase !== 'movement') { + console.log(`[GAME] endTurn() called but gamePhase is '${this.gamePhase}', ignoring`); + return; + } - // Apply supply - const supply = this.map.calculateSupply(this.currentPlayer); - this.distributeSupply(supply); - this.log(`Player ${this.currentPlayer} received ${supply} supply`); - - // Next player - this.currentPlayer = (this.currentPlayer % this.playerCount) + 1; - this.hasMoved = false; + // Prevent re-entrancy - only one turn processing at a time + if (this.isProcessingTurn) { + console.log(`[GAME] endTurn() called but isProcessingTurn is true, ignoring`); + return; + } - this.cancelSelection(); - this.updateUI(); - this.render(); + this.isProcessingTurn = true; + console.log(`[GAME] endTurn() - Player ${this.currentPlayer} ending turn`); - this.log(`Player ${this.currentPlayer}'s turn`); - - // Check if next player is AI - this.checkAndRunAITurn(); + try { + // Apply supply + const supply = this.map.calculateSupply(this.currentPlayer); + this.distributeSupply(supply); + this.log(`Player ${this.currentPlayer} received ${supply} supply`); + console.log(`[GAME] Player ${this.currentPlayer} received ${supply} supply`); + + // Next player + const previousPlayer = this.currentPlayer; + this.currentPlayer = (this.currentPlayer % this.playerCount) + 1; + this.hasMoved = false; + + console.log(`[GAME] Turn transition: P${previousPlayer} -> P${this.currentPlayer}`); + + this.cancelSelection(); + this.updateUI(); + this.render(); + + this.log(`Player ${this.currentPlayer}'s turn`); + console.log(`[GAME] Player ${this.currentPlayer}'s turn started (${this.playerTypes[this.currentPlayer]})`); + + // Check if next player is AI and await completion + await this.checkAndRunAITurn(); + } catch (error) { + console.error(`[GAME] Error in endTurn():`, error); + this.log(`Error during turn transition: ${error.message}`, 'error'); + } finally { + this.isProcessingTurn = false; + console.log(`[GAME] endTurn() completed for Player ${this.currentPlayer}`); + } } - checkAndRunAITurn() { + /** + * Check if current player is AI and run their turn + * @returns {Promise} - true if AI turn was run, false if human turn + */ + async checkAndRunAITurn() { if (this.playerTypes[this.currentPlayer] === 'ai' && this.aiBots[this.currentPlayer]) { + console.log(`[AI] Player ${this.currentPlayer} is AI, starting turn`); this.isAIThinking = true; this.updateUI(); - // Run AI turn and reset flag when done - this.aiBots[this.currentPlayer].playTurn().then(() => { + try { + // Run AI turn and wait for it to complete + await this.aiBots[this.currentPlayer].playTurn(); + console.log(`[AI] Player ${this.currentPlayer} AI turn completed`); + return true; + } catch (error) { + console.error(`[AI] Error during Player ${this.currentPlayer} AI turn:`, error); + this.log(`AI error: ${error.message}`, 'error'); this.isAIThinking = false; - }); + this.updateUI(); + // Still advance to next player even on error + return true; + } finally { + // Always reset the flag when AI turn completes (success or error) + this.isAIThinking = false; + console.log(`[AI] Player ${this.currentPlayer} isAIThinking reset to false`); + } } + console.log(`[AI] Player ${this.currentPlayer} is human, no AI turn needed`); + return false; } distributeSupply(supply) { diff --git a/test/ai-bot.test.js b/test/ai-bot.test.js index f930677..f970a9e 100644 --- a/test/ai-bot.test.js +++ b/test/ai-bot.test.js @@ -919,3 +919,311 @@ describe('GameUI.distributeSupply', () => { assert.strictEqual(finalTotal, initialTotal + 5, 'Should add exactly 5 total'); }); }); + +/** + * Tests for multiple AI bots turn chain + * These tests verify that AI turns complete in sequence without race conditions + */ +describe('Multiple AI Bots Turn Chain', () => { + /** + * Mock GameUI that tracks turn order and AI completion + */ + class TurnTrackingGameUI { + constructor(playerCount, playerTypes) { + this.playerCount = playerCount; + this.playerTypes = playerTypes; + this.currentPlayer = 1; + this.turnOrder = []; + this.aiTurnsCompleted = []; + this.isAIThinking = false; + this.isProcessingTurn = false; + this.selectedCell = null; + this.currentTarget = null; + this.executedMoves = []; + this.aiBots = {}; + } + + endTurn() { + this.turnOrder.push(this.currentPlayer); + this.currentPlayer = (this.currentPlayer % this.playerCount) + 1; + } + + executeAttack() { + this.executedMoves.push({ + from: this.selectedCell, + to: this.currentTarget, + player: this.currentPlayer + }); + } + + updateUI() { + // No-op for testing + } + + log(message) { + // No-op for testing + } + + async checkAndRunAITurn() { + const player = this.currentPlayer; // Capture player before turn advances + if (this.playerTypes[player] === 'ai' && this.aiBots[player]) { + this.isAIThinking = true; + await this.aiBots[player].playTurn(); + this.isAIThinking = false; + this.aiTurnsCompleted.push(player); // Record the player who took the turn + return true; + } + return false; + } + } + + describe('Two AI bots', () => { + it('should complete both AI turns in sequence', async () => { + const map = createTestMap(5); + const gameUI = new TurnTrackingGameUI(2, { 1: 'ai', 2: 'ai' }); + + // Set up AI bots + const bot1 = new AIBot(1, map, gameUI); + const bot2 = new AIBot(2, map, gameUI); + gameUI.aiBots = { 1: bot1, 2: bot2 }; + + // Set up cells for both players + setupPlayerCell(map, 1, 1, 1, 5); + setupPlayerCell(map, 3, 3, 2, 5); + map.getCell(1, 0).type = CELL_TYPES.EMPTY; + map.getCell(3, 2).type = CELL_TYPES.EMPTY; + + bot1.thinkingTime = 0; + bot2.thinkingTime = 0; + + // Run AI turn for player 1 (currentPlayer starts at 1) + const result1 = await gameUI.checkAndRunAITurn(); + + // Verify player 1's turn completed + assert.strictEqual(result1, true, 'Should return true for AI turn'); + assert.strictEqual(gameUI.aiTurnsCompleted.length, 1, 'Should have 1 completed turn'); + assert.strictEqual(gameUI.aiTurnsCompleted[0], 1, 'First completed turn should be P1'); + assert.strictEqual(gameUI.currentPlayer, 2, 'Current player should be 2 after P1 turn'); + + // Run AI turn for player 2 + const result2 = await gameUI.checkAndRunAITurn(); + + // Verify both turns completed in order + assert.strictEqual(result2, true, 'Should return true for AI turn'); + assert.strictEqual(gameUI.aiTurnsCompleted.length, 2, 'Should have 2 completed turns'); + assert.strictEqual(gameUI.aiTurnsCompleted[0], 1, 'First turn should be P1'); + assert.strictEqual(gameUI.aiTurnsCompleted[1], 2, 'Second turn should be P2'); + }); + + it('should not have race conditions between AI turns', async () => { + const map = createTestMap(5); + const gameUI = new TurnTrackingGameUI(2, { 1: 'ai', 2: 'ai' }); + + const bot1 = new AIBot(1, map, gameUI); + const bot2 = new AIBot(2, map, gameUI); + gameUI.aiBots = { 1: bot1, 2: bot2 }; + + setupPlayerCell(map, 1, 1, 1, 5); + setupPlayerCell(map, 3, 3, 2, 5); + map.getCell(1, 0).type = CELL_TYPES.EMPTY; + map.getCell(3, 2).type = CELL_TYPES.EMPTY; + + bot1.thinkingTime = 0; + bot2.thinkingTime = 0; + + let thinkingFlags = []; + + // Override to track isAIThinking + const originalCheckAndRun = gameUI.checkAndRunAITurn.bind(gameUI); + gameUI.checkAndRunAITurn = async function() { + thinkingFlags.push({ player: this.currentPlayer, start: this.isAIThinking }); + const result = await originalCheckAndRun(); + thinkingFlags.push({ player: this.currentPlayer, end: this.isAIThinking }); + return result; + }; + + // Run both turns sequentially + await gameUI.checkAndRunAITurn(); + await gameUI.checkAndRunAITurn(); + + // Verify no overlap - isAIThinking should be false at start of each turn + // and false at end of each turn + assert.strictEqual(thinkingFlags[0].start, false, 'isAIThinking should be false at start of P1 turn'); + assert.strictEqual(thinkingFlags[1].end, false, 'isAIThinking should be false at end of P1 turn'); + assert.strictEqual(thinkingFlags[2].start, false, 'isAIThinking should be false at start of P2 turn'); + assert.strictEqual(thinkingFlags[3].end, false, 'isAIThinking should be false at end of P2 turn'); + }); + }); + + describe('Three AI bots', () => { + it('should complete all three AI turns in correct sequence', async () => { + const map = createTestMap(5); + const gameUI = new TurnTrackingGameUI(3, { 1: 'ai', 2: 'ai', 3: 'ai' }); + + const bot1 = new AIBot(1, map, gameUI); + const bot2 = new AIBot(2, map, gameUI); + const bot3 = new AIBot(3, map, gameUI); + gameUI.aiBots = { 1: bot1, 2: bot2, 3: bot3 }; + + setupPlayerCell(map, 0, 0, 1, 5); + setupPlayerCell(map, 2, 2, 2, 5); + setupPlayerCell(map, 4, 4, 3, 5); + map.getCell(0, 1).type = CELL_TYPES.EMPTY; + map.getCell(2, 1).type = CELL_TYPES.EMPTY; + map.getCell(4, 3).type = CELL_TYPES.EMPTY; + + [bot1, bot2, bot3].forEach(bot => bot.thinkingTime = 0); + + // Run all three turns + await gameUI.checkAndRunAITurn(); + await gameUI.checkAndRunAITurn(); + await gameUI.checkAndRunAITurn(); + + // Verify all turns completed in order + assert.strictEqual(gameUI.aiTurnsCompleted.length, 3); + assert.deepStrictEqual(gameUI.aiTurnsCompleted, [1, 2, 3]); + }); + }); + + describe('Mixed human and AI players', () => { + it('should only run AI turns, skip human turns', async () => { + const map = createTestMap(5); + // P1 human, P2 AI, P3 human, P4 AI + const gameUI = new TurnTrackingGameUI(4, { 1: 'human', 2: 'ai', 3: 'human', 4: 'ai' }); + + const bot2 = new AIBot(2, map, gameUI); + const bot4 = new AIBot(4, map, gameUI); + gameUI.aiBots = { 2: bot2, 4: bot4 }; + + setupPlayerCell(map, 0, 0, 2, 5); + setupPlayerCell(map, 4, 4, 4, 5); + map.getCell(0, 1).type = CELL_TYPES.EMPTY; + map.getCell(4, 3).type = CELL_TYPES.EMPTY; + + [bot2, bot4].forEach(bot => bot.thinkingTime = 0); + + // P1 is human - should return false (currentPlayer starts at 1) + const p1Result = await gameUI.checkAndRunAITurn(); + assert.strictEqual(p1Result, false, 'P1 human should return false'); + assert.strictEqual(gameUI.aiTurnsCompleted.length, 0); + assert.strictEqual(gameUI.currentPlayer, 1, 'Player should not advance in checkAndRunAITurn'); + + // Manually advance to P2 (simulating endTurn behavior) + gameUI.currentPlayer = 2; + + // P2 is AI - should complete + const p2Result = await gameUI.checkAndRunAITurn(); + assert.strictEqual(p2Result, true, 'P2 AI should return true'); + assert.strictEqual(gameUI.aiTurnsCompleted.length, 1); + assert.strictEqual(gameUI.aiTurnsCompleted[0], 2); + + // Manually advance to P3 + gameUI.currentPlayer = 3; + + // P3 is human - should return false + const p3Result = await gameUI.checkAndRunAITurn(); + assert.strictEqual(p3Result, false, 'P3 human should return false'); + + // Manually advance to P4 + gameUI.currentPlayer = 4; + + // P4 is AI - should complete + const p4Result = await gameUI.checkAndRunAITurn(); + assert.strictEqual(p4Result, true, 'P4 AI should return true'); + assert.strictEqual(gameUI.aiTurnsCompleted.length, 2); + + assert.deepStrictEqual(gameUI.aiTurnsCompleted, [2, 4]); + }); + }); + + describe('AI with no moves', () => { + it('should end turn when AI has no valid moves', async () => { + const map = createTestMap(5); + const gameUI = new TurnTrackingGameUI(2, { 1: 'ai', 2: 'ai' }); + + const bot1 = new AIBot(1, map, gameUI); + gameUI.aiBots = { 1: bot1 }; + + // Player 1 has cell but with strength 1 (can't move) + setupPlayerCell(map, 1, 1, 1, 1); + + bot1.thinkingTime = 0; + + let endTurnCalled = false; + const originalEndTurn = gameUI.endTurn.bind(gameUI); + gameUI.endTurn = function() { + endTurnCalled = true; + return originalEndTurn(); + }; + + await gameUI.checkAndRunAITurn(); + + assert.strictEqual(endTurnCalled, true, 'endTurn should be called when no moves available'); + assert.strictEqual(gameUI.aiTurnsCompleted.length, 1); + }); + + it('should end turn when AI has no cells', async () => { + const map = createTestMap(5); + const gameUI = new TurnTrackingGameUI(2, { 1: 'ai', 2: 'human' }); + + const bot1 = new AIBot(1, map, gameUI); + gameUI.aiBots = { 1: bot1 }; + + // Player 1 has no cells + + bot1.thinkingTime = 0; + + let endTurnCalled = false; + const originalEndTurn = gameUI.endTurn.bind(gameUI); + gameUI.endTurn = function() { + endTurnCalled = true; + return originalEndTurn(); + }; + + await gameUI.checkAndRunAITurn(); + + assert.strictEqual(endTurnCalled, true, 'endTurn should be called when AI has no cells'); + }); + }); + + describe('Four AI bots stress test', () => { + it('should handle 4 AI bots completing turns without race conditions', async () => { + const map = createTestMap(5); + const gameUI = new TurnTrackingGameUI(4, { 1: 'ai', 2: 'ai', 3: 'ai', 4: 'ai' }); + + const bots = []; + for (let i = 1; i <= 4; i++) { + const bot = new AIBot(i, map, gameUI); + bot.thinkingTime = 0; + bots.push(bot); + gameUI.aiBots[i] = bot; + + // Set up cells for each player + setupPlayerCell(map, i - 1, i - 1, i, 5); + // Set up empty target cells + const targetQ = i - 1; + const targetR = i < 4 ? i : 0; + if (map.getCell(targetQ, targetR)) { + map.getCell(targetQ, targetR).type = CELL_TYPES.EMPTY; + } + } + + // Run multiple full rounds + for (let round = 0; round < 3; round++) { + for (let i = 1; i <= 4; i++) { + await gameUI.checkAndRunAITurn(); + } + } + + // Verify all 12 turns completed in order (3 rounds * 4 players) + assert.strictEqual(gameUI.aiTurnsCompleted.length, 12); + + // Check the pattern repeats correctly + for (let i = 0; i < 12; i++) { + const expectedPlayer = (i % 4) + 1; + assert.strictEqual(gameUI.aiTurnsCompleted[i], expectedPlayer, + `Turn ${i + 1} should be player ${expectedPlayer}`); + } + }); + }); +});