Add AI bot tests and update documentation
This commit is contained in:
220
QWEN.md
220
QWEN.md
@@ -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
127
README.md
@@ -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
921
test/ai-bot.test.js
Normal 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');
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user