From 41ab2cf187896cc78a047e1e40fb2a787667fa3e Mon Sep 17 00:00:00 2001 From: EJClaw Date: Mon, 5 Oct 2026 04:17:15 +0900 Subject: [PATCH] =?UTF-8?q?M4:=20=EC=9A=94=ED=8A=B8=20=EB=8B=A4=EC=9D=B4?= =?UTF-8?q?=EC=8A=A4=20=EA=B7=9C=EC=B9=99=C2=B7=ED=99=94=EB=A9=B4=C2=B7?= =?UTF-8?q?=ED=85=8C=EC=8A=A4=ED=8A=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 규칙 모듈(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 --- apps/web/src/games/registry.ts | 1 + apps/web/src/games/yacht/index.tsx | 667 +++++++++++++++++++++++++ apps/web/src/lib/catalog-ui.ts | 3 +- apps/web/src/pages/RoomPage.tsx | 8 +- e2e/games.e2e.ts | 50 ++ packages/games/src/index.ts | 2 + packages/games/src/yacht/index.ts | 479 ++++++++++++++++++ packages/games/src/yacht/scoring.ts | 211 ++++++++ packages/games/src/yacht/yacht.test.ts | 573 +++++++++++++++++++++ packages/shared/src/catalog.ts | 1 + 10 files changed, 1991 insertions(+), 4 deletions(-) create mode 100644 apps/web/src/games/yacht/index.tsx create mode 100644 packages/games/src/yacht/index.ts create mode 100644 packages/games/src/yacht/scoring.ts create mode 100644 packages/games/src/yacht/yacht.test.ts diff --git a/apps/web/src/games/registry.ts b/apps/web/src/games/registry.ts index 67eb707..ccf4031 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'), + yacht: () => import('./yacht'), }; const cache = new Map(); diff --git a/apps/web/src/games/yacht/index.tsx b/apps/web/src/games/yacht/index.tsx new file mode 100644 index 0000000..3be8cfe --- /dev/null +++ b/apps/web/src/games/yacht/index.tsx @@ -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 = { + 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, id: string) => players[id]?.nickname ?? '알 수 없음'; +const userOf = (players: Record, 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 = { + 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 ( + + {value !== null && + PIPS[value]!.map(([x, y], i) => )} + + ); +} + +/** Small static die for the rules / last-score line. */ +function MiniDie({ value, size = 26 }: { value: number; size?: number }) { + return ( + + + + ); +} + +function DiceRow({ dice, size }: { dice: number[]; size?: number }) { + return ( + + {dice.map((d, i) => ( + + ))} + + ); +} + +// ---------------------------------------------------------------- board + +interface RollAnim { + key: number; + mask: boolean[]; +} + +function YachtBoard({ view: v, me, players, act, pending, tapConfirm }: BoardProps) { + const myTurn = v.phase === 'playing' && !!me && v.current === me; + const [held, setHeld] = useState(v.held); + const [anim, setAnim] = useState(null); + const [flick, setFlick] = useState(null); + const [flash, setFlash] = useState(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(''); + + // 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 ( +
+ +
+
+ + {v.phase === 'finished' ? '게임 끝' : `${v.round} / ${v.totalRounds} 라운드`} + + {v.current && ( + + + {curName} 차례 + + {'●'.repeat(v.rollsUsed)} + {'○'.repeat(3 - v.rollsUsed)} + + + )} +
+
+ {[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 ( + + ); + })} +
+ {myTurn ? ( + + ) : ( +

+ {v.phase === 'finished' ? '게임이 끝났어요' : v.current ? `${nick(players, v.current)}님이 굴리는 중이에요` : ''} +

+ )} + {myTurn && v.rollsUsed > 0 && v.rollsUsed < 3 &&

남길 주사위를 누르면 고정돼요. 점수표의 칸을 누르면 기록해요.

} + {v.lastScore && } +
+ +

+ {announce} +

