diff --git a/apps/web/src/games/registry.ts b/apps/web/src/games/registry.ts index 67eb707..7e57481 100644 --- a/apps/web/src/games/registry.ts +++ b/apps/web/src/games/registry.ts @@ -9,6 +9,7 @@ export const GAME_UI: Record Promise<{ default: GameUI }>> = { baduk: () => import('./baduk'), seotda: () => import('./seotda'), poker: () => import('./poker'), + rummikub: () => import('./rummikub'), }; const cache = new Map(); diff --git a/apps/web/src/games/rummikub/index.tsx b/apps/web/src/games/rummikub/index.tsx new file mode 100644 index 0000000..82e88c9 --- /dev/null +++ b/apps/web/src/games/rummikub/index.tsx @@ -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, id: string) => players[id]?.nickname ?? '알 수 없음'; +const userOf = (players: Record, 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 = { 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 ( + + + + + + + J + + ); +} + +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' ? ( + <> + + {as && {as.n}} + + ) : ( + <> + {t.n} + + {MARK[t.color]} + + + ); + const label = `${tileLabel(id, as)}${fresh ? ', 새로 가져온 타일' : ''}`; + const dataC = t.kind === 'num' ? t.color : undefined; + if (!onClick) { + return ( + + {body} + + ); + } + return ( + + ); +} + +// ---------------------------------------------------------------- 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): 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) { + const my = v.my; + const mine = v.phase === 'playing' && !!me && v.current === me; + const [sel, setSel] = useState([]); + const [hintIds, setHintIds] = useState([]); + const [local, setLocal] = useState(null); + const [sortBy, setSortBy] = useState<'color' | 'number'>(() => (localStorage.getItem('rk-sort') === 'number' ? 'number' : 'color')); + const [msg, setMsg] = useState(''); + const [confirm, setConfirm] = useState(null); + const [announce, setAnnounce] = useState(''); + const wasMine = useRef(false); + const lastLog = useRef(''); + + 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 ( +
+ + + +
+
+ + {showingDraft && !mine ? `${curName}님이 고민 중인 배치` : mine && my?.changed ? '바닥(확정 전)' : '바닥'} + {thinking && !showingDraft ? ` · ${curName}님이 생각 중…` : ''} + + 남은 타일 {v.poolCount}장 +
+ {mine && my && !my.melded && ( +
+ + 첫 등록 {check?.meldPoints ?? 0} / {v.options.initialMeldPoints}점 + + +
+ )} + {sets.length === 0 && !(mine && sel.length) &&

아직 바닥에 놓인 타일이 없어요.

} + {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 ( +
+ {mine && sel.length > 0 && ( + + )} + {d.tileIds.map((id) => ( + toggle(id) : undefined} /> + ))} + {mine && sel.length > 0 && ( + + )} + {(mine || showingDraft) && !ok && problem && {problem}} + {mine && info?.ambiguous && ( + + {info.kind === 'group' ? '같은 숫자 묶음으로 봐요' : '이어진 숫자로 봐요'} + + + )} +
+ ); + })} + {mine && sel.length > 0 && ( + + )} +
+ + {mine && ( + <> +

+ {msg || + (sel.length + ? `${sel.length}장 골랐어요. 놓을 곳(◀ ▶ 또는 새 묶음)을 누르세요.` + : my?.changed + ? check?.ok + ? '좋아요! 확정을 누르면 차례가 끝나요.' + : (check?.reason ?? '') + : '타일을 눌러 고른 뒤 놓을 곳을 누르세요.')} +

+
+ + + {selFromSets ? ( + + ) : ( + + )} + + + +
+ + {check?.ok ? '지금 확정할 수 있어요' : (check?.reason ?? '')} + + + )} + + {v.phase !== 'playing' && v.round && } + + {my && v.phase === 'playing' && ( +
+
+ + 내 받침대 ({my.rack.length}장{rack.length !== my.rack.length ? `, ${my.rack.length - rack.length}장은 바닥에` : ''}) + +
+ {( + [ + ['color', '색깔순'], + ['number', '숫자순'], + ] as const + ).map(([k, l]) => ( + + ))} +
+
+
+ {rack.length === 0 && {my.rack.length ? '모두 바닥에 놓았어요.' : '타일이 없어요.'}} + {rack.map((id) => ( + toggle(id) : undefined} /> + ))} +
+
+ )} + +

