fertig-classic-games/public/src/games/parchisi/ParchisiLogic.js

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