feat: add 4-player Hong Kong style Mahjong with AI opponents

- Implement pure logic engine for tile management, shanten calculation, claim resolution, and faan scoring.
- Add heuristic AI (5 skill levels) using shanten minimization, ukeire tiebreaking, and adaptive defense.
- Build Phaser UI with tile rendering, dynamic scoreboard, claim prompts, hand-end modals, and a scoring reference panel.
- Integrate into frontend routing, opponent selection, and backend registry.
- Include headless verification script for tile catalog, scoring fixtures, and AI self-play invariant checks.
- Update game icon assets and add in-game tutorial.
This commit is contained in:
Brian Fertig 2026-06-11 18:18:45 -06:00
parent dd749bc570
commit bbb9c329c7
12 changed files with 2107 additions and 2 deletions

Binary file not shown.

Before

Width:  |  Height:  |  Size: 209 KiB

After

Width:  |  Height:  |  Size: 217 KiB

Binary file not shown.

View File

@ -0,0 +1,215 @@
// Mahjong — heuristic opponent. No Phaser, no timers, no state mutation.
// Discards minimise shanten with an ukeire (useful-draw count) tiebreak at
// skill 3+; skills 1-2 use a cheap tile-isolation heuristic instead and claim
// every pung/chow they are offered. Higher skills subtract tiles they can see
// from ukeire counts and lean toward safe discards once an opponent shows a
// big exposed hand. Wins are always taken.
import { isSuited, isHonor, rankOf } from './MahjongData.js';
import { counts34, shanten, claimOptionsFor } from './MahjongLogic.js';
const SKILL_PROFILES = {
1: { noise: 3.0, claimAlways: true, seenAware: false, defense: 0.0, delay: [750, 1300] },
2: { noise: 2.0, claimAlways: true, seenAware: false, defense: 0.0, delay: [700, 1200] },
3: { noise: 1.0, claimAlways: false, seenAware: false, defense: 0.3, delay: [620, 1050] },
4: { noise: 0.4, claimAlways: false, seenAware: true, defense: 0.7, delay: [540, 950] },
5: { noise: 0.0, claimAlways: false, seenAware: true, defense: 1.0, delay: [460, 850] },
};
function profileFor(skill) {
return SKILL_PROFILES[Math.max(1, Math.min(5, skill | 0))] ?? SKILL_PROFILES[3];
}
export function nextThinkDelay(skill) {
const [lo, hi] = profileFor(skill).delay;
return lo + Math.random() * (hi - lo);
}
// Tiles visible to everyone: all rivers, exposed melds and bonus rows. (We
// also count concealed kongs — a minor peek that keeps the bookkeeping flat.)
function seenCounts(state) {
const seen = new Array(34).fill(0);
for (const river of state.discards) for (const k of river) seen[k]++;
for (const p of state.players) {
for (const m of p.melds) for (const k of m.kinds) seen[k]++;
}
return seen;
}
// Cheap usefulness of holding `kind` — for the skill 1-2 discard heuristic.
function usefulness(counts, kind) {
let u = (counts[kind] - 1) * 3;
if (isHonor(kind)) return u + (counts[kind] >= 2 ? 3 : 0);
const r = rankOf(kind);
if (r >= 2 && counts[kind - 1] > 0) u += 2;
if (r <= 8 && counts[kind + 1] > 0) u += 2;
if (r >= 3 && counts[kind - 2] > 0) u += 1;
if (r <= 7 && counts[kind + 2] > 0) u += 1;
if (r === 1 || r === 9) u -= 1;
return u;
}
// Draws that could improve a hand: pairing an existing tile or extending a
// suited shape. A brand-new isolated kind never lowers standard shanten.
function improverKinds(counts) {
const out = new Set();
for (let k = 0; k < 34; k++) {
if (counts[k] === 0) continue;
out.add(k);
if (!isSuited(k)) continue;
const r = rankOf(k);
for (const d of [-2, -1, 1, 2]) {
const t = k + d;
if (r + d >= 1 && r + d <= 9) out.add(t);
}
}
return [...out];
}
// Pick a discard for the player on turn (hand holds 14 3·melds tiles).
export function chooseDiscard(state, seat, skill = 3) {
const prof = profileFor(skill);
const p = state.players[seat];
const counts = counts34(p.hand);
const kinds = [];
for (let k = 0; k < 34; k++) if (counts[k] > 0) kinds.push(k);
if (prof.claimAlways) { // skills 1-2: discard the least useful tile
let best = kinds[0], bestScore = Infinity;
for (const k of kinds) {
const s = usefulness(counts, k) + Math.random() * prof.noise * 2;
if (s < bestScore) { bestScore = s; best = k; }
}
return best;
}
const m = p.melds.length;
let minSh = Infinity;
const perKind = new Map();
for (const k of kinds) {
counts[k]--;
const s = shanten(counts, m);
counts[k]++;
perKind.set(k, s);
if (s < minSh) minSh = s;
}
const candidates = kinds.filter((k) => perKind.get(k) === minSh);
const seen = prof.seenAware ? seenCounts(state) : null;
const threat = prof.defense > 0 && minSh >= 2
&& state.players.some((q) => q.seat !== seat && q.melds.length >= 3);
let best = candidates[0], bestScore = -Infinity;
for (const k of candidates) {
counts[k]--;
let ukeire = 0;
for (const t of improverKinds(counts)) {
counts[t]++;
if (shanten(counts, m) < minSh) {
let left = 4 - counts[t] + 1;
if (seen) left -= seen[t];
if (left > 0) ukeire += left;
}
counts[t]--;
}
counts[k]++;
let score = ukeire + Math.random() * prof.noise * 2;
// someone looks close to winning — favour tiles already seen on the table
if (threat && seen && seen[k] >= 1) score += 4 * prof.defense;
if (score > bestScore) { bestScore = score; best = k; }
}
return best;
}
// React to another player's discard. `options` comes from claimOptionsFor.
// Returns a claim ({ type, tiles? }) or null to pass.
export function chooseClaim(state, seat, options, skill = 3) {
if (!options) return null;
if (options.win) return { type: 'win' };
const prof = profileFor(skill);
const p = state.players[seat];
const kind = state.lastDiscard.kind;
const m = p.melds.length;
const counts = counts34(p.hand);
if (prof.claimAlways) {
if (options.kong) return { type: 'kong' };
if (options.pung) return { type: 'pung' };
if (options.chows.length) return { type: 'chow', tiles: options.chows[0] };
return null;
}
const s0 = shanten(counts, m);
if (options.kong) { // free replacement draw — accept when not a step back
counts[kind] -= 3;
const s = shanten(counts, m + 1);
counts[kind] += 3;
if (s <= s0) return { type: 'kong' };
}
if (options.pung) {
counts[kind] -= 2;
const s = shanten(counts, m + 1);
counts[kind] += 2;
if (s < s0) return { type: 'pung' };
}
for (const tiles of options.chows) {
counts[tiles[0]]--; counts[tiles[1]]--;
const s = shanten(counts, m + 1);
counts[tiles[0]]++; counts[tiles[1]]++;
if (s < s0) return { type: 'chow', tiles };
}
return null;
}
// Decide on a self-drawn win or a kong declaration while awaiting discard.
// `actions` comes from selfActions. Returns { type: 'win' },
// { type: 'kong', spec } or null to just discard.
export function chooseSelfAction(state, seat, actions, skill = 3) {
if (actions.canWin) return { type: 'win' };
const prof = profileFor(skill);
if (!actions.concealedKongs.length && !actions.addedKongs.length) return null;
if (prof.claimAlways) {
if (actions.concealedKongs.length) return { type: 'kong', spec: { type: 'concealed', kind: actions.concealedKongs[0] } };
return { type: 'kong', spec: { type: 'added', kind: actions.addedKongs[0] } };
}
const p = state.players[seat];
const m = p.melds.length;
const counts = counts34(p.hand);
let bestDiscardSh = Infinity;
for (let k = 0; k < 34; k++) {
if (counts[k] === 0) continue;
counts[k]--;
const s = shanten(counts, m);
counts[k]++;
if (s < bestDiscardSh) bestDiscardSh = s;
}
for (const k of actions.concealedKongs) {
counts[k] -= 4;
const s = shanten(counts, m + 1);
counts[k] += 4;
if (s <= bestDiscardSh) return { type: 'kong', spec: { type: 'concealed', kind: k } };
}
for (const k of actions.addedKongs) {
counts[k]--;
const s = shanten(counts, m); // pung→kong: still one meld, one tile fewer
counts[k]++;
if (s <= bestDiscardSh) return { type: 'kong', spec: { type: 'added', kind: k } };
}
return null;
}
// Collect claim intents from every AI seat for the current discard. The
// scene merges these with the human's choice before calling resolveClaims.
export function collectAIClaims(state, humanSeat = 0) {
const intents = [];
for (let seat = 0; seat < 4; seat++) {
if (seat === humanSeat) continue;
const p = state.players[seat];
if (!p.isAI) continue;
const options = claimOptionsFor(state, seat);
if (!options) continue;
const claim = chooseClaim(state, seat, options, p.skill);
if (claim) intents.push({ seat, claim });
}
return intents;
}

View File

