From f2da9bfb20cfdb01b0d674dd883d1f00f0311a6f Mon Sep 17 00:00:00 2001 From: Brian Fertig Date: Sun, 14 Jun 2026 16:23:44 -0600 Subject: [PATCH] fix: correct container hitbox double-shift in Canasta & Cribbage Fix hitbox misalignment where Phaser's Container displayOrigin already centers bounds, causing manual Rectangle offsets to double-shift. feat: add Rummikub game with AI, immutable engine, and solver - Implement full Rummikub gameplay: drag & drop, sets/runs, jokers, table manipulation, opening meld rule, and scoring - Add immutable game engine (RummikubLogic) following existing patterns - Add rules solver (RummikubSolver) for set validation and AI planning - Implement 5-level heuristic AI (RummikubAI) with configurable delay - Register game in registry and routing, include tutorial markdown - Add headless verification script (fixture tests + AI self-play) --- public/src/games/canasta/CanastaGame.js | 5 +- public/src/games/cribbage/CribbageGame.js | 5 +- public/src/games/rummikub/RummikubAI.js | 47 ++ public/src/games/rummikub/RummikubData.js | 106 ++++ public/src/games/rummikub/RummikubGame.js | 551 ++++++++++++++++++++ public/src/games/rummikub/RummikubLogic.js | 286 ++++++++++ public/src/games/rummikub/RummikubSolver.js | 334 ++++++++++++ public/src/games/rummikub/tutorial.md | 55 ++ public/src/main.js | 2 + public/src/scenes/GameRoomScene.js | 2 +- server/games/registry.js | 1 + server/scripts/verifyRummikub.js | 199 +++++++ 12 files changed, 1590 insertions(+), 3 deletions(-) create mode 100644 public/src/games/rummikub/RummikubAI.js create mode 100644 public/src/games/rummikub/RummikubData.js create mode 100644 public/src/games/rummikub/RummikubGame.js create mode 100644 public/src/games/rummikub/RummikubLogic.js create mode 100644 public/src/games/rummikub/RummikubSolver.js create mode 100644 public/src/games/rummikub/tutorial.md create mode 100644 server/scripts/verifyRummikub.js diff --git a/public/src/games/canasta/CanastaGame.js b/public/src/games/canasta/CanastaGame.js index 5cd210c..7909f91 100644 --- a/public/src/games/canasta/CanastaGame.js +++ b/public/src/games/canasta/CanastaGame.js @@ -374,8 +374,11 @@ export default class CanastaGame extends Phaser.Scene { } makeInteractive(c, handler) { + // A Container's displayOrigin is width/2, which Phaser adds during hit-testing, + // so the auto-generated Rectangle(0,0,w,h) lands centered. Passing a manual + // (-w/2,-h/2,w,h) rect double-shifts the hitbox up-left — so don't. c.setSize(CARD_W, CARD_H); - c.setInteractive(new Phaser.Geom.Rectangle(-CARD_W / 2, -CARD_H / 2, CARD_W, CARD_H), Phaser.Geom.Rectangle.Contains); + c.setInteractive({ useHandCursor: true }); c.on('pointerdown', handler); } diff --git a/public/src/games/cribbage/CribbageGame.js b/public/src/games/cribbage/CribbageGame.js index 193bab6..b1729c9 100644 --- a/public/src/games/cribbage/CribbageGame.js +++ b/public/src/games/cribbage/CribbageGame.js @@ -261,8 +261,11 @@ export default class CribbageGame extends Phaser.Scene { } setHandInteractive(container, handler) { + // A Container's displayOrigin is width/2, which Phaser adds during hit-testing, + // so the auto-generated Rectangle(0,0,w,h) lands centered. Passing a manual + // (-w/2,-h/2,w,h) rect double-shifts the hitbox up-left — so don't. container.setSize(CARD_W, CARD_H); - container.setInteractive(new Phaser.Geom.Rectangle(-CARD_W / 2, -CARD_H / 2, CARD_W, CARD_H), Phaser.Geom.Rectangle.Contains); + container.setInteractive({ useHandCursor: true }); container.on('pointerdown', handler); } diff --git a/public/src/games/rummikub/RummikubAI.js b/public/src/games/rummikub/RummikubAI.js new file mode 100644 index 0000000..4bda1f7 --- /dev/null +++ b/public/src/games/rummikub/RummikubAI.js @@ -0,0 +1,47 @@ +// Rummikub — heuristic AI. No Phaser, no state mutation. `planTurn` inspects an +// (already-current) engine state and returns a plain plan describing what tiles +// to lay down (and the resulting table arrangement) or a decision to draw. The +// scene applies & animates the plan; the engine validates the commit. + +import { INITIAL_MELD_MIN } from './RummikubData.js'; +import { bestMeldDecomposition } from './RummikubSolver.js'; + +// Skill 1–5: lower skill is slower, less likely to manipulate the table, and +// adds "noise" (sometimes declines an available meld to draw instead). +const PROFILE = { + 1: { delay: [780, 1250], manipulate: false, declineChance: 0.30 }, + 2: { delay: [680, 1100], manipulate: false, declineChance: 0.18 }, + 3: { delay: [600, 980], manipulate: false, declineChance: 0.08 }, + 4: { delay: [500, 860], manipulate: true, declineChance: 0.03 }, + 5: { delay: [430, 740], manipulate: true, declineChance: 0.0 }, +}; + +function profile(skill) { return PROFILE[Math.max(1, Math.min(5, skill | 0))] || PROFILE[3]; } + +export function thinkDelay(skill) { + const [lo, hi] = profile(skill).delay; + return lo + Math.floor(Math.random() * (hi - lo)); +} + +// Returns { type:'commit', tilesPlayed, newTable, firstMeld } or { type:'draw' }. +export function planTurn(state, seat, skill) { + const p = profile(skill); + const player = state.players[seat]; + const rack = player.rack; + const table = state.table.map((s) => s.slice()); + + const plan = bestMeldDecomposition(rack, table, { + mustReach: player.hasMelded ? 0 : INITIAL_MELD_MIN, + alreadyMelded: player.hasMelded, + manipulate: p.manipulate, + }); + + if (!plan || plan.tilesPlayed.length === 0) return { type: 'draw' }; + + // Low-skill players sometimes sit on a small play and draw instead — but never + // decline a chance to go out. + const goesOut = plan.tilesPlayed.length === rack.length; + if (!goesOut && Math.random() < p.declineChance) return { type: 'draw' }; + + return { type: 'commit', tilesPlayed: plan.tilesPlayed, newTable: plan.newTable, firstMeld: plan.firstMeld }; +} diff --git a/public/src/games/rummikub/RummikubData.js b/public/src/games/rummikub/RummikubData.js new file mode 100644 index 0000000..acdcc4c --- /dev/null +++ b/public/src/games/rummikub/RummikubData.js @@ -0,0 +1,106 @@ +// Rummikub — static data, theme and table geometry. No Phaser, no game state: +// imported by the solver, the logic engine, the AI, the Phaser scene and the +// headless verify harness alike. +// +// Classic Rummikub: 106 tiles (numbers 1–13 in four colours, two copies each = +// 104, plus two jokers). 2–4 players, 14 tiles per rack, race to empty your rack +// by laying down valid sets (groups & runs). First table play must total ≥30. + +export const ICON_FRAME = 68; + +// ── Rules ──────────────────────────────────────────────────────────────────── +export const COLORS_ORDER = ['red', 'blue', 'black', 'orange']; +export const MIN_NUM = 1; +export const MAX_NUM = 13; +export const COPIES = 2; // two copies of every numbered tile +export const JOKER_COUNT = 2; // two jokers +export const TILE_COUNT = 106; // 13 × 4 × 2 + 2 +export const RACK_START = 14; // tiles dealt to each player +export const INITIAL_MELD_MIN = 30; // a player's first table play must total ≥30 +export const JOKER_PENALTY = 30; // a joker left on the rack at game end +export const MIN_SET = 3; // a valid group/run is at least three tiles +export const MAX_PLAYERS = 4; + +// ── Tiles ──────────────────────────────────────────────────────────────────── +// A tile is a plain descriptor: { id, color, number, isJoker }. A joker carries +// color:null / number:null until it is "represented" inside a committed set; the +// solver attaches a transient { rcolor, rnumber } assignment for display only. + +/** Numeric value of a tile for scoring / the initial-meld minimum. Jokers are + * contextual and must be valued via the solver's set assignment. */ +export function tileValue(tile) { + if (tile.isJoker) return 0; + return tile.number; +} + +/** Display hex for a tile's number, by colour. */ +export function colorHex(color) { + switch (color) { + case 'red': return '#d23b32'; + case 'blue': return '#2f6fb0'; + case 'black': return '#222018'; + case 'orange': return '#e08a1e'; + default: return '#222018'; + } +} + +/** Stable rack sort: by colour order, then number; jokers last. */ +export function sortRackTiles(tiles) { + const ci = (c) => { const i = COLORS_ORDER.indexOf(c); return i < 0 ? 99 : i; }; + return tiles.slice().sort((a, b) => { + if (a.isJoker !== b.isJoker) return a.isJoker ? 1 : -1; + if (a.isJoker && b.isJoker) return 0; + return ci(a.color) - ci(b.color) || a.number - b.number; + }); +} + +// ── Theme (classic ivory tiles, warm wood rack, green felt) ─────────────────── +export const THEME = { + feltTop: 0x1c6b3a, // lit centre of the felt + feltMid: 0x125230, // mid green + feltEdge: 0x07351c, // deep green at the edge + rail: 0x5a3a1c, // walnut wood rack + railHi: 0x7a5230, // lit wood grain + railDark: 0x32200f, // wood shadow + brass: 0xc9a227, // brass trim + brassHi: 0xf0d77a, // brass highlight + gold: 0xd4a017, + tileFace: 0xf6efdb, // cream ivory tile face + tileFaceHi:0xfffaf0, // tile sheen + tileSide: 0xb9ae90, // extruded side / shadow + tileEdge: 0x9c8f6a, // tile border + tileSel: 0xfff4cf, // lifted/selected tile tint + validGlow: 0x55d96a, // a complete, valid set + invalidGlow: 0xe0556a, // an incomplete / illegal set + trayFill: 0x0c3f22, // table set backing tray + trayEdge: 0x1f6b3c, + text: 0xf2ead8, + jokerHex: '#8e44ad', +}; + +// ── Table geometry ──────────────────────────────────────────────────────────── +// Seat 0 is always the human (full face-up rack along the bottom). The AI seats +// fill the top, then left, then right depending on the player count. Returns the +// anchor each seat's face-down stack + count label is drawn at, plus the central +// table band and the human rack strip. +export function buildTableLayout(playerCount, { width, height }) { + const cx = width / 2; + const seats = { + 0: { x: cx, y: height - 150, dir: 'h', side: 'bottom' }, + }; + // AI seats by count: 2p → top; 3p → top + left; 4p → top + left + right. + const order = []; + if (playerCount >= 2) order.push({ seat: 1, x: cx, y: 150, dir: 'h', side: 'top' }); + if (playerCount >= 3) order.push({ seat: 2, x: 150, y: 470, dir: 'v', side: 'left' }); + if (playerCount >= 4) order.push({ seat: 3, x: width - 150, y: 470, dir: 'v', side: 'right' }); + for (const o of order) seats[o.seat] = o; + + return { + cx, + seats, + pool: { x: cx, y: 232 }, // face-down draw pool anchor + // Central band where committed sets are laid out (top-left origin, wraps). + table: { x: 120, y: 300, w: width - 240, h: 430 }, + rack: { x: 120, y: height - 168, w: width - 240, h: 150 }, + }; +} diff --git a/public/src/games/rummikub/RummikubGame.js b/public/src/games/rummikub/RummikubGame.js new file mode 100644 index 0000000..31d4492 --- /dev/null +++ b/public/src/games/rummikub/RummikubGame.js @@ -0,0 +1,551 @@ +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 { createOpponentPortrait, createPlayerPortrait } from '../../ui/Portrait.js'; +import { + THEME, colorHex, sortRackTiles, buildTableLayout, INITIAL_MELD_MIN, +} from './RummikubData.js'; +import { isValidSet } from './RummikubSolver.js'; +import { + createInitialState, applyDrag, resetTurn, commitTurn, drawTile, canCommit, + validateCommit, stageAiPlan, stagedPlayedIds, hasStagedChanges, +} from './RummikubLogic.js'; +import { planTurn, thinkDelay } from './RummikubAI.js'; + +// ── Geometry ────────────────────────────────────────────────────────────────── +const TW = 56, TH = 78, TR = 8; // tile face size / corner radius +const RACK_SCALE = 1; +const TABLE_SCALE = 0.86; +const MINI = 0.5; // opponent face-down stacks +const TILE_GAP = 4; + +const D = { felt: -6, glow: -5, rail: -4, band: 0, tray: 2, tile: 10, tileText: 11, + drag: 40, ui: 30, ring: 28, toast: 60, modal: 80, modalUI: 82 }; + +const HEX = (n) => '#' + (n >>> 0).toString(16).padStart(6, '0'); + +export default class RummikubGame extends Phaser.Scene { + constructor() { super('RummikubGame'); } + + init(data) { + this.gameDef = data.game ?? { slug: 'rummikub', name: 'Rummikub' }; + this.opponents = data.opponents ?? []; + this.playerCount = Math.max(2, Math.min(4, this.opponents.length + 1)); + + this.seatName = { 0: 'You' }; + this.seatSkill = { 0: 5 }; + this.seatOpp = {}; + for (let seat = 1; seat < this.playerCount; seat++) { + const opp = this.opponents[seat - 1]; + this.seatOpp[seat] = opp; + this.seatName[seat] = opp?.name ?? `Player ${seat + 1}`; + this.seatSkill[seat] = Math.max(1, Math.min(5, opp?.skill ?? 3)); + } + + this.layout = buildTableLayout(this.playerCount, { width: GAME_WIDTH, height: GAME_HEIGHT }); + this.state = createInitialState({ playerCount: this.playerCount }); + + this.busy = false; + this.recorded = false; + this.dragging = null; + this.originalTableIds = new Set(); + + this.tableObjs = []; + this.rackObjs = []; + this.oppObjs = []; + this.poolObjs = []; + this.setRects = []; + this.portraits = {}; + } + + create() { + try { new MusicPlayer(this, this.cache.json.get('music')?.tracks ?? []); } catch { /* optional */ } + + this.buildBackdrop(); + this.buildPortraits(); + + this.statusText = this.add.text(GAME_WIDTH / 2, 264, '', { + fontFamily: '"Julius Sans One"', fontSize: '24px', color: COLORS.textHex, + }).setOrigin(0.5).setDepth(D.ui).setShadow(0, 2, '#000', 6); + + this.buildButtons(); + + // Track the live pointer so drag-drop can hit-test against trays. + this.input.on('pointermove', (p) => { this.pointer = p; }); + + this.renderAll(); + this.dealAnimation(() => this.advance()); + } + + // ── Backdrop ─────────────────────────────────────────────────────────────── + buildBackdrop() { + const g = this.add.graphics().setDepth(D.felt); + g.fillGradientStyle(THEME.feltTop, THEME.feltTop, THEME.feltEdge, THEME.feltEdge, 1); + g.fillRect(0, 0, GAME_WIDTH, GAME_HEIGHT); + + const glow = this.add.graphics().setDepth(D.glow); + for (let i = 8; i >= 1; i--) { glow.fillStyle(0xffffff, 0.025); glow.fillEllipse(GAME_WIDTH / 2, 470, 1500 * (i / 8), 900 * (i / 8)); } + + // Central table band backing. + const b = this.layout.table; + const band = this.add.graphics().setDepth(D.band); + band.fillStyle(THEME.trayFill, 0.55); band.fillRoundedRect(b.x - 20, b.y - 28, b.w + 40, b.h + 56, 22); + band.lineStyle(2, THEME.brass, 0.6); band.strokeRoundedRect(b.x - 20, b.y - 28, b.w + 40, b.h + 56, 22); + + // Wooden rack rail for the human. + const r = this.layout.rack; + const rail = this.add.graphics().setDepth(D.rail); + rail.fillGradientStyle(THEME.railHi, THEME.railHi, THEME.railDark, THEME.railDark, 1); + rail.fillRoundedRect(r.x - 24, r.y - 22, r.w + 48, r.h + 30, 18); + rail.lineStyle(3, THEME.brass, 0.8); rail.strokeRoundedRect(r.x - 24, r.y - 22, r.w + 48, r.h + 30, 18); + + this.add.text(GAME_WIDTH / 2, 30, 'R U M M I K U B', { + fontFamily: 'Righteous', fontSize: '32px', color: HEX(THEME.brassHi), + }).setOrigin(0.5, 0).setDepth(D.ui).setAlpha(0.6); + + this.add.text(this.layout.table.x - 16, this.layout.table.y - 24, 'TABLE', { + fontFamily: '"Julius Sans One"', fontSize: '14px', color: COLORS.mutedHex, + }).setOrigin(0, 1).setDepth(D.ui); + } + + buildPortraits() { + for (let seat = 0; seat < this.playerCount; seat++) { + const a = this.layout.seats[seat]; + const px = a.side === 'bottom' ? 80 : a.side === 'top' ? 80 : a.x; + const py = a.side === 'bottom' ? a.y : a.side === 'top' ? a.y : a.y - 110; + this.portraits[seat] = seat === 0 + ? createPlayerPortrait(this, px, py, 42, D.ui, 'RummikubGame') + : createOpponentPortrait(this, this.seatOpp[seat], px, py, 42, D.ui, { playIntro: seat === 1 }); + this.add.text(px + 58, py - 8, this.seatName[seat].toUpperCase(), { + fontFamily: 'Righteous', fontSize: '18px', color: HEX(THEME.brassHi), + }).setOrigin(0, 0.5).setDepth(D.ui); + this.portraits[seat].countText = this.add.text(px + 58, py + 16, '', { + fontFamily: '"Julius Sans One"', fontSize: '15px', color: COLORS.mutedHex, + }).setOrigin(0, 0.5).setDepth(D.ui); + const ring = this.add.graphics().setDepth(D.ring).setVisible(false); + ring.lineStyle(5, THEME.brassHi, 0.95); ring.strokeCircle(px, py, 48); + this.portraits[seat].ring = ring; + this.portraits[seat].pos = { x: px, y: py }; + } + } + + buildButtons() { + const y = GAME_HEIGHT - 30; + const cx = GAME_WIDTH / 2; + this.btn = {}; + this.btn.play = new Button(this, cx - 300, y, 'Play', () => this.humanCommit(), + { width: 180, height: 52, fontSize: 22 }).setDepth(D.ui); + this.btn.draw = new Button(this, cx - 100, y, 'Draw', () => this.humanDraw(), + { width: 180, height: 52, fontSize: 22 }).setDepth(D.ui); + this.btn.reset = new Button(this, cx + 100, y, 'Reset', () => this.humanReset(), + { width: 180, height: 52, fontSize: 22, variant: 'ghost' }).setDepth(D.ui); + this.btn.sort = new Button(this, cx + 300, y, 'Sort', () => this.sortRack(), + { width: 180, height: 52, fontSize: 22, variant: 'ghost' }).setDepth(D.ui); + this.btn.leave = new Button(this, GAME_WIDTH - 100, 40, 'Leave', () => this.scene.start('GameMenu'), + { variant: 'ghost', width: 150, height: 46, fontSize: 19 }).setDepth(D.ui); + this.hideActionButtons(); + } + + hideActionButtons() { ['play', 'draw', 'reset', 'sort'].forEach((k) => this.btn[k].setVisible(false)); } + + // ── Tile rendering ─────────────────────────────────────────────────────────── + makeTile(tile, { faceUp = true, scale = 1, rep = null } = {}) { + const w = TW * scale, h = TH * scale, r = TR * scale; + const t = Math.max(2, Math.round(4 * scale)); + const c = this.add.container(0, 0); + c._tileW = w; c._tileH = h; // actual drawn size, used to size the drag hitbox + const g = this.add.graphics(); + + if (!faceUp) { + g.fillStyle(THEME.railDark, 1); g.fillRoundedRect(-w / 2 + t, -h / 2 + t, w, h, r); + g.fillStyle(THEME.rail, 1); g.fillRoundedRect(-w / 2, -h / 2, w, h, r); + g.lineStyle(1.5 * scale, THEME.railHi, 0.7); g.strokeRoundedRect(-w / 2 + 3, -h / 2 + 3, w - 6, h - 6, r * 0.7); + c.add(g); + return c; + } + + // 3D extruded ivory tile. + g.fillStyle(THEME.tileSide, 1); g.fillRoundedRect(-w / 2 + t, -h / 2 + t, w, h, r); + g.fillStyle(THEME.tileFace, 1); g.fillRoundedRect(-w / 2, -h / 2, w, h, r); + g.fillStyle(THEME.tileFaceHi, 0.6); g.fillRoundedRect(-w / 2, -h / 2, w, h * 0.42, r); // sheen + g.lineStyle(1.5 * scale, THEME.tileEdge, 1); g.strokeRoundedRect(-w / 2, -h / 2, w, h, r); + c.add(g); + + if (tile.isJoker) { + const repCol = rep ? colorHex(rep.color) : THEME.jokerHex; + c.add(this.add.text(0, -h * 0.06, '★', { fontFamily: 'serif', fontSize: `${34 * scale}px`, color: repCol }).setOrigin(0.5)); + c.add(this.add.text(0, h * 0.28, rep ? String(rep.number) : 'JOKER', + { fontFamily: 'Righteous', fontSize: `${(rep ? 16 : 11) * scale}px`, color: repCol }).setOrigin(0.5)); + } else { + const col = colorHex(tile.color); + c.add(this.add.text(0, -h * 0.02, String(tile.number), { + fontFamily: 'Righteous', fontSize: `${34 * scale}px`, color: col, + }).setOrigin(0.5)); + // small circle "foot" accent in the tile colour + const dot = this.add.graphics(); + dot.fillStyle(Phaser.Display.Color.HexStringToColor(col).color, 0.9); + dot.fillCircle(0, h * 0.3, 4 * scale); + c.add(dot); + } + return c; + } + + // ── Render everything from current state ────────────────────────────────────── + displayTable() { + return (this.state.currentPlayer === 0 && this.state.phase === 'play') + ? this.state.workingTable : this.state.table; + } + + displayRack() { + return (this.state.currentPlayer === 0 && this.state.phase === 'play') + ? this.state.workingRack : this.state.players[0].rack; + } + + renderAll() { + this.renderPool(); + this.renderOpponents(); + this.renderTable(); + this.renderRack(); + this.updateTurnRings(); + this.updateButtons(); + } + + renderPool() { + this.poolObjs.forEach((o) => o.destroy()); this.poolObjs = []; + const { x, y } = this.layout.pool; + const n = Math.min(6, Math.ceil(this.state.pool.length / 10)); + for (let i = 0; i < n; i++) { + const t = this.makeTile(null, { faceUp: false, scale: 0.62 }); + t.setPosition(x - i * 2, y - i * 2).setDepth(D.tile + i); + this.poolObjs.push(t); + } + this.poolObjs.push(this.add.text(x, y + 40, `${this.state.pool.length} in pool`, { + fontFamily: 'Righteous', fontSize: '16px', color: COLORS.textHex, + }).setOrigin(0.5).setDepth(D.ui)); + } + + renderOpponents() { + this.oppObjs.forEach((o) => o.destroy()); this.oppObjs = []; + for (let seat = 1; seat < this.playerCount; seat++) { + const a = this.layout.seats[seat]; + const count = this.state.players[seat].rack.length; + const horiz = a.dir === 'h'; + const step = horiz ? 16 : 0, vstep = horiz ? 0 : 12; + const shown = Math.min(count, 14); + const span = horiz ? (shown - 1) * step : (shown - 1) * vstep; + for (let i = 0; i < shown; i++) { + const t = this.makeTile(null, { faceUp: false, scale: MINI }); + t.setPosition(a.x + (horiz ? i * step - span / 2 : 0), a.y + (horiz ? 0 : i * vstep - span / 2)) + .setDepth(D.tile + i); + this.oppObjs.push(t); + } + this.portraits[seat].countText.setText(`${count} tiles${this.state.players[seat].hasMelded ? '' : ' · not melded'}`); + } + this.portraits[0].countText.setText(`${this.state.players[0].rack.length} tiles${this.state.players[0].hasMelded ? '' : ' · not melded'}`); + } + + renderTable() { + this.tableObjs.forEach((o) => o.destroy()); this.tableObjs = []; + this.setRects = []; + const sets = this.displayTable(); + const band = this.layout.table; + const tileW = TW * TABLE_SCALE + TILE_GAP; + const setPad = 14, setGap = 18, rowH = TH * TABLE_SCALE + 30; + let cx = band.x, cy = band.y + rowH / 2; + + sets.forEach((set, idx) => { + const setW = set.length * tileW + setPad * 2; + if (cx + setW > band.x + band.w && cx > band.x) { cx = band.x; cy += rowH; } + const valid = isValidSet(set); + const rect = { index: idx, x: cx, y: cy - rowH / 2, w: setW, h: rowH - 6 }; + this.setRects.push(rect); + + const tray = this.add.graphics().setDepth(D.tray); + const col = valid.valid ? THEME.validGlow : THEME.invalidGlow; + tray.fillStyle(0x000000, 0.25); tray.fillRoundedRect(rect.x, rect.y, rect.w, rect.h, 10); + tray.lineStyle(2.5, col, 0.95); tray.strokeRoundedRect(rect.x, rect.y, rect.w, rect.h, 10); + this.tableObjs.push(tray); + + const startX = cx + setPad + (TW * TABLE_SCALE) / 2; + set.forEach((tile, i) => { + const rep = valid.valid ? valid.assignment?.[tile.id] : null; + const obj = this.makeTile(tile, { scale: TABLE_SCALE, rep }); + obj.setPosition(startX + i * tileW, cy).setDepth(D.tile + i); + obj.tileRef = tile; obj._loc = { type: 'set', index: idx }; + if (this.isHumanTurn()) this.makeDraggable(obj); + this.tableObjs.push(obj); + }); + cx += setW + setGap; + }); + + if (sets.length === 0) { + this.tableObjs.push(this.add.text(band.x + band.w / 2, band.y + band.h / 2, + 'Drag tiles here to form sets', { fontFamily: '"Julius Sans One"', fontSize: '22px', color: COLORS.mutedHex }) + .setOrigin(0.5).setDepth(D.ui)); + } + } + + renderRack() { + this.rackObjs.forEach((o) => o.destroy()); this.rackObjs = []; + const rack = this.displayRack(); + const r = this.layout.rack; + const tileW = TW * RACK_SCALE + TILE_GAP; + const perRow = Math.max(1, Math.floor(r.w / tileW)); + const rows = Math.ceil(rack.length / perRow) || 1; + const rowH = TH * RACK_SCALE + 8; + rack.forEach((tile, i) => { + const row = Math.floor(i / perRow); + const inRow = rack.length - row * perRow >= perRow ? perRow : rack.length - row * perRow; + const col = i % perRow; + const rowW = inRow * tileW; + const x = r.x + (r.w - rowW) / 2 + col * tileW + tileW / 2; + const y = r.y + (r.h - rows * rowH) / 2 + row * rowH + rowH / 2; + const obj = this.makeTile(tile, { scale: RACK_SCALE }); + obj.setPosition(x, y).setDepth(D.tile + i); + obj.tileRef = tile; obj._loc = { type: 'rack' }; + if (this.isHumanTurn()) this.makeDraggable(obj); + this.rackObjs.push(obj); + }); + } + + isHumanTurn() { return this.state.currentPlayer === 0 && this.state.phase === 'play' && !this.busy; } + + updateTurnRings() { + for (let seat = 0; seat < this.playerCount; seat++) { + this.portraits[seat].ring.setVisible(this.state.currentPlayer === seat && this.state.phase === 'play'); + } + } + + updateButtons() { + if (!this.isHumanTurn()) { this.hideActionButtons(); return; } + const staged = hasStagedChanges(this.state); + const played = stagedPlayedIds(this.state).length; + this.btn.play.setVisible(true).setEnabled(canCommit(this.state)); + this.btn.draw.setVisible(true).setEnabled(!staged); + this.btn.reset.setVisible(true).setEnabled(staged); + this.btn.sort.setVisible(true).setEnabled(true); + + const me = this.state.players[0]; + if (!me.hasMelded) { + this.statusText.setText(`Opening meld: form new sets worth at least ${INITIAL_MELD_MIN} pts (${played} tile${played === 1 ? '' : 's'} staged)`); + } else { + this.statusText.setText('Drag tiles to build sets, then Play — or Draw a tile to end your turn'); + } + } + + // ── Drag & drop ────────────────────────────────────────────────────────────-- + makeDraggable(obj) { + // A Container's displayOrigin is width/2, which Phaser adds to the hit test, + // so the auto-generated Rectangle(0,0,w,h) lands centered. Passing a manual + // (-w/2,-h/2,w,h) rect double-shifts the hitbox up-left — so don't (see + // ScrabbleGame). Size the hitbox to the tile's actual drawn dimensions. + obj.setSize(obj._tileW || TW, obj._tileH || TH); + obj.setInteractive({ useHandCursor: true }); + this.input.setDraggable(obj); + obj.on('dragstart', () => { + obj._home = { x: obj.x, y: obj.y }; + obj.setDepth(D.drag); + this.dragging = obj; + playSound(this, SFX.PIECE_CLICK); + }); + obj.on('drag', (p, dx, dy) => { obj.x = dx; obj.y = dy; this.pointer = p; }); + obj.on('dragend', (p) => this.onDrop(obj, p)); + } + + onDrop(obj, pointer) { + this.dragging = null; + const px = pointer.x, py = pointer.y; + const tile = obj.tileRef; + const band = this.layout.table, rack = this.layout.rack; + + let dest = null; + for (const rect of this.setRects) { + if (px >= rect.x && px <= rect.x + rect.w && py >= rect.y && py <= rect.y + rect.h) { + dest = { type: 'set', index: rect.index }; break; + } + } + if (!dest && px >= band.x - 20 && px <= band.x + band.w + 20 && py >= band.y - 28 && py <= band.y + band.h + 28) { + dest = { type: 'newSet' }; + } + if (!dest && py >= rack.y - 30) { + if (this.originalTableIds.has(tile.id)) dest = null; // table tiles can't return to rack + else dest = { type: 'rack' }; + } + + if (dest) { + this.state = applyDrag(this.state, tile.id, dest); + playSound(this, dest.type === 'rack' ? SFX.PIECE_CLICK : SFX.CARD_PLACE); + } + this.renderAll(); + } + + // ── Human actions ─────────────────────────────────────────────────────────--- + humanCommit() { + if (!this.isHumanTurn()) return; + const v = validateCommit(this.state); + if (!v.ok) { this.shakeInvalid(); this.toast(v.reason, GAME_WIDTH / 2, 300, '#e0556a'); return; } + const wentOut = this.state.workingRack.length === 0; + this.state = commitTurn(this.state); + playSound(this, SFX.CARD_PLACE); + this.renderAll(); + if (wentOut) this.toast('You go out!', GAME_WIDTH / 2, 300, HEX(THEME.brassHi)); + this.advance(); + } + + humanDraw() { + if (!this.isHumanTurn() || hasStagedChanges(this.state)) return; + const from = this.layout.pool; + this.state = drawTile(this.state); + playSound(this, SFX.CARD_DEAL); + this.renderAll(); + // little pop on the newest rack tile + const last = this.rackObjs[this.state.players[0].rack.length - 1]; + if (last) { const hx = last.x, hy = last.y; last.setPosition(from.x, from.y); + this.tweens.add({ targets: last, x: hx, y: hy, duration: 320, ease: 'Back.easeOut' }); } + this.advance(); + } + + humanReset() { + if (!this.isHumanTurn()) return; + this.state = resetTurn(this.state); + playSound(this, SFX.CARD_SHUFFLE); + this.renderAll(); + } + + sortRack() { + if (!this.isHumanTurn()) return; + this.state = { ...this.state, workingRack: sortRackTiles(this.state.workingRack) }; + playSound(this, SFX.CARD_SHUFFLE); + this.renderAll(); + } + + shakeInvalid() { + const targets = this.tableObjs.filter((o) => o.type === 'Container'); + this.tweens.add({ targets, x: '+=8', duration: 60, yoyo: true, repeat: 3, ease: 'Sine.easeInOut' }); + } + + // ── Turn flow ──────────────────────────────────────────────────────────────── + advance() { + this.updateTurnRings(); + if (this.state.phase === 'gameOver') return this.showGameOver(); + if (this.state.currentPlayer === 0) { + this.busy = false; + this.originalTableIds = new Set(this.state.turnStart.table.flat().map((t) => t.id)); + this.renderAll(); + return; + } + this.runAiTurn(); + } + + async runAiTurn() { + this.busy = true; + this.hideActionButtons(); + const seat = this.state.currentPlayer; + const skill = this.seatSkill[seat]; + this.statusText.setText(`${this.seatName[seat]} is thinking…`); + this.updateTurnRings(); + await this.delay(thinkDelay(skill)); + + const plan = planTurn(this.state, seat, skill); + if (plan.type === 'draw') { + this.state = drawTile(this.state); + playSound(this, SFX.CARD_DEAL); + this.toast(`${this.seatName[seat]} draws`, this.portraits[seat].pos.x, this.portraits[seat].pos.y + 60); + this.renderAll(); + } else { + this.state = stageAiPlan(this.state, plan); + const newIds = new Set(plan.tilesPlayed); + const wentOut = this.state.workingRack.length === 0; + this.state = commitTurn(this.state); + this.renderAll(); + playSound(this, SFX.CARD_PLACE); + this.popInTiles(newIds); + this.toast(`${this.seatName[seat]} plays ${plan.tilesPlayed.length} tile${plan.tilesPlayed.length === 1 ? '' : 's'}`, + this.layout.table.x + this.layout.table.w / 2, this.layout.table.y - 8, HEX(THEME.brassHi)); + if (wentOut) this.toast(`${this.seatName[seat]} goes out!`, GAME_WIDTH / 2, 300, HEX(THEME.brassHi)); + await this.delay(520); + } + await this.delay(260); + this.busy = false; + this.advance(); + } + + popInTiles(idSet) { + for (const obj of this.tableObjs) { + if (obj.tileRef && idSet.has(obj.tileRef.id)) { + const sx = obj.scaleX, sy = obj.scaleY; + obj.setScale(0); + this.tweens.add({ targets: obj, scaleX: sx, scaleY: sy, duration: 240, ease: 'Back.easeOut' }); + } + } + } + + // ── Opening deal ──────────────────────────────────────────────────────────--- + dealAnimation(done) { + playSound(this, SFX.CARD_SHUFFLE); + const from = this.layout.pool; + let delay = 0; + for (const obj of [...this.rackObjs, ...this.oppObjs]) { + const hx = obj.x, hy = obj.y; + obj.setPosition(from.x, from.y).setAlpha(0); + this.tweens.add({ targets: obj, x: hx, y: hy, alpha: 1, duration: 260, delay, ease: 'Cubic.easeOut' }); + delay += 12; + } + this.time.delayedCall(delay + 320, done); + } + + // ── Game over ──────────────────────────────────────────────────────────────── + showGameOver() { + this.busy = true; + this.hideActionButtons(); + this.updateTurnRings(); + const won = this.state.winner === 0; + playSound(this, won ? SFX.VICTORY_SHORT : SFX.CASINO_LOSE); + if (won) this.time.delayedCall(450, () => playSound(this, SFX.CASINO_WIN)); + this.postHistory(won ? 'win' : 'loss'); + + const cx = GAME_WIDTH / 2, cy = 470; + const g = this.add.graphics().setDepth(D.modal); + g.fillStyle(0x000000, 0.74); g.fillRect(0, 0, GAME_WIDTH, GAME_HEIGHT); + g.fillStyle(THEME.feltEdge, 1); g.fillRoundedRect(cx - 420, cy - 220, 840, 440, 24); + g.lineStyle(4, THEME.brass, 1); g.strokeRoundedRect(cx - 420, cy - 220, 840, 440, 24); + + this.add.text(cx, cy - 158, won ? 'You Win!' : `${this.seatName[this.state.winner]} Wins`, { + fontFamily: 'Righteous', fontSize: '56px', color: won ? HEX(THEME.brassHi) : COLORS.textHex, + }).setOrigin(0.5).setDepth(D.modalUI); + + const lines = this.state.players.map((p, i) => + `${this.seatName[i].padEnd(10)} ${this.state.finalScores[i] >= 0 ? '+' : ''}${this.state.finalScores[i]}`); + this.add.text(cx, cy - 30, lines.join('\n'), { + fontFamily: 'Righteous', fontSize: '28px', color: COLORS.textHex, align: 'center', lineSpacing: 12, + }).setOrigin(0.5).setDepth(D.modalUI); + + new Button(this, cx, cy + 150, 'Back to Menu', () => this.scene.start('GameMenu'), + { width: 300, fontSize: 26 }).setDepth(D.modalUI); + } + + // ── Helpers ─────────────────────────────────────────────────────────────────- + toast(text, x, y, color = '#f2ead8') { + const t = this.add.text(x, y, text, { fontFamily: 'Righteous', fontSize: '28px', color }) + .setOrigin(0.5).setDepth(D.toast).setShadow(0, 0, 'rgba(255,207,74,0.6)', 12); + this.tweens.add({ targets: t, y: y - 46, alpha: 0, duration: 1300, ease: 'Cubic.easeOut', onComplete: () => t.destroy() }); + } + + async postHistory(result) { + if (this.recorded) return; + this.recorded = true; + try { + await api.post('/history/single-player', { + slug: 'rummikub', + score: this.state.finalScores?.[0] ?? 0, + opponentScores: this.state.finalScores?.slice(1) ?? [], + result, + }); + } catch { /* non-fatal */ } + } + + delay(ms) { return new Promise((resolve) => this.time.delayedCall(ms, resolve)); } +} diff --git a/public/src/games/rummikub/RummikubLogic.js b/public/src/games/rummikub/RummikubLogic.js new file mode 100644 index 0000000..1712549 --- /dev/null +++ b/public/src/games/rummikub/RummikubLogic.js @@ -0,0 +1,286 @@ +// Rummikub — immutable game engine. No Phaser. Every mutating helper returns a +// NEW state object (mirrors HeartsLogic / CanastaLogic style). The Phaser scene, +// the AI and the verify harness all drive the game through these functions. +// +// A turn is edited on a working copy (workingTable / workingRack) so the player +// can freely rearrange tiles and then Commit (validated) or Reset (snapshot). + +import { + COLORS_ORDER, MIN_NUM, MAX_NUM, COPIES, JOKER_COUNT, RACK_START, + INITIAL_MELD_MIN, JOKER_PENALTY, +} from './RummikubData.js'; +import { isValidSet } from './RummikubSolver.js'; + +// ── RNG ─────────────────────────────────────────────────────────────────────── +function mulberry32(seed) { + let a = seed >>> 0; + return () => { + 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; + }; +} + +function shuffle(arr, rand) { + const a = arr.slice(); + for (let i = a.length - 1; i > 0; i--) { + const j = Math.floor(rand() * (i + 1)); + [a[i], a[j]] = [a[j], a[i]]; + } + return a; +} + +// ── Tiles ───────────────────────────────────────────────────────────────────-- +export function buildTiles() { + const tiles = []; + let id = 0; + for (let copy = 0; copy < COPIES; copy++) { + for (const color of COLORS_ORDER) { + for (let n = MIN_NUM; n <= MAX_NUM; n++) tiles.push({ id: id++, color, number: n, isJoker: false }); + } + } + for (let j = 0; j < JOKER_COUNT; j++) tiles.push({ id: id++, color: null, number: null, isJoker: true }); + return tiles; +} + +// ── Setup ───────────────────────────────────────────────────────────────────-- +export function createInitialState({ seed = (Math.random() * 1e9) | 0, playerCount = 4 } = {}) { + const rand = mulberry32(seed); + const deck = shuffle(buildTiles(), rand); + const players = []; + for (let seat = 0; seat < playerCount; seat++) { + players.push({ seat, rack: deck.splice(0, RACK_START), hasMelded: false }); + } + const state = { + playerCount, + players, + table: [], // committed sets: array of arrays of tiles + pool: deck, // remaining face-down draw tiles + currentPlayer: 0, + phase: 'play', // 'play' | 'gameOver' + consecutivePasses: 0, // turns in a row that emptied the pool with no play + winner: null, + finalScores: null, + commitError: null, + seed, + log: [], + // per-turn working copies (filled by beginTurn): + turnStart: null, + workingTable: [], + workingRack: [], + drewThisTurn: false, + }; + return beginTurn(state); +} + +// Snapshot the table + current rack so the turn can be Reset, and open editable +// working copies. +export function beginTurn(state) { + const seat = state.currentPlayer; + const rack = state.players[seat].rack.slice(); + const table = state.table.map((s) => s.slice()); + return { + ...state, + commitError: null, + drewThisTurn: false, + turnStart: { + table: table.map((s) => s.slice()), + rack: rack.slice(), + hasMelded: state.players[seat].hasMelded, + }, + workingTable: table, + workingRack: rack, + }; +} + +// ── Working-area editing ─────────────────────────────────────────────────────── +// Move a tile (identified by id) from wherever it currently sits in the working +// area to `dest`: { type:'set', index } | { type:'newSet' } | { type:'rack' }. +// Empty working sets are pruned. Returns a new state. +export function applyDrag(state, tileId, dest) { + let tile = null; + const workingRack = state.workingRack.filter((t) => { + if (t.id === tileId) { tile = t; return false; } + return true; + }); + const workingTable = state.workingTable.map((set) => set.filter((t) => { + if (t.id === tileId) { tile = t; return false; } + return true; + })); + if (!tile) return state; // unknown tile + + if (dest.type === 'rack') { + workingRack.push(tile); + } else if (dest.type === 'newSet') { + workingTable.push([tile]); + } else if (dest.type === 'set') { + if (workingTable[dest.index]) workingTable[dest.index].push(tile); + else workingTable.push([tile]); + } + const pruned = workingTable.filter((s) => s.length > 0); + return { ...state, workingTable: pruned, workingRack, commitError: null }; +} + +// Load an AI plan (newTable + the rack tiles it played) into the working copies, +// ready for commitTurn. Returns a new state. +export function stageAiPlan(state, plan) { + const playedIds = new Set(plan.tilesPlayed); + const workingRack = state.workingRack.filter((t) => !playedIds.has(t.id)); + const workingTable = plan.newTable.map((s) => s.slice()); + return { ...state, workingTable, workingRack, commitError: null }; +} + +export function resetTurn(state) { + const ts = state.turnStart; + return { + ...state, + workingTable: ts.table.map((s) => s.slice()), + workingRack: ts.rack.slice(), + drewThisTurn: false, + commitError: null, + }; +} + +// ── Commit validation ────────────────────────────────────────────────────────── +const setKey = (set) => set.map((t) => t.id).slice().sort((a, b) => a - b).join('-'); + +function multisetContains(haystackKeys, needleKeys) { + const counts = new Map(); + for (const k of haystackKeys) counts.set(k, (counts.get(k) || 0) + 1); + for (const k of needleKeys) { + const c = counts.get(k) || 0; + if (c === 0) return false; + counts.set(k, c - 1); + } + return true; +} + +// Returns { ok, reason, played, firstMeldPoints }. +export function validateCommit(state) { + const ts = state.turnStart; + const working = state.workingTable; + + // 1. every working set is a legal group/run. + for (const set of working) { + if (!isValidSet(set).valid) { + return { ok: false, reason: 'Every set on the table must be a valid run or group' }; + } + } + + // 2. tile bookkeeping. + const origTableIds = new Set(ts.table.flat().map((t) => t.id)); + const workTableIds = working.flat().map((t) => t.id); + const workTableSet = new Set(workTableIds); + + for (const id of origTableIds) { + if (!workTableSet.has(id)) return { ok: false, reason: 'Tiles already on the table must stay on the table' }; + } + const played = workTableIds.filter((id) => !origTableIds.has(id)); + if (played.length === 0) { + return { ok: false, reason: 'Play at least one tile to the table, or draw instead' }; + } + + // 3. first meld must total ≥30 from brand-new sets only (no manipulation yet). + if (!ts.hasMelded) { + const origKeys = ts.table.map(setKey); + const workKeys = working.map(setKey); + if (!multisetContains(workKeys, origKeys)) { + return { ok: false, reason: `Your opening meld must be at least ${INITIAL_MELD_MIN} points and can't rearrange the table yet` }; + } + // New sets = working sets minus the (intact) original sets. + const remaining = workKeys.slice(); + for (const k of origKeys) remaining.splice(remaining.indexOf(k), 1); + const newSets = working.filter((s) => remaining.includes(setKey(s))); + let points = 0; + for (const s of newSets) points += isValidSet(s).points; + if (points < INITIAL_MELD_MIN) { + return { ok: false, reason: `Your opening meld must total at least ${INITIAL_MELD_MIN} (currently ${points})`, firstMeldPoints: points }; + } + return { ok: true, played, firstMeldPoints: points }; + } + return { ok: true, played }; +} + +export function canCommit(state) { + return validateCommit(state).ok; +} + +export function commitTurn(state) { + const v = validateCommit(state); + if (!v.ok) return { ...state, commitError: v.reason }; + + const seat = state.currentPlayer; + const players = state.players.map((p, i) => + i === seat ? { ...p, rack: state.workingRack.slice(), hasMelded: true } : p); + const table = state.workingTable.map((s) => s.slice()); + + let next = { ...state, players, table, consecutivePasses: 0, commitError: null, drewThisTurn: false }; + + if (players[seat].rack.length === 0) { + return finishGame({ ...next }, seat); + } + return advanceTurn(next); +} + +// ── Drawing & passing ────────────────────────────────────────────────────────── +export function drawTile(state) { + const seat = state.currentPlayer; + if (state.pool.length === 0) return passTurn(state); // nothing to draw → pass + + const pool = state.pool.slice(); + const tile = pool.shift(); + const players = state.players.map((p, i) => + i === seat ? { ...p, rack: [...state.players[seat].rack, tile] } : p); + const next = { ...state, pool, players, consecutivePasses: 0, drewThisTurn: true, commitError: null }; + return advanceTurn(next); +} + +// Pool exhausted and the player has nothing to play: pass. When everyone passes +// in a row the hand is scored. +export function passTurn(state) { + const passes = state.consecutivePasses + 1; + if (passes >= state.playerCount) return finishGame({ ...state }, null); + return advanceTurn({ ...state, consecutivePasses: passes, commitError: null }); +} + +export function advanceTurn(state) { + const currentPlayer = (state.currentPlayer + 1) % state.playerCount; + return beginTurn({ ...state, currentPlayer }); +} + +// ── Game end ─────────────────────────────────────────────────────────────────── +export function rackPenalty(rack) { + return rack.reduce((sum, t) => sum + (t.isJoker ? JOKER_PENALTY : t.number), 0); +} + +// winnerSeat given (someone went out) or null (pool stalled → lowest rack wins). +function finishGame(state, winnerSeat) { + const penalties = state.players.map((p) => rackPenalty(p.rack)); + let winner = winnerSeat; + if (winner == null) { + // lowest remaining rack points wins; ties broken by lowest seat. + let best = Infinity; + state.players.forEach((p, i) => { if (penalties[i] < best) { best = penalties[i]; winner = i; } }); + } + // Classic scoring: the winner gains the sum of everyone else's rack penalties; + // each loser scores minus their own rack. + const finalScores = state.players.map((p, i) => (i === winner + ? penalties.reduce((a, b, j) => a + (j === winner ? 0 : b), 0) + : -penalties[i])); + return { ...state, phase: 'gameOver', winner, finalScores }; +} + +// ── Read helpers (for the scene / AI) ────────────────────────────────────────── +// Tiles played from the rack so far this working turn. +export function stagedPlayedIds(state) { + const origTableIds = new Set(state.turnStart.table.flat().map((t) => t.id)); + return state.workingTable.flat().map((t) => t.id).filter((id) => !origTableIds.has(id)); +} + +export function hasStagedChanges(state) { + const ts = state.turnStart; + const a = state.workingRack.map((t) => t.id).sort().join(','); + const b = ts.rack.map((t) => t.id).sort().join(','); + return a !== b; +} diff --git a/public/src/games/rummikub/RummikubSolver.js b/public/src/games/rummikub/RummikubSolver.js new file mode 100644 index 0000000..8493df0 --- /dev/null +++ b/public/src/games/rummikub/RummikubSolver.js @@ -0,0 +1,334 @@ +// Rummikub — the rules "brain". Pure functions, no Phaser, no engine state. +// Imported by the logic engine, the AI and the headless verify harness. +// +// isValidSet(tiles) — validate one group/run, assigning jokers. +// solvePartition(tiles) — constructively partition a multiset of tiles into +// valid sets (returns the actual sets, or null). This +// powers the AI's "what can I lay down" search and the +// feasibility checks. +// partitionable(tiles) — boolean wrapper around solvePartition. +// findSetsFromPool(...) — pull disjoint valid sets out of a loose pool (used +// for the 30-point initial meld and laying new sets). +// bestMeldDecomposition — the AI's turn planner. + +import { COLORS_ORDER, MIN_NUM, MAX_NUM } from './RummikubData.js'; + +const CI = (c) => COLORS_ORDER.indexOf(c); + +// ── Single-set validation ───────────────────────────────────────────────────── +// Returns { valid, kind, points, assignment } where assignment maps each joker +// tile id → { color, number } it represents (for display & scoring). +export function isValidSet(tiles) { + if (!tiles || tiles.length < 3 || tiles.length > 13) return { valid: false }; + const reals = tiles.filter((t) => !t.isJoker); + const jokers = tiles.filter((t) => t.isJoker); + if (reals.length === 0) return { valid: false }; // all-joker set never legal + + // GROUP: same number, distinct colours, 3–4 tiles. + if (tiles.length <= 4) { + const num = reals[0].number; + const colors = reals.map((t) => t.color); + const sameNum = reals.every((t) => t.number === num); + const distinct = new Set(colors).size === colors.length; + if (sameNum && distinct && jokers.length <= 4 - reals.length) { + const used = new Set(colors); + const assignment = {}; + const missing = COLORS_ORDER.filter((c) => !used.has(c)); + jokers.forEach((j, i) => { assignment[j.id] = { color: missing[i], number: num }; }); + return { valid: true, kind: 'group', points: num * tiles.length, assignment }; + } + } + + // RUN: same colour, consecutive numbers, jokers fill gaps/ends. + const color = reals[0].color; + if (reals.every((t) => t.color === color)) { + const nums = reals.map((t) => t.number); + if (new Set(nums).size === nums.length) { // no duplicate numbers + const lo = Math.min(...nums), hi = Math.max(...nums); + const spanReals = hi - lo + 1; + const size = tiles.length; + if (spanReals <= size) { + const jLeft = size - reals.length; // jokers to place + // Place the run window so it contains [lo,hi]; extend with leftover jokers. + // Try every start position that keeps [lo,hi] inside a length-`size` window. + for (let start = Math.max(MIN_NUM, hi - size + 1); start <= lo; start++) { + const end = start + size - 1; + if (end > MAX_NUM) continue; + const haveReal = new Set(nums); + const gaps = []; + for (let v = start; v <= end; v++) if (!haveReal.has(v)) gaps.push(v); + if (gaps.length === jLeft) { + const assignment = {}; + jokers.forEach((j, i) => { assignment[j.id] = { color, number: gaps[i] }; }); + let points = 0; + for (let v = start; v <= end; v++) points += v; + return { valid: true, kind: 'run', points, assignment }; + } + } + } + } + } + return { valid: false }; +} + +// ── Pool helpers (counts + concrete tile objects) ───────────────────────────── +function buildPools(tiles) { + const byKey = new Map(); // 'color-num' → array of concrete tile objects + const jokers = []; + for (const t of tiles) { + if (t.isJoker) { jokers.push(t); continue; } + const k = `${t.color}-${t.number}`; + if (!byKey.has(k)) byKey.set(k, []); + byKey.get(k).push(t); + } + return { byKey, jokers }; +} + +function poolSignature(byKey, jokers) { + const parts = []; + for (const [k, arr] of byKey) if (arr.length) parts.push(`${k}:${arr.length}`); + parts.sort(); + return parts.join(',') + `|J${jokers.length}`; +} + +function smallestRealKey(byKey) { + let best = null, bestV = Infinity, bestC = Infinity; + for (const [k, arr] of byKey) { + if (!arr.length) continue; + const dash = k.lastIndexOf('-'); + const color = k.slice(0, dash), num = +k.slice(dash + 1); + if (num < bestV || (num === bestV && CI(color) < bestC)) { + best = { key: k, color, num }; bestV = num; bestC = CI(color); + } + } + return best; +} + +// Constructively partition `tiles` into valid sets. Returns an array of sets +// (each an array of concrete tile objects, jokers carrying a `_rep` assignment) +// or null if impossible. Bounded by a node budget so the AI can never hang. +const SOLVE_BUDGET = 12000; // node cap so the AI can never hang + +export function solvePartition(tiles) { + const { byKey, jokers } = buildPools(tiles); + const fails = new Set(); + const budget = { n: SOLVE_BUDGET }; + + const take = (key) => byKey.get(key).pop(); + const give = (key, t) => byKey.get(key).push(t); + + function rec() { + if (budget.n-- <= 0) return null; + const pick = smallestRealKey(byKey); + if (!pick) return jokers.length === 0 ? [] : null; // leftover jokers → fail + const sig = poolSignature(byKey, jokers); + if (fails.has(sig)) return null; + + const { color, num } = pick; + const candidates = []; + + // Runs of `color` starting at `num` (num is the run minimum by construction). + // jokersNeeded only grows with L, so once it exceeds the pool we can stop. + for (let L = 3; num + L - 1 <= MAX_NUM; L++) { + const need = []; // concrete real keys to pull + let jokersNeeded = 0; + for (let v = num; v < num + L; v++) { + const k = `${color}-${v}`; + if (byKey.get(k)?.length) need.push(k); + else jokersNeeded++; + } + if (jokersNeeded > jokers.length) break; + candidates.push({ type: 'run', need, jokersNeeded, color, start: num, len: L }); + } + + // Groups at value `num` including `color`. + const present = COLORS_ORDER.filter((c) => c !== color && byKey.get(`${c}-${num}`)?.length); + const subs = subsets(present); + for (const R of subs) { + const baseColors = 1 + R.length; + for (let j = Math.max(0, 3 - baseColors); j <= Math.min(jokers.length, 4 - baseColors); j++) { + const size = baseColors + j; + if (size < 3 || size > 4) continue; + const need = [`${color}-${num}`, ...R.map((c) => `${c}-${num}`)]; + candidates.push({ type: 'group', need, jokersNeeded: j, color, num }); + } + } + + for (const cand of candidates) { + const pulledReal = cand.need.map((k) => ({ k, t: take(k) })); + const pulledJok = []; + for (let i = 0; i < cand.jokersNeeded; i++) pulledJok.push(jokers.pop()); + const set = pulledReal.map((p) => p.t); + for (const j of pulledJok) set.push(j); + // attach representation for display/scoring + const v = isValidSet(set); + if (v.valid) { + for (const j of pulledJok) j._rep = v.assignment[j.id]; + set._meta = { kind: v.kind, points: v.points }; + const rest = rec(); + if (rest) return [set, ...rest]; + } + // backtrack + for (const j of pulledJok) { delete j._rep; jokers.push(j); } + for (const p of pulledReal) give(p.k, p.t); + } + + fails.add(sig); + return null; + } + + return rec(); +} + +function subsets(arr) { + const out = [[]]; + for (const x of arr) { + const len = out.length; + for (let i = 0; i < len; i++) out.push([...out[i], x]); + } + return out; +} + +export function partitionable(tiles) { + if (!tiles.length) return true; + return solvePartition(tiles) !== null; +} + +// ── Loose-pool set finding (for laying new sets / the initial meld) ─────────── +// Greedily pull disjoint valid sets out of `poolTiles`, maximising tile count. +// Returns { sets: [[tile,...]], tilesUsed, points }. +export function findSetsFromPool(poolTiles) { + // For small pools, a full partition (laying everything down) is worth trying — + // this is how an AI goes out. Large pools rarely fully partition, so skip the + // expensive solve and go straight to greedy extraction. + if (poolTiles.length <= 15) { + const whole = solvePartition(poolTiles); + if (whole) { + let points = 0, used = 0; + for (const s of whole) { points += s._meta.points; used += s.length; } + return { sets: whole, tilesUsed: used, points }; + } + } + // Otherwise repeatedly extract the best single set from what remains. + let remaining = poolTiles.slice(); + const sets = []; + let guard = 0; + while (guard++ < 60) { + const best = extractOneSet(remaining); + if (!best) break; + sets.push(best.set); + const usedIds = new Set(best.set.map((t) => t.id)); + remaining = remaining.filter((t) => !usedIds.has(t.id)); + } + let points = 0, used = 0; + for (const s of sets) { const v = isValidSet(s); points += v.points; used += s.length; } + return { sets, tilesUsed: used, points }; +} + +// Find one valid set inside a loose pool (prefers larger / higher-scoring sets). +function extractOneSet(pool) { + const { byKey, jokers } = buildPools(pool); + let best = null; + // Try runs by colour. + for (const color of COLORS_ORDER) { + for (let start = MIN_NUM; start <= MAX_NUM - 2; start++) { + for (let L = 3; start + L - 1 <= MAX_NUM; L++) { + const set = []; + let jUsed = 0, ok = true; + const jpool = jokers.slice(); + const usedByKey = new Map(); + for (let v = start; v < start + L; v++) { + const k = `${color}-${v}`; + const avail = (byKey.get(k)?.length || 0) - (usedByKey.get(k) || 0); + if (avail > 0) { usedByKey.set(k, (usedByKey.get(k) || 0) + 1); set.push(findTile(pool, color, v, set)); } + else if (jpool.length) { set.push(jpool.pop()); jUsed++; } + else { ok = false; break; } + } + if (ok) { const v = isValidSet(set); if (v.valid) best = better(best, set, v); } + } + } + } + // Try groups by value. + for (let num = MIN_NUM; num <= MAX_NUM; num++) { + const colorsHere = COLORS_ORDER.filter((c) => byKey.get(`${c}-${num}`)?.length); + for (const R of subsets(colorsHere)) { + if (R.length < 1) continue; + for (let j = 0; j <= jokers.length; j++) { + const size = R.length + j; + if (size < 3 || size > 4) continue; + const set = R.map((c) => findTile(pool, c, num, [])).filter(Boolean); + for (let i = 0; i < j; i++) set.push(jokers[i]); + // ensure distinct concrete ids + if (new Set(set.map((t) => t.id)).size !== set.length) continue; + const v = isValidSet(set); + if (v.valid) best = better(best, set, v); + } + } + } + return best; +} + +function findTile(pool, color, num, exclude) { + const ex = new Set(exclude.map((t) => t.id)); + return pool.find((t) => !t.isJoker && t.color === color && t.number === num && !ex.has(t.id)); +} + +function better(best, set, v) { + const score = set.length * 100 + v.points; + if (!best || score > best.score) return { set: set.slice(), points: v.points, score }; + return best; +} + +// ── AI turn planner ─────────────────────────────────────────────────────────── +// Given the player's rack and the current table, decide what to lay down. +// Returns { tilesPlayed: [ids], newTable: [[tile,...]] } or null (→ draw). +export function bestMeldDecomposition(rackTiles, tableTiles, { mustReach = 0, alreadyMelded = false, manipulate = false } = {}) { + if (!alreadyMelded) { + // First meld: form sets purely from the rack worth ≥ mustReach. + const found = findSetsFromPool(rackTiles); + if (found.points >= mustReach && found.tilesUsed > 0) { + const playedIds = found.sets.flat().map((t) => t.id); + return { tilesPlayed: playedIds, newTable: [...cloneSets(tableTiles), ...found.sets], firstMeld: true }; + } + return null; + } + + // Already melded. 1) Always-safe: lay any complete new sets made from the rack. + const newSets = findSetsFromPool(rackTiles); + let table = cloneSets(tableTiles); + let played = []; + if (newSets.tilesUsed > 0) { + table = [...table, ...newSets.sets]; + played = newSets.sets.flat().map((t) => t.id); + } + const playedSet = new Set(played); + let rackLeft = rackTiles.filter((t) => !playedSet.has(t.id)); + + // 2) Manipulation (higher skill): try to attach more rack tiles by re-solving + // the whole table. Single capped pass — add any rack tile that keeps the + // table partitionable. Skipped when the table is large (solver too costly). + if (manipulate && rackLeft.length && table.flat().length <= 26) { + let pool = table.flat(); + let solves = 0; + for (let i = 0; i < rackLeft.length && solves < 24; i++) { + solves++; + const trial = solvePartition([...pool, rackLeft[i]]); + if (trial) { + pool = trial.flat(); + played.push(rackLeft[i].id); + table = trial; + } + } + } + + if (played.length === 0) return null; + return { tilesPlayed: played, newTable: table, firstMeld: false }; +} + +// Deep-ish clone of table sets (new arrays; tile objects shared by reference are +// fine — callers treat tiles as immutable descriptors). +function cloneSets(tableTiles) { + // tableTiles may be a flat tile array OR an array of sets. Normalise to sets is + // the caller's job; here we expect an array of sets. + return (tableTiles || []).map((s) => s.slice()); +} diff --git a/public/src/games/rummikub/tutorial.md b/public/src/games/rummikub/tutorial.md new file mode 100644 index 0000000..0aa5c16 --- /dev/null +++ b/public/src/games/rummikub/tutorial.md @@ -0,0 +1,55 @@ +# Rummikub — Racks, Runs, and the Joy of the Big Rearrange + +*By Aunt Rivka, who has hosted Sunday tile night for thirty-one years and has never once let anyone leave early* + +--- + +Sit, sit. Push the cat off the chair. You see these little numbered tiles all face-down in the middle? We call that the **pool**. Everyone gets a rack of fourteen, and the whole game is a race to be the first to lay every last one of them down on the table. Simple to learn, and then it gets its claws in you. Let me show you. + +## The Goal + +Be the **first to empty your rack**. That's it. When your rack is bare, you've won the round and everyone else groans and totals up the tiles they got stuck with. + +## The Tiles + +There are **106** of them: the numbers **1 through 13** in **four colours** — red, blue, black and orange — two of each. And **two jokers**, the wild rascals that can stand in for any tile you like. + +## What Counts as a Set + +Everything you lay on the table must belong to a valid **set**, and there are only two kinds: + +- **A Group** — three or four tiles of the **same number**, each a **different colour**. (Red 7, blue 7, black 7. Lovely.) +- **A Run** — three or more tiles of the **same colour** in a **consecutive row**. (Blue 4, blue 5, blue 6.) Runs don't wrap around — 13 is the end of the line, no looping back to 1. + +A **joker** fills any gap. Pop it between red 5 and red 7 and it's a red 6. In a group it becomes whatever colour is missing. It's worth the tile it pretends to be. + +## Your Turn + +When the gold ring is around your portrait, it's you. **Drag tiles** from your rack up to the table to build sets. Each set sits in its own little tray that glows **green when it's valid** and **red when it isn't** — so you always know where you stand. When you're happy, hit **Play**. + +Don't like how it's going? **Reset** snaps everything back to the start of your turn, no harm done. And **Sort** tidies your rack by colour and number when your eyes are tired. + +If you can't (or don't want to) play anything, hit **Draw** to take one tile from the pool — and that ends your turn. + +## The One Big Rule: Your Opening Meld + +Here's the catch that trips up every newcomer. Your **very first** play of the game must be sets, made **entirely from your own rack**, worth **at least 30 points** all together (add up the tile numbers). Until you've made that opening meld, you can't touch the tiles already on the table. Once you're in, though — oh, then the fun begins. + +## The Beautiful Part: Manipulation + +After you've made your opening meld, the whole table is fair game. You may **rearrange anything** out there — break up runs, borrow from groups, shuffle it all about — **as long as every set is still valid when you hit Play**. Take the blue 6 off the end of one run to start another. Split a group to slot in your spare. This is where a stuck rack suddenly comes loose and you lay down six tiles in one glorious move. The table glows green, you press Play, and you feel like a genius. + +Just remember: when your turn ends, **no leftovers**. Every tile on the table must sit in a complete, valid set, and any tile you pulled down has to stay down. + +## When It Ends + +The moment someone empties their rack, the round is over. Everyone else adds up the tiles still on their rack as penalty points (a joker stings for **30**), and the winner is crowned. If the pool runs dry and nobody can move, we settle it on the lowest rack instead. + +## Aunt Rivka's Advice + +- **Hold a joker for the big play.** It's tempting to spend it early, but a joker saved is a rearrange waiting to happen. +- **Watch the table, not just your rack.** Half your moves live in tiles other people laid down. +- **Make your opening 30 and get in the game** — you can't manipulate a thing until you do. +- **Count before you commit.** A red tray means a set is short or broken; tidy it before you press Play. + +Now then. Fourteen tiles, a clear head, and a little nerve. Drag something up to the table and let's see what you've got. diff --git a/public/src/main.js b/public/src/main.js index c25e863..25224d0 100644 --- a/public/src/main.js +++ b/public/src/main.js @@ -77,6 +77,7 @@ import CribbageGame from './games/cribbage/CribbageGame.js'; import CanastaGame from './games/canasta/CanastaGame.js'; import DotLinkGame from './games/dotlink/DotLinkGame.js'; import Game2048 from './games/2048/2048Game.js'; +import RummikubGame from './games/rummikub/RummikubGame.js'; const config = { type: Phaser.AUTO, @@ -167,6 +168,7 @@ const config = { CanastaGame, DotLinkGame, Game2048, + RummikubGame, ], }; diff --git a/public/src/scenes/GameRoomScene.js b/public/src/scenes/GameRoomScene.js index 3f91b24..b67ba6a 100644 --- a/public/src/scenes/GameRoomScene.js +++ b/public/src/scenes/GameRoomScene.js @@ -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', mahjong: 'MahjongGame', jewelquest: 'JewelQuestGame', zuma: 'ZumaGame', bejeweled: 'BejeweledGame', minimotorways: 'MiniMotorwaysGame', slots: 'SlotsGame', cribbage: 'CribbageGame', canasta: 'CanastaGame', dotlink: 'DotLinkGame', '2048': '2048Game' }; + 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', jewelquest: 'JewelQuestGame', zuma: 'ZumaGame', bejeweled: 'BejeweledGame', minimotorways: 'MiniMotorwaysGame', slots: 'SlotsGame', cribbage: 'CribbageGame', canasta: 'CanastaGame', dotlink: 'DotLinkGame', '2048': '2048Game', rummikub: 'RummikubGame' }; if (slugDispatch[this.game.slug]) { this.scene.start(slugDispatch[this.game.slug], { game: this.game, diff --git a/server/games/registry.js b/server/games/registry.js index ef4af5b..b1e458a 100644 --- a/server/games/registry.js +++ b/server/games/registry.js @@ -92,3 +92,4 @@ registerGame({ slug: 'cribbage', name: 'Cribbage', category: 'cards', cardGame: registerGame({ slug: 'canasta', name: 'Canasta', category: 'cards', cardGame: true, minPlayers: 4, maxPlayers: 4, minOpponents: 3, maxOpponents: 3, hasTutorial: true, iconFrame: 65 }); registerGame({ slug: 'dotlink', name: 'Dot Link', category: 'logic', minPlayers: 1, maxPlayers: 1, minOpponents: 0, maxOpponents: 0, iconFrame: 66 }); registerGame({ slug: '2048', name: '2048', category: 'logic', minPlayers: 1, maxPlayers: 1, minOpponents: 0, maxOpponents: 0, iconFrame: 67 }); +registerGame({ slug: 'rummikub', name: 'Rummikub', category: 'cards', cardGame: true, minPlayers: 2, maxPlayers: 4, minOpponents: 1, maxOpponents: 3, hasTutorial: true, iconFrame: 68 }); diff --git a/server/scripts/verifyRummikub.js b/server/scripts/verifyRummikub.js new file mode 100644 index 0000000..2a55199 --- /dev/null +++ b/server/scripts/verifyRummikub.js @@ -0,0 +1,199 @@ +// Headless verification for Rummikub. +// node server/scripts/verifyRummikub.js [--games=N] +// Exits non-zero on any failure. +// +// 1. Fixture tests: set validation (runs/groups/jokers), table partitioning, the +// 30-point initial meld, and core engine transitions. +// 2. Self-play: full 2–4 player games driven by the heuristic AI in every seat, +// asserting invariants (no exceptions, strict tile conservation = 106 every +// turn, legal sets, the opening-meld rule, termination with a winner). + +import { + isValidSet, partitionable, solvePartition, bestMeldDecomposition, +} from '../../public/src/games/rummikub/RummikubSolver.js'; +import { + createInitialState, commitTurn, drawTile, stageAiPlan, validateCommit, + buildTiles, rackPenalty, beginTurn, +} from '../../public/src/games/rummikub/RummikubLogic.js'; +import { planTurn } from '../../public/src/games/rummikub/RummikubAI.js'; +import { INITIAL_MELD_MIN } from '../../public/src/games/rummikub/RummikubData.js'; + +let failures = 0; +function check(name, cond, detail = '') { + if (cond) { console.log(` ok ${name}`); return; } + failures++; + console.error(` FAIL ${name}${detail ? ` — ${detail}` : ''}`); +} + +// Fixture tiles use ids well above the engine's 0..105 so they never collide. +let _id = 100000; +const T = (color, number) => ({ id: _id++, color, number, isJoker: false }); +const J = () => ({ id: _id++, color: null, number: null, isJoker: true }); + +// ── 1. Single-set validation ──────────────────────────────────────────────────── +console.log('Set validation:'); +check('run 4-5-6 red', isValidSet([T('red', 4), T('red', 5), T('red', 6)]).valid); +check('group 7 r/b/k', isValidSet([T('red', 7), T('blue', 7), T('black', 7)]).valid); +check('group of four', isValidSet([T('red', 7), T('blue', 7), T('black', 7), T('orange', 7)]).valid); +check('five not a group', !isValidSet([T('red', 7), T('blue', 7), T('black', 7), T('orange', 7), T('red', 7)]).valid); +check('group needs distinct colours', !isValidSet([T('red', 7), T('red', 7), T('blue', 7)]).valid); +check('run rejects duplicate', !isValidSet([T('red', 5), T('red', 5), T('red', 6)]).valid); +check('run does not wrap 13→1', !isValidSet([T('red', 12), T('red', 13), T('red', 1)]).valid); +check('two tiles too short', !isValidSet([T('red', 4), T('red', 5)]).valid); +{ + const r = isValidSet([T('red', 5), J(), T('red', 7)]); + check('joker fills run gap (=red6)', r.valid && r.points === 18, `points ${r.points}`); +} +{ + const g = isValidSet([T('red', 7), T('blue', 7), J()]); + check('joker fills group, scored at 7', g.valid && g.points === 21, `points ${g.points}`); +} +check('non-adjacent reals + joker invalid', !isValidSet([T('red', 2), T('red', 9), J()]).valid); +check('13 reals + joker too long', !isValidSet( + Array.from({ length: 13 }, (_, i) => T('blue', i + 1)).concat(J())).valid); +check('all-joker set invalid', !isValidSet([J(), J(), J()]).valid); + +// ── 2. Table partitioning ─────────────────────────────────────────────────────── +console.log('Partitioning:'); +check('two clean sets partition', partitionable([ + T('red', 1), T('red', 2), T('red', 3), T('blue', 7), T('black', 7), T('orange', 7), +])); +check('missing tile fails', !partitionable([T('red', 1), T('red', 2), T('blue', 7), T('black', 7)])); +check('empty table partitions', partitionable([])); +{ + // A shared-tile rearrangement: red 1..5 (two copies of 3) = run 1-2-3 + run 3-4-5. + const tiles = [T('red', 1), T('red', 2), T('red', 3), T('red', 3), T('red', 4), T('red', 5)]; + check('overlapping runs partition', partitionable(tiles)); +} +{ + const sol = solvePartition([T('red', 3), T('red', 4), T('red', 5), T('blue', 9), T('black', 9), J()]); + check('joker-in-table partition reconstructs', !!sol && sol.length === 2); +} +{ + // odd count that cannot fully partition (7 tiles, one stranded) + check('stranded tile fails', !partitionable([ + T('red', 1), T('red', 2), T('red', 3), T('blue', 7), T('black', 7), T('orange', 7), T('orange', 1), + ])); +} + +// ── 3. Initial-meld rule ──────────────────────────────────────────────────────── +console.log('Initial meld:'); +{ + // 29 points from one run → rejected; 30 → accepted (via bestMeldDecomposition). + const rack29 = [T('blue', 9), T('blue', 10), T('blue', 11)]; // 30 actually; craft 29: + const r29 = [T('red', 4), T('red', 5), T('red', 6), T('black', 9)]; // 4+5+6=15 only set; <30 + const dec29 = bestMeldDecomposition(r29, [], { mustReach: INITIAL_MELD_MIN, alreadyMelded: false }); + check('sub-30 opening rejected', dec29 === null); + const dec30 = bestMeldDecomposition(rack29, [], { mustReach: INITIAL_MELD_MIN, alreadyMelded: false }); + check('30 opening accepted', !!dec30 && dec30.tilesPlayed.length === 3); +} +{ + // joker counted at represented value toward the 30. + const rack = [T('orange', 10), T('orange', 11), J()]; // 10+11+12 = 33 + const dec = bestMeldDecomposition(rack, [], { mustReach: INITIAL_MELD_MIN, alreadyMelded: false }); + check('joker counts toward opening meld', !!dec); +} + +// ── 4. Engine transitions ─────────────────────────────────────────────────────── +console.log('Engine:'); +for (const pc of [2, 3, 4]) { + const s = createInitialState({ seed: 123, playerCount: pc }); + const dealt = s.players.reduce((a, p) => a + p.rack.length, 0); + check(`${pc}p deal: 14 each + pool = 106`, dealt === 14 * pc && s.pool.length === 106 - 14 * pc, + `dealt ${dealt}, pool ${s.pool.length}`); +} +{ + let s = createInitialState({ seed: 5, playerCount: 4 }); + const before = s.currentPlayer; + const poolBefore = s.pool.length; + s = drawTile(s); + check('draw advances turn', s.currentPlayer === (before + 1) % 4); + check('draw removes one pool tile', s.pool.length === poolBefore - 1); +} +{ + // Reject an invalid working board (a 2-tile "set"). + let s = createInitialState({ seed: 6, playerCount: 2 }); + s = { ...s, workingTable: [[s.workingRack[0], s.workingRack[1]]], workingRack: s.workingRack.slice(2) }; + const v = validateCommit(s); + check('invalid board rejected', !v.ok); + const after = commitTurn(s); + check('rejected commit leaves an error', !!after.commitError && after.currentPlayer === s.currentPlayer); +} +check('tile factory builds 106', buildTiles().length === 106); +check('rack penalty: joker=30', rackPenalty([J()]) === 30); + +// ── 5. Self-play ──────────────────────────────────────────────────────────────── +const games = Number((process.argv.find((a) => a.startsWith('--games=')) || '').split('=')[1]) || 300; +console.log(`Self-play (${games} games):`); + +function tileTotal(s) { + const rack = s.players.reduce((a, p) => a + p.rack.length, 0); + const table = s.table.reduce((a, set) => a + set.length, 0); + return rack + table + s.pool.length; +} + +let exceptions = 0, conserveBad = 0, illegalSet = 0, badRack = 0, noWinner = 0, firstMeldBad = 0; +const winsBySeat = {}; +let meldedGames = 0; + +for (let g = 1; g <= games; g++) { + try { + const playerCount = 2 + (g % 3); // cycles 2,3,4 + let s = createInitialState({ seed: g * 2654435761, playerCount }); + let turns = 0, sawMeld = false; + + while (s.phase !== 'gameOver') { + if (++turns > 100000) throw new Error('turn loop did not terminate'); + if (tileTotal(s) !== 106) { conserveBad++; throw new Error(`tile total ${tileTotal(s)}`); } + + const seat = s.currentPlayer; + const meldedBefore = s.players[seat].hasMelded; + const skill = 1 + ((g + seat) % 5); + const plan = planTurn(s, seat, skill); + + if (plan.type === 'commit') { + // Opening-meld rule must hold. + if (!meldedBefore && plan.firstMeld) { + let pts = 0; + for (const set of plan.newTable) { + const ids = set.map((t) => t.id); + const fresh = ids.some((id) => plan.tilesPlayed.includes(id)); + if (fresh && set.every((t) => plan.tilesPlayed.includes(t.id))) pts += isValidSet(set).points; + } + if (pts < INITIAL_MELD_MIN) firstMeldBad++; + } + s = stageAiPlan(s, plan); + const v = validateCommit(s); + if (!v.ok) { illegalSet++; throw new Error(`AI proposed illegal commit: ${v.reason}`); } + s = commitTurn(s); + sawMeld = true; + } else { + s = drawTile(s); + } + + // Every committed table set is legal. + for (const set of s.table) if (!isValidSet(set).valid) illegalSet++; + for (const p of s.players) if (p.rack.length < 0 || p.rack.length > 60) badRack++; + } + + if (s.winner == null) noWinner++; else winsBySeat[s.winner] = (winsBySeat[s.winner] || 0) + 1; + if (sawMeld) meldedGames++; + } catch (e) { + exceptions++; + if (exceptions <= 5) console.error(` game ${g}: ${e.message}`); + } +} + +check('no exceptions during self-play', exceptions === 0, `${exceptions} games threw`); +check('tile conservation holds (106 every turn)', conserveBad === 0, `${conserveBad} violations`); +check('all committed sets legal', illegalSet === 0, `${illegalSet} illegal`); +check('rack sizes stay sane', badRack === 0, `${badRack} bad`); +check('opening-meld rule never broken', firstMeldBad === 0, `${firstMeldBad} bad`); +check('most games see a meld', meldedGames >= games * 0.8, `${meldedGames}/${games}`); +check('games terminate with a winner', noWinner <= games * 0.05, `${noWinner} without winner`); +const seatList = Object.entries(winsBySeat).map(([k, v]) => `seat${k}:${v}`).join(' '); +check('wins spread across seats', Object.keys(winsBySeat).length >= 2, seatList); +console.log(` results: ${seatList}, no-winner ${noWinner}, melded ${meldedGames}/${games}`); + +console.log(failures ? `\n${failures} check(s) FAILED` : '\nAll checks passed.'); +process.exit(failures ? 1 : 0);