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