@ -0,0 +1,122 @@
// Mahjong (Hong Kong style) — static catalog. No Phaser, no game state.
// Tile kinds, label-art mapping, the faan scoring table (single source of
// truth for both the engine and the in-game reference panel), and the
// faan → base-points ladder.
//
// Tiles are encoded as small integers ("kinds") so the engine can count and
// decompose hands with flat arrays:
// 0..8 bamboo 1-9 9..17 circle 1-9 18..26 character 1-9
// 27..30 winds E S W N 31..33 dragons R G W
// 34..37 flowers (seat E S W N) 38..41 seasons (seat E S W N)
// pinyin sheet index for character (萬) tiles 1..9 — mirrors Mahjong Match.
const CHAR_LABEL = [13, 14, 15, 7, 8, 9, 10, 11, 12];
export const BAMBOO = 0, CIRCLE = 9, CHAR = 18;
export const WIND_KINDS = [27, 28, 29, 30]; // E S W N
export const DRAGON_KINDS = [31, 32, 33]; // red green white
export const FIRST_BONUS = 34; // flowers then seasons
export const KIND_COUNT = 42; // 34 playing + 8 bonus
export const isSuited = (k) => k < 27;
export const isHonor = (k) => k >= 27 && k < 34;
export const isBonus = (k) => k >= FIRST_BONUS;
export const suitOf = (k) => (k < 27 ? Math.floor(k / 9) : -1); // 0 bam, 1 cir, 2 char
export const rankOf = (k) => (k % 9) + 1; // suited kinds only
export const isTerminal = (k) => isSuited(k) && (rankOf(k) === 1 || rankOf(k) === 9);
export const WIND_NAMES = ['East', 'South', 'West', 'North'];
export const SUIT_NAMES = ['Bamboo', 'Circle', 'Character'];
// One entry per kind: { kind, id, label (texture key | null), name }.
export const TILES = (() => {
const t = [];
const suits = [['bamboo', 'Bamboo'], ['circle', 'Circle'], ['char', 'Character']];
suits.forEach(([id, name], s) => {
for (let n = 1; n <= 9; n++) {
const label = id === 'char' ? `mahjong-pinyin${CHAR_LABEL[n - 1]}` : `mahjong-${id}${n}`;
t.push({ kind: s * 9 + n - 1, id: `${id}${n}`, label, name: `${name} ${n}` });
}
});
const winds = [['east', 4], ['south', 3], ['west', 6], ['north', 5]];
winds.forEach(([w, pinyin], i) => {
t.push({ kind: 27 + i, id: `wind-${w}`, label: `mahjong-pinyin${pinyin}`, name: `${WIND_NAMES[i]} Wind` });
});
t.push({ kind: 31, id: 'dragon-red', label: 'mahjong-pinyin1', name: 'Red Dragon' });
t.push({ kind: 32, id: 'dragon-green', label: 'mahjong-pinyin2', name: 'Green Dragon' });
t.push({ kind: 33, id: 'dragon-white', label: null, name: 'White Dragon' }); // drawn procedurally
const flowers = ['orchid', 'peony', 'chrysanthemum', 'lotus'];
flowers.forEach((f, i) => {
t.push({ kind: 34 + i, id: f, label: `mahjong-${f}`, name: `Flower (${WIND_NAMES[i]})` });
});
const seasons = ['spring', 'summer', 'fall', 'winter'];
seasons.forEach((s, i) => {
t.push({ kind: 38 + i, id: s, label: `mahjong-${s}`, name: `Season (${WIND_NAMES[i]})` });
});
return t;
})();
// Full 144-tile wall: 4 copies of each playing tile, 1 of each bonus tile.
export function buildWall() {
const wall = [];
for (let k = 0; k < FIRST_BONUS; k++) wall.push(k, k, k, k);
for (let k = FIRST_BONUS; k < KIND_COUNT; k++) wall.push(k);
return wall;
}
// ── Scoring ──────────────────────────────────────────────────────────────────
// Classic HK Old Style faan values with a 1-faan minimum: a "chicken hand"
// (no faan at all) may not declare a win, but bonus-tile and contextual faan
// count toward the minimum, so hands stay fast. `excludes` lists faan ids the
// engine must NOT also award when this row applies (prevents double counting,
// e.g. Great Dragons already contains its three dragon pungs).
export const MIN_FAAN = 1;
export const LIMIT_FAAN = 13;
export const FAAN_TABLE = [
// hand patterns
{ id: 'common-hand', label: 'Common Hand', faan: 1, desc: 'Four chows and a pair of suited tiles' },
{ id: 'all-pungs', label: 'All Pungs', faan: 3, desc: 'Four pungs or kongs and a pair' },
{ id: 'mixed-one-suit', label: 'Mixed One Suit', faan: 3, desc: 'One suit plus honor tiles only' },
{ id: 'pure-one-suit', label: 'Pure One Suit', faan: 6, desc: 'One suit only, no honors',
excludes: ['mixed-one-suit'] },
{ id: 'small-dragons', label: 'Small Dragons', faan: 5, desc: 'Two dragon pungs and a dragon pair',
excludes: ['dragon-pung'] },
{ id: 'great-dragons', label: 'Great Dragons', faan: 8, desc: 'All three dragon pungs',
excludes: ['dragon-pung', 'small-dragons'] },
{ id: 'small-winds', label: 'Small Winds', faan: 6, desc: 'Three wind pungs and a wind pair',
excludes: ['seat-wind', 'round-wind'] },
{ id: 'great-winds', label: 'Great Winds', faan: 13, desc: 'All four wind pungs',
excludes: ['seat-wind', 'round-wind', 'small-winds', 'all-pungs'] },
{ id: 'all-honors', label: 'All Honors', faan: 13, desc: 'Winds and dragons only',
excludes: ['all-pungs', 'mixed-one-suit'] },
{ id: 'thirteen-orphans', label: 'Thirteen Orphans', faan: 13, desc: 'One of every 1, 9 and honor, plus a duplicate' },
// per-meld faan
{ id: 'dragon-pung', label: 'Dragon Pung', faan: 1, desc: 'Each pung or kong of a dragon' },
{ id: 'seat-wind', label: 'Seat Wind Pung', faan: 1, desc: 'Pung or kong of your seat wind' },
{ id: 'round-wind', label: 'Round Wind Pung', faan: 1, desc: 'Pung or kong of East, the round wind' },
// win context
{ id: 'self-draw', label: 'Self Draw', faan: 1, desc: 'Winning tile drawn from the wall' },
{ id: 'concealed', label: 'Concealed Hand', faan: 1, desc: 'No melds claimed from discards' },
{ id: 'last-tile', label: 'Last Wall Tile', faan: 1, desc: 'Win on the very last wall tile' },
{ id: 'kong-draw', label: 'Kong Replacement', faan: 1, desc: 'Win on the tile drawn after a kong' },
// bonus tiles
{ id: 'seat-flower', label: 'Own Flower / Season', faan: 1, desc: 'Each flower or season matching your seat' },
{ id: 'flower-set', label: 'All Four Flowers', faan: 2, desc: 'The complete flower set' },
{ id: 'season-set', label: 'All Four Seasons', faan: 2, desc: 'The complete season set' },
{ id: 'no-bonus', label: 'No Bonus Tiles', faan: 1, desc: 'Hand finished with no flowers or seasons' },
];
export const FAAN_BY_ID = Object.fromEntries(FAAN_TABLE.map((r) => [r.id, r]));
// Base points per faan (half-doubling ladder, capped at the 13-faan limit).
export const BASE_POINTS = [1, 2, 4, 8, 16, 24, 32, 48, 64, 96, 128, 192, 256, 384];
export const basePoints = (faan) => BASE_POINTS[Math.min(faan, LIMIT_FAAN)];
// Payments: winner by discard collects 2× base from the discarder alone;
// a self-drawn winner collects 1× base from each of the three others.
// Seat colours (shared visual language with the other tabletop games).
export const PLAYER_COLORS = [0xd0473a, 0x4a90d9, 0x49a25a, 0xe2b53c];
export const PLAYER_COLOR_HEX = ['#d0473a', '#4a90d9', '#49a25a', '#e2b53c'];

View File

