fertig-classic-games/public/src/games/mahjong/MahjongLogic.js

603 lines
22 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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