Add AI bot tests and update documentation
This commit is contained in:
220
QWEN.md
220
QWEN.md
@@ -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)
|
||||
|
||||
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 # Консольная версия карты
|
||||
```
|
||||
|
||||
## Настройки игры
|
||||
## Экран запуска
|
||||
|
||||
При запуске можно выбрать:
|
||||
- Количество игроков: 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
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