@ -0,0 +1,780 @@
import * as Phaser from 'phaser';
import { GAME_WIDTH, GAME_HEIGHT, COLORS } from '../../config.js';
import { Button } from '../../ui/Button.js';
import { auth } from '../../services/auth.js';
import { api } from '../../services/api.js';
import { createOpponentPortrait, createPlayerPortrait } from '../../ui/Portrait.js';
import { playSound, SFX } from '../../ui/Sounds.js';
import { MusicPlayer } from '../../ui/MusicPlayer.js';
import {
TILES, SUIT_NAMES, WIND_NAMES, rankOf, isSuited,
FAAN_TABLE, MIN_FAAN, PLAYER_COLOR_HEX,
} from './MahjongData.js';
import {
createInitialState, discardTile, claimOptionsFor, resolveClaims,
selfActions, declareKong, declareWin, startNextHand,
isGameOver, getWinners, seatWindOf,
} from './MahjongLogic.js';
import {
chooseDiscard, chooseSelfAction, collectAIClaims, nextThinkDelay,
} from './MahjongAI.js';
// Deep-green felt with ivory tiles — same table look as Mahjong Match.
const FELT = 0x0e2a1c;
const FACE = 0xf6efdb;
const FACE_EDGE = 0x8d7c52;
const SIDE = 0xb59c66;
const DRAGON_BLUE = 0x3f6bb5;
const BACK_FACE = 0x2e6e4e;
const BACK_INSET = 0x3f8a63;
// Label art is 128×178; keep its aspect when fitting it onto a tile face.
const LABEL_W = 128;
const LABEL_H = 178;
// ── layout ────────────────────────────────────────────────────────────────────
const HAND_Y = 975, HAND_TW = 76, HAND_TH = 104, HAND_STEP = 80;
const SM_W = 44, SM_H = 60; // opponent backs, melds, modal tiles
const RIV_W = 42, RIV_H = 58; // discard rivers
// per-seat river grids: 8 per row, growing toward the table centre
const RIVERS = [
{ cx: 960, y0: 652, dy: 64 }, // seat 0 — bottom
{ cx: 1320, y0: 412, dy: 64 }, // seat 1 — right
{ cx: 960, y0: 198, dy: 64 }, // seat 2 — top
{ cx: 600, y0: 412, dy: 64 }, // seat 3 — left
];
const CLAIM_Y = 820;
const REF_W = 430;
const DEPTH = {
bg: -2, panel: 0, text: 2, tiles: 5, ui: 20, claims: 30, toast: 55, ref: 60, modal: 70,
};
const tileName = (k) => TILES[k].name;
export default class MahjongGame extends Phaser.Scene {
constructor() { super('MahjongGame'); }
init(data) {
this.gameDef = data.game;
this.opponents = data.opponents ?? [];
this.playfield = data.playfield ?? null;
this.humanSeat = 0;
this.driving = false;
this.gameOverShown = false;
this.humanCanDiscard = false;
this.sel = null; // selected hand tile { c, baseY }
this.handTiles = [];
this.claimResolve = null;
this.kongSpecs = [];
this.chowOptions = [];
this.portraitCtrls = [];
this.refOpen = false;
}
create() {
try { new MusicPlayer(this, this.cache.json.get('music')?.tracks ?? []); } catch { /* optional */ }
this.add.rectangle(GAME_WIDTH / 2, GAME_HEIGHT / 2, GAME_WIDTH, GAME_HEIGHT, FELT).setDepth(DEPTH.bg);
const names = [auth.user?.username ?? 'You'];
const skills = { 0: 5 };
for (let seat = 1; seat < 4; seat++) {
const opp = this.opponents[seat - 1];
names.push(opp?.name ?? `Player ${seat + 1}`);
skills[seat] = Math.max(1, Math.min(5, opp?.skill ?? 3));
}
this.gs = createInitialState({ names, skills });
this.dyn = this.add.container(0, 0).setDepth(DEPTH.tiles);
this.buildScoreboard();
this.buildPortraits();
this.buildCentreTexts();
this.buildActionButtons();
this.buildReferencePanel();
new Button(this, 80, GAME_HEIGHT - 40, 'Leave', () => this.scene.start('GameMenu'), {
variant: 'ghost', width: 140, height: 52, fontSize: 20,
}).setDepth(DEPTH.ui);
this.refresh();
this.advance();
}
// ── static chrome ───────────────────────────────────────────────────────────
buildScoreboard() {
this.add.rectangle(180, 132, 320, 224, COLORS.panel, 0.92)
.setStrokeStyle(2, COLORS.accent).setDepth(DEPTH.panel);
this.sbHeader = this.add.text(180, 48, '', {
fontFamily: 'Righteous', fontSize: '22px', color: COLORS.goldHex,
}).setOrigin(0.5).setDepth(DEPTH.text);
this.sbRows = [];
for (let seat = 0; seat < 4; seat++) {
const y = 88 + seat * 40;
const dot = this.add.circle(48, y, 8, Phaser.Display.Color.HexStringToColor(PLAYER_COLOR_HEX[seat]).color)
.setDepth(DEPTH.text);
const name = this.add.text(66, y, '', {
fontFamily: '"Julius Sans One"', fontSize: '20px', color: COLORS.textHex,
}).setOrigin(0, 0.5).setDepth(DEPTH.text);
const score = this.add.text(312, y, '', {
fontFamily: 'Righteous', fontSize: '20px', color: COLORS.accentHex,
}).setOrigin(1, 0.5).setDepth(DEPTH.text);
this.sbRows.push({ dot, name, score });
}
}
buildPortraits() {
const spots = [
{ x: 90, y: 870, r: 46 }, // human — bottom left
{ x: 1830, y: 170, r: 40 }, // seat 1 — right
{ x: 560, y: 95, r: 40 }, // seat 2 — top
{ x: 90, y: 310, r: 40 }, // seat 3 — left
];
for (let seat = 0; seat < 4; seat++) {
const { x, y, r } = spots[seat];
const ring = this.add.graphics().setDepth(DEPTH.ui);
let ctrl;
if (seat === this.humanSeat) {
ctrl = createPlayerPortrait(this, x, y, r, DEPTH.ui, 'MahjongGame');
} else {
const opp = this.opponents[seat - 1] ?? { id: 'bot', spriteIndex: 0 };
ctrl = createOpponentPortrait(this, opp, x, y, r, DEPTH.ui);
}
this.add.text(x, y + r + 14, this.gs.players[seat].name, {
fontFamily: '"Julius Sans One"', fontSize: '15px', color: COLORS.textHex,
}).setOrigin(0.5).setDepth(DEPTH.ui);
this.portraitCtrls.push({ ring, controller: ctrl, x, y, r });
}
}
buildCentreTexts() {
this.wallText = this.add.text(960, 448, '', {
fontFamily: 'Righteous', fontSize: '30px', color: COLORS.goldHex,
}).setOrigin(0.5).setDepth(DEPTH.text);
this.statusText = this.add.text(960, 505, '', {
fontFamily: '"Julius Sans One"', fontSize: '24px', color: COLORS.accentHex,
}).setOrigin(0.5).setDepth(DEPTH.text);
}
buildActionButtons() {
const mk = (label, cb, w = 180) =>
new Button(this, 0, CLAIM_Y, label, cb, { width: w, height: 60, fontSize: 24 })
.setDepth(DEPTH.claims).setVisible(false);
this.claimBtns = {
win: mk('Mahjong!', () => this.resolveClaim({ type: 'win' })),
kong: mk('Kong', () => this.resolveClaim({ type: 'kong' })),
pung: mk('Pung', () => this.resolveClaim({ type: 'pung' })),
chows: [0, 1, 2].map((i) => mk('Chow', () => this.resolveClaim({ type: 'chow', tiles: this.chowOptions[i] }), 210)),
pass: mk('Pass', () => this.resolveClaim(null)),
};
this.selfWinBtn = new Button(this, 1770, CLAIM_Y, 'Mahjong!', () => this.onSelfWin(), {
width: 170, height: 60, fontSize: 24,
}).setDepth(DEPTH.claims).setVisible(false);
this.selfKongBtns = [0, 1].map((i) =>
new Button(this, 1770, CLAIM_Y + 70 + i * 70, 'Kong', () => this.onSelfKong(i), {
width: 170, height: 60, fontSize: 22,
}).setDepth(DEPTH.claims).setVisible(false));
}
// Toggleable list of every scoring hand — the same table the engine scores
// with, so the panel can never drift from the rules.
buildReferencePanel() {
const panel = this.add.container(GAME_WIDTH, 0).setDepth(DEPTH.ref);
const g = this.add.graphics();
g.fillStyle(COLORS.panel, 0.97);
g.fillRect(0, 0, REF_W, GAME_HEIGHT);
g.lineStyle(2, COLORS.accent, 1);
g.lineBetween(0, 0, 0, GAME_HEIGHT);
panel.add(g);
panel.add(this.add.text(REF_W / 2, 34, 'Winning Hands', {
fontFamily: 'Righteous', fontSize: '28px', color: COLORS.goldHex,
}).setOrigin(0.5));
let y = 76;
for (const r of FAAN_TABLE) {
panel.add(this.add.text(24, y, r.label, {
fontFamily: '"Julius Sans One"', fontSize: '19px', color: COLORS.textHex,
}).setOrigin(0, 0.5));
panel.add(this.add.text(REF_W - 24, y, `${r.faan}`, {
fontFamily: 'Righteous', fontSize: '20px', color: COLORS.accentHex,
}).setOrigin(1, 0.5));
panel.add(this.add.text(24, y + 18, r.desc, {
fontFamily: '"Julius Sans One"', fontSize: '13px', color: COLORS.mutedHex,
}).setOrigin(0, 0.5));
y += 44;
}
panel.add(this.add.text(REF_W / 2, y + 14, `A hand needs at least ${MIN_FAAN} faan to win.`, {
fontFamily: '"Julius Sans One"', fontSize: '15px', color: COLORS.goldHex,
}).setOrigin(0.5));
panel.add(this.add.text(REF_W / 2, y + 38, 'Points double with faan · discarder pays 2×, everyone pays on self-draw', {
fontFamily: '"Julius Sans One"', fontSize: '13px', color: COLORS.mutedHex,
}).setOrigin(0.5));
this.refPanel = panel;
this.refBtn = new Button(this, 1810, 44, 'Hands', () => this.toggleReference(), {
width: 150, height: 52, fontSize: 22, variant: 'ghost',
}).setDepth(DEPTH.ref + 1);
}
toggleReference() {
this.refOpen = !this.refOpen;
this.tweens.add({
targets: this.refPanel,
x: this.refOpen ? GAME_WIDTH - REF_W : GAME_WIDTH,
duration: 200,
ease: 'Quad.Out',
});
this.refBtn.setLabel(this.refOpen ? 'Close' : 'Hands');
}
// ── tile drawing ──────────────────────────────────────────────────────────────
makeTileFace(kind, w, h, { highlight = false } = {}) {
const t = Math.max(2, Math.round(w * 0.08));
const r = Math.max(3, Math.round(w * 0.09));
const g = this.add.graphics();
g.fillStyle(SIDE, 1);
g.fillRoundedRect(-w / 2 + t, -h / 2 + t, w, h, r);
g.fillStyle(FACE, 1);
g.fillRoundedRect(-w / 2, -h / 2, w, h, r);
g.lineStyle(highlight ? 3 : 2, highlight ? COLORS.gold : FACE_EDGE, 1);
g.strokeRoundedRect(-w / 2, -h / 2, w, h, r);
const kids = [g];
const tile = TILES[kind];
if (tile.label && this.textures.exists(tile.label)) {
const img = this.add.image(0, 0, tile.label);
img.setScale(Math.min((w * 0.80) / LABEL_W, (h * 0.82) / LABEL_H));
kids.push(img);
} else if (tile.id === 'dragon-white') {
g.lineStyle(Math.max(2, Math.round(w * 0.06)), DRAGON_BLUE, 0.95);
g.strokeRoundedRect(-w * 0.30, -h * 0.32, w * 0.60, h * 0.64, 5);
}
return this.add.container(0, 0, kids);
}
makeTileBack(w, h) {
const t = Math.max(2, Math.round(w * 0.08));
const r = Math.max(3, Math.round(w * 0.09));
const g = this.add.graphics();
g.fillStyle(SIDE, 1);
g.fillRoundedRect(-w / 2 + t, -h / 2 + t, w, h, r);
g.fillStyle(BACK_FACE, 1);
g.fillRoundedRect(-w / 2, -h / 2, w, h, r);
g.lineStyle(2, FACE_EDGE, 1);
g.strokeRoundedRect(-w / 2, -h / 2, w, h, r);
g.fillStyle(BACK_INSET, 1);
g.fillRoundedRect(-w * 0.34, -h * 0.34, w * 0.68, h * 0.68, 4);
return this.add.container(0, 0, [g]);
}
// A meld drawn as a tight row of small tiles. Concealed kongs show their
// backs (other players shouldn't read them at a glance).
makeMeldRow(meld, w, h, faceUp = true) {
const c = this.add.container(0, 0);
const step = w + 2;
const n = meld.kinds.length;
meld.kinds.forEach((k, i) => {
const showFace = faceUp || !meld.concealed;
const tile = showFace ? this.makeTileFace(k, w, h) : this.makeTileBack(w, h);
tile.setPosition((i - (n - 1) / 2) * step, 0);
c.add(tile);
});
return c;
}
// ── dynamic rendering ─────────────────────────────────────────────────────────
refresh() {
this.dyn.removeAll(true);
this.handTiles = [];
this.sel = null;
const gs = this.gs;
this.humanCanDiscard = gs.phase === 'awaitDiscard' && gs.turn === 0 && !this.gameOverShown;
this.renderHumanHand();
this.renderHumanMeldsAndBonus();
for (let seat = 1; seat < 4; seat++) this.renderOpponent(seat);
this.renderRivers();
this.renderScoreboard();
this.renderCentre();
this.updatePortraitRings();
this.updateSelfButtons();
}
renderHumanHand() {
const p = this.gs.players[0];
const tiles = [...p.hand].sort((a, b) => a - b);
let drawn = null;
if (this.humanCanDiscard && this.gs.drawnTile !== null) {
const i = tiles.indexOf(this.gs.drawnTile);
if (i >= 0) { tiles.splice(i, 1); drawn = this.gs.drawnTile; }
}
const totalW = tiles.length * HAND_STEP + (drawn !== null ? HAND_STEP + 22 : 0);
let x = 960 - totalW / 2 + HAND_STEP / 2;
const place = (kind, hx) => {
const c = this.makeTileFace(kind, HAND_TW, HAND_TH);
c.setPosition(hx, HAND_Y);
this.dyn.add(c);
if (this.humanCanDiscard) {
c.setSize(HAND_TW, HAND_TH);
c.setInteractive({ useHandCursor: true });
c.on('pointerup', () => this.onHandTileClick(c, kind));
}
this.handTiles.push({ c, kind });
return c;
};
for (const k of tiles) { place(k, x); x += HAND_STEP; }
if (drawn !== null) place(drawn, x + 22).y -= 10;
}
renderHumanMeldsAndBonus() {
const p = this.gs.players[0];
let x = 220;
for (const m of p.melds) {
const row = this.makeMeldRow(m, SM_W, SM_H, true);
const w = m.kinds.length * (SM_W + 2);
row.setPosition(x + w / 2, 878);
this.dyn.add(row);
x += w + 18;
}
let bx = 1680;
for (const b of [...p.bonus].reverse()) {
const tile = this.makeTileFace(b, SM_W, SM_H);
tile.setPosition(bx, 878);
this.dyn.add(tile);
bx -= SM_W + 4;
}
}
renderOpponent(seat) {
const p = this.gs.players[seat];
const n = p.hand.length;
const step = SM_W + 2;
if (seat === 2) { // top — horizontal row of backs
let x = 960 - ((n - 1) * step) / 2;
for (let i = 0; i < n; i++) {
const back = this.makeTileBack(SM_W, SM_H);
back.setPosition(x, 70);
this.dyn.add(back);
x += step;
}
let mx = 1330;
for (const m of p.melds) {
const row = this.makeMeldRow(m, 40, 56, false);
const w = m.kinds.length * 42;
row.setPosition(mx + w / 2, 70);
this.dyn.add(row);
mx += w + 14;
}
let bx = 1330;
for (const b of p.bonus) {
const tile = this.makeTileFace(b, 36, 50);
tile.setPosition(bx, 132);
this.dyn.add(tile);
bx += 40;
}
} else { // sides — vertical column of rotated backs
const x = seat === 1 ? 1858 : 62;
let y = 580 - ((n - 1) * step) / 2;
for (let i = 0; i < n; i++) {
const back = this.makeTileBack(SM_W, SM_H);
back.setPosition(x, y).setAngle(seat === 1 ? -90 : 90);
this.dyn.add(back);
y += step;
}
const mx = seat === 1 ? 1745 : 175;
let my = 330;
for (const m of p.melds) {
const row = this.makeMeldRow(m, 38, 52, false);
row.setPosition(mx, my);
this.dyn.add(row);
my += 60;
}
p.bonus.forEach((b, i) => {
const tile = this.makeTileFace(b, 36, 50);
tile.setPosition(mx + ((i % 4) - 1.5) * 40, my + 8 + Math.floor(i / 4) * 56);
this.dyn.add(tile);
});
}
}
renderRivers() {
const gs = this.gs;
for (let seat = 0; seat < 4; seat++) {
const river = gs.discards[seat];
const cfg = RIVERS[seat];
const dir = seat === 0 ? -1 : 1; // extra rows grow toward the centre
river.forEach((k, i) => {
const row = Math.floor(i / 8);
const col = i % 8;
const tile = this.makeTileFace(k, RIV_W, RIV_H);
tile.setPosition(cfg.cx + (col - 3.5) * (RIV_W + 4), cfg.y0 + dir * row * cfg.dy);
this.dyn.add(tile);
// pulse the live discard
if (gs.phase === 'awaitClaims' && gs.lastDiscard
&& gs.lastDiscard.from === seat && i === river.length - 1) {
const halo = this.add.graphics();
halo.lineStyle(3, COLORS.gold, 1);
halo.strokeRoundedRect(-RIV_W / 2 - 3, -RIV_H / 2 - 3, RIV_W + 6, RIV_H + 6, 6);
tile.add(halo);
this.tweens.add({ targets: tile, scale: { from: 1.18, to: 1 }, duration: 240, ease: 'Quad.Out' });
}
});
}
}
renderScoreboard() {
const gs = this.gs;
this.sbHeader.setText(`Hand ${gs.handNumber} · East round`);
for (let seat = 0; seat < 4; seat++) {
const p = gs.players[seat];
const wind = WIND_NAMES[seatWindOf(gs, seat)][0];
const dealer = gs.dealer === seat ? ' ◆' : '';
this.sbRows[seat].name.setText(`${wind} · ${p.name}${dealer}`);
this.sbRows[seat].score.setText(String(p.score));
}
}
renderCentre() {
const gs = this.gs;
this.wallText.setText(`Wall: ${Math.max(0, gs.wallEnd - gs.wallPos + 1)}`);
if (this.gameOverShown || gs.phase === 'gameOver') { this.statusText.setText(''); return; }
if (gs.phase === 'handOver') { this.statusText.setText(''); return; }
if (gs.phase === 'awaitDiscard') {
const p = gs.players[gs.turn];
this.statusText.setText(gs.turn === 0 ? 'Your turn — choose a tile to discard' : `${p.name} is thinking…`);
} else if (gs.phase === 'awaitClaims' && gs.lastDiscard) {
this.statusText.setText(`${gs.players[gs.lastDiscard.from].name} discards ${tileName(gs.lastDiscard.kind)}`);
}
}
updatePortraitRings() {
for (let seat = 0; seat < 4; seat++) {
const { ring, x, y, r } = this.portraitCtrls[seat];
ring.clear();
if (seat === this.gs.turn && !isGameOver(this.gs) && this.gs.phase !== 'handOver') {
ring.lineStyle(4, COLORS.gold, 1);
ring.strokeCircle(x, y, r + 6);
}
}
}
updateSelfButtons() {
const show = this.humanCanDiscard;
const acts = show ? selfActions(this.gs) : null;
this.selfWinBtn.setVisible(!!acts?.canWin);
this.kongSpecs = [];
if (acts) {
for (const k of acts.concealedKongs) this.kongSpecs.push({ type: 'concealed', kind: k });
for (const k of acts.addedKongs) this.kongSpecs.push({ type: 'added', kind: k });
}
this.selfKongBtns.forEach((btn, i) => {
const spec = this.kongSpecs[i];
btn.setVisible(!!spec);
if (spec) btn.setLabel(`Kong ${shortName(spec.kind)}`);
});
}
// ── human input ───────────────────────────────────────────────────────────────
onHandTileClick(c, kind) {
if (!this.humanCanDiscard || this.driving === 'modal') return;
if (this.sel && this.sel.c === c) {
playSound(this, SFX.PIECE_CLICK);
this.humanCanDiscard = false;
discardTile(this.gs, kind);
this.refresh();
this.advance();
return;
}
playSound(this, SFX.PIECE_CLICK);
if (this.sel) this.sel.c.y = this.sel.baseY;
this.sel = { c, baseY: c.y };
c.y -= 16;
}
onSelfWin() {
if (!this.humanCanDiscard) return;
this.humanCanDiscard = false;
declareWin(this.gs, 0, { byDiscard: false });
this.refresh();
this.advance();
}
onSelfKong(i) {
if (!this.humanCanDiscard || !this.kongSpecs[i]) return;
playSound(this, SFX.PIECE_CLICK);
declareKong(this.gs, this.kongSpecs[i]);
this.refresh();
// still our discard (with the replacement tile) — or the wall just ran out
if (this.gs.phase !== 'awaitDiscard') this.advance();
}
promptHumanClaim(opts) {
return new Promise((resolve) => {
this.claimResolve = resolve;
this.chowOptions = opts.chows;
const visible = [];
if (opts.win) visible.push(this.claimBtns.win);
if (opts.kong) visible.push(this.claimBtns.kong);
if (opts.pung) visible.push(this.claimBtns.pung);
opts.chows.forEach((tiles, i) => {
const kind = this.gs.lastDiscard.kind;
const run = [...tiles, kind].sort((a, b) => a - b);
this.claimBtns.chows[i].setLabel(`Chow ${run.map((k) => rankOf(k)).join('-')}`);
visible.push(this.claimBtns.chows[i]);
});
visible.push(this.claimBtns.pass);
const step = 226;
const left = 960 - ((visible.length - 1) * step) / 2;
visible.forEach((btn, i) => btn.setPosition(left + i * step, CLAIM_Y).setVisible(true));
this.statusText.setText(`Claim the ${tileName(this.gs.lastDiscard.kind)}?`);
});
}
resolveClaim(claim) {
if (!this.claimResolve) return;
playSound(this, SFX.PIECE_CLICK);
const done = this.claimResolve;
this.claimResolve = null;
this.hideClaimButtons();
done(claim);
}
hideClaimButtons() {
this.claimBtns.win.setVisible(false);
this.claimBtns.kong.setVisible(false);
this.claimBtns.pung.setVisible(false);
this.claimBtns.chows.forEach((b) => b.setVisible(false));
this.claimBtns.pass.setVisible(false);
}
// ── turn driver ───────────────────────────────────────────────────────────────
async advance() {
if (this.driving) return;
this.driving = true;
const gs = this.gs;
try {
for (;;) {
if (this.gameOverShown) return;
this.refresh();
if (gs.phase === 'gameOver') { this.showGameOver(); return; }
if (gs.phase === 'handOver') {
await this.showHandEnd();
startNextHand(gs);
continue;
}
if (gs.phase === 'awaitDiscard') {
if (gs.turn === 0) return; // wait for the human (buttons + hand clicks live)
const seat = gs.turn;
const skill = gs.players[seat].skill;
await this.delay(nextThinkDelay(skill));
if (this.gameOverShown) return;
const acts = selfActions(gs);
const sa = chooseSelfAction(gs, seat, acts, skill);
if (sa?.type === 'win') { declareWin(gs, seat, { byDiscard: false }); continue; }
if (sa?.type === 'kong') {
declareKong(gs, sa.spec);
await this.toast(`${gs.players[seat].name}: Kong!`);
continue;
}
discardTile(gs, chooseDiscard(gs, seat, skill));
playSound(this, SFX.PIECE_CLICK);
continue;
}
if (gs.phase === 'awaitClaims') {
const intents = collectAIClaims(gs, 0);
const humanOpts = claimOptionsFor(gs, 0);
if (humanOpts) {
const claim = await this.promptHumanClaim(humanOpts);
if (claim) intents.push({ seat: 0, claim });
} else {
await this.delay(420); // beat so the discard registers
}
const { applied } = resolveClaims(gs, intents);
if (applied && applied.claim.type !== 'win' && applied.seat !== 0) {
const label = applied.claim.type === 'chow' ? 'Chow' : applied.claim.type === 'pung' ? 'Pung' : 'Kong';
await this.toast(`${gs.players[applied.seat].name}: ${label}!`);
}
continue;
}
return; // unknown phase — bail rather than spin
}
} finally {
this.driving = false;
}
}
// ── hand end / game over ──────────────────────────────────────────────────────
showHandEnd() {
return new Promise((resolve) => {
const gs = this.gs;
const r = gs.result;
const modal = this.add.container(0, 0).setDepth(DEPTH.modal);
modal.add(this.add.rectangle(960, 540, GAME_WIDTH, GAME_HEIGHT, 0x000000, 0.65).setInteractive());
const isDraw = r.type === 'draw';
const faanRows = isDraw ? [] : r.faanList;
const h = isDraw ? 280 : Math.min(940, 360 + faanRows.length * 32 + 130);
const top = 540 - h / 2;
modal.add(this.add.rectangle(960, 540, 980, h, COLORS.panel, 1).setStrokeStyle(2, COLORS.accent));
if (isDraw) {
modal.add(this.add.text(960, top + 70, 'Wall exhausted', {
fontFamily: 'Righteous', fontSize: '44px', color: COLORS.goldHex,
}).setOrigin(0.5));
modal.add(this.add.text(960, top + 130, 'Nobody wins — the dealer redeals.', {
fontFamily: '"Julius Sans One"', fontSize: '24px', color: COLORS.textHex,
}).setOrigin(0.5));
} else {
const winner = gs.players[r.winner];
playSound(this, SFX.CASINO_WIN);
this.portraitCtrls[r.winner]?.controller?.playEmotion?.('happy');
if (r.byDiscard) this.portraitCtrls[r.discarder]?.controller?.playEmotion?.('upset');
modal.add(this.add.text(960, top + 52, `${winner.name} wins!`, {
fontFamily: 'Righteous', fontSize: '44px', color: COLORS.goldHex,
}).setOrigin(0.5));
modal.add(this.add.text(960, top + 100, r.byDiscard
? `off ${gs.players[r.discarder].name}'s ${tileName(r.winTile)}`
: `self-drawn ${tileName(r.winTile)}`, {
fontFamily: '"Julius Sans One"', fontSize: '22px', color: COLORS.mutedHex,
}).setOrigin(0.5));
// the winning hand: melds, then concealed tiles, winning tile last
const concealed = [...winner.hand].sort((a, b) => a - b);
if (!r.byDiscard) {
const i = concealed.indexOf(r.winTile);
if (i >= 0) concealed.splice(i, 1);
}
const groups = [];
for (const m of winner.melds) groups.push(m.kinds);
groups.push(concealed);
const flatW = groups.reduce((a, g2) => a + g2.length * (SM_W + 2) + 14, 0) + SM_W + 24;
let x = 960 - flatW / 2;
const tileY = top + 170;
for (const g2 of groups) {
for (const k of g2) {
const tile = this.makeTileFace(k, SM_W, SM_H);
tile.setPosition(x + SM_W / 2, tileY);
modal.add(tile);
x += SM_W + 2;
}
x += 14;
}
const winTileC = this.makeTileFace(r.winTile, SM_W, SM_H, { highlight: true });
winTileC.setPosition(x + 10 + SM_W / 2, tileY);
modal.add(winTileC);
let y = top + 240;
for (const fr of faanRows) {
modal.add(this.add.text(620, y, fr.label, {
fontFamily: '"Julius Sans One"', fontSize: '20px', color: COLORS.textHex,
}).setOrigin(0, 0.5));
modal.add(this.add.text(1300, y, `${fr.faan} faan`, {
fontFamily: '"Julius Sans One"', fontSize: '20px', color: COLORS.accentHex,
}).setOrigin(1, 0.5));
y += 32;
}
y += 8;
modal.add(this.add.text(620, y, `Total: ${r.faan} faan → ${r.base} base points`, {
fontFamily: 'Righteous', fontSize: '24px', color: COLORS.goldHex,
}).setOrigin(0, 0.5));
y += 44;
for (let seat = 0; seat < 4; seat++) {
const d = r.payments[seat];
if (d === 0) continue;
modal.add(this.add.text(620, y, gs.players[seat].name, {
fontFamily: '"Julius Sans One"', fontSize: '20px', color: PLAYER_COLOR_HEX[seat],
}).setOrigin(0, 0.5));
modal.add(this.add.text(1300, y, (d > 0 ? '+' : '') + d, {
fontFamily: 'Righteous', fontSize: '22px', color: d > 0 ? COLORS.goldHex : COLORS.dangerHex,
}).setOrigin(1, 0.5));
y += 32;
}
}
const repeat = isDraw || r.winner === gs.dealer;
const ending = !repeat && gs.dealer === 3;
const btn = new Button(this, 960, top + h - 56, ending ? 'Final Scores' : 'Next Hand', () => {
modal.destroy(true);
resolve();
}, { width: 280, fontSize: 24 });
btn.setDepth(DEPTH.modal);
modal.add(btn);
});
}
showGameOver() {
if (this.gameOverShown) return;
this.gameOverShown = true;
playSound(this, SFX.VICTORY_SHORT);
this.postHistory().catch(() => {});
this.add.rectangle(960, 540, GAME_WIDTH, GAME_HEIGHT, 0x000000, 0.65)
.setInteractive().setDepth(DEPTH.modal);
const panelW = 720, panelH = 440;
this.add.rectangle(960, 540, panelW, panelH, COLORS.panel, 1)
.setStrokeStyle(2, COLORS.accent).setDepth(DEPTH.modal);
this.add.text(960, 540 - panelH / 2 + 48, 'Final Scores', {
fontFamily: 'Righteous', fontSize: '42px', color: COLORS.goldHex,
}).setOrigin(0.5).setDepth(DEPTH.modal);
const winners = new Set(getWinners(this.gs));
const order = [...this.gs.players].sort((a, b) => b.score - a.score);
let rowY = 540 - panelH / 2 + 110;
for (const p of order) {
const isWin = winners.has(p.seat);
const color = isWin ? COLORS.goldHex : COLORS.textHex;
this.add.text(960 - panelW / 2 + 40, rowY, `${isWin ? '★ ' : ' '}${p.name}`, {
fontFamily: 'Righteous', fontSize: '24px', color,
}).setOrigin(0, 0.5).setDepth(DEPTH.modal);
this.add.text(960 + panelW / 2 - 40, rowY, String(p.score), {
fontFamily: 'Righteous', fontSize: '28px', color,
}).setOrigin(1, 0.5).setDepth(DEPTH.modal);
rowY += 52;
}
new Button(this, 960, 540 + panelH / 2 - 48, 'Back to Menu',
() => this.scene.start('GameMenu'), { width: 280, fontSize: 24 },
).setDepth(DEPTH.modal);
}
async postHistory() {
const totals = this.gs.players.map((p) => p.score);
const winners = new Set(getWinners(this.gs));
let result;
if (winners.has(this.humanSeat) && winners.size === 1) result = 'win';
else if (winners.has(this.humanSeat)) result = 'draw';
else result = 'loss';
await api.post('/history/single-player', {
slug: 'mahjong',
score: totals[this.humanSeat],
opponentScores: totals.filter((_, i) => i !== this.humanSeat),
result,
});
}
// ── small fx ──────────────────────────────────────────────────────────────────
toast(msg) {
playSound(this, SFX.PIECE_CLICK);
const t = this.add.text(960, 560, msg, {
fontFamily: 'Righteous', fontSize: '46px', color: COLORS.goldHex,
}).setOrigin(0.5).setDepth(DEPTH.toast).setAngle(-4);
this.tweens.add({ targets: t, scale: { from: 0.7, to: 1.05 }, duration: 200, ease: 'Back.Out' });
return new Promise((resolve) => {
this.time.delayedCall(850, () => {
this.tweens.add({ targets: t, alpha: 0, duration: 200, onComplete: () => { t.destroy(); resolve(); } });
});
});
}
delay(ms) { return new Promise((resolve) => this.time.delayedCall(ms, resolve)); }
}
// Short tile tag for button labels, e.g. "5 Circle" or "East".
function shortName(kind) {
if (isSuited(kind)) return `${rankOf(kind)} ${SUIT_NAMES[Math.floor(kind / 9)]}`;
return TILES[kind].name.replace(' Wind', '').replace(' Dragon', '');
}

