M4: 요트 다이스 규칙·화면·테스트

- 규칙 모듈(packages/games/src/yacht): 12칸(기본)·13칸 변형, 풀 하우스 요트 인정,
  추가 요트 보너스와 강제/자유 조커, 상단 보너스, 굴림 3회·고정 검증, 기권,
  시간 초과 시 (안 굴렸으면 굴린 뒤) 최고 점수 칸 자동 기록·희생 순서, 1~8명(혼자 가능)
- 점수 계산은 scoreFor 순수 함수로 분리, view에 빈 칸 미리보기·허용 칸 포함
- 테스트: 문서 10절 시나리오 전부, 시드 1,000개 무작위 대국(점수·합계 일관성,
  onTimeout 합법, 결정성), 기권·시간 초과 섞은 200개 대국
- 화면: SVG 주사위(눌러서 고정, 굴림 애니메이션·움직임 줄이기 존중), 남은 굴림 표시,
  전원 점수표(내 열 강조, 미리보기, 0점 확인), 상단 보너스 진행 막대, 기권
- 등록: GAMES, 카탈로그, 웹 레지스트리, 아이콘(준비 중 목록에서 제거)
- 결과 화면: 1인 게임은 무승부/승리로 표시하지 않음
- E2E: PC 방장 + 휴대폰 친구가 12라운드 전체를 화면으로 진행

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
EJClaw
2026-10-05 04:17:15 +09:00
parent 5dd8e6ea07
commit 41ab2cf187
10 changed files with 1991 additions and 4 deletions

View File

@@ -9,6 +9,7 @@ export const GAME_UI: Record<string, () => Promise<{ default: GameUI }>> = {
baduk: () => import('./baduk'),
seotda: () => import('./seotda'),
poker: () => import('./poker'),
yacht: () => import('./yacht'),
};
const cache = new Map<string, GameUI>();

View File

