// 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: 200, 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; // Side opponents at x≈150 (left) and x≈1770 (right) need clear space. // Portrait ring + name text extends ~180px inward from each side opponent. const lm = playerCount >= 3 ? 350 : 120; // left margin const rm = playerCount >= 4 ? 350 : 120; // right margin const tableX = lm, tableY = 300, tableW = width - lm - rm, tableH = 430; return { cx, seats, // Pool sits just outside the table band to the right, near the bottom edge. pool: { x: tableX + tableW + 70, y: tableY + tableH - 20 }, table: { x: tableX, y: tableY, w: tableW, h: tableH }, rack: { x: 120, y: height - 168, w: width - 240, h: 150 }, }; }