View File

@ -0,0 +1,602 @@
// 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 };
}

View File

@ -0,0 +1,91 @@
# Welcome to the Mahjong Table!
*By Auntie Mei — teahouse owner, undefeated since 1987, owner of the loudest tile-shuffle in Kowloon*
---
Ahhh, you came! Sit, sit. Pour yourself some tea — the pu-erh, not the cheap stuff, you are my guest. You hear that sound? *Clack clack clack.* That is the sound of one hundred and forty-four tiles being shuffled, and it is the most beautiful sound in the world. My grandmother taught me this game, her grandmother taught her, and now Auntie Mei teaches you. Pay attention. There WILL be a quiz, and the quiz is me taking all your points.
## What's the Goal?
You and three opponents each build a hand of tiles. The first player to complete a winning hand — **four sets and a pair** — shouts *"Sik wu!"* (or clicks the big Mahjong button, very dignified) and collects points from the others.
A **set** is one of:
- **Chow** — three tiles in a row of the same suit, like 4-5-6 of Circles
- **Pung** — three identical tiles
- **Kong** — four identical tiles (a special pung with a bonus draw)
A **pair** is two identical tiles. Four sets plus the pair makes 14 tiles. That's it. That is the whole shape of the game. Everything else is seasoning.
One session is **one full round**: every player gets to be the dealer (East) at least once. When the fourth dealer finally loses the deal, we count the points, and whoever has the most buys the dim sum. I mean, wins.
## The Tiles
Three suits run 1 to 9: **Bamboo**, **Circles**, and **Characters**. Four of each tile. Then the honor tiles: the four **Winds** (East, South, West, North) and the three **Dragons** (Red, Green, White — the white one is the blank tile; my nephew thought it was broken; he is no longer invited).
There are also eight **bonus tiles** — four Flowers and four Seasons. They are not part of your hand. When you draw one, it is set aside face up and you draw a replacement tile. Free decoration, free faan. We like the flowers very much.
## How a Turn Works
On your turn you **draw one tile** from the wall, then **discard one tile** face up in front of you. That's the whole rhythm: draw, discard, draw, discard, *clack clack*. Your hand stays at 13 tiles between turns.
But here is where mahjong becomes MAHJONG — other players' discards are not garbage. They are opportunity:
- **Pung!** — anyone holding two of the discarded tile may claim it for a pung. Play jumps to them.
- **Kong!** — anyone holding three of it may claim it for a kong, and takes a replacement tile from the back of the wall.
- **Chow** — only the player to the discarder's **left** (that's the next player) may claim it to complete a run.
- **Mahjong!** — anyone may claim any discard that completes their winning hand. This beats every other claim.
Claimed sets are placed face up beside your hand. They still count toward your four sets, but everyone can see what you're collecting — and a hand with no claimed sets earns a bonus for staying **concealed**. Choices, choices.
If the wall runs out before anyone wins, the hand is a draw — nobody pays, and the same dealer redeals. The dealer also keeps the deal whenever the dealer wins. A hot dealer can run the table for a long time. (It is usually me.)
## You Need Faan to Win
You cannot win with just *any* complete hand — a hand worth zero is a "chicken hand," and Auntie Mei's table does not pay chickens. Your hand must be worth at least **1 faan**. Press the **Hands** button in the corner any time to slide out the full list of scoring hands. Keep it open while you learn — that is what it is for.
The big ones:
| Hand | Faan |
|---|---|
| Common Hand (all chows + suited pair) | 1 |
| All Pungs | 3 |
| Mixed One Suit (one suit + honors) | 3 |
| Small Dragons | 5 |
| Pure One Suit | 6 |
| Small Winds | 6 |
| Great Dragons | 8 |
| Great Winds / All Honors / Thirteen Orphans | 13 (limit) |
And the seasoning faan: each Dragon pung is 1, a pung of your own seat wind or of East (the round wind) is 1, winning by self-draw is 1, a fully concealed hand is 1, each Flower or Season matching your seat is 1, and finishing with *no* bonus tiles at all is 1. They stack — that is how a humble hand becomes an expensive one.
## Getting Paid
Faan converts to base points on a doubling ladder (1 faan = 2, 2 = 4, 3 = 8... capped at the 13-faan limit).
- **Win by discard** — the player who threw the tile pays you **double** the base, alone. They will apologize. Do not accept the apology, accept the points.
- **Win by self-draw***everyone* pays you the base. This is why self-draw is the sweetest win.
## Auntie Mei's Teahouse Wisdom
**1. Decide your hand early.** Look at your 13 tiles. Are they leaning toward one suit? Toward pungs? A plan worth 3 faan beats a fast plan worth nothing — remember, zero faan cannot even win.
**2. Do not claim everything.** Every pung you claim shows the table your plan and kills your concealed bonus. Claim when it truly advances you. My late husband claimed every pung he saw. *Every one.* He never beat me once.
**3. Watch the discards.** If a player has three exposed sets, they are one tile from glory. Throwing a fresh, never-seen tile at them is how you end up paying double. When in doubt, discard a tile someone already threw — it passed through the table safely once.
**4. Honor tiles first, usually.** A lone West wind when you are not West and it is not the round wind? It earns nothing and chows with nothing. Out it goes, early, while it is still safe to throw.
**5. Respect the flowers.** Your own flower or season is a free faan. All four flowers is free faan *plus* the table's envy, which is worth even more.
**6. The dealer is dangerous.** The dealer keeps the deal by winning. If the dealer is hot, sometimes the wisest hand is a fast, cheap one that simply ends their turn. Throw water on the fire.
## The AI Opponents
The computer players come in skill levels 1 to 5. The low ones claim every tile like my husband, rest his soul. The high ones count what they've seen, fold their plans around your discards, and stop throwing you winning tiles right when you need them. Treat a skill-5 opponent like a Kowloon grandmother: politely, and with great fear.
---
That's the game. Draw, discard, claim wisely, count your faan, and never — NEVER — throw the tile that wins it for someone else. More tea? Good. The tiles are shuffled. *Clack clack.*
Sik wu! 🀄🍵