+ {flash && ( +
+ {flash} +
+ )} + {confirm && ( + act({ type: 'score', category: confirm.category })} + onClose={() => setConfirm(null)} + /> + )} +
+ ); +} + +function LastScore({ entry: e, view: v, me, players }: { entry: ScoreEntry; view: YachtView; me: string | null; players: Record }) { + return ( +

+ 방금: + {e.player === me ? '나' : nick(players, e.player)} + + + → {catName(e.category, v.options.scoring)} {e.points}점{e.bonus ? ` +${e.bonus}` : ''} + {e.auto ? ' (자동)' : ''} + +

+ ); +} + +// ---------------------------------------------------------------- score sheet + +function ScoreSheet({ + view: v, + me, + players, + myTurn, + pending, + onScore, + pointer, +}: { + view: YachtView; + me: string | null; + players: Record; + myTurn: boolean; + pending: boolean; + onScore: (c: Category, points: number, viaMouse: boolean) => void; + pointer: React.MutableRefObject; +}) { + // 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 ( + + {val} + + ); + } + 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 ( + + ); + } + if (prev !== undefined && v.options.showPreview) return {prev}; + return null; + }; + + const header = ( + + + 칸 + + {cols.map((p) => { + const sh = v.sheets[p]!; + return ( + + {p === me ? '나' : nick(players, p)} + {sh.left &&
기권
} + {!sh.left && sh.away &&
자리 비움
} + + ); + })} + + ); + + const row = (c: Category) => ( + + + {catName(c, v.options.scoring)} + + {cols.map((p) => ( + + {cell(p, c)} + + ))} + + ); + + const sumRow = (label: string, f: (s: YachtSheetView) => React.ReactNode, cls = 'yt-sum') => ( + + + {label} + + {cols.map((p) => ( + + {f(v.sheets[p]!)} + + ))} + + ); + + return ( +
+ + {header} + + {upper.map(row)} + {sumRow('소계', (s) => `${s.upper}/${v.upperBonusThreshold}`)} + {sumRow('보너스 +35', (s) => (s.upperBonus ? `+${s.upperBonus}` : {`${Math.max(0, v.upperBonusThreshold - s.upper)} 남음`}))} + {lower.map(row)} + {yz && sumRow('요트 보너스', (s) => (s.yahtzeeBonus ? `+${s.yahtzeeBonus}` : '-'))} + {sumRow('총점', (s) => s.total, 'yt-total')} + +
+
+ ); +} + +// ---------------------------------------------------------------- side panel + +function YachtSide({ view: v, me, players, act, deadline }: BoardProps) { + 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 ( + <> +
+ {v.order.map((p, i) => { + const sh = v.sheets[p]!; + const turn = v.current === p; + return ( +
+ + {i + 1} + + +
+
+ {nick(players, p)} + {p === me && (나)} +
+
+ {sh.left ? '기권' : sh.away ? '자리 비움' : turn ? '굴리는 중' : `${sh.filled}/${v.totalRounds}칸`} +
+
+ + {sh.total} + + {turn && v.phase === 'playing' && } +
+ ); + })} +
+ {mine && !mine.left && ( +
+
+ 상단 보너스 + {mine.upperBonus ? '35점 확보!' : `${v.upperBonusThreshold}까지 ${Math.max(0, v.upperBonusThreshold - mine.upper)}점 남음`} +
+
+ +
+
+ )} + {v.phase === 'playing' && mine && !mine.left && ( + + )} + {ask && ( + 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 { + 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({ value, options, onChange }: { value: T; options: [T, string][]; onChange: (v: T) => void }) { + return ( +
+ {options.map(([k, l]) => ( + + ))} +
+ ); +} + +function Field({ label, children }: { label: string; children: React.ReactNode }) { + return ( +
+ {label} + {children} +
+ ); +} + +function Options({ value: o, onChange }: OptionsProps) { + const set = (patch: Partial) => onChange({ ...o, ...patch }); + return ( +
+ + set({ scoring })} /> + + {o.scoring === 'yacht51' ? ( + + set({ fullHouseAllowsYacht: k === 'on' })} /> + + ) : ( + + set({ yahtzeeJoker })} /> + + )} + + set({ turnSeconds })} /> + + + set({ showPreview: k === 'on' })} /> + +
+ ); +} + +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 ( +
+

+ 주사위 5개를 한 차례에 최대 3번 굴려요. 마음에 드는 주사위는 눌러서 고정하고 나머지만 다시 굴릴 수 있어요. 고정은 굴릴 때마다 바꿀 수 있어요. +

+

+ 굴린 뒤에는 점수표의 빈 칸 하나를 골라 지금 주사위로 점수를 적어요. 조건에 맞지 않는 칸에 넣으면 0점이에요. 한 번 적은 칸은 바꿀 수 없고, 3번 굴린 뒤에는 꼭 적어야 해요. +

+

+ {yz ? '13칸' : '12칸'}을 모두 채우면 끝나고, 총점이 가장 높은 사람이 이겨요. 혼자서 최고 점수에 도전할 수도 있어요. +

+
+ {rows.map(([name, rule, dice]) => ( +
+ + + {name} · {rule} + +
+ ))} +
+

+ 상단 보너스: 에이스~식스 6칸의 합이 63점 이상이면 35점을 더 받아요(눈마다 3개씩 모으면 딱 63점이에요). +

+ {yz && ( +

+ 추가 요트: 요트 칸에 50점을 적은 뒤 요트가 또 나오면 100점을 더 받아요.{' '} + {options?.yahtzeeJoker === 'free' + ? '이때는 아무 빈 칸에나 적을 수 있어요.' + : '이때는 그 눈의 윗칸이 비어 있으면 거기에 먼저 적어야 하고, 윗칸이 차 있으면 아래쪽 칸에 적어요.'}{' '} + 그 눈의 윗칸이 이미 차 있으면 풀 하우스 25점, 스트레이트 30·40점을 조건과 상관없이 인정해요. +

+ )} +

+ 시간 안에 적지 않으면 지금 주사위로 가장 높은 점수가 나오는 칸에 자동으로 적혀요(아직 안 굴렸으면 먼저 굴려요). 기권하면 남은 칸은 모두 0점이 돼요. +

+
+ ); +} + +const ui: GameUI = { + Board: YachtBoard, + Side: YachtSide, + 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..45edc0f 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: '◐', + yacht: '🎲', }; /** Shown on the home screen so users know what's coming (docs/12-roadmap.md). */ -export const UPCOMING = ['윷놀이', '원카드', '숫자 타일', '다빈치 코드', '요트 다이스', '마작', '고스톱', '뱅', '할리갈리']; +export const UPCOMING = ['윷놀이', '원카드', '숫자 타일', '다빈치 코드', '마작', '고스톱', '뱅', '할리갈리']; diff --git a/apps/web/src/pages/RoomPage.tsx b/apps/web/src/pages/RoomPage.tsx index cca2ebf..a4f7f91 100644 --- a/apps/web/src/pages/RoomPage.tsx +++ b/apps/web/src/pages/RoomPage.tsx @@ -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; diff --git a/e2e/games.e2e.ts b/e2e/games.e2e.ts index 8d6700d..05c359a 100644 --- a/e2e/games.e2e.ts +++ b/e2e/games.e2e.ts @@ -77,6 +77,56 @@ const PLANS: Record = { 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(); + 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) => (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']], diff --git a/packages/games/src/index.ts b/packages/games/src/index.ts index 6afc773..88a29a7 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 { yacht } from './yacht'; export const GAMES: Record = { [omok.id]: omok, @@ -15,4 +16,5 @@ export const GAMES: Record = { [baduk.id]: baduk, [seotda.id]: seotda, [poker.id]: poker, + [yacht.id]: yacht, }; diff --git a/packages/games/src/yacht/index.ts b/packages/games/src/yacht/index.ts new file mode 100644 index 0000000..d40d8dc --- /dev/null +++ b/packages/games/src/yacht/index.ts @@ -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 = (...vals: T[]) => + z.union(vals.map((v) => z.literal(v)) as unknown as [z.ZodLiteral, z.ZodLiteral, ...z.ZodLiteral[]]); + +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; + +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 | { 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; + /** 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; + lastScore: ScoreEntry | null; +} + +export interface YachtSheetView extends SheetTotals { + scores: Partial>; + 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>>; + 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> | null; + /** Yacht bonus that scoring now would add (13칸 변형). */ + previewBonus: number; + sheets: Record; + 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 = { + id: 'yacht', + nameKo: '요트 다이스', + minPlayers: 1, + maxPlayers: 8, + stateVersion: 1, + defaultOptions: yachtOptionsSchema.parse({}), + optionsSchema: yachtOptionsSchema, + actionSchema: yachtActionSchema as z.ZodType, + + 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 = {}; + const autoStreak: Record = {}; + 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(); + for (const row of r.ranking) groups.set(row.rank, [...(groups.get(row.rank) ?? []), row.player]); + const scores: Record = {}; + 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; + }, +}; diff --git a/packages/games/src/yacht/scoring.ts b/packages/games/src/yacht/scoring.ts new file mode 100644 index 0000000..31c3405 --- /dev/null +++ b/packages/games/src/yacht/scoring.ts @@ -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>; + /** `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 = { + 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 = { + 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> = { 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; +} diff --git a/packages/games/src/yacht/yacht.test.ts b/packages/games/src/yacht/yacht.test.ts new file mode 100644 index 0000000..d008e66 --- /dev/null +++ b/packages/games/src/yacht/yacht.test.ts @@ -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; + +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 = {}, 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 }).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 }; + 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); +}); diff --git a/packages/shared/src/catalog.ts b/packages/shared/src/catalog.ts index e91dbf2..51a5494 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: '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);