feat: add Mahjong Match solitaire game with 6 layouts and guaranteed-solvable deals

- Add complete Mahjong Match game (MahjongLogic.js + MahjongMatchGame.js) with pure board model and Phaser UI
- Implement 6 layouts: Garden, Crossroads, Pyramid, Butterfly, Fortress, and classic Turtle (up to 144 tiles)
- Generate guaranteed-solvable deals by simulating reverse removal of free pairs
- Add tile matching logic using half-tile coordinate system for proper layer overlap detection
- Include 38 tile face images (bamboo, circle, pinyin characters, winds, dragons, flowers, seasons)
- Add hint system, shuffle functionality, timer, best time tracking per layout
- Register game in server registry and scene dispatch tables
- Add headless verification script for face set validation and random self-play testing
This commit is contained in:
Brian Fertig 2026-06-11 16:23:17 -06:00
parent 795ef9b11f
commit dd749bc570
48 changed files with 1063 additions and 1 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

View File

@ -0,0 +1,389 @@
// Mahjong Match — pure board model for mahjong solitaire tile matching.
// No Phaser, no DOM. Self-contained so it can be unit-tested in Node.
//
// Coordinates are in half-tile units: a tile is 2 units wide × 2 units tall,
// so two tiles at the same z overlap iff |dx| < 2 and |dy| < 2. This allows
// the half-step offsets classic layouts need (e.g. the Turtle's apex tile
// straddles the four tiles beneath it). Layer z+1 rests on layer z.
//
// A tile is FREE when no tile covers it from above and at least one of its
// left/right sides is open. Matching free pairs of the same group are
// removed; the board is won when empty.
//
// Deals are generated by simulating the game in reverse — repeatedly pick
// two free positions from the full layout, remove them, and assign them the
// next tile pair. The removal order is itself a solution, so every deal is
// guaranteed winnable.
// ── Tile faces ────────────────────────────────────────────────────────────────
// `label` is the texture key for the overlay art (loaded in PreloadScene from
// /assets/images/mahjong). The White Dragon has no art — traditionally it is
// just an empty frame, which the scene draws procedurally (label: null).
// Tiles match when their `group` matches: every face is its own group except
// the four Flowers and four Seasons, which match within their family.
// pinyin sheet index for character (萬) tiles 1..9
const CHAR_LABEL = [13, 14, 15, 7, 8, 9, 10, 11, 12];
export const FACES = (() => {
const faces = [];
for (let n = 1; n <= 9; n++) {
faces.push({ id: `bamboo${n}`, group: `bamboo${n}`, label: `mahjong-bamboo${n}`, copies: 4 });
}
for (let n = 1; n <= 9; n++) {
faces.push({ id: `circle${n}`, group: `circle${n}`, label: `mahjong-circle${n}`, copies: 4 });
}
for (let n = 1; n <= 9; n++) {
faces.push({ id: `char${n}`, group: `char${n}`, label: `mahjong-pinyin${CHAR_LABEL[n - 1]}`, copies: 4 });
}
faces.push({ id: 'wind-east', group: 'wind-east', label: 'mahjong-pinyin4', copies: 4 });
faces.push({ id: 'wind-south', group: 'wind-south', label: 'mahjong-pinyin3', copies: 4 });
faces.push({ id: 'wind-west', group: 'wind-west', label: 'mahjong-pinyin6', copies: 4 });
faces.push({ id: 'wind-north', group: 'wind-north', label: 'mahjong-pinyin5', copies: 4 });
faces.push({ id: 'dragon-red', group: 'dragon-red', label: 'mahjong-pinyin1', copies: 4 });
faces.push({ id: 'dragon-green', group: 'dragon-green', label: 'mahjong-pinyin2', copies: 4 });
faces.push({ id: 'dragon-white', group: 'dragon-white', label: null, copies: 4 });
for (const f of ['orchid', 'peony', 'chrysanthemum', 'lotus']) {
faces.push({ id: f, group: 'flower', label: `mahjong-${f}`, copies: 1 });
}
for (const s of ['spring', 'summer', 'fall', 'winter']) {
faces.push({ id: s, group: 'season', label: `mahjong-${s}`, copies: 1 });
}
return faces;
})();
// ── Layouts ───────────────────────────────────────────────────────────────────
function grid(out, x0, y0, cols, rows, z) {
for (let r = 0; r < rows; r++) {
for (let c = 0; c < cols; c++) out.push({ x: x0 + 2 * c, y: y0 + 2 * r, z });
}
return out;
}
function dedupe(positions) {
const seen = new Set();
const out = [];
for (const t of positions) {
const k = `${t.x},${t.y},${t.z}`;
if (!seen.has(k)) { seen.add(k); out.push(t); }
}
return out;
}
// Easy warm-up: a 10×6 bed with a small raised terrace. 72 tiles.
function garden() {
const p = [];
grid(p, 0, 0, 10, 6, 0);
grid(p, 6, 3, 4, 3, 1);
return p;
}
// A plus-sign of two crossing bars with a raised hub. 90 tiles.
function crossroads() {
let p = [];
grid(p, 0, 4, 14, 4, 0);
grid(p, 8, 0, 6, 8, 0);
p = dedupe(p);
grid(p, 10, 6, 4, 2, 1);
p.push({ x: 12, y: 7, z: 2 }, { x: 14, y: 7, z: 2 });
return p;
}
// Four shrinking tiers with a two-tile cap. 106 tiles.
function pyramid() {
const p = [];
grid(p, 0, 0, 10, 6, 0);
grid(p, 2, 1, 8, 4, 1);
grid(p, 4, 3, 6, 2, 2);
p.push({ x: 8, y: 4, z: 3 }, { x: 10, y: 4, z: 3 });
return p;
}
// Two stacked wings around a tall body. 140 tiles.
function butterfly() {
const p = [];
grid(p, 0, 2, 6, 6, 0); // left wing
grid(p, 18, 2, 6, 6, 0); // right wing
grid(p, 13, 0, 2, 8, 0); // body
grid(p, 2, 4, 4, 4, 1);
grid(p, 20, 4, 4, 4, 1);
grid(p, 13, 2, 2, 6, 1);
grid(p, 4, 6, 2, 2, 2);
grid(p, 22, 6, 2, 2, 2);
return p;
}
// A solid courtyard ringed by a raised rampart. 144 tiles.
function fortress() {
const p = [];
grid(p, 0, 0, 14, 8, 0);
grid(p, 2, 2, 12, 1, 1); // north wall
grid(p, 2, 12, 12, 1, 1); // south wall
grid(p, 2, 4, 1, 4, 1); // west wall
grid(p, 24, 4, 1, 4, 1); // east wall
return p;
}
// The classic 144-tile Turtle.
function turtle() {
const p = [];
const widths = [12, 8, 10, 12, 12, 10, 8, 12];
widths.forEach((w, i) => grid(p, 14 - w, 2 * i, w, 1, 0));
p.push({ x: 0, y: 7, z: 0 }); // left flipper
p.push({ x: 26, y: 7, z: 0 }, { x: 28, y: 7, z: 0 }); // right flippers
grid(p, 8, 2, 6, 6, 1);
grid(p, 10, 4, 4, 4, 2);
grid(p, 12, 6, 2, 2, 3);
p.push({ x: 13, y: 7, z: 4 }); // apex, straddling
return p;
}
export const LAYOUTS = {
garden: { key: 'garden', name: 'Garden', desc: 'A gentle open spread', positions: garden() },
crossroads: { key: 'crossroads', name: 'Crossroads', desc: 'Two bars meet at a hub', positions: crossroads() },
pyramid: { key: 'pyramid', name: 'Pyramid', desc: 'Four tiers to the top', positions: pyramid() },
butterfly: { key: 'butterfly', name: 'Butterfly', desc: 'Stacked wings, tall body', positions: butterfly() },
fortress: { key: 'fortress', name: 'Fortress', desc: 'A walled courtyard', positions: fortress() },
turtle: { key: 'turtle', name: 'Turtle', desc: 'The timeless classic', positions: turtle() },
};
export const LAYOUT_ORDER = ['garden', 'crossroads', 'pyramid', 'butterfly', 'fortress', 'turtle'];
export function layoutBounds(positions) {
let maxX = 0, maxY = 0, maxZ = 0;
for (const t of positions) {
if (t.x > maxX) maxX = t.x;
if (t.y > maxY) maxY = t.y;
if (t.z > maxZ) maxZ = t.z;
}
return { spanX: maxX + 2, spanY: maxY + 2, maxZ };
}
// ── Geometry ──────────────────────────────────────────────────────────────────
export function overlapsXY(a, b) {
return Math.abs(a.x - b.x) < 2 && Math.abs(a.y - b.y) < 2;
}
// Per-position index lists: tiles directly above, and side neighbours that
// block the left/right edge. Computed once per game so free checks are O(k).
function buildAdjacency(positions) {
const n = positions.length;
const above = Array.from({ length: n }, () => []);
const left = Array.from({ length: n }, () => []);
const right = Array.from({ length: n }, () => []);
for (let i = 0; i < n; i++) {
const a = positions[i];
for (let j = 0; j < n; j++) {
if (i === j) continue;
const b = positions[j];
if (b.z === a.z + 1 && overlapsXY(a, b)) above[i].push(j);
if (b.z === a.z && Math.abs(b.y - a.y) < 2) {
if (b.x === a.x - 2) left[i].push(j);
if (b.x === a.x + 2) right[i].push(j);
}
}
}
return { above, left, right };
}
function freeInMask(adj, alive, i) {
if (!alive[i]) return false;
for (const j of adj.above[i]) if (alive[j]) return false;
let L = false, R = false;
for (const j of adj.left[i]) if (alive[j]) { L = true; break; }
if (L) for (const j of adj.right[i]) if (alive[j]) { R = true; break; }
return !(L && R);
}
// ── Deal generation ───────────────────────────────────────────────────────────
function shuffle(arr) {
for (let i = arr.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[arr[i], arr[j]] = [arr[j], arr[i]];
}
return arr;
}
// 72 matchable pairs from the full 144-tile set. Normal faces pair with their
// own duplicates; the one-of-each Flowers and Seasons pair within their family.
export function buildPairPool() {
const byGroup = new Map();
for (const f of FACES) {
if (!byGroup.has(f.group)) byGroup.set(f.group, []);
for (let c = 0; c < f.copies; c++) byGroup.get(f.group).push(f);
}
const pairs = [];
for (const members of byGroup.values()) {
shuffle(members);
for (let k = 0; k + 1 < members.length; k += 2) pairs.push([members[k], members[k + 1]]);
}
return pairs;
}
// Simulate removing free pairs until the masked board is empty. Returns the
// removal order (a valid solution) or null when the random walk strands tiles.
function reverseRemovalOrder(adj, mask) {
const alive = mask.slice();
let remaining = 0;
for (const a of alive) if (a) remaining++;
const order = [];
while (remaining > 0) {
const free = [];
for (let i = 0; i < alive.length; i++) if (freeInMask(adj, alive, i)) free.push(i);
if (free.length < 2) return null;
const ai = Math.floor(Math.random() * free.length);
let bi = Math.floor(Math.random() * (free.length - 1));
if (bi >= ai) bi++;
const a = free[ai], b = free[bi];
order.push([a, b]);
alive[a] = false;
alive[b] = false;
remaining -= 2;
}
return order;
}
// Assign faces to every position so the board is solvable. Falls back to a
// blind shuffle (very rare — only if 300 random removal walks all strand).
function dealFaces(positions, adj) {
const n = positions.length;
const fullMask = positions.map(() => true);
for (let attempt = 0; attempt < 300; attempt++) {
const order = reverseRemovalOrder(adj, fullMask);
if (!order) continue;
const pairs = shuffle(buildPairPool()).slice(0, n / 2);
const faces = new Array(n);
order.forEach(([a, b], k) => { faces[a] = pairs[k][0]; faces[b] = pairs[k][1]; });
return { faces, solvable: true };
}
const pairs = shuffle(buildPairPool()).slice(0, n / 2);
const idx = shuffle(positions.map((_, i) => i));
const faces = new Array(n);
pairs.forEach(([fa, fb], k) => { faces[idx[2 * k]] = fa; faces[idx[2 * k + 1]] = fb; });
return { faces, solvable: false };
}
// ── Game state ────────────────────────────────────────────────────────────────
export function newGame(layoutKey) {
const layout = LAYOUTS[layoutKey] ?? LAYOUTS.garden;
const positions = layout.positions;
const adj = buildAdjacency(positions);
const { faces } = dealFaces(positions, adj);
return {
layoutKey: layout.key,
positions,
adj,
faces,
alive: positions.map(() => true),
remaining: positions.length,
state: 'playing', // 'playing' | 'won'
};
}
export function isFree(g, i) {
return freeInMask(g.adj, g.alive, i);
}
export function canMatch(g, i, j) {
return i !== j && g.faces[i].group === g.faces[j].group;
}
// Remove a matching free pair. Returns true if the move was legal.
export function removePair(g, i, j) {
if (g.state !== 'playing') return false;
if (!canMatch(g, i, j) || !isFree(g, i) || !isFree(g, j)) return false;
g.alive[i] = false;
g.alive[j] = false;
g.remaining -= 2;
if (g.remaining === 0) g.state = 'won';
return true;
}
// Every currently playable move as [i, j] index pairs (used for the hint
// button, the moves-available counter, and stuck detection).
export function findMoves(g) {
const byGroup = new Map();
for (let i = 0; i < g.positions.length; i++) {
if (!isFree(g, i)) continue;
const grp = g.faces[i].group;
if (!byGroup.has(grp)) byGroup.set(grp, []);
byGroup.get(grp).push(i);
}
const moves = [];
for (const members of byGroup.values()) {
for (let a = 0; a < members.length; a++) {
for (let b = a + 1; b < members.length; b++) moves.push([members[a], members[b]]);
}
}
return moves;
}
// Redistribute the remaining faces over the remaining positions so the rest
// of the board is solvable again. Returns false when no solvable arrangement
// was found (the tiles are then shuffled blindly — e.g. two tiles stacked
// directly on one another can never be freed, no matter the arrangement).
export function reshuffleRemaining(g) {
if (g.state !== 'playing' || g.remaining < 2) return true;
// Pair the surviving faces by group. Group counts stay even because every
// removal takes two tiles of one group.
const byGroup = new Map();
const aliveIdx = [];
for (let i = 0; i < g.positions.length; i++) {
if (!g.alive[i]) continue;
aliveIdx.push(i);
const grp = g.faces[i].group;
if (!byGroup.has(grp)) byGroup.set(grp, []);
byGroup.get(grp).push(g.faces[i]);
}
const pairs = [];
const leftovers = [];
for (const members of byGroup.values()) {
shuffle(members);
for (let k = 0; k + 1 < members.length; k += 2) pairs.push([members[k], members[k + 1]]);
if (members.length % 2) leftovers.push(members[members.length - 1]);
}
for (let k = 0; k + 1 < leftovers.length; k += 2) pairs.push([leftovers[k], leftovers[k + 1]]);
for (let attempt = 0; attempt < 300; attempt++) {
const order = reverseRemovalOrder(g.adj, g.alive);
if (!order) continue;
order.forEach(([a, b], k) => { g.faces[a] = pairs[k][0]; g.faces[b] = pairs[k][1]; });
return true;
}
shuffle(aliveIdx);
pairs.forEach(([fa, fb], k) => { g.faces[aliveIdx[2 * k]] = fa; g.faces[aliveIdx[2 * k + 1]] = fb; });
return false;
}
// ── Layout validation (used by the verify script) ─────────────────────────────
export function validateLayout(positions) {
const errors = [];
if (positions.length % 2) errors.push(`odd tile count ${positions.length}`);
const seen = new Set();
for (const t of positions) {
const k = `${t.x},${t.y},${t.z}`;
if (seen.has(k)) errors.push(`duplicate position ${k}`);
seen.add(k);
}
for (let i = 0; i < positions.length; i++) {
const a = positions[i];
for (let j = i + 1; j < positions.length; j++) {
const b = positions[j];
if (a.z === b.z && overlapsXY(a, b)) {
errors.push(`overlap at z${a.z}: (${a.x},${a.y}) vs (${b.x},${b.y})`);
}
}
if (a.z > 0) {
const supported = positions.some((b) => b.z === a.z - 1 && overlapsXY(a, b));
if (!supported) errors.push(`unsupported tile (${a.x},${a.y},${a.z})`);
}
}
return errors;
}

