228 lines
7.3 KiB
JavaScript
228 lines
7.3 KiB
JavaScript
// Pure Mexican Train rules (double-six set). No Phaser dependency.
|
|
//
|
|
// Match structure:
|
|
// - Successive rounds. Each round pulls an "engine" double into the central
|
|
// hub: round 0 -> 6-6, round 1 -> 5-5, ... round 6 -> 0-0, then wraps.
|
|
// - Players play tiles onto trains radiating from the hub. Each player owns
|
|
// one personal train; there is also one communal "Mexican" train.
|
|
// - A round ends when a player empties their hand (scores 0 that round) or
|
|
// the round is blocked (boneyard empty + everyone passed in a row). Each
|
|
// remaining hand's pip total is added to that player's cumulative score.
|
|
// - The match ends when any player's cumulative score reaches the target.
|
|
// LOWEST cumulative score wins.
|
|
//
|
|
// Train access:
|
|
// - You may always play on your own train and the Mexican train.
|
|
// - You may play on another player's train only while it is "open" (marked,
|
|
// because that player couldn't play on their turn).
|
|
// - Playing on your own train removes your marker.
|
|
//
|
|
// Doubles (house rule: must be covered immediately):
|
|
// - Placing a double opens it; the next play (by anyone, in turn) must cover
|
|
// that double before any other play is allowed. The player who laid it
|
|
// keeps the turn to try to cover it themselves.
|
|
|
|
export const SET_MAX = 6; // double-six
|
|
export const MEXICAN = 'mexican';
|
|
|
|
export function makeTileSet() {
|
|
const tiles = [];
|
|
for (let a = 0; a <= SET_MAX; a++) {
|
|
for (let b = a; b <= SET_MAX; b++) tiles.push({ a, b });
|
|
}
|
|
return tiles; // 28 tiles
|
|
}
|
|
|
|
export const tilePips = (t) => t.a + t.b;
|
|
export const isDoubleTile = (t) => t.a === t.b;
|
|
|
|
export function handSizeFor(n) {
|
|
if (n === 2) return 7;
|
|
if (n === 3) return 6;
|
|
return 5; // 4 players
|
|
}
|
|
|
|
export function cloneState(state) {
|
|
return JSON.parse(JSON.stringify(state));
|
|
}
|
|
|
|
function shuffle(arr) {
|
|
for (let i = arr.length - 1; i > 0; i--) {
|
|
const j = Math.floor(Math.random() * (i + 1));
|
|
[arr[i], arr[j]] = [arr[j], arr[i]];
|
|
}
|
|
return arr;
|
|
}
|
|
|
|
// Engine double value for a given round index.
|
|
export function hubValueForRound(roundIndex) {
|
|
return SET_MAX - (roundIndex % (SET_MAX + 1));
|
|
}
|
|
|
|
export function createInitialState({ playerNames, target = 100 }) {
|
|
const players = playerNames.map((p) => ({
|
|
name: p.name,
|
|
isAI: !!p.isAI,
|
|
avatar: p.avatar ?? null,
|
|
hand: [],
|
|
score: 0,
|
|
}));
|
|
const state = {
|
|
players,
|
|
current: 0,
|
|
round: 0,
|
|
target,
|
|
hub: { value: SET_MAX },
|
|
trains: {}, // key -> { tiles: [{left, right}], marker }
|
|
boneyard: [],
|
|
openDouble: null, // { train, value } when a double awaits covering
|
|
phase: 'playing', // playing -> roundover -> (next round) | gameover
|
|
consecutivePasses: 0,
|
|
startPlayer: 0,
|
|
roundWinner: null,
|
|
lastRoundScores: null,
|
|
};
|
|
dealRound(state, 0, 0);
|
|
return state;
|
|
}
|
|
|
|
// Mutates `s` to set up a fresh round. Used internally.
|
|
function dealRound(s, roundIndex, startPlayer) {
|
|
const n = s.players.length;
|
|
const hubVal = hubValueForRound(roundIndex);
|
|
s.hub = { value: hubVal };
|
|
|
|
const tiles = shuffle(makeTileSet().filter((t) => !(t.a === hubVal && t.b === hubVal)));
|
|
const hs = handSizeFor(n);
|
|
s.players.forEach((p) => { p.hand = tiles.splice(0, hs); });
|
|
s.boneyard = tiles;
|
|
|
|
s.trains = {};
|
|
for (let i = 0; i < n; i++) s.trains[String(i)] = { tiles: [], marker: false };
|
|
s.trains[MEXICAN] = { tiles: [], marker: false };
|
|
|
|
s.openDouble = null;
|
|
s.consecutivePasses = 0;
|
|
s.current = startPlayer ?? 0;
|
|
s.phase = 'playing';
|
|
s.roundWinner = null;
|
|
s.lastRoundScores = null;
|
|
}
|
|
|
|
export function startNextRound(state) {
|
|
const s = cloneState(state);
|
|
s.round += 1;
|
|
dealRound(s, s.round, s.startPlayer ?? 0);
|
|
return s;
|
|
}
|
|
|
|
// The currently exposed end value of a train (hub value if empty).
|
|
export function trainOpenEnd(state, key) {
|
|
const tr = state.trains[key];
|
|
if (!tr || tr.tiles.length === 0) return state.hub.value;
|
|
return tr.tiles[tr.tiles.length - 1].right;
|
|
}
|
|
|
|
// Train keys the given player may currently play on.
|
|
export function playableTrainKeys(state, idx) {
|
|
if (state.openDouble) return [state.openDouble.train];
|
|
const keys = [String(idx), MEXICAN];
|
|
for (let j = 0; j < state.players.length; j++) {
|
|
if (j !== idx && state.trains[String(j)]?.marker) keys.push(String(j));
|
|
}
|
|
return keys;
|
|
}
|
|
|
|
// All legal moves for a player: [{ tileIndex, train }].
|
|
export function getLegalMoves(state, idx) {
|
|
if (state.phase !== 'playing') return [];
|
|
const moves = [];
|
|
const hand = state.players[idx].hand;
|
|
for (const key of playableTrainKeys(state, idx)) {
|
|
const end = trainOpenEnd(state, key);
|
|
hand.forEach((t, i) => {
|
|
if (t.a === end || t.b === end) moves.push({ tileIndex: i, train: key });
|
|
});
|
|
}
|
|
return moves;
|
|
}
|
|
|
|
// Place a tile. Returns new state. Advances the turn unless the placed tile is
|
|
// a double (the player keeps the turn to cover it). Ends/score the round if the
|
|
// player goes out.
|
|
export function playTile(state, move) {
|
|
if (state.phase !== 'playing') return state;
|
|
const s = cloneState(state);
|
|
const player = s.players[s.current];
|
|
const tile = player.hand[move.tileIndex];
|
|
if (!tile) return state;
|
|
|
|
const key = move.train;
|
|
const end = trainOpenEnd(s, key);
|
|
if (tile.a !== end && tile.b !== end) return state; // illegal
|
|
|
|
const left = tile.a === end ? tile.a : tile.b;
|
|
const right = tile.a === end ? tile.b : tile.a;
|
|
s.trains[key].tiles.push({ left, right });
|
|
player.hand.splice(move.tileIndex, 1);
|
|
|
|
if (key === String(s.current)) s.trains[key].marker = false; // played own train
|
|
s.consecutivePasses = 0;
|
|
|
|
const placedDouble = left === right;
|
|
s.openDouble = placedDouble ? { train: key, value: right } : null;
|
|
|
|
if (player.hand.length === 0) {
|
|
endRound(s);
|
|
return s;
|
|
}
|
|
if (!placedDouble) advance(s);
|
|
return s;
|
|
}
|
|
|
|
// Draw one tile from the boneyard into the current player's hand.
|
|
export function drawTile(state) {
|
|
if (state.phase !== 'playing' || state.boneyard.length === 0) return state;
|
|
const s = cloneState(state);
|
|
s.players[s.current].hand.push(s.boneyard.pop());
|
|
return s;
|
|
}
|
|
|
|
export const canDraw = (state) => state.boneyard.length > 0;
|
|
|
|
// Current player gives up their turn: marks their own train and advances.
|
|
export function passTurn(state) {
|
|
if (state.phase !== 'playing') return state;
|
|
const s = cloneState(state);
|
|
s.trains[String(s.current)].marker = true;
|
|
s.consecutivePasses += 1;
|
|
advance(s);
|
|
if (s.boneyard.length === 0 && s.consecutivePasses >= s.players.length) {
|
|
endRound(s); // blocked
|
|
}
|
|
return s;
|
|
}
|
|
|
|
function advance(s) {
|
|
s.current = (s.current + 1) % s.players.length;
|
|
}
|
|
|
|
function endRound(s) {
|
|
const roundPts = s.players.map((p) => p.hand.reduce((a, t) => a + t.a + t.b, 0));
|
|
let winner = s.players.findIndex((p) => p.hand.length === 0);
|
|
if (winner === -1) winner = roundPts.indexOf(Math.min(...roundPts)); // blocked
|
|
s.players.forEach((p, i) => { p.score += roundPts[i]; });
|
|
s.lastRoundScores = roundPts;
|
|
s.roundWinner = winner;
|
|
s.startPlayer = winner;
|
|
s.phase = s.players.some((p) => p.score >= s.target) ? 'gameover' : 'roundover';
|
|
}
|
|
|
|
export const isMatchOver = (state) => state.phase === 'gameover';
|
|
|
|
// Match winners: indices tied for the lowest cumulative score.
|
|
export function getWinners(state) {
|
|
const min = Math.min(...state.players.map((p) => p.score));
|
|
return state.players.map((p, i) => ({ i, s: p.score })).filter((x) => x.s === min).map((x) => x.i);
|
|
}
|