/** * 마작패 (docs/games/mahjong.md §3, §8.1). Tile ids 0..135, kind = id >> 2 (0..33): * 0-8 1m-9m, 9-17 1p-9p, 18-26 1s-9s, 27-30 동남서북, 31-33 백발중. * Over the wire a tile is a short code "[r]", e.g. "5m0r" (red five), "3p2", "7z1" (중) — unique per * tile so hidden-information leak checks stay meaningful. */ export type TileId = number; export type Kind = number; export const EAST = 27; export const SOUTH = 28; export const WEST = 29; export const NORTH = 30; export const HAKU = 31; export const HATSU = 32; export const CHUN = 33; export const KIND_CODES: string[] = [ ...['m', 'p', 's'].flatMap((s) => Array.from({ length: 9 }, (_, i) => `${i + 1}${s}`)), ...Array.from({ length: 7 }, (_, i) => `${i + 1}z`), ]; const KIND_BY_CODE: Record = Object.fromEntries(KIND_CODES.map((c, i) => [c, i])); export const kindOf = (id: TileId): Kind => id >> 2; export const isHonor = (k: Kind): boolean => k >= 27; export const isDragon = (k: Kind): boolean => k >= 31; export const isWind = (k: Kind): boolean => k >= 27 && k <= 30; export const isTerminal = (k: Kind): boolean => k < 27 && (k % 9 === 0 || k % 9 === 8); export const isYaochu = (k: Kind): boolean => k >= 27 || k % 9 === 0 || k % 9 === 8; export const suitOf = (k: Kind): number => (k < 27 ? Math.floor(k / 9) : 3); export const numOf = (k: Kind): number => (k < 27 ? (k % 9) + 1 : 0); export const YAOCHU: Kind[] = [0, 8, 9, 17, 18, 26, 27, 28, 29, 30, 31, 32, 33]; export const GREEN: Kind[] = [19, 20, 21, 23, 25, HATSU]; /** Kinds missing in 3-player (산마): 2m~8m. */ export const isSanmaRemoved = (k: Kind): boolean => k >= 1 && k <= 7; export function allTiles(sanma: boolean): TileId[] { const out: TileId[] = []; for (let id = 0; id < 136; id++) if (!(sanma && isSanmaRemoved(kindOf(id)))) out.push(id); return out; } /** Red fives in use: 4인 3 → 5m·5p·5s, 2 → 5p·5s; 산마 → 5p·5s (or none). Copy 0 of each five is the red one. */ export function redTiles(akaDora: number, sanma: boolean): TileId[] { if (akaDora <= 0) return []; if (sanma || akaDora === 2) return [52, 88]; return [16, 52, 88]; } export function tileCode(id: TileId, red: readonly TileId[]): string { return `${KIND_CODES[kindOf(id)]}${id & 3}${red.includes(id) ? 'r' : ''}`; } /** Inverse of tileCode (the red marker is ignored); null when malformed. */ export function parseTileCode(code: string): TileId | null { const m = /^([1-9][mps]|[1-7]z)([0-3])r?$/.exec(code); if (!m) return null; return KIND_BY_CODE[m[1]!]! * 4 + Number(m[2]); } export const kindCode = (k: Kind): string => KIND_CODES[k]!; export const kindFromCode = (code: string): Kind | null => KIND_BY_CODE[code] ?? null; /** Dora shown by an indicator (§6.2, 산마: 1m ↔ 9m). */ export function doraFromIndicator(ind: Kind, sanma: boolean): Kind { if (ind < 27) { if (sanma && ind === 0) return 8; if (sanma && ind === 8) return 0; return ind - (ind % 9) + (((ind % 9) + 1) % 9); } if (ind <= 30) return ind === 30 ? 27 : ind + 1; return ind === 33 ? 31 : ind + 1; } const SUIT_KO = ['만수', '통수', '삭수']; const HONOR_KO = ['동', '남', '서', '북', '백', '발', '중']; /** Screen-reader / log name, e.g. "만수 5", "동", "중". */ export function kindNameKo(k: Kind): string { return k < 27 ? `${SUIT_KO[suitOf(k)]} ${numOf(k)}` : HONOR_KO[k - 27]!; } /** Short name, e.g. "5만", "3통", "7삭", "동". */ export function kindShortKo(k: Kind): string { return k < 27 ? `${numOf(k)}${['만', '통', '삭'][suitOf(k)]}` : HONOR_KO[k - 27]!; } export const WIND_KO = ['동', '남', '서', '북']; /** Sort key: 만→통→삭→자, by number, red five before plain fives. */ export function compareTiles(a: TileId, b: TileId): number { return a - b; } export function counts34(kinds: Iterable): number[] { const c = new Array(34).fill(0); for (const k of kinds) c[k]!++; return c; }