- 규칙 모듈(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>
280 lines
11 KiB
TypeScript
280 lines
11 KiB
TypeScript
/**
|
||
* 숫자 타일 (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;
|
||
}
|