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
**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
@@ -11,45 +11,11 @@ A strategy dice game played on a hexagonal grid where players command armies of
### Features
- **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
- **Dice Combat**: Roll-based battle system
- **Solid Territory Supply**: Supply = size of largest connected territory
### 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
- **Flexible Setup**: Any combination of human/AI players (hotseat, full AI, or mixed)
## Directory Structure
@@ -62,13 +28,14 @@ hexo/
├── .gitignore # Git ignore rules
├── jsdom-pkg/ # Local jsdom library copy
├── 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
│ ├── game.js # Main game logic and rendering
│ ├── map.js # HexMap module
│ ├── game.js # Main game logic and canvas rendering
│ ├── map.js # HexMap module (map generation, cells, supply)
│ └── ai-bot.js # AI bot player logic
└── 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
@@ -98,29 +65,170 @@ npm test
- Map module exports both ES and CommonJS for compatibility
- 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)
- JavaScript/TypeScript expected for implementation
- DOM-based rendering likely planned (given jsdom inclusion)
- Game logic will need to implement:
- Hexagonal grid generation
- Dice mechanics and randomization
- Turn-based combat system
- Player state management
### Map System
- Generatable hexagonal grid map (20×20 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)
### 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
When development begins, focus on these components:
### 1. Map Generator (`map.js`)
1. **Map Generator**: Hexagonal grid creation with passable/impassable cells
2. **Dice Engine**: Randomization and strength calculation
3. **Combat System**: Attack/defense resolution logic
4. **Game State**: Player turns, unit positions, victory conditions
5. **UI/Rendering**: Visual representation of the game board
- Hexagonal grid creation with passable/impassable cells
- Cell ownership tracking
- Neighbor calculation (6 directions)
- Supply calculation (largest connected territory)
### 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
- 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
- Game rules are documented in Russian in README.md
- All game logic runs in the browser (no server-side game state)

121
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 # Консольная версия карты
```
## Настройки игры
## Экран запуска
При запуске можно выбрать:
- Количество игроков: 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. Карта
- Гексагональная сетка 20×20 ячеек
- Каждая ячейка может быть:
- Проходима (пустая или принадлежит игроку)
- Непроходима (заблокирована)
- **Проходима** (пустая или принадлежит игроку)
- **Непроходима** (заблокирована, серый цвет)
### 2. Игровые единицы
- Кубик: 6-гранный, значения 1-6
- На поле может быть до 8 кубиков
- **Сила юнита**: `F = (cnt-1)*6 + current_dice`
- **Кубик**: 6-гранный, значения 1-6
- На поле может быть до **8 кубиков** на клетку
- **Сила юнита**: `F = (cnt-1) × 6 + current_dice`
- `cnt` — количество кубиков
- `current_dice` — значение верхнего кубика
### 3. Ход игры
#### Перемещение/Атака
- Можно ходить, если сила > 1
- На целевую клетку переходит `сила-1`, на исходной остаётся 1
- Можно ходить, если **сила > 1**
- На целевую клетку переходит `сила-1`, на исходной остаётся **1**
- При атаке оба игрока бросают кости:
- Атакующий: `attack_roll = rnd(1..сила-1)`
- Защищающийся: `defense_roll = rnd(1..сила)`
- **Атакующий**: `attack_roll = rnd(1..сила-1)`
- **Защищающийся**: `defense_roll = rnd(1..сила)`
#### Результат боя
- Если `attack_roll > defense_roll`:
- Атакующий побеждает, занимает клетку с `attack_roll-1`
- На исходной клетке остаётся 1
- Если `attack_roll <= defense_roll`:
- Атака отбита, атакующий уменьшается до 1
- Защитник остаётся с `defense_roll - attack_roll` (мин. 1)
| Условие | Результат |
|---------|-----------|
| `attack_roll > defense_roll` | Атакующий побеждает, занимает клетку с `attack_roll-1`. На исходной клетке остаётся **1** |
| `attack_roll <= defense_roll` | Атака отбита. Атакующий уменьшается до **1**. Защитник остаётся с `defense_roll - attack_roll` (мин. **1**) |
#### Снабжение
- После хода игрок получает снабжение
После хода игрок получает снабжение:
- **Снабжение = размер наибольшей непрерывной территории**
- Непрерывная территория — связанные между собой клетки игрока
- Если территория разорвана врагом — считается только largest кусок
- Снабжение распределяется по 1 единице случайным клеткам (не максимальным)
- Максимум на клетке: 48 (8 кубиков × 6)
- **Непрерывная территория** — связанные между собой клетки игрока
- Если территория разорвана врагом — считается только наибольший кусок
- Снабжение распределяется по **1 единице** случайным клеткам (не максимальным)
- **Максимум на клетке**: 48 (8 кубиков × 6)
### 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');
});
});