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:
@@ -9,6 +9,7 @@ export const GAME_UI: Record<string, () => Promise<{ default: GameUI }>> = {
|
|||||||
baduk: () => import('./baduk'),
|
baduk: () => import('./baduk'),
|
||||||
seotda: () => import('./seotda'),
|
seotda: () => import('./seotda'),
|
||||||
poker: () => import('./poker'),
|
poker: () => import('./poker'),
|
||||||
|
yacht: () => import('./yacht'),
|
||||||
};
|
};
|
||||||
|
|
||||||
const cache = new Map<string, GameUI>();
|
const cache = new Map<string, GameUI>();
|
||||||
|
|||||||
667
apps/web/src/games/yacht/index.tsx
Normal file
667
apps/web/src/games/yacht/index.tsx
Normal 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;
|
||||||
@@ -8,7 +8,8 @@ export const GAME_ICON: Record<string, string> = {
|
|||||||
mahjong: '🀇',
|
mahjong: '🀇',
|
||||||
yut: '🪵',
|
yut: '🪵',
|
||||||
othello: '◐',
|
othello: '◐',
|
||||||
|
yacht: '🎲',
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Shown on the home screen so users know what's coming (docs/12-roadmap.md). */
|
/** Shown on the home screen so users know what's coming (docs/12-roadmap.md). */
|
||||||
export const UPCOMING = ['윷놀이', '원카드', '숫자 타일', '다빈치 코드', '요트 다이스', '마작', '고스톱', '뱅', '할리갈리'];
|
export const UPCOMING = ['윷놀이', '원카드', '숫자 타일', '다빈치 코드', '마작', '고스톱', '뱅', '할리갈리'];
|
||||||
|
|||||||
@@ -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 seated = room.seats.filter(Boolean).map((s) => s!.user.id);
|
||||||
const amSeated = seated.includes(me.id);
|
const amSeated = seated.includes(me.id);
|
||||||
const voted = room.rematchVotes.includes(me.id);
|
const voted = room.rematchVotes.includes(me.id);
|
||||||
const allFirst = result.ranking.length === 1;
|
// Solo games (e.g. 요트 다이스) have one ranked player: neither a draw nor a win.
|
||||||
const won = !allFirst && result.ranking[0]?.includes(me.id);
|
const solo = result.ranking.flat().length === 1;
|
||||||
const lost = !allFirst && !won && result.ranking.some((g) => g.includes(me.id));
|
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(() => {
|
useEffect(() => {
|
||||||
if (played.current) return;
|
if (played.current) return;
|
||||||
played.current = true;
|
played.current = true;
|
||||||
|
|||||||
@@ -77,6 +77,56 @@ const PLANS: Record<string, Plan> = {
|
|||||||
await finishHand(pages);
|
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: {
|
othello: {
|
||||||
// cursor starts at d4 (3,3): black d3 = up; then white c3 = up, left
|
// cursor starts at d4 (3,3): black d3 = up; then white c3 = up, left
|
||||||
moves: [['ArrowUp', 'Enter'], ['ArrowUp', 'ArrowLeft', 'Enter']],
|
moves: [['ArrowUp', 'Enter'], ['ArrowUp', 'ArrowLeft', 'Enter']],
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { janggi } from './janggi';
|
|||||||
import { baduk } from './baduk';
|
import { baduk } from './baduk';
|
||||||
import { seotda } from './seotda';
|
import { seotda } from './seotda';
|
||||||
import { poker } from './poker';
|
import { poker } from './poker';
|
||||||
|
import { yacht } from './yacht';
|
||||||
|
|
||||||
export const GAMES: Record<string, AnyGameDefinition> = {
|
export const GAMES: Record<string, AnyGameDefinition> = {
|
||||||
[omok.id]: omok,
|
[omok.id]: omok,
|
||||||
@@ -15,4 +16,5 @@ export const GAMES: Record<string, AnyGameDefinition> = {
|
|||||||
[baduk.id]: baduk,
|
[baduk.id]: baduk,
|
||||||
[seotda.id]: seotda,
|
[seotda.id]: seotda,
|
||||||
[poker.id]: poker,
|
[poker.id]: poker,
|
||||||
|
[yacht.id]: yacht,
|
||||||
};
|
};
|
||||||
|
|||||||
479
packages/games/src/yacht/index.ts
Normal file
479
packages/games/src/yacht/index.ts
Normal 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;
|
||||||
|
},
|
||||||
|
};
|
||||||
211
packages/games/src/yacht/scoring.ts
Normal file
211
packages/games/src/yacht/scoring.ts
Normal 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;
|
||||||
|
}
|
||||||
573
packages/games/src/yacht/yacht.test.ts
Normal file
573
packages/games/src/yacht/yacht.test.ts
Normal 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);
|
||||||
|
});
|
||||||
@@ -18,6 +18,7 @@ export const CATALOG: CatalogEntry[] = [
|
|||||||
{ id: 'baduk', nameKo: '바둑', minPlayers: 2, maxPlayers: 2, minutes: '15~60', level: '어려움', blurb: '집을 더 많이 지은 쪽이 승리', available: true },
|
{ 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: '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: '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);
|
export const catalogById = (id: string): CatalogEntry | undefined => CATALOG.find((g) => g.id === id);
|
||||||
|
|||||||
Reference in New Issue
Block a user