// Pure Chinese Checkers rules. No Phaser dependency. // // Board model: // - 121 holes on a 6-pointed star, addressed in axial hex coords (q, r). // - Inner hexagon of radius 4 (61 cells) + 6 triangular points of side 4 // (10 cells each), one per hex direction. // - 6 colors, one per triangle. Each player has 10 pegs starting in their // home triangle and must fill the OPPOSITE triangle (their target). // // A cell (q, r) is on the board iff at least 2 of {|q|, |r|, |s|} // (where s = -q-r) are ≤ 4. Equivalently the inner hex plus 6 points where // one axis is in (4, 8]. // // Move types per turn (mutually exclusive): // 1. Step — one hex to an adjacent empty cell. // 2. Jump chain — over one or more adjacent pegs (own or opponent) to the // empty cell directly on the other side, repeatable from each landing. // // Enforced rules: // - A peg may never come to REST in another player's home or target triangle // (jumping THROUGH is allowed). // - Once a peg has left its own home triangle, it may not re-enter it. // - A peg in its own target triangle may only move within the target. export const COLORS = ['red', 'yellow', 'green', 'blue', 'orange', 'purple']; // Triangle ids correspond to which axis-extreme defines the point. // Pointy-top hex coords with +q east, +r south-east: // r- = top/north, r+ = south // q+ = NE, q- = SW // s- = SE, s+ = NW export const TRIANGLES = { 'r-': [[1,-5],[2,-5],[3,-5],[4,-5],[2,-6],[3,-6],[4,-6],[3,-7],[4,-7],[4,-8]], 'r+': [[-1,5],[-2,5],[-3,5],[-4,5],[-2,6],[-3,6],[-4,6],[-3,7],[-4,7],[-4,8]], 'q+': [[5,-1],[5,-2],[5,-3],[5,-4],[6,-2],[6,-3],[6,-4],[7,-3],[7,-4],[8,-4]], 'q-': [[-5,1],[-5,2],[-5,3],[-5,4],[-6,2],[-6,3],[-6,4],[-7,3],[-7,4],[-8,4]], 's+': [[-1,-4],[-2,-3],[-3,-2],[-4,-1],[-2,-4],[-3,-3],[-4,-2],[-3,-4],[-4,-3],[-4,-4]], 's-': [[1,4],[2,3],[3,2],[4,1],[2,4],[3,3],[4,2],[3,4],[4,3],[4,4]], }; export const COLOR_HOME = { red: 'r-', yellow: 'q+', green: 's-', blue: 'r+', orange: 'q-', purple: 's+', }; export const COLOR_TARGET = { red: 'r+', yellow: 'q-', green: 's+', blue: 'r-', orange: 'q+', purple: 's-', }; export const PEGS_PER_PLAYER = 10; export const cellKey = (q, r) => `${q},${r}`; // ── Static board topology ─────────────────────────────────────────────────── const BOARD_CELLS = new Set(); const TRIANGLE_OF = new Map(); // key -> 'center' | triangle id (function buildBoard() { // Inner hexagon (radius 4): max(|q|,|r|,|s|) ≤ 4. for (let q = -4; q <= 4; q++) { const rMin = Math.max(-4, -q - 4); const rMax = Math.min(4, -q + 4); for (let r = rMin; r <= rMax; r++) { BOARD_CELLS.add(cellKey(q, r)); TRIANGLE_OF.set(cellKey(q, r), 'center'); } } for (const [tri, cells] of Object.entries(TRIANGLES)) { for (const [q, r] of cells) { BOARD_CELLS.add(cellKey(q, r)); TRIANGLE_OF.set(cellKey(q, r), tri); } } })(); export function isOnBoard(q, r) { return BOARD_CELLS.has(cellKey(q, r)); } export function triangleAt(q, r) { return TRIANGLE_OF.get(cellKey(q, r)) ?? null; } export function allCells() { return [...BOARD_CELLS].map((k) => { const [q, r] = k.split(',').map(Number); return { q, r }; }); } // Six axial neighbor offsets. const NEIGHBOR_OFFSETS = [ [ 1, 0], [-1, 0], [ 0, 1], [ 0, -1], [ 1, -1], [-1, 1], ]; const NEIGHBORS = new Map(); for (const k of BOARD_CELLS) { const [q, r] = k.split(',').map(Number); const arr = []; for (const [dq, dr] of NEIGHBOR_OFFSETS) { if (isOnBoard(q + dq, r + dr)) arr.push([q + dq, r + dr]); } NEIGHBORS.set(k, arr); } // Triangle centroid (for AI heuristic). export const TRIANGLE_CENTROID = {}; for (const [id, cells] of Object.entries(TRIANGLES)) { let sq = 0, sr = 0; for (const [q, r] of cells) { sq += q; sr += r; } TRIANGLE_CENTROID[id] = { q: sq / cells.length, r: sr / cells.length }; } export function hexDistance(q1, r1, q2, r2) { return (Math.abs(q1 - q2) + Math.abs(r1 - r2) + Math.abs((q1 + r1) - (q2 + r2))) / 2; } // ── State ────────────────────────────────────────────────────────────────── export function createInitialState(seatColors = COLORS) { if (seatColors.length !== 6) { throw new Error('Chinese Checkers requires exactly 6 seats.'); } const pegs = {}; const leftHome = {}; for (const color of seatColors) { const home = TRIANGLES[COLOR_HOME[color]]; pegs[color] = home.map(([q, r]) => ({ q, r })); leftHome[color] = Array(PEGS_PER_PLAYER).fill(false); } return { seatColors: [...seatColors], pegs, leftHome, currentSeat: 0, phase: 'play', // 'play' | 'game_over' winner: null, finishedOrder: [], // colors in finishing order lastMove: null, }; } export function cloneState(state) { return { seatColors: [...state.seatColors], pegs: Object.fromEntries( state.seatColors.map((c) => [c, state.pegs[c].map((p) => ({ q: p.q, r: p.r }))]), ), leftHome: Object.fromEntries( state.seatColors.map((c) => [c, [...state.leftHome[c]]]), ), currentSeat: state.currentSeat, phase: state.phase, winner: state.winner, finishedOrder: [...state.finishedOrder], lastMove: state.lastMove ? { ...state.lastMove, path: state.lastMove.path.map((p) => ({ ...p })) } : null, }; } export const currentColor = (state) => state.seatColors[state.currentSeat]; export function pegAt(state, q, r) { for (const color of state.seatColors) { const arr = state.pegs[color]; for (let i = 0; i < arr.length; i++) { if (arr[i].q === q && arr[i].r === r) return { color, pegIdx: i }; } } return null; } function buildOccupiedSet(state) { const occ = new Set(); for (const color of state.seatColors) { for (const p of state.pegs[color]) occ.add(cellKey(p.q, p.r)); } return occ; } // ── Move generation ──────────────────────────────────────────────────────── // All legal destinations for one peg. Each move: // { color, pegIdx, q, r, isJump, path: [{q,r}, ...] } // where path[0] is the peg's start and the last entry is the landing. export function getMovesForPeg(state, color, pegIdx) { if (state.phase !== 'play') return []; if (state.finishedOrder.includes(color)) return []; const peg = state.pegs[color][pegIdx]; const startKey = cellKey(peg.q, peg.r); const startTri = triangleAt(peg.q, peg.r); const targetTri = COLOR_TARGET[color]; const homeTri = COLOR_HOME[color]; const pegInTarget = startTri === targetTri; // Treat the peg's own start square as empty when projecting jumps — // it has vacated for the duration of the move. const occ = buildOccupiedSet(state); occ.delete(startKey); const isLegalRest = (q, r) => { if (pegInTarget) { // Pegs already in target may only land in target. return triangleAt(q, r) === targetTri; } const tri = triangleAt(q, r); if (tri === 'center') return true; if (tri === targetTri) return true; if (tri === homeTri) { // Once peg has left home, can't return. return !state.leftHome[color][pegIdx]; } // Any other player's home/target. return false; }; const moves = []; // 1. Single step. for (const [nq, nr] of NEIGHBORS.get(startKey)) { if (occ.has(cellKey(nq, nr))) continue; if (!isLegalRest(nq, nr)) continue; moves.push({ color, pegIdx, q: nq, r: nr, isJump: false, path: [{ q: peg.q, r: peg.r }, { q: nq, r: nr }], }); } // 2. Jump chain — BFS over reachable empty landings via jump-over-occupied. const visited = new Set([startKey]); const queue = [{ q: peg.q, r: peg.r, path: [{ q: peg.q, r: peg.r }] }]; while (queue.length) { const node = queue.shift(); for (const [dq, dr] of NEIGHBOR_OFFSETS) { const midQ = node.q + dq, midR = node.r + dr; const landQ = node.q + 2 * dq, landR = node.r + 2 * dr; if (!isOnBoard(landQ, landR)) continue; const midKey = cellKey(midQ, midR); const landKey = cellKey(landQ, landR); if (!occ.has(midKey)) continue; // need a peg to jump over if (occ.has(landKey)) continue; // landing must be empty if (visited.has(landKey)) continue; // avoid loops visited.add(landKey); const newPath = [...node.path, { q: landQ, r: landR }]; if (isLegalRest(landQ, landR)) { moves.push({ color, pegIdx, q: landQ, r: landR, isJump: true, path: newPath, }); } // Continue BFS even if this landing can't be a rest — chain may exit // back into legal territory. queue.push({ q: landQ, r: landR, path: newPath }); } } return moves; } export function getAllValidMoves(state, color = currentColor(state)) { const out = []; for (let i = 0; i < PEGS_PER_PLAYER; i++) { for (const m of getMovesForPeg(state, color, i)) out.push(m); } return out; } export function hasAnyMove(state, color = currentColor(state)) { for (let i = 0; i < PEGS_PER_PLAYER; i++) { if (getMovesForPeg(state, color, i).length > 0) return true; } return false; } // ── Move application ─────────────────────────────────────────────────────── export function applyMove(state, move) { const s = cloneState(state); const peg = s.pegs[move.color][move.pegIdx]; peg.q = move.q; peg.r = move.r; if (triangleAt(peg.q, peg.r) !== COLOR_HOME[move.color]) { s.leftHome[move.color][move.pegIdx] = true; } s.lastMove = { color: move.color, pegIdx: move.pegIdx, path: move.path.map((p) => ({ q: p.q, r: p.r })), }; if (isColorFinished(s, move.color) && !s.finishedOrder.includes(move.color)) { s.finishedOrder.push(move.color); if (s.winner === null) s.winner = move.color; } if (s.finishedOrder.length >= s.seatColors.length - 1) { s.phase = 'game_over'; return s; } // Advance turn, skipping any seats that have already finished. do { s.currentSeat = (s.currentSeat + 1) % s.seatColors.length; } while (s.finishedOrder.includes(s.seatColors[s.currentSeat])); return s; } export function isColorFinished(state, color) { const target = COLOR_TARGET[color]; return state.pegs[color].every((p) => triangleAt(p.q, p.r) === target); } export function passTurn(state) { // Used when a player has zero legal moves (extremely rare in CC). const s = cloneState(state); if (s.phase !== 'play') return s; do { s.currentSeat = (s.currentSeat + 1) % s.seatColors.length; } while (s.finishedOrder.includes(s.seatColors[s.currentSeat])); return s; }