Files
hexo/test/ai-bot.test.js
2026-02-22 11:04:11 +03:00

1468 lines
47 KiB
JavaScript

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();
// AI now makes multiple moves per turn (at least 1)
assert.ok(gameUI.executedMoves.length >= 1, 'Expected at least 1 move to be executed');
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}`);
}
});
});
});
describe('AIBot - Different Map Sizes', () => {
describe('AI works on 10x10 map', () => {
it('should find valid moves on 10x10 map', () => {
const map = new HexMap(10);
const gameUI = new MockGameUI();
const bot = new AIBot(1, map, gameUI);
// Clear map
map.cells.forEach(cell => {
cell.type = CELL_TYPES.EMPTY;
});
setupPlayerCell(map, 2, 2, 1, 8);
setupPlayerCell(map, 2, 1, 2, 4);
const playerCells = map.getPlayerCells(1);
const moves = bot.findPossibleMoves(playerCells);
assert.ok(moves.length > 0, 'Should find moves on 10x10 map');
});
it('should respect boundaries on 10x10 map', () => {
const map = new HexMap(10);
const gameUI = new MockGameUI();
const bot = new AIBot(1, map, gameUI);
map.cells.forEach(cell => {
cell.type = CELL_TYPES.EMPTY;
});
setupPlayerCell(map, 0, 0, 1, 8);
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);
moves.forEach(move => {
assert.ok(move.to.q >= 0 && move.to.q < 10, 'Target q should be in bounds');
assert.ok(move.to.r >= 0 && move.to.r < 10, 'Target r should be in bounds');
});
});
});
describe('AI works on 15x15 map', () => {
it('should find valid moves on 15x15 map', () => {
const map = new HexMap(15);
const gameUI = new MockGameUI();
const bot = new AIBot(1, map, gameUI);
map.cells.forEach(cell => {
cell.type = CELL_TYPES.EMPTY;
});
setupPlayerCell(map, 5, 5, 1, 8);
setupPlayerCell(map, 5, 4, 2, 4);
const playerCells = map.getPlayerCells(1);
const moves = bot.findPossibleMoves(playerCells);
assert.ok(moves.length > 0, 'Should find moves on 15x15 map');
});
it('should respect boundaries on 15x15 map', () => {
const map = new HexMap(15);
const gameUI = new MockGameUI();
const bot = new AIBot(1, map, gameUI);
map.cells.forEach(cell => {
cell.type = CELL_TYPES.EMPTY;
});
setupPlayerCell(map, 14, 14, 1, 8);
map.getCell(13, 14).type = CELL_TYPES.EMPTY;
map.getCell(14, 13).type = CELL_TYPES.EMPTY;
const playerCells = map.getPlayerCells(1);
const moves = bot.findPossibleMoves(playerCells);
moves.forEach(move => {
assert.ok(move.to.q >= 0 && move.to.q < 15, 'Target q should be in bounds');
assert.ok(move.to.r >= 0 && move.to.r < 15, 'Target r should be in bounds');
});
});
});
describe('AI works on 20x20 map', () => {
it('should find valid moves on 20x20 map', () => {
const map = new HexMap(20);
const gameUI = new MockGameUI();
const bot = new AIBot(1, map, gameUI);
map.cells.forEach(cell => {
cell.type = CELL_TYPES.EMPTY;
});
setupPlayerCell(map, 10, 10, 1, 8);
setupPlayerCell(map, 10, 9, 2, 4);
const playerCells = map.getPlayerCells(1);
const moves = bot.findPossibleMoves(playerCells);
assert.ok(moves.length > 0, 'Should find moves on 20x20 map');
});
it('should respect boundaries on 20x20 map', () => {
const map = new HexMap(20);
const gameUI = new MockGameUI();
const bot = new AIBot(1, map, gameUI);
map.cells.forEach(cell => {
cell.type = CELL_TYPES.EMPTY;
});
setupPlayerCell(map, 19, 19, 1, 8);
map.getCell(18, 19).type = CELL_TYPES.EMPTY;
map.getCell(19, 18).type = CELL_TYPES.EMPTY;
const playerCells = map.getPlayerCells(1);
const moves = bot.findPossibleMoves(playerCells);
moves.forEach(move => {
assert.ok(move.to.q >= 0 && move.to.q < 20, 'Target q should be in bounds');
assert.ok(move.to.r >= 0 && move.to.r < 20, 'Target r should be in bounds');
});
});
});
describe('AI works on 25x25 map', () => {
it('should find valid moves on 25x25 map', () => {
const map = new HexMap(25);
const gameUI = new MockGameUI();
const bot = new AIBot(1, map, gameUI);
map.cells.forEach(cell => {
cell.type = CELL_TYPES.EMPTY;
});
setupPlayerCell(map, 12, 12, 1, 8);
setupPlayerCell(map, 12, 11, 2, 4);
const playerCells = map.getPlayerCells(1);
const moves = bot.findPossibleMoves(playerCells);
assert.ok(moves.length > 0, 'Should find moves on 25x25 map');
});
it('should respect boundaries on 25x25 map', () => {
const map = new HexMap(25);
const gameUI = new MockGameUI();
const bot = new AIBot(1, map, gameUI);
map.cells.forEach(cell => {
cell.type = CELL_TYPES.EMPTY;
});
setupPlayerCell(map, 24, 24, 1, 8);
map.getCell(23, 24).type = CELL_TYPES.EMPTY;
map.getCell(24, 23).type = CELL_TYPES.EMPTY;
const playerCells = map.getPlayerCells(1);
const moves = bot.findPossibleMoves(playerCells);
moves.forEach(move => {
assert.ok(move.to.q >= 0 && move.to.q < 25, 'Target q should be in bounds');
assert.ok(move.to.r >= 0 && move.to.r < 25, 'Target r should be in bounds');
});
});
it('should calculate move priorities correctly on large map', () => {
const map = new HexMap(25);
const gameUI = new MockGameUI();
const bot = new AIBot(1, map, gameUI);
map.cells.forEach(cell => {
cell.type = CELL_TYPES.EMPTY;
});
setupPlayerCell(map, 12, 12, 1, 10);
// Weak enemy
setupPlayerCell(map, 12, 11, 2, 3);
// Strong enemy
setupPlayerCell(map, 11, 12, 2, 9);
// Empty cell
map.getCell(13, 12).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, 3);
});
});
describe('AI playTurn works on different map sizes', () => {
it('should complete turn on 10x10 map', async () => {
const map = new HexMap(10);
const gameUI = new MockGameUI();
const bot = new AIBot(1, map, gameUI);
map.cells.forEach(cell => {
cell.type = CELL_TYPES.EMPTY;
});
setupPlayerCell(map, 2, 2, 1, 8);
setupPlayerCell(map, 2, 1, 2, 2);
bot.thinkingTime = 0;
await bot.playTurn();
assert.strictEqual(gameUI.turnEnded, true);
});
it('should complete turn on 25x25 map', async () => {
const map = new HexMap(25);
const gameUI = new MockGameUI();
const bot = new AIBot(1, map, gameUI);
map.cells.forEach(cell => {
cell.type = CELL_TYPES.EMPTY;
});
setupPlayerCell(map, 12, 12, 1, 8);
setupPlayerCell(map, 12, 11, 2, 2);
bot.thinkingTime = 0;
await bot.playTurn();
assert.strictEqual(gameUI.turnEnded, true);
});
});
});