// Mahjong (Hong Kong style) — pure game engine. No Phaser, no timers. // Deterministic given a seed so AI self-play is reproducible. // // One session is one full East round: every player deals at least once; the // dealer repeats after a dealer win or a wall-exhausted draw (goulash). Chow // claims come only from the player to the left; pung/kong/win from anyone. // Flowers and seasons are revealed and replaced immediately. Kong and bonus // replacement tiles draw from the back of the live wall (no reserved dead // wall). Deliberately out of scope: robbing the kong, rare limit hands other // than Thirteen Orphans, and sacred-discard etiquette rules. // // Hands are stored merged: while a player is awaiting discard their hand // holds 14 − 3·melds tiles (the drawn tile already in it, with state.drawnTile // recording which kind arrived); otherwise 13 − 3·melds. import { buildWall, isSuited, isHonor, isBonus, rankOf, WIND_KINDS, DRAGON_KINDS, FIRST_BONUS, FAAN_BY_ID, MIN_FAAN, LIMIT_FAAN, basePoints, } from './MahjongData.js'; // ── seeded RNG (mulberry32) ────────────────────────────────────────────────── function mulberry32(seed) { let a = seed >>> 0; return function () { a |= 0; a = (a + 0x6d2b79f5) | 0; let t = Math.imul(a ^ (a >>> 15), 1 | a); t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; return ((t ^ (t >>> 14)) >>> 0) / 4294967296; }; } // ── small helpers ──────────────────────────────────────────────────────────── export function counts34(tiles) { const c = new Array(34).fill(0); for (const k of tiles) c[k]++; return c; } const sortHand = (hand) => hand.sort((a, b) => a - b); export const seatWindOf = (state, seat) => (seat - state.dealer + 4) % 4; // ── state ──────────────────────────────────────────────────────────────────── export function createInitialState({ names = [], skills = {}, seed } = {}) { const rng = mulberry32((seed ?? (Date.now() & 0x7fffffff)) | 0); const players = []; for (let seat = 0; seat < 4; seat++) { players.push({ name: names[seat] ?? `Player ${seat + 1}`, seat, score: 0, skill: skills[seat] ?? 3, isAI: seat !== 0, hand: [], melds: [], // { type: 'chow'|'pung'|'kong', kinds, concealed, from } bonus: [], // revealed flowers / seasons }); } const state = { players, wall: [], wallPos: 0, wallEnd: 143, dealer: 0, roundWind: 0, // East round only — one full round per session handNumber: 1, turn: 0, phase: 'awaitDiscard', // awaitDiscard | awaitClaims | handOver | gameOver drawnTile: null, lastDrawWasKongReplacement: false, lastDiscard: null, // { kind, from } discards: [[], [], [], []], result: null, winners: [], _rng: rng, }; dealHand(state); return state; } export function cloneState(s) { const rngState = s._rng; // functions don't clone; lookahead must not draw tiles const copy = JSON.parse(JSON.stringify({ ...s, _rng: undefined })); copy._rng = rngState; return copy; } export function dealHand(state) { const rng = state._rng; const wall = buildWall(); for (let i = wall.length - 1; i > 0; i--) { const j = Math.floor(rng() * (i + 1)); [wall[i], wall[j]] = [wall[j], wall[i]]; } state.wall = wall; state.wallPos = 0; state.wallEnd = wall.length - 1; state.discards = [[], [], [], []]; state.result = null; state.lastDiscard = null; state.drawnTile = null; state.lastDrawWasKongReplacement = false; for (const p of state.players) { p.hand = []; p.melds = []; p.bonus = []; } // 13 tiles each (dealer first), then reveal and replace bonus tiles in the // same order. The wall cannot run out during the deal. for (let i = 0; i < 4; i++) { const p = state.players[(state.dealer + i) % 4]; for (let n = 0; n < 13; n++) p.hand.push(state.wall[state.wallPos++]); } for (let i = 0; i < 4; i++) { const p = state.players[(state.dealer + i) % 4]; for (let h = 0; h < p.hand.length; h++) { while (isBonus(p.hand[h])) { p.bonus.push(p.hand[h]); p.hand[h] = state.wall[state.wallEnd--]; } } sortHand(p.hand); } state.turn = state.dealer; drawForCurrent(state); // dealer's 14th tile return state; } // Draw from the front of the wall for the player on turn. Bonus tiles are // revealed and replaced from the back. Ends the hand in a draw when the wall // is exhausted. export function drawForCurrent(state) { if (state.wallPos > state.wallEnd) return endInDraw(state); const p = state.players[state.turn]; let k = state.wall[state.wallPos++]; while (isBonus(k)) { p.bonus.push(k); if (state.wallPos > state.wallEnd) return endInDraw(state); k = state.wall[state.wallEnd--]; } p.hand.push(k); sortHand(p.hand); state.drawnTile = k; state.lastDrawWasKongReplacement = false; state.phase = 'awaitDiscard'; return state; } // Replacement draw after a kong — from the back of the wall. function drawReplacement(state) { const p = state.players[state.turn]; for (;;) { if (state.wallPos > state.wallEnd) return endInDraw(state); const k = state.wall[state.wallEnd--]; if (isBonus(k)) { p.bonus.push(k); continue; } p.hand.push(k); sortHand(p.hand); state.drawnTile = k; state.lastDrawWasKongReplacement = true; state.phase = 'awaitDiscard'; return state; } } function endInDraw(state) { state.drawnTile = null; state.result = { type: 'draw' }; state.phase = 'handOver'; return state; } const removeFromHand = (hand, kind, n) => { for (let i = 0; i < n; i++) hand.splice(hand.indexOf(kind), 1); }; // Discard `kind` from the hand of the player on turn. Returns false if the // tile isn't held. export function discardTile(state, kind) { if (state.phase !== 'awaitDiscard') return false; const p = state.players[state.turn]; if (!p.hand.includes(kind)) return false; removeFromHand(p.hand, kind, 1); state.discards[state.turn].push(kind); state.lastDiscard = { kind, from: state.turn }; state.drawnTile = null; state.lastDrawWasKongReplacement = false; state.phase = 'awaitClaims'; return true; } // What `seat` may do with the tile just discarded. Null when nothing. // Chows only off the player to the left. Win is only offered when the hand // would score at least MIN_FAAN. export function claimOptionsFor(state, seat) { if (state.phase !== 'awaitClaims' || !state.lastDiscard) return null; const { kind, from } = state.lastDiscard; if (seat === from) return null; const p = state.players[seat]; const n = p.hand.filter((t) => t === kind).length; const opts = { win: false, pung: n >= 2, kong: n >= 3, chows: [] }; if (seat === (from + 1) % 4 && isSuited(kind)) { const r = rankOf(kind); const has = (kk) => p.hand.includes(kk); if (r >= 3 && has(kind - 2) && has(kind - 1)) opts.chows.push([kind - 2, kind - 1]); if (r >= 2 && r <= 8 && has(kind - 1) && has(kind + 1)) opts.chows.push([kind - 1, kind + 1]); if (r <= 7 && has(kind + 1) && has(kind + 2)) opts.chows.push([kind + 1, kind + 2]); } if (canWinWith(p.hand, p.melds, kind)) { opts.win = evaluateWin(state, seat, kind, true).faan >= MIN_FAAN; } return (opts.win || opts.pung || opts.kong || opts.chows.length) ? opts : null; } // Resolve all claim intents on the current discard. intents is an array of // { seat, claim } where claim is null (pass) or { type, tiles? }. Priority: // win beats kong/pung beats chow; among multiple wins the seat nearest // counter-clockwise from the discarder takes it (one-winner rule). With no // claims the next player simply draws. Returns { applied } for animation. export function resolveClaims(state, intents) { if (state.phase !== 'awaitClaims' || !state.lastDiscard) return { applied: null }; const { kind, from } = state.lastDiscard; const bySeat = new Map(); for (const i of intents) if (i && i.claim) bySeat.set(i.seat, i.claim); let chosen = null; for (let d = 1; d <= 3 && !chosen; d++) { const s = (from + d) % 4; if (bySeat.get(s)?.type === 'win') chosen = { seat: s, claim: bySeat.get(s) }; } if (!chosen) { for (const [s, c] of bySeat) { if (c.type === 'kong' || c.type === 'pung') { chosen = { seat: s, claim: c }; break; } } } if (!chosen) { for (const [s, c] of bySeat) if (c.type === 'chow') { chosen = { seat: s, claim: c }; break; } } if (!chosen) { state.lastDiscard = null; state.turn = (from + 1) % 4; drawForCurrent(state); return { applied: null }; } const p = state.players[chosen.seat]; const c = chosen.claim; if (c.type === 'win') { declareWin(state, chosen.seat, { byDiscard: true }); } else if (c.type === 'pung') { state.discards[from].pop(); removeFromHand(p.hand, kind, 2); p.melds.push({ type: 'pung', kinds: [kind, kind, kind], concealed: false, from }); state.turn = chosen.seat; state.drawnTile = null; state.lastDiscard = null; state.phase = 'awaitDiscard'; } else if (c.type === 'kong') { state.discards[from].pop(); removeFromHand(p.hand, kind, 3); p.melds.push({ type: 'kong', kinds: [kind, kind, kind, kind], concealed: false, from }); state.turn = chosen.seat; state.lastDiscard = null; drawReplacement(state); } else { // chow state.discards[from].pop(); const [a, b] = c.tiles; removeFromHand(p.hand, a, 1); removeFromHand(p.hand, b, 1); p.melds.push({ type: 'chow', kinds: [a, b, kind].sort((x, y) => x - y), concealed: false, from }); state.turn = chosen.seat; state.drawnTile = null; state.lastDiscard = null; state.phase = 'awaitDiscard'; } return { applied: chosen }; } // Options for the player on turn while awaiting discard: self-drawn win and // kong declarations. Self-win is only possible on a drawn tile (a hand // completed by claiming would have been won off the discard instead). export function selfActions(state) { const res = { canWin: false, concealedKongs: [], addedKongs: [] }; if (state.phase !== 'awaitDiscard') return res; const p = state.players[state.turn]; const c = counts34(p.hand); for (let k = 0; k < 34; k++) if (c[k] === 4) res.concealedKongs.push(k); for (const m of p.melds) { if (m.type === 'pung' && p.hand.includes(m.kinds[0])) res.addedKongs.push(m.kinds[0]); } if (state.drawnTile !== null) { const need = 4 - p.melds.length; if (isThirteenOrphans(c) && p.melds.length === 0) { res.canWin = evaluateWin(state, state.turn, state.drawnTile, false).faan >= MIN_FAAN; } else if (decompose(c, need).length > 0) { res.canWin = evaluateWin(state, state.turn, state.drawnTile, false).faan >= MIN_FAAN; } } return res; } // Declare a concealed or added kong for the player on turn, then draw the // replacement tile. (Exposed kongs of a discard go through resolveClaims.) export function declareKong(state, { type, kind }) { if (state.phase !== 'awaitDiscard') return false; const p = state.players[state.turn]; if (type === 'concealed') { if (p.hand.filter((t) => t === kind).length !== 4) return false; removeFromHand(p.hand, kind, 4); p.melds.push({ type: 'kong', kinds: [kind, kind, kind, kind], concealed: true, from: null }); } else if (type === 'added') { const m = p.melds.find((x) => x.type === 'pung' && x.kinds[0] === kind); if (!m || !p.hand.includes(kind)) return false; removeFromHand(p.hand, kind, 1); m.type = 'kong'; m.kinds.push(kind); } else { return false; } drawReplacement(state); return true; } // Declare a win for `seat` — self-drawn or off the current discard. Applies // payments and ends the hand. export function declareWin(state, seat, { byDiscard }) { const winTile = byDiscard ? state.lastDiscard.kind : state.drawnTile; const res = evaluateWin(state, seat, winTile, byDiscard); const discarder = byDiscard ? state.lastDiscard.from : null; if (byDiscard) state.discards[discarder].pop(); for (let s = 0; s < 4; s++) state.players[s].score += res.payments[s]; state.result = { type: 'win', winner: seat, byDiscard, discarder, winTile, faanList: res.faanList, faan: res.faan, base: res.base, payments: res.payments, }; state.lastDiscard = null; state.phase = 'handOver'; return state; } // Advance to the next hand. The dealer repeats after a dealer win or a drawn // hand; otherwise the deal passes on, and once the fourth dealer's turn ends // the round — and the session — is over. export function startNextHand(state) { if (state.phase !== 'handOver') return false; const r = state.result; const repeat = r.type === 'draw' || (r.type === 'win' && r.winner === state.dealer); if (!repeat) { if (state.dealer === 3) { state.phase = 'gameOver'; state.winners = computeWinners(state); return true; } state.dealer += 1; } state.handNumber += 1; dealHand(state); return true; } function computeWinners(state) { let max = -Infinity; state.players.forEach((p) => { if (p.score > max) max = p.score; }); return state.players.filter((p) => p.score === max).map((p) => p.seat); } export function isGameOver(state) { return state.phase === 'gameOver'; } export function getWinners(state) { return state.winners; } // ── hand evaluation ────────────────────────────────────────────────────────── // All ways to read `counts` as `need` sets plus a pair. Returns an array of // { pair, sets: [{ type: 'chow'|'pung', kind }] } — possibly with duplicate // readings, which is harmless since the evaluator takes the max-faan one. export function decompose(counts, need = 4) { const total = counts.reduce((a, b) => a + b, 0); if (total !== need * 3 + 2) return []; const out = []; const c = counts.slice(); const sets = []; let pairKind = -1; const rec = (start) => { let k = start; while (k < 34 && c[k] === 0) k++; if (k >= 34) { out.push({ pair: pairKind, sets: sets.slice() }); return; } if (c[k] >= 3) { c[k] -= 3; sets.push({ type: 'pung', kind: k }); rec(k); sets.pop(); c[k] += 3; } if (isSuited(k) && rankOf(k) <= 7 && c[k + 1] > 0 && c[k + 2] > 0) { c[k]--; c[k + 1]--; c[k + 2]--; sets.push({ type: 'chow', kind: k }); rec(k); sets.pop(); c[k]++; c[k + 1]++; c[k + 2]++; } }; for (let p = 0; p < 34; p++) { if (c[p] < 2) continue; c[p] -= 2; pairKind = p; rec(0); c[p] += 2; } return out; } const ORPHAN_KINDS = [0, 8, 9, 17, 18, 26, 27, 28, 29, 30, 31, 32, 33]; export function isThirteenOrphans(counts) { let total = 0, dup = 0; for (const k of ORPHAN_KINDS) { if (counts[k] === 0) return false; if (counts[k] >= 2) dup++; total += counts[k]; } return total === 14 && dup === 1; } // Would adding `kind` to this concealed hand complete it? export function canWinWith(hand, melds, kind) { const c = counts34(hand); c[kind]++; if (melds.length === 0 && isThirteenOrphans(c)) return true; return decompose(c, 4 - melds.length).length > 0; } // Every kind a 13 − 3·melds hand is waiting on. export function winningTiles(hand, melds) { const out = []; for (let k = 0; k < 34; k++) if (canWinWith(hand, melds, k)) out.push(k); return out; } // Standard-form shanten (sets·2 + partials + pair maximisation), min'd with // Thirteen Orphans when fully concealed. −1 means a complete hand. Used by // the AI; counts must hold 13 − 3·meldCount tiles (or 14 − 3·meldCount when // measuring a hand that still has to discard — the result is then the // shanten after its best discard). export function shanten(counts, meldCount = 0) { const maxSets = 4 - meldCount; const c = counts.slice(0, 34); let bestValue = 0; const rec = (start, sets, partials, pair) => { // partials beyond block capacity (4 sets total incl. melds) don't count const value = sets * 2 + Math.min(partials, maxSets - sets) + (pair ? 1 : 0); if (value > bestValue) bestValue = value; // even turning everything left into sets cannot beat the best found if (value + (maxSets - sets) * 2 + (pair ? 0 : 1) <= bestValue) return; let k = start; while (k < 34 && c[k] === 0) k++; if (k >= 34) return; if (sets < maxSets) { if (c[k] >= 3) { c[k] -= 3; rec(k, sets + 1, partials, pair); c[k] += 3; } if (isSuited(k) && rankOf(k) <= 7 && c[k + 1] > 0 && c[k + 2] > 0) { c[k]--; c[k + 1]--; c[k + 2]--; rec(k, sets + 1, partials, pair); c[k]++; c[k + 1]++; c[k + 2]++; } } if (!pair && c[k] >= 2) { c[k] -= 2; rec(k, sets, partials, true); c[k] += 2; } if (sets + partials < maxSets) { if (c[k] >= 2) { c[k] -= 2; rec(k, sets, partials + 1, pair); c[k] += 2; } if (isSuited(k) && rankOf(k) <= 8 && c[k + 1] > 0) { c[k]--; c[k + 1]--; rec(k, sets, partials + 1, pair); c[k]++; c[k + 1]++; } if (isSuited(k) && rankOf(k) <= 7 && c[k + 2] > 0) { c[k]--; c[k + 2]--; rec(k, sets, partials + 1, pair); c[k]++; c[k + 2]++; } } c[k]--; // leave this tile as a floater rec(k, sets, partials, pair); c[k]++; }; rec(0, 0, 0, false); let best = maxSets * 2 - bestValue; if (meldCount === 0) { let kinds = 0, hasDup = false; for (const k of ORPHAN_KINDS) { if (counts[k] >= 1) kinds++; if (counts[k] >= 2) hasDup = true; } const sh13 = 13 - kinds - (hasDup ? 1 : 0); if (sh13 < best) best = sh13; } return best; } // ── faan scoring ───────────────────────────────────────────────────────────── const row = (id, times = 1) => { const r = FAAN_BY_ID[id]; const out = []; for (let i = 0; i < times; i++) out.push({ id: r.id, label: r.label, faan: r.faan }); return out; }; // Faan rows for one reading of the hand (concealed decomposition + melds). function patternRows(dec, meldSets, seatWindKind, roundWindKind) { const sets = dec.sets.concat(meldSets); const rows = []; // suit purity over every set and the pair (chows are always suited) const kinds = [dec.pair, ...sets.map((s) => s.kind)]; const suits = new Set(); let hasHonor = false; for (const k of kinds) { if (isSuited(k)) suits.add(Math.floor(k / 9)); else hasHonor = true; } for (const s of sets) if (s.type === 'chow') suits.add(Math.floor(s.kind / 9)); if (suits.size === 0) rows.push(...row('all-honors')); else if (suits.size === 1) rows.push(...row(hasHonor ? 'mixed-one-suit' : 'pure-one-suit')); if (sets.every((s) => s.type === 'chow') && isSuited(dec.pair)) rows.push(...row('common-hand')); if (sets.every((s) => s.type === 'pung')) rows.push(...row('all-pungs')); const dragonPungs = sets.filter((s) => s.type === 'pung' && DRAGON_KINDS.includes(s.kind)).length; const dragonPair = DRAGON_KINDS.includes(dec.pair); if (dragonPungs === 3) rows.push(...row('great-dragons')); else if (dragonPungs === 2 && dragonPair) rows.push(...row('small-dragons')); else if (dragonPungs > 0) rows.push(...row('dragon-pung', dragonPungs)); const windPungKinds = sets.filter((s) => s.type === 'pung' && WIND_KINDS.includes(s.kind)).map((s) => s.kind); const windPair = WIND_KINDS.includes(dec.pair); if (windPungKinds.length === 4) rows.push(...row('great-winds')); else if (windPungKinds.length === 3 && windPair) rows.push(...row('small-winds')); else { if (windPungKinds.includes(seatWindKind)) rows.push(...row('seat-wind')); if (windPungKinds.includes(roundWindKind)) rows.push(...row('round-wind')); } return applyExcludes(rows); } function applyExcludes(rows) { const excluded = new Set(); for (const r of rows) for (const ex of FAAN_BY_ID[r.id].excludes ?? []) excluded.add(ex); return rows.filter((r) => !excluded.has(r.id)); } // Score a completed hand for `seat`. winTile is added to the concealed hand // for discard wins (it is already in the hand for self-draws). Returns // { faanList, faan, base, payments } — payments is a zero-sum array of four // score deltas: 2× base from the discarder, or 1× base from everyone on a // self-draw. export function evaluateWin(state, seat, winTile, byDiscard) { const p = state.players[seat]; const counts = counts34(byDiscard ? p.hand.concat([winTile]) : p.hand); const seatWindKind = WIND_KINDS[seatWindOf(state, seat)]; const roundWindKind = WIND_KINDS[state.roundWind]; const meldSets = p.melds.map((m) => ({ type: m.type === 'chow' ? 'chow' : 'pung', kind: m.kinds[0] })); let bestRows = []; let bestFaan = -1; if (p.melds.length === 0 && isThirteenOrphans(counts)) { bestRows = row('thirteen-orphans'); bestFaan = LIMIT_FAAN; } else { for (const dec of decompose(counts, 4 - p.melds.length)) { const rows = patternRows(dec, meldSets, seatWindKind, roundWindKind); const total = rows.reduce((a, r) => a + r.faan, 0); if (total > bestFaan) { bestFaan = total; bestRows = rows; } } if (bestFaan < 0) { bestFaan = 0; bestRows = []; } // defensive; callers check canWinWith } const faanList = bestRows.slice(); if (!byDiscard) faanList.push(...row('self-draw')); if (p.melds.every((m) => m.concealed)) faanList.push(...row('concealed')); if (!byDiscard && state.wallPos > state.wallEnd) faanList.push(...row('last-tile')); if (!byDiscard && state.lastDrawWasKongReplacement) faanList.push(...row('kong-draw')); const seatWind = seatWindOf(state, seat); const own = p.bonus.filter((b) => (b - FIRST_BONUS) % 4 === seatWind).length; if (own > 0) faanList.push(...row('seat-flower', own)); if ([34, 35, 36, 37].every((k) => p.bonus.includes(k))) faanList.push(...row('flower-set')); if ([38, 39, 40, 41].every((k) => p.bonus.includes(k))) faanList.push(...row('season-set')); if (p.bonus.length === 0) faanList.push(...row('no-bonus')); const faan = Math.min(faanList.reduce((a, r) => a + r.faan, 0), LIMIT_FAAN); const base = basePoints(faan); const payments = [0, 0, 0, 0]; if (byDiscard) { payments[seat] = 2 * base; payments[state.lastDiscard.from] = -2 * base; } else { payments[seat] = 3 * base; for (let s = 0; s < 4; s++) if (s !== seat) payments[s] = -base; } return { faanList, faan, base, payments }; }