220 lines
6.5 KiB
JavaScript
220 lines
6.5 KiB
JavaScript
// 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;
|
||
}
|