diff --git a/QWEN.md b/QWEN.md
new file mode 100644
index 0000000..55def0f
--- /dev/null
+++ b/QWEN.md
@@ -0,0 +1,122 @@
+# Project Context: hexo
+
+## Project Overview
+
+**hexo** is an educational game project - a clone of [DiceWars](https://www.gamedesign.jp/games/dicewars/). The project is in early development stage.
+
+### Game Concept
+
+A strategy dice game played on a hexagonal grid where players command armies of dice and battle to conquer territories.
+
+### Core Game Mechanics
+
+#### Map System
+- Generatable hexagonal grid map (20x20 cells)
+- Each cell can be either playable or blocked/impassable
+- Each field can hold up to 8 dice
+- Each player-owned field provides +1 supply unit to the player
+
+#### 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, calculated during gameplay)
+
+#### Game Rules
+1. **Setup**: Multiple dice are placed on the map for each player at the start
+2. **Movement Conditions**: A user can move a unit if its strength > 1
+ - When moving: strength-1 transfers to target cell, strength 1 remains on source cell
+3. **Combat**: When attacking a cell with enemy dice, both players roll:
+ - **Attacker**: `F_attack = rnd(F-1)` (random value less than original strength minus 1)
+ - **Defender**: `F_defence = current_strength` (full field strength)
+ - **Victory**: If `F_attack > F_defence`, attacker wins; otherwise defender repels the attack
+ - **On Victory**: Attacker leaves strength 1 on source cell, transfers `F_attack-1` to target
+ - **On Defeat**: Defender retains `F_defence - F_attack` (minimum 1)
+4. **Supply Phase**: After all players have moved, each receives supply:
+ - `S = sum(Cell)` where Cell = supply value from each player-owned cell
+ - Maximum per cell: `8 * full_dice` (48 dice points)
+ - If all cells already have maximum strength, no supply is added
+
+## Directory Structure
+
+```
+hexo/
+├── README.md # Game specifications and rules (in Russian)
+├── QWEN.md # This file - project context for AI assistance
+├── .gitignore # Git ignore rules (Python-focused template)
+├── jsdom-pkg/ # Local copy of jsdom library
+│ └── package/
+│ └── lib/
+│ ├── api.js # Main jsdom API entry point
+│ └── jsdom/ # jsdom core implementation
+│ ├── browser/ # Browser emulation (Window, parser, resources)
+│ ├── living/ # DOM living standard implementations
+│ ├── generated/ # Auto-generated Web IDL bindings
+│ └── ... # Various DOM/CSS/SVG/XHR implementations
+└── node_modules/
+ └── jsdom/ # Installed jsdom dependency
+```
+
+## Technology Stack
+
+- **Runtime**: Node.js (inferred from jsdom usage)
+- **Core Library**: [jsdom](https://github.com/jsdom/jsdom) - JavaScript implementation of DOM and HTML standards
+ - Provides browser-like environment for server-side JavaScript
+ - Enables DOM manipulation, event handling, and web API emulation
+
+## Development Status
+
+**Early Stage**: The project currently contains:
+- Game design documentation (README.md)
+- jsdom library setup (both local copy and node_modules installation)
+- No main game source files yet
+
+## Building and Running
+
+> **TODO**: Build and run commands are not yet defined. The project is in initial setup phase.
+
+Expected setup once development begins:
+```bash
+# Install dependencies
+npm install
+
+# Run the game (TBD)
+npm start
+
+# Run tests (TBD)
+npm test
+```
+
+## Development Conventions
+
+> **TODO**: Coding standards and testing practices are not yet established.
+
+### 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
+
+## Key Implementation Areas
+
+When development begins, focus on these components:
+
+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
+
+## 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
diff --git a/README.md b/README.md
index 816bc1e..9c6b5ca 100644
--- a/README.md
+++ b/README.md
@@ -1,2 +1,37 @@
# hexo
+Это учебный проект. Игра, клон https://www.gamedesign.jp/games/dicewars/
+
+1. Генерируемкая карта
+ 1. ккаждое поле - ячейка гексагон
+ 2. размер карты 20 х 20 ячеек
+ 3. каждое поле может быть доступно для игроков или быть непроходимо для всех
+ 4. каджое принадлежащее игроку поле дет ему +1 единицу снабжения
+
+2. игрок управляет кубиками
+ 1. кубик игральный 6 граннный
+ 2. на каждом поле может быть до 8 кубиков
+ 3. сила текущего юнита (ячейка с кубиками) расчитывается,
+ F = (cnt-1)*full_dice + current_dice,
+ где cnt - количество кубиков,
+ full_dice - максимальное значение на кубике = 6
+ сurrent_dice - верхний кубик с текущим значением от 1 до 6, расчитываемое в ходе игры
+
+3. правила игры
+ 1. вначале на карты помещается несколько кубиков для каждых игроков
+ 2. юзер может ходить юнитом, если
+ 1. его сила больше 1, при этом на захваченную клетку перемещается сила-1, а на исходной остается кубик с силой 1
+ 2. если на захватываемой клетке вражеские кубики, то оба игрока бросают кости
+ 1. у нападающего количество максимальной силыменьше на 1 исходной, F_attac=rnd(F-1)
+ 2. у защищающегося полная сила поля, F_defence=ктв(F)
+ 3.
+ 4. условия победы
+ 1. если F_attac>F_defence - то победа нападающего иначе защищающийся отбил атаку
+ 2. при победе нападающего он оставляет на исходной клетке кубик с силой 1, на захватываемую переводит F_attac-1,
+ 3. при пройгрыше у защищающегося отсается F_defence-F_attac, но не меньше 1
+ 3. После того, как все игроки походили, каждый получает снабжение по формуле
+ S=sum(Cell), где Cell - значение снабжения с каждой клетки, принадлежащей игроку
+ 1. Саксимальное кол-во на клетке может быть 8*Full_dice
+ 2. Если все клетки игрока имеют максимальное колво силы, то ничего не добавляется.
+
+
\ No newline at end of file
diff --git a/package.json b/package.json
new file mode 100644
index 0000000..079b69b
--- /dev/null
+++ b/package.json
@@ -0,0 +1,17 @@
+{
+ "name": "hexo",
+ "version": "0.1.0",
+ "description": "Educational dice game - DiceWars clone",
+ "main": "src/index.js",
+ "scripts": {
+ "start": "node src/index.js",
+ "serve": "node server.js",
+ "test": "node --test"
+ },
+ "keywords": ["game", "dicewars", "hexagon"],
+ "author": "",
+ "license": "MIT",
+ "dependencies": {
+ "jsdom": "file:./jsdom-pkg/package"
+ }
+}
diff --git a/public/game.js b/public/game.js
new file mode 100644
index 0000000..6b502cf
--- /dev/null
+++ b/public/game.js
@@ -0,0 +1,570 @@
+/**
+ * Hexo Game UI - Canvas Rendering and Interactions
+ */
+
+import { HexMap, CELL_TYPES } from './map.js';
+
+// Game constants
+const HEX_SIZE = 20;
+const MAP_SIZE = 20;
+const ANIMATION_DURATION = 300;
+const ISO_ANGLE = Math.PI / 6; // 30 degrees for isometric view
+
+// Colors
+const COLORS = {
+ blocked: '#2a2a4a',
+ empty: '#3a5a6a',
+ player1: '#4ecca3',
+ player2: '#e94560',
+ highlight: 'rgba(255, 255, 255, 0.3)',
+ selected: 'rgba(233, 69, 96, 0.6)',
+ target: 'rgba(78, 204, 163, 0.5)',
+ stroke: '#1a1a2e',
+ text: '#eeeeee',
+ textDark: '#1a1a2e',
+ dice: '#ffffff',
+ diceBorder: '#333333'
+};
+
+/**
+ * Game UI Controller
+ */
+class GameUI {
+ constructor() {
+ this.canvas = document.getElementById('game-canvas');
+ this.ctx = this.canvas.getContext('2d');
+ this.map = null;
+ this.selectedCell = null;
+ this.currentTarget = null;
+ this.currentPlayer = 1;
+ this.gamePhase = 'movement'; // movement, supply, gameover
+ this.hasMoved = false;
+
+ this.offsetX = 0;
+ this.offsetY = 0;
+
+ this.init();
+ }
+
+ init() {
+ this.setupEventListeners();
+ this.newGame();
+ console.log('Game initialized');
+ }
+
+ newGame() {
+ this.map = new HexMap(MAP_SIZE);
+ console.log('Map created, cells:', this.map.cells.size);
+ this.selectedCell = null;
+ this.currentTarget = null;
+ this.currentPlayer = 1;
+ this.gamePhase = 'movement';
+ this.hasMoved = false;
+
+ // Initialize starting positions
+ this.initializePlayers();
+
+ this.centerMap();
+ this.render();
+ this.updateUI();
+ this.log('New game started! Player 1\'s turn.');
+ }
+
+ initializePlayers() {
+ // Get random empty cells for each player
+ const emptyCells = this.map.getEmptyCells();
+
+ // Shuffle and pick starting positions
+ const shuffled = emptyCells.sort(() => Math.random() - 0.5);
+
+ // Player 1 starting position (top-left area)
+ const p1Cell = shuffled.find(c => c.q < 8 && c.r < 8);
+ if (p1Cell) {
+ this.map.setOwner(p1Cell.q, p1Cell.r, 1);
+ p1Cell.setStrength(8); // Starting strength
+ }
+
+ // Player 2 starting position (bottom-right area)
+ const p2Cell = shuffled.find(c => c.q > 10 && c.r > 10 && c.type === CELL_TYPES.EMPTY);
+ if (p2Cell) {
+ this.map.setOwner(p2Cell.q, p2Cell.r, 2);
+ p2Cell.setStrength(8);
+ }
+ }
+
+ centerMap() {
+ const canvasWidth = this.canvas.width;
+ const canvasHeight = this.canvas.height;
+
+ // Calculate map dimensions for isometric view
+ const mapWidth = MAP_SIZE * HEX_SIZE * 2;
+ const mapHeight = MAP_SIZE * HEX_SIZE;
+
+ this.offsetX = canvasWidth / 2;
+ this.offsetY = HEX_SIZE * 2;
+ }
+
+ setupEventListeners() {
+ this.canvas.addEventListener('click', (e) => this.handleClick(e));
+ this.canvas.addEventListener('mousemove', (e) => this.handleMouseMove(e));
+
+ document.getElementById('end-turn-btn').addEventListener('click', () => this.endTurn());
+ document.getElementById('new-game-btn').addEventListener('click', () => this.newGame());
+ document.getElementById('attack-btn').addEventListener('click', () => this.executeAttack());
+ document.getElementById('cancel-btn').addEventListener('click', () => this.cancelSelection());
+ }
+
+ hexToPixel(q, r) {
+ // Isometric projection for pointy-top hex grid
+ // q axis goes top-left to bottom-right
+ // r axis goes top-right to bottom-left
+ const isoX = (q - r) * HEX_SIZE * 1.5;
+ const isoY = (q + r) * HEX_SIZE * 0.75;
+
+ return {
+ x: this.offsetX + isoX,
+ y: this.offsetY + isoY
+ };
+ }
+
+ pixelToHex(x, y) {
+ const adjX = x - this.offsetX;
+ const adjY = y - this.offsetY;
+
+ // Reverse isometric projection
+ // From: isoX = (q - r) * HEX_SIZE * 1.5
+ // isoY = (q + r) * HEX_SIZE * 0.75
+ const q = (adjX / (HEX_SIZE * 1.5) + adjY / (HEX_SIZE * 0.75)) / 2;
+ const r = (adjY / (HEX_SIZE * 0.75) - adjX / (HEX_SIZE * 1.5)) / 2;
+
+ const qi = Math.round(q);
+ const ri = Math.round(r);
+
+ if (qi >= 0 && qi < MAP_SIZE && ri >= 0 && ri < MAP_SIZE) {
+ return { q: qi, r: ri };
+ }
+ return null;
+ }
+
+ /**
+ * Get neighboring cells for isometric hex grid
+ * Directions for pointy-top hex in axial coordinates with isometric projection:
+ * hexToPixel: isoX = (q-r)*1.5, isoY = (q+r)*0.75
+ *
+ * Visual directions:
+ * - top: (-1, -1) - both decrease
+ * - bottom: (+1, +1) - both increase
+ * - top-left: (-1, 0) - q decreases
+ * - bottom-left: (0, +1) - r increases
+ * - top-right: (0, -1) - r decreases
+ * - bottom-right: (+1, 0) - q increases
+ */
+ getValidTargets(q, r) {
+ const directions = [
+ [+1, 0], // bottom-right
+ [+1, +1], // bottom (straight down)
+ [0, +1], // bottom-left
+ [-1, 0], // top-left
+ [-1, -1], // top (straight up)
+ [0, -1] // top-right
+ ];
+
+ const targets = [];
+ 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) {
+ const cell = this.map.getCell(nq, nr);
+ if (cell && cell.isPassable()) {
+ targets.push(cell);
+ }
+ }
+ }
+ return targets;
+ }
+
+ drawHex(q, r, fillStyle, strokeStyle = COLORS.stroke, lineWidth = 2) {
+ const { x, y } = this.hexToPixel(q, r);
+
+ // Draw elongated hexagon for isometric view
+ const hexWidth = HEX_SIZE * 1.3;
+ const hexHeight = HEX_SIZE * 0.75;
+
+ // Hexagon vertices for isometric view (elongated horizontally)
+ const vertices = [
+ { x: x + hexWidth, y: y }, // right
+ { x: x + hexWidth * 0.5, y: y - hexHeight }, // top-right
+ { x: x - hexWidth * 0.5, y: y - hexHeight }, // top-left
+ { x: x - hexWidth, y: y }, // left
+ { x: x - hexWidth * 0.5, y: y + hexHeight }, // bottom-left
+ { x: x + hexWidth * 0.5, y: y + hexHeight } // bottom-right
+ ];
+
+ this.ctx.beginPath();
+ this.ctx.moveTo(vertices[0].x, vertices[0].y);
+ for (let i = 1; i < vertices.length; i++) {
+ this.ctx.lineTo(vertices[i].x, vertices[i].y);
+ }
+ this.ctx.closePath();
+
+ this.ctx.fillStyle = fillStyle;
+ this.ctx.fill();
+
+ this.ctx.strokeStyle = strokeStyle;
+ this.ctx.lineWidth = lineWidth;
+ this.ctx.stroke();
+ }
+
+ drawDice(cell) {
+ if (cell.dice.length === 0) return;
+
+ const { x, y } = this.hexToPixel(cell.q, cell.r);
+ const strength = cell.getStrength();
+
+ // Draw strength number
+ this.ctx.fillStyle = COLORS.text;
+ this.ctx.font = 'bold 11px Arial';
+ this.ctx.textAlign = 'center';
+ this.ctx.textBaseline = 'middle';
+ this.ctx.fillText(strength.toString(), x, y - 3);
+
+ // Draw dice count indicator (small dots)
+ if (cell.dice.length > 1) {
+ const dotY = y + 8;
+ for (let i = 0; i < Math.min(cell.dice.length, 5); i++) {
+ this.ctx.beginPath();
+ this.ctx.arc(x - 6 + i * 4, dotY, 1.5, 0, Math.PI * 2);
+ this.ctx.fillStyle = COLORS.dice;
+ this.ctx.fill();
+ }
+ }
+ }
+
+ drawOwnerIndicator(cell) {
+ if (!cell.isOwned()) return;
+
+ const { x, y } = this.hexToPixel(cell.q, cell.r);
+ const ownerColor = cell.getOwner() === 1 ? COLORS.player1 : COLORS.player2;
+
+ // Draw border matching the hexagon shape
+ const hexWidth = HEX_SIZE * 1.3 - 3;
+ const hexHeight = HEX_SIZE * 0.75 - 3;
+
+ const vertices = [
+ { x: x + hexWidth, y: y },
+ { x: x + hexWidth * 0.5, y: y - hexHeight },
+ { x: x - hexWidth * 0.5, y: y - hexHeight },
+ { x: x - hexWidth, y: y },
+ { x: x - hexWidth * 0.5, y: y + hexHeight },
+ { x: x + hexWidth * 0.5, y: y + hexHeight }
+ ];
+
+ this.ctx.beginPath();
+ this.ctx.moveTo(vertices[0].x, vertices[0].y);
+ for (let i = 1; i < vertices.length; i++) {
+ this.ctx.lineTo(vertices[i].x, vertices[i].y);
+ }
+ this.ctx.closePath();
+ this.ctx.strokeStyle = ownerColor;
+ this.ctx.lineWidth = 3;
+ this.ctx.stroke();
+ }
+
+ render() {
+ // Clear canvas
+ this.ctx.fillStyle = COLORS.stroke;
+ this.ctx.fillRect(0, 0, this.canvas.width, this.canvas.height);
+
+ if (!this.map) return;
+
+ // Draw all cells
+ for (let r = 0; r < MAP_SIZE; r++) {
+ for (let q = 0; q < MAP_SIZE; 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) {
+ color = COLORS.player1;
+ } else if (cell.type === CELL_TYPES.PLAYER2) {
+ color = COLORS.player2;
+ } else {
+ color = COLORS.empty;
+ }
+
+ // Apply transparency for non-owned cells
+ if (!cell.isOwned()) {
+ color = this.hexToRgba(color, 0.6);
+ }
+
+ this.drawHex(q, r, color);
+ this.drawOwnerIndicator(cell);
+ this.drawDice(cell);
+ }
+ }
+
+ // Draw selection highlight
+ if (this.selectedCell) {
+ this.drawHex(
+ this.selectedCell.q,
+ this.selectedCell.r,
+ COLORS.selected,
+ COLORS.text,
+ 3
+ );
+
+ // Highlight valid targets (not blocked, not own)
+ 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,
+ COLORS.target,
+ COLORS.player1,
+ 2
+ );
+ }
+ }
+ }
+ }
+
+ hexToRgba(hex, alpha) {
+ const r = parseInt(hex.slice(1, 3), 16);
+ const g = parseInt(hex.slice(3, 5), 16);
+ const b = parseInt(hex.slice(5, 7), 16);
+ return `rgba(${r}, ${g}, ${b}, ${alpha})`;
+ }
+
+ handleClick(e) {
+ if (this.gamePhase !== 'movement') return;
+
+ const rect = this.canvas.getBoundingClientRect();
+ const x = e.clientX - rect.left;
+ const y = e.clientY - rect.top;
+
+ const hexPos = this.pixelToHex(x, y);
+ if (!hexPos) return;
+
+ const cell = this.map.getCell(hexPos.q, hexPos.r);
+ if (!cell || !cell.isPassable()) return;
+
+ // Handle cell selection
+ if (this.selectedCell === null) {
+ // Select own cell with strength > 1
+ if (cell.getOwner() === this.currentPlayer && cell.getStrength() > 1) {
+ this.selectedCell = cell;
+ this.instruction = `Select target cell or cancel`;
+ this.updateUI();
+ this.render();
+ }
+ } else {
+ // Check if clicking on same cell - deselect
+ if (cell === this.selectedCell) {
+ this.cancelSelection();
+ return;
+ }
+
+ // Check if valid target using isometric-aware adjacency
+ const targets = this.getValidTargets(this.selectedCell.q, this.selectedCell.r);
+ const isValidTarget = targets.some(n => n.q === cell.q && n.r === cell.r);
+
+ // Cannot move to own cells - only attack enemy or capture empty
+ if (cell.getOwner() === this.currentPlayer) {
+ // Select different own cell instead
+ if (cell.getStrength() > 1) {
+ this.selectedCell = cell;
+ this.updateUI();
+ this.render();
+ }
+ return;
+ }
+
+ if (isValidTarget) {
+ this.currentTarget = cell;
+ this.executeAttack();
+ } else {
+ this.cancelSelection();
+ }
+ }
+ }
+
+ handleMouseMove(e) {
+ // Could add hover effects here
+ }
+
+ executeAttack() {
+ if (!this.selectedCell || !this.currentTarget) return;
+
+ const attacker = this.selectedCell;
+ const defender = this.currentTarget;
+ const attackStrength = attacker.getStrength() - 1;
+
+ if (defender.type === CELL_TYPES.EMPTY || defender.getOwner() !== this.currentPlayer) {
+ // Attack empty or enemy cell
+ let defenseStrength = defender.getStrength();
+
+ if (defenseStrength > 0) {
+ // Combat! Roll dice
+ const attackRoll = Math.floor(Math.random() * attackStrength) + 1;
+ const defenseRoll = Math.floor(Math.random() * defenseStrength) + 1;
+
+ this.log(`Attack: ${attackStrength} vs ${defenseStrength} | Roll: ${attackRoll} vs ${defenseRoll}`);
+
+ if (attackRoll > defenseRoll) {
+ // Attacker wins
+ const remainingStrength = attackRoll - 1;
+ attacker.setStrength(1);
+
+ if (remainingStrength > 0) {
+ defender.setStrength(remainingStrength);
+ this.map.setOwner(defender.q, defender.r, this.currentPlayer);
+ this.log(`Victory! Captured cell with strength ${remainingStrength}`, 'victory');
+ }
+ } else {
+ // Defender wins
+ const remainingDefense = defenseRoll - attackRoll;
+ defender.setStrength(Math.max(1, remainingDefense));
+ this.log(`Attack repelled! Defender has ${Math.max(1, remainingDefense)} strength`, 'defeat');
+ }
+ } else {
+ // Move to empty cell - transfer attackStrength (original - 1)
+ attacker.setStrength(1);
+ defender.setStrength(attackStrength);
+ this.map.setOwner(defender.q, defender.r, this.currentPlayer);
+ this.log(`Moved to empty cell with strength ${attackStrength}`);
+ }
+ }
+
+ this.hasMoved = true;
+ this.cancelSelection();
+ this.render();
+ this.updateUI();
+ }
+
+ cancelSelection() {
+ this.selectedCell = null;
+ this.currentTarget = null;
+ this.updateUI();
+ this.render();
+ }
+
+ endTurn() {
+ if (this.gamePhase !== 'movement') return;
+
+ // Apply supply
+ const supply = this.map.calculateSupply(this.currentPlayer);
+ this.distributeSupply(supply);
+
+ this.log(`Player ${this.currentPlayer} received ${supply} supply`);
+
+ // Switch player
+ this.currentPlayer = this.currentPlayer === 1 ? 2 : 1;
+ this.hasMoved = false;
+
+ this.cancelSelection();
+ this.updateUI();
+ this.render();
+
+ this.log(`Player ${this.currentPlayer}'s turn`);
+ }
+
+ distributeSupply(supply) {
+ const playerCells = this.map.getPlayerCells(this.currentPlayer);
+
+ // Find cells that can receive more dice
+ const eligibleCells = playerCells.filter(cell => !cell.isMaxStrength());
+
+ if (eligibleCells.length === 0 || supply === 0) return;
+
+ // Distribute supply randomly among eligible cells
+ 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) {
+ const addStrength = Math.min(remainingSupply, 48 - currentStrength);
+ const newStrength = currentStrength + addStrength;
+ randomCell.setStrength(newStrength);
+ remainingSupply -= addStrength;
+ }
+
+ if (randomCell.isMaxStrength()) {
+ eligibleCells.splice(eligibleCells.indexOf(randomCell), 1);
+ }
+ }
+ }
+
+ updateUI() {
+ // Update player stats
+ const p1Cells = this.map.getPlayerCells(1);
+ const p2Cells = this.map.getPlayerCells(2);
+
+ const p1Strength = p1Cells.reduce((sum, c) => sum + c.getStrength(), 0);
+ const p2Strength = p2Cells.reduce((sum, c) => sum + c.getStrength(), 0);
+
+ document.getElementById('player1-cells').textContent = p1Cells.length;
+ document.getElementById('player1-supply').textContent = this.map.calculateSupply(1);
+ document.getElementById('player1-strength').textContent = p1Strength;
+
+ document.getElementById('player2-cells').textContent = p2Cells.length;
+ document.getElementById('player2-supply').textContent = this.map.calculateSupply(2);
+ document.getElementById('player2-strength').textContent = p2Strength;
+
+ // Update active player
+ document.getElementById('player1-card').classList.toggle('active', this.currentPlayer === 1);
+ document.getElementById('player2-card').classList.toggle('active', this.currentPlayer === 2);
+
+ // Update game info
+ document.getElementById('current-turn').textContent = this.currentPlayer;
+ document.getElementById('game-phase').textContent = this.gamePhase;
+
+ // Update instruction
+ const instruction = document.getElementById('action-instruction');
+ if (this.selectedCell) {
+ instruction.textContent = `Select target to attack (strength: ${this.selectedCell.getStrength()})`;
+ } else {
+ instruction.textContent = 'Select a cell with dice to move';
+ }
+
+ // Update buttons
+ const attackBtn = document.getElementById('attack-btn');
+ const cancelBtn = document.getElementById('cancel-btn');
+ const endTurnBtn = document.getElementById('end-turn-btn');
+
+ attackBtn.disabled = !this.selectedCell;
+ cancelBtn.disabled = !this.selectedCell;
+ endTurnBtn.disabled = !this.hasMoved;
+ }
+
+ log(message, type = '') {
+ const logContainer = document.getElementById('battle-log');
+ const entry = document.createElement('div');
+ entry.className = `log-entry ${type}`;
+ entry.textContent = `[Turn ${this.currentPlayer}] ${message}`;
+ logContainer.insertBefore(entry, logContainer.firstChild);
+
+ // Keep only last 50 entries
+ while (logContainer.children.length > 50) {
+ logContainer.removeChild(logContainer.lastChild);
+ }
+ }
+
+ showMessage(message) {
+ const overlay = document.getElementById('message-overlay');
+ const messageEl = document.getElementById('overlay-message');
+ messageEl.textContent = message;
+ overlay.classList.add('visible');
+
+ setTimeout(() => {
+ overlay.classList.remove('visible');
+ }, 2000);
+ }
+}
+
+// Initialize game when DOM is ready
+document.addEventListener('DOMContentLoaded', () => {
+ window.game = new GameUI();
+});
diff --git a/public/index.html b/public/index.html
new file mode 100644
index 0000000..2ec74b1
--- /dev/null
+++ b/public/index.html
@@ -0,0 +1,109 @@
+
+
+
+
+
+ HEXO - DiceWars Clone
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/public/map.js b/public/map.js
new file mode 100644
index 0000000..c76a9c8
--- /dev/null
+++ b/public/map.js
@@ -0,0 +1,173 @@
+/**
+ * Hexagonal grid map for the DiceWars game.
+ * Browser version (ES Module)
+ */
+
+const MAP_SIZE = 20;
+const CELL_TYPES = {
+ EMPTY: 0,
+ BLOCKED: 1,
+ PLAYER1: 2,
+ PLAYER2: 3,
+};
+
+class HexCell {
+ constructor(q, r, type = CELL_TYPES.EMPTY) {
+ this.q = q;
+ this.r = r;
+ this.type = type;
+ this.dice = [];
+ }
+
+ getStrength() {
+ if (this.dice.length === 0) return 0;
+ const cnt = this.dice.length;
+ const fullDice = 6;
+ const currentDice = this.dice[this.dice.length - 1];
+ return (cnt - 1) * fullDice + currentDice;
+ }
+
+ isMaxStrength() {
+ return this.dice.length >= 8 && this.getStrength() >= 48;
+ }
+
+ addDie(value) {
+ if (this.dice.length < 8) {
+ this.dice.push(value);
+ return true;
+ }
+ return false;
+ }
+
+ setStrength(targetStrength) {
+ if (targetStrength <= 0) {
+ this.dice = [];
+ return;
+ }
+
+ const fullDice = 6;
+ const cnt = Math.floor((targetStrength - 1) / fullDice) + 1;
+ const remainder = targetStrength - (cnt - 1) * fullDice;
+
+ this.dice = new Array(cnt - 1).fill(6);
+ if (remainder > 0) {
+ this.dice.push(remainder);
+ }
+ }
+
+ isPassable() {
+ return this.type !== CELL_TYPES.BLOCKED;
+ }
+
+ isOwned() {
+ return this.type === CELL_TYPES.PLAYER1 || this.type === CELL_TYPES.PLAYER2;
+ }
+
+ getOwner() {
+ if (this.type === CELL_TYPES.PLAYER1) return 1;
+ if (this.type === CELL_TYPES.PLAYER2) return 2;
+ return 0;
+ }
+}
+
+class HexMap {
+ constructor(size = MAP_SIZE) {
+ this.size = size;
+ this.cells = new Map();
+ this.generate();
+ }
+
+ generate() {
+ this.cells.clear();
+
+ for (let q = 0; q < this.size; q++) {
+ for (let r = 0; r < this.size; r++) {
+ const key = this.getKey(q, r);
+ const type = Math.random() < 0.15 ? CELL_TYPES.BLOCKED : CELL_TYPES.EMPTY;
+ this.cells.set(key, new HexCell(q, r, type));
+ }
+ }
+ }
+
+ getCell(q, r) {
+ const key = this.getKey(q, r);
+ return this.cells.get(key);
+ }
+
+ getCellByKey(key) {
+ return this.cells.get(key);
+ }
+
+ getKey(q, r) {
+ return `${q},${r}`;
+ }
+
+ getPassableCells() {
+ return Array.from(this.cells.values()).filter(cell => cell.isPassable());
+ }
+
+ getEmptyCells() {
+ return Array.from(this.cells.values()).filter(
+ cell => cell.isPassable() && !cell.isOwned()
+ );
+ }
+
+ getPlayerCells(playerId) {
+ const targetType = playerId === 1 ? CELL_TYPES.PLAYER1 : CELL_TYPES.PLAYER2;
+ return Array.from(this.cells.values()).filter(
+ cell => cell.type === targetType
+ );
+ }
+
+ getNeighbors(q, r) {
+ const directions = [
+ [1, 0], [1, -1], [0, -1],
+ [-1, 0], [-1, 1], [0, 1]
+ ];
+
+ const neighbors = [];
+ for (const [dq, dr] of directions) {
+ const nq = q + dq;
+ const nr = r + dr;
+ if (nq >= 0 && nq < this.size && nr >= 0 && nr < this.size) {
+ const cell = this.getCell(nq, nr);
+ if (cell && cell.isPassable()) {
+ neighbors.push(cell);
+ }
+ }
+ }
+ return neighbors;
+ }
+
+ calculateSupply(playerId) {
+ const playerCells = this.getPlayerCells(playerId);
+ let supply = 0;
+
+ for (const cell of playerCells) {
+ supply += 1;
+ }
+
+ return supply;
+ }
+
+ setOwner(q, r, playerId) {
+ const cell = this.getCell(q, r);
+ if (cell && cell.isPassable()) {
+ cell.type = playerId === 1 ? CELL_TYPES.PLAYER1 : CELL_TYPES.PLAYER2;
+ return true;
+ }
+ return false;
+ }
+
+ clearOwner(q, r) {
+ const cell = this.getCell(q, r);
+ if (cell) {
+ cell.type = CELL_TYPES.EMPTY;
+ cell.dice = [];
+ return true;
+ }
+ return false;
+ }
+}
+
+export { HexMap, HexCell, CELL_TYPES, MAP_SIZE };
diff --git a/public/styles.css b/public/styles.css
new file mode 100644
index 0000000..b86a09c
--- /dev/null
+++ b/public/styles.css
@@ -0,0 +1,378 @@
+* {
+ margin: 0;
+ padding: 0;
+ box-sizing: border-box;
+}
+
+:root {
+ --bg-primary: #1a1a2e;
+ --bg-secondary: #16213e;
+ --bg-panel: #0f3460;
+ --text-primary: #eee;
+ --text-secondary: #aaa;
+ --accent-primary: #e94560;
+ --accent-secondary: #00adb5;
+ --player1-color: #4ecca3;
+ --player2-color: #e94560;
+ --blocked-color: #2a2a4a;
+ --empty-color: #3a5a6a;
+ --highlight-color: rgba(255, 255, 255, 0.3);
+ --selected-color: rgba(233, 69, 96, 0.5);
+ --target-color: rgba(78, 204, 163, 0.5);
+ --hex-stroke: #1a1a2e;
+}
+
+body {
+ font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
+ background: var(--bg-primary);
+ color: var(--text-primary);
+ min-height: 100vh;
+ overflow: hidden;
+}
+
+.game-container {
+ display: flex;
+ flex-direction: column;
+ height: 100vh;
+}
+
+/* Header */
+.game-header {
+ background: var(--bg-secondary);
+ padding: 10px 20px;
+ text-align: center;
+ border-bottom: 2px solid var(--accent-primary);
+}
+
+.game-header h1 {
+ font-size: 1.8rem;
+ color: var(--accent-primary);
+ text-transform: uppercase;
+ letter-spacing: 4px;
+}
+
+.subtitle {
+ font-size: 0.9rem;
+ color: var(--text-secondary);
+}
+
+/* Game Area */
+.game-area {
+ display: flex;
+ flex: 1;
+ overflow: hidden;
+}
+
+/* Side Panels */
+.side-panel {
+ width: 220px;
+ background: var(--bg-secondary);
+ padding: 15px;
+ display: flex;
+ flex-direction: column;
+ gap: 15px;
+ overflow-y: auto;
+}
+
+.left-panel {
+ border-right: 1px solid var(--bg-panel);
+}
+
+.right-panel {
+ border-left: 1px solid var(--bg-panel);
+}
+
+/* Player Cards */
+.player-card {
+ background: var(--bg-panel);
+ border-radius: 8px;
+ padding: 12px;
+ border-left: 4px solid transparent;
+ transition: all 0.3s ease;
+}
+
+.player-card.active {
+ box-shadow: 0 0 15px rgba(233, 69, 96, 0.4);
+}
+
+.player-1 {
+ border-left-color: var(--player1-color);
+}
+
+.player-1.active {
+ background: linear-gradient(135deg, var(--bg-panel), rgba(78, 204, 163, 0.1));
+}
+
+.player-2 {
+ border-left-color: var(--player2-color);
+}
+
+.player-2.active {
+ background: linear-gradient(135deg, var(--bg-panel), rgba(233, 69, 96, 0.1));
+}
+
+.player-card h3 {
+ font-size: 1rem;
+ margin-bottom: 10px;
+ text-transform: uppercase;
+ letter-spacing: 1px;
+}
+
+.player-stats {
+ display: flex;
+ flex-direction: column;
+ gap: 6px;
+}
+
+.stat {
+ display: flex;
+ justify-content: space-between;
+ font-size: 0.85rem;
+}
+
+.stat-label {
+ color: var(--text-secondary);
+}
+
+.stat-value {
+ font-weight: bold;
+ color: var(--text-primary);
+}
+
+/* Game Info */
+.game-info {
+ background: var(--bg-panel);
+ border-radius: 8px;
+ padding: 12px;
+}
+
+.game-info h3 {
+ font-size: 0.9rem;
+ margin-bottom: 10px;
+ color: var(--text-secondary);
+}
+
+.info-item {
+ display: flex;
+ justify-content: space-between;
+ font-size: 0.85rem;
+ padding: 4px 0;
+ border-bottom: 1px solid rgba(255, 255, 255, 0.1);
+}
+
+.info-item:last-child {
+ border-bottom: none;
+}
+
+/* Buttons */
+.btn {
+ padding: 10px 16px;
+ border: none;
+ border-radius: 6px;
+ cursor: pointer;
+ font-size: 0.9rem;
+ font-weight: 600;
+ text-transform: uppercase;
+ letter-spacing: 1px;
+ transition: all 0.2s ease;
+}
+
+.btn:disabled {
+ opacity: 0.5;
+ cursor: not-allowed;
+}
+
+.btn-primary {
+ background: var(--accent-primary);
+ color: white;
+}
+
+.btn-primary:hover:not(:disabled) {
+ background: #ff6b6b;
+ transform: translateY(-2px);
+}
+
+.btn-secondary {
+ background: var(--bg-panel);
+ color: var(--text-primary);
+}
+
+.btn-secondary:hover:not(:disabled) {
+ background: var(--accent-secondary);
+}
+
+.btn-action {
+ background: var(--accent-secondary);
+ color: white;
+ flex: 1;
+}
+
+.btn-action:hover:not(:disabled) {
+ background: #00cec9;
+}
+
+/* Actions Panel */
+.actions-panel {
+ background: var(--bg-panel);
+ border-radius: 8px;
+ padding: 12px;
+}
+
+.actions-panel h3 {
+ font-size: 0.9rem;
+ margin-bottom: 8px;
+ color: var(--text-secondary);
+}
+
+.instruction {
+ font-size: 0.8rem;
+ color: var(--text-primary);
+ margin-bottom: 12px;
+ min-height: 40px;
+}
+
+.action-buttons {
+ display: flex;
+ gap: 8px;
+}
+
+/* Battle Log */
+.battle-log {
+ background: var(--bg-panel);
+ border-radius: 8px;
+ padding: 12px;
+ flex: 1;
+ display: flex;
+ flex-direction: column;
+ min-height: 200px;
+}
+
+.battle-log h3 {
+ font-size: 0.9rem;
+ margin-bottom: 10px;
+ color: var(--text-secondary);
+}
+
+.log-entries {
+ flex: 1;
+ overflow-y: auto;
+ font-size: 0.75rem;
+ font-family: 'Consolas', monospace;
+}
+
+.log-entry {
+ padding: 4px 6px;
+ margin-bottom: 4px;
+ background: rgba(0, 0, 0, 0.2);
+ border-radius: 4px;
+ border-left: 3px solid var(--accent-secondary);
+}
+
+.log-entry.attack {
+ border-left-color: var(--accent-primary);
+}
+
+.log-entry.victory {
+ border-left-color: var(--player1-color);
+}
+
+.log-entry.defeat {
+ border-left-color: var(--player2-color);
+}
+
+/* Canvas Container */
+.canvas-container {
+ flex: 1;
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ background: var(--bg-primary);
+ position: relative;
+ overflow: hidden;
+ min-width: 600px;
+ min-height: 600px;
+}
+
+#game-canvas {
+ border-radius: 8px;
+ box-shadow: 0 10px 40px rgba(0, 0, 0, 0.4);
+ display: block;
+}
+
+.canvas-overlay {
+ position: absolute;
+ top: 50%;
+ left: 50%;
+ transform: translate(-50%, -50%);
+ background: rgba(26, 26, 46, 0.95);
+ padding: 30px 50px;
+ border-radius: 12px;
+ border: 2px solid var(--accent-primary);
+ display: none;
+ z-index: 10;
+}
+
+.canvas-overlay.visible {
+ display: block;
+}
+
+#overlay-message {
+ font-size: 1.5rem;
+ color: var(--accent-primary);
+ text-transform: uppercase;
+ letter-spacing: 3px;
+}
+
+/* Footer */
+.game-footer {
+ background: var(--bg-secondary);
+ padding: 8px 20px;
+ text-align: center;
+ border-top: 1px solid var(--bg-panel);
+}
+
+.game-footer p {
+ font-size: 0.8rem;
+ color: var(--text-secondary);
+}
+
+/* Scrollbar */
+::-webkit-scrollbar {
+ width: 8px;
+}
+
+::-webkit-scrollbar-track {
+ background: var(--bg-primary);
+}
+
+::-webkit-scrollbar-thumb {
+ background: var(--bg-panel);
+ border-radius: 4px;
+}
+
+::-webkit-scrollbar-thumb:hover {
+ background: var(--accent-secondary);
+}
+
+/* Responsive */
+@media (max-width: 1200px) {
+ .side-panel {
+ width: 180px;
+ }
+}
+
+@media (max-width: 900px) {
+ .game-area {
+ flex-direction: column;
+ }
+
+ .side-panel {
+ width: 100%;
+ flex-direction: row;
+ flex-wrap: wrap;
+ }
+
+ .canvas-container {
+ min-height: 400px;
+ }
+}
diff --git a/server.js b/server.js
new file mode 100644
index 0000000..a7faada
--- /dev/null
+++ b/server.js
@@ -0,0 +1,46 @@
+const http = require('http');
+const fs = require('fs');
+const path = require('path');
+
+const PORT = 8080;
+
+const MIME_TYPES = {
+ '.html': 'text/html',
+ '.css': 'text/css',
+ '.js': 'application/javascript',
+ '.json': 'application/json',
+ '.png': 'image/png',
+ '.jpg': 'image/jpeg',
+ '.gif': 'image/gif',
+ '.svg': 'image/svg+xml',
+ '.ico': 'image/x-icon'
+};
+
+const server = http.createServer((req, res) => {
+ let filePath = req.url === '/' ? '/index.html' : req.url;
+ filePath = path.join(__dirname, 'public', filePath);
+
+ const ext = path.extname(filePath);
+ const contentType = MIME_TYPES[ext] || 'application/octet-stream';
+
+ fs.readFile(filePath, (err, content) => {
+ if (err) {
+ if (err.code === 'ENOENT') {
+ res.writeHead(404);
+ res.end('File not found');
+ } else {
+ res.writeHead(500);
+ res.end('Server error');
+ }
+ } else {
+ res.writeHead(200, { 'Content-Type': contentType });
+ res.end(content);
+ }
+ });
+});
+
+server.listen(PORT, () => {
+ console.log(`\n=== HEXO Game Server ===`);
+ console.log(`Server running at http://localhost:${PORT}/`);
+ console.log(`Press Ctrl+C to stop\n`);
+});
diff --git a/src/index.js b/src/index.js
new file mode 100644
index 0000000..fde38af
--- /dev/null
+++ b/src/index.js
@@ -0,0 +1,28 @@
+/**
+ * Hexo - DiceWars Clone
+ * Main entry point
+ */
+
+const { HexMap, CELL_TYPES } = require('./map.js');
+
+console.log('=== HEXO - DiceWars Clone ===\n');
+
+// Create and display the map
+const map = new HexMap(20);
+
+console.log('Generated Map (20x20 hexagonal grid):\n');
+console.log('Legend: [██]=Blocked, [P1]=Player1, [P2]=Player2, [nn]=Strength, [ ]=Empty\n');
+
+map.render();
+
+// Show some statistics
+const passableCells = map.getPassableCells().length;
+const blockedCells = Array.from(map.cells.values()).filter(
+ cell => cell.type === CELL_TYPES.BLOCKED
+).length;
+
+console.log(`\n=== Map Statistics ===`);
+console.log(`Total cells: ${map.size * map.size}`);
+console.log(`Passable: ${passableCells}`);
+console.log(`Blocked: ${blockedCells}`);
+console.log(`Blocked %: ${((blockedCells / (map.size * map.size)) * 100).toFixed(1)}%`);
diff --git a/src/map.js b/src/map.js
new file mode 100644
index 0000000..2704f91
--- /dev/null
+++ b/src/map.js
@@ -0,0 +1,268 @@
+/**
+ * Hexagonal grid map for the DiceWars game.
+ *
+ * Map is a 20x20 hexagonal grid where each cell can be:
+ * - passable (playable)
+ * - impassable (blocked)
+ *
+ * Uses axial coordinates (q, r) for hexagon positioning.
+ */
+
+const MAP_SIZE = 20;
+const CELL_TYPES = {
+ EMPTY: 0, // Passable, unowned
+ BLOCKED: 1, // Impassable terrain
+ PLAYER1: 2, // Player 1 owned
+ PLAYER2: 3, // Player 2 owned
+};
+
+/**
+ * Represents a single hex cell on the map
+ */
+class HexCell {
+ constructor(q, r, type = CELL_TYPES.EMPTY) {
+ this.q = q; // Axial coordinate q
+ this.r = r; // Axial coordinate r
+ this.type = type; // Cell ownership/type
+ this.dice = []; // Array of dice values on this cell
+ }
+
+ /**
+ * Calculate unit strength: F = (cnt-1)*full_dice + current_dice
+ */
+ getStrength() {
+ if (this.dice.length === 0) return 0;
+ const cnt = this.dice.length;
+ const fullDice = 6;
+ const currentDice = this.dice[this.dice.length - 1]; // Top die
+ return (cnt - 1) * fullDice + currentDice;
+ }
+
+ /**
+ * Check if cell has maximum strength (8 dice with 6 on top)
+ */
+ isMaxStrength() {
+ return this.dice.length >= 8 && this.getStrength() >= 48;
+ }
+
+ /**
+ * Add a die to this cell
+ */
+ addDie(value) {
+ if (this.dice.length < 8) {
+ this.dice.push(value);
+ return true;
+ }
+ return false;
+ }
+
+ /**
+ * Remove dice from cell, leaving specified strength
+ */
+ setStrength(targetStrength) {
+ if (targetStrength <= 0) {
+ this.dice = [];
+ return;
+ }
+
+ const fullDice = 6;
+ const cnt = Math.floor((targetStrength - 1) / fullDice) + 1;
+ const remainder = targetStrength - (cnt - 1) * fullDice;
+
+ this.dice = new Array(cnt - 1).fill(6);
+ if (remainder > 0) {
+ this.dice.push(remainder);
+ }
+ }
+
+ isPassable() {
+ return this.type !== CELL_TYPES.BLOCKED;
+ }
+
+ isOwned() {
+ return this.type === CELL_TYPES.PLAYER1 || this.type === CELL_TYPES.PLAYER2;
+ }
+
+ getOwner() {
+ if (this.type === CELL_TYPES.PLAYER1) return 1;
+ if (this.type === CELL_TYPES.PLAYER2) return 2;
+ return 0;
+ }
+}
+
+/**
+ * Hexagonal map generator and manager
+ */
+class HexMap {
+ constructor(size = MAP_SIZE) {
+ this.size = size;
+ this.cells = new Map(); // Key: "q,r", Value: HexCell
+ this.generate();
+ }
+
+ /**
+ * Generate the hexagonal grid
+ */
+ generate() {
+ this.cells.clear();
+
+ for (let q = 0; q < this.size; q++) {
+ for (let r = 0; r < this.size; r++) {
+ const key = this.getKey(q, r);
+ const type = Math.random() < 0.15 ? CELL_TYPES.BLOCKED : CELL_TYPES.EMPTY;
+ this.cells.set(key, new HexCell(q, r, type));
+ }
+ }
+ }
+
+ /**
+ * Get cell by axial coordinates
+ */
+ getCell(q, r) {
+ const key = this.getKey(q, r);
+ return this.cells.get(key);
+ }
+
+ /**
+ * Get cell by key string
+ */
+ getCellByKey(key) {
+ return this.cells.get(key);
+ }
+
+ /**
+ * Generate key from coordinates
+ */
+ getKey(q, r) {
+ return `${q},${r}`;
+ }
+
+ /**
+ * Get all passable cells
+ */
+ getPassableCells() {
+ return Array.from(this.cells.values()).filter(cell => cell.isPassable());
+ }
+
+ /**
+ * Get all empty (unowned) passable cells
+ */
+ getEmptyCells() {
+ return Array.from(this.cells.values()).filter(
+ cell => cell.isPassable() && !cell.isOwned()
+ );
+ }
+
+ /**
+ * Get all cells owned by a player
+ */
+ getPlayerCells(playerId) {
+ const targetType = playerId === 1 ? CELL_TYPES.PLAYER1 : CELL_TYPES.PLAYER2;
+ return Array.from(this.cells.values()).filter(
+ cell => cell.type === targetType
+ );
+ }
+
+ /**
+ * Get neighboring cells (6 directions in hex grid)
+ */
+ getNeighbors(q, r) {
+ const directions = [
+ [1, 0], [1, -1], [0, -1],
+ [-1, 0], [-1, 1], [0, 1]
+ ];
+
+ const neighbors = [];
+ for (const [dq, dr] of directions) {
+ const nq = q + dq;
+ const nr = r + dr;
+ if (nq >= 0 && nq < this.size && nr >= 0 && nr < this.size) {
+ const cell = this.getCell(nq, nr);
+ if (cell && cell.isPassable()) {
+ neighbors.push(cell);
+ }
+ }
+ }
+ return neighbors;
+ }
+
+ /**
+ * Calculate supply for a player: S = sum of all owned cells
+ */
+ calculateSupply(playerId) {
+ const playerCells = this.getPlayerCells(playerId);
+ let supply = 0;
+
+ for (const cell of playerCells) {
+ supply += 1; // Each owned cell gives +1 supply
+ }
+
+ return supply;
+ }
+
+ /**
+ * Render map to console (simplified ASCII representation)
+ */
+ render() {
+ let output = '';
+
+ for (let r = 0; r < this.size; r++) {
+ // Offset every other row for hex appearance
+ const offset = r % 2 === 0 ? 0 : 2;
+ output += ' '.repeat(offset);
+
+ for (let q = 0; q < this.size; q++) {
+ const cell = this.getCell(q, r);
+ const symbol = this.getCellSymbol(cell);
+ output += `[${symbol}]`;
+ }
+ output += '\n';
+ }
+
+ console.log(output);
+ }
+
+ /**
+ * Get symbol for cell visualization
+ */
+ getCellSymbol(cell) {
+ if (cell.type === CELL_TYPES.BLOCKED) return '██';
+ if (cell.type === CELL_TYPES.PLAYER1) return 'P1';
+ if (cell.type === CELL_TYPES.PLAYER2) return 'P2';
+ if (cell.dice.length > 0) return cell.getStrength().toString().padStart(2, ' ');
+ return ' ';
+ }
+
+ /**
+ * Set cell ownership
+ */
+ setOwner(q, r, playerId) {
+ const cell = this.getCell(q, r);
+ if (cell && cell.isPassable()) {
+ cell.type = playerId === 1 ? CELL_TYPES.PLAYER1 : CELL_TYPES.PLAYER2;
+ return true;
+ }
+ return false;
+ }
+
+ /**
+ * Clear cell ownership
+ */
+ clearOwner(q, r) {
+ const cell = this.getCell(q, r);
+ if (cell) {
+ cell.type = CELL_TYPES.EMPTY;
+ cell.dice = [];
+ return true;
+ }
+ return false;
+ }
+}
+
+// ES Module exports for browser
+export { HexMap, HexCell, CELL_TYPES, MAP_SIZE };
+
+// CommonJS exports for Node.js
+if (typeof module !== 'undefined' && module.exports) {
+ module.exports = { HexMap, HexCell, CELL_TYPES, MAP_SIZE };
+}
diff --git a/test/map.test.js b/test/map.test.js
new file mode 100644
index 0000000..87710d3
--- /dev/null
+++ b/test/map.test.js
@@ -0,0 +1,400 @@
+const { describe, it } = require('node:test');
+const assert = require('node:assert');
+const { HexMap, HexCell, CELL_TYPES, MAP_SIZE } = require('../src/map.js');
+
+describe('HexCell', () => {
+ it('should create a cell with axial coordinates', () => {
+ const cell = new HexCell(3, 5);
+ assert.strictEqual(cell.q, 3);
+ assert.strictEqual(cell.r, 5);
+ assert.strictEqual(cell.type, CELL_TYPES.EMPTY);
+ assert.strictEqual(cell.dice.length, 0);
+ });
+
+ it('should calculate strength correctly', () => {
+ const cell = new HexCell(0, 0);
+
+ // Empty cell has 0 strength
+ assert.strictEqual(cell.getStrength(), 0);
+
+ // Single die with value 4 = strength 4
+ cell.dice = [4];
+ assert.strictEqual(cell.getStrength(), 4);
+
+ // Two dice: [6, 3] = (2-1)*6 + 3 = 9
+ cell.dice = [6, 3];
+ assert.strictEqual(cell.getStrength(), 9);
+
+ // Three dice: [6, 6, 2] = (3-1)*6 + 2 = 14
+ cell.dice = [6, 6, 2];
+ assert.strictEqual(cell.getStrength(), 14);
+ });
+
+ it('should detect max strength', () => {
+ const cell = new HexCell(0, 0);
+ cell.dice = [6, 6, 6, 6, 6, 6, 6, 6];
+ assert.strictEqual(cell.isMaxStrength(), true);
+
+ cell.dice = [6, 6, 6, 6, 6, 6, 6, 5];
+ assert.strictEqual(cell.isMaxStrength(), false);
+ });
+
+ it('should add dice up to limit', () => {
+ const cell = new HexCell(0, 0);
+
+ for (let i = 0; i < 8; i++) {
+ assert.strictEqual(cell.addDie(6), true);
+ }
+
+ // 9th die should fail
+ assert.strictEqual(cell.addDie(6), false);
+ assert.strictEqual(cell.dice.length, 8);
+ });
+
+ it('should set strength correctly', () => {
+ const cell = new HexCell(0, 0);
+
+ cell.setStrength(1);
+ assert.deepStrictEqual(cell.dice, [1]);
+
+ cell.setStrength(6);
+ assert.deepStrictEqual(cell.dice, [6]);
+
+ cell.setStrength(7);
+ assert.deepStrictEqual(cell.dice, [6, 1]);
+
+ cell.setStrength(14);
+ assert.deepStrictEqual(cell.dice, [6, 6, 2]);
+
+ cell.setStrength(0);
+ assert.deepStrictEqual(cell.dice, []);
+ });
+
+ it('should check passability and ownership', () => {
+ const emptyCell = new HexCell(0, 0, CELL_TYPES.EMPTY);
+ const blockedCell = new HexCell(0, 0, CELL_TYPES.BLOCKED);
+ const player1Cell = new HexCell(0, 0, CELL_TYPES.PLAYER1);
+
+ assert.strictEqual(emptyCell.isPassable(), true);
+ assert.strictEqual(blockedCell.isPassable(), false);
+ assert.strictEqual(emptyCell.isOwned(), false);
+ assert.strictEqual(player1Cell.isOwned(), true);
+ assert.strictEqual(emptyCell.getOwner(), 0);
+ assert.strictEqual(player1Cell.getOwner(), 1);
+ });
+});
+
+describe('HexMap', () => {
+ it('should create a 20x20 map by default', () => {
+ const map = new HexMap();
+ assert.strictEqual(map.size, MAP_SIZE);
+ assert.strictEqual(map.cells.size, MAP_SIZE * MAP_SIZE);
+ });
+
+ it('should generate cells with correct coordinates', () => {
+ const map = new HexMap(5);
+
+ for (let q = 0; q < 5; q++) {
+ for (let r = 0; r < 5; r++) {
+ const cell = map.getCell(q, r);
+ assert.ok(cell, `Cell at ${q},${r} should exist`);
+ assert.strictEqual(cell.q, q);
+ assert.strictEqual(cell.r, r);
+ }
+ }
+ });
+
+ it('should have some blocked cells', () => {
+ const map = new HexMap();
+ const blockedCells = Array.from(map.cells.values()).filter(
+ cell => cell.type === CELL_TYPES.BLOCKED
+ );
+
+ // Should have some blocked cells (roughly 15%)
+ assert.ok(blockedCells.length > 0, 'Should have at least one blocked cell');
+ assert.ok(blockedCells.length < map.cells.size / 2, 'Should not have more than 50% blocked');
+ });
+
+ it('should return passable cells', () => {
+ const map = new HexMap(5);
+ const passableCells = map.getPassableCells();
+
+ assert.ok(passableCells.length > 0);
+ passableCells.forEach(cell => {
+ assert.strictEqual(cell.isPassable(), true);
+ });
+ });
+
+ it('should return neighbors correctly', () => {
+ const map = new HexMap(5);
+
+ // 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 centerNeighbors = map.getNeighbors(2, 2);
+ assert.strictEqual(centerNeighbors.length, 6);
+
+ // Corner cell should have 2 neighbors
+ const cornerNeighbors = map.getNeighbors(0, 0);
+ assert.strictEqual(cornerNeighbors.length, 2);
+
+ // Edge cell should have 3-4 neighbors
+ const edgeNeighbors = map.getNeighbors(0, 2);
+ assert.ok(edgeNeighbors.length >= 3 && edgeNeighbors.length <= 4);
+ });
+
+ it('should set and clear ownership', () => {
+ const map = new HexMap(5);
+
+ // Clear the map first
+ map.cells.forEach(cell => {
+ cell.type = CELL_TYPES.EMPTY;
+ });
+
+ // Set ownership on cells that exist
+ assert.strictEqual(map.setOwner(2, 2, 1), true);
+ const cell = map.getCell(2, 2);
+ assert.strictEqual(cell.type, CELL_TYPES.PLAYER1);
+ assert.strictEqual(cell.getOwner(), 1);
+
+ // Set player 2 ownership on adjacent cell (2,3 is a neighbor of 2,2)
+ assert.strictEqual(map.setOwner(2, 3, 2), true);
+ const cell2 = map.getCell(2, 3);
+ assert.strictEqual(cell2.type, CELL_TYPES.PLAYER2);
+ assert.strictEqual(cell2.getOwner(), 2);
+
+ // Clear ownership
+ assert.strictEqual(map.clearOwner(2, 2), true);
+ const clearedCell = map.getCell(2, 2);
+ assert.strictEqual(clearedCell.type, CELL_TYPES.EMPTY);
+ assert.strictEqual(clearedCell.getOwner(), 0);
+ });
+
+ it('should return player cells', () => {
+ const map = new HexMap(5);
+
+ // First clear any existing ownership
+ map.cells.forEach(cell => {
+ cell.type = CELL_TYPES.EMPTY;
+ });
+
+ map.setOwner(0, 0, 1);
+ map.setOwner(0, 1, 1);
+ map.setOwner(1, 0, 2);
+
+ const player1Cells = map.getPlayerCells(1);
+ const player2Cells = map.getPlayerCells(2);
+
+ assert.strictEqual(player1Cells.length, 2);
+ assert.strictEqual(player2Cells.length, 1);
+ });
+
+ it('should calculate supply correctly', () => {
+ const map = new HexMap(5);
+
+ // First clear any existing ownership
+ map.cells.forEach(cell => {
+ cell.type = CELL_TYPES.EMPTY;
+ });
+
+ map.setOwner(0, 0, 1);
+ map.setOwner(0, 1, 1);
+ map.setOwner(1, 0, 1);
+
+ const supply = map.calculateSupply(1);
+ assert.strictEqual(supply, 3); // 3 cells = 3 supply
+ });
+
+ it('should not allow setting owner on blocked cell', () => {
+ const map = new HexMap(5);
+
+ // Find a blocked cell
+ let blockedCell = null;
+ map.cells.forEach(cell => {
+ if (cell.type === CELL_TYPES.BLOCKED) {
+ blockedCell = cell;
+ }
+ });
+
+ if (blockedCell) {
+ assert.strictEqual(map.setOwner(blockedCell.q, blockedCell.r, 1), false);
+ }
+ });
+});
+
+describe('Target Cell Selection Logic', () => {
+ it('should allow attacking adjacent enemy cells', () => {
+ const map = new HexMap(5);
+
+ // Clear map
+ map.cells.forEach(cell => {
+ cell.type = CELL_TYPES.EMPTY;
+ });
+
+ // Set up: player 1 at (2,2), player 2 at adjacent (2,1)
+ // Neighbors of (2,2) in axial coords: (3,2), (3,1), (2,1), (1,2), (1,3), (2,3)
+ map.setOwner(2, 2, 1);
+ map.setOwner(2, 1, 2); // (2,1) is a neighbor of (2,2)
+
+ const p1Cell = map.getCell(2, 2);
+ p1Cell.setStrength(8);
+
+ const p2Cell = map.getCell(2, 1);
+ p2Cell.setStrength(6);
+
+ // Get neighbors of player 1 cell
+ const neighbors = map.getNeighbors(2, 2);
+
+ // Player 2 cell should be in neighbors
+ const hasP2Cell = neighbors.some(n => n.q === 2 && n.r === 1);
+ assert.strictEqual(hasP2Cell, true, 'Enemy cell should be adjacent');
+
+ // Valid target: not blocked and not own
+ // All 6 neighbors are valid because none of them are owned by player 1
+ // (only the center cell 2,2 is owned by player 1, but it's not a neighbor of itself)
+ const validTargets = neighbors.filter(n =>
+ n.type !== CELL_TYPES.BLOCKED && n.getOwner() !== 1
+ );
+
+ // Should have 6 valid targets (one is enemy at 2,1, rest are empty)
+ assert.strictEqual(validTargets.length, 6, 'Should have 6 valid targets (enemy + 5 empty)');
+
+ // Verify enemy cell is in valid targets
+ const enemyInTargets = validTargets.some(n => n.q === 2 && n.r === 1);
+ assert.strictEqual(enemyInTargets, true);
+ });
+
+ it('should allow capturing adjacent empty cells', () => {
+ const map = new HexMap(5);
+
+ // Clear map
+ map.cells.forEach(cell => {
+ cell.type = CELL_TYPES.EMPTY;
+ });
+
+ // Set up: player 1 at (2,2), empty at adjacent (2,3)
+ map.setOwner(2, 2, 1);
+
+ const p1Cell = map.getCell(2, 2);
+ p1Cell.setStrength(8);
+
+ const emptyCell = map.getCell(2, 3);
+ assert.strictEqual(emptyCell.type, CELL_TYPES.EMPTY);
+
+ // Get neighbors of player 1 cell
+ const neighbors = map.getNeighbors(2, 2);
+
+ // Empty cell should be in neighbors and be a valid target
+ const validTargets = neighbors.filter(n =>
+ n.type !== CELL_TYPES.BLOCKED && n.getOwner() !== 1
+ );
+
+ const hasEmptyCell = validTargets.some(n => n.q === 2 && n.r === 3);
+ assert.strictEqual(hasEmptyCell, true, 'Empty cell should be a valid target');
+ });
+
+ it('should NOT allow attacking own cells', () => {
+ const map = new HexMap(5);
+
+ // Clear map
+ map.cells.forEach(cell => {
+ cell.type = CELL_TYPES.EMPTY;
+ });
+
+ // Set up: player 1 owns two adjacent cells
+ map.setOwner(2, 2, 1);
+ map.setOwner(2, 3, 1);
+
+ const p1Cell1 = map.getCell(2, 2);
+ p1Cell1.setStrength(8);
+
+ const p1Cell2 = map.getCell(2, 3);
+ p1Cell2.setStrength(6);
+
+ // Get neighbors of first player 1 cell
+ const neighbors = map.getNeighbors(2, 2);
+
+ // Valid targets should NOT include own cells
+ const validTargets = neighbors.filter(n =>
+ n.type !== CELL_TYPES.BLOCKED && n.getOwner() !== 1
+ );
+
+ const hasOwnCell = validTargets.some(n => n.getOwner() === 1);
+ assert.strictEqual(hasOwnCell, false, 'Own cells should not be valid targets');
+ });
+
+ it('should NOT allow attacking through blocked cells', () => {
+ const map = new HexMap(5);
+
+ // Clear map
+ map.cells.forEach(cell => {
+ cell.type = CELL_TYPES.EMPTY;
+ });
+
+ // Set up: player 1 at (2,2), blocked at adjacent (2,3)
+ map.setOwner(2, 2, 1);
+ map.getCell(2, 3).type = CELL_TYPES.BLOCKED;
+
+ const p1Cell = map.getCell(2, 2);
+ p1Cell.setStrength(8);
+
+ // Get neighbors of player 1 cell
+ const neighbors = map.getNeighbors(2, 2);
+
+ // Blocked cell should NOT be in passable neighbors
+ const hasBlocked = neighbors.some(n => n.type === CELL_TYPES.BLOCKED);
+ assert.strictEqual(hasBlocked, false, 'Blocked cells should not be neighbors');
+ });
+
+ it('should correctly identify all valid targets from center position', () => {
+ const map = new HexMap(5);
+
+ // Clear map
+ map.cells.forEach(cell => {
+ cell.type = CELL_TYPES.EMPTY;
+ });
+
+ // Set up: player 1 at center (2,2)
+ map.setOwner(2, 2, 1);
+ const centerCell = map.getCell(2, 2);
+ centerCell.setStrength(8);
+
+ // Surround with different cell types
+ // (2,1) - empty
+ // (3,1) - enemy (player 2)
+ // (3,2) - blocked
+ // (2,3) - empty
+ // (1,3) - enemy (player 2)
+ // (1,2) - own (player 1)
+ map.setOwner(3, 1, 2);
+ map.getCell(3, 2).type = CELL_TYPES.BLOCKED;
+ map.setOwner(1, 3, 2);
+ map.setOwner(1, 2, 1);
+
+ const neighbors = map.getNeighbors(2, 2);
+
+ // Valid targets: empty cells and enemy cells, not blocked, not own
+ const validTargets = neighbors.filter(n =>
+ n.type !== CELL_TYPES.BLOCKED && n.getOwner() !== 1
+ );
+
+ // Should have 3 valid targets: (2,1) empty, (3,1) enemy, (2,3) empty
+ // (1,3) enemy, but need to check if it's actually a neighbor
+ assert.ok(validTargets.length >= 2, 'Should have at least 2 valid targets');
+
+ // Verify no own cells in valid targets
+ validTargets.forEach(t => {
+ assert.notStrictEqual(t.getOwner(), 1, 'Valid target should not be own cell');
+ });
+
+ // Verify no blocked cells in valid targets
+ validTargets.forEach(t => {
+ assert.notStrictEqual(t.type, CELL_TYPES.BLOCKED, 'Valid target should not be blocked');
+ });
+ });
+});