fertig-classic-games/public/src/games/chinesecheckers/ChineseCheckersLogic.js

343 lines
11 KiB
JavaScript

// 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;
}