+ {announce} +

+ {confirm === 'draw' && ( + act({ type: v.poolCount ? 'draw' : 'pass' })} + onClose={() => setConfirm(null)} + /> + )} + {confirm === 'commit' && ( + act({ type: 'commit', turn: v.turnNo })} + onClose={() => setConfirm(null)} + /> + )} +
+ ); +} + +function Players({ v, me, players, deadline }: { v: RummikubView; me: string | null; players: Record; deadline: number | null }) { + return ( +
+ {v.seats.map((s) => { + const turn = v.current === s.id; + const u = userOf(players, s.id); + return ( +
+ + + {u.nickname} + {s.id === me && (나)} + + + {s.tiles}장 + + {!s.melded && s.inRound && ( + + 첫 등록 전 + + )} + {s.left && ( + + 나감 + + )} + {!s.left && s.away && ( + + 자리 비움 + + )} + {v.match.total > 1 && {s.wins}승} + {turn && } +
+ ); + })} +
+ ); +} + +function RoundPanel({ v, me, players, act, pending, deadline }: { v: RummikubView; me: string | null; players: Record; 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 ( +
+ + {v.match.total > 1 ? `${r.gameNo}판 결과 · ` : ''} + {title} + + {order.map((p) => ( +
+ + {nick(players, p)} + {r.winners.includes(p) ? ' 🏆' : ''} + + = 0 ? 'var(--success)' : 'var(--danger)' }}> + {r.scores[p]! > 0 ? '+' : ''} + {r.scores[p]}점 + + 남은 타일 {r.rackValues[p]}점 + + {(r.revealed[p] ?? []).map((id) => ( + + ))} + +
+ ))} + 점수는 이 방에서 순위를 정하는 데만 써요. + {v.phase === 'roundEnd' && ( +
+ {v.my?.needReady ? ( + + ) : ( + {me && v.ready.includes(me) ? '다른 사람을 기다리는 중이에요.' : '다음 판을 기다리는 중이에요.'} + )} + +
+ )} +
+ ); +} + +function logText(e: LogEntry, players: Record, 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) { + 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 ( + <> + +
+
+ {v.phase === 'finished' ? '게임 끝' : v.match.total > 1 ? `${v.match.gameNo}판 / ${v.match.total}판` : '숫자 타일'} + 남은 타일 {v.poolCount}장 +
+
    + {order.map((s) => ( +
  1. + + + {nick(players, s.id)} + {s.left ? ' · 나감' : ''} + + {s.tiles}장 + {v.match.total > 1 && ( + + {s.wins}승 · {s.total > 0 ? '+' : ''} + {s.total}점 + + )} +
  2. + ))} +
+ {lastRound > 0 && v.match.gameNo >= 1 && ( + + 선 정하기:{' '} + {v.opening + .filter((o) => o.round === lastRound) + .map((o) => `${nick(players, o.player)} ${o.n === null ? '조커(다시)' : o.n}`) + .join(' · ')} + {lastRound > 1 ? ` (${lastRound}번째 뽑기)` : ''} + + )} +
+ {v.log.length > 0 && ( +
+ 기록 +
    + {[...v.log].reverse().map((e, i) => ( +
  1. + {logText(e, players, me)} +
  2. + ))} +
+
+ )} +
+ 규칙 요약 + +
+ + ); +} + +// ---------------------------------------------------------------- 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 { + 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({ value, options, onChange, label }: { value: T; options: [T, string][]; onChange: (v: T) => void; label: string }) { + return ( +
+ {options.map(([k, l]) => ( + + ))} +
+ ); +} + +function Field({ label, help, children }: { label: string; help?: string; children: React.ReactNode }) { + return ( +
+ {label} + {children} + {help && {help}} +
+ ); +} + +function Options({ value: o, onChange }: OptionsProps) { + const [more, setMore] = useState(false); + const set = (patch: Partial) => onChange({ ...o, ...patch }); + return ( +
+ + set({ initialMeldPoints })} /> + + + set({ turnSeconds })} /> + + + set({ gamesPerMatch })} /> + + + set({ hints })} /> + + + {more && ( + <> + + set({ timeoutValidDraft })} /> + + + set({ invalidTimeoutPenalty })} /> + + + set({ invalidCommitPenalty })} /> + + + set({ jokerPenalty })} /> + + + set({ initialMeldAllowExtend })} /> + + + set({ showDraftToOthers })} /> + + + )} +
+ ); +} + +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 ( + + {ids.map((id) => ( + + ))} + + ); +} + +function Rules({ options }: { options?: RummikubOptions }) { + const o = options; + const meld = o?.initialMeldPoints ?? 30; + return ( +
+ +

+ 1부터 13까지 숫자가 적힌 4가지 색 타일(색마다 모양 표시 ● ♥ ◆ ★)이 두 벌씩 104장, 조커 2장이 있어요. 각자 14장을 받고, 내 받침대의 타일을 가장 먼저 모두 내려놓으면 이겨요. +

+

+ 묶음 두 가지 +

+
    +
  • + 같은 숫자, 다른 색 3~4장 +
  • +
  • + 같은 색, 이어지는 숫자 3장 이상 (13 다음에 1은 올 수 없어요) +
  • +
  • + 조커는 아무 타일이나 대신해요 +
  • +
+

+ 내 차례: 타일을 1장 이상 내고 "확정"을 누르거나, "한 장 가져오기"를 눌러요. 타일을 누른 뒤 놓을 곳(◀ 앞, ▶ 뒤, 새 묶음)을 누르면 옮겨져요. 확정할 때 바닥의 모든 묶음이 올바라야 해요. +

+

+ 첫 등록:{' '} + {meld ? `처음 낼 때는 받침대 타일만으로 만든 새 묶음의 숫자 합이 ${meld}점 이상이어야 해요. 조커는 대신하는 숫자로 세요.` : '이 방은 첫 등록 점수가 없어요.'}{' '} + {o?.initialMeldAllowExtend ? '첫 등록 차례에도 점수를 채운 뒤에는 바닥 묶음을 고칠 수 있어요.' : '첫 등록 전에는 바닥 묶음을 건드릴 수 없어요.'} +

+

+ 바닥 고치기: 첫 등록 뒤에는 바닥의 묶음을 쪼개고 합치고 옮겨서 내 타일을 끼워 넣을 수 있어요. 예를 들어 빨강 4-5-6-7-8 사이에 내 빨강 6을 넣어 4-5-6과 6-7-8로 나눌 수 있어요. 바닥 타일은 받침대로 가져올 수 없고, 조커를 빼 왔다면 같은 차례에 다시 바닥에 써야 해요. +

+

+ 시간: 한 차례는 {o ? `${o.turnSeconds}초` : '1분'}예요. 시간이 다 되면{' '} + {o?.timeoutValidDraft === 'revert' ? '놓아 둔 타일을 되돌리고 1장을 가져와요' : '올바르게 놓아 둔 타일은 자동으로 확정돼요'}. 묶음이 틀린 채로 끝나면 처음으로 되돌리고 {o?.invalidTimeoutPenalty ?? 3}장을 가져와요. +

+

+ 점수: 누가 다 내려놓으면 나머지는 받침대 숫자 합만큼 빼고(조커 {o?.jokerPenalty ?? 30}점), 이긴 사람이 그만큼 더해요. 남은 타일이 없는데 아무도 못 내면 받침대 합이 가장 적은 사람이 이겨요. 점수는 이 방의 순위에만 써요. +

+
+ ); +} + +const ui: GameUI = { + Board: RummikubBoard, + Side: RummikubSide, + statusText, + isMyTurn, + Options, + optionsSummary, + Rules, +}; + +export default ui; diff --git a/apps/web/src/lib/catalog-ui.ts b/apps/web/src/lib/catalog-ui.ts index 6d4e91a..b3312f4 100644 --- a/apps/web/src/lib/catalog-ui.ts +++ b/apps/web/src/lib/catalog-ui.ts @@ -8,7 +8,8 @@ export const GAME_ICON: Record = { 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 = ['윷놀이', '원카드', '다빈치 코드', '요트 다이스', '마작', '고스톱', '뱅', '할리갈리']; diff --git a/e2e/games.e2e.ts b/e2e/games.e2e.ts index 8d6700d..7439b80 100644 --- a/e2e/games.e2e.ts +++ b/e2e/games.e2e.ts @@ -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; /** Steps run before normal moves (e.g. janggi formation choice). */ setup?: (host: Page, guest: Page) => Promise; /** 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 = { + 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(); diff --git a/packages/games/package.json b/packages/games/package.json index 350023d..26e5133 100644 --- a/packages/games/package.json +++ b/packages/games/package.json @@ -5,6 +5,7 @@ "type": "module", "exports": { ".": "./src/index.ts", + "./rummikub/rules": "./src/rummikub/rules.ts", "./*": "./src/*/index.ts" }, "dependencies": { diff --git a/packages/games/src/index.ts b/packages/games/src/index.ts index 6afc773..aa5453e 100644 --- a/packages/games/src/index.ts +++ b/packages/games/src/index.ts @@ -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 = { [omok.id]: omok, @@ -15,4 +16,5 @@ export const GAMES: Record = { [baduk.id]: baduk, [seotda.id]: seotda, [poker.id]: poker, + [rummikub.id]: rummikub, }; diff --git a/packages/games/src/rummikub/index.ts b/packages/games/src/rummikub/index.ts new file mode 100644 index 0000000..6e2f1b0 --- /dev/null +++ b/packages/games/src/rummikub/index.ts @@ -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 = (...vals: T[]) => + z.union(vals.map((v) => z.literal(v)) as unknown as [z.ZodLiteral, z.ZodLiteral, ...z.ZodLiteral[]]); + +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; + +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 | { 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; +} + +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; + rackValues: Record; + /** Racks revealed for counting at the end of the round (public from then on). */ + revealed: Record; + /** 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; + table: TableSet[]; + nextSetId: number; + hasMelded: Record; + current: PlayerId; + turnNo: number; + turnStartedAt: number; + draft: { sets: DraftSet[]; history: DraftSet[][]; edits: number }; + timeoutsInRow: Record; + consecutivePasses: number; + /** Tiles each player drew at the end of their last turn (shown only to them). */ + lastDrawn: Record; + /** 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; totals: Record }; + final: { ranking: PlayerId[][]; scores: Record; 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; + 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 => 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(); + 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(); + 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, 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[][] { + 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, + players: PlayerId[], + reason: 'rackEmpty' | 'stalemate', + jokerPenalty: number, + eligible: PlayerId[] = players, +): { winners: PlayerId[]; scores: Record; rackValues: Record } { + const rackValues: Record = {}; + for (const p of players) rackValues[p] = rackValue(racks[p] ?? [], jokerPenalty); + const scores: Record = {}; + 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 = {}; + 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 = {}; + 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(); + 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 = { + id: 'rummikub', + nameKo: '숫자 타일', + minPlayers: 2, + maxPlayers: 4, + stateVersion: 1, + defaultOptions: rummikubOptionsSchema.parse({}), + optionsSchema: rummikubOptionsSchema, + actionSchema: rummikubActionSchema as z.ZodType, + + 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 }[] = [...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); +} diff --git a/packages/games/src/rummikub/rules.ts b/packages/games/src/rummikub/rules.ts new file mode 100644 index 0000000..67019f7 --- /dev/null +++ b/packages/games/src/rummikub/rules.ts @@ -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; + +export const COLORS: TileColor[] = ['K', 'R', 'B', 'O']; +export const COLOR_KO: Record = { K: '검정', R: '빨강', B: '파랑', O: '주황' }; + +/** 106 ids: `R7a`, `R7b`, ... and `JK1`, `JK2` (§3). */ +export const ALL_TILE_IDS: string[] = (() => { + const out: string[] = []; + for (const c of COLORS) for (let n = 1; n <= 13; n++) for (const copy of ['a', 'b']) out.push(`${c}${n}${copy}`); + out.push('JK1', 'JK2'); + return out; +})(); + +const TILES = new Map( + ALL_TILE_IDS.map((id): [string, Tile] => + id.startsWith('JK') ? [id, { id, kind: 'joker' }] : [id, { id, kind: 'num', color: id[0] as TileColor, n: Number(id.slice(1, -1)) }], + ), +); + +export const isTileId = (id: string): boolean => TILES.has(id); +export function tileOf(id: string): Tile { + const t = TILES.get(id); + if (!t) throw new Error(`unknown tile ${id}`); + return t; +} +export const isJoker = (id: string): boolean => id === 'JK1' || id === 'JK2'; + +export interface DraftSet { + tileIds: string[]; + /** Hint for sets that could be read both ways, e.g. [빨강5, 조커, 조커] (§5.5). */ + as?: 'run' | 'group'; +} + +export interface JokerAs { + /** null = one of the missing colours of a group (not fixed). */ + color: TileColor | null; + n: number; +} + +export interface NormalSet { + /** Display order (runs ascending). */ + tileIds: string[]; + kind: 'run' | 'group'; + jokerAs: Record; + points: number; +} + +function asGroup(ts: Tile[]): NormalSet | null { + if (ts.length < 3 || ts.length > 4) return null; + const nums = ts.filter((t): t is NumTile => t.kind === 'num'); + if (nums.length === 0) return null; + const n = nums[0]!.n; + if (nums.some((t) => t.n !== n)) return null; + if (new Set(nums.map((t) => t.color)).size !== nums.length) return null; + const jokerAs: Record = {}; + for (const t of ts) if (t.kind === 'joker') jokerAs[t.id] = { color: null, n }; + // Numbers first in colour order, jokers last. + const order = [...nums].sort((a, b) => COLORS.indexOf(a.color) - COLORS.indexOf(b.color)).map((t) => t.id); + for (const t of ts) if (t.kind === 'joker') order.push(t.id); + return { tileIds: order, kind: 'group', jokerAs, points: n * ts.length }; +} + +function runFrom(ts: Tile[], start: number, color: TileColor): NormalSet { + const jokerAs: Record = {}; + let points = 0; + ts.forEach((t, k) => { + points += start + k; + if (t.kind === 'joker') jokerAs[t.id] = { color, n: start + k }; + }); + return { tileIds: ts.map((t) => t.id), kind: 'run', jokerAs, points }; +} + +function asRun(ts: Tile[]): NormalSet | null { + const len = ts.length; + if (len < 3 || len > 13) return null; + const nums = ts.filter((t): t is NumTile => t.kind === 'num'); + if (nums.length === 0) return null; + const color = nums[0]!.color; + if (nums.some((t) => t.color !== color)) return null; + // (a) the order the client sent, if it is a valid run as it stands. + const first = ts.findIndex((t) => t.kind === 'num'); + const start = nums[0]!.n - first; + if (start >= 1 && start + len - 1 <= 13 && ts.every((t, k) => t.kind === 'joker' || t.n === start + k)) return runFrom(ts, start, color); + // (b) sort, fill gaps with jokers, extra jokers go up (towards 13) first, then down. + const sorted = [...nums].sort((a, b) => a.n - b.n); + for (let i = 1; i < sorted.length; i++) if (sorted[i]!.n === sorted[i - 1]!.n) return null; + const min = sorted[0]!.n; + const max = sorted[sorted.length - 1]!.n; + const jokers = ts.filter((t) => t.kind === 'joker'); + const gaps = max - min + 1 - sorted.length; + if (gaps > jokers.length) return null; + const extra = jokers.length - gaps; + const up = Math.min(extra, 13 - max); + const down = extra - up; + if (min - down < 1) return null; + const lo = min - down; + const hi = max + up; + const out: Tile[] = []; + let j = 0; + let k = 0; + for (let n = lo; n <= hi; n++) { + if (sorted[k]?.n === n) out.push(sorted[k++]!); + else out.push(jokers[j++]!); + } + return runFrom(out, lo, color); +} + +/** Validates one set and decides what its jokers stand for (§5.5, §8.2). null = not a valid set. */ +export function normalizeSet(d: DraftSet): NormalSet | null { + const ids = d.tileIds; + if (ids.length < 3 || ids.length > 13) return null; + const ts: Tile[] = []; + for (const id of ids) { + const t = TILES.get(id); + if (!t || ts.includes(t)) return null; + ts.push(t); + } + const g = asGroup(ts); + const r = asRun(ts); + if (g && r) return d.as === 'group' ? g : r; + return g ?? r; +} + +/** Short Korean reason why a set is not valid (null when valid) — live red/green feedback (§9). */ +export function setProblem(d: DraftSet): string | null { + const ids = d.tileIds; + if (ids.length === 0) return null; + if (ids.length < 3) return '3장 이상이어야 해요'; + if (normalizeSet(d)) return null; + const ts = ids.filter(isTileId).map(tileOf); + const nums = ts.filter((t): t is NumTile => t.kind === 'num'); + if (nums.length === 0) return '숫자 타일이 1장 이상 있어야 해요'; + const sameN = nums.every((t) => t.n === nums[0]!.n); + const sameC = nums.every((t) => t.color === nums[0]!.color); + if (sameN && nums.length > 1) { + if (new Set(nums.map((t) => t.color)).size !== nums.length) return '같은 색이 두 개예요'; + if (ids.length > 4) return '같은 숫자 묶음은 4장까지예요'; + } + if (sameC) { + if (new Set(nums.map((t) => t.n)).size !== nums.length) return '같은 숫자가 두 번 있어요'; + if (ids.length > 13) return '13장보다 길 수 없어요'; + if (nums.some((t) => t.n === 13) && nums.some((t) => t.n === 1)) return '13 다음에 1은 올 수 없어요'; + return '숫자가 이어지지 않아요'; + } + return '같은 색 연속 숫자나, 같은 숫자 다른 색이어야 해요'; +} + +/** Score of a tile left on the rack at the end (§6). */ +export const tileValue = (id: string, jokerPenalty: number): number => { + const t = tileOf(id); + return t.kind === 'joker' ? jokerPenalty : t.n; +}; +export const rackValue = (ids: readonly string[], jokerPenalty: number): number => ids.reduce((a, id) => a + tileValue(id, jokerPenalty), 0); + +/** Order-insensitive key of an arrangement (each set as a tile set). */ +export function arrangementKey(sets: readonly { tileIds: readonly string[] }[]): string { + return sets + .filter((s) => s.tileIds.length > 0) + .map((s) => [...s.tileIds].sort().join(',')) + .sort() + .join('|'); +} + +/** Rack sort orders (§9): by colour then number, or number then colour; jokers last. */ +export function sortTiles(ids: readonly string[], by: 'color' | 'number'): string[] { + const key = (id: string): number => { + const t = tileOf(id); + if (t.kind === 'joker') return 10_000 + Number(id.slice(2)); + const c = COLORS.indexOf(t.color); + return by === 'color' ? c * 100 + t.n * 2 + (id.endsWith('b') ? 1 : 0) : t.n * 10 + c + (id.endsWith('b') ? 0.5 : 0); + }; + return [...ids].sort((a, b) => key(a) - key(b)); +} + +/** Korean label of a tile for screen readers, e.g. "빨강 7", "조커". */ +export function tileLabel(id: string, as?: JokerAs): string { + const t = tileOf(id); + if (t.kind === 'joker') return as ? `조커(${as.color ? `${COLOR_KO[as.color]} ` : ''}${as.n})` : '조커'; + return `${COLOR_KO[t.color]} ${t.n}`; +} + +/** + * Candidate sets made only from rack tiles (not disjoint): groups of 3–4 colours, maximal runs, and a few + * joker completions. No table rearrangement search (§9 hint: bounded work). + */ +export function findRackSets(rack: readonly string[]): NormalSet[] { + const nums = rack.map(tileOf).filter((t): t is NumTile => t.kind === 'num'); + const jokers = rack.filter(isJoker); + const out: NormalSet[] = []; + const push = (ids: string[]) => { + const n = normalizeSet({ tileIds: ids }); + if (n) out.push(n); + }; + // Groups + for (let n = 1; n <= 13; n++) { + const byColor = new Map(); + for (const t of nums) if (t.n === n && !byColor.has(t.color)) byColor.set(t.color, t.id); + const ids = [...byColor.values()]; + if (ids.length >= 3) push(ids); + else if (ids.length === 2 && jokers.length > 0) push([...ids, jokers[0]!]); + } + // Runs: from every start that has no tile right below it, extend upwards, filling holes with jokers. + for (const c of COLORS) { + const at = new Map(); + for (const t of nums) if (t.color === c && !at.has(t.n)) at.set(t.n, t.id); + for (const [start] of [...at].sort((a, b) => a[0] - b[0])) { + if (at.has(start - 1)) continue; + const ids: string[] = []; + let jUsed = 0; + let best: string[] | null = null; + for (let n = start; n <= 13; n++) { + const id = at.get(n); + if (id) ids.push(id); + else if (jUsed < jokers.length) ids.push(jokers[jUsed++]!); + else break; + if (ids.length >= 3 && !isJoker(ids[ids.length - 1]!)) best = [...ids]; + } + if (best) push(best); + else if (jokers.length > 0 && at.has(start + 1) && start + 2 <= 13) push([at.get(start)!, at.get(start + 1)!, jokers[0]!]); + } + } + return out; +} + +/** Disjoint rack-only sets, greedy by points; stops as soon as `minPoints` is reached. */ +export function pickDisjoint(rack: readonly string[], minPoints = Infinity): { sets: NormalSet[]; points: number } { + const cands = findRackSets(rack).sort((a, b) => b.points - a.points); + const used = new Set(); + const sets: NormalSet[] = []; + let points = 0; + for (const c of cands) { + if (c.tileIds.some((id) => used.has(id))) continue; + sets.push(c); + for (const id of c.tileIds) used.add(id); + points += c.points; + if (points >= minPoints) break; + } + return { sets, points }; +} + +/** Disjoint rack-only sets reaching `minPoints` (first meld), or null. */ +export function findMeld(rack: readonly string[], minPoints: number): NormalSet[] | null { + const r = pickDisjoint(rack, minPoints); + return r.sets.length && r.points >= minPoints ? r.sets : null; +} + +/** Adds one tile to a valid set at the matching end (run) or as a missing colour (group); null if it does not fit. */ +export function extendSet(set: Pick, id: string): NormalSet | null { + const t = tileOf(id); + const ids = set.tileIds; + const ts = ids.map(tileOf); + let cand: string[] | null = null; + if (set.kind === 'group') { + if (ids.length >= 4) return null; + if (t.kind === 'num') { + const n = ts.find((x): x is NumTile => x.kind === 'num')?.n; + if (n !== t.n || ts.some((x) => x.kind === 'num' && x.color === t.color)) return null; + } + cand = [...ids, id]; + } else { + if (ids.length >= 13) return null; + const val = (x: Tile) => (x.kind === 'num' ? x.n : (set.jokerAs[x.id]?.n ?? 0)); + const lo = val(ts[0]!); + const hi = val(ts[ts.length - 1]!); + if (t.kind === 'joker') cand = hi < 13 ? [...ids, id] : lo > 1 ? [id, ...ids] : null; + else if (!ts.some((x) => x.kind === 'num' && x.color === t.color)) return null; + else if (t.n === hi + 1) cand = [...ids, id]; + else if (t.n === lo - 1) cand = [id, ...ids]; + } + return cand ? normalizeSet({ tileIds: cand, as: set.kind }) : null; +} diff --git a/packages/games/src/rummikub/rummikub.test.ts b/packages/games/src/rummikub/rummikub.test.ts new file mode 100644 index 0000000..372d8a6 --- /dev/null +++ b/packages/games/src/rummikub/rummikub.test.ts @@ -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 = {}, 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; 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 = {}, 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 = { 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 }).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[] = [ + {}, + { 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(); + 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 = {}; + 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); + } +}); diff --git a/packages/shared/src/catalog.ts b/packages/shared/src/catalog.ts index e91dbf2..dc1c5c2 100644 --- a/packages/shared/src/catalog.ts +++ b/packages/shared/src/catalog.ts @@ -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);