Merge branch 'm4/rummikub' into codex/owner/grp_discord_joke-app

# Conflicts:
#	apps/web/src/games/registry.ts
#	apps/web/src/lib/catalog-ui.ts
#	e2e/games.e2e.ts
#	packages/games/src/index.ts
#	packages/shared/src/catalog.ts
This commit is contained in:
EJClaw
2026-10-05 04:40:49 +09:00
10 changed files with 2776 additions and 0 deletions

View File

@@ -5,6 +5,7 @@
"type": "module",
"exports": {
".": "./src/index.ts",
"./rummikub/rules": "./src/rummikub/rules.ts",
"./*": "./src/*/index.ts"
},
"dependencies": {

View File

@@ -9,6 +9,7 @@ import { poker } from './poker';
import { yacht } from './yacht';
import { yut } from './yut';
import { oneCard } from './one-card';
import { rummikub } from './rummikub';
export const GAMES: Record<string, AnyGameDefinition> = {
[omok.id]: omok,
@@ -21,4 +22,5 @@ export const GAMES: Record<string, AnyGameDefinition> = {
[yacht.id]: yacht,
[yut.id]: yut,
[oneCard.id]: oneCard,
[rummikub.id]: rummikub,
};

View File

@@ -0,0 +1,832 @@
/** 숫자 타일 (루미큐브 방식, docs/games/rummikub.md). Scores are room-only result points (§6, §12). */
import { z } from 'zod';
import { fail, ok, type GameDefinition, type GameEvent, type GameResult, type PlayerId, type Rng, type Validation } from '@bg/engine';
import {
ALL_TILE_IDS,
arrangementKey,
extendSet,
findMeld,
isJoker,
pickDisjoint,
normalizeSet,
rackValue,
setProblem,
tileOf,
type DraftSet,
type JokerAs,
type NormalSet,
type TileColor,
} from './rules';
export * from './rules';
const lit = <T extends number>(...vals: T[]) =>
z.union(vals.map((v) => z.literal(v)) as unknown as [z.ZodLiteral<T>, z.ZodLiteral<T>, ...z.ZodLiteral<T>[]]);
export const rummikubOptionsSchema = z.object({
initialMeldPoints: lit(0, 20, 30, 50)
.default(30)
.meta({ title: '첫 등록 최소 점수', labels: { 0: '없음', 20: '20점', 30: '30점(공식)', 50: '50점' } }),
turnSeconds: lit(60, 90, 120, 180)
.default(60)
.meta({ title: '한 차례 제한 시간', labels: { 60: '1분(공식)', 90: '1분 30초', 120: '2분', 180: '3분' } }),
timeoutValidDraft: z
.enum(['commit', 'revert'])
.default('commit')
.meta({ title: '시간이 다 됐을 때 올바르게 놓아 둔 타일', labels: { commit: '자동으로 확정', revert: '되돌리고 한 장 가져오기(공식)' } }),
invalidTimeoutPenalty: lit(1, 3)
.default(3)
.meta({ title: '시간이 다 됐는데 덜 맞췄을 때 가져올 장수', labels: { 1: '1장', 3: '3장(공식)' } }),
invalidCommitPenalty: z
.enum(['reject', 'penalty3'])
.default('reject')
.meta({ title: '틀린 채로 확정을 누르면', labels: { reject: '알려 주고 계속 고치기', penalty3: '되돌리고 3장 가져오기' } }),
jokerPenalty: lit(30, 50)
.default(30)
.meta({ title: '받침대에 남은 조커 감점', labels: { 30: '30점(공식)', 50: '50점(대회)' } }),
initialMeldAllowExtend: z.boolean().default(false).meta({ title: '첫 등록하는 차례에 바닥 묶음에도 붙이기 허용' }),
showDraftToOthers: z.boolean().default(false).meta({ title: '고민 중인 배치를 다른 사람에게 보이기(되돌려도 이미 본 타일은 남아요)' }),
gamesPerMatch: z
.enum(['1', 'players', '2', '3', '4'])
.default('1')
.meta({ title: '판 수', labels: { 1: '1판', players: '인원수만큼(공식)', 2: '2판', 3: '3판', 4: '4판' } }),
hints: z.boolean().default(true).meta({ title: '"도와줘" 힌트 버튼' }),
});
export type RummikubOptions = z.infer<typeof rummikubOptionsSchema>;
const draftSetSchema = z.object({
tileIds: z.array(z.string().max(4)).min(1).max(106),
as: z.enum(['run', 'group']).optional(),
});
export const rummikubActionSchema = z.discriminatedUnion('type', [
z.object({ type: z.literal('setDraft'), sets: z.array(draftSetSchema).max(106), turn: z.number().int().optional() }),
z.object({ type: z.literal('undoDraft') }),
z.object({ type: z.literal('resetDraft') }),
z.object({ type: z.literal('commit'), turn: z.number().int().optional() }),
z.object({ type: z.literal('draw') }),
z.object({ type: z.literal('pass') }),
z.object({ type: z.literal('ready') }),
]);
/** Client actions plus the system-only ones (timer, leaving) that are not in the schema. */
export type RummikubAction = z.infer<typeof rummikubActionSchema> | { type: 'timeout' } | { type: 'leave' };
// ---------------------------------------------------------------- state
export interface TableSet {
/** Kept across commits for the set with the biggest overlap (animation continuity). */
id: string;
tileIds: string[];
kind: 'run' | 'group';
jokerAs: Record<string, JokerAs>;
}
export type MoveKind = 'commit' | 'draw' | 'pass' | 'timeoutCommit' | 'timeoutDraw' | 'penalty' | 'left';
export interface LogEntry {
turn: number;
by: PlayerId;
kind: MoveKind;
/** Tiles placed (commit) or drawn. */
count: number;
}
export interface RoundResult {
gameNo: number;
endReason: 'rackEmpty' | 'stalemate' | 'abandoned';
winners: PlayerId[];
scores: Record<PlayerId, number>;
rackValues: Record<PlayerId, number>;
/** Racks revealed for counting at the end of the round (public from then on). */
revealed: Record<PlayerId, string[]>;
/** Rank groups for this round, best first. */
ranking: PlayerId[][];
}
export interface OpeningDraw {
round: number;
player: PlayerId;
/** No tile ids here: the tiles go back into the bag and are shuffled again. */
color: TileColor | null;
n: number | null;
}
export interface RummikubState {
options: RummikubOptions;
/** Seat order = turn order (clockwise). */
seats: PlayerId[];
left: PlayerId[];
/** Players dealt into the current round. */
roundPlayers: PlayerId[];
pool: string[];
racks: Record<PlayerId, string[]>;
table: TableSet[];
nextSetId: number;
hasMelded: Record<PlayerId, boolean>;
current: PlayerId;
turnNo: number;
turnStartedAt: number;
draft: { sets: DraftSet[]; history: DraftSet[][]; edits: number };
timeoutsInRow: Record<PlayerId, number>;
consecutivePasses: number;
/** Tiles each player drew at the end of their last turn (shown only to them). */
lastDrawn: Record<PlayerId, string[]>;
/** Set ids that changed in the last commit (1 s highlight on screen). */
lastChanged: string[];
log: LogEntry[];
phase: 'playing' | 'roundEnd' | 'finished';
roundEndAt: number | null;
ready: PlayerId[];
opening: OpeningDraw[];
round: RoundResult | null;
match: { gameNo: number; total: number; wins: Record<PlayerId, number>; totals: Record<PlayerId, number> };
final: { ranking: PlayerId[][]; scores: Record<PlayerId, number>; summary: string; reason: 'normal' | 'abandoned' } | null;
}
// ---------------------------------------------------------------- view
export interface DraftSetView {
tileIds: string[];
as?: 'run' | 'group';
ok: boolean;
/** Short reason when not valid. */
problem: string | null;
kind: 'run' | 'group' | null;
jokerAs: Record<string, JokerAs>;
points: number;
/** Both readings possible ([빨강5, 조커, 조커]) — the screen offers a toggle. */
ambiguous: boolean;
}
export interface SeatView {
id: PlayerId;
tiles: number;
melded: boolean;
left: boolean;
away: boolean;
wins: number;
total: number;
inRound: boolean;
}
export interface TurnCheck {
ok: boolean;
reason: string | null;
invalidIdx: number[];
/** Points of new rack-only sets (first meld progress). */
meldPoints: number;
fromRack: number;
}
export interface RummikubView {
phase: RummikubState['phase'];
options: RummikubOptions;
seats: SeatView[];
current: PlayerId | null;
turnNo: number;
turnStartedAt: number;
turnMs: number;
poolCount: number;
table: TableSet[];
lastChanged: string[];
log: LogEntry[];
/** Number of edits the current player made this turn (shown as "생각 중"). */
draftEdits: number;
/** Full draft: the current player always, others only with showDraftToOthers. */
draft: DraftSetView[] | null;
me: PlayerId | null;
my: {
rack: string[];
lastDrawn: string[];
melded: boolean;
canUndo: boolean;
changed: boolean;
check: TurnCheck | null;
needReady: boolean;
} | null;
opening: OpeningDraw[];
round: RoundResult | null;
match: RummikubState['match'];
roundEndAt: number | null;
ready: PlayerId[];
result: RummikubState['final'];
}
// ---------------------------------------------------------------- helpers
const AWAY_MS = 3000;
const ROUND_END_MS = 30_000;
const HISTORY_MAX = 100;
const activeSeats = (s: RummikubState): PlayerId[] => s.roundPlayers.filter((p) => !s.left.includes(p));
const startSets = (s: RummikubState): DraftSet[] => s.table.map((t) => ({ tileIds: [...t.tileIds], as: t.kind }));
const tableTiles = (s: RummikubState): Set<string> => new Set(s.table.flatMap((t) => t.tileIds));
const draftChanged = (s: RummikubState): boolean => arrangementKey(s.draft.sets) !== arrangementKey(s.table);
const cleanSet = (d: DraftSet): DraftSet => (d.as ? { tileIds: [...d.tileIds], as: d.as } : { tileIds: [...d.tileIds] });
function totalGames(o: RummikubOptions, players: number): number {
return o.gamesPerMatch === 'players' ? players : Number(o.gamesPerMatch);
}
/** C1–C3 (§8.2): tiles come from the start table or my rack, no duplicates, table tiles stay, first-meld rule. */
function conserve(s: RummikubState, p: PlayerId, sets: readonly DraftSet[]): string | null {
const start = tableTiles(s);
const rack = new Set(s.racks[p] ?? []);
const seen = new Set<string>();
for (const d of sets) {
if (d.tileIds.length === 0) return '빈 묶음이 있어요.';
for (const id of d.tileIds) {
if (seen.has(id)) return '같은 타일이 두 번 들어 있어요.';
seen.add(id);
if (!start.has(id) && !rack.has(id)) return '내 받침대나 바닥에 없는 타일이에요.';
}
}
for (const id of start) if (!seen.has(id)) return '바닥의 타일은 내 받침대로 가져올 수 없어요.';
if (!s.hasMelded[p] && !s.options.initialMeldAllowExtend) {
const keys = new Set(sets.map((d) => [...d.tileIds].sort().join(',')));
for (const t of s.table) if (!keys.has([...t.tileIds].sort().join(','))) return '첫 등록 전에는 바닥의 타일을 옮길 수 없어요.';
}
return null;
}
type TurnOutcome = { ok: true; normalized: NormalSet[]; fromRack: string[]; meldPoints: number } | { ok: false; reason: string; invalidIdx: number[]; meldPoints: number; fromRack: string[] };
/** Turn commit validation (§8.2 validateTurn), shared by commit, timeout and the live check in the view. */
function validateTurn(s: RummikubState, p: PlayerId, sets: readonly DraftSet[], pre?: (NormalSet | null)[]): TurnOutcome {
const start = tableTiles(s);
const fromRack = sets.flatMap((d) => d.tileIds).filter((id) => !start.has(id));
const normalized = pre ?? sets.map(normalizeSet);
const newOnly = normalized.filter((n, i) => n && sets[i]!.tileIds.every((id) => !start.has(id))) as NormalSet[];
const meldPoints = newOnly.reduce((a, n) => a + n.points, 0);
const bad = (reason: string, invalidIdx: number[] = []): TurnOutcome => ({ ok: false, reason, invalidIdx, meldPoints, fromRack });
const c = conserve(s, p, sets);
if (c) return bad(c);
if (fromRack.length === 0) return bad('받침대에서 타일을 1개 이상 내야 해요.');
const invalidIdx = normalized.flatMap((n, i) => (n ? [] : [i]));
if (invalidIdx.length) return bad('빨간 표시된 묶음을 고쳐 주세요.', invalidIdx);
if (!s.hasMelded[p]) {
const min = s.options.initialMeldPoints;
if (meldPoints < min) return bad(`첫 등록은 ${min}점 이상이어야 해요 (지금 ${meldPoints}점).`);
}
return { ok: true, normalized: normalized as NormalSet[], fromRack, meldPoints };
}
/** New table from normalized sets; ids are inherited from the old set with the biggest overlap. */
function buildTable(s: RummikubState, normalized: NormalSet[]): { table: TableSet[]; changed: string[] } {
const taken = new Set<string>();
const changed: string[] = [];
const table = normalized.map((n) => {
let best: TableSet | null = null;
let bestOverlap = 0;
for (const t of s.table) {
if (taken.has(t.id)) continue;
const ov = t.tileIds.filter((id) => n.tileIds.includes(id)).length;
if (ov > bestOverlap) {
best = t;
bestOverlap = ov;
}
}
let id: string;
if (best) {
taken.add(best.id);
id = best.id;
if (best.tileIds.join(',') !== n.tileIds.join(',')) changed.push(id);
} else {
id = `s${s.nextSetId++}`;
changed.push(id);
}
return { id, tileIds: n.tileIds, kind: n.kind, jokerAs: n.jokerAs };
});
return { table, changed };
}
function drawTiles(s: RummikubState, p: PlayerId, count: number): string[] {
const got = s.pool.splice(0, Math.min(count, s.pool.length));
s.racks[p]!.push(...got);
return got;
}
function pushLog(s: RummikubState, by: PlayerId, kind: MoveKind, count: number): void {
s.log.push({ turn: s.turnNo, by, kind, count });
if (s.log.length > 12) s.log.splice(0, s.log.length - 12);
}
function startTurn(s: RummikubState, p: PlayerId, now: number): void {
s.current = p;
s.turnNo += 1;
s.turnStartedAt = now;
s.draft = { sets: startSets(s), history: [], edits: 0 };
}
function nextPlayer(s: RummikubState, from: PlayerId): PlayerId {
const order = s.roundPlayers;
const i = order.indexOf(from);
for (let k = 1; k <= order.length; k++) {
const p = order[(i + k) % order.length]!;
if (!s.left.includes(p)) return p;
}
return from;
}
/** Turn ended without placing tiles: draw `count` (if the pool has any) or count it as a pass (§5.8). */
function endWithoutPlay(s: RummikubState, p: PlayerId, count: number, kind: MoveKind, now: number, events: GameEvent[]): void {
s.draft = { sets: startSets(s), history: [], edits: 0 };
const got = drawTiles(s, p, count);
s.lastDrawn[p] = got;
s.lastChanged = [];
if (got.length > 0) {
pushLog(s, p, kind, got.length);
events.push({ type: 'drew', player: p, count: got.length, penalty: kind === 'penalty' });
} else {
pushLog(s, p, 'pass', 0);
events.push({ type: 'passed', player: p });
s.consecutivePasses += 1;
if (s.consecutivePasses >= activeSeats(s).length) {
endRound(s, 'stalemate', null, now, events);
return;
}
}
startTurn(s, nextPlayer(s, p), now);
}
function commitTurn(s: RummikubState, p: PlayerId, out: Extract<TurnOutcome, { ok: true }>, kind: MoveKind, now: number, events: GameEvent[]): void {
const { table, changed } = buildTable(s, out.normalized);
s.table = table;
const placed = new Set(out.fromRack);
s.racks[p] = s.racks[p]!.filter((id) => !placed.has(id));
s.hasMelded[p] = true;
s.consecutivePasses = 0;
s.lastDrawn[p] = [];
s.lastChanged = changed;
pushLog(s, p, kind, out.fromRack.length);
events.push({ type: 'committed', player: p, count: out.fromRack.length, changed });
if (s.racks[p]!.length === 0) endRound(s, 'rackEmpty', p, now, events);
else startTurn(s, nextPlayer(s, p), now);
}
function rankByScore(players: PlayerId[], winners: PlayerId[], scores: Record<PlayerId, number>): PlayerId[][] {
const rest = players.filter((p) => !winners.includes(p)).sort((a, b) => scores[b]! - scores[a]!);
const groups: PlayerId[][] = winners.length ? [[...winners]] : [];
for (const p of rest) {
const last = groups[groups.length - 1];
if (last && !winners.includes(last[0]!) && scores[last[0]!] === scores[p]) last.push(p);
else groups.push([p]);
}
return groups;
}
/** §6 scoring. */
export function scoreRound(
racks: Record<PlayerId, string[]>,
players: PlayerId[],
reason: 'rackEmpty' | 'stalemate',
jokerPenalty: number,
eligible: PlayerId[] = players,
): { winners: PlayerId[]; scores: Record<PlayerId, number>; rackValues: Record<PlayerId, number> } {
const rackValues: Record<PlayerId, number> = {};
for (const p of players) rackValues[p] = rackValue(racks[p] ?? [], jokerPenalty);
const scores: Record<PlayerId, number> = {};
let winners: PlayerId[];
if (reason === 'rackEmpty') {
winners = players.filter((p) => (racks[p] ?? []).length === 0).slice(0, 1);
} else {
const pool = eligible.length ? eligible : players;
const minV = Math.min(...pool.map((p) => rackValues[p]!));
let low = pool.filter((p) => rackValues[p] === minV);
const minN = Math.min(...low.map((p) => (racks[p] ?? []).length));
low = low.filter((p) => (racks[p] ?? []).length === minN);
winners = low;
}
const base = reason === 'rackEmpty' ? 0 : rackValues[winners[0]!]!;
let sum = 0;
for (const p of players) {
if (winners.includes(p)) continue;
scores[p] = -(rackValues[p]! - base);
sum += rackValues[p]! - base;
}
for (const w of winners) scores[w] = Math.floor(sum / winners.length);
return { winners, scores, rackValues };
}
function endRound(s: RummikubState, reason: 'rackEmpty' | 'stalemate', by: PlayerId | null, now: number, events: GameEvent[]): void {
const players = s.roundPlayers;
const eligible = activeSeats(s);
const { winners, scores, rackValues } = scoreRound(s.racks, players, reason, s.options.jokerPenalty, eligible);
if (by && !winners.includes(by)) throw new Error('rack-empty winner mismatch');
for (const p of players) s.match.totals[p] = (s.match.totals[p] ?? 0) + scores[p]!;
for (const w of winners) s.match.wins[w] = (s.match.wins[w] ?? 0) + 1;
const revealed: Record<PlayerId, string[]> = {};
for (const p of players) revealed[p] = [...s.racks[p]!];
s.round = { gameNo: s.match.gameNo, endReason: reason, winners, scores, rackValues, revealed, ranking: rankByScore(players, winners, scores) };
s.draft = { sets: startSets(s), history: [], edits: 0 };
events.push({ type: 'roundEnded', reason, winners, scores });
if (s.match.gameNo >= s.match.total || eligible.length < 2) finishMatch(s, eligible.length < 2 ? 'abandoned' : 'normal');
else {
s.phase = 'roundEnd';
s.roundEndAt = now;
s.ready = [...s.left];
}
}
function finishMatch(s: RummikubState, reason: 'normal' | 'abandoned'): void {
s.phase = 'finished';
s.roundEndAt = null;
const present = s.seats.filter((p) => !s.left.includes(p));
const { wins, totals } = s.match;
const sorted = [...present].sort((a, b) => (wins[b] ?? 0) - (wins[a] ?? 0) || (totals[b] ?? 0) - (totals[a] ?? 0));
const ranking: PlayerId[][] = [];
for (const p of sorted) {
const last = ranking[ranking.length - 1];
if (last && (wins[last[0]!] ?? 0) === (wins[p] ?? 0) && (totals[last[0]!] ?? 0) === (totals[p] ?? 0)) last.push(p);
else ranking.push([p]);
}
const gone = s.seats.filter((p) => s.left.includes(p));
if (gone.length) ranking.push(gone);
const scores: Record<PlayerId, number> = {};
for (const p of s.seats) scores[p] = totals[p] ?? 0;
const top = ranking[0] ?? [];
const topScore = scores[top[0]!] ?? 0;
const pts = `${topScore >= 0 ? '+' : ''}${topScore}점`;
const who = top.length > 1 ? '공동 1위' : '1위';
let summary: string;
if (reason === 'abandoned') summary = '다른 참가자가 모두 나가서 끝났어요';
else if (s.match.total > 1) summary = `${s.match.gameNo}판 끝 · ${who} ${wins[top[0]!] ?? 0}승, 합계 ${pts}`;
else if (s.round?.endReason === 'rackEmpty') summary = `타일을 모두 내려놓았어요! 다 냈다! (${who} ${pts})`;
else summary = `더 낼 수 있는 사람이 없어 끝났어요. 남은 타일 점수가 가장 적은 ${who} (${pts})`;
s.final = { ranking, scores, summary, reason };
}
/** §4: highest single draw starts (jokers and ties draw again), then everything is reshuffled and dealt. */
function setupRound(s: RummikubState, rng: Rng, now: number): void {
const players = s.seats.filter((p) => !s.left.includes(p));
s.roundPlayers = players;
const opening: OpeningDraw[] = [];
let cands = [...players];
let first = cands[0]!;
for (let round = 1; round <= 50; round++) {
const bag = rng.shuffle(ALL_TILE_IDS);
let k = 0;
const val = new Map<PlayerId, number>();
for (const p of cands) {
// A joker has no number: that player draws again (§4).
for (;;) {
const t = tileOf(bag[k++]!);
opening.push({ round, player: p, color: t.kind === 'num' ? t.color : null, n: t.kind === 'num' ? t.n : null });
if (t.kind === 'num') {
val.set(p, t.n);
break;
}
}
}
const max = Math.max(...val.values());
const top = cands.filter((p) => val.get(p) === max);
first = top[0]!;
if (top.length === 1) break;
cands = top;
}
const bag = rng.shuffle(ALL_TILE_IDS);
s.racks = {};
players.forEach((p, i) => {
s.racks[p] = bag.slice(i * 14, i * 14 + 14);
});
s.pool = bag.slice(players.length * 14);
s.table = [];
s.nextSetId = 1;
s.hasMelded = Object.fromEntries(players.map((p) => [p, s.options.initialMeldPoints === 0]));
s.consecutivePasses = 0;
s.lastDrawn = Object.fromEntries(players.map((p) => [p, []]));
s.lastChanged = [];
s.log = [];
s.opening = opening;
s.phase = 'playing';
s.roundEndAt = null;
s.ready = [];
s.match.gameNo += 1;
s.turnNo = 0;
startTurn(s, first, now);
}
function describeSet(d: DraftSet, n: NormalSet | null): DraftSetView {
// Only one number tile + jokers can be read both as a run and as a group.
const ambiguous = !!n && d.tileIds.length <= 4 && d.tileIds.filter((id) => !isJoker(id)).length === 1;
return {
tileIds: d.tileIds,
...(d.as ? { as: d.as } : {}),
ok: !!n,
problem: n ? null : setProblem(d),
kind: n?.kind ?? null,
jokerAs: n?.jokerAs ?? {},
points: n?.points ?? 0,
ambiguous,
};
}
// ---------------------------------------------------------------- definition
export const rummikub: GameDefinition<RummikubState, RummikubAction, RummikubView, RummikubOptions> = {
id: 'rummikub',
nameKo: '숫자 타일',
minPlayers: 2,
maxPlayers: 4,
stateVersion: 1,
defaultOptions: rummikubOptionsSchema.parse({}),
optionsSchema: rummikubOptionsSchema,
actionSchema: rummikubActionSchema as z.ZodType<RummikubAction>,
setup({ players, options, rng, now }) {
const s: RummikubState = {
options,
seats: [...players],
left: [],
roundPlayers: [],
pool: [],
racks: {},
table: [],
nextSetId: 1,
hasMelded: {},
current: players[0]!,
turnNo: 0,
turnStartedAt: now,
draft: { sets: [], history: [], edits: 0 },
timeoutsInRow: Object.fromEntries(players.map((p) => [p, 0])),
consecutivePasses: 0,
lastDrawn: {},
lastChanged: [],
log: [],
phase: 'playing',
roundEndAt: null,
ready: [],
opening: [],
round: null,
match: {
gameNo: 0,
total: totalGames(options, players.length),
wins: Object.fromEntries(players.map((p) => [p, 0])),
totals: Object.fromEntries(players.map((p) => [p, 0])),
},
final: null,
};
setupRound(s, rng, now);
return s;
},
validate(s, actor, a): Validation {
if (!s.seats.includes(actor)) return fail('이 게임의 플레이어가 아니에요.');
if (s.phase === 'finished') return fail('게임이 이미 끝났어요.');
if (a.type === 'leave') return s.left.includes(actor) ? fail('이미 나간 플레이어예요.') : ok;
if (s.left.includes(actor)) return fail('방을 나간 플레이어예요.');
if (s.phase === 'roundEnd') {
if (a.type !== 'ready' && a.type !== 'timeout') return fail('다음 판을 기다리는 중이에요.');
return s.ready.includes(actor) ? fail('이미 준비했어요.') : ok;
}
if (a.type === 'ready') return fail('지금은 할 수 없어요.');
if (actor !== s.current) return fail('지금은 내 차례가 아니에요.');
if ((a.type === 'setDraft' || a.type === 'commit') && a.turn !== undefined && a.turn !== s.turnNo) return fail('지난 차례의 요청이라 무시했어요.');
switch (a.type) {
case 'setDraft': {
const err = conserve(s, actor, a.sets);
return err ? fail(err) : ok;
}
case 'undoDraft':
return s.draft.history.length ? ok : fail('되돌릴 것이 없어요.');
case 'resetDraft':
return ok;
case 'commit': {
const r = validateTurn(s, actor, s.draft.sets);
if (r.ok) return ok;
if (s.options.invalidCommitPenalty === 'reject' || !draftChanged(s) || r.fromRack.length === 0) return fail(r.reason);
return ok;
}
case 'draw':
return ok;
case 'pass':
return s.pool.length ? fail('가져올 타일이 남아 있으면 넘길 수 없어요. 한 장 가져와 주세요.') : ok;
case 'timeout':
return ok;
}
},
apply(prev, actor, a, { rng, now }) {
// The state is plain JSON by contract; this is much faster than structuredClone.
const s = JSON.parse(JSON.stringify(prev)) as RummikubState;
const events: GameEvent[] = [];
if (a.type !== 'timeout' && a.type !== 'leave') s.timeoutsInRow[actor] = 0;
if (a.type === 'leave') {
s.left.push(actor);
events.push({ type: 'left', player: actor });
if (s.phase === 'roundEnd') {
if (!s.ready.includes(actor)) s.ready.push(actor);
if (s.seats.filter((p) => !s.left.includes(p)).length < 2) finishMatch(s, 'abandoned');
else if (s.seats.every((p) => s.ready.includes(p))) setupRound(s, rng, now);
} else if (activeSeats(s).length < 2) {
// Nobody left to play against: the match ends without scoring this round.
s.draft = { sets: startSets(s), history: [], edits: 0 };
finishMatch(s, 'abandoned');
} else if (s.current === actor) {
pushLog(s, actor, 'left', 0);
startTurn(s, nextPlayer(s, actor), now);
}
return { state: s, events };
}
if (s.phase === 'roundEnd') {
s.ready.push(actor);
if (s.seats.every((p) => s.ready.includes(p))) setupRound(s, rng, now);
return { state: s, events };
}
switch (a.type) {
case 'setDraft':
s.draft.history.push(s.draft.sets);
if (s.draft.history.length > HISTORY_MAX) s.draft.history.shift();
s.draft.sets = a.sets.map(cleanSet);
s.draft.edits += 1;
break;
case 'undoDraft':
s.draft.sets = s.draft.history.pop()!;
s.draft.edits += 1;
break;
case 'resetDraft':
if (draftChanged(s)) {
s.draft.history.push(s.draft.sets);
if (s.draft.history.length > HISTORY_MAX) s.draft.history.shift();
}
s.draft.sets = startSets(s);
s.draft.edits += 1;
break;
case 'commit': {
const r = validateTurn(s, actor, s.draft.sets);
if (r.ok) commitTurn(s, actor, r, 'commit', now, events);
else endWithoutPlay(s, actor, 3, 'penalty', now, events);
break;
}
case 'draw':
case 'pass':
endWithoutPlay(s, actor, 1, 'draw', now, events);
break;
case 'timeout': {
s.timeoutsInRow[actor] = (s.timeoutsInRow[actor] ?? 0) + 1;
events.push({ type: 'timeout', player: actor });
if (!draftChanged(s)) {
endWithoutPlay(s, actor, 1, 'timeoutDraw', now, events);
break;
}
const r = validateTurn(s, actor, s.draft.sets);
if (r.ok) {
if (s.options.timeoutValidDraft === 'commit') commitTurn(s, actor, r, 'timeoutCommit', now, events);
else endWithoutPlay(s, actor, 1, 'timeoutDraw', now, events);
} else if (r.fromRack.length === 0 && r.invalidIdx.length === 0) {
// Only rearranged (or nothing from the rack): put it back, no penalty.
endWithoutPlay(s, actor, 1, 'timeoutDraw', now, events);
} else endWithoutPlay(s, actor, s.options.invalidTimeoutPenalty, 'penalty', now, events);
break;
}
}
return { state: s, events };
},
view(s, viewer) {
const me = viewer && s.seats.includes(viewer) ? viewer : null;
const playing = s.phase === 'playing';
const isCur = playing && me === s.current;
const draftVisible = playing && (isCur || s.options.showDraftToOthers);
const norms = draftVisible ? s.draft.sets.map(normalizeSet) : [];
let check: TurnCheck | null = null;
if (isCur) {
const r = validateTurn(s, me, s.draft.sets, norms);
check = r.ok
? { ok: true, reason: null, invalidIdx: [], meldPoints: r.meldPoints, fromRack: r.fromRack.length }
: { ok: false, reason: r.reason, invalidIdx: r.invalidIdx, meldPoints: r.meldPoints, fromRack: r.fromRack.length };
}
return {
phase: s.phase,
options: s.options,
seats: s.seats.map((id) => ({
id,
tiles: (s.racks[id] ?? []).length,
melded: !!s.hasMelded[id],
left: s.left.includes(id),
away: (s.timeoutsInRow[id] ?? 0) >= 3,
wins: s.match.wins[id] ?? 0,
total: s.match.totals[id] ?? 0,
inRound: s.roundPlayers.includes(id),
})),
current: playing ? s.current : null,
turnNo: s.turnNo,
turnStartedAt: s.turnStartedAt,
turnMs: (s.timeoutsInRow[s.current] ?? 0) >= 3 ? AWAY_MS : s.options.turnSeconds * 1000,
poolCount: s.pool.length,
table: s.table,
lastChanged: s.lastChanged,
log: s.log,
draftEdits: playing ? s.draft.edits : 0,
draft: draftVisible ? s.draft.sets.map((d, i) => describeSet(d, norms[i] ?? null)) : null,
me,
my: me
? {
rack: s.racks[me] ?? [],
lastDrawn: s.lastDrawn[me] ?? [],
melded: !!s.hasMelded[me],
canUndo: isCur && s.draft.history.length > 0,
changed: isCur && draftChanged(s),
check,
needReady: s.phase === 'roundEnd' && !s.ready.includes(me) && !s.left.includes(me),
}
: null,
opening: s.opening,
// Revealed racks of the previous round are dropped once new tiles are dealt (same ids are back in play).
round: s.round && playing ? { ...s.round, revealed: {} } : s.round,
match: s.match,
roundEndAt: s.roundEndAt,
ready: s.ready,
result: s.final,
};
},
activePlayers(s) {
if (s.phase === 'playing') return [s.current];
if (s.phase === 'roundEnd') return s.seats.filter((p) => !s.ready.includes(p) && !s.left.includes(p));
return [];
},
deadline(s) {
if (s.phase === 'playing') return s.turnStartedAt + ((s.timeoutsInRow[s.current] ?? 0) >= 3 ? AWAY_MS : s.options.turnSeconds * 1000);
if (s.phase === 'roundEnd') return (s.roundEndAt ?? 0) + ROUND_END_MS;
return null;
},
onTimeout() {
return { type: 'timeout' };
},
onLeave(s, player) {
if (!s.seats.includes(player) || s.left.includes(player) || s.phase === 'finished') return null;
return { type: 'leave' };
},
result(s): GameResult | null {
if (!s.final) return null;
return { ranking: s.final.ranking, scores: s.final.scores, summary: s.final.summary, reason: s.final.reason };
},
/** Test helper: draw / play rack-only sets / extend a table set / an invalid try, then commit or undo. */
legalActions(s, p) {
if (!s.seats.includes(p) || s.left.includes(p) || s.phase === 'finished') return [];
if (s.phase === 'roundEnd') return s.ready.includes(p) ? [] : [{ type: 'ready' }];
if (p !== s.current) return [];
const acts: RummikubAction[] = [];
if (draftChanged(s)) {
const r = validateTurn(s, p, s.draft.sets);
if (r.ok) acts.push(s.draft.edits % 4 === 3 ? { type: 'undoDraft' } : { type: 'commit' });
else {
acts.push({ type: 'resetDraft' }, { type: 'draw' });
if (s.options.invalidCommitPenalty === 'penalty3' && r.fromRack.length > 0) acts.push({ type: 'commit' });
}
return acts;
}
const drawAct: RummikubAction = s.pool.length ? { type: 'draw' } : { type: 'pass' };
const rack = s.racks[p]!;
const base = startSets(s);
if (!s.hasMelded[p]) {
const meld = findMeld(rack, s.options.initialMeldPoints);
if (meld) acts.push({ type: 'setDraft', sets: [...base, ...meld.map((m) => ({ tileIds: m.tileIds }))] });
} else {
// Everything playable at once: disjoint rack sets, then single tiles added to any set that accepts them.
const all: { tileIds: string[]; kind: 'run' | 'group'; jokerAs: Record<string, JokerAs> }[] = [...s.table, ...pickDisjoint(rack).sets];
const used = new Set(all.flatMap((d) => d.tileIds));
let firstExt: DraftSet[] | null = null;
for (const id of rack) {
if (used.has(id)) continue;
for (let i = 0; i < all.length; i++) {
const ext = extendSet(all[i]!, id);
if (!ext) continue;
if (!firstExt && i < base.length) firstExt = base.map((d, k) => (k === i ? { tileIds: ext.tileIds } : d));
all[i] = ext;
used.add(id);
break;
}
}
if (all.length !== base.length || firstExt) acts.push({ type: 'setDraft', sets: all.map((d) => ({ tileIds: d.tileIds })) });
// Rearrangement only: one rack tile added to an existing table set.
if (firstExt) acts.push({ type: 'setDraft', sets: firstExt });
}
if (!acts.length || s.turnNo % 3 === 0) acts.push(drawAct);
// An unfinished try (single tile) now and then, to exercise invalid commit / timeout paths.
if (rack.length && s.turnNo % 7 === 0) acts.push({ type: 'setDraft', sets: [...base, { tileIds: [rack[0]!] }] });
return acts;
},
/** Hidden tiles of `player` (rack minus tiles already shown in a visible draft) plus the pool. */
secretsOf(s, player) {
if (s.phase !== 'playing') return [...s.pool];
let rack = s.racks[player] ?? [];
if (s.options.showDraftToOthers && player === s.current) {
const shown = new Set(s.draft.sets.flatMap((d) => d.tileIds));
rack = rack.filter((id) => !shown.has(id));
}
return [...rack, ...s.pool];
},
};
/** Total number of tiles in a state (invariant: always 106). */
export function tileCount(s: RummikubState): number {
return s.pool.length + s.table.reduce((a, t) => a + t.tileIds.length, 0) + Object.values(s.racks).reduce((a, r) => a + r.length, 0);
}

View File

@@ -0,0 +1,279 @@
/**
* 숫자 타일 (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;
}

View File

@@ -0,0 +1,752 @@
import { describe, expect, test } from 'bun:test';
import { SeededRng, randomPlayout, seedN, type GameEvent, type PlayerId } from '@bg/engine';
import {
ALL_TILE_IDS,
findMeld,
normalizeSet,
rummikub,
scoreRound,
setProblem,
sortTiles,
tileCount,
type DraftSet,
type RummikubAction,
type RummikubOptions,
type RummikubState,
} from './index';
// ---------------------------------------------------------------- helpers
let clock = 1_000_000;
const RNG = () => SeededRng.fromSeed([7, 7, 7, 7]);
function mk(opts: Partial<RummikubOptions> = {}, players = ['A', 'B'], seed = 1): RummikubState {
const options = rummikub.optionsSchema.parse(opts);
return rummikub.setup({ players, options, rng: SeededRng.fromSeed(seedN(seed)), now: clock });
}
/**
* Puts the game into a hand-made position: given racks and table sets, everything else goes to the pool
* (in id order), `current` has the turn with a fresh draft.
*/
function rig(
s: RummikubState,
o: { racks: Record<PlayerId, string[]>; table?: string[][]; current?: PlayerId; melded?: PlayerId[]; pool?: string[] },
): RummikubState {
const table = o.table ?? [];
const used = new Set([...Object.values(o.racks).flat(), ...table.flat()]);
s.racks = Object.fromEntries(s.roundPlayers.map((p) => [p, [...(o.racks[p] ?? [])]]));
s.table = table.map((ids, i) => {
const n = normalizeSet({ tileIds: ids });
if (!n) throw new Error(`rig: invalid table set ${ids}`);
return { id: `s${i + 1}`, tileIds: n.tileIds, kind: n.kind, jokerAs: n.jokerAs };
});
s.nextSetId = table.length + 1;
const rest = ALL_TILE_IDS.filter((id) => !used.has(id));
s.pool = o.pool ? o.pool : rest;
if (o.pool) {
// The given pool must hold every tile that is in no rack and not on the table (106 in total).
const extra = rest.filter((id) => !o.pool!.includes(id));
if (extra.length) throw new Error('rig: pool must contain every unused tile');
}
s.current = o.current ?? s.roundPlayers[0]!;
s.hasMelded = Object.fromEntries(s.roundPlayers.map((p) => [p, (o.melded ?? []).includes(p)]));
s.draft = { sets: s.table.map((t) => ({ tileIds: [...t.tileIds], as: t.kind })), history: [], edits: 0 };
s.consecutivePasses = 0;
expect(tileCount(s)).toBe(106);
return s;
}
function act(s: RummikubState, who: PlayerId, a: RummikubAction): RummikubState {
return actE(s, who, a).state;
}
function actE(s: RummikubState, who: PlayerId, a: RummikubAction): { state: RummikubState; events: GameEvent[] } {
const v = rummikub.validate(s, who, a);
if (!v.ok) throw new Error(`${who} ${JSON.stringify(a)} rejected: ${v.reason}`);
clock += 1000;
const out = rummikub.apply(s, who, a, { rng: RNG(), now: clock });
expect(tileCount(out.state)).toBe(106);
return out;
}
function reason(s: RummikubState, who: PlayerId, a: RummikubAction): string {
const v = rummikub.validate(s, who, a);
if (v.ok) throw new Error(`expected rejection of ${JSON.stringify(a)}`);
return v.reason;
}
const tableSets = (s: RummikubState): DraftSet[] => s.table.map((t) => ({ tileIds: [...t.tileIds] }));
const setsOf = (s: RummikubState) => s.table.map((t) => t.tileIds);
/** Sets the draft and commits in one go. */
const play = (s: RummikubState, who: PlayerId, sets: string[][]) => act(act(s, who, { type: 'setDraft', sets: sets.map((tileIds) => ({ tileIds })) }), who, { type: 'commit' });
// ---------------------------------------------------------------- setup
describe('셋업 (§4)', () => {
test('106 tiles: 104 numbers + 2 jokers, ids R7a/R7b/JK1/JK2', () => {
expect(ALL_TILE_IDS).toHaveLength(106);
expect(new Set(ALL_TILE_IDS).size).toBe(106);
expect(ALL_TILE_IDS.filter((id) => id.startsWith('JK'))).toEqual(['JK1', 'JK2']);
expect(ALL_TILE_IDS).toContain('R7a');
expect(ALL_TILE_IDS).toContain('O13b');
});
test.each([
[2, 78],
[3, 64],
[4, 50],
])('%i players: 14 tiles each, pool %i', (n, pool) => {
const players = ['A', 'B', 'C', 'D'].slice(0, n);
const s = mk({}, players, 3);
for (const p of players) expect(s.racks[p]).toHaveLength(14);
expect(s.pool).toHaveLength(pool);
expect(tileCount(s)).toBe(106);
expect(new Set([...s.pool, ...Object.values(s.racks).flat()]).size).toBe(106);
expect(s.table).toEqual([]);
});
test('same seed → same deal and first player; the opening draw picks the highest number', () => {
const a = mk({}, ['A', 'B', 'C'], 42);
const b = mk({}, ['A', 'B', 'C'], 42);
expect(JSON.stringify(a)).toBe(JSON.stringify(b));
const lastRound = Math.max(...a.opening.map((o) => o.round));
const final = a.opening.filter((o) => o.round === lastRound && o.n !== null);
const max = Math.max(...final.map((o) => o.n!));
expect(final.filter((o) => o.n === max).map((o) => o.player)).toEqual([a.current]);
// Different seeds give different deals.
expect(JSON.stringify(mk({}, ['A', 'B', 'C'], 43).racks)).not.toBe(JSON.stringify(a.racks));
});
test('turn order is the seat order starting from the first player', () => {
const s = mk({}, ['A', 'B', 'C', 'D'], 5);
const order = [s.current];
let t = s;
for (let i = 0; i < 4; i++) {
t = act(t, t.current, { type: 'draw' });
order.push(t.current);
}
const i0 = ['A', 'B', 'C', 'D'].indexOf(order[0]!);
expect(order).toEqual([0, 1, 2, 3, 4].map((k) => ['A', 'B', 'C', 'D'][(i0 + k) % 4]!));
});
});
// ---------------------------------------------------------------- sets
describe('세트 판정 (§5.1)', () => {
const ok = (ids: string[], as?: 'run' | 'group') => normalizeSet(as ? { tileIds: ids, as } : { tileIds: ids });
test('invalid sets from the checklist', () => {
expect(ok(['R12a', 'R13a', 'R1a'])).toBeNull();
expect(setProblem({ tileIds: ['R12a', 'R13a', 'R1a'] })).toBe('13 다음에 1은 올 수 없어요');
expect(ok(['B5a', 'R5a', 'B5b'])).toBeNull();
expect(setProblem({ tileIds: ['B5a', 'R5a', 'B5b'] })).toBe('같은 색이 두 개예요');
expect(ok(['B5a', 'R5a', 'K5a', 'O5a', 'JK1'])).toBeNull();
expect(setProblem({ tileIds: ['B5a', 'R5a', 'K5a', 'O5a', 'JK1'] })).toBe('같은 숫자 묶음은 4장까지예요');
expect(ok(['R1a', 'R2a'])).toBeNull();
expect(setProblem({ tileIds: ['R1a', 'R2a'] })).toBe('3장 이상이어야 해요');
expect(ok(['R3a', 'R5a', 'R6a'])).toBeNull();
expect(setProblem({ tileIds: ['R3a', 'R5a', 'R6a'] })).toBe('숫자가 이어지지 않아요');
expect(ok(['R3a', 'B4a', 'R5a'])).toBeNull();
expect(ok(['R3a', 'R3b', 'R4a'])).toBeNull();
expect(ok(['JK1', 'JK2', 'R5a', 'R5a'])).toBeNull(); // duplicate id
});
test('valid groups and runs, points', () => {
expect(ok(['R7a', 'B7a', 'K7a'])).toMatchObject({ kind: 'group', points: 21 });
expect(ok(['R7a', 'B7a', 'K7a', 'O7b'])).toMatchObject({ kind: 'group', points: 28 });
expect(ok(['B3a', 'B4a', 'B5a', 'B6a'])).toMatchObject({ kind: 'run', points: 18, tileIds: ['B3a', 'B4a', 'B5a', 'B6a'] });
// Sent in any order: sorted ascending.
expect(ok(['B6a', 'B4a', 'B5a'])).toMatchObject({ kind: 'run', tileIds: ['B4a', 'B5a', 'B6a'] });
const all13 = Array.from({ length: 13 }, (_, i) => `O${i + 1}a`);
expect(ok(all13)).toMatchObject({ kind: 'run', points: 91 });
});
test('jokers: gap filling, two jokers in one set, interpretation (§5.5)', () => {
expect(ok(['K9a', 'K11a', 'JK1'])).toMatchObject({ kind: 'run', tileIds: ['K9a', 'JK1', 'K11a'], jokerAs: { JK1: { color: 'K', n: 10 } } });
expect(ok(['K5a', 'JK1', 'JK2'])).toMatchObject({ kind: 'run', points: 18 });
expect(ok(['K5a', 'JK1', 'JK2'], 'group')).toMatchObject({ kind: 'group', points: 15, jokerAs: { JK1: { color: null, n: 5 } } });
expect(ok(['R7a', 'B7a', 'JK2'])).toMatchObject({ kind: 'group', points: 21 });
// [조커, 검정9, 검정10] in this order = 8-9-10; [검정9, 검정10, 조커] = 9-10-11.
expect(ok(['JK1', 'K9a', 'K10a'])).toMatchObject({ points: 27, jokerAs: { JK1: { n: 8 } } });
expect(ok(['K9a', 'K10a', 'JK1'])).toMatchObject({ points: 30, jokerAs: { JK1: { n: 11 } } });
// [빨강12, 빨강13, 조커] cannot go up: 11-12-13.
expect(ok(['R12a', 'R13a', 'JK1'])).toMatchObject({ kind: 'run', tileIds: ['JK1', 'R12a', 'R13a'], points: 36, jokerAs: { JK1: { color: 'R', n: 11 } } });
// Jokers cannot stand in for 0 or 14.
expect(ok(['JK1', 'R1a', 'R2a'])).toMatchObject({ points: 6, tileIds: ['R1a', 'R2a', 'JK1'] });
});
test('rack sort by colour / number keeps jokers last', () => {
expect(sortTiles(['JK1', 'R3a', 'K5a', 'K2a', 'B3a'], 'color')).toEqual(['K2a', 'K5a', 'R3a', 'B3a', 'JK1']);
expect(sortTiles(['JK1', 'R3a', 'K5a', 'K2a', 'B3a'], 'number')).toEqual(['K2a', 'R3a', 'B3a', 'K5a', 'JK1']);
});
});
// ---------------------------------------------------------------- first meld
describe('첫 등록 (§5.3)', () => {
test('30 points from rack tiles only; 21 points is refused with the current total', () => {
let s = rig(mk(), { racks: { A: ['R10a', 'R11a', 'R12a', 'K5a'], B: ['B1a', 'B2a', 'B3a', 'K5b', 'R5a', 'O5a'] }, current: 'B' });
s = act(s, 'B', { type: 'setDraft', sets: [{ tileIds: ['B1a', 'B2a', 'B3a'] }, { tileIds: ['K5b', 'R5a', 'O5a'] }] });
expect(reason(s, 'B', { type: 'commit' })).toBe('첫 등록은 30점 이상이어야 해요 (지금 21점).');
expect(rummikub.view(s, 'B').my!.check).toMatchObject({ ok: false, meldPoints: 21 });
s = act(s, 'B', { type: 'draw' });
expect(s.table).toEqual([]);
s = play(s, 'A', [['R10a', 'R11a', 'R12a']]);
expect(s.hasMelded.A).toBe(true);
expect(s.racks.A).toEqual(['K5a']);
});
test('joker position decides the first meld: [조커, 검정9, 검정10] = 27 refused, [검정9, 검정10, 조커] = 30 accepted', () => {
let s = rig(mk(), { racks: { A: ['JK1', 'K9a', 'K10a', 'R1a'], B: ['B1a'] } });
s = act(s, 'A', { type: 'setDraft', sets: [{ tileIds: ['JK1', 'K9a', 'K10a'] }] });
expect(reason(s, 'A', { type: 'commit' })).toBe('첫 등록은 30점 이상이어야 해요 (지금 27점).');
s = act(s, 'A', { type: 'setDraft', sets: [{ tileIds: ['K9a', 'K10a', 'JK1'] }] });
s = act(s, 'A', { type: 'commit' });
expect(s.table[0]!.jokerAs).toEqual({ JK1: { color: 'K', n: 11 } });
});
test('before the first meld: touching or extending table sets is refused at setDraft (C3)', () => {
const s = rig(mk(), { racks: { A: ['R3a', 'K10a', 'B10a', 'O10a'], B: ['B1a'] }, table: [['R4a', 'R5a', 'R6a', 'R7a']], melded: ['B'] });
// Extend the table run with a rack tile.
expect(reason(s, 'A', { type: 'setDraft', sets: [{ tileIds: ['R3a', 'R4a', 'R5a', 'R6a', 'R7a'] }] })).toBe('첫 등록 전에는 바닥의 타일을 옮길 수 없어요.');
// Split the table run.
expect(reason(s, 'A', { type: 'setDraft', sets: [{ tileIds: ['R4a', 'R5a'] }, { tileIds: ['R6a', 'R7a'] }] })).toBe('첫 등록 전에는 바닥의 타일을 옮길 수 없어요.');
// A new set next to the untouched table set is fine.
const t = play(s, 'A', [['R4a', 'R5a', 'R6a', 'R7a'], ['K10a', 'B10a', 'O10a']]);
expect(t.hasMelded.A).toBe(true);
});
test('initialMeldAllowExtend: 30 from new sets first, then extending in the same turn is allowed', () => {
const base = { racks: { A: ['R3a', 'K10a', 'B10a', 'O10a', 'R8a'], B: ['B1a'] }, table: [['R4a', 'R5a', 'R6a', 'R7a']], melded: ['B'] };
let s = rig(mk({ initialMeldAllowExtend: true }), base);
s = act(s, 'A', { type: 'setDraft', sets: [{ tileIds: ['R3a', 'R4a', 'R5a', 'R6a', 'R7a', 'R8a'] }] });
expect(reason(s, 'A', { type: 'commit' })).toBe('첫 등록은 30점 이상이어야 해요 (지금 0점).');
s = play(s, 'A', [['R3a', 'R4a', 'R5a', 'R6a', 'R7a', 'R8a'], ['K10a', 'B10a', 'O10a']]);
expect(s.racks.A).toEqual([]);
});
test('initialMeldPoints 0 = no first-meld rule at all', () => {
const s = mk({ initialMeldPoints: 0 });
expect(Object.values(s.hasMelded).every(Boolean)).toBe(true);
});
});
// ---------------------------------------------------------------- rearrangement
describe('재배치와 조커 회수 (§5.4, §5.5)', () => {
test('table [빨강4..8] + rack 빨강6 → [4,5,6] [6,7,8]', () => {
let s = rig(mk(), { racks: { A: ['R6b', 'K1a'], B: ['B1a'] }, table: [['R4a', 'R5a', 'R6a', 'R7a', 'R8a']], melded: ['A'] });
s = play(s, 'A', [['R4a', 'R5a', 'R6a'], ['R6b', 'R7a', 'R8a']]);
expect(setsOf(s)).toEqual([['R4a', 'R5a', 'R6a'], ['R6b', 'R7a', 'R8a']]);
expect(s.racks.A).toEqual(['K1a']);
// The first set keeps its id (biggest overlap), the second is new; both are highlighted as changed.
expect(s.table.map((t) => t.id)).toEqual(['s1', 's2']);
expect(s.lastChanged.sort()).toEqual(['s1', 's2']);
});
test('official examples: add 파랑3 to 파랑4-5-6, 4th colour to a group, take 파랑4 from a 4-group', () => {
let s = rig(mk(), {
racks: { A: ['B3a', 'O8a', 'B3b', 'B5b', 'B6b', 'K1a'], B: ['B1a'] },
table: [['B4a', 'B5a', 'B6a'], ['R8a', 'K8a', 'B8a'], ['R4a', 'K4a', 'B4b', 'O4a']],
melded: ['A'],
});
s = play(s, 'A', [['B3a', 'B4a', 'B5a', 'B6a'], ['R8a', 'K8a', 'B8a', 'O8a'], ['R4a', 'K4a', 'O4a'], ['B3b', 'B4b', 'B5b', 'B6b']]);
expect(s.racks.A).toEqual(['K1a']);
expect(s.table).toHaveLength(4);
});
test('joker retrieval: [파랑5, 조커, 파랑7] + [파랑6, 검정3, 빨강3] → [파랑5,6,7] [검정3, 빨강3, 조커]', () => {
let s = rig(mk(), { racks: { A: ['B6a', 'K3a', 'R3a', 'O1a'], B: ['B1a'] }, table: [['B5a', 'JK1', 'B7a']], melded: ['A'] });
// Taking the joker back to the rack is refused (C2).
expect(reason(s, 'A', { type: 'setDraft', sets: [{ tileIds: ['B5a', 'B6a', 'B7a'] }] })).toBe('바닥의 타일은 내 받침대로 가져올 수 없어요.');
s = play(s, 'A', [['B5a', 'B6a', 'B7a'], ['K3a', 'R3a', 'JK1']]);
expect(setsOf(s)).toEqual([['B5a', 'B6a', 'B7a'], ['K3a', 'R3a', 'JK1']]);
expect(s.table[1]!.jokerAs).toEqual({ JK1: { color: null, n: 3 } });
expect(s.racks.A).toEqual(['O1a']);
});
test('rearranging without a rack tile is refused; leftover single tiles are refused', () => {
let s = rig(mk(), { racks: { A: ['K1a', 'K2a'], B: ['B1a'] }, table: [['R4a', 'R5a', 'R6a', 'R7a', 'R8a', 'R9a']], melded: ['A'] });
s = act(s, 'A', { type: 'setDraft', sets: [{ tileIds: ['R4a', 'R5a', 'R6a'] }, { tileIds: ['R7a', 'R8a', 'R9a'] }] });
expect(reason(s, 'A', { type: 'commit' })).toBe('받침대에서 타일을 1개 이상 내야 해요.');
s = act(s, 'A', { type: 'setDraft', sets: [{ tileIds: ['R4a', 'R5a', 'R6a'] }, { tileIds: ['R7a', 'R8a', 'R9a'] }, { tileIds: ['K1a', 'K2a'] }] });
expect(reason(s, 'A', { type: 'commit' })).toBe('빨간 표시된 묶음을 고쳐 주세요.');
expect(rummikub.view(s, 'A').my!.check!.invalidIdx).toEqual([2]);
expect(rummikub.view(s, 'A').draft![2]).toMatchObject({ ok: false, problem: '3장 이상이어야 해요' });
});
test('cheating attempts: foreign tiles, duplicates, table tiles into the rack, empty sets', () => {
const s = rig(mk(), { racks: { A: ['K1a', 'K2a', 'K3a'], B: ['B1a', 'B2a', 'B3a'] }, table: [['R4a', 'R5a', 'R6a']], melded: ['A', 'B'] });
const base = tableSets(s);
expect(reason(s, 'A', { type: 'setDraft', sets: [...base, { tileIds: ['B1a', 'B2a', 'B3a'] }] })).toBe('내 받침대나 바닥에 없는 타일이에요.');
expect(reason(s, 'A', { type: 'setDraft', sets: [...base, { tileIds: ['O9a', 'O10a', 'O11a'] }] })).toBe('내 받침대나 바닥에 없는 타일이에요.');
expect(reason(s, 'A', { type: 'setDraft', sets: [...base, { tileIds: ['XX1', 'K2a', 'K3a'] }] })).toBe('내 받침대나 바닥에 없는 타일이에요.');
expect(reason(s, 'A', { type: 'setDraft', sets: [...base, { tileIds: ['K1a', 'K1a', 'K2a'] }] })).toBe('같은 타일이 두 번 들어 있어요.');
expect(reason(s, 'A', { type: 'setDraft', sets: [...base, { tileIds: ['K1a', 'K2a', 'K3a', 'R4a'] }] })).toBe('같은 타일이 두 번 들어 있어요.');
expect(reason(s, 'A', { type: 'setDraft', sets: [{ tileIds: ['R4a', 'R5a'] }, { tileIds: ['K1a', 'K2a', 'K3a'] }] })).toBe('바닥의 타일은 내 받침대로 가져올 수 없어요.');
expect(rummikub.actionSchema.safeParse({ type: 'setDraft', sets: [{ tileIds: [] }] }).success).toBe(false);
expect(reason(s, 'B', { type: 'setDraft', sets: base })).toBe('지금은 내 차례가 아니에요.');
expect(reason(s, 'Z', { type: 'draw' })).toBe('이 게임의 플레이어가 아니에요.');
// A valid commit puts exactly the played tiles on the table.
const t = play(s, 'A', [...base.map((d) => d.tileIds), ['K1a', 'K2a', 'K3a']]);
expect(t.racks.A).toEqual([]);
});
});
// ---------------------------------------------------------------- draft / undo
describe('초안과 되돌리기 (§5.6)', () => {
test('5 setDraft → 2 undo → the 3rd draft; reset → turn start; survives a save/reload', () => {
let s = rig(mk(), { racks: { A: ['K1a', 'K2a', 'K3a', 'K4a', 'K5a'], B: ['B1a'] }, table: [['R4a', 'R5a', 'R6a']], melded: ['A'] });
const base = tableSets(s);
const drafts = [1, 2, 3, 4, 5].map((k) => [...base, { tileIds: ['K1a', 'K2a', 'K3a', 'K4a', 'K5a'].slice(0, k) }]);
for (const d of drafts) s = act(s, 'A', { type: 'setDraft', sets: d });
s = act(s, 'A', { type: 'undoDraft' });
s = act(s, 'A', { type: 'undoDraft' });
expect(s.draft.sets).toEqual(drafts[2]!);
// Reconnect = the persisted state is reloaded; the draft is still there.
const reloaded = JSON.parse(JSON.stringify(s)) as RummikubState;
expect(rummikub.view(reloaded, 'A').draft!.map((d) => d.tileIds)).toEqual(drafts[2]!.map((d) => d.tileIds));
s = act(s, 'A', { type: 'resetDraft' });
expect(s.draft.sets.map((d) => d.tileIds)).toEqual(base.map((d) => d.tileIds));
expect(rummikub.view(s, 'A').my!.changed).toBe(false);
// Reset can be undone too.
s = act(s, 'A', { type: 'undoDraft' });
expect(s.draft.sets).toEqual(drafts[2]!);
for (let i = 0; i < 3; i++) s = act(s, 'A', { type: 'undoDraft' });
expect(reason(s, 'A', { type: 'undoDraft' })).toBe('되돌릴 것이 없어요.');
});
test('history keeps at most 100 steps', () => {
let s = rig(mk(), { racks: { A: ['K1a', 'K2a'], B: ['B1a'] }, melded: ['A'] });
for (let i = 0; i < 130; i++) s = act(s, 'A', { type: 'setDraft', sets: [{ tileIds: [i % 2 ? 'K1a' : 'K2a'] }] });
expect(s.draft.history).toHaveLength(100);
});
test('draw with a changed draft throws the draft away and draws one', () => {
let s = rig(mk(), { racks: { A: ['K1a', 'K2a', 'K3a'], B: ['B1a'] }, melded: ['A'] });
s = act(s, 'A', { type: 'setDraft', sets: [{ tileIds: ['K1a', 'K2a', 'K3a'] }] });
s = act(s, 'A', { type: 'draw' });
expect(s.table).toEqual([]);
expect(s.racks.A).toHaveLength(4);
expect(s.current).toBe('B');
});
test('invalidCommitPenalty=penalty3: an invalid commit reverts, draws 3 and ends the turn', () => {
let s = rig(mk({ invalidCommitPenalty: 'penalty3' }), { racks: { A: ['K1a', 'K2a'], B: ['B1a'] }, melded: ['A'] });
expect(reason(s, 'A', { type: 'commit' })).toBe('받침대에서 타일을 1개 이상 내야 해요.');
s = act(s, 'A', { type: 'setDraft', sets: [{ tileIds: ['K1a', 'K2a'] }] });
s = act(s, 'A', { type: 'commit' });
expect(s.table).toEqual([]);
expect(s.racks.A).toHaveLength(5);
expect(s.current).toBe('B');
expect(s.log.at(-1)).toMatchObject({ by: 'A', kind: 'penalty', count: 3 });
});
});
// ---------------------------------------------------------------- timeouts
describe('시간 초과 (§5.7, §8.5)', () => {
const pos = (opts: Partial<RummikubOptions> = {}, pool?: string[]) => {
const s = mk(opts);
const racks = { A: ['K1a', 'K2a', 'K3a', 'O9a'], B: ['B1a'] };
const table = [['R4a', 'R5a', 'R6a']];
if (pool) {
// Park everything except `pool` in B's rack so the pool has exactly these tiles.
const used = new Set([...racks.A, ...table.flat(), ...pool]);
racks.B = ALL_TILE_IDS.filter((id) => !used.has(id));
}
return rig(s, { racks, table, melded: ['A'], ...(pool ? { pool } : {}) });
};
test('timeout is system-only and legal for the current player', () => {
const s = pos();
expect(rummikub.actionSchema.safeParse({ type: 'timeout' }).success).toBe(false);
expect(rummikub.actionSchema.safeParse({ type: 'leave' }).success).toBe(false);
expect(rummikub.validate(s, 'A', rummikub.onTimeout(s, 'A')).ok).toBe(true);
expect(rummikub.deadline!(s)).toBe(s.turnStartedAt + 60_000);
});
test('invalid draft: table and rack restored, 3 tiles drawn; with only 2 in the pool, 2', () => {
let s = pos();
const rackBefore = [...s.racks.A!];
s = act(s, 'A', { type: 'setDraft', sets: [{ tileIds: ['R4a', 'R5a'] }, { tileIds: ['R6a', 'K1a'] }] });
s = act(s, 'A', { type: 'timeout' });
expect(setsOf(s)).toEqual([['R4a', 'R5a', 'R6a']]);
expect(s.racks.A!.slice(0, 4)).toEqual(rackBefore);
expect(s.racks.A).toHaveLength(7);
expect(s.lastDrawn.A).toHaveLength(3);
expect(s.current).toBe('B');
let t = pos({}, ['O13a', 'O13b']);
t = act(t, 'A', { type: 'setDraft', sets: [...tableSets(t), { tileIds: ['K1a'] }] });
t = act(t, 'A', { type: 'timeout' });
expect(t.racks.A).toHaveLength(6);
expect(t.pool).toEqual([]);
});
test('invalidTimeoutPenalty=1 draws only one', () => {
let s = pos({ invalidTimeoutPenalty: 1 });
s = act(s, 'A', { type: 'setDraft', sets: [...tableSets(s), { tileIds: ['K1a'] }] });
s = act(s, 'A', { type: 'timeout' });
expect(s.racks.A).toHaveLength(5);
});
test('valid draft: auto-commit (default) or revert + draw 1; no change: draw 1', () => {
let s = pos();
s = act(s, 'A', { type: 'setDraft', sets: [...tableSets(s), { tileIds: ['K1a', 'K2a', 'K3a'] }] });
s = act(s, 'A', { type: 'timeout' });
expect(setsOf(s)).toEqual([['R4a', 'R5a', 'R6a'], ['K1a', 'K2a', 'K3a']]);
expect(s.racks.A).toEqual(['O9a']);
expect(s.log.at(-1)).toMatchObject({ kind: 'timeoutCommit', count: 3 });
let r = pos({ timeoutValidDraft: 'revert' });
r = act(r, 'A', { type: 'setDraft', sets: [...tableSets(r), { tileIds: ['K1a', 'K2a', 'K3a'] }] });
r = act(r, 'A', { type: 'timeout' });
expect(setsOf(r)).toEqual([['R4a', 'R5a', 'R6a']]);
expect(r.racks.A).toHaveLength(5);
let u = pos();
u = act(u, 'A', { type: 'timeout' });
expect(u.racks.A).toHaveLength(5);
expect(u.log.at(-1)).toMatchObject({ kind: 'timeoutDraw', count: 1 });
});
test('3 timeouts in a row → away: the deadline becomes short; acting again resets it', () => {
let s = mk({}, ['A', 'B'], 2);
const p = s.current;
for (let i = 0; i < 3; i++) {
s = act(s, p, { type: 'timeout' });
s = act(s, s.current, { type: 'draw' });
}
expect(rummikub.view(s, null).seats.find((x) => x.id === p)!.away).toBe(true);
expect(rummikub.deadline!(s)).toBe(s.turnStartedAt + 3000);
s = act(s, p, { type: 'draw' });
expect(s.timeoutsInRow[p]).toBe(0);
});
});
// ---------------------------------------------------------------- scoring / end
describe('점수와 끝 (§6, §5.8)', () => {
test('rack emptied: 4-player example B -15, C 조커 -30, D -15, A +60', () => {
const r = scoreRound({ A: [], B: ['R3a', 'K12a'], C: ['JK1'], D: ['R1a', 'B1a', 'O13a'] }, ['A', 'B', 'C', 'D'], 'rackEmpty', 30);
expect(r.winners).toEqual(['A']);
expect(r.scores).toEqual({ A: 60, B: -15, C: -30, D: -15 });
expect(scoreRound({ A: [], B: ['JK1'] }, ['A', 'B'], 'rackEmpty', 50).scores).toEqual({ A: 50, B: -50 });
});
test('stalemate example: A=5, B=12, C=30 → A +32, B -7, C -25; ties: fewer tiles, then shared', () => {
const r = scoreRound({ A: ['R5a'], B: ['R12a'], C: ['R10a', 'B10a', 'K10a'] }, ['A', 'B', 'C'], 'stalemate', 30);
expect(r.winners).toEqual(['A']);
expect(r.scores).toEqual({ A: 32, B: -7, C: -25 });
const t = scoreRound({ A: ['R5a'], B: ['R2a', 'R3a'], C: ['R10a'] }, ['A', 'B', 'C'], 'stalemate', 30);
expect(t.winners).toEqual(['A']);
const sh = scoreRound({ A: ['R5a'], B: ['B5a'], C: ['R10a'] }, ['A', 'B', 'C'], 'stalemate', 30);
expect(sh.winners).toEqual(['A', 'B']);
expect(sh.scores).toEqual({ A: 2, B: 2, C: -5 });
});
test('emptying the rack ends the game with ranking, scores and a Korean summary', () => {
let s = rig(mk({}, ['A', 'B', 'C', 'D']), {
racks: { A: ['K1a', 'K2a', 'K3a'], B: ['R3a', 'K12a'], C: ['JK1'], D: ['R1a', 'B1a', 'O13a'] },
melded: ['A'],
});
s = play(s, 'A', [['K1a', 'K2a', 'K3a']]);
expect(s.phase).toBe('finished');
const res = rummikub.result(s)!;
expect(res.ranking).toEqual([['A'], ['B', 'D'], ['C']]);
expect(res.scores).toEqual({ A: 60, B: -15, C: -30, D: -15 });
expect(res.summary).toContain('+60점');
expect(res.reason).toBe('normal');
expect(rummikub.view(s, 'B').round!.revealed.C).toEqual(['JK1']);
expect(reason(s, 'B', { type: 'draw' })).toBe('게임이 이미 끝났어요.');
});
test('stalemate: empty pool, 3 players pass in a row → end; a commit in between resets the count', () => {
const s0 = mk({}, ['A', 'B', 'C']);
// Empty pool: everything else is parked in C's rack.
const racks: Record<string, string[]> = { A: ['K1a', 'K2a', 'K3a', 'R5a'], B: ['R12a'] };
const used = new Set(Object.values(racks).flat());
racks.C = ALL_TILE_IDS.filter((id) => !used.has(id));
let s = rig(s0, { racks, melded: ['A', 'B', 'C'], pool: [] });
s = act(s, 'A', { type: 'pass' });
s = act(s, 'B', { type: 'pass' });
expect(s.consecutivePasses).toBe(2);
s = play(s, 'C', [['O11a', 'O12a', 'O13a']]);
expect(s.consecutivePasses).toBe(0);
s = act(s, 'A', { type: 'draw' }); // empty pool: draw = pass
s = act(s, 'B', { type: 'pass' });
expect(s.phase).toBe('playing');
s = act(s, 'C', { type: 'timeout' }); // a timeout without playing counts too
expect(s.phase).toBe('finished');
expect(s.round!.endReason).toBe('stalemate');
// A = 1+2+3+5 = 11 < B = 12.
expect(s.round!.winners).toEqual(['A']);
expect(s.round!.scores.A! + s.round!.scores.B! + s.round!.scores.C!).toBe(0);
});
test('pass is refused while the pool has tiles', () => {
const s = mk();
expect(reason(s, s.current, { type: 'pass' })).toBe('가져올 타일이 남아 있으면 넘길 수 없어요. 한 장 가져와 주세요.');
});
test('gamesPerMatch=2: round end → ready → new deal; wins then totals decide', () => {
let s = rig(mk({ gamesPerMatch: '2' }), { racks: { A: ['K1a', 'K2a', 'K3a'], B: ['R10a', 'R11a'] }, melded: ['A'] });
s = play(s, 'A', [['K1a', 'K2a', 'K3a']]);
expect(s.phase).toBe('roundEnd');
expect(rummikub.result(s)).toBeNull();
expect(s.match).toMatchObject({ gameNo: 1, total: 2, wins: { A: 1, B: 0 }, totals: { A: 21, B: -21 } });
expect(rummikub.activePlayers(s).sort()).toEqual(['A', 'B']);
expect(rummikub.deadline!(s)).toBe(s.roundEndAt! + 30_000);
expect(reason(s, 'A', { type: 'draw' })).toBe('다음 판을 기다리는 중이에요.');
s = act(s, 'A', { type: 'ready' });
expect(reason(s, 'A', { type: 'ready' })).toBe('이미 준비했어요.');
s = act(s, 'B', { type: 'timeout' });
expect(s.phase).toBe('playing');
expect(s.match.gameNo).toBe(2);
expect(s.table).toEqual([]);
expect(Object.values(s.racks).map((r) => r.length)).toEqual([14, 14]);
expect(rummikub.view(s, 'A').round!.revealed).toEqual({});
s = rig(s, { racks: { A: ['O5a'], B: ['B1a', 'B2a', 'B3a'] }, current: 'B', melded: ['B'] });
s = play(s, 'B', [['B1a', 'B2a', 'B3a']]);
expect(s.phase).toBe('finished');
// 1 win each; totals A 21-5 = 16, B -21+5 = -16 → A first.
expect(rummikub.result(s)!.ranking).toEqual([['A'], ['B']]);
expect(rummikub.result(s)!.summary).toContain('2판 끝');
});
test('gamesPerMatch=players uses the number of players', () => {
expect(mk({ gamesPerMatch: 'players' }, ['A', 'B', 'C']).match.total).toBe(3);
});
});
// ---------------------------------------------------------------- leaving
describe('나가기', () => {
test('3 players: the current player leaves → the next one plays; left players are skipped', () => {
let s = mk({}, ['A', 'B', 'C'], 4);
const p = s.current;
expect(rummikub.onLeave!(s, p)).toEqual({ type: 'leave' });
s = act(s, p, { type: 'leave' });
expect(s.current).not.toBe(p);
for (let i = 0; i < 4; i++) {
expect(s.current).not.toBe(p);
s = act(s, s.current, { type: 'draw' });
}
expect(reason(s, p, { type: 'draw' })).toBe('방을 나간 플레이어예요.');
expect(rummikub.onLeave!(s, p)).toBeNull();
});
test('2 players: one leaves → finished, the other ranks first', () => {
let s = mk({}, ['A', 'B']);
s = act(s, 'B', { type: 'leave' });
expect(s.phase).toBe('finished');
expect(rummikub.result(s)).toMatchObject({ ranking: [['A'], ['B']], reason: 'abandoned' });
});
});
// ---------------------------------------------------------------- information
describe('정보 공개 (§8.3)', () => {
test('opponents and spectators see counts only; the draft is hidden unless showDraftToOthers', () => {
let s = rig(mk(), { racks: { A: ['K1a', 'K2a', 'K3a', 'O7a'], B: ['B1a', 'B9b'] }, table: [['R4a', 'R5a', 'R6a']], melded: ['A'] });
s = act(s, 'A', { type: 'setDraft', sets: [...tableSets(s), { tileIds: ['K1a', 'K2a', 'K3a'] }] });
for (const viewer of ['B', null]) {
const v = rummikub.view(s, viewer);
const json = JSON.stringify(v);
for (const id of [...s.racks.A!, ...s.pool, ...(viewer ? [] : s.racks.B!)]) expect(json.includes(`"${id}"`)).toBe(false);
expect(v.draft).toBeNull();
expect(v.draftEdits).toBe(1);
expect(v.seats.find((x) => x.id === 'A')!.tiles).toBe(4);
expect(v.poolCount).toBe(s.pool.length);
}
expect(rummikub.view(s, 'B').my!.rack).toEqual(['B1a', 'B9b']);
const own = rummikub.view(s, 'A');
expect(own.draft!.map((d) => d.ok)).toEqual([true, true]);
expect(own.my!.check).toMatchObject({ ok: true, fromRack: 3 });
let t = rig(mk({ showDraftToOthers: true }), { racks: { A: ['K1a', 'K2a', 'K3a', 'O7a'], B: ['B1a'] }, melded: ['A'] });
t = act(t, 'A', { type: 'setDraft', sets: [{ tileIds: ['K1a', 'K2a', 'K3a'] }] });
const vb = rummikub.view(t, 'B');
expect(vb.draft!.map((d) => d.tileIds)).toEqual([['K1a', 'K2a', 'K3a']]);
expect(JSON.stringify(vb).includes('"O7a"')).toBe(false);
expect(rummikub.secretsOf!(t, 'A')).not.toContain('K1a');
});
test('draw events carry no tile; only the drawer sees the new tile', () => {
const s = mk();
const p = s.current;
const out = actE(s, p, { type: 'draw' });
const drawn = out.state.lastDrawn[p]!;
expect(drawn).toHaveLength(1);
expect(JSON.stringify(out.events).includes(drawn[0]!)).toBe(false);
expect(out.events).toEqual([{ type: 'drew', player: p, count: 1, penalty: false }]);
expect(rummikub.view(out.state, p).my!.lastDrawn).toEqual(drawn);
const other = s.seats.find((x) => x !== p)!;
expect(JSON.stringify(rummikub.view(out.state, other)).includes(`"${drawn[0]}"`)).toBe(false);
});
test('the opening draw is shown without tile ids (they are shuffled back)', () => {
const s = mk({}, ['A', 'B', 'C']);
expect(s.opening.length).toBeGreaterThanOrEqual(3);
expect(JSON.stringify(s.opening)).not.toMatch(/[KRBO]\d+[ab]/);
});
});
// ---------------------------------------------------------------- concurrency
describe('동시성', () => {
test('the same commit twice: the second is "not your turn"', () => {
let s = rig(mk(), { racks: { A: ['K1a', 'K2a', 'K3a', 'R1a'], B: ['B1a'] }, melded: ['A'] });
s = act(s, 'A', { type: 'setDraft', sets: [{ tileIds: ['K1a', 'K2a', 'K3a'] }] });
const commit = { type: 'commit' } as const;
s = act(s, 'A', commit);
expect(reason(s, 'A', commit)).toBe('지금은 내 차례가 아니에요.');
});
test('a setDraft from an earlier turn is refused (turn number)', () => {
let s = rig(mk(), { racks: { A: ['K1a', 'K2a', 'K3a', 'R1a'], B: ['B1a'] }, melded: ['A'] });
const turn = s.turnNo;
s = act(s, 'A', { type: 'draw' });
expect(reason(s, 'A', { type: 'setDraft', sets: [{ tileIds: ['K1a'] }], turn })).toBe('지금은 내 차례가 아니에요.');
s = act(s, 'B', { type: 'draw' });
expect(reason(s, 'A', { type: 'setDraft', sets: [{ tileIds: ['K1a'] }], turn })).toBe('지난 차례의 요청이라 무시했어요.');
expect(rummikub.validate(s, 'A', { type: 'setDraft', sets: [{ tileIds: ['K1a'] }], turn: s.turnNo }).ok).toBe(true);
});
});
// ---------------------------------------------------------------- options / hints
describe('옵션과 힌트', () => {
test('every option has a Korean title; defaults follow the doc', () => {
const shape = (rummikub.optionsSchema as unknown as { shape: Record<string, { meta(): { title?: string } | undefined }> }).shape;
for (const [k, v] of Object.entries(shape)) expect(v.meta()?.title, k).toBeTruthy();
expect(rummikub.defaultOptions).toEqual({
initialMeldPoints: 30,
turnSeconds: 60,
timeoutValidDraft: 'commit',
invalidTimeoutPenalty: 3,
invalidCommitPenalty: 'reject',
jokerPenalty: 30,
initialMeldAllowExtend: false,
showDraftToOthers: false,
gamesPerMatch: '1',
hints: true,
});
expect(rummikub.optionsSchema.safeParse({ initialMeldPoints: 25 }).success).toBe(false);
});
test('findMeld finds a 30+ point combination from the rack only', () => {
const m = findMeld(['R10a', 'R11a', 'R12a', 'K1a', 'B5a'], 30)!;
expect(m.map((x) => x.tileIds)).toEqual([['R10a', 'R11a', 'R12a']]);
expect(findMeld(['B1a', 'B2a', 'B3a', 'K5a', 'R5a', 'O5a'], 30)).toBeNull();
});
});
// ---------------------------------------------------------------- properties
const CONFIGS: Partial<RummikubOptions>[] = [
{},
{ initialMeldPoints: 0, showDraftToOthers: true },
{ initialMeldPoints: 50, invalidCommitPenalty: 'penalty3', jokerPenalty: 50 },
{ timeoutValidDraft: 'revert', invalidTimeoutPenalty: 1, initialMeldAllowExtend: true },
{ gamesPerMatch: '2', initialMeldPoints: 20 },
];
/** Every tile exactly once across pool, racks and table. */
let checkedTable: unknown = null;
function conserved(st: unknown): void {
const s = st as RummikubState;
const all = [...s.pool, ...s.table.flatMap((t) => t.tileIds), ...Object.values(s.racks).flat()];
if (all.length !== 106 || new Set(all).size !== 106) throw new Error(`tiles not conserved: ${all.length}/${new Set(all).size}`);
// The committed table is always made of valid sets (checked once per turn; it only changes between turns).
const key = `${s.match.gameNo}:${s.turnNo}`;
if (checkedTable === key) return;
checkedTable = key;
for (const t of s.table) if (!normalizeSet({ tileIds: t.tileIds, as: t.kind })) throw new Error(`invalid table set ${t.tileIds}`);
}
describe('random playouts (docs/09 §7)', () => {
test('engine randomPlayout (round trip, checkNoLeak via secretsOf, determinism) across options', () => {
for (let i = 0; i < 25; i++) {
const options = rummikub.optionsSchema.parse(CONFIGS[i % CONFIGS.length]);
const players = ['P0', 'P1', 'P2', 'P3'].slice(0, 2 + (i % 3));
const a = randomPlayout(rummikub, { players, options, seed: seedN(9000 + i), maxSteps: 4000, invariant: conserved, maxApplyMs: 30 });
expect(a.finished).toBe(true);
if (i < 5) {
const b = randomPlayout(rummikub, { players, options, seed: seedN(9000 + i), maxSteps: 4000, maxApplyMs: 30 });
expect(JSON.stringify(b.finalState)).toBe(JSON.stringify(a.finalState));
}
}
}, 60_000);
/**
* 1,000 seeds, same checks as randomPlayout with each view computed once per step (fast leak check),
* plus: onTimeout legal for every waiting player at every step, and 1 in 8 moves is a timeout.
*/
const TILE_RE = /"(?:[KRBO]\d{1,2}[ab]|JK[12])"/g;
function run(seed: number): { state: RummikubState; steps: number } {
const options = rummikub.optionsSchema.parse(CONFIGS[seed % CONFIGS.length]);
const players = ['P0', 'P1', 'P2', 'P3'].slice(0, 2 + (seed % 3));
const rng = SeededRng.fromSeed(seedN(seed));
const chooser = SeededRng.fromSeed(seedN(seed + 500_000));
let now = 1_700_000_000_000;
let s = rummikub.setup({ players, options, rng, now });
for (let step = 0; step < 4000; step++) {
// JSON round trip is checked every step in the engine playouts above (apply itself clones through JSON); here every 10th step.
if (step % 10 === 0) {
const json = JSON.stringify(s);
if (JSON.stringify(JSON.parse(json)) !== json) throw new Error('not JSON round-trippable');
}
conserved(s);
// Leak check: which hidden tile belongs to whom; every viewer (and a spectator) is checked once.
const pool = new Set(s.pool);
const owner = new Map<string, PlayerId>();
for (const p of players) for (const id of rummikub.secretsOf!(s, p)) owner.set(id, pool.has(id) ? '*pool' : p);
for (const viewer of [...players, null]) {
const ids = JSON.stringify(rummikub.view(s, viewer)).match(TILE_RE) ?? [];
for (const q of ids) {
const o = owner.get(q.slice(1, -1));
if (o && o !== viewer) throw new Error(`seed ${seed} step ${step}: ${viewer} sees ${q} of ${o}`);
}
}
if (rummikub.result(s)) return { state: s, steps: step };
const waiting = rummikub.activePlayers(s);
if (!waiting.length) throw new Error('no active players');
for (const p of waiting) {
const v = rummikub.validate(s, p, rummikub.onTimeout(s, p));
if (!v.ok) throw new Error(`seed ${seed}: onTimeout illegal: ${v.reason}`);
}
const actor = waiting[chooser.int(waiting.length)]!;
const legal = rummikub.legalActions!(s, actor);
const a = legal.length && chooser.int(8) !== 0 ? legal[chooser.int(legal.length)]! : rummikub.onTimeout(s, actor);
const v = rummikub.validate(s, actor, a);
if (!v.ok) throw new Error(`seed ${seed}: generated action rejected: ${v.reason} ${JSON.stringify(a)}`);
now += 1000;
const t0 = performance.now();
const out = rummikub.apply(s, actor, a, { rng, now });
const ms = performance.now() - t0;
if (ms > 30) throw new Error(`apply took ${ms.toFixed(1)}ms`);
for (const e of out.events) {
const ids = JSON.stringify(e).match(TILE_RE) ?? [];
for (const q of ids) if (!out.state.table.some((t) => t.tileIds.includes(q.slice(1, -1)))) throw new Error(`event leaks ${q}`);
}
s = out.state;
}
throw new Error(`seed ${seed}: not finished within 4000 steps`);
}
for (let chunk = 0; chunk < 10; chunk++) {
test(`1,000 seeded playouts: seeds ${chunk * 100 + 1}–${chunk * 100 + 100}`, () => {
const ends: Record<string, number> = {};
for (let seed = chunk * 100 + 1; seed <= chunk * 100 + 100; seed++) {
const { state } = run(seed);
expect(state.phase).toBe('finished');
const res = rummikub.result(state)!;
expect(res.ranking.flat().sort()).toEqual([...state.seats].sort());
const k = state.round?.endReason ?? 'none';
ends[k] = (ends[k] ?? 0) + 1;
if (seed % 100 === 0) expect(JSON.stringify(run(seed).state)).toBe(JSON.stringify(state));
}
expect(ends.stalemate ?? 0).toBeGreaterThan(0);
}, 60_000);
}
});

View File

@@ -21,6 +21,7 @@ export const CATALOG: CatalogEntry[] = [
{ id: 'yacht', nameKo: '요트 다이스', minPlayers: 1, maxPlayers: 8, minutes: '10~30', level: '쉬움', blurb: '주사위 다섯 개로 족보 만들기 · 혼자서도 OK', available: true },
{ id: 'yut', nameKo: '윷놀이', minPlayers: 2, maxPlayers: 8, minutes: '15~25', level: '쉬움', blurb: '도·개·걸·윷·모! 개인전 또는 팀전', available: true },
{ id: 'one-card', nameKo: '원카드', minPlayers: 2, maxPlayers: 10, minutes: '10~15', level: '쉬움', blurb: '같은 무늬·숫자를 내고 공격 카드로 먹이기! · 색깔 카드 모드', available: true },
{ id: 'rummikub', nameKo: '숫자 타일', minPlayers: 2, maxPlayers: 4, minutes: '30~60', level: '보통', blurb: '같은 숫자, 이어지는 숫자로 묶어 먼저 다 내려놓기!', available: true },
];
export const catalogById = (id: string): CatalogEntry | undefined => CATALOG.find((g) => g.id === id);