/** * 숫자 타일 (docs/games/rummikub.md §3, §5.1, §5.5): tile ids, set validation / joker interpretation, * scoring helpers and simple "what can I play from my rack" search (hint button + random playouts). * Pure functions without zod so the screen can import them cheaply. */ export type TileColor = 'K' | 'R' | 'B' | 'O'; export type Tile = { id: string; kind: 'num'; color: TileColor; n: number } | { id: string; kind: 'joker' }; export type NumTile = Extract; export const COLORS: TileColor[] = ['K', 'R', 'B', 'O']; export const COLOR_KO: Record = { K: '검정', R: '빨강', B: '파랑', O: '주황' }; /** 106 ids: `R7a`, `R7b`, ... and `JK1`, `JK2` (§3). */ export const ALL_TILE_IDS: string[] = (() => { const out: string[] = []; for (const c of COLORS) for (let n = 1; n <= 13; n++) for (const copy of ['a', 'b']) out.push(`${c}${n}${copy}`); out.push('JK1', 'JK2'); return out; })(); const TILES = new Map( ALL_TILE_IDS.map((id): [string, Tile] => id.startsWith('JK') ? [id, { id, kind: 'joker' }] : [id, { id, kind: 'num', color: id[0] as TileColor, n: Number(id.slice(1, -1)) }], ), ); export const isTileId = (id: string): boolean => TILES.has(id); export function tileOf(id: string): Tile { const t = TILES.get(id); if (!t) throw new Error(`unknown tile ${id}`); return t; } export const isJoker = (id: string): boolean => id === 'JK1' || id === 'JK2'; export interface DraftSet { tileIds: string[]; /** Hint for sets that could be read both ways, e.g. [빨강5, 조커, 조커] (§5.5). */ as?: 'run' | 'group'; } export interface JokerAs { /** null = one of the missing colours of a group (not fixed). */ color: TileColor | null; n: number; } export interface NormalSet { /** Display order (runs ascending). */ tileIds: string[]; kind: 'run' | 'group'; jokerAs: Record; points: number; } function asGroup(ts: Tile[]): NormalSet | null { if (ts.length < 3 || ts.length > 4) return null; const nums = ts.filter((t): t is NumTile => t.kind === 'num'); if (nums.length === 0) return null; const n = nums[0]!.n; if (nums.some((t) => t.n !== n)) return null; if (new Set(nums.map((t) => t.color)).size !== nums.length) return null; const jokerAs: Record = {}; for (const t of ts) if (t.kind === 'joker') jokerAs[t.id] = { color: null, n }; // Numbers first in colour order, jokers last. const order = [...nums].sort((a, b) => COLORS.indexOf(a.color) - COLORS.indexOf(b.color)).map((t) => t.id); for (const t of ts) if (t.kind === 'joker') order.push(t.id); return { tileIds: order, kind: 'group', jokerAs, points: n * ts.length }; } function runFrom(ts: Tile[], start: number, color: TileColor): NormalSet { const jokerAs: Record = {}; let points = 0; ts.forEach((t, k) => { points += start + k; if (t.kind === 'joker') jokerAs[t.id] = { color, n: start + k }; }); return { tileIds: ts.map((t) => t.id), kind: 'run', jokerAs, points }; } function asRun(ts: Tile[]): NormalSet | null { const len = ts.length; if (len < 3 || len > 13) return null; const nums = ts.filter((t): t is NumTile => t.kind === 'num'); if (nums.length === 0) return null; const color = nums[0]!.color; if (nums.some((t) => t.color !== color)) return null; // (a) the order the client sent, if it is a valid run as it stands. const first = ts.findIndex((t) => t.kind === 'num'); const start = nums[0]!.n - first; if (start >= 1 && start + len - 1 <= 13 && ts.every((t, k) => t.kind === 'joker' || t.n === start + k)) return runFrom(ts, start, color); // (b) sort, fill gaps with jokers, extra jokers go up (towards 13) first, then down. const sorted = [...nums].sort((a, b) => a.n - b.n); for (let i = 1; i < sorted.length; i++) if (sorted[i]!.n === sorted[i - 1]!.n) return null; const min = sorted[0]!.n; const max = sorted[sorted.length - 1]!.n; const jokers = ts.filter((t) => t.kind === 'joker'); const gaps = max - min + 1 - sorted.length; if (gaps > jokers.length) return null; const extra = jokers.length - gaps; const up = Math.min(extra, 13 - max); const down = extra - up; if (min - down < 1) return null; const lo = min - down; const hi = max + up; const out: Tile[] = []; let j = 0; let k = 0; for (let n = lo; n <= hi; n++) { if (sorted[k]?.n === n) out.push(sorted[k++]!); else out.push(jokers[j++]!); } return runFrom(out, lo, color); } /** Validates one set and decides what its jokers stand for (§5.5, §8.2). null = not a valid set. */ export function normalizeSet(d: DraftSet): NormalSet | null { const ids = d.tileIds; if (ids.length < 3 || ids.length > 13) return null; const ts: Tile[] = []; for (const id of ids) { const t = TILES.get(id); if (!t || ts.includes(t)) return null; ts.push(t); } const g = asGroup(ts); const r = asRun(ts); if (g && r) return d.as === 'group' ? g : r; return g ?? r; } /** Short Korean reason why a set is not valid (null when valid) — live red/green feedback (§9). */ export function setProblem(d: DraftSet): string | null { const ids = d.tileIds; if (ids.length === 0) return null; if (ids.length < 3) return '3장 이상이어야 해요'; if (normalizeSet(d)) return null; const ts = ids.filter(isTileId).map(tileOf); const nums = ts.filter((t): t is NumTile => t.kind === 'num'); if (nums.length === 0) return '숫자 타일이 1장 이상 있어야 해요'; const sameN = nums.every((t) => t.n === nums[0]!.n); const sameC = nums.every((t) => t.color === nums[0]!.color); if (sameN && nums.length > 1) { if (new Set(nums.map((t) => t.color)).size !== nums.length) return '같은 색이 두 개예요'; if (ids.length > 4) return '같은 숫자 묶음은 4장까지예요'; } if (sameC) { if (new Set(nums.map((t) => t.n)).size !== nums.length) return '같은 숫자가 두 번 있어요'; if (ids.length > 13) return '13장보다 길 수 없어요'; if (nums.some((t) => t.n === 13) && nums.some((t) => t.n === 1)) return '13 다음에 1은 올 수 없어요'; return '숫자가 이어지지 않아요'; } return '같은 색 연속 숫자나, 같은 숫자 다른 색이어야 해요'; } /** Score of a tile left on the rack at the end (§6). */ export const tileValue = (id: string, jokerPenalty: number): number => { const t = tileOf(id); return t.kind === 'joker' ? jokerPenalty : t.n; }; export const rackValue = (ids: readonly string[], jokerPenalty: number): number => ids.reduce((a, id) => a + tileValue(id, jokerPenalty), 0); /** Order-insensitive key of an arrangement (each set as a tile set). */ export function arrangementKey(sets: readonly { tileIds: readonly string[] }[]): string { return sets .filter((s) => s.tileIds.length > 0) .map((s) => [...s.tileIds].sort().join(',')) .sort() .join('|'); } /** Rack sort orders (§9): by colour then number, or number then colour; jokers last. */ export function sortTiles(ids: readonly string[], by: 'color' | 'number'): string[] { const key = (id: string): number => { const t = tileOf(id); if (t.kind === 'joker') return 10_000 + Number(id.slice(2)); const c = COLORS.indexOf(t.color); return by === 'color' ? c * 100 + t.n * 2 + (id.endsWith('b') ? 1 : 0) : t.n * 10 + c + (id.endsWith('b') ? 0.5 : 0); }; return [...ids].sort((a, b) => key(a) - key(b)); } /** Korean label of a tile for screen readers, e.g. "빨강 7", "조커". */ export function tileLabel(id: string, as?: JokerAs): string { const t = tileOf(id); if (t.kind === 'joker') return as ? `조커(${as.color ? `${COLOR_KO[as.color]} ` : ''}${as.n})` : '조커'; return `${COLOR_KO[t.color]} ${t.n}`; } /** * Candidate sets made only from rack tiles (not disjoint): groups of 3–4 colours, maximal runs, and a few * joker completions. No table rearrangement search (§9 hint: bounded work). */ export function findRackSets(rack: readonly string[]): NormalSet[] { const nums = rack.map(tileOf).filter((t): t is NumTile => t.kind === 'num'); const jokers = rack.filter(isJoker); const out: NormalSet[] = []; const push = (ids: string[]) => { const n = normalizeSet({ tileIds: ids }); if (n) out.push(n); }; // Groups for (let n = 1; n <= 13; n++) { const byColor = new Map(); for (const t of nums) if (t.n === n && !byColor.has(t.color)) byColor.set(t.color, t.id); const ids = [...byColor.values()]; if (ids.length >= 3) push(ids); else if (ids.length === 2 && jokers.length > 0) push([...ids, jokers[0]!]); } // Runs: from every start that has no tile right below it, extend upwards, filling holes with jokers. for (const c of COLORS) { const at = new Map(); for (const t of nums) if (t.color === c && !at.has(t.n)) at.set(t.n, t.id); for (const [start] of [...at].sort((a, b) => a[0] - b[0])) { if (at.has(start - 1)) continue; const ids: string[] = []; let jUsed = 0; let best: string[] | null = null; for (let n = start; n <= 13; n++) { const id = at.get(n); if (id) ids.push(id); else if (jUsed < jokers.length) ids.push(jokers[jUsed++]!); else break; if (ids.length >= 3 && !isJoker(ids[ids.length - 1]!)) best = [...ids]; } if (best) push(best); else if (jokers.length > 0 && at.has(start + 1) && start + 2 <= 13) push([at.get(start)!, at.get(start + 1)!, jokers[0]!]); } } return out; } /** Disjoint rack-only sets, greedy by points; stops as soon as `minPoints` is reached. */ export function pickDisjoint(rack: readonly string[], minPoints = Infinity): { sets: NormalSet[]; points: number } { const cands = findRackSets(rack).sort((a, b) => b.points - a.points); const used = new Set(); const sets: NormalSet[] = []; let points = 0; for (const c of cands) { if (c.tileIds.some((id) => used.has(id))) continue; sets.push(c); for (const id of c.tileIds) used.add(id); points += c.points; if (points >= minPoints) break; } return { sets, points }; } /** Disjoint rack-only sets reaching `minPoints` (first meld), or null. */ export function findMeld(rack: readonly string[], minPoints: number): NormalSet[] | null { const r = pickDisjoint(rack, minPoints); return r.sets.length && r.points >= minPoints ? r.sets : null; } /** Adds one tile to a valid set at the matching end (run) or as a missing colour (group); null if it does not fit. */ export function extendSet(set: Pick, id: string): NormalSet | null { const t = tileOf(id); const ids = set.tileIds; const ts = ids.map(tileOf); let cand: string[] | null = null; if (set.kind === 'group') { if (ids.length >= 4) return null; if (t.kind === 'num') { const n = ts.find((x): x is NumTile => x.kind === 'num')?.n; if (n !== t.n || ts.some((x) => x.kind === 'num' && x.color === t.color)) return null; } cand = [...ids, id]; } else { if (ids.length >= 13) return null; const val = (x: Tile) => (x.kind === 'num' ? x.n : (set.jokerAs[x.id]?.n ?? 0)); const lo = val(ts[0]!); const hi = val(ts[ts.length - 1]!); if (t.kind === 'joker') cand = hi < 13 ? [...ids, id] : lo > 1 ? [id, ...ids] : null; else if (!ts.some((x) => x.kind === 'num' && x.color === t.color)) return null; else if (t.n === hi + 1) cand = [...ids, id]; else if (t.n === lo - 1) cand = [id, ...ids]; } return cand ? normalizeSet({ tileIds: cand, as: set.kind }) : null; }