// Pure game logic — no Phaser dependency. // Convention: point indices 0–23 (0 = point 1, White's home ace-point). // White moves high→low (23→0), bears off past index 0. // Black moves low→high (0→23), bears off past index 23. // White home board: indices 0–5. Black home board: indices 18–23. export function createInitialState() { const points = Array.from({ length: 24 }, () => ({ color: null, count: 0 })); points[23] = { color: 'white', count: 2 }; points[12] = { color: 'white', count: 5 }; points[7] = { color: 'white', count: 3 }; points[5] = { color: 'white', count: 5 }; points[0] = { color: 'black', count: 2 }; points[11] = { color: 'black', count: 5 }; points[16] = { color: 'black', count: 3 }; points[18] = { color: 'black', count: 5 }; return { points, bar: { white: 0, black: 0 }, borneOff: { white: 0, black: 0 }, dice: null, movesLeft: [], currentPlayer: 'white', phase: 'roll', winner: null, }; } export function cloneState(state) { return JSON.parse(JSON.stringify(state)); } export function rollDice(state) { const d1 = Math.ceil(Math.random() * 6); const d2 = Math.ceil(Math.random() * 6); return rollSpecificDice(state, d1, d2); } export function rollSpecificDice(state, d1, d2) { const s = cloneState(state); s.dice = [d1, d2]; s.movesLeft = d1 === d2 ? [d1, d1, d1, d1] : [d1, d2]; s.phase = 'move'; return s; } export function endTurn(state) { const s = cloneState(state); s.currentPlayer = s.currentPlayer === 'white' ? 'black' : 'white'; s.dice = null; s.movesLeft = []; s.phase = 'roll'; return s; } export function allCheckersInHome(state, player) { if (state.bar[player] > 0) return false; const [homeMin, homeMax] = player === 'white' ? [0, 5] : [18, 23]; for (let i = 0; i < 24; i++) { if (i < homeMin || i > homeMax) { if (state.points[i].color === player && state.points[i].count > 0) return false; } } return true; } // Furthest checker from the bear-off edge (by index): // White: highest index in 0–5 (index 5 = 6 pips away) // Black: lowest index in 18–23 (index 18 = 6 pips away) function furthestHomeChecker(state, player) { const { points } = state; if (player === 'white') { for (let i = 5; i >= 0; i--) { if (points[i].color === 'white' && points[i].count > 0) return i; } } else { for (let i = 18; i <= 23; i++) { if (points[i].color === 'black' && points[i].count > 0) return i; } } return -1; } export function getValidMoves(state) { const { currentPlayer: player, movesLeft, points, bar } = state; const opp = player === 'white' ? 'black' : 'white'; const uniqueDice = [...new Set(movesLeft)]; const inBearOff = allCheckersInHome(state, player); const moves = []; const isBlocked = (idx) => points[idx]?.color === opp && points[idx].count >= 2; const isHit = (idx) => points[idx]?.color === opp && points[idx].count === 1; // Bar entry takes priority — only these moves if bar has checkers if (bar[player] > 0) { for (const die of uniqueDice) { // White enters opposite side: die 1 → index 23, die 6 → index 18 // Black enters near side: die 1 → index 0, die 6 → index 5 const entryIdx = player === 'white' ? (24 - die) : (die - 1); if (!isBlocked(entryIdx)) { moves.push({ from: 'bar', to: entryIdx, dieUsed: die, hit: isHit(entryIdx) }); } } return dedup(moves); } for (const die of uniqueDice) { for (let i = 0; i < 24; i++) { if (points[i].color !== player || points[i].count === 0) continue; if (player === 'white') { const dest = i - die; if (dest >= 0) { if (!isBlocked(dest)) moves.push({ from: i, to: dest, dieUsed: die, hit: isHit(dest) }); } else if (inBearOff) { if (dest === -1) { // Exact bear-off (die == i + 1) moves.push({ from: i, to: 'off', dieUsed: die, hit: false }); } else { // Overshoot — only the furthest checker may be borne off if (furthestHomeChecker(state, 'white') === i) { moves.push({ from: i, to: 'off', dieUsed: die, hit: false }); } } } } else { const dest = i + die; if (dest <= 23) { if (!isBlocked(dest)) moves.push({ from: i, to: dest, dieUsed: die, hit: isHit(dest) }); } else if (inBearOff) { if (dest === 24) { // Exact bear-off (die == 24 - i) moves.push({ from: i, to: 'off', dieUsed: die, hit: false }); } else { // Overshoot if (furthestHomeChecker(state, 'black') === i) { moves.push({ from: i, to: 'off', dieUsed: die, hit: false }); } } } } } } return dedup(moves); } function dedup(moves) { const seen = new Set(); return moves.filter((m) => { const key = `${m.from}|${m.to}|${m.dieUsed}`; if (seen.has(key)) return false; seen.add(key); return true; }); } export function applyMove(state, move) { let s = cloneState(state); const player = s.currentPlayer; const opp = player === 'white' ? 'black' : 'white'; // Remove from source if (move.from === 'bar') { s.bar[player] = Math.max(0, s.bar[player] - 1); } else { s.points[move.from].count--; if (s.points[move.from].count === 0) s.points[move.from].color = null; } // Handle hit — send opponent blot to bar if (move.hit && move.to !== 'off') { s.points[move.to].count = 0; s.points[move.to].color = null; s.bar[opp]++; } // Place on destination if (move.to === 'off') { s.borneOff[player]++; } else { s.points[move.to].color = player; s.points[move.to].count++; } // Consume the die const dieIdx = s.movesLeft.indexOf(move.dieUsed); if (dieIdx !== -1) s.movesLeft.splice(dieIdx, 1); // Win check if (s.borneOff[player] >= 15) { s.winner = player; s.phase = 'game_over'; return s; } // Auto-end turn if no dice remain or no legal moves remain if (s.movesLeft.length === 0 || getValidMoves(s).length === 0) { s = endTurn(s); } return s; } export function hasAnyMove(state) { return getValidMoves(state).length > 0; } export function computePipCount(state, player) { let pips = 0; for (let i = 0; i < 24; i++) { const pt = state.points[i]; if (pt.color !== player || pt.count === 0) continue; const dist = player === 'white' ? (i + 1) : (24 - i); pips += dist * pt.count; } pips += state.bar[player] * 25; return pips; }