Files
joke-app/packages/games/src/rummikub/rules.ts
EJClaw 96e406b2c9 M4: 숫자 타일 규칙·화면·테스트
- 규칙 모듈(packages/games/src/rummikub): 106타일, 선 정하기·분배, 그룹/런 판정과 조커 해석,
  서버 초안(setDraft/undoDraft/resetDraft) + 보존 검사(C1~C3), 확정 검증(받침대 1장 이상,
  첫 등록 점수, 재배치), 시간 초과(그대로면 1장, 올바르면 확정/되돌림, 틀리면 벌칙 장수),
  풀 소진 교착 종료, 점수 계산, 여러 판 매치, 나가기·자리 비움, 숨김 정보 view, secretsOf
- 테스트: 문서 10절 시나리오 전부 + 엔진 randomPlayout 25회 + 시드 1,000개 무작위 대국
  (타일 106장 보존, 매 단계 정보 유출·onTimeout 합법성 검사, 결정성)
- 화면(apps/web/src/games/rummikub): 탭으로 고르고 놓을 곳 누르기(◀ 앞 / ▶ 뒤 / 새 묶음),
  받침대 색깔순·숫자순 정렬, 되돌리기·처음으로, 실시간 묶음 검사(초록/빨강 + 이유),
  첫 등록 진행 막대, 도와줘 힌트, 한 장 가져오기·확정, 색+모양 표시 타일(조커 자체 디자인)
- 등록: GAMES, CATALOG, 화면 레지스트리, 아이콘(곧 나올 게임 목록에서 제거)
- E2E: PC 방장 + 휴대폰 친구가 화면으로 10차례(힌트로 내기, 되돌리기, 가져오기) 진행
- @bg/games 패키지에 './rummikub/rules' 내보내기 추가(화면 청크에 zod가 안 들어가게)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 04:34:59 +09:00

280 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 숫자 타일 (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<Tile, { kind: 'num' }>;
export const COLORS: TileColor[] = ['K', 'R', 'B', 'O'];
export const COLOR_KO: Record<TileColor, string> = { 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<string, Tile>(
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<string, JokerAs>;
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<string, JokerAs> = {};
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<string, JokerAs> = {};
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<TileColor, string>();
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<number, string>();
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<string>();
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<NormalSet, 'tileIds' | 'kind' | 'jokerAs'>, 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;
}