View File

@ -0,0 +1,542 @@
import * as Phaser from 'phaser';
import { GAME_WIDTH, GAME_HEIGHT, COLORS } from '../../config.js';
import { Button } from '../../ui/Button.js';
import { MusicPlayer } from '../../ui/MusicPlayer.js';
import { playSound, SFX } from '../../ui/Sounds.js';
import { api } from '../../services/api.js';
import {
LAYOUTS, LAYOUT_ORDER, layoutBounds,
newGame, isFree, canMatch, removePair, findMoves, reshuffleRemaining,
} from './MahjongLogic.js';
// Deep-green felt with ivory tiles — the classic mahjong table look.
const FELT = 0x0e2a1c;
const FACE = 0xf6efdb;
const FACE_HOVER = 0xfff9e8;
const FACE_PICKED = 0xffdf9e;
const FACE_EDGE = 0x8d7c52;
const PICK_EDGE = 0xff9d00;
const SIDE = 0xb59c66;
const DRAGON_BLUE = 0x3f6bb5;
const DIM_TINT = 0x8f8f8f;
// Stacked-tile shades for the layout previews, indexed by z.
const PREVIEW_Z = [0x9c8f6e, 0xb3a47e, 0xcab98e, 0xe0cf9f, 0xf6e6b0];
// Label art is 128×178; keep its aspect when fitting it onto a tile face.
const LABEL_W = 128;
const LABEL_H = 178;
const D = { bg: -2, ui: 30 };
export default class MahjongMatchGame extends Phaser.Scene {
constructor() { super('MahjongMatchGame'); }
init(data) {
this.gameDef = data.game ?? { slug: 'mahjongmatch', name: 'Mahjong Match' };
this.view = 'select';
this.g = null;
this.layoutKey = null;
this.tileObjs = []; // tileObjs[i] = { container, gfx, label, hover }
this.selected = null;
this.hintPair = null;
this.hintTimer = null;
this.elapsed = 0;
this.timerEvent = null;
this.overlay = null;
this.overlayUp = false;
this.tilesText = null;
this.movesText = null;
this.timerText = null;
}
create() {
try {
const music = this.cache.json.get('music');
if (music?.tracks) new MusicPlayer(this, music.tracks);
} catch (_) { /* optional */ }
this.add.rectangle(GAME_WIDTH / 2, GAME_HEIGHT / 2, GAME_WIDTH, GAME_HEIGHT, FELT).setDepth(D.bg);
this.layer = this.add.container(0, 0);
this.showLayoutSelect();
}
clearLayer() {
if (this.timerEvent) { this.timerEvent.remove(false); this.timerEvent = null; }
if (this.hintTimer) { this.hintTimer.remove(false); this.hintTimer = null; }
if (this.overlay) { this.overlay.destroy(true); this.overlay = null; }
this.layer.removeAll(true);
this.tileObjs = [];
this.selected = null;
this.hintPair = null;
this.tilesText = null;
this.movesText = null;
this.timerText = null;
}
// ── Layout select ─────────────────────────────────────────────────────────────
showLayoutSelect() {
this.view = 'select';
this.overlayUp = false;
this.clearLayer();
const cx = GAME_WIDTH / 2;
const title = this.add.text(cx, 100, 'MAHJONG MATCH', {
fontFamily: 'Righteous', fontSize: '78px', color: COLORS.goldHex,
}).setOrigin(0.5);
const sub = this.add.text(cx, 178, 'Clear the board by matching free pairs. A tile is free when nothing rests on it and a side is open.', {
fontFamily: '"Julius Sans One"', fontSize: '26px', color: COLORS.mutedHex,
}).setOrigin(0.5);
this.layer.add([title, sub]);
const CARD_W = 480;
const CARD_H = 310;
const GAP_X = 60;
const ROW_Y = [420, 770];
const totalW = 3 * CARD_W + 2 * GAP_X;
const left = cx - totalW / 2 + CARD_W / 2;
LAYOUT_ORDER.forEach((key, i) => {
const layout = LAYOUTS[key];
const x = left + (i % 3) * (CARD_W + GAP_X);
const y = ROW_Y[Math.floor(i / 3)];
const card = this.add.rectangle(x, y, CARD_W, CARD_H, 0x143523)
.setStrokeStyle(3, COLORS.gold, 0.55);
this.layer.add(card);
const preview = this.add.graphics();
this._drawLayoutPreview(preview, layout, x, y - 60, 320, 150);
this.layer.add(preview);
const name = this.add.text(x, y + 52, layout.name, {
fontFamily: 'Righteous', fontSize: '38px', color: COLORS.textHex,
}).setOrigin(0.5);
const info = this.add.text(x, y + 96, `${layout.positions.length} tiles · ${layout.desc}`, {
fontFamily: '"Julius Sans One"', fontSize: '21px', color: COLORS.mutedHex,
}).setOrigin(0.5);
const best = this._bestFor(key);
const bestLbl = this.add.text(x, y + 130, best !== null ? `Best: ${this._fmtTime(best)}` : 'Not cleared yet', {
fontFamily: '"Julius Sans One"', fontSize: '20px', color: COLORS.goldHex,
}).setOrigin(0.5);
this.layer.add([name, info, bestLbl]);
card.setInteractive({ useHandCursor: true });
card.on('pointerover', () => card.setStrokeStyle(5, COLORS.gold, 1));
card.on('pointerout', () => card.setStrokeStyle(3, COLORS.gold, 0.55));
card.on('pointerup', () => this.startGame(key));
});
const back = new Button(this, cx, GAME_HEIGHT - 60, 'Back', () => this.scene.start('GameMenu'),
{ variant: 'ghost', width: 220, height: 56, fontSize: 24 });
this.layer.add(back);
}
// Top-down miniature of a layout: one shaded rect per tile, lighter per layer.
_drawLayoutPreview(gfx, layout, cx, cy, boxW, boxH) {
const { spanX, spanY, maxZ } = layoutBounds(layout.positions);
const mhw = Math.min(boxW / (spanX + maxZ), boxH / (spanY * 1.33));
const mhh = mhw * 1.33;
const lift = mhw * 0.45;
const ox = cx - (spanX * mhw - maxZ * lift) / 2;
const oy = cy - (spanY * mhh - maxZ * lift) / 2;
const sorted = [...layout.positions].sort((a, b) => (a.z - b.z) || (a.y - b.y) || (a.x - b.x));
for (const t of sorted) {
const px = ox + t.x * mhw - t.z * lift;
const py = oy + t.y * mhh - t.z * lift;
gfx.fillStyle(PREVIEW_Z[Math.min(t.z, PREVIEW_Z.length - 1)], 1);
gfx.fillRect(px, py, 2 * mhw, 2 * mhh);
gfx.lineStyle(1, 0x241e12, 0.9);
gfx.strokeRect(px, py, 2 * mhw, 2 * mhh);
}
}
_bestFor(layoutKey) {
const v = parseInt(localStorage.getItem(`mahjongmatch-best-${layoutKey}`), 10);
return isNaN(v) ? null : v;
}
_fmtTime(seconds) {
const m = Math.floor(seconds / 60);
const s = String(seconds % 60).padStart(2, '0');
return `${m}:${s}`;
}
// ── Gameplay ──────────────────────────────────────────────────────────────────
startGame(layoutKey) {
this.view = 'play';
this.layoutKey = layoutKey;
this.g = newGame(layoutKey);
this.selected = null;
this.hintPair = null;
this.elapsed = 0;
this.overlayUp = false;
this.clearLayer();
this._computeLayout();
this._buildTiles();
this._drawHud();
this._startTimer();
}
// Fit the layout into the area right of the button strip.
_computeLayout() {
const { spanX, spanY, maxZ } = layoutBounds(this.g.positions);
const LEFT = 310;
const TOP = 160;
const availW = GAME_WIDTH - 60 - LEFT;
const availH = GAME_HEIGHT - 40 - TOP;
this.halfW = Math.min(availW / (spanX + 1), availH / ((spanY + 1) * 1.33), 50);
this.halfH = this.halfW * 1.33;
this.tileW = this.halfW * 2;
this.tileH = this.halfH * 2;
this.thick = Math.max(5, Math.round(this.halfW * 0.20));
// Higher layers shift up-left by `thick`; center the overall silhouette.
const visW = spanX * this.halfW + (maxZ + 1) * this.thick;
const visH = spanY * this.halfH + (maxZ + 1) * this.thick;
this.originX = LEFT + (availW - visW) / 2 + maxZ * this.thick;
this.originY = TOP + (availH - visH) / 2 + maxZ * this.thick;
}
_tileScreenPos(i) {
const t = this.g.positions[i];
return {
x: this.originX + (t.x + 1) * this.halfW - t.z * this.thick,
y: this.originY + (t.y + 1) * this.halfH - t.z * this.thick,
};
}
_buildTiles() {
this.tileObjs = [];
const order = this.g.positions
.map((_, i) => i)
.filter((i) => this.g.alive[i])
.sort((a, b) => {
const pa = this.g.positions[a], pb = this.g.positions[b];
return (pa.z - pb.z) || (pa.y - pb.y) || (pa.x - pb.x);
});
for (const i of order) {
const { x, y } = this._tileScreenPos(i);
const container = this.add.container(x, y);
const gfx = this.add.graphics();
container.add(gfx);
let label = null;
const face = this.g.faces[i];
if (face.label && this.textures.exists(face.label)) {
const scale = Math.min((this.tileW * 0.80) / LABEL_W, (this.tileH * 0.82) / LABEL_H);
label = this.add.image(0, 0, face.label).setScale(scale);
container.add(label);
}
container.setSize(this.tileW, this.tileH);
container.setInteractive({ useHandCursor: true });
container.on('pointerover', () => { this.tileObjs[i].hover = true; this._redrawTile(i); });
container.on('pointerout', () => { this.tileObjs[i].hover = false; this._redrawTile(i); });
container.on('pointerup', () => this.onTileClick(i));
this.layer.add(container);
this.tileObjs[i] = { container, gfx, label, hover: false };
this._redrawTile(i);
}
}
_redrawTile(i) {
const o = this.tileObjs[i];
if (!o || !this.g.alive[i]) return;
const w = this.tileW, h = this.tileH, t = this.thick, r = Math.max(4, t);
const free = isFree(this.g, i);
const picked = this.selected === i || (this.hintPair?.includes(i) ?? false);
const gfx = o.gfx;
gfx.clear();
// Extruded body toward the lower-right, then the top face.
gfx.fillStyle(SIDE, 1);
gfx.fillRoundedRect(-w / 2 + t, -h / 2 + t, w, h, r);
gfx.fillStyle(picked ? FACE_PICKED : (o.hover && free ? FACE_HOVER : FACE), 1);
gfx.fillRoundedRect(-w / 2, -h / 2, w, h, r);
gfx.lineStyle(picked ? 3 : 2, picked ? PICK_EDGE : FACE_EDGE, 1);
gfx.strokeRoundedRect(-w / 2, -h / 2, w, h, r);
// The White Dragon face is traditionally an empty blue frame.
if (this.g.faces[i].id === 'dragon-white') {
gfx.lineStyle(Math.max(3, t * 0.5), DRAGON_BLUE, 0.95);
gfx.strokeRoundedRect(-w * 0.30, -h * 0.32, w * 0.60, h * 0.64, 6);
}
if (!free) {
gfx.fillStyle(0x000000, 0.30);
gfx.fillRoundedRect(-w / 2, -h / 2, w, h, r);
}
if (o.label) {
if (free) o.label.clearTint(); else o.label.setTint(DIM_TINT);
}
}
_refreshTiles() {
for (let i = 0; i < this.g.positions.length; i++) {
if (this.g.alive[i]) this._redrawTile(i);
}
}
_rebuildTiles() {
for (const o of this.tileObjs) o?.container?.destroy(true);
this._buildTiles();
}
// ── HUD ───────────────────────────────────────────────────────────────────────
_drawHud() {
const cx = GAME_WIDTH / 2;
const layout = LAYOUTS[this.layoutKey];
const title = this.add.text(40, 64, 'MAHJONG MATCH', {
fontFamily: 'Righteous', fontSize: '40px', color: COLORS.goldHex,
}).setOrigin(0, 0.5).setDepth(D.ui);
const diff = this.add.text(40, 106, layout.name, {
fontFamily: '"Julius Sans One"', fontSize: '26px', color: COLORS.mutedHex,
}).setOrigin(0, 0.5).setDepth(D.ui);
this.layer.add([title, diff]);
this.tilesText = this.add.text(cx, 56, '', {
fontFamily: 'Righteous', fontSize: '38px', color: COLORS.textHex,
}).setOrigin(0.5).setDepth(D.ui);
this.movesText = this.add.text(cx, 100, '', {
fontFamily: '"Julius Sans One"', fontSize: '24px', color: COLORS.mutedHex,
}).setOrigin(0.5).setDepth(D.ui);
this.timerText = this.add.text(GAME_WIDTH - 50, 80, '', {
fontFamily: 'Righteous', fontSize: '34px', color: COLORS.textHex,
}).setOrigin(1, 0.5).setDepth(D.ui);
this.layer.add([this.tilesText, this.movesText, this.timerText]);
const stripCx = 150;
const BTN_W = 220, BTN_H = 58, BTN_GAP = 16;
let btnY = GAME_HEIGHT / 2 - (4 * BTN_H + 3 * BTN_GAP) / 2;
const hint = new Button(this, stripCx, btnY, 'Hint', () => this.showHint(),
{ width: BTN_W, height: BTN_H, fontSize: 22, variant: 'ghost' });
btnY += BTN_H + BTN_GAP;
const shuffleB = new Button(this, stripCx, btnY, 'Shuffle', () => this.doShuffle(),
{ width: BTN_W, height: BTN_H, fontSize: 22, variant: 'ghost' });
btnY += BTN_H + BTN_GAP;
const restart = new Button(this, stripCx, btnY, 'New Game', () => this.startGame(this.layoutKey),
{ width: BTN_W, height: BTN_H, fontSize: 22 });
btnY += BTN_H + BTN_GAP;
const layouts = new Button(this, stripCx, btnY, 'Layouts', () => this.showLayoutSelect(),
{ width: BTN_W, height: BTN_H, fontSize: 22, variant: 'ghost' });
this.layer.add([hint, shuffleB, restart, layouts]);
this._updateHud();
}
_updateHud() {
if (this.tilesText) this.tilesText.setText(`Tiles: ${this.g.remaining}`);
if (this.movesText) this.movesText.setText(`Moves available: ${findMoves(this.g).length}`);
if (this.timerText) this.timerText.setText(`${this._fmtTime(this.elapsed)}`);
}
_startTimer() {
this.timerEvent = this.time.addEvent({
delay: 1000, loop: true,
callback: () => {
this.elapsed++;
if (this.timerText) this.timerText.setText(`${this._fmtTime(this.elapsed)}`);
},
});
}
// ── Input ─────────────────────────────────────────────────────────────────────
onTileClick(i) {
if (this.overlayUp || this.g.state !== 'playing' || !this.g.alive[i]) return;
if (!isFree(this.g, i)) {
this._shakeTile(i);
return;
}
if (this.selected === i) {
this.selected = null;
this._redrawTile(i);
return;
}
if (this.selected !== null && canMatch(this.g, this.selected, i)) {
const a = this.selected;
this.selected = null;
this._clearHint();
if (!removePair(this.g, a, i)) return;
playSound(this, SFX.CARD_PLACE);
this._animateRemoval(a);
this._animateRemoval(i);
this._refreshTiles();
this._updateHud();
if (this.g.state === 'won') {
this._onWin();
} else if (findMoves(this.g).length === 0) {
this._onStuck(true);
}
return;
}
const prev = this.selected;
this.selected = i;
playSound(this, SFX.PIECE_CLICK);
if (prev !== null) this._redrawTile(prev);
this._redrawTile(i);
}
_shakeTile(i) {
const o = this.tileObjs[i];
if (!o) return;
const { x } = this._tileScreenPos(i);
this.tweens.add({
targets: o.container, x: x + 6, duration: 45, yoyo: true, repeat: 2,
onComplete: () => o.container.setX(x),
});
}
_animateRemoval(i) {
const o = this.tileObjs[i];
if (!o) return;
this.tweens.add({
targets: o.container,
alpha: 0, y: o.container.y - 26, scaleX: 0.7, scaleY: 0.7,
duration: 230, ease: 'Quad.easeIn',
onComplete: () => o.container.setVisible(false),
});
}
// ── Hint & shuffle ────────────────────────────────────────────────────────────
showHint() {
if (this.overlayUp || this.g.state !== 'playing') return;
const moves = findMoves(this.g);
if (!moves.length) return;
this._clearHint();
this.hintPair = moves[Math.floor(Math.random() * moves.length)];
playSound(this, SFX.CARD_SHOW);
for (const i of this.hintPair) this._redrawTile(i);
this.hintTimer = this.time.delayedCall(1300, () => this._clearHint());
}
_clearHint() {
if (this.hintTimer) { this.hintTimer.remove(false); this.hintTimer = null; }
const pair = this.hintPair;
this.hintPair = null;
if (pair) for (const i of pair) if (this.g.alive[i]) this._redrawTile(i);
}
doShuffle() {
if (this.overlayUp || this.g.state !== 'playing' || this.g.remaining < 2) return;
this.selected = null;
this._clearHint();
const solvable = reshuffleRemaining(this.g);
playSound(this, SFX.CARD_SHUFFLE);
this._rebuildTiles();
this._updateHud();
if (findMoves(this.g).length === 0) this._onStuck(solvable);
return solvable;
}
// ── Overlays ──────────────────────────────────────────────────────────────────
_dismissOverlay() {
if (this.overlay) { this.overlay.destroy(true); this.overlay = null; }
this.overlayUp = false;
}
_makeOverlayPanel(strokeColor) {
const cx = GAME_WIDTH / 2, cy = GAME_HEIGHT / 2;
this.overlay = this.add.container(0, 0);
this.overlayUp = true;
const dim = this.add.rectangle(cx, cy, GAME_WIDTH, GAME_HEIGHT, 0x000000, 0.62).setInteractive();
const panel = this.add.graphics();
panel.fillStyle(COLORS.panel, 0.98);
panel.fillRoundedRect(cx - 340, cy - 210, 680, 420, 20);
panel.lineStyle(3, strokeColor, 1);
panel.strokeRoundedRect(cx - 340, cy - 210, 680, 420, 20);
this.overlay.add([dim, panel]);
return { cx, cy };
}
_onWin() {
if (this.timerEvent) { this.timerEvent.remove(false); this.timerEvent = null; }
playSound(this, SFX.VICTORY_SHORT);
const lsKey = `mahjongmatch-best-${this.layoutKey}`;
const prev = this._bestFor(this.layoutKey);
const newBest = prev === null || this.elapsed < prev;
if (newBest) localStorage.setItem(lsKey, String(this.elapsed));
api.post('/history/single-player', {
slug: 'mahjongmatch', score: this.elapsed, opponentScores: [], result: 'win',
}).catch(() => { /* best effort */ });
const { cx, cy } = this._makeOverlayPanel(0x45d17a);
const title = this.add.text(cx, cy - 130, 'Board Cleared!', {
fontFamily: 'Righteous', fontSize: '68px', color: '#45d17a',
}).setOrigin(0.5);
const stat = this.add.text(cx, cy - 40, `You cleared the ${LAYOUTS[this.layoutKey].name} in ${this._fmtTime(this.elapsed)}.`, {
fontFamily: '"Julius Sans One"', fontSize: '28px', color: COLORS.textHex,
}).setOrigin(0.5);
const bestMsg = newBest && prev !== null
? `★ New Best! (was ${this._fmtTime(prev)})`
: `Best: ${this._fmtTime(newBest ? this.elapsed : prev)}`;
const best = this.add.text(cx, cy + 16, bestMsg, {
fontFamily: '"Julius Sans One"', fontSize: '26px', color: COLORS.goldHex,
}).setOrigin(0.5);
this.overlay.add([title, stat, best]);
const again = new Button(this, cx - 170, cy + 120, 'Play Again', () => this.startGame(this.layoutKey),
{ width: 280, height: 60, fontSize: 26 });
const layouts = new Button(this, cx + 170, cy + 120, 'Layouts', () => this.showLayoutSelect(),
{ width: 280, height: 60, fontSize: 26, variant: 'ghost' });
this.overlay.add([again, layouts]);
}
// `shuffleHelps` is false when the remaining tiles cannot be rearranged into
// a solvable board (e.g. a pair stacked directly on top of each other).
_onStuck(shuffleHelps) {
const { cx, cy } = this._makeOverlayPanel(COLORS.danger);
const title = this.add.text(cx, cy - 130, 'No Moves Left', {
fontFamily: 'Righteous', fontSize: '64px', color: COLORS.dangerHex,
}).setOrigin(0.5);
const msg = shuffleHelps
? `${this.g.remaining} tiles remain, but no free pair matches.\nShuffle the remaining tiles to keep going.`
: `${this.g.remaining} tiles remain, and no arrangement of them\ncan be cleared. Start a new game.`;
const stat = this.add.text(cx, cy - 36, msg, {
fontFamily: '"Julius Sans One"', fontSize: '26px', color: COLORS.textHex, align: 'center',
}).setOrigin(0.5);
this.overlay.add([title, stat]);
let bx = cx - 220;
if (shuffleHelps) {
const shuffleB = new Button(this, bx, cy + 120, 'Shuffle', () => { this._dismissOverlay(); this.doShuffle(); },
{ width: 200, height: 60, fontSize: 24 });
this.overlay.add(shuffleB);
bx += 220;
}
const again = new Button(this, bx, cy + 120, 'New Game', () => this.startGame(this.layoutKey),
{ width: 200, height: 60, fontSize: 24, variant: shuffleHelps ? 'ghost' : undefined });
bx += 220;
const giveUp = new Button(this, bx, cy + 120, 'Give Up', () => this._giveUp(),
{ width: 200, height: 60, fontSize: 24, variant: 'ghost' });
this.overlay.add([again, giveUp]);
}
_giveUp() {
api.post('/history/single-player', {
slug: 'mahjongmatch', score: this.elapsed, opponentScores: [], result: 'loss',
}).catch(() => { /* best effort */ });
this.showLayoutSelect();
}
}

