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

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

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -18,6 +18,7 @@ export const CATALOG: CatalogEntry[] = [
{ id: 'baduk', nameKo: '바둑', minPlayers: 2, maxPlayers: 2, minutes: '15~60', level: '어려움', blurb: '집을 더 많이 지은 쪽이 승리', available: true },
{ id: 'seotda', nameKo: '섯다', minPlayers: 2, maxPlayers: 10, minutes: '20~40', level: '보통', blurb: '화투 두 장으로 겨루는 족보 승부 · 놀이용 칩', available: true },
{ id: 'poker', nameKo: '포커', minPlayers: 2, maxPlayers: 10, minutes: '20~60', level: '보통', blurb: '홀덤·7포커·인디언 등 7가지 모드 · 놀이용 칩', available: true },
{ id: 'yacht', nameKo: '요트 다이스', minPlayers: 1, maxPlayers: 8, minutes: '10~30', level: '쉬움', blurb: '주사위 다섯 개로 족보 만들기 · 혼자서도 OK', available: true },
];
export const catalogById = (id: string): CatalogEntry | undefined => CATALOG.find((g) => g.id === id);