View File

@ -67,6 +67,7 @@ import PuddingMonstersGame from './games/puddingmonsters/PuddingMonstersGame.js'
import ShiftGame from './games/shift/ShiftGame.js';
import BlockFighterGame from './games/blockfighter/BlockFighterGame.js';
import MahjongMatchGame from './games/mahjongmatch/MahjongMatchGame.js';
import MahjongGame from './games/mahjong/MahjongGame.js';
const config = {
type: Phaser.AUTO,
@ -147,6 +148,7 @@ const config = {
ShiftGame,
BlockFighterGame,
MahjongMatchGame,
MahjongGame,
],
};

View File

@ -22,7 +22,7 @@ export default class GameRoomScene extends Phaser.Scene {
}
create() {
const slugDispatch = { backgammon: 'Backgammon', holdem: 'HoldemGame', blackjack: 'BlackjackGame', parchisi: 'ParchisiGame', yatzi: 'YatziGame', skipbo: 'SkipBoGame', phase10: 'Phase10Game', chinesecheckers: 'ChineseCheckersGame', gofish: 'GoFishGame', uno: 'UnoGame', craps: 'CrapsGame', roulette: 'RouletteGame', mexicantrain: 'MexicanTrainGame', hearts: 'HeartsGame', catan: 'CatanGame', tickettoride: 'TicketToRideGame', nerts: 'NertsGame', bingo: 'BingoGame', baccarat: 'BaccaratGame', dominion: 'DominionGame', checkers: 'CheckersGame', chess: 'ChessGame', wordle: 'WordleGame', scrabble: 'ScrabbleGame', ghost: 'GhostGame', wordladder: 'WordLadderGame', wordsearch: 'WordSearchGame', hangman: 'HangmanGame', sudoku: 'SudokuGame', othello: 'OthelloGame', go: 'GoGame', battleship: 'BattleshipGame', mastermind: 'MastermindGame', connect4: 'Connect4Game', boggle: 'BoggleGame', oldmaid: 'OldMaidGame', blokus: 'BlokusGame', spellingbee: 'SpellingBeeGame', minicrossword: 'MiniCrosswordGame', forbiddenisland: 'ForbiddenIslandGame', solitairetour: 'SolitaireTourGame', splendor: 'SplendorGame', tectonic: 'TectonicGame', labyrinth: 'LabyrinthGame', videopoker: 'VideoPokerGame', farkel: 'FarkelGame', stratego: 'StrategoGame', kiitos: 'KiitosGame', monopoly: 'MonopolyGame', triominoes: 'TriominoesGame', freecell: 'FreecellGame', rushhour: 'RushHourGame', hexsweeper: 'HexsweeperGame', puddingmonsters: 'PuddingMonstersGame', shift: 'ShiftGame', blockfighter: 'BlockFighterGame', mahjongmatch: 'MahjongMatchGame' };
const slugDispatch = { backgammon: 'Backgammon', holdem: 'HoldemGame', blackjack: 'BlackjackGame', parchisi: 'ParchisiGame', yatzi: 'YatziGame', skipbo: 'SkipBoGame', phase10: 'Phase10Game', chinesecheckers: 'ChineseCheckersGame', gofish: 'GoFishGame', uno: 'UnoGame', craps: 'CrapsGame', roulette: 'RouletteGame', mexicantrain: 'MexicanTrainGame', hearts: 'HeartsGame', catan: 'CatanGame', tickettoride: 'TicketToRideGame', nerts: 'NertsGame', bingo: 'BingoGame', baccarat: 'BaccaratGame', dominion: 'DominionGame', checkers: 'CheckersGame', chess: 'ChessGame', wordle: 'WordleGame', scrabble: 'ScrabbleGame', ghost: 'GhostGame', wordladder: 'WordLadderGame', wordsearch: 'WordSearchGame', hangman: 'HangmanGame', sudoku: 'SudokuGame', othello: 'OthelloGame', go: 'GoGame', battleship: 'BattleshipGame', mastermind: 'MastermindGame', connect4: 'Connect4Game', boggle: 'BoggleGame', oldmaid: 'OldMaidGame', blokus: 'BlokusGame', spellingbee: 'SpellingBeeGame', minicrossword: 'MiniCrosswordGame', forbiddenisland: 'ForbiddenIslandGame', solitairetour: 'SolitaireTourGame', splendor: 'SplendorGame', tectonic: 'TectonicGame', labyrinth: 'LabyrinthGame', videopoker: 'VideoPokerGame', farkel: 'FarkelGame', stratego: 'StrategoGame', kiitos: 'KiitosGame', monopoly: 'MonopolyGame', triominoes: 'TriominoesGame', freecell: 'FreecellGame', rushhour: 'RushHourGame', hexsweeper: 'HexsweeperGame', puddingmonsters: 'PuddingMonstersGame', shift: 'ShiftGame', blockfighter: 'BlockFighterGame', mahjongmatch: 'MahjongMatchGame', mahjong: 'MahjongGame' };
if (slugDispatch[this.game.slug]) {
this.scene.start(slugDispatch[this.game.slug], {
game: this.game,

View File

@ -384,7 +384,7 @@ export default class OpponentSelectScene extends Phaser.Scene {
// Skill control: pips always show the level; the +/- buttons appear only
// when this opponent is selected. Enabled for games with a 15 AI skill.
if (['nerts', 'checkers', 'chess', 'wordle', 'scrabble', 'ghost', 'wordladder', 'othello', 'go', 'mastermind', 'connect4', 'boggle', 'forbiddenisland', 'labyrinth', 'stratego', 'triominoes'].includes(this.gameDef.slug)) {
if (['nerts', 'checkers', 'chess', 'wordle', 'scrabble', 'ghost', 'wordladder', 'othello', 'go', 'mastermind', 'connect4', 'boggle', 'forbiddenisland', 'labyrinth', 'stratego', 'triominoes', 'mahjong'].includes(this.gameDef.slug)) {
bio.style.webkitLineClamp = '1';
const skillRow = document.createElement('div');

View File

@ -82,3 +82,4 @@ registerGame({ slug: 'puddingmonsters', name: 'Jell-o Monsters', category: '
registerGame({ slug: 'shift', name: 'Shift', category: 'logic', minPlayers: 1, maxPlayers: 1, minOpponents: 0, maxOpponents: 0, iconFrame: 55 });
registerGame({ slug: 'blockfighter', name: 'Block Fighter', category: 'logic', minPlayers: 1, maxPlayers: 1, minOpponents: 0, maxOpponents: 0, hasTutorial: true, iconFrame: 56 });
registerGame({ slug: 'mahjongmatch', name: 'Mahjong Match', category: 'logic', minPlayers: 1, maxPlayers: 1, minOpponents: 0, maxOpponents: 0, iconFrame: 57 });
registerGame({ slug: 'mahjong', name: 'Mahjong', category: 'tabletop', minPlayers: 4, maxPlayers: 4, minOpponents: 3, maxOpponents: 3, hasTutorial: true, iconFrame: 58 });

View File

@ -0,0 +1,292 @@
// Headless verification for Mahjong (Hong Kong style).
// node server/scripts/verifyMahjong.js
// Exits non-zero on any failure.
//
// 1. Tile catalog: 144-tile wall, label assets exist on disk.
// 2. Faan evaluator fixtures: known hands score the expected faan rows.
// 3. Payments: zero-sum, points ladder monotonic and capped.
// 4. AI self-play: full sessions with per-step invariants (tile conservation,
// hand sizes, legal claims, minimum faan, zero-sum scores, termination).
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import {
TILES, buildWall, BASE_POINTS, LIMIT_FAAN, MIN_FAAN,
} from '../../public/src/games/mahjong/MahjongData.js';
import {
createInitialState, dealHand, discardTile, claimOptionsFor, resolveClaims,
selfActions, declareKong, declareWin, startNextHand, isGameOver, getWinners,
counts34, decompose, isThirteenOrphans, canWinWith, winningTiles, shanten,
evaluateWin,
} from '../../public/src/games/mahjong/MahjongLogic.js';
import {
chooseDiscard, chooseClaim, chooseSelfAction,
} from '../../public/src/games/mahjong/MahjongAI.js';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const IMG_DIR = path.join(__dirname, '../../public/assets/images/mahjong');
let failures = 0;
function check(ok, msg) {
if (!ok) { failures++; console.error(`${msg}`); }
return ok;
}
// kind shorthands for fixtures
const b = (n) => n - 1, c = (n) => 9 + n - 1, ch = (n) => 18 + n - 1;
const E = 27, S = 28, W = 29, N = 30, R = 31, G = 32, Wh = 33;
// ── 1. Tile catalog ──────────────────────────────────────────────────────────
console.log('Tile catalog:');
check(TILES.length === 42, `kind count ${TILES.length}, expected 42`);
const wall = buildWall();
check(wall.length === 144, `wall ${wall.length} tiles, expected 144`);
const wc = new Array(42).fill(0);
for (const k of wall) wc[k]++;
for (let k = 0; k < 34; k++) check(wc[k] === 4, `kind ${k} has ${wc[k]} copies, expected 4`);
for (let k = 34; k < 42; k++) check(wc[k] === 1, `bonus kind ${k} has ${wc[k]} copies, expected 1`);
for (const t of TILES) {
if (!t.label) continue; // white dragon is drawn procedurally
const file = path.join(IMG_DIR, `${t.label.replace(/^mahjong-/, '')}.png`);
check(fs.existsSync(file), `missing label asset for ${t.id}: ${file}`);
}
console.log(' ok');
// ── 2. Faan fixtures ─────────────────────────────────────────────────────────
// Build a minimal state around one player's completed hand. For discard wins
// `hand` excludes the winning tile; for self-draws it includes it.
function fixtureState({ hand, melds = [], bonus = [], seat = 0, dealer = 0,
byDiscard, winTile, lastKong = false, wallEmpty = false }) {
const players = [];
for (let s = 0; s < 4; s++) {
players.push({ name: `P${s + 1}`, seat: s, score: 0, skill: 3, isAI: s !== 0,
hand: [], melds: [], bonus: [] });
}
players[seat] = { ...players[seat], hand: hand.slice(), melds, bonus };
return {
players, dealer, roundWind: 0,
wallPos: wallEmpty ? 1 : 0, wallEnd: wallEmpty ? 0 : 90,
lastDrawWasKongReplacement: lastKong,
lastDiscard: byDiscard ? { kind: winTile, from: (seat + 1) % 4 } : null,
};
}
function checkFixture(name, args, expectFaan, mustHave = [], mustNotHave = []) {
const state = fixtureState(args);
const res = evaluateWin(state, args.seat ?? 0, args.winTile, !!args.byDiscard);
const ids = res.faanList.map((r) => r.id);
check(res.faan === expectFaan, `${name}: faan ${res.faan}, expected ${expectFaan} [${ids.join(', ')}]`);
for (const id of mustHave) check(ids.includes(id), `${name}: missing faan row '${id}'`);
for (const id of mustNotHave) check(!ids.includes(id), `${name}: unexpected faan row '${id}'`);
check(res.payments.reduce((a, x) => a + x, 0) === 0, `${name}: payments not zero-sum`);
return res;
}
console.log('Faan fixtures:');
const exposedPung = (k, from = 1) => ({ type: 'pung', kinds: [k, k, k], concealed: false, from });
checkFixture('thirteen orphans',
{ hand: [b(1), b(1), b(9), c(1), c(9), ch(1), ch(9), E, S, W, N, R, G, Wh], byDiscard: false, winTile: b(1) },
13, ['thirteen-orphans']);
checkFixture('pure one suit',
{ hand: [b(1), b(1), b(1), b(2), b(3), b(4), b(4), b(5), b(6), b(7), b(8), b(9), b(9)],
byDiscard: true, winTile: b(9) },
8, ['pure-one-suit', 'concealed', 'no-bonus'], ['mixed-one-suit']);
checkFixture('mixed one suit + round wind',
{ hand: [c(1), c(2), c(3), c(4), c(5), c(6), c(7), c(8), c(9), E, E, R, R],
seat: 1, byDiscard: true, winTile: E },
6, ['mixed-one-suit', 'round-wind', 'concealed', 'no-bonus'], ['seat-wind', 'pure-one-suit']);
checkFixture('all pungs (exposed)',
{ hand: [b(2), b(2), c(5), c(5), c(5), ch(7), ch(7), ch(7), b(9), b(9)],
melds: [exposedPung(N)], seat: 2, byDiscard: true, winTile: b(2) },
4, ['all-pungs', 'no-bonus'], ['concealed', 'seat-wind', 'round-wind']);
checkFixture('common hand',
{ hand: [b(1), b(2), b(3), b(4), b(5), b(6), c(1), c(2), c(3), ch(4), ch(5), ch(6), c(9)],
byDiscard: true, winTile: c(9) },
3, ['common-hand', 'concealed', 'no-bonus']);
checkFixture('chicken hand scores zero',
{ hand: [c(2), c(3), c(4), c(5), c(6), c(7), ch(3), ch(4), ch(8), ch(8)],
melds: [exposedPung(b(2))], bonus: [35], byDiscard: true, winTile: ch(5) },
0, [], ['no-bonus', 'seat-flower', 'common-hand']);
checkFixture('dealer east pung stacks seat + round wind',
{ hand: [b(2), b(3), b(5), b(6), b(7), ch(3), ch(3), ch(3), c(9), c(9)],
melds: [exposedPung(E)], seat: 0, dealer: 0, byDiscard: true, winTile: b(4) },
3, ['seat-wind', 'round-wind', 'no-bonus']);
checkFixture('great dragons',
{ hand: [Wh, Wh, Wh, b(2), b(3), c(5), c(5)],
melds: [exposedPung(R), exposedPung(G)], byDiscard: true, winTile: b(4) },
9, ['great-dragons', 'no-bonus'], ['dragon-pung', 'small-dragons']);
checkFixture('small dragons',
{ hand: [Wh, Wh, b(2), b(3), b(4), c(6), c(7)],
melds: [exposedPung(R), exposedPung(G)], byDiscard: true, winTile: c(8) },
6, ['small-dragons', 'no-bonus'], ['dragon-pung', 'great-dragons']);
checkFixture('all honors capped at limit',
{ hand: [E, E, E, S, S, S, R, R, R, G, G, G, N, N], byDiscard: false, winTile: N },
13, ['all-honors'], ['all-pungs', 'mixed-one-suit']);
checkFixture('self draw context',
{ hand: [b(1), b(2), b(3), b(4), b(5), b(6), c(1), c(2), c(3), ch(4), ch(5), ch(6), c(9), c(9)],
byDiscard: false, winTile: c(9) },
4, ['common-hand', 'self-draw', 'concealed', 'no-bonus']);
checkFixture('kong replacement context',
{ hand: [b(1), b(2), b(3), b(4), b(5), b(6), c(1), c(2), c(3), ch(4), ch(5), ch(6), c(9), c(9)],
byDiscard: false, winTile: c(9), lastKong: true },
5, ['kong-draw', 'self-draw']);
checkFixture('ambiguous 111222333 read as chows',
{ hand: [b(1), b(1), b(1), b(2), b(2), b(2), b(3), b(3), b(3), ch(7), ch(8), ch(9), c(5)],
byDiscard: true, winTile: c(5) },
3, ['common-hand', 'concealed', 'no-bonus']);
checkFixture('flowers: own + full set',
{ hand: [b(1), b(2), b(3), b(4), b(5), b(6), c(1), c(2), c(3), ch(4), ch(5), ch(6), c(9)],
bonus: [34, 35, 36, 37], byDiscard: true, winTile: c(9) },
5, ['common-hand', 'concealed', 'seat-flower', 'flower-set'], ['no-bonus']);
// decomposer / shanten sanity
{
const tenpai = counts34([b(1), b(2), b(3), b(4), b(5), b(6), c(1), c(2), c(3), ch(4), ch(5), ch(6), c(9)]);
check(shanten(tenpai, 0) === 0, `tenpai hand shanten ${shanten(tenpai, 0)}, expected 0`);
const complete = counts34([b(1), b(2), b(3), b(4), b(5), b(6), c(1), c(2), c(3), ch(4), ch(5), ch(6), c(9), c(9)]);
check(shanten(complete, 0) === -1, `complete hand shanten ${shanten(complete, 0)}, expected -1`);
check(decompose(complete, 4).length > 0, 'complete hand fails to decompose');
const waits = winningTiles([b(1), b(2), b(3), b(4), b(5), b(6), c(1), c(2), c(3), ch(4), ch(5), ch(6), c(9)], []);
check(waits.length === 1 && waits[0] === c(9), `waits [${waits.join(',')}], expected [${c(9)}]`);
check(canWinWith([b(1), b(1), b(9), c(1), c(9), ch(1), ch(9), E, S, W, N, R, G], [], Wh),
'thirteen orphans not recognised by canWinWith');
check(isThirteenOrphans(counts34([b(1), b(1), b(9), c(1), c(9), ch(1), ch(9), E, S, W, N, R, G, Wh])),
'isThirteenOrphans rejects a valid hand');
}
console.log(` ${failures === 0 ? 'ok' : 'see failures above'}`);
// ── 3. Points ladder ─────────────────────────────────────────────────────────
console.log('Points ladder:');
check(BASE_POINTS.length === LIMIT_FAAN + 1, 'BASE_POINTS does not cover 0..LIMIT_FAAN');
for (let i = 1; i < BASE_POINTS.length; i++) {
check(BASE_POINTS[i] > BASE_POINTS[i - 1], `BASE_POINTS not monotonic at faan ${i}`);
}
console.log(' ok');
// ── 4. AI self-play ──────────────────────────────────────────────────────────
console.log('Self-play (40 sessions, mixed skills):');
function tileConservation(state) {
let total = state.wallEnd - state.wallPos + 1;
for (const p of state.players) {
total += p.hand.length + p.bonus.length;
for (const m of p.melds) total += m.kinds.length;
}
for (const river of state.discards) total += river.length;
return total;
}
function checkInvariants(state, tag) {
if (state.phase === 'handOver' || state.phase === 'gameOver') return;
const total = tileConservation(state);
check(total === 144, `${tag}: ${total} tiles accounted for, expected 144`);
for (const p of state.players) {
const drawing = state.phase === 'awaitDiscard' && state.turn === p.seat;
const expected = (drawing ? 14 : 13) - 3 * p.melds.length;
check(p.hand.length === expected,
`${tag}: seat ${p.seat} hand ${p.hand.length}, expected ${expected} (${state.phase})`);
}
}
function playSession(seed) {
const skills = { 0: 1 + (seed % 5), 1: 1 + ((seed >> 2) % 5), 2: 3, 3: 5 };
const state = createInitialState({ names: ['A', 'B', 'C', 'D'], skills, seed });
const dealersSeen = new Set();
let hands = 0, wins = 0, draws = 0, claims = 0, kongs = 0;
let guard = 100000;
while (!isGameOver(state) && guard-- > 0) {
checkInvariants(state, `seed ${seed} hand ${state.handNumber}`);
if (state.phase === 'handOver') {
dealersSeen.add(state.dealer);
hands++;
const r = state.result;
if (r.type === 'win') {
wins++;
check(r.faan >= MIN_FAAN, `seed ${seed}: win below minimum faan (${r.faan})`);
check(r.payments.reduce((a, x) => a + x, 0) === 0, `seed ${seed}: payments not zero-sum`);
} else {
draws++;
}
const sum = state.players.reduce((a, p) => a + p.score, 0);
check(sum === 0, `seed ${seed}: cumulative scores sum to ${sum}, expected 0`);
startNextHand(state);
continue;
}
if (state.phase === 'awaitDiscard') {
const seat = state.turn;
const p = state.players[seat];
const acts = selfActions(state);
const sa = chooseSelfAction(state, seat, acts, p.skill);
if (sa?.type === 'win') {
check(acts.canWin, `seed ${seed}: AI declared an unoffered win`);
declareWin(state, seat, { byDiscard: false });
continue;
}
if (sa?.type === 'kong') {
kongs++;
check(declareKong(state, sa.spec), `seed ${seed}: illegal kong ${JSON.stringify(sa.spec)}`);
continue;
}
const d = chooseDiscard(state, seat, p.skill);
check(p.hand.includes(d), `seed ${seed}: AI discarded unheld kind ${d}`);
discardTile(state, d);
continue;
}
if (state.phase === 'awaitClaims') {
const from = state.lastDiscard.from;
const intents = [];
for (let seat = 0; seat < 4; seat++) {
const options = claimOptionsFor(state, seat);
if (!options) continue;
check(seat !== from, `seed ${seed}: discarder offered a claim`);
if (options.chows.length) {
check(seat === (from + 1) % 4, `seed ${seed}: chow offered to non-left seat`);
}
const claim = chooseClaim(state, seat, options, state.players[seat].skill);
if (claim) intents.push({ seat, claim });
}
const { applied } = resolveClaims(state, intents);
if (applied && applied.claim.type !== 'win') claims++;
continue;
}
check(false, `seed ${seed}: unknown phase ${state.phase}`);
break;
}
check(guard > 0, `seed ${seed}: session did not terminate`);
check(dealersSeen.size === 4 || guard <= 0, `seed ${seed}: only ${dealersSeen.size}/4 seats dealt`);
check(getWinners(state).length >= 1, `seed ${seed}: no session winner`);
return { hands, wins, draws, claims, kongs };
}
let tHands = 0, tWins = 0, tDraws = 0, tClaims = 0, tKongs = 0;
const t0 = Date.now();
for (let g = 0; g < 40; g++) {
const r = playSession(1000 + g * 7919);
tHands += r.hands; tWins += r.wins; tDraws += r.draws; tClaims += r.claims; tKongs += r.kongs;
}
const secs = ((Date.now() - t0) / 1000).toFixed(1);
console.log(` ${tHands} hands over 40 sessions in ${secs}s — ${tWins} wins, ${tDraws} drawn, ${tClaims} claims, ${tKongs} kongs`);
check(tWins > 0, 'no hand was ever won — evaluator or AI suspect');
check(tWins / Math.max(1, tHands) > 0.3, `only ${tWins}/${tHands} hands won — too many goulash draws`);
if (failures) {
console.error(`\nFAILED: ${failures} check(s).`);
process.exit(1);
}
console.log('\nAll Mahjong checks passed.');