Add AI bot tests and update documentation

This commit is contained in:
sokol
2026-02-21 20:34:42 +03:00
parent a0f6276e5d
commit afebcbca1a
3 changed files with 1178 additions and 90 deletions

220
QWEN.md
View File

@@ -2,7 +2,7 @@
## Project Overview ## Project Overview
**hexo** is an educational game project - a clone of [DiceWars](https://www.gamedesign.jp/games/dicewars/). **hexo** is an educational game project a clone of [DiceWars](https://www.gamedesign.jp/games/dicewars/).
### Game Concept ### Game Concept
@@ -11,45 +11,11 @@ A strategy dice game played on a hexagonal grid where players command armies of
### Features ### Features
- **2-4 Players**: Support for multiple human and/or AI players - **2-4 Players**: Support for multiple human and/or AI players
- **AI Bots**: Computer-controlled players with smart move selection - **AI Bots**: Computer-controlled players with smart move selection and thinking delay
- **Hexagonal Grid**: 20×20 map with proper adjacency - **Hexagonal Grid**: 20×20 map with proper adjacency
- **Dice Combat**: Roll-based battle system - **Dice Combat**: Roll-based battle system
- **Solid Territory Supply**: Supply = size of largest connected territory - **Solid Territory Supply**: Supply = size of largest connected territory
- **Flexible Setup**: Any combination of human/AI players (hotseat, full AI, or mixed)
### Core Game Mechanics
#### Map System
- Generatable hexagonal grid map (20x20 cells)
- Each cell can be passable or blocked/impassable
- Each field can hold up to 8 dice
- Cells are connected to 6 neighbors (hexagonal adjacency)
#### Dice System
- Standard 6-sided dice
- Unit strength calculation formula:
```
F = (cnt-1) * full_dice + current_dice
```
Where:
- `cnt` = number of dice on the field
- `full_dice` = maximum die value (6)
- `current_dice` = top die current value (1-6)
#### Game Rules
1. **Setup**: Each player starts with dice on their starting position
2. **Movement**: Can move if strength > 1
- Source cell left with 1, target receives strength-1
3. **Combat**: Both sides roll dice (1 to their strength)
- **Attacker wins**: Takes cell with attack_roll-1, source becomes 1
- **Defender wins**: Attacker reduced to 1, defender keeps defense_roll-attack_roll (min 1)
4. **Supply**: After turn ends, player receives supply = largest connected territory size
- Distributed 1 by 1 to random non-max cells
- Max per cell: 48 (8 dice × 6)
#### AI Bot Logic
- Evaluates all possible moves
- Prioritizes: winning attacks > expansion to empty > reinforcement
- Includes thinking delay for natural gameplay
## Directory Structure ## Directory Structure
@@ -62,13 +28,14 @@ hexo/
├── .gitignore # Git ignore rules ├── .gitignore # Git ignore rules
├── jsdom-pkg/ # Local jsdom library copy ├── jsdom-pkg/ # Local jsdom library copy
├── public/ # All application files ├── public/ # All application files
│ ├── index.html # Main HTML page with start screen │ ├── index.html # Main HTML page with start screen and game UI
│ ├── styles.css # Game UI styles │ ├── styles.css # Game UI styles
│ ├── game.js # Main game logic and rendering │ ├── game.js # Main game logic and canvas rendering
│ ├── map.js # HexMap module │ ├── map.js # HexMap module (map generation, cells, supply)
│ └── ai-bot.js # AI bot player logic │ └── ai-bot.js # AI bot player logic
└── test/ # Unit tests └── test/ # Unit tests
── map.test.js # Map and cell tests ── map.test.js # Map and cell tests
└── ai-bot.test.js # AI bot tests
``` ```
## Technology Stack ## Technology Stack
@@ -98,29 +65,170 @@ npm test
- Map module exports both ES and CommonJS for compatibility - Map module exports both ES and CommonJS for compatibility
- Tests use Node.js built-in `node:test` module - Tests use Node.js built-in `node:test` module
> **TODO**: Coding standards and testing practices are not yet established. ## Core Game Mechanics
### Inferred Practices (based on jsdom usage) ### Map System
- JavaScript/TypeScript expected for implementation
- DOM-based rendering likely planned (given jsdom inclusion) - Generatable hexagonal grid map (20×20 cells)
- Game logic will need to implement: - Each cell can be passable or blocked/impassable
- Hexagonal grid generation - Each field can hold up to 8 dice
- Dice mechanics and randomization - Cells are connected to 6 neighbors (hexagonal adjacency)
- Turn-based combat system
- Player state management ### Dice System
- Standard 6-sided dice
- Unit strength calculation formula:
```
F = (cnt - 1) × full_dice + current_dice
```
Where:
- `cnt` = number of dice on the field
- `full_dice` = maximum die value (6)
- `current_dice` = top die current value (1-6)
### Game Rules
1. **Setup**: Each player starts with dice on their starting position
2. **Movement**: Can move if strength > 1
- Source cell left with 1, target receives strength-1
3. **Combat**: Both sides roll dice (1 to their strength)
- **Attacker wins**: Takes cell with `attack_roll - 1`, source becomes 1
- **Defender wins**: Attacker reduced to 1, defender keeps `defense_roll - attack_roll` (min 1)
4. **Supply**: After turn ends, player receives supply = largest connected territory size
- Distributed 1 by 1 to random non-max cells
- Max per cell: 48 (8 dice × 6)
### Player Configuration
| Player | Color | HEX Code |
|--------|-------|----------|
| P1 | Green | `#4ecca3` |
| P2 | Red | `#e94560` |
| P3 | Yellow | `#f9ed69` |
| P4 | Cyan | `#a8e6cf` |
### Starting Positions
Players are placed at fixed positions on the map:
- **P1**: Top area (q: 2, r: 2)
- **P2**: Bottom area (q: MAP_SIZE-3, r: MAP_SIZE-3)
- **P3**: Bottom-left (q: 2, r: MAP_SIZE-3)
- **P4**: Top-right (q: MAP_SIZE-3, r: 2)
Each player starts with strength 8 at their starting position.
## AI Bot Logic
### Implementation Details (`ai-bot.js`)
The AI bot is implemented in the `AIBot` class with the following behavior:
#### Thinking Delay
- **Delay**: 1000ms between moves
- **Purpose**: Creates natural gameplay feel, allows human players to follow AI actions
#### Move Evaluation
The AI evaluates all possible moves using a priority system:
```javascript
movePriority(move) {
let priority = 0;
// Attack weak enemies (highest priority)
if (move.type === 'attack') {
if (move.attackStrength > move.defenseStrength) {
priority += 100; // Likely to win
priority += move.attackStrength - move.defenseStrength;
} else {
priority -= 50; // Risky attack
}
}
// Expand to empty cells (medium priority)
if (move.type === 'expand') {
priority += 50;
priority += move.attackStrength; // Stronger placement = better
}
// Prefer moves that create strong positions
priority += move.attackStrength * 0.5;
return priority;
}
```
#### Move Types
| Type | Priority | Description |
|------|----------|-------------|
| **Attack (favorable)** | 100+ | Attack enemy with higher strength |
| **Expand** | 50+ | Capture empty cells |
| **Attack (risky)** | -50 | Attack enemy with equal/higher strength |
#### Turn Flow
1. Get all player cells
2. Find all possible moves (neighbors that are not own cells)
3. Sort moves by priority
4. Wait for thinking delay (1000ms)
5. Execute best move
6. Repeat until no moves available, then end turn
### Integration with Game UI
The AI bot integrates with the main game through:
- **`gameUI.selectedCell`** / **`gameUI.currentTarget`**: Set before executing moves
- **`gameUI.executeAttack()`**: Called to perform the actual attack
- **`gameUI.endTurn()`**: Called when AI has no more moves
- **`gameUI.isAIThinking`**: Flag to prevent user interaction during AI turn
## Key Implementation Areas ## Key Implementation Areas
When development begins, focus on these components: ### 1. Map Generator (`map.js`)
1. **Map Generator**: Hexagonal grid creation with passable/impassable cells - Hexagonal grid creation with passable/impassable cells
2. **Dice Engine**: Randomization and strength calculation - Cell ownership tracking
3. **Combat System**: Attack/defense resolution logic - Neighbor calculation (6 directions)
4. **Game State**: Player turns, unit positions, victory conditions - Supply calculation (largest connected territory)
5. **UI/Rendering**: Visual representation of the game board
### 2. Dice Engine (`game.js`)
- Randomization for combat rolls
- Strength calculation
- Dice distribution during supply phase
### 3. Combat System (`game.js`)
- Attack/defense resolution logic
- Victory/defeat outcomes
- Cell ownership transfer
### 4. Game State (`game.js`)
- Player turns management
- Unit positions tracking
- Victory conditions (last player standing)
### 5. UI/Rendering (`game.js`)
- HTML5 Canvas rendering
- Hexagon drawing with proper coordinates
- Player color indicators
- Dice visualization
- Battle log
### 6. AI Bot (`ai-bot.js`)
- Move evaluation and prioritization
- Thinking delay for natural gameplay
- Integration with game UI for move execution
## Notes ## Notes
- The `.gitignore` file appears to be a Python template and may need to be updated for a JavaScript project - The `.gitignore` file appears to be a Python template and may need to be updated for a JavaScript project
- The `jsdom-pkg` directory contains a local copy of jsdom, possibly for offline development or custom modifications - The `jsdom-pkg` directory contains a local copy of jsdom, possibly for offline development or custom modifications
- Game rules are documented in Russian in README.md - Game rules are documented in Russian in README.md
- All game logic runs in the browser (no server-side game state)

127
README.md
View File

@@ -1,6 +1,6 @@
# hexo # HEXO
Учебный проект. Игра, клон https://www.gamedesign.jp/games/dicewars/ Учебный проект. Игра, клон [DiceWars](https://www.gamedesign.jp/games/dicewars/)
## Запуск игры ## Запуск игры
@@ -10,60 +10,119 @@ npm test # Запустить тесты
npm start # Консольная версия карты npm start # Консольная версия карты
``` ```
## Настройки игры ## Экран запуска
При запуске можно выбрать: При запуске игры открывается экран настройки:
- Количество игроков: 2-4
- Тип каждого игрока: Человек (Human) или AI бот 1. **Выберите количество игроков**: 2, 3 или 4
2. **Настройте тип каждого игрока**:
- **Human** — управление человеком (клики мышью)
- **AI Bot** — управление компьютером
3. Нажмите **Start Game** для начала игры
### Комбинации игроков
Можно создавать любые комбинации:
- Все игроки — люди (Hotseat)
- Все игроки — AI боты (наблюдение за игрой ИИ)
- Смешанный режим (например, P1-Human, P2-AI, P3-Human, P4-AI)
## Цвета игроков
| Игрок | Цвет | HEX |
|-------|------|-----|
| P1 | 🟢 Зелёный | `#4ecca3` |
| P2 | 🔴 Красный | `#e94560` |
| P3 | 🟡 Жёлтый | `#f9ed69` |
| P4 | 🔵 Бирюзовый | `#a8e6cf` |
## AI Bot
### Как работает
AI бот автоматически играет за выбранного игрока:
1. **Анализ поля** — бот оценивает все возможные ходы
2. **Приоритеты ходов**:
- 🎯 Атака слабого противника (высокий шанс победы)
- 📈 Захват пустых клеток (расширение территории)
- 💪 Укрепление позиций (перемещение к сильным клеткам)
3. **Задержка мышления** — 1000 мс перед каждым ходом для естественности геймплея
### Индикаторы AI
- В карточке игрока отображается метка **(AI)**
- Во время хода AI в панели действий показано: *"AI is thinking..."*
- Кнопки управления отключены во время хода AI
## Правила игры ## Правила игры
### 1. Карта ### 1. Карта
- Гексагональная сетка 20×20 ячеек - Гексагональная сетка 20×20 ячеек
- Каждая ячейка может быть: - Каждая ячейка может быть:
- Проходима (пустая или принадлежит игроку) - **Проходима** (пустая или принадлежит игроку)
- Непроходима (заблокирована) - **Непроходима** (заблокирована, серый цвет)
### 2. Игровые единицы ### 2. Игровые единицы
- Кубик: 6-гранный, значения 1-6
- На поле может быть до 8 кубиков - **Кубик**: 6-гранный, значения 1-6
- **Сила юнита**: `F = (cnt-1)*6 + current_dice` - На поле может быть до **8 кубиков** на клетку
- **Сила юнита**: `F = (cnt-1) × 6 + current_dice`
- `cnt` — количество кубиков - `cnt` — количество кубиков
- `current_dice` — значение верхнего кубика - `current_dice` — значение верхнего кубика
### 3. Ход игры ### 3. Ход игры
#### Перемещение/Атака #### Перемещение/Атака
- Можно ходить, если сила > 1
- На целевую клетку переходит `сила-1`, на исходной остаётся 1 - Можно ходить, если **сила > 1**
- На целевую клетку переходит `сила-1`, на исходной остаётся **1**
- При атаке оба игрока бросают кости: - При атаке оба игрока бросают кости:
- Атакующий: `attack_roll = rnd(1..сила-1)` - **Атакующий**: `attack_roll = rnd(1..сила-1)`
- Защищающийся: `defense_roll = rnd(1..сила)` - **Защищающийся**: `defense_roll = rnd(1..сила)`
#### Результат боя #### Результат боя
- Если `attack_roll > defense_roll`:
- Атакующий побеждает, занимает клетку с `attack_roll-1` | Условие | Результат |
- На исходной клетке остаётся 1 |---------|-----------|
- Если `attack_roll <= defense_roll`: | `attack_roll > defense_roll` | Атакующий побеждает, занимает клетку с `attack_roll-1`. На исходной клетке остаётся **1** |
- Атака отбита, атакующий уменьшается до 1 | `attack_roll <= defense_roll` | Атака отбита. Атакующий уменьшается до **1**. Защитник остаётся с `defense_roll - attack_roll` (мин. **1**) |
- Защитник остаётся с `defense_roll - attack_roll` (мин. 1)
#### Снабжение #### Снабжение
- После хода игрок получает снабжение
После хода игрок получает снабжение:
- **Снабжение = размер наибольшей непрерывной территории** - **Снабжение = размер наибольшей непрерывной территории**
- Непрерывная территория — связанные между собой клетки игрока - **Непрерывная территория** — связанные между собой клетки игрока
- Если территория разорвана врагом — считается только largest кусок - Если территория разорвана врагом — считается только наибольший кусок
- Снабжение распределяется по 1 единице случайным клеткам (не максимальным) - Снабжение распределяется по **1 единице** случайным клеткам (не максимальным)
- Максимум на клетке: 48 (8 кубиков × 6) - **Максимум на клетке**: 48 (8 кубиков × 6)
### 4. Победа ### 4. Победа
- Последний игрок, оставшийся с клетками на карте
**Последний игрок**, оставшийся с клетками на карте, побеждает.
## Управление ## Управление
- **Клик** на свою клетку → выбор | Действие | Управление |
- **Клик** на соседнюю вражескую/пустую → атака |----------|------------|
- **Cancel** → отмена выбора | Выбрать клетку | Клик на свою клетку с кубиками |
- **End Turn** → завершить ход | Атаковать | Клик на соседнюю вражескую/пустую клетку |
| Отменить выбор | Кнопка **Cancel** или клик на ту же клетку |
| Завершить ход | Кнопка **End Turn** |
| Вернуться в меню | Кнопка **Main Menu** |
### Индикаторы на экране
- **Выделенная клетка** — красная подсветка
- **Доступные цели** — зелёная подсветка соседних клеток
- **Лог боёв** — правая панель с историей действий
- **Инфо о клетке** — сила и количество кубиков выбранной клетки
## Настройки игры
Игра поддерживает:
- **2-4 игрока** (любая комбинация людей и AI)
- **Случайная генерация карты** при каждом запуске
- **Случайные стартовые позиции** для игроков

921
test/ai-bot.test.js Normal file
View File

@@ -0,0 +1,921 @@
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');
});
});