fertig-classic-games/public/src/games/mexicantrain/MexicanTrainLogic.js

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