diff --git a/QWEN.md b/QWEN.md index f366bf4..b5c6b81 100644 --- a/QWEN.md +++ b/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) diff --git a/README.md b/README.md index 94e9569..abaef40 100644 --- a/README.md +++ b/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** → завершить ход - - \ No newline at end of file +| Действие | Управление | +|----------|------------| +| Выбрать клетку | Клик на свою клетку с кубиками | +| Атаковать | Клик на соседнюю вражескую/пустую клетку | +| Отменить выбор | Кнопка **Cancel** или клик на ту же клетку | +| Завершить ход | Кнопка **End Turn** | +| Вернуться в меню | Кнопка **Main Menu** | + +### Индикаторы на экране + +- **Выделенная клетка** — красная подсветка +- **Доступные цели** — зелёная подсветка соседних клеток +- **Лог боёв** — правая панель с историей действий +- **Инфо о клетке** — сила и количество кубиков выбранной клетки + +## Настройки игры + +Игра поддерживает: +- **2-4 игрока** (любая комбинация людей и AI) +- **Случайная генерация карты** при каждом запуске +- **Случайные стартовые позиции** для игроков diff --git a/test/ai-bot.test.js b/test/ai-bot.test.js new file mode 100644 index 0000000..f930677 --- /dev/null +++ b/test/ai-bot.test.js @@ -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'); + }); +});