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:
@@ -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,
|
||||
};
|
||||
|
||||
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);
|
||||
});
|
||||
Reference in New Issue
Block a user