View File

@ -66,6 +66,7 @@ import HexsweeperGame from './games/hexsweeper/HexsweeperGame.js';
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';
const config = {
type: Phaser.AUTO,
@ -145,6 +146,7 @@ const config = {
PuddingMonstersGame,
ShiftGame,
BlockFighterGame,
MahjongMatchGame,
],
};

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' };
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' };
if (slugDispatch[this.game.slug]) {
this.scene.start(slugDispatch[this.game.slug], {
game: this.game,

View File

@ -140,6 +140,19 @@ export default class PreloadScene extends Phaser.Scene {
this.load.spritesheet('monopoly-pawns', '/assets/images/monopoly-pawns.png', { frameWidth: 80, frameHeight: 80 });
// Monopoly card art: frame 0 = Chance, frame 1 = Community Chest, at 200×300.
this.load.spritesheet('monopoly-cards', '/assets/images/monopoly-cards.png', { frameWidth: 200, frameHeight: 300 });
// Mahjong Match tile-label overlays (128×178 transparent PNGs). The White
// Dragon has no art — the scene draws its traditional empty frame.
const mahjongLabels = [
...Array.from({ length: 9 }, (_, i) => `bamboo${i + 1}`),
...Array.from({ length: 9 }, (_, i) => `circle${i + 1}`),
...Array.from({ length: 15 }, (_, i) => `pinyin${i + 1}`),
'orchid', 'peony', 'chrysanthemum', 'lotus',
'spring', 'summer', 'fall', 'winter',
];
for (const name of mahjongLabels) {
this.load.image(`mahjong-${name}`, `/assets/images/mahjong/${name}.png`);
}
this.load.audio('sfx-monopoly-purchase', '/assets/fx/monopoly-purchase.mp3');
this.load.audio('sfx-monopoly-expense', '/assets/fx/monopoly-expense.mp3');
this.load.audio('sfx-monopoly-pay', '/assets/fx/monopoly-pay.mp3');

View File

@ -81,3 +81,4 @@ registerGame({ slug: 'hexsweeper', name: 'Hexsweeper', category:
registerGame({ slug: 'puddingmonsters', name: 'Jell-o Monsters', category: 'logic', minPlayers: 1, maxPlayers: 1, minOpponents: 0, maxOpponents: 0, hasTutorial: true, iconFrame: 53 });
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 });

View File

@ -0,0 +1,115 @@
// Headless verification for Mahjong Match.
// node server/scripts/verifyMahjongMatch.js
// Exits non-zero on any failure.
//
// 1. Face set: 144 tiles, 72 pairs, label assets exist on disk.
// 2. Layouts: expected tile counts, no overlaps, every raised tile supported.
// 3. Deals: every generated board is clearable by replaying free matching
// pairs greedily with reshuffles (and spot-checked exhaustively).
// 4. Random self-play: full games with random move choice + shuffle-on-stuck.
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import {
FACES, LAYOUTS, LAYOUT_ORDER, buildPairPool, validateLayout,
newGame, isFree, canMatch, removePair, findMoves, reshuffleRemaining,
} from '../../public/src/games/mahjongmatch/MahjongLogic.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;
}
// ── 1. Face set ─────────────────────────────────────────────────────────────
console.log('Face set:');
const totalTiles = FACES.reduce((s, f) => s + f.copies, 0);
check(totalTiles === 144, `tile count ${totalTiles}, expected 144`);
check(FACES.length === 42, `face count ${FACES.length}, expected 42`);
const pool = buildPairPool();
check(pool.length === 72, `pair pool ${pool.length}, expected 72`);
check(pool.every(([a, b]) => a.group === b.group), 'pair pool contains a mismatched pair');
for (const f of FACES) {
if (!f.label) continue; // white dragon is drawn procedurally
const file = path.join(IMG_DIR, `${f.label.replace(/^mahjong-/, '')}.png`);
check(fs.existsSync(file), `missing label asset for ${f.id}: ${file}`);
}
console.log(' ok');
// ── 2. Layouts ──────────────────────────────────────────────────────────────
console.log('Layouts:');
const EXPECTED_COUNTS = { garden: 72, crossroads: 90, pyramid: 106, butterfly: 140, fortress: 144, turtle: 144 };
for (const key of LAYOUT_ORDER) {
const layout = LAYOUTS[key];
const n = layout.positions.length;
check(n === EXPECTED_COUNTS[key], `${key}: ${n} tiles, expected ${EXPECTED_COUNTS[key]}`);
check(n <= 144, `${key}: more tiles than the set provides`);
const errors = validateLayout(layout.positions);
for (const e of errors) check(false, `${key}: ${e}`);
console.log(` ${layout.name.padEnd(11)} ${n} tiles${errors.length ? '' : ' ok'}`);
}
// ── 3 & 4. Deals + random self-play ─────────────────────────────────────────
// Play with uniformly random move choice; on stuck, reshuffle (cap 30).
// Guaranteed-solvable deals can still strand under random play, but a healthy
// engine should clear the vast majority and never crash or stall.
function playRandomGame(layoutKey) {
const g = newGame(layoutKey);
let shuffles = 0;
let guard = 10000;
while (g.state === 'playing' && guard-- > 0) {
const moves = findMoves(g);
if (moves.length === 0) {
if (shuffles >= 30) return { g, cleared: false, shuffles };
shuffles++;
const ok = reshuffleRemaining(g);
if (!ok && findMoves(g).length === 0) return { g, cleared: false, shuffles, dead: true };
continue;
}
const [a, b] = moves[Math.floor(Math.random() * moves.length)];
if (!check(isFree(g, a) && isFree(g, b) && canMatch(g, a, b), `${layoutKey}: findMoves returned illegal move`)) return { g, cleared: false, shuffles };
if (!check(removePair(g, a, b), `${layoutKey}: removePair rejected a legal move`)) return { g, cleared: false, shuffles };
}
check(guard > 0, `${layoutKey}: game did not terminate`);
return { g, cleared: g.state === 'won', shuffles };
}
console.log('Self-play (60 games per layout, random moves + shuffle on stuck):');
for (const key of LAYOUT_ORDER) {
let cleared = 0, totalShuffles = 0, dead = 0;
for (let i = 0; i < 60; i++) {
const r = playRandomGame(key);
if (r.cleared) cleared++;
if (r.dead) dead++;
totalShuffles += r.shuffles;
if (r.g.state === 'won') {
check(r.g.remaining === 0, `${key}: won with ${r.g.remaining} tiles remaining`);
check(r.g.alive.every((a) => !a), `${key}: won with alive tiles`);
}
}
check(cleared >= 55, `${key}: only ${cleared}/60 games cleared — generator or engine suspect`);
console.log(` ${LAYOUTS[key].name.padEnd(11)} cleared ${cleared}/60, avg shuffles ${(totalShuffles / 60).toFixed(2)}, dead-ends ${dead}`);
}
// Sanity: a fresh deal must always open with at least one available move.
console.log('Opening-move sanity (200 deals per layout):');
for (const key of LAYOUT_ORDER) {
let minMoves = Infinity;
for (let i = 0; i < 200; i++) {
const g = newGame(key);
const m = findMoves(g).length;
if (m < minMoves) minMoves = m;
}
check(minMoves >= 1, `${key}: produced a deal with no opening move`);
console.log(` ${LAYOUTS[key].name.padEnd(11)} min opening moves ${minMoves}`);
}
if (failures) {
console.error(`\nFAILED: ${failures} check(s).`);
process.exit(1);
}
console.log('\nAll Mahjong Match checks passed.');