@@ -0,0 +1,667 @@
/** 요트 다이스 화면 (docs/games/yacht.md §9). */
import { useEffect, useLayoutEffect, useRef, useState } from 'react';
import type { Category, ScoreEntry, YachtOptions, YachtSheetView, YachtView } from '@bg/games/yacht';
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';
// The client draws from the view only; names and hints live here so the rules module isn't bundled.
const NAME: Record<Category, string> = {
ones: '에이스',
twos: '듀스',
threes: '트레이',
fours: '포',
fives: '파이브',
sixes: '식스',
choice: '초이스',
threeKind: '쓰리 카인드',
fourKind: '포 카인드',
fullHouse: '풀 하우스',
smallStraight: '스몰 스트레이트',
largeStraight: '라지 스트레이트',
yacht: '요트',
};
const UPPER: Category[] = ['ones', 'twos', 'threes', 'fours', 'fives', 'sixes'];
const catName = (c: Category, scoring: YachtOptions['scoring']) => (c === 'choice' && scoring === 'yahtzee' ? '찬스' : NAME[c]);
const ROLL_MS = 600;
const nick = (players: Record<string, PublicUser>, id: string) => players[id]?.nickname ?? '알 수 없음';
const userOf = (players: Record<string, PublicUser>, id: string): PublicUser => players[id] ?? { id, nickname: '알 수 없음', avatar: null, kind: 'guest' };
const reducedMotion = () => typeof matchMedia !== 'undefined' && matchMedia('(prefers-reduced-motion: reduce)').matches;
const CSS = `
.yt-wrap { width: 100%; max-width: 1080px; display: flex; flex-direction: column; gap: 12px; }
.yt-tray { display: flex; flex-direction: column; gap: 12px; align-items: stretch; }
.yt-head { display: flex; align-items: center; gap: 8px; justify-content: space-between; flex-wrap: wrap; }
.yt-dice { display: flex; gap: 8px; justify-content: center; padding: 6px 0 18px; }
.yt-die { --s: 64px; width: var(--s); height: var(--s); padding: 0; border-radius: 14px; border: 3px solid #c9c4b8; background: #fffdf8;
box-shadow: 0 3px 0 #b7b1a3, 0 4px 10px rgba(0,0,0,.15); cursor: pointer; position: relative; transition: transform .15s ease, border-color .15s; flex: none; }
.yt-die svg { display: block; width: 100%; height: 100%; }
.yt-die:disabled { cursor: default; }
.yt-die.held { transform: translateY(14px); border-color: var(--primary); box-shadow: 0 0 0 3px var(--primary-soft), 0 3px 8px rgba(0,0,0,.15); }
.yt-die.blank { background: var(--surface-2); border-style: dashed; box-shadow: none; }
.yt-die:focus-visible { outline: 3px solid var(--focus); outline-offset: 3px; }
.yt-lock { position: absolute; left: 50%; bottom: -12px; transform: translateX(-50%); background: var(--primary); color: var(--primary-text);
font-size: .72rem; font-weight: 800; border-radius: 999px; padding: 1px 7px; white-space: nowrap; line-height: 1.4; }
@keyframes yt-tumble { 0% { transform: translateY(-18px) rotate(-30deg) scale(.9); } 40% { transform: translateY(4px) rotate(200deg); }
70% { transform: translateY(-6px) rotate(330deg); } 100% { transform: translateY(0) rotate(360deg); } }
.yt-die.rolling { animation: yt-tumble ${ROLL_MS}ms ease-out both; }
@keyframes yt-pop { 0% { transform: translate(-50%, -50%) scale(.4); opacity: 0; } 60% { transform: translate(-50%, -50%) scale(1.15); opacity: 1; } 100% { transform: translate(-50%, -50%) scale(1); } }
.yt-flash { position: fixed; left: 50%; top: 40%; transform: translate(-50%, -50%); z-index: 30; pointer-events: none; font-size: 4rem; font-weight: 900;
color: #fde047; text-shadow: 0 4px 12px rgba(0,0,0,.55), 0 0 2px #854d0e; animation: yt-pop .5s ease-out both; white-space: nowrap; }
.yt-roll { min-height: 64px; font-size: 1.2rem; }
.yt-sheet { padding: 0; overflow: auto; }
.yt-table { border-collapse: separate; border-spacing: 0; width: 100%; font-variant-numeric: tabular-nums; }
.yt-table th, .yt-table td { height: 38px; padding: 0 6px; border-bottom: 1px solid var(--border); text-align: center; white-space: nowrap; }
.yt-table thead th { position: sticky; top: 0; background: var(--surface); z-index: 2; height: 46px; font-size: .9rem; }
.yt-table th.yt-cat { position: sticky; left: 0; z-index: 1; background: var(--surface); text-align: left; font-weight: 700; padding-left: 12px; min-width: 112px; }
.yt-table thead th.yt-cat { z-index: 3; }
.yt-table td { min-width: 56px; }
.yt-table td.mine { background: var(--primary-soft); min-width: 72px; font-size: 1.08rem; }
.yt-table th.turn { box-shadow: inset 0 -4px 0 var(--focus); }
.yt-table tr.yt-sum th, .yt-table tr.yt-sum td { background: var(--surface-2); font-weight: 700; font-size: .92rem; }
.yt-table tr.yt-sum td.mine { background: var(--primary-soft); }
.yt-table tr.yt-total th, .yt-table tr.yt-total td { font-weight: 900; font-size: 1.1rem; border-bottom: 0; height: 44px; }
.yt-filled { font-weight: 800; }
.yt-ghost { color: var(--text-sub); opacity: .75; font-weight: 600; }
.yt-cell { width: 100%; height: 34px; border-radius: 8px; border: 2px dashed var(--border); background: var(--surface); color: var(--text-sub);
font: inherit; font-weight: 700; cursor: pointer; }
.yt-cell.good { border-style: solid; border-color: var(--success); color: var(--success); background: var(--success-soft); }
.yt-cell.zero { opacity: .6; }
.yt-cell:hover:not(:disabled) { border-color: var(--primary); color: var(--text); }
.yt-cell:disabled { cursor: not-allowed; opacity: .35; }
.yt-cell:focus-visible { outline: 3px solid var(--focus); outline-offset: 1px; }
@keyframes yt-new { from { background: #fde68a; } to { background: transparent; } }
.yt-new { animation: yt-new 1.6s ease-out; border-radius: 6px; }
.yt-bar { height: 8px; border-radius: 4px; background: var(--surface-2); overflow: hidden; }
.yt-bar > span { display: block; height: 100%; background: var(--success); transition: width .3s; }
.yt-pname { display: inline-block; max-width: 72px; overflow: hidden; text-overflow: ellipsis; vertical-align: bottom; }
@media (min-width: 1100px) {
.yt-wrap { display: grid; grid-template-columns: 380px minmax(0, 1fr); align-items: start; max-height: 100%; }
.yt-die { --s: 64px; }
.yt-sheet { max-height: calc(100vh - 64px - 52px - 40px); }
.yt-table th, .yt-table td { height: 34px; }
.yt-cell { height: 30px; }
}
@media (prefers-reduced-motion: reduce) {
.yt-die, .yt-die.held { transition: none; }
.yt-die.rolling, .yt-flash, .yt-new { animation: none; }
}
`;
// ---------------------------------------------------------------- dice
const PIPS: Record<number, [number, number][]> = {
1: [[50, 50]],
2: [[28, 28], [72, 72]],
3: [[26, 26], [50, 50], [74, 74]],
4: [[28, 28], [72, 28], [28, 72], [72, 72]],
5: [[26, 26], [74, 26], [50, 50], [26, 74], [74, 74]],
6: [[28, 24], [72, 24], [28, 50], [72, 50], [28, 76], [72, 76]],
};
function DieFace({ value }: { value: number | null }) {
return (
<svg viewBox="0 0 100 100" aria-hidden>
{value !== null &&
PIPS[value]!.map(([x, y], i) => <circle key={i} cx={x} cy={y} r={value === 1 ? 13 : 10} fill={value === 1 ? '#c81e1e' : '#1c1e21'} />)}
</svg>
);
}
/** Small static die for the rules / last-score line. */
function MiniDie({ value, size = 26 }: { value: number; size?: number }) {
return (
<span style={{ display: 'inline-block', width: size, height: size, borderRadius: size / 4.5, border: '2px solid #c9c4b8', background: '#fffdf8', verticalAlign: 'middle' }}>
<DieFace value={value} />
</span>
);
}
function DiceRow({ dice, size }: { dice: number[]; size?: number }) {
return (
<span style={{ display: 'inline-flex', gap: 3 }} aria-label={`주사위 ${dice.join(', ')}`} role="img">
{dice.map((d, i) => (
<MiniDie key={i} value={d} size={size} />
))}
</span>
);
}
// ---------------------------------------------------------------- board
interface RollAnim {
key: number;
mask: boolean[];
}
function YachtBoard({ view: v, me, players, act, pending, tapConfirm }: BoardProps<YachtView>) {
const myTurn = v.phase === 'playing' && !!me && v.current === me;
const [held, setHeld] = useState<boolean[]>(v.held);
const [anim, setAnim] = useState<RollAnim | null>(null);
const [flick, setFlick] = useState<number[] | null>(null);
const [flash, setFlash] = useState<string | null>(null);
const [announce, setAnnounce] = useState('');
const [confirm, setConfirm] = useState<{ category: Category; points: number } | null>(null);
const prevRoll = useRef(v.rollSeq);
const prevTurn = useRef(v.turnSeq);
const pointer = useRef<string>('');
// Server holds win once our own pending action is answered (optimistic taps in between).
useEffect(() => {
if (!pending) setHeld(v.held);
}, [v.held, v.turnSeq, v.rollSeq, pending]);
// Roll animation, yacht flash and screen-reader text when a new roll arrives.
useLayoutEffect(() => {
if (v.rollSeq <= prevRoll.current || !v.dice) {
prevRoll.current = v.rollSeq;
return;
}
prevRoll.current = v.rollSeq;
const who = v.current === me ? '내가' : `${nick(players, v.current ?? '')}님이`;
setAnnounce(`${who} 굴렸어요: ${v.dice.join(', ')}`);
if (v.dice.every((d) => d === v.dice![0])) {
setFlash('요트!');
haptic([80, 40, 80]);
}
if (reducedMotion()) {
sounds.stone();
return;
}
const mask = v.rollsUsed === 1 ? [true, true, true, true, true] : v.held.map((h) => !h);
setAnim({ key: v.rollSeq, mask });
}, [v.rollSeq]); // eslint-disable-line react-hooks/exhaustive-deps
useEffect(() => {
if (!anim) return;
const t = setInterval(() => setFlick([0, 1, 2, 3, 4].map(() => 1 + Math.floor(Math.random() * 6))), 70);
const end = setTimeout(() => {
setAnim(null);
setFlick(null);
sounds.stone();
}, ROLL_MS);
return () => {
clearInterval(t);
clearTimeout(end);
};
}, [anim]);
useEffect(() => {
if (!flash) return;
const t = setTimeout(() => setFlash(null), 1500);
return () => clearTimeout(t);
}, [flash]);
// Score announcement + my-turn cue on turn changes.
useEffect(() => {
if (v.turnSeq === prevTurn.current) return;
prevTurn.current = v.turnSeq;
setConfirm(null);
const ls = v.lastScore;
if (ls) {
const who = ls.player === me ? '내가' : `${nick(players, ls.player)}님이`;
setAnnounce(`${who} ${catName(ls.category, v.options.scoring)}에 ${ls.points}점을 기록했어요${ls.bonus ? ` (요트 보너스 +${ls.bonus})` : ''}${ls.auto ? ' (시간 초과로 자동 기록)' : ''}.`);
}
if (v.phase === 'playing' && v.current === me) {
haptic(40);
if (document.visibilityState === 'hidden') sounds.myTurn();
}
}, [v.turnSeq]); // eslint-disable-line react-hooks/exhaustive-deps
const canRoll = myTurn && !pending && v.rollsUsed < 3 && !(v.rollsUsed > 0 && held.every(Boolean));
const canHold = myTurn && v.rollsUsed > 0 && v.rollsUsed < 3;
const toggle = (i: number) => {
if (!canHold) return;
const next = held.map((h, j) => (j === i ? !h : h));
setHeld(next);
act({ type: 'hold', held: next });
};
const roll = () => {
if (!canRoll) return;
act({ type: 'roll' });
};
const score = (category: Category, points: number, viaMouse: boolean) => {
if (!myTurn || pending) return;
if (points === 0 || (tapConfirm && !viaMouse)) setConfirm({ category, points });
else act({ type: 'score', category });
};
const shownDice = v.dice ?? null;
const rollLabel =
v.rollsUsed >= 3 ? '점수 칸을 골라 주세요' : v.rollsUsed > 0 && held.every(Boolean) ? '모두 고정했어요' : `🎲 굴리기 (남은 ${v.rollsLeft}번)`;
const curName = v.current ? (v.current === me ? '내' : `${nick(players, v.current)}님`) : '';
return (
<div className="yt-wrap">
<style>{CSS}</style>
<section className="card yt-tray" aria-label="주사위">
<div className="yt-head">
<strong>
{v.phase === 'finished' ? '게임 끝' : `${v.round} / ${v.totalRounds} 라운드`}
</strong>
{v.current && (
<span className="row small" style={{ gap: 6, flexWrap: 'nowrap', minWidth: 0 }}>
<Avatar user={userOf(players, v.current)} size={24} />
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{curName} 차례</span>
<span className="badge" aria-label={`굴린 횟수 ${v.rollsUsed}번`}>
{'●'.repeat(v.rollsUsed)}
{'○'.repeat(3 - v.rollsUsed)}
</span>
</span>
)}
</div>
<div className="yt-dice" role="group" aria-label={canHold ? '주사위. 누르면 고정하거나 풀어요' : '주사위'}>
{[0, 1, 2, 3, 4].map((i) => {
const value = shownDice ? (anim?.mask[i] && flick ? flick[i]! : shownDice[i]!) : null;
const isHeld = !!shownDice && v.rollsUsed > 0 && held[i]!;
const rolling = !!anim && anim.mask[i];
return (
<button
key={`${i}-${rolling ? anim!.key : 's'}`}
type="button"
className={`yt-die${isHeld ? ' held' : ''}${shownDice ? '' : ' blank'}${rolling ? ' rolling' : ''}`}
aria-pressed={canHold ? isHeld : undefined}
aria-label={shownDice ? `주사위 ${i + 1}: ${shownDice[i]}${isHeld ? ', 고정됨' : ''}` : `주사위 ${i + 1}: 아직 안 굴림`}
disabled={!canHold}
onClick={() => toggle(i)}
>
<DieFace value={value} />
{isHeld && <span className="yt-lock">🔒 고정</span>}
</button>
);
})}
</div>
{myTurn ? (
<button className="btn btn-primary btn-block yt-roll" disabled={!canRoll} onClick={roll}>
{rollLabel}
</button>
) : (
<p className="muted small" style={{ textAlign: 'center', minHeight: 24 }}>
{v.phase === 'finished' ? '게임이 끝났어요' : v.current ? `${nick(players, v.current)}님이 굴리는 중이에요` : ''}
</p>
)}
{myTurn && v.rollsUsed > 0 && v.rollsUsed < 3 && <p className="muted small" style={{ textAlign: 'center' }}>남길 주사위를 누르면 고정돼요. 점수표의 칸을 누르면 기록해요.</p>}
{v.lastScore && <LastScore entry={v.lastScore} view={v} me={me} players={players} />}
</section>
<ScoreSheet view={v} me={me} players={players} myTurn={myTurn} pending={pending} onScore={score} pointer={pointer} />
<p className="sr-only" aria-live="polite">
{announce}
</p>
{flash && (
<div className="yt-flash" aria-hidden>
{flash}
</div>
)}
{confirm && (
<ConfirmSheet
title={`${catName(confirm.category, v.options.scoring)}에 ${confirm.points}점 기록할까요?`}
body={confirm.points === 0 ? '조건에 맞지 않아서 0점이 돼요. 한 번 기록한 칸은 바꿀 수 없어요.' : '한 번 기록한 칸은 바꿀 수 없어요.'}
confirm="기록하기"
onConfirm={() => act({ type: 'score', category: confirm.category })}
onClose={() => setConfirm(null)}
/>
)}
</div>
);
}
function LastScore({ entry: e, view: v, me, players }: { entry: ScoreEntry; view: YachtView; me: string | null; players: Record<string, PublicUser> }) {
return (
<p className="small row" style={{ justifyContent: 'center', gap: 6 }}>
<span className="muted">방금:</span>
<strong>{e.player === me ? '나' : nick(players, e.player)}</strong>
<DiceRow dice={e.dice} size={20} />
<span>
→ {catName(e.category, v.options.scoring)} {e.points}점{e.bonus ? ` +${e.bonus}` : ''}
{e.auto ? ' (자동)' : ''}
</span>
</p>
);
}
// ---------------------------------------------------------------- score sheet
function ScoreSheet({
view: v,
me,
players,
myTurn,
pending,
onScore,
pointer,
}: {
view: YachtView;
me: string | null;
players: Record<string, PublicUser>;
myTurn: boolean;
pending: boolean;
onScore: (c: Category, points: number, viaMouse: boolean) => void;
pointer: React.MutableRefObject<string>;
}) {
// My column first (bigger), then everyone else in turn order.
const cols = me && v.order.includes(me) ? [me, ...v.order.filter((p) => p !== me)] : v.order;
const upper = v.categories.filter((c) => UPPER.includes(c));
const lower = v.categories.filter((c) => !UPPER.includes(c));
const ls = v.lastScore;
const yz = v.options.scoring === 'yahtzee';
const cell = (p: string, c: Category) => {
const sh = v.sheets[p]!;
const val = sh.scores[c];
if (val !== undefined) {
const fresh = ls && ls.player === p && ls.category === c;
return (
<span key={fresh ? `n${v.turnSeq}` : 'f'} className={`yt-filled${fresh ? ' yt-new' : ''}`}>
{val}
</span>
);
}
const prev = v.current === p ? v.preview?.[c] : undefined;
if (p === me && myTurn && v.rollsUsed > 0 && prev !== undefined) {
const allowed = v.allowed.includes(c);
const show = v.options.showPreview;
return (
<button
type="button"
className={`yt-cell${show && prev > 0 ? ' good' : ''}${show && prev === 0 ? ' zero' : ''}`}
disabled={!allowed || pending}
aria-label={`${catName(c, v.options.scoring)}에 ${show ? `${prev}점 ` : ''}기록하기${allowed ? '' : ' (지금은 넣을 수 없어요)'}`}
onPointerDown={(e) => (pointer.current = e.pointerType)}
onClick={(e) => {
const viaMouse = e.detail === 0 || pointer.current === 'mouse';
pointer.current = '';
onScore(c, prev, viaMouse);
}}
>
{show ? prev : '기록'}
</button>
);
}
if (prev !== undefined && v.options.showPreview) return <span className="yt-ghost">{prev}</span>;
return null;
};
const header = (
<tr>
<th className="yt-cat" scope="col">
칸
</th>
{cols.map((p) => {
const sh = v.sheets[p]!;
return (
<th key={p} scope="col" className={v.current === p ? 'turn' : undefined} title={nick(players, p)}>
<span className="yt-pname">{p === me ? '나' : nick(players, p)}</span>
{sh.left && <div className="small muted">기권</div>}
{!sh.left && sh.away && <div className="small muted">자리 비움</div>}
</th>
);
})}
</tr>
);
const row = (c: Category) => (
<tr key={c}>
<th className="yt-cat" scope="row">
{catName(c, v.options.scoring)}
</th>
{cols.map((p) => (
<td key={p} className={p === me ? 'mine' : undefined}>
{cell(p, c)}
</td>
))}
</tr>
);
const sumRow = (label: string, f: (s: YachtSheetView) => React.ReactNode, cls = 'yt-sum') => (
<tr className={cls}>
<th className="yt-cat" scope="row">
{label}
</th>
{cols.map((p) => (
<td key={p} className={p === me ? 'mine' : undefined}>
{f(v.sheets[p]!)}
</td>
))}
</tr>
);
return (
<section className="card yt-sheet" aria-label="점수표">
<table className="yt-table">
<thead>{header}</thead>
<tbody>
{upper.map(row)}
{sumRow('소계', (s) => `${s.upper}/${v.upperBonusThreshold}`)}
{sumRow('보너스 +35', (s) => (s.upperBonus ? `+${s.upperBonus}` : <span className="muted small">{`${Math.max(0, v.upperBonusThreshold - s.upper)} 남음`}</span>))}
{lower.map(row)}
{yz && sumRow('요트 보너스', (s) => (s.yahtzeeBonus ? `+${s.yahtzeeBonus}` : '-'))}
{sumRow('총점', (s) => s.total, 'yt-total')}
</tbody>
</table>
</section>
);
}
// ---------------------------------------------------------------- side panel
function YachtSide({ view: v, me, players, act, deadline }: BoardProps<YachtView>) {
const [ask, setAsk] = useState(false);
const mine = me ? v.sheets[me] : undefined;
const total = v.options.turnSeconds > 0 ? v.options.turnSeconds * 1000 : deadline ? Math.max(1000, deadline - v.turnStartedAt) : 60_000;
return (
<>
<section className="card stack" style={{ padding: 12, gap: 8 }} aria-label="참가자">
{v.order.map((p, i) => {
const sh = v.sheets[p]!;
const turn = v.current === p;
return (
<div key={p} className="row" style={{ gap: 10, flexWrap: 'nowrap', padding: '4px 6px', borderRadius: 10, border: `3px solid ${turn ? 'var(--focus)' : 'transparent'}` }}>
<span className="muted small" style={{ width: 16, textAlign: 'right' }}>
{i + 1}
</span>
<Avatar user={userOf(players, p)} size={30} />
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontWeight: 700, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>
{nick(players, p)}
{p === me && <span className="muted small"> (나)</span>}
</div>
<div className="small muted">
{sh.left ? '기권' : sh.away ? '자리 비움' : turn ? '굴리는 중' : `${sh.filled}/${v.totalRounds}칸`}
</div>
</div>
<span style={{ fontWeight: 800, fontSize: '1.3rem', fontVariantNumeric: 'tabular-nums' }} aria-label={`${nick(players, p)} 총점 ${sh.total}점`}>
{sh.total}
</span>
{turn && v.phase === 'playing' && <TurnTimer deadline={deadline} totalMs={total} mine={p === me} size={40} />}
</div>
);
})}
</section>
{mine && !mine.left && (
<section className="card stack" style={{ padding: 12, gap: 6 }} aria-label="상단 보너스">
<div className="row small" style={{ justifyContent: 'space-between' }}>
<strong>상단 보너스</strong>
<span>{mine.upperBonus ? '35점 확보!' : `${v.upperBonusThreshold}까지 ${Math.max(0, v.upperBonusThreshold - mine.upper)}점 남음`}</span>
</div>
<div className="yt-bar" role="progressbar" aria-valuemin={0} aria-valuemax={v.upperBonusThreshold} aria-valuenow={Math.min(mine.upper, v.upperBonusThreshold)} aria-label="상단 합계">
<span style={{ width: `${Math.min(100, (mine.upper / v.upperBonusThreshold) * 100)}%` }} />
</div>
</section>
)}
{v.phase === 'playing' && mine && !mine.left && (
<button className="btn btn-danger btn-sm" onClick={() => setAsk(true)}>
🏳 기권
</button>
)}
{ask && (
<ConfirmSheet
title="기권할까요?"
body="남은 칸은 모두 0점이 되고, 이후 차례에서 빠져요."
confirm="기권하기"
danger
onConfirm={() => act({ type: 'concede' })}
onClose={() => setAsk(false)}
/>
)}
</>
);
}
// ---------------------------------------------------------------- status
function isMyTurn(v: YachtView, me: string | null): boolean {
return v.phase === 'playing' && !!me && v.current === me;
}
function statusText(v: YachtView, me: string | null, players: Record<string, PublicUser>): string {
if (v.phase === 'finished') return v.result?.summary ?? '게임이 끝났어요';
if (me && v.sheets[me]?.left) return '기권했어요. 구경하는 중이에요';
if (isMyTurn(v, me)) {
if (v.rollsUsed === 0) return '내 차례예요. 주사위를 굴려 주세요';
if (v.rollsUsed < 3) return `내 차례예요. 다시 굴리거나(${v.rollsLeft}번 남음) 칸을 골라 주세요`;
return '내 차례예요. 점수를 넣을 칸을 골라 주세요';
}
return v.current ? `${nick(players, v.current)}님 차례예요 · ${v.round}/${v.totalRounds}라운드` : '잠시만 기다려 주세요';
}
// ---------------------------------------------------------------- options
function Seg<T extends string | number>({ value, options, onChange }: { value: T; options: [T, string][]; onChange: (v: T) => void }) {
return (
<div className="segmented">
{options.map(([k, l]) => (
<button key={String(k)} type="button" aria-pressed={value === k} onClick={() => onChange(k)}>
{l}
</button>
))}
</div>
);
}
function Field({ label, children }: { label: string; children: React.ReactNode }) {
return (
<div className="stack" style={{ gap: 6 }}>
<strong>{label}</strong>
{children}
</div>
);
}
function Options({ value: o, onChange }: OptionsProps<YachtOptions>) {
const set = (patch: Partial<YachtOptions>) => onChange({ ...o, ...patch });
return (
<div className="stack" style={{ gap: 16 }}>
<Field label="점수 규칙">
<Seg value={o.scoring} options={[['yacht51', '요트(12칸)'], ['yahtzee', '13칸 변형(미국식)']]} onChange={(scoring) => set({ scoring })} />
</Field>
{o.scoring === 'yacht51' ? (
<Field label="5개 모두 같은 눈을 풀 하우스로">
<Seg value={o.fullHouseAllowsYacht ? 'on' : 'off'} options={[['on', '인정'], ['off', '인정 안 함(전통 규칙)']]} onChange={(k) => set({ fullHouseAllowsYacht: k === 'on' })} />
</Field>
) : (
<Field label="요트가 또 나왔을 때">
<Seg value={o.yahtzeeJoker} options={[['forced', '정해진 순서대로(공식)'], ['free', '아무 칸이나']]} onChange={(yahtzeeJoker) => set({ yahtzeeJoker })} />
</Field>
)}
<Field label="한 차례 제한 시간">
<Seg value={o.turnSeconds} options={[[30, '30초'], [60, '60초'], [90, '90초'], [0, '제한 없음']]} onChange={(turnSeconds) => set({ turnSeconds })} />
</Field>
<Field label="점수 미리 보기">
<Seg value={o.showPreview ? 'on' : 'off'} options={[['on', '빈 칸에 보여 주기'], ['off', '보여 주지 않기']]} onChange={(k) => set({ showPreview: k === 'on' })} />
</Field>
</div>
);
}
function optionsSummary(o: YachtOptions): string {
return [
o.scoring === 'yacht51' ? '요트 12칸' : '13칸 변형',
...(o.scoring === 'yacht51' && !o.fullHouseAllowsYacht ? ['풀 하우스에 요트 불인정'] : []),
...(o.scoring === 'yahtzee' ? [o.yahtzeeJoker === 'forced' ? '추가 요트는 정해진 순서' : '추가 요트는 아무 칸'] : []),
o.turnSeconds > 0 ? `한 차례 ${o.turnSeconds}초` : '시간 제한 없음',
...(o.showPreview ? [] : ['미리 보기 끔']),
].join(' · ');
}
// ---------------------------------------------------------------- rules
function Rules({ options }: { options?: YachtOptions }) {
const yz = options?.scoring === 'yahtzee';
const fhYacht = options?.fullHouseAllowsYacht ?? true;
const rows: [string, string, number[]][] = yz
? [
['에이스~식스', '그 눈이 나온 주사위의 합', [6, 6, 6, 2, 1]],
['쓰리 카인드', '같은 눈 3개 이상이면 5개 합', [4, 4, 4, 2, 5]],
['포 카인드', '같은 눈 4개 이상이면 5개 합', [3, 3, 3, 3, 6]],
['풀 하우스', '같은 눈 3개 + 다른 같은 눈 2개면 25점', [2, 2, 5, 5, 5]],
['스몰 스트레이트', '이어지는 눈 4개면 30점', [1, 2, 3, 4, 6]],
['라지 스트레이트', '이어지는 눈 5개면 40점', [2, 3, 4, 5, 6]],
['요트', '5개 모두 같은 눈이면 50점', [5, 5, 5, 5, 5]],
['찬스', '아무 조합이나 5개 합', [6, 5, 4, 4, 2]],
]
: [
['에이스~식스', '그 눈이 나온 주사위의 합', [6, 6, 6, 2, 1]],
['초이스', '아무 조합이나 5개 합', [6, 5, 4, 4, 2]],
['포 카인드', '같은 눈 4개 이상이면 5개 합', [3, 3, 3, 3, 6]],
['풀 하우스', `같은 눈 3개 + 다른 같은 눈 2개면 5개 합${fhYacht ? '(5개 모두 같아도 인정)' : ''}`, [2, 2, 5, 5, 5]],
['스몰 스트레이트', '이어지는 눈 4개면 15점', [1, 2, 3, 4, 6]],
['라지 스트레이트', '이어지는 눈 5개면 30점', [2, 3, 4, 5, 6]],
['요트', '5개 모두 같은 눈이면 50점', [5, 5, 5, 5, 5]],
];
return (
<div className="stack">
<p>
주사위 5개를 한 차례에 <strong>최대 3번</strong> 굴려요. 마음에 드는 주사위는 눌러서 <strong>고정</strong>하고 나머지만 다시 굴릴 수 있어요. 고정은 굴릴 때마다 바꿀 수 있어요.
</p>
<p>
굴린 뒤에는 점수표의 <strong>빈 칸 하나</strong>를 골라 지금 주사위로 점수를 적어요. 조건에 맞지 않는 칸에 넣으면 0점이에요. 한 번 적은 칸은 바꿀 수 없고, 3번 굴린 뒤에는 꼭 적어야 해요.
</p>
<p>
{yz ? '13칸' : '12칸'}을 모두 채우면 끝나고, <strong>총점이 가장 높은 사람</strong>이 이겨요. 혼자서 최고 점수에 도전할 수도 있어요.
</p>
<div className="stack" style={{ gap: 6 }}>
{rows.map(([name, rule, dice]) => (
<div key={name} className="row" style={{ gap: 10, flexWrap: 'nowrap' }}>
<DiceRow dice={dice} size={22} />
<span className="small">
<strong>{name}</strong> · {rule}
</span>
</div>
))}
</div>
<p>
<strong>상단 보너스</strong>: 에이스~식스 6칸의 합이 63점 이상이면 35점을 더 받아요(눈마다 3개씩 모으면 딱 63점이에요).
</p>
{yz && (
<p>
<strong>추가 요트</strong>: 요트 칸에 50점을 적은 뒤 요트가 또 나오면 100점을 더 받아요.{' '}
{options?.yahtzeeJoker === 'free'
? '이때는 아무 빈 칸에나 적을 수 있어요.'
: '이때는 그 눈의 윗칸이 비어 있으면 거기에 먼저 적어야 하고, 윗칸이 차 있으면 아래쪽 칸에 적어요.'}{' '}
그 눈의 윗칸이 이미 차 있으면 풀 하우스 25점, 스트레이트 30·40점을 조건과 상관없이 인정해요.
</p>
)}
<p className="muted">
시간 안에 적지 않으면 지금 주사위로 가장 높은 점수가 나오는 칸에 자동으로 적혀요(아직 안 굴렸으면 먼저 굴려요). 기권하면 남은 칸은 모두 0점이 돼요.
</p>
</div>
);
}
const ui: GameUI<YachtView, YachtOptions> = {
Board: YachtBoard,
Side: YachtSide,
statusText,
isMyTurn,
Options,
optionsSummary,
Rules,
};
export default ui;

