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>
This commit is contained in:
@@ -9,6 +9,7 @@ export const GAME_UI: Record<string, () => Promise<{ default: GameUI }>> = {
|
||||
baduk: () => import('./baduk'),
|
||||
seotda: () => import('./seotda'),
|
||||
poker: () => import('./poker'),
|
||||
rummikub: () => import('./rummikub'),
|
||||
};
|
||||
|
||||
const cache = new Map<string, GameUI>();
|
||||
|
||||
823
apps/web/src/games/rummikub/index.tsx
Normal file
823
apps/web/src/games/rummikub/index.tsx
Normal file
@@ -0,0 +1,823 @@
|
||||
/** 숫자 타일 화면 (docs/games/rummikub.md §9). Tap a tile to pick it up, then tap where it goes. */
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
// Pure tile/set helpers only (no zod): keeps this lazy chunk small.
|
||||
import { findMeld, normalizeSet, pickDisjoint, setProblem, sortTiles, tileLabel, tileOf, type DraftSet, type JokerAs, type TileColor } from '@bg/games/rummikub/rules';
|
||||
import type { LogEntry, RummikubOptions, RummikubView } from '@bg/games/rummikub';
|
||||
import type { PublicUser } from '@bg/shared/lite';
|
||||
import { Avatar, ConfirmSheet } from '../../components/ui';
|
||||
import { TurnTimer } from '../../components/TurnTimer';
|
||||
import { haptic, sounds } from '../../lib/feedback';
|
||||
import type { BoardProps, GameUI, OptionsProps } from '../types';
|
||||
|
||||
const nick = (players: Record<string, PublicUser>, id: string) => players[id]?.nickname ?? '알 수 없음';
|
||||
const userOf = (players: Record<string, PublicUser>, id: string): PublicUser => players[id] ?? { id, nickname: '알 수 없음', avatar: null, kind: 'guest' };
|
||||
|
||||
/** Colour + shape mark so colours never have to be told apart by hue alone (§9). */
|
||||
const MARK: Record<TileColor, string> = { K: '●', R: '♥', B: '◆', O: '★' };
|
||||
|
||||
const CSS = `
|
||||
.rk-root { width: 100%; align-self: stretch; flex: 1; min-height: 0; display: flex; flex-direction: column; gap: 10px; }
|
||||
.rk-players { display: flex; flex-wrap: wrap; gap: 6px; }
|
||||
.rk-player { display: flex; align-items: center; gap: 6px; background: var(--surface); color: var(--text); border: 3px solid transparent;
|
||||
border-radius: 12px; padding: 4px 10px 4px 6px; box-shadow: var(--shadow); font-size: .9rem; min-height: 44px; }
|
||||
.rk-player.turn { border-color: var(--focus); }
|
||||
.rk-player.gone { opacity: .55; }
|
||||
.rk-player .rk-count { font-weight: 800; font-variant-numeric: tabular-nums; }
|
||||
.rk-badge { display: inline-block; border-radius: 999px; padding: 0 8px; font-weight: 800; font-size: .75rem; line-height: 1.6; }
|
||||
.rk-table { flex: 1; min-height: 200px; overflow: auto; background: var(--felt); border-radius: var(--radius-lg); padding: 10px;
|
||||
display: flex; flex-wrap: wrap; align-content: flex-start; align-items: flex-start; gap: 10px 12px; box-shadow: inset 0 2px 10px rgba(0,0,0,.25); }
|
||||
.rk-table-head { flex-basis: 100%; display: flex; justify-content: space-between; align-items: center; gap: 8px; color: #fff; font-weight: 700; font-size: .92rem; text-shadow: 0 1px 2px rgba(0,0,0,.4); }
|
||||
.rk-empty { color: rgba(255,255,255,.85); font-size: .95rem; padding: 6px 2px; }
|
||||
.rk-set { display: flex; flex-wrap: wrap; align-items: center; gap: 3px; padding: 4px; border-radius: 10px; border: 3px solid transparent; background: rgba(0,0,0,.18); max-width: 100%; }
|
||||
.rk-set.ok { border-color: #86efac; }
|
||||
.rk-set.bad { border-color: #fca5a5; border-style: dashed; background: rgba(127,29,29,.35); }
|
||||
.rk-set.changed { animation: rk-flash 1s ease-out 2; }
|
||||
@keyframes rk-flash { 0% { box-shadow: 0 0 0 5px #fde047; } 100% { box-shadow: 0 0 0 0 rgba(253,224,71,0); } }
|
||||
.rk-problem { flex-basis: 100%; font-size: .8rem; font-weight: 700; color: #fff; background: #b91c1c; border-radius: 6px; padding: 1px 6px; }
|
||||
.rk-setinfo { flex-basis: 100%; display: flex; gap: 6px; align-items: center; justify-content: space-between; font-size: .78rem; color: rgba(255,255,255,.9); }
|
||||
.rk-setinfo button { background: rgba(255,255,255,.15); color: #fff; border: 1px solid rgba(255,255,255,.5); border-radius: 6px; min-height: 28px; padding: 0 6px; cursor: pointer; font-size: .78rem; }
|
||||
.rk-tile { --w: 44px; --h: 60px; --tc: #111827; position: relative; width: var(--w); height: var(--h); flex: none; padding: 0; border-radius: 8px;
|
||||
background: linear-gradient(#fffdf7, #f1e7cf); border: 1px solid #c9b98f; box-shadow: 0 2px 0 #b5a172, 0 3px 6px rgba(0,0,0,.25);
|
||||
display: inline-flex; flex-direction: column; align-items: center; justify-content: center; color: var(--tc); font-weight: 900; line-height: 1;
|
||||
font-family: inherit; transition: transform .2s ease, box-shadow .2s ease; user-select: none; }
|
||||
button.rk-tile { cursor: pointer; }
|
||||
button.rk-tile:disabled { cursor: default; }
|
||||
.rk-tile[data-c=K] { --tc: #111827; }
|
||||
.rk-tile[data-c=R] { --tc: #c62828; }
|
||||
.rk-tile[data-c=B] { --tc: #1d4ed8; }
|
||||
.rk-tile[data-c=O] { --tc: #a14a00; }
|
||||
.rk-n { font-size: calc(var(--h) * .44); font-variant-numeric: tabular-nums; letter-spacing: -.02em; }
|
||||
.rk-m { font-size: calc(var(--h) * .2); margin-top: 3px; }
|
||||
.rk-jk { display: block; width: calc(var(--w) * .62); height: calc(var(--w) * .62); }
|
||||
.rk-jkas { position: absolute; right: 2px; top: 1px; font-size: .62rem; font-weight: 800; color: #374151; }
|
||||
.rk-tile[aria-pressed=true] { transform: translateY(-7px); box-shadow: 0 0 0 3px var(--focus), 0 6px 10px rgba(0,0,0,.3); }
|
||||
.rk-tile.hint { box-shadow: 0 0 0 3px #a855f7, 0 3px 6px rgba(0,0,0,.25); }
|
||||
.rk-tile.fresh::after { content: '새'; position: absolute; top: -7px; left: -5px; background: var(--primary); color: var(--primary-text); font-size: .62rem; font-weight: 800; border-radius: 999px; padding: 1px 4px; }
|
||||
.rk-tile.sm { --w: 38px; --h: 52px; }
|
||||
.rk-tile.xs { --w: 28px; --h: 38px; box-shadow: 0 1px 0 #b5a172; }
|
||||
.rk-tile:focus-visible { outline: 3px solid var(--focus); outline-offset: 2px; }
|
||||
.rk-slot { min-width: 30px; height: 52px; border: 2px dashed rgba(255,255,255,.85); border-radius: 8px; background: rgba(255,255,255,.14); color: #fff;
|
||||
font-weight: 900; font-size: 1rem; cursor: pointer; padding: 0 4px; }
|
||||
.rk-slot:hover, .rk-newset:hover { background: rgba(255,255,255,.28); }
|
||||
.rk-newset { border: 2px dashed rgba(255,255,255,.9); color: #fff; background: rgba(255,255,255,.12); border-radius: 10px; min-height: 60px; padding: 0 16px; font-weight: 800; font-size: 1rem; cursor: pointer; }
|
||||
.rk-meld { flex-basis: 100%; display: flex; align-items: center; gap: 8px; color: #fff; font-weight: 700; font-size: .9rem; }
|
||||
.rk-meld progress { flex: 1; height: 12px; accent-color: #facc15; }
|
||||
.rk-bar { display: grid; grid-template-columns: repeat(6, minmax(0, 1fr)); gap: 6px; }
|
||||
.rk-bar .btn { min-height: 52px; padding: 0 6px; font-size: .98rem; }
|
||||
.rk-msg { margin: 0; min-height: 1.4em; text-align: center; font-size: .92rem; }
|
||||
.rk-rackwrap { display: flex; flex-direction: column; gap: 6px; }
|
||||
.rk-rackhead { display: flex; align-items: center; justify-content: space-between; gap: 8px; flex-wrap: wrap; }
|
||||
.rk-rackhead .segmented button { min-height: 40px; }
|
||||
.rk-rack { background: var(--wood); border-radius: var(--radius-lg); padding: 10px 8px 14px; display: flex; flex-wrap: wrap; gap: 5px; min-height: 84px;
|
||||
box-shadow: inset 0 -7px 0 var(--wood-line); align-content: flex-start; }
|
||||
.rk-rack-empty { color: #3b2a0a; font-weight: 700; align-self: center; }
|
||||
.rk-result { display: grid; gap: 6px; }
|
||||
.rk-result-row { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
|
||||
.rk-mini { display: inline-flex; gap: 2px; flex-wrap: wrap; }
|
||||
@media (max-width: 1099px) {
|
||||
.rk-table { overflow: visible; min-height: 160px; }
|
||||
}
|
||||
@media (max-width: 719px) {
|
||||
.rk-bar { position: sticky; bottom: 0; z-index: 5; background: var(--bg); padding: 6px 0; grid-template-columns: repeat(3, minmax(0, 1fr)); }
|
||||
.rk-bar .btn { min-height: 48px; font-size: .95rem; }
|
||||
.rk-tile.sm { --w: 34px; --h: 47px; }
|
||||
.rk-slot { height: 47px; min-width: 28px; }
|
||||
.rk-table { padding: 8px; gap: 8px; }
|
||||
.rk-player { font-size: .85rem; padding: 3px 8px 3px 4px; }
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.rk-tile { transition: none; }
|
||||
.rk-set.changed { animation: none; box-shadow: 0 0 0 3px #fde047; }
|
||||
}
|
||||
`;
|
||||
|
||||
// ---------------------------------------------------------------- tile
|
||||
|
||||
function JokerFace() {
|
||||
// Four coloured quarters + a ring: not a face, not a copy of any commercial joker design.
|
||||
return (
|
||||
<svg className="rk-jk" viewBox="0 0 40 40" aria-hidden>
|
||||
<path d="M20 20 L20 3 A17 17 0 0 1 37 20 Z" fill="#c62828" />
|
||||
<path d="M20 20 L37 20 A17 17 0 0 1 20 37 Z" fill="#1d4ed8" />
|
||||
<path d="M20 20 L20 37 A17 17 0 0 1 3 20 Z" fill="#a14a00" />
|
||||
<path d="M20 20 L3 20 A17 17 0 0 1 20 3 Z" fill="#111827" />
|
||||
<circle cx="20" cy="20" r="8" fill="#fffdf7" />
|
||||
<text x="20" y="24" textAnchor="middle" fontSize="11" fontWeight="900" fill="#111827">J</text>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
interface TileProps {
|
||||
id: string;
|
||||
size?: 'md' | 'sm' | 'xs';
|
||||
as?: JokerAs;
|
||||
selected?: boolean;
|
||||
hint?: boolean;
|
||||
fresh?: boolean;
|
||||
onClick?: () => void;
|
||||
}
|
||||
|
||||
function Tile({ id, size = 'md', as, selected, hint, fresh, onClick }: TileProps) {
|
||||
const t = tileOf(id);
|
||||
const cls = `rk-tile${size !== 'md' ? ` ${size}` : ''}${hint ? ' hint' : ''}${fresh ? ' fresh' : ''}`;
|
||||
const body =
|
||||
t.kind === 'joker' ? (
|
||||
<>
|
||||
<JokerFace />
|
||||
{as && <span className="rk-jkas">{as.n}</span>}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span className="rk-n">{t.n}</span>
|
||||
<span className="rk-m" aria-hidden>
|
||||
{MARK[t.color]}
|
||||
</span>
|
||||
</>
|
||||
);
|
||||
const label = `${tileLabel(id, as)}${fresh ? ', 새로 가져온 타일' : ''}`;
|
||||
const dataC = t.kind === 'num' ? t.color : undefined;
|
||||
if (!onClick) {
|
||||
return (
|
||||
<span className={cls} data-c={dataC} role="img" aria-label={label}>
|
||||
{body}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<button type="button" className={cls} data-c={dataC} aria-pressed={!!selected} aria-label={label} onClick={onClick}>
|
||||
{body}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- draft editing
|
||||
|
||||
/** Keeps a set in a readable order: the server's interpretation when valid, otherwise as placed. */
|
||||
function arrange(d: DraftSet): DraftSet {
|
||||
const n = normalizeSet(d);
|
||||
const tileIds = n ? n.tileIds : d.tileIds;
|
||||
return d.as ? { tileIds, as: d.as } : { tileIds };
|
||||
}
|
||||
|
||||
type Target = { set: number; at: 'start' | 'end' } | 'new' | 'rack';
|
||||
|
||||
/** Moves `sel` to the target; tiles that were on the table at turn start never go back to the rack. */
|
||||
function moveTiles(sets: DraftSet[], sel: string[], target: Target, onTable0: Set<string>): DraftSet[] | null {
|
||||
const moving = target === 'rack' ? sel.filter((id) => !onTable0.has(id)) : sel;
|
||||
if (!moving.length) return null;
|
||||
const ordered = sortTiles(moving, 'number');
|
||||
const next: DraftSet[] = sets.map((d) => ({ ...d, tileIds: d.tileIds.filter((id) => !moving.includes(id)) }));
|
||||
let touched: DraftSet | null = null;
|
||||
if (target === 'new') {
|
||||
touched = { tileIds: ordered };
|
||||
next.push(touched);
|
||||
} else if (target !== 'rack') {
|
||||
touched = next[target.set]!;
|
||||
touched.tileIds = target.at === 'start' ? [...ordered, ...touched.tileIds] : [...touched.tileIds, ...ordered];
|
||||
}
|
||||
return next.filter((d) => d.tileIds.length > 0).map((d) => (d === touched ? arrange(d) : d));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- board
|
||||
|
||||
function RummikubBoard({ view: v, me, players, act, pending, deadline }: BoardProps<RummikubView>) {
|
||||
const my = v.my;
|
||||
const mine = v.phase === 'playing' && !!me && v.current === me;
|
||||
const [sel, setSel] = useState<string[]>([]);
|
||||
const [hintIds, setHintIds] = useState<string[]>([]);
|
||||
const [local, setLocal] = useState<DraftSet[] | null>(null);
|
||||
const [sortBy, setSortBy] = useState<'color' | 'number'>(() => (localStorage.getItem('rk-sort') === 'number' ? 'number' : 'color'));
|
||||
const [msg, setMsg] = useState('');
|
||||
const [confirm, setConfirm] = useState<null | 'draw' | 'commit'>(null);
|
||||
const [announce, setAnnounce] = useState('');
|
||||
const wasMine = useRef(false);
|
||||
const lastLog = useRef<string>('');
|
||||
|
||||
const serverSets: DraftSet[] = useMemo(
|
||||
() => (v.draft ? v.draft.map((d) => (d.as ? { tileIds: d.tileIds, as: d.as } : { tileIds: d.tileIds })) : v.table.map((t) => ({ tileIds: t.tileIds, as: t.kind }))),
|
||||
[v.draft, v.table],
|
||||
);
|
||||
// Optimistic draft until the server answers (cleared when nothing is pending any more).
|
||||
useEffect(() => {
|
||||
if (!pending) setLocal(null);
|
||||
}, [pending, v.turnNo, v.draftEdits]);
|
||||
const sets = mine && local ? local : serverSets;
|
||||
const onTable0 = useMemo(() => new Set(v.table.flatMap((t) => t.tileIds)), [v.table]);
|
||||
const inSets = useMemo(() => new Set(sets.flatMap((d) => d.tileIds)), [sets]);
|
||||
const rack = useMemo(() => sortTiles((my?.rack ?? []).filter((id) => !inSets.has(id)), sortBy), [my?.rack, inSets, sortBy]);
|
||||
const fresh = new Set(my?.lastDrawn ?? []);
|
||||
|
||||
// Turn change: clear selection and hints, announce.
|
||||
useEffect(() => {
|
||||
setSel([]);
|
||||
setHintIds([]);
|
||||
setMsg('');
|
||||
if (mine && !wasMine.current) {
|
||||
haptic(40);
|
||||
sounds.myTurn();
|
||||
setAnnounce('내 차례예요.');
|
||||
}
|
||||
wasMine.current = mine;
|
||||
}, [v.turnNo, mine]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
useEffect(() => {
|
||||
const e = v.log[v.log.length - 1];
|
||||
if (!e) return;
|
||||
const key = `${v.match.gameNo}-${e.turn}-${e.kind}`;
|
||||
if (key === lastLog.current) return;
|
||||
lastLog.current = key;
|
||||
if (e.by !== me) setAnnounce(logText(e, players, me));
|
||||
}, [v.log]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
// Drop selected tiles that disappeared (e.g. after undo on the server).
|
||||
useEffect(() => {
|
||||
setSel((s) => (s.every((id) => inSets.has(id) || rack.includes(id)) ? s : s.filter((id) => inSets.has(id) || rack.includes(id))));
|
||||
}, [inSets, rack]);
|
||||
|
||||
const send = (next: DraftSet[]) => {
|
||||
setLocal(next);
|
||||
act({ type: 'setDraft', sets: next, turn: v.turnNo });
|
||||
};
|
||||
|
||||
const toggle = (id: string) => {
|
||||
if (!mine) return;
|
||||
setHintIds([]);
|
||||
setMsg('');
|
||||
setSel((s) => (s.includes(id) ? s.filter((x) => x !== id) : [...s, id]));
|
||||
};
|
||||
|
||||
const move = (target: Target) => {
|
||||
if (!mine || !sel.length) return;
|
||||
if (!my?.melded && !v.options.initialMeldAllowExtend && sel.some((id) => onTable0.has(id))) {
|
||||
setMsg('첫 등록 전에는 바닥의 타일을 옮길 수 없어요. 내 받침대 타일만 골라 주세요.');
|
||||
return;
|
||||
}
|
||||
if (target === 'rack' && sel.some((id) => onTable0.has(id))) setMsg('바닥에 있던 타일은 받침대로 가져올 수 없어요.');
|
||||
else setMsg('');
|
||||
const next = moveTiles(sets, sel, target, onTable0);
|
||||
if (!next) return;
|
||||
setSel([]);
|
||||
send(next);
|
||||
};
|
||||
|
||||
const toggleKind = (i: number) => {
|
||||
const d = sets[i]!;
|
||||
const cur = v.draft?.[i]?.kind ?? 'run';
|
||||
send(sets.map((x, k) => (k === i ? { tileIds: d.tileIds, as: cur === 'run' ? 'group' : 'run' } : x)));
|
||||
};
|
||||
|
||||
const hint = () => {
|
||||
const free = rack;
|
||||
let found: string[][] = [];
|
||||
if (!my?.melded) found = (findMeld(free, v.options.initialMeldPoints) ?? []).map((m) => m.tileIds);
|
||||
else found = pickDisjoint(free).sets.slice(0, 1).map((m) => m.tileIds);
|
||||
if (!found.length) {
|
||||
setHintIds([]);
|
||||
setMsg(my?.melded ? '받침대 타일만으로 바로 만들 수 있는 묶음이 없어요. 바닥 묶음에 붙여 보거나 한 장 가져오세요.' : `받침대 타일만으로 ${v.options.initialMeldPoints}점을 만들기 어려워요. 한 장 가져오세요.`);
|
||||
return;
|
||||
}
|
||||
setHintIds(found.flat());
|
||||
setSel(found[0]!);
|
||||
setMsg(found.length > 1 ? `보라색 타일로 묶음 ${found.length}개를 만들 수 있어요. 첫 묶음을 골라 두었어요. "새 묶음"을 눌러 보세요.` : '보라색 타일로 묶음을 만들 수 있어요. 골라 두었으니 "새 묶음"을 눌러 보세요.');
|
||||
};
|
||||
|
||||
const commit = () => {
|
||||
if (my?.check && !my.check.ok && v.options.invalidCommitPenalty === 'penalty3' && my.changed && my.check.fromRack > 0) {
|
||||
setConfirm('commit');
|
||||
return;
|
||||
}
|
||||
act({ type: 'commit', turn: v.turnNo });
|
||||
};
|
||||
const draw = () => {
|
||||
if (my?.changed) setConfirm('draw');
|
||||
else act({ type: v.poolCount ? 'draw' : 'pass' });
|
||||
};
|
||||
|
||||
const check = my?.check ?? null;
|
||||
const viewDraft = v.draft;
|
||||
const showingDraft = !!viewDraft;
|
||||
const thinking = v.current && v.current !== me && v.draftEdits > 0;
|
||||
const curName = v.current ? nick(players, v.current) : '';
|
||||
const selFromSets = sel.some((id) => inSets.has(id) && !onTable0.has(id));
|
||||
|
||||
return (
|
||||
<div className="rk-root">
|
||||
<style>{CSS}</style>
|
||||
<Players v={v} me={me} players={players} deadline={deadline} />
|
||||
|
||||
<section className="rk-table" aria-label="바닥">
|
||||
<div className="rk-table-head">
|
||||
<span>
|
||||
{showingDraft && !mine ? `${curName}님이 고민 중인 배치` : mine && my?.changed ? '바닥(확정 전)' : '바닥'}
|
||||
{thinking && !showingDraft ? ` · ${curName}님이 생각 중…` : ''}
|
||||
</span>
|
||||
<span aria-label={`남은 타일 ${v.poolCount}장`}>남은 타일 {v.poolCount}장</span>
|
||||
</div>
|
||||
{mine && my && !my.melded && (
|
||||
<div className="rk-meld">
|
||||
<span>
|
||||
첫 등록 {check?.meldPoints ?? 0} / {v.options.initialMeldPoints}점
|
||||
</span>
|
||||
<progress max={v.options.initialMeldPoints || 1} value={Math.min(check?.meldPoints ?? 0, v.options.initialMeldPoints)} aria-label="첫 등록 점수" />
|
||||
</div>
|
||||
)}
|
||||
{sets.length === 0 && !(mine && sel.length) && <p className="rk-empty">아직 바닥에 놓인 타일이 없어요.</p>}
|
||||
{sets.map((d, i) => {
|
||||
const info = mine || showingDraft ? (local ? null : viewDraft?.[i]) : null;
|
||||
const n = info ? null : normalizeSet(d);
|
||||
const ok = info ? info.ok : !!n;
|
||||
const problem = info ? info.problem : n ? null : setProblem(d);
|
||||
const jokerAs = info ? info.jokerAs : (n?.jokerAs ?? {});
|
||||
const tableSet = !mine && !showingDraft ? v.table[i] : undefined;
|
||||
const changed = !!tableSet && v.lastChanged.includes(tableSet.id);
|
||||
const label = `묶음 ${i + 1}: ${d.tileIds.map((id) => tileLabel(id, jokerAs[id])).join(', ')}${mine || showingDraft ? (ok ? ' (올바름)' : ` (고쳐야 함: ${problem ?? ''})`) : ''}`;
|
||||
return (
|
||||
<div
|
||||
key={tableSet ? `${tableSet.id}-${changed ? v.turnNo : 0}` : `d${i}`}
|
||||
role="group"
|
||||
aria-label={label}
|
||||
className={`rk-set${mine || showingDraft ? (ok ? ' ok' : ' bad') : ''}${changed ? ' changed' : ''}`}
|
||||
>
|
||||
{mine && sel.length > 0 && (
|
||||
<button type="button" className="rk-slot" aria-label={`묶음 ${i + 1} 앞에 놓기`} onClick={() => move({ set: i, at: 'start' })}>
|
||||
◀
|
||||
</button>
|
||||
)}
|
||||
{d.tileIds.map((id) => (
|
||||
<Tile key={id} id={id} size="sm" as={jokerAs[id]} selected={sel.includes(id)} onClick={mine ? () => toggle(id) : undefined} />
|
||||
))}
|
||||
{mine && sel.length > 0 && (
|
||||
<button type="button" className="rk-slot" aria-label={`묶음 ${i + 1} 뒤에 놓기`} onClick={() => move({ set: i, at: 'end' })}>
|
||||
▶
|
||||
</button>
|
||||
)}
|
||||
{(mine || showingDraft) && !ok && problem && <span className="rk-problem">{problem}</span>}
|
||||
{mine && info?.ambiguous && (
|
||||
<span className="rk-setinfo">
|
||||
<span>{info.kind === 'group' ? '같은 숫자 묶음으로 봐요' : '이어진 숫자로 봐요'}</span>
|
||||
<button type="button" onClick={() => toggleKind(i)}>
|
||||
{info.kind === 'group' ? '이어진 숫자로' : '같은 숫자로'}
|
||||
</button>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{mine && sel.length > 0 && (
|
||||
<button type="button" className="rk-newset" onClick={() => move('new')}>
|
||||
+ 새 묶음
|
||||
</button>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{mine && (
|
||||
<>
|
||||
<p className="rk-msg" aria-live="polite" style={{ color: msg || (check && !check.ok && my?.changed) ? 'var(--danger)' : 'var(--text-sub)' }}>
|
||||
{msg ||
|
||||
(sel.length
|
||||
? `${sel.length}장 골랐어요. 놓을 곳(◀ ▶ 또는 새 묶음)을 누르세요.`
|
||||
: my?.changed
|
||||
? check?.ok
|
||||
? '좋아요! 확정을 누르면 차례가 끝나요.'
|
||||
: (check?.reason ?? '')
|
||||
: '타일을 눌러 고른 뒤 놓을 곳을 누르세요.')}
|
||||
</p>
|
||||
<div className="rk-bar" role="group" aria-label="내 차례 행동">
|
||||
<button type="button" className="btn btn-secondary" disabled={!my?.canUndo || pending} onClick={() => act({ type: 'undoDraft' })}>
|
||||
되돌리기
|
||||
</button>
|
||||
<button type="button" className="btn btn-secondary" disabled={!my?.changed || pending} onClick={() => act({ type: 'resetDraft' })}>
|
||||
처음으로
|
||||
</button>
|
||||
{selFromSets ? (
|
||||
<button type="button" className="btn btn-soft" onClick={() => move('rack')}>
|
||||
받침대로
|
||||
</button>
|
||||
) : (
|
||||
<button type="button" className="btn btn-soft" disabled={!v.options.hints} onClick={hint} title={v.options.hints ? '받침대 타일로 바로 낼 수 있는 묶음을 찾아 줘요' : '이 방은 힌트를 쓰지 않아요'}>
|
||||
도와줘
|
||||
</button>
|
||||
)}
|
||||
<button type="button" className="btn btn-soft" disabled={!sel.length} onClick={() => move('new')}>
|
||||
새 묶음
|
||||
</button>
|
||||
<button type="button" className="btn btn-secondary" disabled={pending} onClick={draw}>
|
||||
{v.poolCount ? '한 장 가져오기' : '넘기기'}
|
||||
</button>
|
||||
<button type="button" className={`btn ${check?.ok ? 'btn-primary' : 'btn-secondary'}`} disabled={!my?.changed || pending} onClick={commit} aria-describedby="rk-commit-help">
|
||||
확정
|
||||
</button>
|
||||
</div>
|
||||
<span id="rk-commit-help" className="sr-only">
|
||||
{check?.ok ? '지금 확정할 수 있어요' : (check?.reason ?? '')}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
|
||||
{v.phase !== 'playing' && v.round && <RoundPanel v={v} me={me} players={players} act={act} pending={pending} deadline={deadline} />}
|
||||
|
||||
{my && v.phase === 'playing' && (
|
||||
<div className="rk-rackwrap">
|
||||
<div className="rk-rackhead">
|
||||
<strong>
|
||||
내 받침대 <span className="muted small">({my.rack.length}장{rack.length !== my.rack.length ? `, ${my.rack.length - rack.length}장은 바닥에` : ''})</span>
|
||||
</strong>
|
||||
<div className="segmented" role="group" aria-label="받침대 정렬">
|
||||
{(
|
||||
[
|
||||
['color', '색깔순'],
|
||||
['number', '숫자순'],
|
||||
] as const
|
||||
).map(([k, l]) => (
|
||||
<button
|
||||
key={k}
|
||||
type="button"
|
||||
aria-pressed={sortBy === k}
|
||||
onClick={() => {
|
||||
setSortBy(k);
|
||||
localStorage.setItem('rk-sort', k);
|
||||
}}
|
||||
>
|
||||
{l}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="rk-rack" role="group" aria-label="내 받침대">
|
||||
{rack.length === 0 && <span className="rk-rack-empty">{my.rack.length ? '모두 바닥에 놓았어요.' : '타일이 없어요.'}</span>}
|
||||
{rack.map((id) => (
|
||||
<Tile key={id} id={id} selected={sel.includes(id)} hint={hintIds.includes(id)} fresh={fresh.has(id)} onClick={mine ? () => toggle(id) : undefined} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<p className="sr-only" aria-live="polite">
|
||||
{announce}
|
||||
</p>
|
||||
{confirm === 'draw' && (
|
||||
<ConfirmSheet
|
||||
title="놓은 타일을 되돌리고 한 장 가져올까요?"
|
||||
body="이번 차례에 바꾼 배치는 모두 처음으로 돌아가요."
|
||||
confirm={v.poolCount ? '되돌리고 한 장 가져오기' : '되돌리고 넘기기'}
|
||||
onConfirm={() => act({ type: v.poolCount ? 'draw' : 'pass' })}
|
||||
onClose={() => setConfirm(null)}
|
||||
/>
|
||||
)}
|
||||
{confirm === 'commit' && (
|
||||
<ConfirmSheet
|
||||
title="틀린 묶음이 있어요. 그래도 확정할까요?"
|
||||
body={`${check?.reason ?? ''} 지금 확정하면 놓은 타일이 돌아오고 3장을 가져와요.`}
|
||||
confirm="그래도 확정"
|
||||
danger
|
||||
onConfirm={() => act({ type: 'commit', turn: v.turnNo })}
|
||||
onClose={() => setConfirm(null)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Players({ v, me, players, deadline }: { v: RummikubView; me: string | null; players: Record<string, PublicUser>; deadline: number | null }) {
|
||||
return (
|
||||
<div className="rk-players" role="list" aria-label="참가자">
|
||||
{v.seats.map((s) => {
|
||||
const turn = v.current === s.id;
|
||||
const u = userOf(players, s.id);
|
||||
return (
|
||||
<div
|
||||
key={s.id}
|
||||
role="listitem"
|
||||
className={`rk-player${turn ? ' turn' : ''}${s.left || !s.inRound ? ' gone' : ''}`}
|
||||
aria-label={`${u.nickname}${s.id === me ? '(나)' : ''}, 타일 ${s.tiles}장, ${s.melded ? '첫 등록 함' : '첫 등록 전'}${turn ? ', 지금 차례' : ''}${s.left ? ', 나감' : s.away ? ', 자리 비움' : ''}`}
|
||||
>
|
||||
<Avatar user={u} size={26} />
|
||||
<span style={{ maxWidth: 110, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', fontWeight: s.id === me ? 800 : 600 }}>
|
||||
{u.nickname}
|
||||
{s.id === me && <span className="muted"> (나)</span>}
|
||||
</span>
|
||||
<span className="rk-count" title="받침대 타일 수">
|
||||
{s.tiles}장
|
||||
</span>
|
||||
{!s.melded && s.inRound && (
|
||||
<span className="rk-badge" style={{ background: 'var(--surface-2)', color: 'var(--text-sub)' }}>
|
||||
첫 등록 전
|
||||
</span>
|
||||
)}
|
||||
{s.left && (
|
||||
<span className="rk-badge" style={{ background: 'var(--danger)', color: '#fff' }}>
|
||||
나감
|
||||
</span>
|
||||
)}
|
||||
{!s.left && s.away && (
|
||||
<span className="rk-badge" style={{ background: 'var(--warning)', color: '#fff' }}>
|
||||
자리 비움
|
||||
</span>
|
||||
)}
|
||||
{v.match.total > 1 && <span className="small muted">{s.wins}승</span>}
|
||||
{turn && <TurnTimer deadline={deadline} totalMs={v.turnMs} mine={s.id === me} size={30} />}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function RoundPanel({ v, me, players, act, pending, deadline }: { v: RummikubView; me: string | null; players: Record<string, PublicUser>; act: (a: unknown) => void; pending: boolean; deadline: number | null }) {
|
||||
const r = v.round!;
|
||||
const title =
|
||||
r.endReason === 'rackEmpty'
|
||||
? `${r.winners.map((w) => nick(players, w)).join(', ')}님이 타일을 모두 내려놓았어요! 다 냈다!`
|
||||
: r.endReason === 'stalemate'
|
||||
? '더 낼 수 있는 사람이 없어 끝났어요. 남은 타일 점수가 적은 사람이 이겨요.'
|
||||
: '판이 끝났어요.';
|
||||
const order = r.ranking.flat();
|
||||
return (
|
||||
<div className="card stack rk-result" style={{ padding: 12 }} aria-live="polite">
|
||||
<strong>
|
||||
{v.match.total > 1 ? `${r.gameNo}판 결과 · ` : ''}
|
||||
{title}
|
||||
</strong>
|
||||
{order.map((p) => (
|
||||
<div key={p} className="rk-result-row">
|
||||
<strong style={{ minWidth: 70 }}>
|
||||
{nick(players, p)}
|
||||
{r.winners.includes(p) ? ' 🏆' : ''}
|
||||
</strong>
|
||||
<span style={{ fontWeight: 800, color: r.scores[p]! >= 0 ? 'var(--success)' : 'var(--danger)' }}>
|
||||
{r.scores[p]! > 0 ? '+' : ''}
|
||||
{r.scores[p]}점
|
||||
</span>
|
||||
<span className="small muted">남은 타일 {r.rackValues[p]}점</span>
|
||||
<span className="rk-mini" aria-label={`${nick(players, p)}님의 남은 타일`}>
|
||||
{(r.revealed[p] ?? []).map((id) => (
|
||||
<Tile key={id} id={id} size="xs" />
|
||||
))}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
<span className="small muted">점수는 이 방에서 순위를 정하는 데만 써요.</span>
|
||||
{v.phase === 'roundEnd' && (
|
||||
<div className="row" style={{ justifyContent: 'space-between' }}>
|
||||
{v.my?.needReady ? (
|
||||
<button type="button" className="btn btn-primary" disabled={pending} onClick={() => act({ type: 'ready' })}>
|
||||
다음 판 시작
|
||||
</button>
|
||||
) : (
|
||||
<span className="small muted">{me && v.ready.includes(me) ? '다른 사람을 기다리는 중이에요.' : '다음 판을 기다리는 중이에요.'}</span>
|
||||
)}
|
||||
<TurnTimer deadline={deadline} totalMs={30_000} mine={!!v.my?.needReady} size={36} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function logText(e: LogEntry, players: Record<string, PublicUser>, me: string | null): string {
|
||||
const who = e.by === me ? '내가' : `${nick(players, e.by)}님이`;
|
||||
switch (e.kind) {
|
||||
case 'commit':
|
||||
return `${who} ${e.count}장을 냈어요.`;
|
||||
case 'timeoutCommit':
|
||||
return `시간이 다 되어 ${who} 놓아 둔 ${e.count}장이 확정됐어요.`;
|
||||
case 'draw':
|
||||
return `${who} 1장을 가져왔어요.`;
|
||||
case 'timeoutDraw':
|
||||
return `시간이 다 되어 ${who} ${e.count}장을 가져왔어요.`;
|
||||
case 'penalty':
|
||||
return `${who} 묶음을 다 맞추지 못해 ${e.count}장을 가져왔어요.`;
|
||||
case 'pass':
|
||||
return `${who} 넘겼어요.`;
|
||||
case 'left':
|
||||
return `${nick(players, e.by)}님이 나갔어요.`;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- side
|
||||
|
||||
function RummikubSide({ view: v, me, players }: BoardProps<RummikubView>) {
|
||||
const order = [...v.seats].sort((a, b) => b.wins - a.wins || b.total - a.total);
|
||||
const lastRound = v.opening.length ? Math.max(...v.opening.map((o) => o.round)) : 0;
|
||||
return (
|
||||
<>
|
||||
<style>{CSS}</style>
|
||||
<div className="card stack" style={{ gap: 6, padding: 12 }}>
|
||||
<div className="row" style={{ justifyContent: 'space-between' }}>
|
||||
<strong>{v.phase === 'finished' ? '게임 끝' : v.match.total > 1 ? `${v.match.gameNo}판 / ${v.match.total}판` : '숫자 타일'}</strong>
|
||||
<span className="small muted">남은 타일 {v.poolCount}장</span>
|
||||
</div>
|
||||
<ol style={{ listStyle: 'none', margin: 0, padding: 0, display: 'grid', gap: 4 }}>
|
||||
{order.map((s) => (
|
||||
<li key={s.id} className="row" style={{ gap: 6, flexWrap: 'nowrap', opacity: s.left ? 0.55 : 1 }}>
|
||||
<Avatar user={userOf(players, s.id)} size={24} />
|
||||
<span style={{ flex: 1, minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', fontWeight: s.id === me ? 800 : 600 }}>
|
||||
{nick(players, s.id)}
|
||||
{s.left ? ' · 나감' : ''}
|
||||
</span>
|
||||
<span className="small">{s.tiles}장</span>
|
||||
{v.match.total > 1 && (
|
||||
<span className="small muted">
|
||||
{s.wins}승 · {s.total > 0 ? '+' : ''}
|
||||
{s.total}점
|
||||
</span>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
{lastRound > 0 && v.match.gameNo >= 1 && (
|
||||
<span className="small muted">
|
||||
선 정하기:{' '}
|
||||
{v.opening
|
||||
.filter((o) => o.round === lastRound)
|
||||
.map((o) => `${nick(players, o.player)} ${o.n === null ? '조커(다시)' : o.n}`)
|
||||
.join(' · ')}
|
||||
{lastRound > 1 ? ` (${lastRound}번째 뽑기)` : ''}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{v.log.length > 0 && (
|
||||
<details className="card" open style={{ padding: 12 }}>
|
||||
<summary style={{ cursor: 'pointer', fontWeight: 700 }}>기록</summary>
|
||||
<ol style={{ listStyle: 'none', margin: '8px 0 0', padding: 0, display: 'grid', gap: 2, maxHeight: 180, overflow: 'auto' }}>
|
||||
{[...v.log].reverse().map((e, i) => (
|
||||
<li key={`${e.turn}-${i}`} className="small">
|
||||
{logText(e, players, me)}
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
</details>
|
||||
)}
|
||||
<details className="card wide-open" style={{ padding: 12 }}>
|
||||
<summary style={{ cursor: 'pointer', fontWeight: 700 }}>규칙 요약</summary>
|
||||
<Rules options={v.options} />
|
||||
</details>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- status
|
||||
|
||||
function isMyTurn(v: RummikubView, me: string | null): boolean {
|
||||
if (!me) return false;
|
||||
if (v.phase === 'roundEnd') return !!v.my?.needReady;
|
||||
return v.phase === 'playing' && v.current === me;
|
||||
}
|
||||
|
||||
function statusText(v: RummikubView, me: string | null, players: Record<string, PublicUser>): string {
|
||||
if (v.phase === 'finished') return v.result?.summary ?? '게임이 끝났어요';
|
||||
if (v.phase === 'roundEnd') return v.my?.needReady ? '판이 끝났어요. 다음 판을 시작해 주세요' : '다음 판을 기다리는 중이에요';
|
||||
if (v.current === me && v.my) {
|
||||
if (!v.my.melded) return `내 차례예요 · 첫 등록 ${v.my.check?.meldPoints ?? 0}/${v.options.initialMeldPoints}점`;
|
||||
return '내 차례예요. 타일을 내거나 한 장 가져오세요';
|
||||
}
|
||||
if (!v.current) return '잠시만 기다려 주세요';
|
||||
return `${nick(players, v.current)}님 차례예요${v.draftEdits > 0 ? ' · 생각 중' : ''}`;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- options
|
||||
|
||||
function Seg<T extends string | number | boolean>({ value, options, onChange, label }: { value: T; options: [T, string][]; onChange: (v: T) => void; label: string }) {
|
||||
return (
|
||||
<div className="segmented" role="group" aria-label={label}>
|
||||
{options.map(([k, l]) => (
|
||||
<button key={String(k)} type="button" aria-pressed={value === k} onClick={() => onChange(k)}>
|
||||
{l}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Field({ label, help, children }: { label: string; help?: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="stack" style={{ gap: 6 }}>
|
||||
<strong>{label}</strong>
|
||||
{children}
|
||||
{help && <span className="small muted">{help}</span>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Options({ value: o, onChange }: OptionsProps<RummikubOptions>) {
|
||||
const [more, setMore] = useState(false);
|
||||
const set = (patch: Partial<RummikubOptions>) => onChange({ ...o, ...patch });
|
||||
return (
|
||||
<div className="stack" style={{ gap: 16 }}>
|
||||
<Field label="첫 등록 최소 점수" help="처음 낼 때는 받침대 타일만으로 만든 묶음의 숫자 합이 이 점수 이상이어야 해요.">
|
||||
<Seg label="첫 등록 최소 점수" value={o.initialMeldPoints} options={[[0, '없음'], [20, '20점'], [30, '30점(공식)'], [50, '50점']]} onChange={(initialMeldPoints) => set({ initialMeldPoints })} />
|
||||
</Field>
|
||||
<Field label="한 차례 제한 시간" help="어르신이나 아이와 할 때는 넉넉하게 골라 주세요.">
|
||||
<Seg label="한 차례 제한 시간" value={o.turnSeconds} options={[[60, '1분'], [90, '1분 30초'], [120, '2분'], [180, '3분']]} onChange={(turnSeconds) => set({ turnSeconds })} />
|
||||
</Field>
|
||||
<Field label="판 수">
|
||||
<Seg label="판 수" value={o.gamesPerMatch} options={[['1', '1판'], ['players', '인원수만큼'], ['2', '2판'], ['3', '3판'], ['4', '4판']]} onChange={(gamesPerMatch) => set({ gamesPerMatch })} />
|
||||
</Field>
|
||||
<Field label='"도와줘" 힌트 버튼'>
|
||||
<Seg label="힌트 버튼" value={o.hints} options={[[true, '켜기'], [false, '끄기']]} onChange={(hints) => set({ hints })} />
|
||||
</Field>
|
||||
<button type="button" className="btn btn-ghost" onClick={() => setMore(!more)} aria-expanded={more}>
|
||||
{more ? '▲ 자세한 설정 닫기' : '▼ 자세한 설정'}
|
||||
</button>
|
||||
{more && (
|
||||
<>
|
||||
<Field label="시간이 다 됐을 때 올바르게 놓아 둔 타일">
|
||||
<Seg label="시간 초과 때 올바른 배치" value={o.timeoutValidDraft} options={[['commit', '자동으로 확정'], ['revert', '되돌리고 한 장 가져오기(공식)']]} onChange={(timeoutValidDraft) => set({ timeoutValidDraft })} />
|
||||
</Field>
|
||||
<Field label="시간이 다 됐는데 덜 맞췄을 때 가져올 장수">
|
||||
<Seg label="시간 초과 벌칙" value={o.invalidTimeoutPenalty} options={[[1, '1장'], [3, '3장(공식)']]} onChange={(invalidTimeoutPenalty) => set({ invalidTimeoutPenalty })} />
|
||||
</Field>
|
||||
<Field label="틀린 채로 확정을 누르면">
|
||||
<Seg label="틀린 확정" value={o.invalidCommitPenalty} options={[['reject', '알려 주고 계속 고치기'], ['penalty3', '되돌리고 3장 가져오기']]} onChange={(invalidCommitPenalty) => set({ invalidCommitPenalty })} />
|
||||
</Field>
|
||||
<Field label="받침대에 남은 조커 감점">
|
||||
<Seg label="조커 감점" value={o.jokerPenalty} options={[[30, '30점(공식)'], [50, '50점(대회)']]} onChange={(jokerPenalty) => set({ jokerPenalty })} />
|
||||
</Field>
|
||||
<Field label="첫 등록하는 차례에 바닥 묶음에도 붙이기" help="켜면 첫 등록 점수를 새 묶음으로 채운 뒤, 같은 차례에 바닥 묶음을 고칠 수 있어요.">
|
||||
<Seg label="첫 등록 차례 재배치" value={o.initialMeldAllowExtend} options={[[false, '안 됨(공식)'], [true, '허용']]} onChange={(initialMeldAllowExtend) => set({ initialMeldAllowExtend })} />
|
||||
</Field>
|
||||
<Field label="고민 중인 배치를 다른 사람에게 보이기" help="켜면 바닥에 올려 본 타일이 바로 보여요. 다시 되돌려도 이미 본 타일은 기억에 남아요.">
|
||||
<Seg label="고민 중인 배치 공개" value={o.showDraftToOthers} options={[[false, '안 보이기'], [true, '보이기']]} onChange={(showDraftToOthers) => set({ showDraftToOthers })} />
|
||||
</Field>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function optionsSummary(o: RummikubOptions): string {
|
||||
const games = o.gamesPerMatch === 'players' ? '인원수만큼' : `${o.gamesPerMatch}판`;
|
||||
const sec = o.turnSeconds % 60 ? `${Math.floor(o.turnSeconds / 60)}분 ${o.turnSeconds % 60}초` : `${o.turnSeconds / 60}분`;
|
||||
return [o.initialMeldPoints ? `첫 등록 ${o.initialMeldPoints}점` : '첫 등록 점수 없음', `한 차례 ${sec}`, games, o.jokerPenalty === 50 ? '조커 50점' : '', o.showDraftToOthers ? '배치 공개' : '']
|
||||
.filter(Boolean)
|
||||
.join(' · ');
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- rules
|
||||
|
||||
function Example({ ids }: { ids: string[] }) {
|
||||
const n = normalizeSet({ tileIds: ids });
|
||||
return (
|
||||
<span className="rk-mini" style={{ verticalAlign: 'middle', margin: '2px 0' }}>
|
||||
{ids.map((id) => (
|
||||
<Tile key={id} id={id} size="xs" as={n?.jokerAs[id]} />
|
||||
))}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function Rules({ options }: { options?: RummikubOptions }) {
|
||||
const o = options;
|
||||
const meld = o?.initialMeldPoints ?? 30;
|
||||
return (
|
||||
<div className="stack">
|
||||
<style>{CSS}</style>
|
||||
<p>
|
||||
1부터 13까지 숫자가 적힌 4가지 색 타일(색마다 모양 표시 ● ♥ ◆ ★)이 두 벌씩 104장, 조커 2장이 있어요. 각자 14장을 받고, 내 받침대의 타일을 가장 먼저 모두 내려놓으면 이겨요.
|
||||
</p>
|
||||
<p>
|
||||
<strong>묶음 두 가지</strong>
|
||||
</p>
|
||||
<ul style={{ margin: 0, paddingLeft: 20 }}>
|
||||
<li>
|
||||
같은 숫자, 다른 색 3~4장 <Example ids={['K7a', 'R7a', 'B7a']} />
|
||||
</li>
|
||||
<li>
|
||||
같은 색, 이어지는 숫자 3장 이상 <Example ids={['B3a', 'B4a', 'B5a', 'B6a']} /> (13 다음에 1은 올 수 없어요)
|
||||
</li>
|
||||
<li>
|
||||
조커는 아무 타일이나 대신해요 <Example ids={['O9a', 'O10a', 'JK1']} />
|
||||
</li>
|
||||
</ul>
|
||||
<p>
|
||||
<strong>내 차례</strong>: 타일을 1장 이상 내고 "확정"을 누르거나, "한 장 가져오기"를 눌러요. 타일을 누른 뒤 놓을 곳(◀ 앞, ▶ 뒤, 새 묶음)을 누르면 옮겨져요. 확정할 때 바닥의 모든 묶음이 올바라야 해요.
|
||||
</p>
|
||||
<p>
|
||||
<strong>첫 등록</strong>:{' '}
|
||||
{meld ? `처음 낼 때는 받침대 타일만으로 만든 새 묶음의 숫자 합이 ${meld}점 이상이어야 해요. 조커는 대신하는 숫자로 세요.` : '이 방은 첫 등록 점수가 없어요.'}{' '}
|
||||
{o?.initialMeldAllowExtend ? '첫 등록 차례에도 점수를 채운 뒤에는 바닥 묶음을 고칠 수 있어요.' : '첫 등록 전에는 바닥 묶음을 건드릴 수 없어요.'}
|
||||
</p>
|
||||
<p>
|
||||
<strong>바닥 고치기</strong>: 첫 등록 뒤에는 바닥의 묶음을 쪼개고 합치고 옮겨서 내 타일을 끼워 넣을 수 있어요. 예를 들어 빨강 4-5-6-7-8 사이에 내 빨강 6을 넣어 4-5-6과 6-7-8로 나눌 수 있어요. 바닥 타일은 받침대로 가져올 수 없고, 조커를 빼 왔다면 같은 차례에 다시 바닥에 써야 해요.
|
||||
</p>
|
||||
<p>
|
||||
<strong>시간</strong>: 한 차례는 {o ? `${o.turnSeconds}초` : '1분'}예요. 시간이 다 되면{' '}
|
||||
{o?.timeoutValidDraft === 'revert' ? '놓아 둔 타일을 되돌리고 1장을 가져와요' : '올바르게 놓아 둔 타일은 자동으로 확정돼요'}. 묶음이 틀린 채로 끝나면 처음으로 되돌리고 {o?.invalidTimeoutPenalty ?? 3}장을 가져와요.
|
||||
</p>
|
||||
<p>
|
||||
<strong>점수</strong>: 누가 다 내려놓으면 나머지는 받침대 숫자 합만큼 빼고(조커 {o?.jokerPenalty ?? 30}점), 이긴 사람이 그만큼 더해요. 남은 타일이 없는데 아무도 못 내면 받침대 합이 가장 적은 사람이 이겨요. 점수는 이 방의 순위에만 써요.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const ui: GameUI<RummikubView, RummikubOptions> = {
|
||||
Board: RummikubBoard,
|
||||
Side: RummikubSide,
|
||||
statusText,
|
||||
isMyTurn,
|
||||
Options,
|
||||
optionsSummary,
|
||||
Rules,
|
||||
};
|
||||
|
||||
export default ui;
|
||||
@@ -8,7 +8,8 @@ export const GAME_ICON: Record<string, string> = {
|
||||
mahjong: '🀇',
|
||||
yut: '🪵',
|
||||
othello: '◐',
|
||||
rummikub: '🔢',
|
||||
};
|
||||
|
||||
/** Shown on the home screen so users know what's coming (docs/12-roadmap.md). */
|
||||
export const UPCOMING = ['윷놀이', '원카드', '숫자 타일', '다빈치 코드', '요트 다이스', '마작', '고스톱', '뱅', '할리갈리'];
|
||||
export const UPCOMING = ['윷놀이', '원카드', '다빈치 코드', '요트 다이스', '마작', '고스톱', '뱅', '할리갈리'];
|
||||
|
||||
@@ -19,6 +19,8 @@ mkdirSync(SHOTS, { recursive: true });
|
||||
interface Plan {
|
||||
/** Total players including the host (default 2). Extra players join on phones. */
|
||||
players?: number;
|
||||
/** Host changes room options before starting (inline settings form on PC). */
|
||||
lobby?: (host: Page) => Promise<void>;
|
||||
/** Steps run before normal moves (e.g. janggi formation choice). */
|
||||
setup?: (host: Page, guest: Page) => Promise<void>;
|
||||
/** Board games: key sequences on the grid, in turn order. */
|
||||
@@ -53,7 +55,86 @@ async function finishHand(pages: Page[]) {
|
||||
for (const p of pages) await p.getByText('이번 판 결과').first().waitFor({ timeout: 10_000 });
|
||||
}
|
||||
|
||||
/** 숫자 타일: whoever has the turn tries "도와줘" → "새 묶음" → "확정"; otherwise takes one tile. */
|
||||
async function rummikubTurns(pages: Page[], turns: number) {
|
||||
const stats = { plays: 0, draws: 0, undo: false };
|
||||
const myTurn = (p: Page) => p.evaluate(() => !!document.querySelector('.turn-status')?.textContent?.includes('내 차례예요'));
|
||||
const bar = (p: Page) => p.getByRole('group', { name: '내 차례 행동' });
|
||||
/** Waits until a bar button is enabled / disabled (server answered). */
|
||||
const barButton = (p: Page, name: string, enabled: boolean) =>
|
||||
p.waitForFunction(
|
||||
([n, e]) => [...document.querySelectorAll('.rk-bar button')].some((b) => b.textContent === n && b.hasAttribute('disabled') !== e),
|
||||
[name, enabled] as const,
|
||||
{ timeout: 5000 },
|
||||
);
|
||||
for (let n = 0; n < turns; n++) {
|
||||
let mover: Page | null = null;
|
||||
for (let t = 0; t < 150 && !mover; t++) {
|
||||
for (const p of pages) if (!mover && (await myTurn(p))) mover = p;
|
||||
if (!mover) await pages[0]!.waitForTimeout(100);
|
||||
}
|
||||
if (!mover) throw new Error(`nobody has the turn (turn ${n + 1})`);
|
||||
await bar(mover).waitFor();
|
||||
let played = false;
|
||||
await bar(mover).getByRole('button', { name: '도와줘' }).click();
|
||||
const picked = await mover.locator('.rk-rack button[aria-pressed="true"]').count();
|
||||
if (picked > 0) {
|
||||
// Tap one picked tile twice (deselect, select again) — the tap-to-select path.
|
||||
const first = mover.locator('.rk-rack button[aria-pressed="true"]').first();
|
||||
const label = await first.getAttribute('aria-label');
|
||||
await first.click();
|
||||
await mover.locator(`.rk-rack button[aria-label="${label}"]`).first().click();
|
||||
await bar(mover).getByRole('button', { name: '새 묶음' }).click();
|
||||
await mover.locator('.rk-set').last().waitFor();
|
||||
if (!stats.undo) {
|
||||
// Undo once (back to the turn start), then place again.
|
||||
await barButton(mover, '되돌리기', true);
|
||||
await bar(mover).getByRole('button', { name: '되돌리기' }).click();
|
||||
await barButton(mover, '처음으로', false);
|
||||
stats.undo = true;
|
||||
await bar(mover).getByRole('button', { name: '도와줘' }).click();
|
||||
await bar(mover).getByRole('button', { name: '새 묶음' }).click();
|
||||
}
|
||||
const commit = bar(mover).getByRole('button', { name: '확정' });
|
||||
await mover
|
||||
.waitForFunction(() => [...document.querySelectorAll('.rk-bar button')].some((b) => b.textContent === '확정' && b.classList.contains('btn-primary') && !b.hasAttribute('disabled')), null, { timeout: 5000 })
|
||||
.then(
|
||||
() => (played = true),
|
||||
() => {},
|
||||
);
|
||||
if (played) {
|
||||
await commit.click();
|
||||
stats.plays++;
|
||||
} else {
|
||||
await bar(mover).getByRole('button', { name: '처음으로' }).click();
|
||||
await mover.waitForTimeout(300);
|
||||
}
|
||||
}
|
||||
if (!played) {
|
||||
await bar(mover).getByRole('button', { name: /^(한 장 가져오기|넘기기)$/ }).click();
|
||||
const sheet = mover.getByRole('button', { name: /^되돌리고/ });
|
||||
if (await sheet.count()) await sheet.click();
|
||||
stats.draws++;
|
||||
}
|
||||
await mover.waitForFunction(() => !document.querySelector('.turn-status')?.textContent?.includes('내 차례예요'), null, { timeout: 5000 });
|
||||
}
|
||||
console.log(` rummikub: ${stats.plays} plays, ${stats.draws} draws, undo ${stats.undo ? 'used' : 'not used'}`);
|
||||
return stats;
|
||||
}
|
||||
|
||||
const PLANS: Record<string, Plan> = {
|
||||
rummikub: {
|
||||
// PC host + phone guest, first meld "없음" so hint plays happen early; 10 real turns through the UI.
|
||||
lobby: async (host) => {
|
||||
await host.getByRole('group', { name: '첫 등록 최소 점수' }).getByRole('button', { name: '없음' }).click();
|
||||
await host.getByRole('button', { name: '설정 저장' }).click();
|
||||
await host.getByText('첫 등록 점수 없음').first().waitFor();
|
||||
},
|
||||
drive: async (pages) => {
|
||||
const stats = await rummikubTurns(pages, 10);
|
||||
if (stats.plays + stats.draws < 10) throw new Error('not enough turns');
|
||||
},
|
||||
},
|
||||
seotda: {
|
||||
// 3 players, default 2장 섯다 (1장씩 두 번): everyone checks both betting rounds → showdown.
|
||||
players: 3,
|
||||
@@ -161,6 +242,7 @@ for (const id of games) {
|
||||
await g.getByRole('button', { name: '이 이름으로 입장하기' }).click();
|
||||
await host.getByLabel('자리').getByText(nick, { exact: true }).waitFor();
|
||||
}
|
||||
if (plan.lobby) await plan.lobby(host);
|
||||
await host.screenshot({ path: join(SHOTS, `${id}-0-lobby.png`) });
|
||||
await host.getByRole('button', { name: '▶ 게임 시작' }).click();
|
||||
await host.getByRole('button', { name: '시작하기' }).click();
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
"type": "module",
|
||||
"exports": {
|
||||
".": "./src/index.ts",
|
||||
"./rummikub/rules": "./src/rummikub/rules.ts",
|
||||
"./*": "./src/*/index.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
|
||||
@@ -6,6 +6,7 @@ import { janggi } from './janggi';
|
||||
import { baduk } from './baduk';
|
||||
import { seotda } from './seotda';
|
||||
import { poker } from './poker';
|
||||
import { rummikub } from './rummikub';
|
||||
|
||||
export const GAMES: Record<string, AnyGameDefinition> = {
|
||||
[omok.id]: omok,
|
||||
@@ -15,4 +16,5 @@ export const GAMES: Record<string, AnyGameDefinition> = {
|
||||
[baduk.id]: baduk,
|
||||
[seotda.id]: seotda,
|
||||
[poker.id]: poker,
|
||||
[rummikub.id]: rummikub,
|
||||
};
|
||||
|
||||
832
packages/games/src/rummikub/index.ts
Normal file
832
packages/games/src/rummikub/index.ts
Normal 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);
|
||||
}
|
||||
279
packages/games/src/rummikub/rules.ts
Normal file
279
packages/games/src/rummikub/rules.ts
Normal 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;
|
||||
}
|
||||
752
packages/games/src/rummikub/rummikub.test.ts
Normal file
752
packages/games/src/rummikub/rummikub.test.ts
Normal 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);
|
||||
}
|
||||
});
|
||||
@@ -18,6 +18,7 @@ export const CATALOG: CatalogEntry[] = [
|
||||
{ id: 'baduk', nameKo: '바둑', minPlayers: 2, maxPlayers: 2, minutes: '15~60', level: '어려움', blurb: '집을 더 많이 지은 쪽이 승리', available: true },
|
||||
{ id: 'seotda', nameKo: '섯다', minPlayers: 2, maxPlayers: 10, minutes: '20~40', level: '보통', blurb: '화투 두 장으로 겨루는 족보 승부 · 놀이용 칩', available: true },
|
||||
{ id: 'poker', nameKo: '포커', minPlayers: 2, maxPlayers: 10, minutes: '20~60', level: '보통', blurb: '홀덤·7포커·인디언 등 7가지 모드 · 놀이용 칩', 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);
|
||||
|
||||
Reference in New Issue
Block a user