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