View File

@@ -8,7 +8,8 @@ export const GAME_ICON: Record<string, string> = {
mahjong: '🀇',
yut: '🪵',
othello: '◐',
yacht: '🎲',
};
/** Shown on the home screen so users know what's coming (docs/12-roadmap.md). */
export const UPCOMING = ['윷놀이', '원카드', '숫자 타일', '다빈치 코드', '요트 다이스', '마작', '고스톱', '뱅', '할리갈리'];
export const UPCOMING = ['윷놀이', '원카드', '숫자 타일', '다빈치 코드', '마작', '고스톱', '뱅', '할리갈리'];

View File

@@ -562,9 +562,11 @@ function ResultPanel({ room, me, players, onLeave }: { room: RoomView; me: Publi
const seated = room.seats.filter(Boolean).map((s) => s!.user.id);
const amSeated = seated.includes(me.id);
const voted = room.rematchVotes.includes(me.id);
const allFirst = result.ranking.length === 1;
const won = !allFirst && result.ranking[0]?.includes(me.id);
const lost = !allFirst && !won && result.ranking.some((g) => g.includes(me.id));
// Solo games (e.g. 요트 다이스) have one ranked player: neither a draw nor a win.
const solo = result.ranking.flat().length === 1;
const allFirst = !solo && result.ranking.length === 1;
const won = !solo && !allFirst && result.ranking[0]?.includes(me.id);
const lost = !solo && !allFirst && !won && result.ranking.some((g) => g.includes(me.id));
useEffect(() => {
if (played.current) return;
played.current = true;

View File

@@ -77,6 +77,56 @@ const PLANS: Record<string, Plan> = {
await finishHand(pages);
},
},
yacht: {
// PC host + phone guest play a whole 12-round game through the UI: roll, sometimes hold + re-roll, then
// write the best open category (phone uses touch taps, so the confirm sheet path is exercised too).
drive: async (pages) => {
const myTurn = (p: Page) => p.evaluate(() => !!document.querySelector('.turn-status')?.textContent?.includes('내 차례예요'));
const shot = new Set<string>();
for (let turn = 0; turn < 24; turn++) {
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 ${turn + 1})`);
const phone = mover !== pages[0];
const press = (l: ReturnType<Page['locator']>) => (phone ? l.tap() : l.click());
await press(mover.getByRole('button', { name: /^🎲 굴리기/ }));
await mover.locator('.yt-die[aria-pressed]').first().waitFor();
if (turn % 3 !== 2) {
// Hold the first die, check it shows as held, then roll the rest again.
await press(mover.locator('.yt-die').first());
await mover.locator('.yt-die').first().and(mover.locator('[aria-pressed="true"]')).waitFor();
await press(mover.getByRole('button', { name: /^🎲 굴리기 \(남은 2번\)/ }));
await mover.getByRole('button', { name: /^🎲 굴리기 \(남은 1번\)/ }).waitFor();
await mover.waitForTimeout(700); // roll animation
}
const cells = mover.locator('button.yt-cell:not([disabled])');
await cells.first().waitFor();
// Mid-game screenshots of a turn in progress (held die + preview cells), light and dark.
const tag = phone ? 'phone' : 'pc';
if (turn >= 8 && !shot.has(tag)) {
shot.add(tag);
await mover.screenshot({ path: join(SHOTS, `yacht-mid-${tag}.png`), fullPage: phone });
await mover.emulateMedia({ colorScheme: 'dark' });
await mover.waitForTimeout(200);
await mover.screenshot({ path: join(SHOTS, `yacht-mid-${tag}-dark.png`), fullPage: phone });
await mover.emulateMedia({ colorScheme: 'light' });
}
const values = await cells.allInnerTexts();
let best = 0;
values.forEach((v, i) => Number(v) > Number(values[best]) && (best = i));
await press(cells.nth(best));
const confirm = mover.getByRole('button', { name: '기록하기', exact: true });
if (await confirm.isVisible({ timeout: 600 }).catch(() => false)) await confirm.click();
await mover.waitForFunction(() => !document.querySelector('.turn-status')?.textContent?.includes('내 차례예요'), null, { timeout: 5000 });
}
for (const p of pages) await p.getByRole('dialog', { name: '게임 결과' }).waitFor({ timeout: 10_000 });
const sheet = await pages[0]!.locator('.yt-table tr.yt-total').innerText();
if (!/\d/.test(sheet)) throw new Error('no totals on the score sheet');
},
},
othello: {
// cursor starts at d4 (3,3): black d3 = up; then white c3 = up, left
moves: [['ArrowUp', 'Enter'], ['ArrowUp', 'ArrowLeft', 'Enter']],

View File

@@ -6,6 +6,7 @@ import { janggi } from './janggi';
import { baduk } from './baduk';
import { seotda } from './seotda';
import { poker } from './poker';
import { yacht } from './yacht';
export const GAMES: Record<string, AnyGameDefinition> = {
[omok.id]: omok,
@@ -15,4 +16,5 @@ export const GAMES: Record<string, AnyGameDefinition> = {
[baduk.id]: baduk,
[seotda.id]: seotda,
[poker.id]: poker,
[yacht.id]: yacht,
};

View File

@@ -0,0 +1,479 @@
/** 요트 다이스 (docs/games/yacht.md). No hidden information: every viewer gets the same view. */
import { z } from 'zod';
import { fail, ok, type GameDefinition, type GameEvent, type GameResult, type PlayerId } from '@bg/engine';
import {
CATEGORIES,
UPPER_BONUS_THRESHOLD,
allowedCategories,
autoCategory,
categoryName,
scoreFor,
totals,
upperOf,
type Category,
type Sheet,
type SheetTotals,
} from './scoring';
export * from './scoring';
const lit = <T extends number>(...vals: T[]) =>
z.union(vals.map((v) => z.literal(v)) as unknown as [z.ZodLiteral<T>, z.ZodLiteral<T>, ...z.ZodLiteral<T>[]]);
export const yachtOptionsSchema = z.object({
scoring: z
.enum(['yacht51', 'yahtzee'])
.default('yacht51')
.meta({ title: '점수 규칙', labels: { yacht51: '요트(12칸)', yahtzee: '13칸 변형(미국식)' } }),
fullHouseAllowsYacht: z.boolean().default(true).meta({ title: '12칸 규칙: 5개 모두 같은 눈도 풀 하우스로 인정' }),
yahtzeeJoker: z
.enum(['forced', 'free'])
.default('forced')
.meta({ title: '13칸 변형: 요트가 또 나왔을 때', labels: { forced: '정해진 순서대로 넣기(공식)', free: '아무 칸이나 고르기' } }),
turnSeconds: lit(30, 60, 90, 0)
.default(60)
.meta({ title: '한 차례 제한 시간', labels: { 30: '30초', 60: '60초', 90: '90초', 0: '제한 없음' } }),
showPreview: z.boolean().default(true).meta({ title: '빈 칸에 지금 넣을 때 점수 미리 보여 주기' }),
});
export type YachtOptions = z.infer<typeof yachtOptionsSchema>;
const categoryEnum = z.enum([
'ones',
'twos',
'threes',
'fours',
'fives',
'sixes',
'choice',
'threeKind',
'fourKind',
'fullHouse',
'smallStraight',
'largeStraight',
'yacht',
]);
export const yachtActionSchema = z.discriminatedUnion('type', [
z.object({ type: z.literal('roll') }),
z.object({ type: z.literal('hold'), held: z.array(z.boolean()).length(5) }),
z.object({ type: z.literal('score'), category: categoryEnum }),
z.object({ type: z.literal('concede') }),
]);
/** Client actions plus the system-only timeout action (not in the schema). */
export type YachtAction = z.infer<typeof yachtActionSchema> | { type: 'timeout' };
export const DICE = 5;
export const MAX_ROLLS = 3;
/** Consecutive automatic turns before a player is shown as "자리 비움" (§8.5). */
export const AWAY_AFTER = 3;
export interface ScoreEntry {
player: PlayerId;
category: Category;
points: number;
bonus: number;
dice: number[];
auto: boolean;
}
export interface YachtState {
options: YachtOptions;
/** Turn order (shuffled at setup). */
order: PlayerId[];
/** Index into `order` of the player to move. */
current: number;
round: number;
totalRounds: number;
/** null before the first roll of a turn. */
dice: number[] | null;
held: boolean[];
rollsUsed: number;
/** Increases on every roll (animation key). */
rollSeq: number;
sheets: Record<PlayerId, Sheet>;
/** Players who conceded / left (their open categories were set to 0). */
left: PlayerId[];
phase: 'playing' | 'finished';
turnSeq: number;
turnStartedAt: number;
deadlineAt: number | null;
autoStreak: Record<PlayerId, number>;
lastScore: ScoreEntry | null;
}
export interface YachtSheetView extends SheetTotals {
scores: Partial<Record<Category, number>>;
filled: number;
left: boolean;
away: boolean;
}
export interface YachtRankRow extends SheetTotals {
rank: number;
player: PlayerId;
conceded: boolean;
yachts: number;
}
export interface YachtResult {
ranking: YachtRankRow[];
winners: PlayerId[];
sheets: Record<PlayerId, Partial<Record<Category, number>>>;
summary: string;
reason: GameResult['reason'];
}
export interface YachtView {
options: YachtOptions;
categories: Category[];
order: PlayerId[];
/** Player to move, null once finished. */
current: PlayerId | null;
round: number;
totalRounds: number;
dice: number[] | null;
held: boolean[];
rollsUsed: number;
rollsLeft: number;
rollSeq: number;
turnSeq: number;
canRoll: boolean;
canHold: boolean;
/** Categories the current dice may be written into (empty before the first roll). */
allowed: Category[];
/** Points per open category for the current dice (current player always; everyone if `showPreview`). */
preview: Partial<Record<Category, number>> | null;
/** Yacht bonus that scoring now would add (13칸 변형). */
previewBonus: number;
sheets: Record<PlayerId, YachtSheetView>;
phase: YachtState['phase'];
turnStartedAt: number;
deadlineAt: number | null;
lastScore: ScoreEntry | null;
upperBonusThreshold: number;
result: YachtResult | null;
}
const rulesOf = (o: YachtOptions) => ({ scoring: o.scoring, fullHouseAllowsYacht: o.fullHouseAllowsYacht, yahtzeeJoker: o.yahtzeeJoker });
const cats = (s: YachtState) => CATEGORIES[s.options.scoring];
const filledCount = (s: YachtState, p: PlayerId) => cats(s).filter((c) => s.sheets[p]!.scores[c] !== undefined).length;
const sheetFull = (s: YachtState, p: PlayerId) => filledCount(s, p) === s.totalRounds;
const currentPlayer = (s: YachtState): PlayerId | null => (s.phase === 'playing' ? s.order[s.current]! : null);
const deadlineFor = (o: YachtOptions, now: number) => (o.turnSeconds > 0 ? now + o.turnSeconds * 1000 : null);
const noHolds = () => [false, false, false, false, false];
const yachtCount = (s: YachtState, p: PlayerId) => {
const sh = s.sheets[p]!;
return (sh.scores.yacht === 50 ? 1 : 0) + sh.yahtzeeBonus / 100;
};
function rollDice(s: YachtState, rng: { int(n: number): number }, events: GameEvent[], player: PlayerId): void {
const first = s.rollsUsed === 0;
const dice = s.dice ?? [1, 1, 1, 1, 1];
if (first) s.held = noHolds();
for (let i = 0; i < DICE; i++) if (first || !s.held[i]) dice[i] = 1 + rng.int(6);
s.dice = dice;
s.rollsUsed++;
s.rollSeq++;
events.push({ type: 'rolled', player, dice: [...dice], held: [...s.held], rollsUsed: s.rollsUsed });
}
/** Next player who still has open categories (skips conceded players); finishes the game if none. */
function advance(s: YachtState, now: number, events: GameEvent[]): void {
s.dice = null;
s.held = noHolds();
s.rollsUsed = 0;
const n = s.order.length;
const active = s.order.filter((p) => !s.left.includes(p));
// A multi-player game ends when at most one player is still in (§8.6: everybody conceded → end now).
const tooFew = n > 1 ? active.length <= 1 : active.length === 0;
let next = -1;
if (!tooFew) {
for (let step = 1; step <= n; step++) {
const i = (s.current + step) % n;
const p = s.order[i]!;
if (!s.left.includes(p) && !sheetFull(s, p)) {
next = i;
break;
}
}
}
if (next < 0) {
s.phase = 'finished';
s.deadlineAt = null;
events.push({ type: 'gameOver' });
return;
}
s.current = next;
s.turnSeq++;
s.turnStartedAt = now;
s.deadlineAt = deadlineFor(s.options, now);
const round = filledCount(s, s.order[next]!) + 1;
if (round !== s.round) {
s.round = round;
events.push({ type: 'roundChanged', round });
}
events.push({ type: 'turnChanged', player: s.order[next]! });
}
function writeScore(s: YachtState, player: PlayerId, category: Category, auto: boolean, now: number, events: GameEvent[]): void {
const sheet = s.sheets[player]!;
const dice = s.dice!;
const before = totals(sheet);
const out = scoreFor(dice, category, sheet, rulesOf(s.options));
sheet.scores[category] = out.points;
sheet.yahtzeeBonus += out.yahtzeeBonus;
s.lastScore = { player, category, points: out.points, bonus: out.yahtzeeBonus, dice: [...dice], auto };
events.push({ type: 'scored', player, category, points: out.points, bonus: out.yahtzeeBonus, joker: out.joker, auto });
if (before.upperBonus === 0 && totals(sheet).upperBonus > 0) events.push({ type: 'upperBonus', player });
advance(s, now, events);
}
function rankRows(s: YachtState): YachtRankRow[] {
const rows = s.order.map((p) => ({ player: p, conceded: s.left.includes(p), yachts: yachtCount(s, p), ...totals(s.sheets[p]!) }));
// Conceded players rank below everyone who played on (then by total).
rows.sort((a, b) => Number(a.conceded) - Number(b.conceded) || b.total - a.total);
const out: YachtRankRow[] = [];
rows.forEach((r, i) => {
const prev = out[i - 1];
const tied = prev && prev.total === r.total && prev.conceded === r.conceded;
out.push({ ...r, rank: tied ? prev.rank : i + 1 });
});
return out;
}
function buildResult(s: YachtState): YachtResult {
const ranking = rankRows(s);
const winners = ranking.filter((r) => r.rank === 1).map((r) => r.player);
const top = ranking[0]!;
const yachtNote = (r: YachtRankRow) => (r.yachts > 0 ? ` (요트 ${r.yachts}회)` : '');
const allLeft = s.order.every((p) => s.left.includes(p));
let summary: string;
if (s.order.length === 1) {
summary = allLeft ? `기권으로 끝났어요. 총점 ${top.total}점` : `총점 ${top.total}점${yachtNote(top)}`;
} else if (winners.length > 1) {
summary = `공동 1위 ${winners.length}명, ${top.total}점`;
} else {
const second = ranking[1]!;
summary = `1위 ${top.total}점${yachtNote(top)} · 2위 ${second.total}점`;
if (ranking.some((r) => r.conceded) && s.order.filter((p) => !s.left.includes(p)).length <= 1) summary = `다른 사람이 모두 기권했어요. ${summary}`;
}
const sheets: YachtResult['sheets'] = {};
for (const p of s.order) sheets[p] = { ...s.sheets[p]!.scores };
const anyLeft = s.left.length > 0;
const reason: GameResult['reason'] = allLeft ? 'abandoned' : anyLeft && s.order.some((p) => !s.left.includes(p) && !sheetFull(s, p)) ? 'resign' : 'normal';
return { ranking, winners, sheets, summary, reason };
}
function concede(s: YachtState, player: PlayerId, now: number, events: GameEvent[]): void {
const sheet = s.sheets[player]!;
for (const c of cats(s)) if (sheet.scores[c] === undefined) sheet.scores[c] = 0;
s.left.push(player);
events.push({ type: 'conceded', player });
const wasCurrent = s.order[s.current] === player;
const active = s.order.filter((p) => !s.left.includes(p));
if (wasCurrent || (s.order.length > 1 ? active.length <= 1 : active.length === 0)) advance(s, now, events);
}
export const yacht: GameDefinition<YachtState, YachtAction, YachtView, YachtOptions> = {
id: 'yacht',
nameKo: '요트 다이스',
minPlayers: 1,
maxPlayers: 8,
stateVersion: 1,
defaultOptions: yachtOptionsSchema.parse({}),
optionsSchema: yachtOptionsSchema,
actionSchema: yachtActionSchema as z.ZodType<YachtAction>,
setup({ players, options, rng, now }) {
if (players.length < 1 || players.length > 8) throw new Error('yacht: 1~8 players');
const order = rng.shuffle(players);
const sheets: Record<PlayerId, Sheet> = {};
const autoStreak: Record<PlayerId, number> = {};
for (const p of players) {
sheets[p] = { scores: {}, yahtzeeBonus: 0 };
autoStreak[p] = 0;
}
return {
options,
order,
current: 0,
round: 1,
totalRounds: CATEGORIES[options.scoring].length,
dice: null,
held: noHolds(),
rollsUsed: 0,
rollSeq: 0,
sheets,
left: [],
phase: 'playing',
turnSeq: 1,
turnStartedAt: now,
deadlineAt: deadlineFor(options, now),
autoStreak,
lastScore: null,
};
},
validate(s, actor, a) {
if (!s.sheets[actor]) return fail('이 게임의 플레이어가 아니에요.');
if (s.phase !== 'playing') return fail('게임이 이미 끝났어요.');
if (s.left.includes(actor)) return fail('이미 기권했어요.');
if (a.type === 'concede') return ok;
if (s.order[s.current] !== actor) return fail('지금은 내 차례가 아니에요.');
switch (a.type) {
case 'roll':
if (s.rollsUsed >= MAX_ROLLS) return fail('이번 차례에는 더 굴릴 수 없어요.');
if (s.rollsUsed > 0 && s.held.every(Boolean)) return fail('굴릴 주사위를 하나 이상 풀어 주세요.');
return ok;
case 'hold':
if (!Array.isArray(a.held) || a.held.length !== DICE || a.held.some((h) => typeof h !== 'boolean')) return fail('잘못된 요청이에요.');
if (s.rollsUsed === 0) return fail('먼저 주사위를 굴려 주세요.');
if (s.rollsUsed >= MAX_ROLLS) return fail('더 굴릴 수 없어서 고정할 필요가 없어요.');
return ok;
case 'score': {
if (s.rollsUsed === 0 || !s.dice) return fail('먼저 주사위를 굴려 주세요.');
if (!cats(s).includes(a.category)) return fail('이 규칙에서는 쓰지 않는 칸이에요.');
const sheet = s.sheets[actor]!;
if (sheet.scores[a.category] !== undefined) return fail('이미 기록한 칸이에요.');
const allowed = allowedCategories(s.dice, sheet, rulesOf(s.options));
if (!allowed.includes(a.category)) {
const own = upperOf(s.dice[0]!);
return fail(
allowed.length === 1 && allowed[0] === own
? `이 요트는 ${categoryName(own, s.options.scoring)} 칸에 먼저 넣어야 해요.`
: '이 요트는 아래쪽 칸에 먼저 넣어야 해요.',
);
}
return ok;
}
case 'timeout':
return ok;
}
},
apply(prev, actor, a, { rng, now }) {
const s = structuredClone(prev);
const events: GameEvent[] = [];
if (a.type !== 'timeout' && a.type !== 'concede') s.autoStreak[actor] = 0;
switch (a.type) {
case 'roll':
if (s.rollsUsed >= MAX_ROLLS) throw new Error('no rolls left');
rollDice(s, rng, events, actor);
break;
case 'hold':
if (s.rollsUsed === 0) throw new Error('hold before roll');
s.held = [...a.held];
events.push({ type: 'holdChanged', player: actor, held: [...s.held] });
break;
case 'score':
if (!s.dice || s.sheets[actor]!.scores[a.category] !== undefined) throw new Error('illegal score');
writeScore(s, actor, a.category, false, now, events);
break;
case 'timeout': {
s.autoStreak[actor] = (s.autoStreak[actor] ?? 0) + 1;
if (s.rollsUsed === 0) rollDice(s, rng, events, actor);
const c = autoCategory(s.dice!, s.sheets[actor]!, rulesOf(s.options));
events.push({ type: 'timeoutAuto', player: actor });
writeScore(s, actor, c, true, now, events);
break;
}
case 'concede':
concede(s, actor, now, events);
break;
}
return { state: s, events };
},
view(s, viewer) {
const cur = currentPlayer(s);
const rules = rulesOf(s.options);
const curSheet = cur ? s.sheets[cur]! : null;
const rolled = !!s.dice && s.rollsUsed > 0 && !!curSheet;
const allowed = rolled ? allowedCategories(s.dice!, curSheet!, rules) : [];
let preview: YachtView['preview'] = null;
let previewBonus = 0;
if (rolled && (s.options.showPreview || viewer === cur)) {
preview = {};
for (const c of cats(s)) {
if (curSheet!.scores[c] !== undefined) continue;
const o = scoreFor(s.dice!, c, curSheet!, rules);
preview[c] = o.points;
previewBonus = Math.max(previewBonus, o.yahtzeeBonus);
}
}
const sheets: YachtView['sheets'] = {};
for (const p of s.order) {
const sh = s.sheets[p]!;
sheets[p] = { scores: sh.scores, filled: filledCount(s, p), left: s.left.includes(p), away: (s.autoStreak[p] ?? 0) >= AWAY_AFTER, ...totals(sh) };
}
return {
options: s.options,
categories: [...cats(s)],
order: s.order,
current: cur,
round: s.round,
totalRounds: s.totalRounds,
dice: s.dice,
held: s.held,
rollsUsed: s.rollsUsed,
rollsLeft: MAX_ROLLS - s.rollsUsed,
rollSeq: s.rollSeq,
turnSeq: s.turnSeq,
canRoll: !!cur && s.rollsUsed < MAX_ROLLS && !(s.rollsUsed > 0 && s.held.every(Boolean)),
canHold: !!cur && s.rollsUsed > 0 && s.rollsUsed < MAX_ROLLS,
allowed,
preview,
previewBonus,
sheets,
phase: s.phase,
turnStartedAt: s.turnStartedAt,
deadlineAt: s.deadlineAt,
lastScore: s.lastScore,
upperBonusThreshold: UPPER_BONUS_THRESHOLD,
result: s.phase === 'finished' ? buildResult(s) : null,
};
},
activePlayers(s) {
const cur = currentPlayer(s);
return cur ? [cur] : [];
},
deadline(s) {
return s.phase === 'playing' ? s.deadlineAt : null;
},
onTimeout() {
return { type: 'timeout' };
},
onLeave(s, player) {
if (s.phase !== 'playing' || !s.sheets[player] || s.left.includes(player)) return null;
return { type: 'concede' };
},
result(s): GameResult | null {
if (s.phase !== 'finished') return null;
const r = buildResult(s);
const groups = new Map<number, PlayerId[]>();
for (const row of r.ranking) groups.set(row.rank, [...(groups.get(row.rank) ?? []), row.player]);
const scores: Record<PlayerId, number> = {};
for (const row of r.ranking) scores[row.player] = row.total;
return { ranking: [...groups.values()], scores, summary: r.summary, reason: r.reason };
},
/** Test helper: every hold pattern, roll and score for the current player (concede left out). */
legalActions(s, p) {
if (s.phase !== 'playing' || s.order[s.current] !== p) return [];
const out: YachtAction[] = [];
if (s.rollsUsed < MAX_ROLLS && !(s.rollsUsed > 0 && s.held.every(Boolean))) out.push({ type: 'roll' });
if (s.rollsUsed > 0 && s.rollsUsed < MAX_ROLLS) {
for (let m = 0; m < 32; m++) {
const held = [0, 1, 2, 3, 4].map((i) => ((m >> i) & 1) === 1);
if (held.some((h, i) => h !== s.held[i])) out.push({ type: 'hold', held });
}
}
if (s.rollsUsed > 0 && s.dice) for (const c of allowedCategories(s.dice, s.sheets[p]!, rulesOf(s.options))) out.push({ type: 'score', category: c });
return out;
},
};

View File

@@ -0,0 +1,211 @@
/** 요트 다이스 점수 계산 (docs/games/yacht.md §6). Shared by the server rules and the client preview. */
export type Category =
| 'ones'
| 'twos'
| 'threes'
| 'fours'
| 'fives'
| 'sixes'
| 'choice'
| 'threeKind'
| 'fourKind'
| 'fullHouse'
| 'smallStraight'
| 'largeStraight'
| 'yacht';
export type Scoring = 'yacht51' | 'yahtzee';
export type JokerRule = 'forced' | 'free';
export interface ScoringRules {
scoring: Scoring;
fullHouseAllowsYacht: boolean;
yahtzeeJoker: JokerRule;
}
export interface Sheet {
scores: Partial<Record<Category, number>>;
/** `yahtzee` mode: accumulated yacht bonus (multiples of 100). */
yahtzeeBonus: number;
}
export const UPPER: readonly Category[] = ['ones', 'twos', 'threes', 'fours', 'fives', 'sixes'];
/** Table order of §6.1 / §6.2 (also the tie-break order for automatic scoring). */
export const CATEGORIES: Record<Scoring, readonly Category[]> = {
yacht51: [...UPPER, 'choice', 'fourKind', 'fullHouse', 'smallStraight', 'largeStraight', 'yacht'],
yahtzee: [...UPPER, 'threeKind', 'fourKind', 'fullHouse', 'smallStraight', 'largeStraight', 'yacht', 'choice'],
};
/** §8.5: category given up first when every open category scores 0. */
export const SACRIFICE_ORDER: readonly Category[] = [
'ones',
'yacht',
'largeStraight',
'smallStraight',
'fullHouse',
'fourKind',
'threeKind',
'twos',
'threes',
'fours',
'fives',
'sixes',
'choice',
];
export const UPPER_BONUS_THRESHOLD = 63;
export const UPPER_BONUS = 35;
export const YAHTZEE_BONUS = 100;
const NAME: Record<Category, string> = {
ones: '에이스',
twos: '듀스',
threes: '트레이',
fours: '포',
fives: '파이브',
sixes: '식스',
choice: '초이스',
threeKind: '쓰리 카인드',
fourKind: '포 카인드',
fullHouse: '풀 하우스',
smallStraight: '스몰 스트레이트',
largeStraight: '라지 스트레이트',
yacht: '요트',
};
export function categoryName(c: Category, scoring: Scoring = 'yacht51'): string {
return c === 'choice' && scoring === 'yahtzee' ? '찬스' : NAME[c];
}
export const isUpper = (c: Category): boolean => UPPER.includes(c);
/** Face value of an upper category (1..6), else 0. */
export const faceOf = (c: Category): number => UPPER.indexOf(c) + 1;
export const upperOf = (face: number): Category => UPPER[face - 1]!;
const sum = (dice: readonly number[]) => dice.reduce((a, b) => a + b, 0);
function counts(dice: readonly number[]): number[] {
const c = [0, 0, 0, 0, 0, 0, 0];
for (const d of dice) c[d]!++;
return c;
}
const hasRun = (c: number[], from: number, len: number) => {
for (let f = from; f < from + len; f++) if (!c[f]) return false;
return true;
};
export const isYacht = (dice: readonly number[]): boolean => dice.length === 5 && dice.every((d) => d === dice[0]);
/** Plain category score, no joker. */
export function baseScore(dice: readonly number[], c: Category, rules: ScoringRules): number {
const cnt = counts(dice);
const max = Math.max(...cnt);
const total = sum(dice);
const y51 = rules.scoring === 'yacht51';
switch (c) {
case 'ones':
case 'twos':
case 'threes':
case 'fours':
case 'fives':
case 'sixes': {
const f = faceOf(c);
return cnt[f]! * f;
}
case 'choice':
return total;
case 'threeKind':
return max >= 3 ? total : 0;
case 'fourKind':
return max >= 4 ? total : 0;
case 'fullHouse': {
const two = cnt.includes(3) && cnt.includes(2);
const ok = two || (max === 5 && y51 && rules.fullHouseAllowsYacht);
return ok ? (y51 ? total : 25) : 0;
}
case 'smallStraight':
return hasRun(cnt, 1, 4) || hasRun(cnt, 2, 4) || hasRun(cnt, 3, 4) ? (y51 ? 15 : 30) : 0;
case 'largeStraight':
return hasRun(cnt, 1, 5) || hasRun(cnt, 2, 5) ? (y51 ? 30 : 40) : 0;
case 'yacht':
return max === 5 ? 50 : 0;
}
}
export interface ScoreOutcome {
points: number;
/** `yahtzee` joker rule changed the score (fixed full house / straight values). */
joker: boolean;
/** `yahtzee` extra-yacht bonus earned by this entry (0 or 100). */
yahtzeeBonus: number;
}
/** True when a `yahtzee`-mode extra yacht may use the joker scores (§6.2). */
export function jokerActive(dice: readonly number[], sheet: Sheet, rules: ScoringRules): boolean {
if (rules.scoring !== 'yahtzee' || !isYacht(dice) || sheet.scores.yacht === undefined) return false;
return sheet.scores[upperOf(dice[0]!)] !== undefined;
}
/** §8.2: `scoreFor(dice, category, sheet, options)`. */
export function scoreFor(dice: readonly number[], c: Category, sheet: Sheet, rules: ScoringRules): ScoreOutcome {
const base = baseScore(dice, c, rules);
if (rules.scoring !== 'yahtzee') return { points: base, joker: false, yahtzeeBonus: 0 };
const bonus = isYacht(dice) && sheet.scores.yacht === 50 && c !== 'yacht' ? YAHTZEE_BONUS : 0;
if (jokerActive(dice, sheet, rules)) {
const fixed: Partial<Record<Category, number>> = { fullHouse: 25, smallStraight: 30, largeStraight: 40 };
const f = fixed[c];
if (f !== undefined) return { points: f, joker: f !== base, yahtzeeBonus: bonus };
}
return { points: base, joker: false, yahtzeeBonus: bonus };
}
/** Open categories the dice may be written into (applies the `forced` joker order). */
export function allowedCategories(dice: readonly number[], sheet: Sheet, rules: ScoringRules): Category[] {
const open = CATEGORIES[rules.scoring].filter((c) => sheet.scores[c] === undefined);
if (rules.scoring !== 'yahtzee' || rules.yahtzeeJoker !== 'forced' || !isYacht(dice) || sheet.scores.yacht === undefined) return open;
const own = upperOf(dice[0]!);
if (sheet.scores[own] === undefined) return [own];
const lower = open.filter((c) => !isUpper(c));
return lower.length ? lower : open;
}
export interface SheetTotals {
upper: number;
upperBonus: number;
lower: number;
yahtzeeBonus: number;
total: number;
}
export function totals(sheet: Sheet): SheetTotals {
let upper = 0;
let lower = 0;
for (const [c, v] of Object.entries(sheet.scores) as [Category, number][]) {
if (isUpper(c)) upper += v;
else lower += v;
}
const upperBonus = upper >= UPPER_BONUS_THRESHOLD ? UPPER_BONUS : 0;
return { upper, upperBonus, lower, yahtzeeBonus: sheet.yahtzeeBonus, total: upper + upperBonus + lower + sheet.yahtzeeBonus };
}
/**
* §8.5 automatic choice: best-scoring allowed category (table order on ties);
* if every allowed category scores 0, the first open one in the sacrifice order.
*/
export function autoCategory(dice: readonly number[], sheet: Sheet, rules: ScoringRules): Category {
const allowed = allowedCategories(dice, sheet, rules);
let best: Category | null = null;
let bestPts = 0;
for (const c of allowed) {
const p = scoreFor(dice, c, sheet, rules).points;
if (p > bestPts) {
best = c;
bestPts = p;
}
}
if (best) return best;
const sacrifice = SACRIFICE_ORDER.find((c) => allowed.includes(c));
if (!sacrifice) throw new Error('no open category');
return sacrifice;
}

View File

@@ -0,0 +1,573 @@
import { describe, expect, test } from 'bun:test';
import { SeededRng, randomPlayout, seedN, type GameEvent } from '@bg/engine';
import {
CATEGORIES,
autoCategory,
baseScore,
scoreFor,
totals,
yacht,
type Category,
type ScoringRules,
type Sheet,
type YachtAction,
type YachtOptions,
type YachtState,
} from './index';
// ---------------------------------------------------------------- helpers
const Y51: ScoringRules = { scoring: 'yacht51', fullHouseAllowsYacht: true, yahtzeeJoker: 'forced' };
const YZ: ScoringRules = { scoring: 'yahtzee', fullHouseAllowsYacht: true, yahtzeeJoker: 'forced' };
const empty = (): Sheet => ({ scores: {}, yahtzeeBonus: 0 });
const all = (dice: number[], r: ScoringRules = Y51, sheet: Sheet = empty()) =>
Object.fromEntries(CATEGORIES[r.scoring].map((c) => [c, scoreFor(dice, c, sheet, r).points])) as Record<Category, number>;
let clock = 1_000;
const rng = () => SeededRng.fromSeed([3, 1, 4, 1]);
/** Fresh game with turn order exactly `players` (seeds are searched until the shuffle keeps it). */
function make(opts: Partial<YachtOptions> = {}, players = ['A', 'B', 'C']): YachtState {
const options = yacht.optionsSchema.parse(opts);
for (let seed = 1; seed < 2000; seed++) {
const s = yacht.setup({ players, options, rng: SeededRng.fromSeed(seedN(seed)), now: 0 });
if (s.order.join() === players.join()) return s;
}
throw new Error('no seed keeps the order');
}
function actE(s: YachtState, p: string, a: YachtAction, r = rng()): { state: YachtState; events: GameEvent[] } {
const v = yacht.validate(s, p, a);
if (!v.ok) throw new Error(`${p} ${JSON.stringify(a)}: ${v.reason}`);
return yacht.apply(s, p, a, { rng: r, now: (clock += 1000) });
}
const act = (s: YachtState, p: string, a: YachtAction, r?: SeededRng) => actE(s, p, a, r).state;
const reject = (s: YachtState, p: string, a: YachtAction) => {
const v = yacht.validate(s, p, a);
return v.ok ? null : v.reason;
};
/** Rolls once, then forces the dice (tests set exact dice). */
function rolled(s: YachtState, p: string, dice: number[]): YachtState {
const t = s.rollsUsed === 0 ? act(s, p, { type: 'roll' }) : s;
t.dice = [...dice];
return t;
}
const cur = (s: YachtState) => s.order[s.current]!;
// ---------------------------------------------------------------- scoring
describe('yacht51 scoring (§6.1, §10)', () => {
test('[6,6,6,6,2] matches the doc example', () => {
const r = all([6, 6, 6, 6, 2]);
expect(r).toMatchObject({ sixes: 24, twos: 2, choice: 26, fourKind: 26, fullHouse: 0, smallStraight: 0, largeStraight: 0, yacht: 0, ones: 0 });
});
test('[3,3,5,5,5]: full house 21, choice 21, threes 6, fives 15', () => {
expect(all([3, 3, 5, 5, 5])).toMatchObject({ fullHouse: 21, choice: 21, threes: 6, fives: 15, fourKind: 0, yacht: 0 });
});
test('straights: [1,2,3,4,6] small 15 / large 0, [2,3,4,5,6] small 15 / large 30', () => {
expect(all([1, 2, 3, 4, 6])).toMatchObject({ smallStraight: 15, largeStraight: 0 });
expect(all([6, 3, 2, 5, 4])).toMatchObject({ smallStraight: 15, largeStraight: 30 });
expect(all([1, 2, 3, 4, 5])).toMatchObject({ smallStraight: 15, largeStraight: 30 });
expect(all([3, 4, 5, 6, 6])).toMatchObject({ smallStraight: 15, largeStraight: 0 });
expect(all([1, 2, 3, 5, 6])).toMatchObject({ smallStraight: 0, largeStraight: 0 });
expect(all([1, 1, 2, 3, 4])).toMatchObject({ smallStraight: 15 });
});
test('[4,4,4,4,4]: yacht 50, four of a kind 20, fours 20; full house depends on the option', () => {
expect(all([4, 4, 4, 4, 4])).toMatchObject({ yacht: 50, fourKind: 20, fullHouse: 20, fours: 20, choice: 20 });
expect(all([4, 4, 4, 4, 4], { ...Y51, fullHouseAllowsYacht: false })).toMatchObject({ fullHouse: 0, yacht: 50 });
});
test('upper categories sum only that face; full house needs 3 + 2', () => {
expect(all([1, 1, 1, 2, 3])).toMatchObject({ ones: 3, twos: 2, threes: 3, fours: 0, fullHouse: 0 });
expect(all([2, 2, 2, 2, 3])).toMatchObject({ fourKind: 11, fullHouse: 0, twos: 8 });
expect(all([6, 6, 6, 5, 5])).toMatchObject({ fullHouse: 28 });
});
test('upper bonus: 62 → 0, 63 → 35', () => {
const s62: Sheet = { scores: { ones: 2, twos: 6, threes: 9, fours: 12, fives: 15, sixes: 18 }, yahtzeeBonus: 0 };
expect(totals(s62)).toMatchObject({ upper: 62, upperBonus: 0, total: 62 });
const s63: Sheet = { scores: { ...s62.scores, ones: 3, choice: 20 }, yahtzeeBonus: 0 };
expect(totals(s63)).toMatchObject({ upper: 63, upperBonus: 35, lower: 20, total: 118 });
});
test('theoretical maximum 325 (323 without yacht full house)', () => {
const best = (rules: ScoringRules) => {
const sh = empty();
for (const c of CATEGORIES.yacht51) {
let m = 0;
for (let a = 1; a <= 6; a++)
for (let b = 1; b <= 6; b++)
for (let c2 = 1; c2 <= 6; c2++)
for (let d = 1; d <= 6; d++) for (let e = 1; e <= 6; e++) m = Math.max(m, baseScore([a, b, c2, d, e], c, rules));
sh.scores[c] = m;
}
return totals(sh).total;
};
expect(best(Y51)).toBe(325);
expect(best({ ...Y51, fullHouseAllowsYacht: false })).toBe(323);
});
});
describe('yahtzee (13칸 변형) scoring (§6.2)', () => {
test('fixed values and three of a kind / chance', () => {
expect(all([3, 3, 5, 5, 5], YZ)).toMatchObject({ fullHouse: 25, threeKind: 21, fourKind: 0, choice: 21 });
expect(all([1, 2, 3, 4, 6], YZ)).toMatchObject({ smallStraight: 30, largeStraight: 0 });
expect(all([2, 3, 4, 5, 6], YZ)).toMatchObject({ smallStraight: 30, largeStraight: 40 });
// A first yacht is not a full house in this variant.
expect(all([4, 4, 4, 4, 4], YZ)).toMatchObject({ yacht: 50, fullHouse: 0, threeKind: 20, fourKind: 20 });
});
test('yacht bonus +100 only when the yacht box holds 50', () => {
const with50: Sheet = { scores: { yacht: 50 }, yahtzeeBonus: 0 };
expect(scoreFor([5, 5, 5, 5, 5], 'fives', with50, YZ)).toEqual({ points: 25, joker: false, yahtzeeBonus: 100 });
const with0: Sheet = { scores: { yacht: 0 }, yahtzeeBonus: 0 };
expect(scoreFor([5, 5, 5, 5, 5], 'fives', with0, YZ).yahtzeeBonus).toBe(0);
});
test('automatic choice: best score, table order on ties, sacrifice order when all are 0', () => {
expect(autoCategory([6, 6, 6, 6, 2], empty(), Y51)).toBe('choice'); // 26 = fourKind 26 → choice is higher in the table
expect(autoCategory([5, 5, 5, 5, 5], empty(), Y51)).toBe('yacht');
const onlyZero: Sheet = { scores: { twos: 4, threes: 9, fours: 8, fives: 10, sixes: 12, choice: 20, fourKind: 0, fullHouse: 0, smallStraight: 15, largeStraight: 30 }, yahtzeeBonus: 0 };
// [2,2,3,4,6]: ones 0, yacht 0 → sacrifice order picks ones first.
expect(autoCategory([2, 2, 3, 4, 6], onlyZero, Y51)).toBe('ones');
expect(autoCategory([2, 2, 3, 4, 6], { ...onlyZero, scores: { ...onlyZero.scores, ones: 0 } }, Y51)).toBe('yacht');
});
});
// ---------------------------------------------------------------- turn flow
describe('turn flow and validation (§5, §8.2, §10)', () => {
test('before the first roll: SCORE and SET_HOLD are rejected; not my turn is rejected', () => {
const s = make();
expect(reject(s, 'A', { type: 'score', category: 'choice' })).toBe('먼저 주사위를 굴려 주세요.');
expect(reject(s, 'A', { type: 'hold', held: [true, false, false, false, false] })).toBe('먼저 주사위를 굴려 주세요.');
expect(reject(s, 'B', { type: 'roll' })).toBe('지금은 내 차례가 아니에요.');
expect(reject(s, 'Z', { type: 'roll' })).toBe('이 게임의 플레이어가 아니에요.');
expect(reject(s, 'A', { type: 'roll' })).toBeNull();
});
test('roll limit: a 4th roll is rejected, holds are rejected after the 3rd roll', () => {
let s = make();
for (let i = 0; i < 3; i++) s = act(s, 'A', { type: 'roll' });
expect(s.rollsUsed).toBe(3);
expect(reject(s, 'A', { type: 'roll' })).toBe('이번 차례에는 더 굴릴 수 없어요.');
expect(reject(s, 'A', { type: 'hold', held: [true, true, true, true, true] })).not.toBeNull();
expect(yacht.view(s, 'A').canRoll).toBe(false);
});
test('all five held → roll rejected', () => {
let s = act(make(), 'A', { type: 'roll' });
s = act(s, 'A', { type: 'hold', held: [true, true, true, true, true] });
expect(reject(s, 'A', { type: 'roll' })).toBe('굴릴 주사위를 하나 이상 풀어 주세요.');
expect(yacht.view(s, 'B').canRoll).toBe(false);
});
test('the first roll ignores holds; held dice keep their value; released dice roll again', () => {
let s = act(make(), 'A', { type: 'roll' });
s.dice = [1, 2, 3, 4, 5];
s = act(s, 'A', { type: 'hold', held: [true, false, true, false, true] });
expect(s.held).toEqual([true, false, true, false, true]);
// Pick an rng whose next values would change die 1 (index 0) if it rolled.
s = act(s, 'A', { type: 'roll' }, SeededRng.fromSeed(seedN(5)));
expect([s.dice![0], s.dice![2], s.dice![4]]).toEqual([1, 3, 5]);
// 3rd roll: release die 0 → it is rolled again (consumes the first rng value).
const before = s.dice!.slice();
s = act(s, 'A', { type: 'hold', held: [false, true, true, true, true] });
const r = SeededRng.fromSeed(seedN(9));
const expected = 1 + SeededRng.fromSeed(seedN(9)).int(6);
s = act(s, 'A', { type: 'roll' }, r);
expect(s.dice![0]).toBe(expected);
expect(s.dice!.slice(1)).toEqual(before.slice(1));
});
test('dice are rolled index 0 → 4 with rng.int(6) (replay order)', () => {
const s = make();
const r1 = SeededRng.fromSeed(seedN(42));
const ref = SeededRng.fromSeed(seedN(42));
const out = act(s, 'A', { type: 'roll' }, r1);
expect(out.dice).toEqual([0, 1, 2, 3, 4].map(() => 1 + ref.int(6)));
});
test('filled categories are rejected; a zero-scoring category is allowed', () => {
let s = rolled(make(), 'A', [1, 2, 3, 4, 6]);
s = act(s, 'A', { type: 'score', category: 'yacht' });
expect(s.sheets.A!.scores.yacht).toBe(0);
expect(cur(s)).toBe('B');
s = rolled(s, 'B', [2, 2, 2, 2, 2]);
s = act(s, 'B', { type: 'score', category: 'twos' });
s = rolled(s, 'C', [3, 3, 3, 3, 3]);
s = act(s, 'C', { type: 'score', category: 'yacht' });
s = rolled(s, 'A', [5, 5, 5, 5, 5]);
expect(reject(s, 'A', { type: 'score', category: 'yacht' })).toBe('이미 기록한 칸이에요.');
expect(reject(s, 'A', { type: 'score', category: 'threeKind' })).toBe('이 규칙에서는 쓰지 않는 칸이에요.');
expect(s.round).toBe(2);
});
test('scoring ends the turn and resets dice/holds; events', () => {
let s = rolled(make(), 'A', [6, 6, 6, 6, 2]);
s = act(s, 'A', { type: 'hold', held: [true, true, true, true, false] });
const out = actE(s, 'A', { type: 'score', category: 'sixes' });
expect(out.events.map((e) => e.type)).toEqual(['scored', 'turnChanged']);
expect(out.events[0]).toMatchObject({ player: 'A', category: 'sixes', points: 24, bonus: 0 });
expect(out.state).toMatchObject({ dice: null, rollsUsed: 0, held: [false, false, false, false, false] });
});
test('upper bonus event fires once when the upper total reaches 63', () => {
let s = make({}, ['A']);
s.sheets.A!.scores = { ones: 3, twos: 6, threes: 9, fours: 12, fives: 15 };
s = rolled(s, 'A', [6, 6, 6, 1, 1]);
const out = actE(s, 'A', { type: 'score', category: 'sixes' });
expect(out.events.map((e) => e.type)).toContain('upperBonus');
expect(yacht.view(out.state, 'A').sheets.A).toMatchObject({ upper: 63, upperBonus: 35 });
});
test('12 rounds × 3 players end automatically; ties share the rank', () => {
let s = make();
const plan = CATEGORIES.yacht51;
for (let round = 0; round < 12; round++) {
for (const p of ['A', 'B', 'C']) {
expect(cur(s)).toBe(p);
expect(s.round).toBe(round + 1);
// A and B mirror each other; C always rolls ones.
s = rolled(s, p, p === 'C' ? [1, 1, 2, 2, 3] : [6, 6, 6, 6, 6]);
s = act(s, p, { type: 'score', category: plan[round]! });
}
}
expect(s.phase).toBe('finished');
const r = yacht.result(s)!;
expect(r.ranking).toEqual([['A', 'B'], ['C']]);
expect(r.reason).toBe('normal');
expect(r.scores!.A).toBe(r.scores!.B);
expect(r.summary).toContain('공동 1위');
expect(yacht.activePlayers(s)).toEqual([]);
expect(reject(s, 'A', { type: 'roll' })).toBe('게임이 이미 끝났어요.');
const v = yacht.view(s, null).result!;
expect(v.ranking.map((x) => x.rank)).toEqual([1, 1, 3]);
expect(v.winners).toEqual(['A', 'B']);
});
test('solo: 12 rounds in a row, result is the total', () => {
let s = make({}, ['S']);
for (let round = 0; round < 12; round++) {
expect(cur(s)).toBe('S');
s = rolled(s, 'S', [5, 5, 5, 5, 5]);
s = act(s, 'S', { type: 'score', category: CATEGORIES.yacht51[round]! });
}
expect(s.phase).toBe('finished');
const r = yacht.result(s)!;
expect(r.ranking).toEqual([['S']]);
expect(r.scores).toEqual({ S: 25 + 25 + 25 + 25 + 50 }); // fives, choice, 4K, full house (yacht counts), yacht
expect(r.summary).toContain('총점');
expect(r.summary).toContain('요트 1회');
});
});
describe('yahtzee joker and bonus in play (§6.2, §10)', () => {
test('second yacht after a 50 → +100', () => {
let s = make({ scoring: 'yahtzee' }, ['A']);
s = rolled(s, 'A', [3, 3, 3, 3, 3]);
s = act(s, 'A', { type: 'score', category: 'yacht' });
s = rolled(s, 'A', [2, 2, 2, 2, 2]);
const out = actE(s, 'A', { type: 'score', category: 'twos' });
expect(out.events[0]).toMatchObject({ points: 10, bonus: 100 });
expect(yacht.view(out.state, 'A').sheets.A).toMatchObject({ yahtzeeBonus: 100, total: 160 });
});
test('yacht box 0 → no bonus', () => {
let s = make({ scoring: 'yahtzee' }, ['A']);
s = rolled(s, 'A', [1, 2, 3, 4, 6]);
s = act(s, 'A', { type: 'score', category: 'yacht' });
s = rolled(s, 'A', [2, 2, 2, 2, 2]);
s = act(s, 'A', { type: 'score', category: 'twos' });
expect(s.sheets.A!.yahtzeeBonus).toBe(0);
});
test('forced joker: own upper box first, then lower boxes (large straight 40), then any upper box at 0', () => {
let s = make({ scoring: 'yahtzee', yahtzeeJoker: 'forced' }, ['A']);
s.sheets.A!.scores = { yacht: 50 };
s = rolled(s, 'A', [5, 5, 5, 5, 5]);
expect(reject(s, 'A', { type: 'score', category: 'largeStraight' })).toBe('이 요트는 파이브 칸에 먼저 넣어야 해요.');
expect(reject(s, 'A', { type: 'score', category: 'choice' })).not.toBeNull();
expect(yacht.view(s, 'A').allowed).toEqual(['fives']);
expect(reject(s, 'A', { type: 'score', category: 'fives' })).toBeNull();
s.sheets.A!.scores = { yacht: 50, fives: 15 };
expect(reject(s, 'A', { type: 'score', category: 'ones' })).toBe('이 요트는 아래쪽 칸에 먼저 넣어야 해요.');
const out = actE(s, 'A', { type: 'score', category: 'largeStraight' });
expect(out.events[0]).toMatchObject({ points: 40, bonus: 100, joker: true });
let t = make({ scoring: 'yahtzee', yahtzeeJoker: 'forced' }, ['A']);
t.sheets.A!.scores = { yacht: 0, fives: 15, threeKind: 10, fourKind: 0, fullHouse: 25, smallStraight: 30, largeStraight: 40, choice: 20 };
t = rolled(t, 'A', [5, 5, 5, 5, 5]);
expect(yacht.view(t, 'A').allowed).toEqual(['ones', 'twos', 'threes', 'fours', 'sixes']);
const z = actE(t, 'A', { type: 'score', category: 'twos' });
expect(z.events[0]).toMatchObject({ points: 0, bonus: 0 });
});
test('free joker: any open box; fixed scores only when the own upper box is filled', () => {
let s = make({ scoring: 'yahtzee', yahtzeeJoker: 'free' }, ['A']);
s.sheets.A!.scores = { yacht: 50 };
s = rolled(s, 'A', [4, 4, 4, 4, 4]);
expect(reject(s, 'A', { type: 'score', category: 'fullHouse' })).toBeNull();
expect(yacht.view(s, 'A').preview).toMatchObject({ fullHouse: 0, largeStraight: 0, fours: 20 });
s.sheets.A!.scores = { yacht: 50, fours: 16 };
expect(yacht.view(s, 'A').preview).toMatchObject({ fullHouse: 25, smallStraight: 30, largeStraight: 40, ones: 0 });
expect(yacht.view(s, 'A').previewBonus).toBe(100);
});
});
// ---------------------------------------------------------------- timeout, leave, concede
describe('timeout, leaving and conceding (§8.5, §10)', () => {
test('timeout without a roll: rolls, then scores the best category', () => {
const s = make();
expect(yacht.onTimeout(s, 'A')).toEqual({ type: 'timeout' });
expect(yacht.actionSchema.safeParse({ type: 'timeout' }).success).toBe(false);
const out = actE(s, 'A', yacht.onTimeout(s, 'A'));
const types = out.events.map((e) => e.type);
expect(types.slice(0, 3)).toEqual(['rolled', 'timeoutAuto', 'scored']);
const dice = (out.events[0] as unknown as { dice: number[] }).dice;
const scored = out.events[2] as unknown as { category: Category; points: number; auto: boolean };
expect(scored.category).toBe(autoCategory(dice, { scores: {}, yahtzeeBonus: 0 }, Y51));
expect(scored.auto).toBe(true);
expect(cur(out.state)).toBe('B');
expect(out.state.autoStreak.A).toBe(1);
});
test('timeout when every open category scores 0 → sacrifice order', () => {
let s = make({}, ['A']);
s.sheets.A!.scores = { twos: 4, threes: 9, fours: 8, fives: 10, sixes: 12, choice: 20, fourKind: 0, fullHouse: 0, smallStraight: 15, largeStraight: 30 };
s = rolled(s, 'A', [2, 2, 3, 4, 6]);
s = act(s, 'A', { type: 'timeout' });
expect(s.sheets.A!.scores.ones).toBe(0);
expect(s.sheets.A!.scores.yacht).toBeUndefined();
});
test('deadline is per turn (not reset by rolls); 0 = no limit', () => {
let s = yacht.setup({ players: ['A', 'B'], options: yacht.optionsSchema.parse({ turnSeconds: 30 }), rng: rng(), now: 1000 });
expect(yacht.deadline!(s)).toBe(31_000);
s = yacht.apply(s, cur(s), { type: 'roll' }, { rng: rng(), now: 5000 }).state;
expect(yacht.deadline!(s)).toBe(31_000);
s = yacht.apply(s, cur(s), { type: 'score', category: 'choice' }, { rng: rng(), now: 9000 }).state;
expect(yacht.deadline!(s)).toBe(39_000);
const n = yacht.setup({ players: ['A'], options: yacht.optionsSchema.parse({ turnSeconds: 0 }), rng: rng(), now: 0 });
expect(yacht.deadline!(n)).toBeNull();
});
test('a disconnected player is auto-played via timeout without blocking others; 3 autos → away', () => {
let s = make();
for (let i = 0; i < 3; i++) {
s = act(s, 'A', yacht.onTimeout(s, 'A'));
s = act(rolled(s, 'B', [1, 2, 3, 4, 5]), 'B', { type: 'score', category: CATEGORIES.yacht51[11 - i]! });
s = act(rolled(s, 'C', [1, 2, 3, 4, 5]), 'C', { type: 'score', category: CATEGORIES.yacht51[11 - i]! });
}
expect(yacht.view(s, 'B').sheets.A!.away).toBe(true);
expect(s.round).toBe(4);
s = act(s, 'A', { type: 'roll' });
expect(yacht.view(s, 'B').sheets.A!.away).toBe(false);
});
test('concede: open categories become 0, the player is skipped, and still appears in the result', () => {
let s = make();
s = act(rolled(s, 'A', [6, 6, 6, 6, 6]), 'A', { type: 'score', category: 'yacht' });
// B concedes during their own turn → C moves next.
expect(yacht.onLeave!(s, 'B')).toEqual({ type: 'concede' });
const out = actE(s, 'B', { type: 'concede' });
s = out.state;
expect(out.events.map((e) => e.type)).toContain('conceded');
expect(Object.values(s.sheets.B!.scores)).toEqual(Array(12).fill(0));
expect(cur(s)).toBe('C');
expect(reject(s, 'B', { type: 'roll' })).toBe('이미 기권했어요.');
expect(yacht.onLeave!(s, 'B')).toBeNull();
// Play to the end with A and C.
while (s.phase === 'playing') {
const p = cur(s);
expect(p).not.toBe('B');
s = act(s, p, yacht.onTimeout(s, p));
}
const r = yacht.result(s)!;
expect(r.ranking.flat().sort()).toEqual(['A', 'B', 'C']);
expect(r.ranking.at(-1)).toEqual(['B']);
expect(r.scores!.B).toBe(0);
});
test('a non-current player may concede; when only one player remains the game ends', () => {
let s = make({}, ['A', 'B']);
s = act(s, 'B', { type: 'concede' });
expect(s.phase).toBe('finished');
const r = yacht.result(s)!;
expect(r.ranking).toEqual([['A'], ['B']]);
expect(r.reason).toBe('resign');
expect(r.summary).toContain('기권');
});
test('solo concede ends the game at once', () => {
let s = make({}, ['S']);
s = act(s, 'S', { type: 'concede' });
expect(s.phase).toBe('finished');
expect(yacht.result(s)).toMatchObject({ ranking: [['S']], reason: 'abandoned' });
});
});
// ---------------------------------------------------------------- view, options
describe('view and options', () => {
test('preview for the current player always; for others only with showPreview', () => {
let s = rolled(make({ showPreview: false }), 'A', [6, 6, 6, 6, 2]);
expect(yacht.view(s, 'A').preview).toMatchObject({ sixes: 24, fourKind: 26, fullHouse: 0 });
expect(yacht.view(s, 'B').preview).toBeNull();
expect(yacht.view(s, null).preview).toBeNull();
s = rolled(make(), 'A', [6, 6, 6, 6, 2]);
expect(yacht.view(s, null).preview).toMatchObject({ sixes: 24 });
expect(yacht.view(make(), 'A').preview).toBeNull();
});
test('no RNG / hidden state in the view; same seed + actions → same dice', () => {
const run = () => {
const r = SeededRng.fromSeed(seedN(77));
let s = yacht.setup({ players: ['A', 'B'], options: yacht.defaultOptions, rng: r, now: 0 });
const seen: number[][] = [];
for (let i = 0; i < 6; i++) {
s = yacht.apply(s, cur(s), { type: 'roll' }, { rng: r, now: i }).state;
seen.push(s.dice!.slice());
s = yacht.apply(s, cur(s), { type: 'timeout' }, { rng: r, now: i }).state;
}
return { seen, view: JSON.stringify(yacht.view(s, 'A')) };
};
const a = run();
expect(run()).toEqual(a);
expect(a.view).not.toMatch(/rng|seed/i);
// No hidden information: with showPreview every viewer gets the same view.
const r = rolled(make(), 'A', [1, 2, 3, 4, 5]);
expect(JSON.stringify(yacht.view(r, 'B'))).toBe(JSON.stringify(yacht.view(r, 'A')));
expect(JSON.stringify(yacht.view(r, null))).toBe(JSON.stringify(yacht.view(r, 'A')));
});
test('schema defaults, Korean metadata, client-only actions', () => {
expect(yacht.defaultOptions).toEqual({ scoring: 'yacht51', fullHouseAllowsYacht: true, yahtzeeJoker: 'forced', turnSeconds: 60, showPreview: true });
const shape = (yacht.optionsSchema as unknown as { shape: Record<string, { meta(): { title?: string } | undefined }> }).shape;
for (const [k, v] of Object.entries(shape)) expect(v.meta()?.title, k).toBeTruthy();
// Trademark note (§12): no "Yahtzee"/"야찌" in any visible title or label.
const visible = Object.values(shape).map((v) => {
const m = v.meta() as { title?: string; labels?: Record<string, string> };
return [m.title, ...Object.values(m.labels ?? {})].join(' ');
});
expect(visible.join(' ')).not.toMatch(/yahtzee|야찌/i);
expect(yacht.optionsSchema.safeParse({ turnSeconds: 45 }).success).toBe(false);
expect(yacht.actionSchema.safeParse({ type: 'hold', held: [true, false] }).success).toBe(false);
expect(yacht.actionSchema.safeParse({ type: 'score', category: 'nope' }).success).toBe(false);
expect(yacht.actionSchema.safeParse({ type: 'score', category: 'yacht' }).success).toBe(true);
expect(yacht.minPlayers).toBe(1);
expect(yacht.maxPlayers).toBe(8);
expect(() => yacht.setup({ players: 'ABCDEFGHI'.split(''), options: yacht.defaultOptions, rng: rng(), now: 0 })).toThrow();
expect(make({ scoring: 'yahtzee' }).totalRounds).toBe(13);
});
});
// ---------------------------------------------------------------- properties
describe('random playouts (docs/09 §7)', () => {
/** Checks every transition: scored values match scoreFor on the previous dice, totals add up, onTimeout is legal. */
function checker() {
let prev: YachtState | null = null;
return (st: unknown) => {
const s = st as YachtState;
const cats = CATEGORIES[s.options.scoring];
for (const p of s.order) {
const sh = s.sheets[p]!;
for (const k of Object.keys(sh.scores)) if (!cats.includes(k as Category)) throw new Error(`foreign category ${k}`);
const t = totals(sh);
if (t.total !== t.upper + t.upperBonus + t.lower + t.yahtzeeBonus) throw new Error('totals');
if (Object.values(sh.scores).some((v) => v < 0 || v > 50)) throw new Error('score out of range');
if (prev) {
const before = prev.sheets[p]!;
const added = cats.filter((c) => before.scores[c] === undefined && sh.scores[c] !== undefined);
if (added.length > 0 && !s.left.includes(p)) {
if (added.length !== 1) throw new Error('more than one category filled');
const c = added[0]!;
const rules = { scoring: s.options.scoring, fullHouseAllowsYacht: s.options.fullHouseAllowsYacht, yahtzeeJoker: s.options.yahtzeeJoker };
const dice = prev.dice ?? s.lastScore!.dice;
const exp = scoreFor(dice, c, before, rules);
if (sh.scores[c] !== exp.points || sh.yahtzeeBonus - before.yahtzeeBonus !== exp.yahtzeeBonus) throw new Error(`score mismatch ${c}`);
}
}
}
if (s.phase === 'playing') {
if (s.rollsUsed < 0 || s.rollsUsed > 3) throw new Error('rollsUsed');
if ((s.dice === null) !== (s.rollsUsed === 0)) throw new Error('dice / rollsUsed mismatch');
if (s.dice && s.dice.some((d) => !Number.isInteger(d) || d < 1 || d > 6)) throw new Error('die value');
if (s.round < 1 || s.round > s.totalRounds) throw new Error('round');
for (const p of yacht.activePlayers(s)) {
const a = yacht.onTimeout(s, p);
const v = yacht.validate(s, p, a);
if (!v.ok) throw new Error(`onTimeout illegal: ${v.reason}`);
yacht.apply(s, p, a, { rng: SeededRng.fromSeed([1, 2, 3, 4]), now: 0 });
}
} else {
const r = yacht.result(s)!;
// Normal end: every sheet is full. Early end only when at most one player is still in.
const stillIn = s.order.filter((p) => !s.left.includes(p));
if (r.reason === 'normal') {
for (const p of s.order) if (Object.keys(s.sheets[p]!.scores).length !== s.totalRounds) throw new Error('unfinished sheet');
} else if (stillIn.length > 1) throw new Error('ended early with players left');
for (const p of s.order) if (r.scores![p] !== totals(s.sheets[p]!).total) throw new Error('result total');
}
prev = s;
};
}
test('1,000 seeds: finish, scores consistent, onTimeout legal, deterministic', () => {
for (let i = 1; i <= 1000; i++) {
const n = i % 10 === 0 ? 8 : 1 + (i % 4);
const players = Array.from({ length: n }, (_, k) => `P${k}`);
const options = yacht.optionsSchema.parse({
scoring: i % 3 === 0 ? 'yahtzee' : 'yacht51',
fullHouseAllowsYacht: i % 2 === 0,
yahtzeeJoker: i % 6 === 0 ? 'free' : 'forced',
});
const a = randomPlayout(yacht, { players, options, seed: seedN(i), maxSteps: 20_000, invariant: checker() });
if (!a.finished) throw new Error(`seed ${i} did not finish`);
const r = yacht.result(a.finalState as YachtState)!;
expect(r.ranking.flat().sort()).toEqual([...players].sort());
if (i % 25 === 1) {
const b = randomPlayout(yacht, { players, options, seed: seedN(i), maxSteps: 20_000 });
expect(JSON.stringify(b.finalState)).toBe(JSON.stringify(a.finalState));
}
}
}, 60_000);
test('200 seeds with random concedes and timeouts', () => {
for (let i = 1; i <= 200; i++) {
const players = Array.from({ length: 1 + (i % 5) }, (_, k) => `P${k}`);
const r = SeededRng.fromSeed(seedN(i));
const chooser = SeededRng.fromSeed(seedN(i + 999));
const check = checker();
let s = yacht.setup({ players, options: yacht.optionsSchema.parse({ scoring: i % 2 ? 'yahtzee' : 'yacht51' }), rng: r, now: 0 });
let steps = 0;
while (s.phase === 'playing') {
if (++steps > 5000) throw new Error('too long');
check(s);
const roll = chooser.int(100);
let actor = cur(s);
let a: YachtAction;
if (roll < 2) {
const alive = players.filter((p) => !s.left.includes(p));
actor = alive[chooser.int(alive.length)]!;
a = { type: 'concede' };
} else if (roll < 12) a = yacht.onTimeout(s, actor);
else {
const legal = yacht.legalActions!(s, actor);
a = legal[chooser.int(legal.length)]!;
}
const v = yacht.validate(s, actor, a);
if (!v.ok) throw new Error(`rejected ${JSON.stringify(a)}: ${v.reason}`);
s = yacht.apply(s, actor, a, { rng: r, now: steps * 1000 }).state;
}
check(s);
expect(yacht.result(s)!.ranking.flat().sort()).toEqual([...players].sort());
}
}, 30_000);
});

View File

@@ -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: 'yacht', nameKo: '요트 다이스', minPlayers: 1, maxPlayers: 8, minutes: '10~30', level: '쉬움', blurb: '주사위 다섯 개로 족보 만들기 · 혼자서도 OK', available: true },
];
export const catalogById = (id: string): CatalogEntry | undefined => CATALOG.find((g) => g.id === id);