const { describe, it, beforeEach } = require('node:test'); const assert = require('node:assert'); const { HexMap, HexCell, CELL_TYPES, MAP_SIZE } = require('../public/map.js'); const { AIBot } = require('../public/ai-bot.js'); /** * Mock GameUI for testing AIBot * Provides minimal interface needed for AI bot to function */ class MockGameUI { constructor() { this.selectedCell = null; this.currentTarget = null; this.executedMoves = []; this.turnEnded = false; } endTurn() { this.turnEnded = true; } executeAttack() { this.executedMoves.push({ from: this.selectedCell, to: this.currentTarget }); } reset() { this.selectedCell = null; this.currentTarget = null; this.executedMoves = []; this.turnEnded = false; } } /** * Helper to create a clean map for testing */ function createTestMap(size = 5) { const map = new HexMap(size); // Clear all cells to empty for predictable testing map.cells.forEach(cell => { cell.type = CELL_TYPES.EMPTY; cell.dice = []; }); return map; } /** * Helper to set up player cells with specific strength */ function setupPlayerCell(map, q, r, playerId, strength) { const cellType = playerId === 1 ? CELL_TYPES.PLAYER1 : CELL_TYPES.PLAYER2; const cell = map.getCell(q, r); cell.type = cellType; cell.setStrength(strength); return cell; } describe('AIBot', () => { describe('Instantiation', () => { it('should create AIBot with playerId, map, and gameUI', () => { const map = createTestMap(); const gameUI = new MockGameUI(); const bot = new AIBot(1, map, gameUI); assert.strictEqual(bot.playerId, 1); assert.strictEqual(bot.map, map); assert.strictEqual(bot.gameUI, gameUI); assert.strictEqual(bot.thinkingTime, 1000); }); it('should work with different player IDs', () => { const map = createTestMap(); const gameUI = new MockGameUI(); const bot1 = new AIBot(1, map, gameUI); const bot2 = new AIBot(2, map, gameUI); assert.strictEqual(bot1.playerId, 1); assert.strictEqual(bot2.playerId, 2); }); it('should have default thinking time of 1000ms', () => { const map = createTestMap(); const gameUI = new MockGameUI(); const bot = new AIBot(1, map, gameUI); assert.strictEqual(bot.thinkingTime, 1000); }); }); describe('findPossibleMoves', () => { it('should return empty array when player has no cells', () => { const map = createTestMap(); const gameUI = new MockGameUI(); const bot = new AIBot(1, map, gameUI); const moves = bot.findPossibleMoves([]); assert.deepStrictEqual(moves, []); }); it('should return empty array when all player cells have strength <= 1', () => { const map = createTestMap(); const gameUI = new MockGameUI(); const bot = new AIBot(1, map, gameUI); setupPlayerCell(map, 2, 2, 1, 1); setupPlayerCell(map, 2, 3, 1, 0); const playerCells = map.getPlayerCells(1); const moves = bot.findPossibleMoves(playerCells); assert.deepStrictEqual(moves, []); }); it('should identify valid attack targets on adjacent enemy cells', () => { const map = createTestMap(); const gameUI = new MockGameUI(); const bot = new AIBot(1, map, gameUI); // Player 1 at (2,2) with strength 8 setupPlayerCell(map, 2, 2, 1, 8); // Player 2 at adjacent (2,1) with strength 4 setupPlayerCell(map, 2, 1, 2, 4); const playerCells = map.getPlayerCells(1); const moves = bot.findPossibleMoves(playerCells); // Should find at least one attack move to the enemy cell assert.ok(moves.length >= 1, 'Should find at least one move'); // Find the attack move targeting the enemy const attackMove = moves.find(m => m.to.q === 2 && m.to.r === 1); assert.ok(attackMove, 'Should find attack move to enemy cell'); assert.strictEqual(attackMove.from.q, 2); assert.strictEqual(attackMove.from.r, 2); assert.strictEqual(attackMove.attackStrength, 7); // 8 - 1 assert.strictEqual(attackMove.defenseStrength, 4); assert.strictEqual(attackMove.type, 'attack'); }); it('should identify valid expansion targets on adjacent empty cells', () => { const map = createTestMap(); const gameUI = new MockGameUI(); const bot = new AIBot(1, map, gameUI); // Player 1 at (2,2) with strength 6 setupPlayerCell(map, 2, 2, 1, 6); // Empty cell at adjacent (2,3) const emptyCell = map.getCell(2, 3); emptyCell.type = CELL_TYPES.EMPTY; const playerCells = map.getPlayerCells(1); const moves = bot.findPossibleMoves(playerCells); // Find expansion move to the specific empty cell const expansionMove = moves.find(m => m.to.q === 2 && m.to.r === 3); assert.ok(expansionMove, 'Should find expansion move to empty cell at (2,3)'); assert.strictEqual(expansionMove.attackStrength, 5); // 6 - 1 assert.strictEqual(expansionMove.type, 'expand'); }); it('should NOT include moves to own cells', () => { const map = createTestMap(); const gameUI = new MockGameUI(); const bot = new AIBot(1, map, gameUI); // Player 1 owns two adjacent cells setupPlayerCell(map, 2, 2, 1, 8); setupPlayerCell(map, 2, 3, 1, 4); const playerCells = map.getPlayerCells(1); const moves = bot.findPossibleMoves(playerCells); // Should only have moves from each cell to neighbors that are NOT owned by player 1 moves.forEach(move => { assert.notStrictEqual(move.to.getOwner(), 1, 'Move target should not be own cell'); }); }); it('should skip cells with strength <= 1 as source', () => { const map = createTestMap(); const gameUI = new MockGameUI(); const bot = new AIBot(1, map, gameUI); // Player 1 cell with strength 1 (cannot attack) setupPlayerCell(map, 2, 2, 1, 1); // Empty adjacent cell const emptyCell = map.getCell(2, 1); emptyCell.type = CELL_TYPES.EMPTY; const playerCells = map.getPlayerCells(1); const moves = bot.findPossibleMoves(playerCells); assert.strictEqual(moves.length, 0, 'Should not generate moves from cell with strength 1'); }); it('should find multiple moves from multiple player cells', () => { const map = createTestMap(); const gameUI = new MockGameUI(); const bot = new AIBot(1, map, gameUI); // Two player 1 cells with strength > 1 setupPlayerCell(map, 1, 1, 1, 5); setupPlayerCell(map, 3, 3, 1, 6); // Enemy cell adjacent to first setupPlayerCell(map, 1, 0, 2, 3); // Empty cell adjacent to second const emptyCell = map.getCell(3, 2); emptyCell.type = CELL_TYPES.EMPTY; const playerCells = map.getPlayerCells(1); const moves = bot.findPossibleMoves(playerCells); assert.ok(moves.length >= 2, 'Should find moves from both cells'); }); it('should calculate correct attack and defense strengths', () => { const map = createTestMap(); const gameUI = new MockGameUI(); const bot = new AIBot(1, map, gameUI); setupPlayerCell(map, 2, 2, 1, 10); setupPlayerCell(map, 2, 1, 2, 6); const playerCells = map.getPlayerCells(1); const moves = bot.findPossibleMoves(playerCells); // Find the move targeting the enemy cell at (2,1) const attackMove = moves.find(m => m.to.q === 2 && m.to.r === 1); assert.ok(attackMove, 'Should find attack move'); assert.strictEqual(attackMove.attackStrength, 9); // 10 - 1 assert.strictEqual(attackMove.defenseStrength, 6); }); }); describe('movePriority', () => { it('should give higher priority to winning attacks', () => { const map = createTestMap(); const gameUI = new MockGameUI(); const bot = new AIBot(1, map, gameUI); // Winning attack: attack 8 vs defense 3 const winningAttack = { type: 'attack', attackStrength: 8, defenseStrength: 3, from: { q: 0, r: 0 }, to: { q: 0, r: 1 } }; // Losing attack: attack 3 vs defense 8 const losingAttack = { type: 'attack', attackStrength: 3, defenseStrength: 8, from: { q: 0, r: 0 }, to: { q: 0, r: 1 } }; const winningPriority = bot.movePriority(winningAttack); const losingPriority = bot.movePriority(losingAttack); assert.ok(winningPriority > losingPriority, 'Winning attack should have higher priority'); assert.ok(winningPriority >= 100, 'Winning attack should have base priority of 100+'); }); it('should give positive priority to expansion moves', () => { const map = createTestMap(); const gameUI = new MockGameUI(); const bot = new AIBot(1, map, gameUI); const expansionMove = { type: 'expand', attackStrength: 5, defenseStrength: 0, from: { q: 0, r: 0 }, to: { q: 0, r: 1 } }; const priority = bot.movePriority(expansionMove); assert.ok(priority >= 50, 'Expansion should have base priority of 50+'); }); it('should prefer attacks over expansion when attack is favorable', () => { const map = createTestMap(); const gameUI = new MockGameUI(); const bot = new AIBot(1, map, gameUI); // Favorable attack: 10 vs 2 const attack = { type: 'attack', attackStrength: 10, defenseStrength: 2, from: { q: 0, r: 0 }, to: { q: 0, r: 1 } }; // Expansion with same strength const expansion = { type: 'expand', attackStrength: 10, defenseStrength: 0, from: { q: 0, r: 0 }, to: { q: 0, r: 1 } }; const attackPriority = bot.movePriority(attack); const expansionPriority = bot.movePriority(expansion); assert.ok(attackPriority > expansionPriority, 'Favorable attack should beat expansion'); }); it('should add bonus based on attack strength', () => { const map = createTestMap(); const gameUI = new MockGameUI(); const bot = new AIBot(1, map, gameUI); const weakExpansion = { type: 'expand', attackStrength: 2, defenseStrength: 0, from: { q: 0, r: 0 }, to: { q: 0, r: 1 } }; const strongExpansion = { type: 'expand', attackStrength: 8, defenseStrength: 0, from: { q: 0, r: 0 }, to: { q: 0, r: 1 } }; const weakPriority = bot.movePriority(weakExpansion); const strongPriority = bot.movePriority(strongExpansion); assert.ok(strongPriority > weakPriority, 'Stronger expansion should have higher priority'); }); it('should penalize risky attacks', () => { const map = createTestMap(); const gameUI = new MockGameUI(); const bot = new AIBot(1, map, gameUI); const riskyAttack = { type: 'attack', attackStrength: 3, defenseStrength: 7, from: { q: 0, r: 0 }, to: { q: 0, r: 1 } }; const priority = bot.movePriority(riskyAttack); assert.ok(priority < 50, 'Risky attack should have reduced priority'); }); it('should rank moves: strong attack > weak attack > expansion', () => { const map = createTestMap(); const gameUI = new MockGameUI(); const bot = new AIBot(1, map, gameUI); const strongAttack = { type: 'attack', attackStrength: 10, defenseStrength: 2, from: { q: 0, r: 0 }, to: { q: 0, r: 1 } }; const weakAttack = { type: 'attack', attackStrength: 5, defenseStrength: 4, from: { q: 0, r: 0 }, to: { q: 0, r: 1 } }; const expansion = { type: 'expand', attackStrength: 5, defenseStrength: 0, from: { q: 0, r: 0 }, to: { q: 0, r: 1 } }; const strongPriority = bot.movePriority(strongAttack); const weakPriority = bot.movePriority(weakAttack); const expansionPriority = bot.movePriority(expansion); assert.ok(strongPriority > weakPriority, 'Strong attack should beat weak attack'); assert.ok(weakPriority > expansionPriority || strongPriority > expansionPriority, 'Attacks should generally be preferred over expansion'); }); }); describe('AI prefers attacking weak enemies', () => { it('should prefer attacking weak enemy over empty cell', () => { const map = createTestMap(); const gameUI = new MockGameUI(); const bot = new AIBot(1, map, gameUI); setupPlayerCell(map, 2, 2, 1, 8); // Weak enemy at (2,1) setupPlayerCell(map, 2, 1, 2, 2); // Empty cell at (2,3) const emptyCell = map.getCell(2, 3); emptyCell.type = CELL_TYPES.EMPTY; const playerCells = map.getPlayerCells(1); const moves = bot.findPossibleMoves(playerCells); // Sort by priority moves.sort((a, b) => bot.movePriority(b) - bot.movePriority(a)); assert.strictEqual(moves[0].type, 'attack', 'Should prefer attack on weak enemy'); assert.strictEqual(moves[0].to.getOwner(), 2, 'Should target enemy cell'); }); it('should prefer attacking very weak enemy (strength 1)', () => { const map = createTestMap(); const gameUI = new MockGameUI(); const bot = new AIBot(1, map, gameUI); setupPlayerCell(map, 2, 2, 1, 6); // Very weak enemy setupPlayerCell(map, 2, 1, 2, 1); // Empty cell const emptyCell = map.getCell(3, 2); emptyCell.type = CELL_TYPES.EMPTY; const playerCells = map.getPlayerCells(1); const moves = bot.findPossibleMoves(playerCells); moves.sort((a, b) => bot.movePriority(b) - bot.movePriority(a)); const attackMove = moves.find(m => m.type === 'attack'); const expandMove = moves.find(m => m.type === 'expand'); assert.ok(attackMove, 'Should have attack move'); assert.ok(expandMove, 'Should have expansion move'); assert.ok(bot.movePriority(attackMove) > bot.movePriority(expandMove), 'Attack on weak enemy should be preferred'); }); it('should calculate advantage correctly for attack prioritization', () => { const map = createTestMap(); const gameUI = new MockGameUI(); const bot = new AIBot(1, map, gameUI); // Attack with +5 advantage const bigAdvantage = { type: 'attack', attackStrength: 10, defenseStrength: 5, from: { q: 0, r: 0 }, to: { q: 0, r: 1 } }; // Attack with +1 advantage const smallAdvantage = { type: 'attack', attackStrength: 6, defenseStrength: 5, from: { q: 0, r: 0 }, to: { q: 0, r: 1 } }; const bigPriority = bot.movePriority(bigAdvantage); const smallPriority = bot.movePriority(smallAdvantage); assert.ok(bigPriority > smallPriority, 'Bigger advantage should have higher priority'); }); }); describe('AI does not select moves with strength <= 1', () => { it('should not generate moves from cells with strength 1', () => { const map = createTestMap(); const gameUI = new MockGameUI(); const bot = new AIBot(1, map, gameUI); setupPlayerCell(map, 2, 2, 1, 1); const emptyCell = map.getCell(2, 1); emptyCell.type = CELL_TYPES.EMPTY; const playerCells = map.getPlayerCells(1); const moves = bot.findPossibleMoves(playerCells); assert.strictEqual(moves.length, 0, 'Should not generate any moves'); }); it('should not generate moves from cells with strength 0', () => { const map = createTestMap(); const gameUI = new MockGameUI(); const bot = new AIBot(1, map, gameUI); setupPlayerCell(map, 2, 2, 1, 0); const emptyCell = map.getCell(2, 1); emptyCell.type = CELL_TYPES.EMPTY; const playerCells = map.getPlayerCells(1); const moves = bot.findPossibleMoves(playerCells); assert.strictEqual(moves.length, 0, 'Should not generate any moves'); }); it('should generate moves from cells with strength 2', () => { const map = createTestMap(); const gameUI = new MockGameUI(); const bot = new AIBot(1, map, gameUI); setupPlayerCell(map, 2, 2, 1, 2); const emptyCell = map.getCell(2, 1); emptyCell.type = CELL_TYPES.EMPTY; const playerCells = map.getPlayerCells(1); const moves = bot.findPossibleMoves(playerCells); assert.ok(moves.length > 0, 'Should generate moves from cell with strength 2'); assert.strictEqual(moves[0].attackStrength, 1, 'Attack strength should be 1'); }); it('should filter out cells with strength <= 1 when multiple cells exist', () => { const map = createTestMap(); const gameUI = new MockGameUI(); const bot = new AIBot(1, map, gameUI); // Strong cell setupPlayerCell(map, 2, 2, 1, 8); // Weak cell setupPlayerCell(map, 3, 3, 1, 1); // Empty cells adjacent to both map.getCell(2, 1).type = CELL_TYPES.EMPTY; map.getCell(3, 2).type = CELL_TYPES.EMPTY; const playerCells = map.getPlayerCells(1); const moves = bot.findPossibleMoves(playerCells); // All moves should come from the strong cell only moves.forEach(move => { assert.ok(move.from.getStrength() > 1, 'Move source should have strength > 1'); }); }); }); describe('AI respects map boundaries', () => { it('should not generate moves outside map boundaries', () => { const map = createTestMap(5); const gameUI = new MockGameUI(); const bot = new AIBot(1, map, gameUI); // Cell at corner setupPlayerCell(map, 0, 0, 1, 8); const playerCells = map.getPlayerCells(1); const moves = bot.findPossibleMoves(playerCells); // All moves should be within bounds moves.forEach(move => { assert.ok(move.to.q >= 0 && move.to.q < 5, 'Target q should be in bounds'); assert.ok(move.to.r >= 0 && move.to.r < 5, 'Target r should be in bounds'); }); }); it('should handle edge cells correctly', () => { const map = createTestMap(5); const gameUI = new MockGameUI(); const bot = new AIBot(1, map, gameUI); // Cell at edge setupPlayerCell(map, 0, 2, 1, 8); const playerCells = map.getPlayerCells(1); const moves = bot.findPossibleMoves(playerCells); // All moves should be within bounds moves.forEach(move => { assert.ok(move.to.q >= 0 && move.to.q < 5, 'Target q should be in bounds'); assert.ok(move.to.r >= 0 && move.to.r < 5, 'Target r should be in bounds'); }); }); it('should use map.getNeighbors which respects boundaries', () => { const map = createTestMap(5); const gameUI = new MockGameUI(); const bot = new AIBot(1, map, gameUI); // Corner cell should have only 2 neighbors setupPlayerCell(map, 0, 0, 1, 8); // Make adjacent cells passable map.getCell(0, 1).type = CELL_TYPES.EMPTY; map.getCell(1, 0).type = CELL_TYPES.EMPTY; const playerCells = map.getPlayerCells(1); const moves = bot.findPossibleMoves(playerCells); // Should only have moves to valid neighbors assert.ok(moves.length <= 2, 'Corner cell should have at most 2 moves'); }); it('should not include blocked cells as targets', () => { const map = createTestMap(5); const gameUI = new MockGameUI(); const bot = new AIBot(1, map, gameUI); setupPlayerCell(map, 2, 2, 1, 8); // Block an adjacent cell map.getCell(2, 1).type = CELL_TYPES.BLOCKED; const playerCells = map.getPlayerCells(1); const moves = bot.findPossibleMoves(playerCells); // Should not have move to blocked cell const hasBlockedTarget = moves.some(m => m.to.type === CELL_TYPES.BLOCKED); assert.strictEqual(hasBlockedTarget, false, 'Should not target blocked cells'); }); }); describe('executeMove', () => { it('should set selectedCell and currentTarget on gameUI', () => { const map = createTestMap(); const gameUI = new MockGameUI(); const bot = new AIBot(1, map, gameUI); setupPlayerCell(map, 2, 2, 1, 8); setupPlayerCell(map, 2, 1, 2, 4); const playerCells = map.getPlayerCells(1); const moves = bot.findPossibleMoves(playerCells); bot.executeMove(moves[0]); assert.strictEqual(gameUI.selectedCell, moves[0].from); assert.strictEqual(gameUI.currentTarget, moves[0].to); }); it('should call executeAttack on gameUI', () => { const map = createTestMap(); const gameUI = new MockGameUI(); const bot = new AIBot(1, map, gameUI); setupPlayerCell(map, 2, 2, 1, 8); setupPlayerCell(map, 2, 1, 2, 4); const playerCells = map.getPlayerCells(1); const moves = bot.findPossibleMoves(playerCells); bot.executeMove(moves[0]); assert.strictEqual(gameUI.executedMoves.length, 1); }); }); describe('playTurn', () => { it('should end turn when no moves available (cells with strength <= 1)', async () => { const map = createTestMap(); const gameUI = new MockGameUI(); const bot = new AIBot(1, map, gameUI); // Player has cells but all with strength <= 1 (no valid moves) setupPlayerCell(map, 2, 2, 1, 1); bot.thinkingTime = 0; // Skip waiting for faster tests await bot.playTurn(); assert.strictEqual(gameUI.turnEnded, true); }); it('should execute best move when moves are available', async () => { const map = createTestMap(); const gameUI = new MockGameUI(); const bot = new AIBot(1, map, gameUI); setupPlayerCell(map, 2, 2, 1, 8); setupPlayerCell(map, 2, 1, 2, 2); // Weak enemy bot.thinkingTime = 0; // Skip waiting await bot.playTurn(); assert.strictEqual(gameUI.executedMoves.length, 1); assert.strictEqual(gameUI.selectedCell.q, 2); assert.strictEqual(gameUI.selectedCell.r, 2); }); }); describe('Integration: Full AI decision making', () => { it('should choose attack over expansion when attack is favorable', () => { const map = createTestMap(); const gameUI = new MockGameUI(); const bot = new AIBot(1, map, gameUI); setupPlayerCell(map, 2, 2, 1, 10); // Weak enemy setupPlayerCell(map, 2, 1, 2, 3); // Empty cell map.getCell(3, 2).type = CELL_TYPES.EMPTY; const playerCells = map.getPlayerCells(1); const moves = bot.findPossibleMoves(playerCells); moves.sort((a, b) => bot.movePriority(b) - bot.movePriority(a)); assert.strictEqual(moves[0].type, 'attack', 'Should prefer favorable attack'); }); it('should handle multiple potential targets correctly', () => { const map = createTestMap(); const gameUI = new MockGameUI(); const bot = new AIBot(1, map, gameUI); setupPlayerCell(map, 2, 2, 1, 8); // Multiple enemies with different strengths setupPlayerCell(map, 2, 1, 2, 2); // Weak setupPlayerCell(map, 1, 2, 2, 6); // Strong // Empty cell map.getCell(3, 2).type = CELL_TYPES.EMPTY; const playerCells = map.getPlayerCells(1); const moves = bot.findPossibleMoves(playerCells); moves.sort((a, b) => bot.movePriority(b) - bot.movePriority(a)); // Best move should be attack on weak enemy assert.strictEqual(moves[0].type, 'attack'); assert.strictEqual(moves[0].defenseStrength, 2); }); it('should prefer expansion when no favorable attacks exist', () => { const map = createTestMap(); const gameUI = new MockGameUI(); const bot = new AIBot(1, map, gameUI); setupPlayerCell(map, 2, 2, 1, 5); // Strong enemy (risky attack) setupPlayerCell(map, 2, 1, 2, 8); // Empty cell map.getCell(3, 2).type = CELL_TYPES.EMPTY; const playerCells = map.getPlayerCells(1); const moves = bot.findPossibleMoves(playerCells); moves.sort((a, b) => bot.movePriority(b) - bot.movePriority(a)); // Expansion might be preferred over risky attack const bestMove = moves[0]; assert.ok( bestMove.type === 'expand' || bestMove.attackStrength > bestMove.defenseStrength, 'Should prefer safe moves' ); }); }); }); describe('GameUI.distributeSupply', () => { // We need to test distributeSupply which is a method on GameUI // Since GameUI has DOM dependencies, we'll create a minimal mock class MinimalGameUI { constructor(map, currentPlayer) { this.map = map; this.currentPlayer = currentPlayer; } // Copy the distributeSupply method logic distributeSupply(supply) { const playerCells = this.map.getPlayerCells(this.currentPlayer); const eligibleCells = playerCells.filter(cell => !cell.isMaxStrength()); if (eligibleCells.length === 0 || supply === 0) return; let remainingSupply = supply; while (remainingSupply > 0 && eligibleCells.length > 0) { const randomCell = eligibleCells[Math.floor(Math.random() * eligibleCells.length)]; const currentStrength = randomCell.getStrength(); if (currentStrength < 48) { randomCell.setStrength(currentStrength + 1); remainingSupply--; } if (randomCell.isMaxStrength()) { eligibleCells.splice(eligibleCells.indexOf(randomCell), 1); } } } } it('should add strength one-by-one to eligible cells', () => { const map = createTestMap(); const currentPlayer = 1; const gameUI = new MinimalGameUI(map, currentPlayer); setupPlayerCell(map, 2, 2, 1, 5); setupPlayerCell(map, 2, 3, 1, 3); const initialStrength = map.getPlayerCells(1) .reduce((sum, c) => sum + c.getStrength(), 0); gameUI.distributeSupply(3); const finalStrength = map.getPlayerCells(1) .reduce((sum, c) => sum + c.getStrength(), 0); assert.strictEqual(finalStrength, initialStrength + 3, 'Should add exactly 3 strength'); }); it('should only add to cells that are not at max strength', () => { const map = createTestMap(); const currentPlayer = 1; const gameUI = new MinimalGameUI(map, currentPlayer); // Cell at max strength (48) const maxCell = setupPlayerCell(map, 2, 2, 1, 48); // Cell below max setupPlayerCell(map, 2, 3, 1, 10); gameUI.distributeSupply(5); assert.strictEqual(maxCell.getStrength(), 48, 'Max cell should not increase'); }); it('should stop when supply is exhausted', () => { const map = createTestMap(); const currentPlayer = 1; const gameUI = new MinimalGameUI(map, currentPlayer); setupPlayerCell(map, 2, 2, 1, 5); gameUI.distributeSupply(3); const cell = map.getCell(2, 2); assert.strictEqual(cell.getStrength(), 8, 'Should add exactly 3 to strength'); }); it('should stop when all cells reach max strength', () => { const map = createTestMap(); const currentPlayer = 1; const gameUI = new MinimalGameUI(map, currentPlayer); // Cell near max setupPlayerCell(map, 2, 2, 1, 47); // Try to add more than can fit gameUI.distributeSupply(10); const cell = map.getCell(2, 2); assert.strictEqual(cell.getStrength(), 48, 'Should cap at max strength'); }); it('should do nothing when supply is 0', () => { const map = createTestMap(); const currentPlayer = 1; const gameUI = new MinimalGameUI(map, currentPlayer); setupPlayerCell(map, 2, 2, 1, 5); const initialStrength = map.getCell(2, 2).getStrength(); gameUI.distributeSupply(0); const finalStrength = map.getCell(2, 2).getStrength(); assert.strictEqual(finalStrength, initialStrength, 'Should not change with 0 supply'); }); it('should do nothing when no eligible cells exist', () => { const map = createTestMap(); const currentPlayer = 1; const gameUI = new MinimalGameUI(map, currentPlayer); // No player cells gameUI.distributeSupply(5); const playerCells = map.getPlayerCells(1); assert.strictEqual(playerCells.length, 0); }); it('should distribute to random eligible cells', () => { const map = createTestMap(); const currentPlayer = 1; const gameUI = new MinimalGameUI(map, currentPlayer); // Multiple eligible cells setupPlayerCell(map, 1, 1, 1, 5); setupPlayerCell(map, 2, 2, 1, 5); setupPlayerCell(map, 3, 3, 1, 5); gameUI.distributeSupply(6); const totalStrength = map.getPlayerCells(1) .reduce((sum, c) => sum + c.getStrength(), 0); assert.strictEqual(totalStrength, 15 + 6, 'Should distribute all supply'); // At least some cells should have received supply (random distribution) const cells = map.getPlayerCells(1); const receivedSupply = cells.filter(c => c.getStrength() > 5); assert.ok(receivedSupply.length > 0, 'Some cells should receive supply'); }); it('should handle multiple cells with different initial strengths', () => { const map = createTestMap(); const currentPlayer = 1; const gameUI = new MinimalGameUI(map, currentPlayer); setupPlayerCell(map, 1, 1, 1, 3); setupPlayerCell(map, 2, 2, 1, 6); setupPlayerCell(map, 3, 3, 1, 4); const initialTotal = map.getPlayerCells(1) .reduce((sum, c) => sum + c.getStrength(), 0); gameUI.distributeSupply(5); const finalTotal = map.getPlayerCells(1) .reduce((sum, c) => sum + c.getStrength(), 0); 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}`); } }); }); });