428 lines
15 KiB
JavaScript
428 lines
15 KiB
JavaScript
// Pure Parcheesi (American standard) rules. No Phaser dependency.
|
|
//
|
|
// Board model:
|
|
// - 68-square outer track (indices 0..67), clockwise.
|
|
// - 4 colors with fixed entry squares 17 apart: red=0, blue=17, yellow=34, green=51.
|
|
// - Each color travels 67 outer steps from its entry to its own home-entry
|
|
// (which is the square immediately CCW from the entry: entry-1 mod 68),
|
|
// then 7 squares up its home column, then 1 final step into home.
|
|
// - Total path from entry to home = 75 movement pips.
|
|
//
|
|
// Pawn position is one of:
|
|
// 'nest' | { track: 0..67 } | { home: 0..6 } | 'home'
|
|
//
|
|
// Safe squares: 12 total. The 4 colored entries + 8 white safeties spaced
|
|
// at +7 and +12 from each entry. Pawns cannot be captured on a safe square,
|
|
// EXCEPT a pawn entering the board (leaving the nest with a 5) bops any
|
|
// single opponent occupying its entry square.
|
|
|
|
export const COLORS = ['red', 'blue', 'yellow', 'green'];
|
|
|
|
export const ENTRY = { red: 0, blue: 17, yellow: 34, green: 51 };
|
|
// Home-entry = the LAST outer-track square a pawn occupies before turning
|
|
// into its home column. = (entry + 67) mod 68 = (entry - 1 + 68) mod 68.
|
|
export const HOME_ENTRY = { red: 67, blue: 16, yellow: 33, green: 50 };
|
|
|
|
export const TRACK_LEN = 68;
|
|
export const HOME_COL_LEN = 7;
|
|
export const PAWNS_PER_PLAYER = 4;
|
|
|
|
const SAFE_SET = new Set([
|
|
0, 7, 12,
|
|
17, 24, 29,
|
|
34, 41, 46,
|
|
51, 58, 63,
|
|
]);
|
|
|
|
export function isSafeTrack(idx) {
|
|
return SAFE_SET.has(idx);
|
|
}
|
|
|
|
export function createInitialState(playerOrder = COLORS) {
|
|
const pawns = {};
|
|
for (const c of COLORS) {
|
|
pawns[c] = Array.from({ length: PAWNS_PER_PLAYER }, () => ({ loc: 'nest' }));
|
|
}
|
|
return {
|
|
players: [...playerOrder],
|
|
pawns,
|
|
currentPlayer: playerOrder[0],
|
|
dice: null,
|
|
movesLeft: [], // remaining die values: 1..6, plus bonus 10/20
|
|
consecutiveDoubles: 0,
|
|
lastWasDoubles: false, // if true and dice exhausted, same player re-rolls
|
|
phase: 'roll', // 'roll' | 'move' | 'game_over'
|
|
winner: null,
|
|
log: [], // optional, for surfaces — capped
|
|
};
|
|
}
|
|
|
|
export function cloneState(state) {
|
|
return JSON.parse(JSON.stringify(state));
|
|
}
|
|
|
|
// ─── Dice ──────────────────────────────────────────────────────────────────
|
|
|
|
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];
|
|
const doubles = d1 === d2;
|
|
|
|
if (doubles) {
|
|
s.consecutiveDoubles += 1;
|
|
s.lastWasDoubles = true;
|
|
if (s.consecutiveDoubles >= 3) {
|
|
// Three-doubles penalty: furthest-from-home pawn of current player
|
|
// goes back to the nest; turn ends. No moves played.
|
|
applyThreeDoublesPenalty(s);
|
|
return s;
|
|
}
|
|
const allOut = pawnsInNest(s, s.currentPlayer) === 0;
|
|
if (allOut) {
|
|
// Bonus moves: also use the "back" of each die (1↔6, 2↔5, 3↔4)
|
|
s.movesLeft = [d1, d2, 7 - d1, 7 - d2];
|
|
} else {
|
|
s.movesLeft = [d1, d2];
|
|
}
|
|
} else {
|
|
s.consecutiveDoubles = 0;
|
|
s.lastWasDoubles = false;
|
|
s.movesLeft = [d1, d2];
|
|
}
|
|
s.phase = 'move';
|
|
return s;
|
|
}
|
|
|
|
function applyThreeDoublesPenalty(s) {
|
|
const player = s.currentPlayer;
|
|
// Hasbro rule: "the pawn farthest along on its journey to Home" =
|
|
// the MOST ADVANCED pawn (smallest remaining distance, but >0).
|
|
let bestIdx = -1;
|
|
let bestDist = Infinity;
|
|
for (let i = 0; i < PAWNS_PER_PLAYER; i++) {
|
|
const p = s.pawns[player][i];
|
|
if (p.loc === 'nest' || p.loc === 'home') continue;
|
|
const dist = pawnDistanceToHome(p, player);
|
|
if (dist < bestDist) { bestDist = dist; bestIdx = i; }
|
|
}
|
|
if (bestIdx >= 0) s.pawns[player][bestIdx] = { loc: 'nest' };
|
|
endTurn(s, true);
|
|
}
|
|
|
|
// Mutates state in place — used during sequencing.
|
|
export function endTurn(s, fromPenalty = false) {
|
|
s.dice = null;
|
|
s.movesLeft = [];
|
|
s.lastWasDoubles = false;
|
|
if (fromPenalty) s.consecutiveDoubles = 0;
|
|
const idx = s.players.indexOf(s.currentPlayer);
|
|
s.currentPlayer = s.players[(idx + 1) % s.players.length];
|
|
s.consecutiveDoubles = 0;
|
|
s.phase = 'roll';
|
|
return s;
|
|
}
|
|
|
|
// ─── Geometry helpers ──────────────────────────────────────────────────────
|
|
|
|
// Steps remaining from a pawn's current position to its home circle (>=0).
|
|
// nest = 76 (5 to enter + 75 from entry to home).
|
|
export function pawnDistanceToHome(pawn, color) {
|
|
if (pawn.loc === 'nest') return 76;
|
|
if (pawn.loc === 'home') return 0;
|
|
if (pawn.home !== undefined) return (HOME_COL_LEN - pawn.home); // home: 0..6 → 7..1 then home → 0
|
|
if (pawn.track !== undefined) {
|
|
// Distance from track t to home-entry, then +7 (cols) + 1 (home circle) = +8.
|
|
const homeEntry = HOME_ENTRY[color];
|
|
const d = (homeEntry - pawn.track + TRACK_LEN) % TRACK_LEN;
|
|
return d + 8;
|
|
}
|
|
return 0;
|
|
}
|
|
|
|
function pawnsInNest(s, color) {
|
|
return s.pawns[color].filter((p) => p.loc === 'nest').length;
|
|
}
|
|
|
|
function pawnsHome(s, color) {
|
|
return s.pawns[color].filter((p) => p.loc === 'home').length;
|
|
}
|
|
|
|
function pawnsOnTrack(s) {
|
|
// returns Map<trackIdx, Array<{color, pawnIdx}>>
|
|
const m = new Map();
|
|
for (const c of COLORS) {
|
|
for (let i = 0; i < PAWNS_PER_PLAYER; i++) {
|
|
const p = s.pawns[c][i];
|
|
if (p.track !== undefined) {
|
|
const arr = m.get(p.track) ?? [];
|
|
arr.push({ color: c, pawnIdx: i });
|
|
m.set(p.track, arr);
|
|
}
|
|
}
|
|
}
|
|
return m;
|
|
}
|
|
|
|
// A blockade is 2 same-color pawns on one outer-track square.
|
|
// Returns Set of trackIdx that are blockades.
|
|
function blockadeSet(s) {
|
|
const set = new Set();
|
|
const map = pawnsOnTrack(s);
|
|
for (const [idx, arr] of map) {
|
|
if (arr.length >= 2 && arr.every((p) => p.color === arr[0].color)) {
|
|
set.add(idx);
|
|
}
|
|
}
|
|
return set;
|
|
}
|
|
|
|
// Returns occupant of a track square: null | {color, count}.
|
|
function trackOccupants(s, idx) {
|
|
const arr = [];
|
|
for (const c of COLORS) {
|
|
for (let i = 0; i < PAWNS_PER_PLAYER; i++) {
|
|
if (s.pawns[c][i].track === idx) arr.push({ color: c, pawnIdx: i });
|
|
}
|
|
}
|
|
return arr;
|
|
}
|
|
|
|
// Returns count of pawns of this color in this home-col index.
|
|
function homeColOccupants(s, color, hIdx) {
|
|
return s.pawns[color].filter((p) => p.home === hIdx).length;
|
|
}
|
|
|
|
// ─── Move generation ───────────────────────────────────────────────────────
|
|
|
|
// Build the path squares a pawn would traverse moving `steps` from `from`.
|
|
// Returns an ORDERED array of positions for each step taken (length = steps).
|
|
// The final element is the landing square. Returns null if path is invalid
|
|
// (overshoots home).
|
|
function projectPath(from, color, steps) {
|
|
const path = [];
|
|
let pos = from;
|
|
for (let k = 0; k < steps; k++) {
|
|
if (pos.loc === 'nest') return null; // nest exit handled separately
|
|
if (pos === 'home' || pos.loc === 'home') return null;
|
|
if (pos.home !== undefined) {
|
|
const next = pos.home + 1;
|
|
if (next < HOME_COL_LEN) { pos = { home: next }; path.push(pos); }
|
|
else if (next === HOME_COL_LEN) { pos = { loc: 'home' }; path.push(pos); }
|
|
else return null; // overshoot
|
|
} else {
|
|
// On outer track
|
|
if (pos.track === HOME_ENTRY[color]) {
|
|
pos = { home: 0 };
|
|
path.push(pos);
|
|
} else {
|
|
const nextIdx = (pos.track + 1) % TRACK_LEN;
|
|
pos = { track: nextIdx };
|
|
path.push(pos);
|
|
}
|
|
}
|
|
}
|
|
return path;
|
|
}
|
|
|
|
// Check if a movement path is blocked by any blockade (track squares only).
|
|
// The first step's "from" is NOT included; we examine every square the pawn
|
|
// would *enter*. A blockade on the destination also blocks unless it's the
|
|
// final square AND a same-color blockade isn't an issue — for the standard
|
|
// rule, you cannot land on or pass any blockade (your own or opponents').
|
|
function pathBlocked(path, blockades) {
|
|
for (const sq of path) {
|
|
if (sq.track !== undefined && blockades.has(sq.track)) return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
// Build a Move object describing a pawn step. dieUsed is the die value
|
|
// consumed (1..6, or 10/20 for bonuses).
|
|
function makeMove(player, pawnIdx, from, to, dieUsed, hit = null) {
|
|
const move = { player, pawnIdx, from, to, dieUsed, hit };
|
|
return move;
|
|
}
|
|
|
|
// All legal moves for the current player with currently remaining dice.
|
|
// Returns flat array of single-die moves. Sum-of-5 nest entries are also
|
|
// included as special moves with `combineDice: [d1, d2]` (dieUsed=5).
|
|
export function getValidMoves(s) {
|
|
if (s.phase !== 'move') return [];
|
|
const player = s.currentPlayer;
|
|
const moves = [];
|
|
const blockades = blockadeSet(s);
|
|
const uniqueDice = [...new Set(s.movesLeft)];
|
|
const inNest = s.pawns[player].some((p) => p.loc === 'nest');
|
|
|
|
// 1. Nest exits with a 5 on a single die
|
|
if (inNest && s.movesLeft.includes(5)) {
|
|
const entryIdx = ENTRY[player];
|
|
const occ = trackOccupants(s, entryIdx);
|
|
// Blocked if opponent has 2+ on entry
|
|
const oppCount = occ.filter((o) => o.color !== player).length;
|
|
const ownCount = occ.filter((o) => o.color === player).length;
|
|
const blockedByOppPair = oppCount >= 2;
|
|
const blockedByOwnTriple = ownCount >= 2; // can't make a 3-stack with own pawns
|
|
if (!blockedByOppPair && !blockedByOwnTriple) {
|
|
// Pick the lowest-index pawn still in nest
|
|
const pawnIdx = s.pawns[player].findIndex((p) => p.loc === 'nest');
|
|
// Bop on entry: if exactly one opponent pawn sits on the entry, capture
|
|
let hit = null;
|
|
if (oppCount === 1) {
|
|
const opp = occ.find((o) => o.color !== player);
|
|
hit = { color: opp.color, pawnIdx: opp.pawnIdx, sq: { track: entryIdx } };
|
|
}
|
|
moves.push(makeMove(player, pawnIdx, { loc: 'nest' }, { track: entryIdx }, 5, hit));
|
|
}
|
|
}
|
|
|
|
// 2. Nest exit using BOTH dice summing to 5 (not 5+5 doubles)
|
|
if (inNest && s.dice && s.dice[0] + s.dice[1] === 5 && s.dice[0] !== s.dice[1]
|
|
&& s.movesLeft.includes(s.dice[0]) && s.movesLeft.includes(s.dice[1])) {
|
|
const entryIdx = ENTRY[player];
|
|
const occ = trackOccupants(s, entryIdx);
|
|
const oppCount = occ.filter((o) => o.color !== player).length;
|
|
const ownCount = occ.filter((o) => o.color === player).length;
|
|
if (oppCount < 2 && ownCount < 2) {
|
|
const pawnIdx = s.pawns[player].findIndex((p) => p.loc === 'nest');
|
|
let hit = null;
|
|
if (oppCount === 1) {
|
|
const opp = occ.find((o) => o.color !== player);
|
|
hit = { color: opp.color, pawnIdx: opp.pawnIdx, sq: { track: entryIdx } };
|
|
}
|
|
const mv = makeMove(player, pawnIdx, { loc: 'nest' }, { track: entryIdx }, 5, hit);
|
|
mv.combineDice = [s.dice[0], s.dice[1]];
|
|
moves.push(mv);
|
|
}
|
|
}
|
|
|
|
// 3. Track / home-column pawn movements for each remaining die
|
|
for (let pawnIdx = 0; pawnIdx < PAWNS_PER_PLAYER; pawnIdx++) {
|
|
const p = s.pawns[player][pawnIdx];
|
|
if (p.loc === 'nest' || p.loc === 'home') continue;
|
|
|
|
for (const die of uniqueDice) {
|
|
const path = projectPath(p, player, die);
|
|
if (!path) continue;
|
|
if (pathBlocked(path, blockades)) continue;
|
|
|
|
const dest = path[path.length - 1];
|
|
// Validate landing
|
|
let hit = null;
|
|
if (dest.track !== undefined) {
|
|
const occ = trackOccupants(s, dest.track);
|
|
const own = occ.filter((o) => o.color === player);
|
|
const opp = occ.filter((o) => o.color !== player);
|
|
// Cannot land on opponent's blockade — already filtered by pathBlocked.
|
|
// Cannot land creating 3-stack of own color.
|
|
if (own.length >= 2) continue;
|
|
// Capture: exactly one opponent on a non-safe square
|
|
if (opp.length === 1 && !isSafeTrack(dest.track)) {
|
|
hit = { color: opp[0].color, pawnIdx: opp[0].pawnIdx, sq: { track: dest.track } };
|
|
}
|
|
// Cannot land on safe square already occupied by opponent (mutual safety)
|
|
if (opp.length >= 1 && isSafeTrack(dest.track)) continue;
|
|
if (opp.length >= 2) continue;
|
|
} else if (dest.home !== undefined) {
|
|
// Home column squares are safe; can't share with own pawn (max 1 per square in column).
|
|
if (homeColOccupants(s, player, dest.home) >= 1) continue;
|
|
}
|
|
// Landing in 'home' has no occupancy constraint
|
|
moves.push(makeMove(player, pawnIdx, copyLoc(p), dest, die, hit));
|
|
}
|
|
}
|
|
|
|
return moves;
|
|
}
|
|
|
|
function copyLoc(p) {
|
|
if (p.loc) return { loc: p.loc };
|
|
if (p.track !== undefined) return { track: p.track };
|
|
if (p.home !== undefined) return { home: p.home };
|
|
return p;
|
|
}
|
|
|
|
// ─── Move application ─────────────────────────────────────────────────────
|
|
|
|
// Apply a single move. Returns a NEW state. Handles capture (+20), home (+10),
|
|
// dice consumption, and end-of-turn (with doubles re-roll handling).
|
|
export function applyMove(state, move) {
|
|
const s = cloneState(state);
|
|
const player = move.player;
|
|
|
|
// Update pawn
|
|
s.pawns[player][move.pawnIdx] = locFromMove(move.to);
|
|
|
|
// Process capture
|
|
if (move.hit) {
|
|
s.pawns[move.hit.color][move.hit.pawnIdx] = { loc: 'nest' };
|
|
s.movesLeft.push(20);
|
|
}
|
|
|
|
// Process home arrival
|
|
const arrived = move.to.loc === 'home';
|
|
if (arrived) {
|
|
s.movesLeft.push(10);
|
|
}
|
|
|
|
// Consume die(s)
|
|
if (move.combineDice) {
|
|
for (const d of move.combineDice) {
|
|
const idx = s.movesLeft.indexOf(d);
|
|
if (idx !== -1) s.movesLeft.splice(idx, 1);
|
|
}
|
|
} else {
|
|
const idx = s.movesLeft.indexOf(move.dieUsed);
|
|
if (idx !== -1) s.movesLeft.splice(idx, 1);
|
|
}
|
|
|
|
// Win check
|
|
if (pawnsHome(s, player) >= PAWNS_PER_PLAYER) {
|
|
s.winner = player;
|
|
s.phase = 'game_over';
|
|
return s;
|
|
}
|
|
|
|
// End-of-turn handling
|
|
if (s.movesLeft.length === 0 || !hasAnyMove(s)) {
|
|
if (s.lastWasDoubles && s.consecutiveDoubles < 3) {
|
|
// Doubles → same player re-rolls
|
|
s.dice = null;
|
|
s.movesLeft = [];
|
|
s.lastWasDoubles = false;
|
|
s.phase = 'roll';
|
|
} else {
|
|
endTurn(s);
|
|
}
|
|
}
|
|
return s;
|
|
}
|
|
|
|
function locFromMove(to) {
|
|
if (to.loc === 'home') return { loc: 'home' };
|
|
if (to.track !== undefined) return { track: to.track };
|
|
if (to.home !== undefined) return { home: to.home };
|
|
return to;
|
|
}
|
|
|
|
export function hasAnyMove(s) {
|
|
return getValidMoves(s).length > 0;
|
|
}
|
|
|
|
// ─── Helpers exposed for AI / UI ──────────────────────────────────────────
|
|
|
|
export { pawnsHome, pawnsInNest, blockadeSet, trackOccupants };
|
|
|
|
// Compute total pip distance for a color (lower = better progress).
|
|
export function totalPipsRemaining(s, color) {
|
|
let total = 0;
|
|
for (const p of s.pawns[color]) total += pawnDistanceToHome(p, color);
|
|
return total;
|
|
}
|