diff --git a/public/game.js b/public/game.js index 834feb1..c4350ce 100644 --- a/public/game.js +++ b/public/game.js @@ -8,7 +8,7 @@ import { AIBot } from './ai-bot.js'; // Game constants const HEX_SIZE = 14; -const MAP_SIZE = 20; +const DEFAULT_MAP_SIZE = 20; const ANIMATION_DURATION = 300; // Player colors @@ -45,6 +45,7 @@ class GameUI { this.canvas = document.getElementById('game-canvas'); this.ctx = this.canvas.getContext('2d'); this.map = null; + this.mapSize = DEFAULT_MAP_SIZE; this.selectedCell = null; this.currentTarget = null; this.currentPlayer = 1; @@ -95,21 +96,22 @@ class GameUI { startGame() { // Get settings this.playerCount = parseInt(document.getElementById('player-count').value); - + this.mapSize = parseInt(document.getElementById('map-size').value); + for (let i = 1; i <= this.playerCount; i++) { const typeSelect = document.getElementById(`player${i}-type`); this.playerTypes[i] = typeSelect.value; } - + // Show game screen document.getElementById('start-screen').style.display = 'none'; document.getElementById('game-screen').style.display = 'flex'; - + this.newGame(); } newGame() { - this.map = new HexMap(MAP_SIZE); + this.map = new HexMap(this.mapSize); this.selectedCell = null; this.currentTarget = null; this.currentPlayer = 1; @@ -174,17 +176,19 @@ class GameUI { initializePlayers() { // Place starting units for each player at fixed positions that are always passable // Use corners of the map to ensure they don't overlap + // Positions scale with map size + const offset = Math.max(1, Math.floor(this.mapSize / 10)); const positions = [ - { q: 1, r: 1 }, // Player 1 - top-left - { q: MAP_SIZE - 2, r: MAP_SIZE - 2 }, // Player 2 - bottom-right - { q: 1, r: MAP_SIZE - 2 }, // Player 3 - bottom-left - { q: MAP_SIZE - 2, r: 1 } // Player 4 - top-right + { q: offset, r: offset }, // Player 1 - top-left + { q: this.mapSize - 1 - offset, r: this.mapSize - 1 - offset }, // Player 2 - bottom-right + { q: offset, r: this.mapSize - 1 - offset }, // Player 3 - bottom-left + { q: this.mapSize - 1 - offset, r: offset } // Player 4 - top-right ]; for (let i = 1; i <= this.playerCount; i++) { const pos = positions[i - 1]; if (!pos) continue; - + // Force the cell to be passable and set ownership const cell = this.map.getCell(pos.q, pos.r); if (cell) { @@ -206,26 +210,26 @@ class GameUI { const canvasHeight = this.canvas.height; const sqrt3 = Math.sqrt(3); - - // Calculate actual map bounds + + // Calculate actual map bounds based on dynamic map size // For pointy-top hex grid: - // - Width spans from q=0 to q=19, with r offset - // - Rightmost point: (q=19, r=19) at x = HEX_SIZE * sqrt3 * 28.5 - // - Height spans from r=0 to r=19 - // - Bottommost point: (any q, r=19) at y = HEX_SIZE * 1.5 * 19 - - const mapWidth = HEX_SIZE * sqrt3 * ((MAP_SIZE - 1) + (MAP_SIZE - 1) / 2); - const mapHeight = HEX_SIZE * 1.5 * (MAP_SIZE - 1); - + // - Width spans from q=0 to q=mapSize-1, with r offset + // - Rightmost point: (q=mapSize-1, r=mapSize-1) at x = HEX_SIZE * sqrt3 * (mapSize - 1 + (mapSize - 1) / 2) + // - Height spans from r=0 to r=mapSize-1 + // - Bottommost point: (any q, r=mapSize-1) at y = HEX_SIZE * 1.5 * (mapSize - 1) + + const mapWidth = HEX_SIZE * sqrt3 * ((this.mapSize - 1) + (this.mapSize - 1) / 2); + const mapHeight = HEX_SIZE * 1.5 * (this.mapSize - 1); + // Add padding for hex radius (hex extends beyond center point) const hexRadius = HEX_SIZE; const totalWidth = mapWidth + 2 * hexRadius; const totalHeight = mapHeight + 2 * hexRadius; - + // Center the map on canvas this.offsetX = (canvasWidth - totalWidth) / 2 + hexRadius; this.offsetY = (canvasHeight - totalHeight) / 2 + hexRadius; - + console.log(`[GAME] Map: canvas=${canvasWidth}x${canvasHeight}, map=${mapWidth.toFixed(0)}x${mapHeight.toFixed(0)}, total=${totalWidth.toFixed(0)}x${totalHeight.toFixed(0)}`); console.log(`[GAME] Offset: x=${this.offsetX.toFixed(1)}, y=${this.offsetY.toFixed(1)}`); } @@ -256,7 +260,7 @@ class GameUI { const qi = Math.round(q); const ri = Math.round(r); - if (qi >= 0 && qi < MAP_SIZE && ri >= 0 && ri < MAP_SIZE) { + if (qi >= 0 && qi < this.mapSize && ri >= 0 && ri < this.mapSize) { return { q: qi, r: ri }; } return null; @@ -276,7 +280,7 @@ class GameUI { for (const [dq, dr] of directions) { const nq = q + dq; const nr = r + dr; - if (nq >= 0 && nq < MAP_SIZE && nr >= 0 && nr < MAP_SIZE) { + if (nq >= 0 && nq < this.mapSize && nr >= 0 && nr < this.mapSize) { const cell = this.map.getCell(nq, nr); if (cell && cell.isPassable()) { targets.push(cell); @@ -367,14 +371,14 @@ class GameUI { render() { this.ctx.fillStyle = COLORS.stroke; this.ctx.fillRect(0, 0, this.canvas.width, this.canvas.height); - + if (!this.map) return; - - for (let r = 0; r < MAP_SIZE; r++) { - for (let q = 0; q < MAP_SIZE; q++) { + + for (let r = 0; r < this.mapSize; r++) { + for (let q = 0; q < this.mapSize; q++) { const cell = this.map.getCell(q, r); let color; - + if (cell.type === CELL_TYPES.BLOCKED) { color = COLORS.blocked; } else if (cell.type === CELL_TYPES.PLAYER1) { @@ -388,32 +392,32 @@ class GameUI { } else { color = COLORS.empty; } - + if (!cell.isOwned()) { color = this.hexToRgba(color, 0.6); } - + this.drawHex(q, r, color); this.drawOwnerIndicator(cell); this.drawDice(cell); } } - + if (this.selectedCell) { this.drawHex( - this.selectedCell.q, - this.selectedCell.r, + this.selectedCell.q, + this.selectedCell.r, COLORS.selected, COLORS.text, 3 ); - + const targets = this.getValidTargets(this.selectedCell.q, this.selectedCell.r); for (const target of targets) { if (target.getOwner() !== this.currentPlayer) { this.drawHex( - target.q, - target.r, + target.q, + target.r, COLORS.target, PLAYER_COLORS[this.currentPlayer] || COLORS.player1, 2 @@ -642,18 +646,21 @@ class GameUI { const playerCells = this.map.getPlayerCells(i); const playerStrength = playerCells.reduce((sum, c) => sum + c.getStrength(), 0); const playerSupply = this.map.calculateSupply(i); - + document.getElementById(`player${i}-cells`).textContent = playerCells.length; document.getElementById(`player${i}-supply`).textContent = playerSupply; document.getElementById(`player${i}-strength`).textContent = playerStrength; - + const card = document.getElementById(`player${i}-card`); card.classList.toggle('active', i === this.currentPlayer); } - + document.getElementById('current-turn').textContent = this.currentPlayer; document.getElementById('game-phase').textContent = this.gamePhase; - + + // Update map size display + document.getElementById('map-size-display').textContent = `${this.mapSize}x${this.mapSize}`; + const instruction = document.getElementById('action-instruction'); if (this.selectedCell) { instruction.textContent = `Select target (strength: ${this.selectedCell.getStrength()})`; @@ -662,10 +669,10 @@ class GameUI { } else { instruction.textContent = 'Select a cell with dice to move'; } - + const cellInfo = document.getElementById('selected-cell-info'); cellInfo.style.display = 'block'; - + if (this.selectedCell) { document.getElementById('cell-strength').textContent = this.selectedCell.getStrength(); document.getElementById('cell-dice').textContent = this.selectedCell.dice.length; diff --git a/public/index.html b/public/index.html index e4501ba..ca10507 100644 --- a/public/index.html +++ b/public/index.html @@ -26,6 +26,16 @@ +
+ + +
+
@@ -91,6 +101,10 @@ Phase: Movement
+
+ Map Size: + 20x20 +
diff --git a/test/ai-bot.test.js b/test/ai-bot.test.js index 226e796..e75fa2d 100644 --- a/test/ai-bot.test.js +++ b/test/ai-bot.test.js @@ -1222,9 +1222,246 @@ describe('Multiple AI Bots Turn Chain', () => { // Check the pattern repeats correctly for (let i = 0; i < 12; i++) { const expectedPlayer = (i % 4) + 1; - assert.strictEqual(gameUI.aiTurnsCompleted[i], expectedPlayer, + assert.strictEqual(gameUI.aiTurnsCompleted[i], expectedPlayer, `Turn ${i + 1} should be player ${expectedPlayer}`); } }); }); }); + +describe('AIBot - Different Map Sizes', () => { + describe('AI works on 10x10 map', () => { + it('should find valid moves on 10x10 map', () => { + const map = new HexMap(10); + const gameUI = new MockGameUI(); + const bot = new AIBot(1, map, gameUI); + + // Clear map + map.cells.forEach(cell => { + cell.type = CELL_TYPES.EMPTY; + }); + + setupPlayerCell(map, 2, 2, 1, 8); + setupPlayerCell(map, 2, 1, 2, 4); + + const playerCells = map.getPlayerCells(1); + const moves = bot.findPossibleMoves(playerCells); + + assert.ok(moves.length > 0, 'Should find moves on 10x10 map'); + }); + + it('should respect boundaries on 10x10 map', () => { + const map = new HexMap(10); + const gameUI = new MockGameUI(); + const bot = new AIBot(1, map, gameUI); + + map.cells.forEach(cell => { + cell.type = CELL_TYPES.EMPTY; + }); + + setupPlayerCell(map, 0, 0, 1, 8); + 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); + + moves.forEach(move => { + assert.ok(move.to.q >= 0 && move.to.q < 10, 'Target q should be in bounds'); + assert.ok(move.to.r >= 0 && move.to.r < 10, 'Target r should be in bounds'); + }); + }); + }); + + describe('AI works on 15x15 map', () => { + it('should find valid moves on 15x15 map', () => { + const map = new HexMap(15); + const gameUI = new MockGameUI(); + const bot = new AIBot(1, map, gameUI); + + map.cells.forEach(cell => { + cell.type = CELL_TYPES.EMPTY; + }); + + setupPlayerCell(map, 5, 5, 1, 8); + setupPlayerCell(map, 5, 4, 2, 4); + + const playerCells = map.getPlayerCells(1); + const moves = bot.findPossibleMoves(playerCells); + + assert.ok(moves.length > 0, 'Should find moves on 15x15 map'); + }); + + it('should respect boundaries on 15x15 map', () => { + const map = new HexMap(15); + const gameUI = new MockGameUI(); + const bot = new AIBot(1, map, gameUI); + + map.cells.forEach(cell => { + cell.type = CELL_TYPES.EMPTY; + }); + + setupPlayerCell(map, 14, 14, 1, 8); + map.getCell(13, 14).type = CELL_TYPES.EMPTY; + map.getCell(14, 13).type = CELL_TYPES.EMPTY; + + const playerCells = map.getPlayerCells(1); + const moves = bot.findPossibleMoves(playerCells); + + moves.forEach(move => { + assert.ok(move.to.q >= 0 && move.to.q < 15, 'Target q should be in bounds'); + assert.ok(move.to.r >= 0 && move.to.r < 15, 'Target r should be in bounds'); + }); + }); + }); + + describe('AI works on 20x20 map', () => { + it('should find valid moves on 20x20 map', () => { + const map = new HexMap(20); + const gameUI = new MockGameUI(); + const bot = new AIBot(1, map, gameUI); + + map.cells.forEach(cell => { + cell.type = CELL_TYPES.EMPTY; + }); + + setupPlayerCell(map, 10, 10, 1, 8); + setupPlayerCell(map, 10, 9, 2, 4); + + const playerCells = map.getPlayerCells(1); + const moves = bot.findPossibleMoves(playerCells); + + assert.ok(moves.length > 0, 'Should find moves on 20x20 map'); + }); + + it('should respect boundaries on 20x20 map', () => { + const map = new HexMap(20); + const gameUI = new MockGameUI(); + const bot = new AIBot(1, map, gameUI); + + map.cells.forEach(cell => { + cell.type = CELL_TYPES.EMPTY; + }); + + setupPlayerCell(map, 19, 19, 1, 8); + map.getCell(18, 19).type = CELL_TYPES.EMPTY; + map.getCell(19, 18).type = CELL_TYPES.EMPTY; + + const playerCells = map.getPlayerCells(1); + const moves = bot.findPossibleMoves(playerCells); + + moves.forEach(move => { + assert.ok(move.to.q >= 0 && move.to.q < 20, 'Target q should be in bounds'); + assert.ok(move.to.r >= 0 && move.to.r < 20, 'Target r should be in bounds'); + }); + }); + }); + + describe('AI works on 25x25 map', () => { + it('should find valid moves on 25x25 map', () => { + const map = new HexMap(25); + const gameUI = new MockGameUI(); + const bot = new AIBot(1, map, gameUI); + + map.cells.forEach(cell => { + cell.type = CELL_TYPES.EMPTY; + }); + + setupPlayerCell(map, 12, 12, 1, 8); + setupPlayerCell(map, 12, 11, 2, 4); + + const playerCells = map.getPlayerCells(1); + const moves = bot.findPossibleMoves(playerCells); + + assert.ok(moves.length > 0, 'Should find moves on 25x25 map'); + }); + + it('should respect boundaries on 25x25 map', () => { + const map = new HexMap(25); + const gameUI = new MockGameUI(); + const bot = new AIBot(1, map, gameUI); + + map.cells.forEach(cell => { + cell.type = CELL_TYPES.EMPTY; + }); + + setupPlayerCell(map, 24, 24, 1, 8); + map.getCell(23, 24).type = CELL_TYPES.EMPTY; + map.getCell(24, 23).type = CELL_TYPES.EMPTY; + + const playerCells = map.getPlayerCells(1); + const moves = bot.findPossibleMoves(playerCells); + + moves.forEach(move => { + assert.ok(move.to.q >= 0 && move.to.q < 25, 'Target q should be in bounds'); + assert.ok(move.to.r >= 0 && move.to.r < 25, 'Target r should be in bounds'); + }); + }); + + it('should calculate move priorities correctly on large map', () => { + const map = new HexMap(25); + const gameUI = new MockGameUI(); + const bot = new AIBot(1, map, gameUI); + + map.cells.forEach(cell => { + cell.type = CELL_TYPES.EMPTY; + }); + + setupPlayerCell(map, 12, 12, 1, 10); + // Weak enemy + setupPlayerCell(map, 12, 11, 2, 3); + // Strong enemy + setupPlayerCell(map, 11, 12, 2, 9); + // Empty cell + map.getCell(13, 12).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, 3); + }); + }); + + describe('AI playTurn works on different map sizes', () => { + it('should complete turn on 10x10 map', async () => { + const map = new HexMap(10); + const gameUI = new MockGameUI(); + const bot = new AIBot(1, map, gameUI); + + map.cells.forEach(cell => { + cell.type = CELL_TYPES.EMPTY; + }); + + setupPlayerCell(map, 2, 2, 1, 8); + setupPlayerCell(map, 2, 1, 2, 2); + + bot.thinkingTime = 0; + + await bot.playTurn(); + + assert.strictEqual(gameUI.turnEnded, true); + }); + + it('should complete turn on 25x25 map', async () => { + const map = new HexMap(25); + const gameUI = new MockGameUI(); + const bot = new AIBot(1, map, gameUI); + + map.cells.forEach(cell => { + cell.type = CELL_TYPES.EMPTY; + }); + + setupPlayerCell(map, 12, 12, 1, 8); + setupPlayerCell(map, 12, 11, 2, 2); + + bot.thinkingTime = 0; + + await bot.playTurn(); + + assert.strictEqual(gameUI.turnEnded, true); + }); + }); +}); diff --git a/test/map.test.js b/test/map.test.js index fd66fae..02b5da2 100644 --- a/test/map.test.js +++ b/test/map.test.js @@ -2,6 +2,95 @@ const { describe, it } = require('node:test'); const assert = require('node:assert'); const { HexMap, HexCell, CELL_TYPES, MAP_SIZE } = require('../public/map.js'); +describe('HexMap - Dynamic Map Sizes', () => { + it('should create a 10x10 map', () => { + const map = new HexMap(10); + assert.strictEqual(map.size, 10); + assert.strictEqual(map.cells.size, 10 * 10); + }); + + it('should create a 15x15 map', () => { + const map = new HexMap(15); + assert.strictEqual(map.size, 15); + assert.strictEqual(map.cells.size, 15 * 15); + }); + + it('should create a 20x20 map', () => { + const map = new HexMap(20); + assert.strictEqual(map.size, 20); + assert.strictEqual(map.cells.size, 20 * 20); + }); + + it('should create a 25x25 map', () => { + const map = new HexMap(25); + assert.strictEqual(map.size, 25); + assert.strictEqual(map.cells.size, 25 * 25); + }); + + it('should generate cells with correct coordinates for all map sizes', () => { + const sizes = [10, 15, 20, 25]; + + for (const size of sizes) { + const map = new HexMap(size); + + for (let q = 0; q < size; q++) { + for (let r = 0; r < size; r++) { + const cell = map.getCell(q, r); + assert.ok(cell, `Cell at ${q},${r} should exist for size ${size}`); + assert.strictEqual(cell.q, q); + assert.strictEqual(cell.r, r); + } + } + } + }); + + it('should have correct neighbor counts for different map sizes', () => { + const sizes = [10, 15, 20, 25]; + + for (const size of sizes) { + const map = new HexMap(size); + + // Clear all blocks for predictable testing + map.cells.forEach(cell => { + if (cell.type === CELL_TYPES.BLOCKED) { + cell.type = CELL_TYPES.EMPTY; + } + }); + + // Center cell should have 6 neighbors + const centerQ = Math.floor(size / 2); + const centerR = Math.floor(size / 2); + const centerNeighbors = map.getNeighbors(centerQ, centerR); + assert.strictEqual(centerNeighbors.length, 6, `Center cell should have 6 neighbors for size ${size}`); + + // Corner cell should have 2 neighbors + const cornerNeighbors = map.getNeighbors(0, 0); + assert.strictEqual(cornerNeighbors.length, 2, `Corner cell should have 2 neighbors for size ${size}`); + } + }); + + it('should calculate supply correctly for different map sizes', () => { + const sizes = [10, 15, 20, 25]; + + for (const size of sizes) { + const map = new HexMap(size); + + // Clear any existing ownership + map.cells.forEach(cell => { + cell.type = CELL_TYPES.EMPTY; + }); + + // Create a connected territory of 5 cells + for (let i = 0; i < 5; i++) { + map.setOwner(i, 0, 1); + } + + const supply = map.calculateSupply(1); + assert.strictEqual(supply, 5, `Supply should be 5 for connected territory in size ${size}`); + } + }); +}); + describe('HexCell', () => { it('should create a cell with axial coordinates', () => { const cell = new HexCell(3, 5);