M4: 윷놀이 규칙·화면·테스트

- 규칙(packages/games/yut): 29칸 윷판 그래프(모서리·방은 멈췄다 출발할 때만 꺾음, 방에서는 항상 참먹이 쪽),
  가락 단위 확률(realistic 0.6 / fair 0.5, 빽도 표시 가락, 낙), 윷·모·잡기 추가 던지기, 모아 둔 결과 자유 순서,
  쓸 수 없는 빽도 자동 버림, 빽도는 왔던 길(trail)로, 도 자리 빽도(참먹이/바로 남), pass/reach 날 판정,
  업기·잡기(지나가면 안 잡음), 개인전 2~4명(1등 종료/꼴찌까지), 팀전 4·6·8명(청·홍 번갈아, 팀원 순환),
  기권(팀은 전원 기권 시 패배), 시간 초과 자동 행동(잡기 > 내기 > 업기 > 뒤처진 말 > 올리기), 3연속 초과 시 자리 비움(5초)
- 테스트: 문서 10절 시나리오 + 확률 10만 회(±0.5%p), 무작위 1,000판(불변식·onTimeout 합법·결정성·리플레이),
  자동 진행·무작위 기권 60판, apply p99 < 5ms. 서버 방 테스트 2개(팀전 인원 검사, 연결 끊김 자동 진행·재접속 View)
- 화면(apps/web/games/yut): SVG 윷판·말(편마다 색+모양), 윷가락 던지기 애니메이션, 결과 칩 → 말 → 한 번 더 눌러 확정,
  경로 미리보기와 잡기!/날!/업기 배지, 자동 이동, 칸마다 통통 이동 애니메이션, 진행 기록, 옵션·규칙 설명, 키보드 조작
- 엔진/서버(하위 호환): GameDefinition.playerCountError(옵션별 인원 규칙) 추가, 방 시작 시 검사.
  방 인원이 게임 최대였으면 옵션 변경으로 범위가 바뀔 때 새 최대를 따라감(팀전 8자리)
- 등록: GAMES, 카탈로그(쉬움·15~25분), 화면 레지스트리, 홈의 "준비 중" 목록에서 윷놀이 제거
- E2E: PC 방장 + 휴대폰 친구, 말 2개로 실제 화면 조작(키보드 2단계 확정)으로 끝까지, 다크 모드 화면 저장

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
EJClaw
2026-10-05 04:29:46 +09:00
parent 5dd8e6ea07
commit 2610e37d49
12 changed files with 2706 additions and 1 deletions

View File

@@ -47,6 +47,8 @@ export interface GameDefinition<S, A, V, O> {
optionsSchema: ZodType<O>;
actionSchema: ZodType<A>;
playersFor?(options: O): { min: number; max: number };
/** Extra player-count rule `playersFor` cannot express (e.g. even counts only); Korean reason or null. */
playerCountError?(options: O, count: number): string | null;
setup(ctx: SetupContext<O>): S;
validate(state: S, actor: PlayerId, action: A): Validation;
apply(state: S, actor: PlayerId, action: A, ctx: ApplyContext): { state: S; events: GameEvent[] };

View File

@@ -6,6 +6,7 @@ import { janggi } from './janggi';
import { baduk } from './baduk';
import { seotda } from './seotda';
import { poker } from './poker';
import { yut } from './yut';
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,
[yut.id]: yut,
};

View File

@@ -0,0 +1,152 @@
/**
* 윷판 그래프와 윷가락 (docs/games/yut.md §5.1, §5.2, §6).
* Pure helpers shared by the rules module, its tests and the screen (node names, throw labels).
*/
import type { Rng } from '@bg/engine';
/** 0..28, see the table in §5.2. 0 = 참먹이(출발·도착), 22 = 방. */
export type NodeId = number;
export type Throw = 'do' | 'gae' | 'geol' | 'yut' | 'mo' | 'backdo';
export type Sticks = [boolean, boolean, boolean, boolean];
export const NODE_COUNT = 29;
export const GOAL = 'goal' as const;
export const NODE_NAMES: readonly string[] = [
'참먹이', '도', '개', '걸', '윷', '모', '뒷도', '뒷개', '뒷걸', '뒷윷',
'뒷모', '찌도', '찌개', '찌걸', '찌윷', '찌모', '날도', '날개', '날걸', '날윷',
'앞모도', '앞모개', '방', '속윷', '속모', '뒷모도', '뒷모개', '사려', '안찌',
];
export const THROW_STEPS: Record<Throw, number> = { do: 1, gae: 2, geol: 3, yut: 4, mo: 5, backdo: -1 };
export const THROW_KO: Record<Throw, string> = { do: '도', gae: '개', geol: '걸', yut: '윷', mo: '모', backdo: '빽도' };
/** Display order of the result chips. */
export const THROW_ORDER: Throw[] = ['backdo', 'do', 'gae', 'geol', 'yut', 'mo'];
/**
* One step forward (§5.2 table). `starting` = this step leaves the node the piece stood on,
* `prev` = the node passed just before `node` within the same move (only matters at 방).
* Returns GOAL when the piece leaves 참먹이 after a lap.
*/
export function step(node: NodeId | 'home', starting: boolean, prev: NodeId | null): NodeId | typeof GOAL {
if (node === 'home') return 1;
if (node === 0) return GOAL;
if (starting) {
if (node === 5) return 20;
if (node === 10) return 25;
if (node === 22) return 27;
}
switch (node) {
case 19:
return 0;
case 22:
return prev === 26 ? 27 : 23;
case 24:
return 15;
case 26:
return 22;
case 28:
return 0;
default:
return node + 1; // 1..18 outer ring, 20→21, 21→22, 23→24, 25→26, 27→28
}
}
export interface Advance {
/** Nodes entered, in order (the start node is not included; GOAL is not a node). */
path: NodeId[];
/** Final node, or GOAL when the piece finished. */
to: NodeId | typeof GOAL;
}
/**
* Moves `n` (≥ 1) steps forward from `from`. Reaching GOAL drops the remaining steps.
* With `reach`, arriving at 참먹이 after a lap (always the case when entering 0 by moving forward) finishes at once.
*/
export function advance(from: NodeId | 'home', n: number, finishRule: 'pass' | 'reach'): Advance {
let cur: NodeId | 'home' = from;
let prev: NodeId | null = null;
const path: NodeId[] = [];
for (let i = 0; i < n; i++) {
const nxt = step(cur, i === 0, prev);
if (nxt === GOAL) return { path, to: GOAL };
path.push(nxt);
prev = cur === 'home' ? null : cur;
cur = nxt;
if (cur === 0 && finishRule === 'reach') return { path, to: GOAL };
}
return { path, to: cur as NodeId };
}
/** Fallback predecessor when a piece has no usable trail (only after 참먹이/빽도 corner cases). */
export function defaultPrev(node: NodeId): NodeId {
switch (node) {
case 0:
return 19;
case 20:
return 5;
case 25:
return 10;
case 22:
return 21;
case 23:
return 22;
case 27:
return 22;
default:
return node - 1; // 2..19, 21, 24, 26, 28
}
}
/** Minimum steps to finish under `pass` (§6), index = node; HOME = 12. `reach` subtracts 1. */
const DIST_PASS: readonly number[] = [1, 11, 10, 9, 8, 7, 11, 10, 9, 8, 7, 10, 9, 8, 7, 6, 5, 4, 3, 2, 6, 5, 4, 8, 7, 6, 5, 3, 2];
export const HOME_DIST_PASS = 12;
export function remainingDistance(node: NodeId | 'home', finishRule: 'pass' | 'reach'): number {
const d = node === 'home' ? HOME_DIST_PASS : DIST_PASS[node]!;
return finishRule === 'reach' ? d - 1 : d;
}
/** Maps the four sticks (true = 배(flat side) up; index 0 is the 빽도-marked stick) to a result (§5.1). */
export function throwOf(sticks: Sticks, backdo: boolean): Throw {
const k = sticks.filter(Boolean).length;
switch (k) {
case 0:
return 'mo';
case 1:
return backdo && sticks[0] ? 'backdo' : 'do';
case 2:
return 'gae';
case 3:
return 'geol';
default:
return 'yut';
}
}
export const FLAT_P = { realistic: 0.6, fair: 0.5 } as const;
/** One throw (§8.4): 낙 check first (if enabled), then each stick independently. */
export function rollSticks(
rng: Rng,
o: { stickModel: keyof typeof FLAT_P; backdo: boolean; nakPercent: number },
): { sticks: Sticks; result: Throw } | { sticks: null; result: 'nak' } {
if (o.nakPercent > 0 && rng.next() < o.nakPercent / 100) return { sticks: null, result: 'nak' };
const p = FLAT_P[o.stickModel];
const sticks = [rng.next() < p, rng.next() < p, rng.next() < p, rng.next() < p] as Sticks;
return { sticks, result: throwOf(sticks, o.backdo) };
}
/** Exact result probabilities (§5.1 table). */
export function throwProbabilities(p: number, backdo = true): Record<Throw, number> {
const q = 1 - p;
const one = 4 * p * q ** 3;
return {
backdo: backdo ? p * q ** 3 : 0,
do: backdo ? 3 * p * q ** 3 : one,
gae: 6 * p ** 2 * q ** 2,
geol: 4 * p ** 3 * q,
yut: p ** 4,
mo: q ** 4,
};
}

View File

@@ -0,0 +1,833 @@
/** 윷놀이 (docs/games/yut.md). No hidden information; the RNG state is kept by the runner, never in the state. */
import { z } from 'zod';
import { fail, ok, type GameDefinition, type GameEvent, type GameResult, type PlayerId, type Rng } from '@bg/engine';
import {
GOAL,
THROW_KO,
THROW_STEPS,
advance,
defaultPrev,
remainingDistance,
rollSticks,
type NodeId,
type Sticks,
type Throw,
} from './board';
export * from './board';
// ---------------------------------------------------------------- options
export const yutOptionsSchema = z.object({
mode: z
.enum(['individual', 'team'])
.default('individual')
.meta({ title: '경기 방식', labels: { individual: '개인전(2~4명)', team: '팀전(4·6·8명, 2팀)' } }),
pieces: z
.union([z.literal(2), z.literal(3), z.literal(4), z.literal(5)])
.default(4)
.meta({ title: '한 편의 말 수', labels: { 2: '2개', 3: '3개', 4: '4개', 5: '5개' } }),
stickModel: z
.enum(['realistic', 'fair'])
.default('realistic')
.meta({ title: '윷가락 확률', labels: { realistic: '실제 윷처럼(개·걸이 잘 나오고 모는 드묾)', fair: '반반(앞뒤가 나올 확률이 같음)' } }),
backdo: z.boolean().default(true).meta({ title: '빽도 사용' }),
backdoFromDo: z
.enum(['chammeogi', 'finish'])
.default('chammeogi')
.meta({ title: '도 자리 말이 빽도를 받으면', labels: { chammeogi: '참먹이로 감(다음 전진에서 남)', finish: '바로 남' } }),
finishRule: z
.enum(['pass', 'reach'])
.default('pass')
.meta({ title: '날(완주) 판정', labels: { pass: '참먹이를 지나야 남', reach: '참먹이에 도착하면 남' } }),
nakPercent: z
.union([z.literal(0), z.literal(3), z.literal(5), z.literal(10)])
.default(0)
.meta({ title: '낙(윷가락이 판 밖으로 떨어짐) 확률', labels: { 0: '낙 없음', 3: '3%', 5: '5%', 10: '10%' } }),
backdoCaptureBonus: z.boolean().default(true).meta({ title: '빽도로 잡아도 한 번 더 던지기' }),
ranking: z
.enum(['first', 'all'])
.default('first')
.meta({ title: '3~4명 개인전에서 1등이 나오면', labels: { first: '바로 끝(나머지는 진행도로 순위)', all: '꼴찌가 정해질 때까지 계속' } }),
turnSeconds: z
.union([z.literal(15), z.literal(30), z.literal(60), z.literal(null)])
.default(30)
.meta({ title: '행동 제한 시간', labels: { 15: '15초', 30: '30초', 60: '60초', null: '무제한' } }),
});
export type YutOptions = z.infer<typeof yutOptionsSchema>;
export const yutActionSchema = z.discriminatedUnion('type', [
z.object({ type: z.literal('THROW') }),
z.object({
type: z.literal('MOVE'),
throwIndex: z.number().int().min(0).max(999),
from: z.union([z.literal('home'), z.number().int().min(0).max(28)]),
}),
z.object({ type: z.literal('CONCEDE') }),
]);
/** Client actions plus the system-only timer action (not in the schema). */
export type YutAction = z.infer<typeof yutActionSchema> | { type: 'TIMEOUT' };
export type YutMoveAction = Extract<YutAction, { type: 'MOVE' }>;
// ---------------------------------------------------------------- state
/** 개인전: seat index. 팀전: 0 = 청팀, 1 = 홍팀. */
export type SideId = number;
export interface Piece {
id: number;
status: 'home' | 'board' | 'done';
node: NodeId | null;
/** True while standing on 참먹이 after a lap (every board piece on 0 is lapped). */
lapped: boolean;
/** Nodes visited since entering the board, current node last (for 빽도). Shared by a stack. */
trail: NodeId[];
}
export interface SideState {
id: SideId;
members: PlayerId[];
pieces: Piece[];
finishedRank: number | null;
/** Members who conceded; the side is out when all of them did. */
conceded: PlayerId[];
out: boolean;
outSeq: number | null;
/** Index into `members` of whoever throws next time this side has the turn. */
cursor: number;
}
export interface YutMoveInfo {
no: number;
side: SideId;
player: PlayerId;
throw: Throw;
from: NodeId | 'home';
to: NodeId | typeof GOAL;
path: NodeId[];
count: number;
captured: { side: SideId; count: number }[];
stacked: boolean;
}
export interface YutStats {
throws: Record<Throw | 'nak', number>;
/** Keyed by side id. */
captures: Record<string, number>;
}
export interface YutRankEntry {
rank: number;
players: PlayerId[];
side: SideId;
finishedPieces: number;
remainingDistance: number;
}
export interface YutResult {
ranking: YutRankEntry[];
winners: PlayerId[];
summary: string;
reason: 'normal' | 'resign';
stats: YutStats;
}
export interface YutState {
options: YutOptions;
players: PlayerId[];
sideOf: Record<PlayerId, SideId>;
sides: SideState[];
turnSide: SideId;
current: PlayerId;
phase: 'throw' | 'move' | 'finished';
/** Throws still owed this turn (윷/모/잡기 bonus). 1 when the throw phase starts. */
throwsOwed: number;
/** Results collected this turn, in throw order. */
pending: Throw[];
lastThrow: { no: number; player: PlayerId; sticks: Sticks | null; result: Throw | 'nak' } | null;
lastMove: YutMoveInfo | null;
throwNo: number;
moveNo: number;
/** +1 per applied action (timer key / duplicate guard). */
turnSeq: number;
deadlineAt: number | null;
timeouts: Record<PlayerId, number>;
log: GameEvent[];
stats: YutStats;
nextRank: number;
outCount: number;
final: YutResult | null;
}
// ---------------------------------------------------------------- views
export interface YutLegalMove {
throwIndex: number;
throw: Throw;
from: NodeId | 'home';
to: NodeId | typeof GOAL;
/** Nodes entered (for the dotted preview). */
path: NodeId[];
/** Pieces that move together. */
count: number;
/** Opponent pieces caught. */
captures: number;
stacks: boolean;
finishes: boolean;
}
export interface YutSideView {
id: SideId;
name: string;
members: PlayerId[];
conceded: PlayerId[];
out: boolean;
finishedRank: number | null;
home: number;
onBoard: number;
done: number;
remaining: number;
}
export interface YutStackView {
node: NodeId;
side: SideId;
count: number;
}
export interface YutView {
options: YutOptions;
team: boolean;
me: PlayerId | null;
mySide: SideId | null;
phase: YutState['phase'];
sides: YutSideView[];
stacks: YutStackView[];
turnSide: SideId;
current: PlayerId;
throwsOwed: number;
pending: Throw[];
lastThrow: YutState['lastThrow'];
lastMove: YutMoveInfo | null;
turnSeq: number;
deadlineAt: number | null;
away: PlayerId[];
/** Only for the player to move (null otherwise). */
legalMoves: YutLegalMove[] | null;
/** The move a timeout would play (§8.5), for the player to move. */
suggested: { throwIndex: number; from: NodeId | 'home' } | null;
log: GameEvent[];
stats: YutStats;
result: YutResult | null;
}
// ---------------------------------------------------------------- helpers
const TEAM_NAMES = ['청팀', '홍팀'];
const SOLO_NAMES = ['빨강', '파랑', '초록', '노랑'];
const LOG_MAX = 40;
const AWAY_AFTER = 3;
const AWAY_MS = 5000;
export function sideName(o: Pick<YutOptions, 'mode'>, side: SideId): string {
return o.mode === 'team' ? TEAM_NAMES[side]! : SOLO_NAMES[side]!;
}
export function playerRange(o: Pick<YutOptions, 'mode'>): { min: number; max: number } {
return o.mode === 'team' ? { min: 4, max: 8 } : { min: 2, max: 4 };
}
/** Player-count check that `playersFor` cannot express (팀전은 짝수만). */
export function playerCountError(o: Pick<YutOptions, 'mode'>, n: number): string | null {
if (o.mode === 'team') return n === 4 || n === 6 || n === 8 ? null : '팀전은 4명, 6명, 8명(짝수)으로만 할 수 있어요.';
return n >= 2 && n <= 4 ? null : '개인전은 2~4명이 할 수 있어요.';
}
const sideActive = (x: SideState) => x.finishedRank === null && !x.out;
const memberActive = (x: SideState, i: number) => !x.conceded.includes(x.members[i]!);
/** First active member index at or after `from` (cyclic). */
function memberFrom(x: SideState, from: number): number {
for (let k = 0; k < x.members.length; k++) {
const i = (from + k) % x.members.length;
if (memberActive(x, i)) return i;
}
return from % x.members.length;
}
function pieceDistance(p: Piece, rule: YutOptions['finishRule']): number {
if (p.status === 'done') return 0;
return remainingDistance(p.status === 'home' ? 'home' : p.node!, rule);
}
function sideDistance(x: SideState, rule: YutOptions['finishRule']): number {
return x.pieces.reduce((a, p) => a + pieceDistance(p, rule), 0);
}
const doneCount = (x: SideState) => x.pieces.filter((p) => p.status === 'done').length;
function pushLog(s: YutState, events: GameEvent[], e: GameEvent): void {
events.push(e);
s.log.push(e);
if (s.log.length > LOG_MAX) s.log.splice(0, s.log.length - LOG_MAX);
}
interface Plan {
throwIndex: number;
throw: Throw;
from: NodeId | 'home';
to: NodeId | typeof GOAL;
path: NodeId[];
movers: number[];
trailAfter: NodeId[];
victims: { side: SideId; ids: number[] }[];
stackWith: number[];
}
/** Where `from` would go with `pending[throwIndex]`; null if that is not a legal move. */
function planMove(s: YutState, sideId: SideId, throwIndex: number, from: NodeId | 'home'): Plan | null {
const t = s.pending[throwIndex];
const side = s.sides[sideId];
if (!t || !side) return null;
const o = s.options;
let movers: Piece[];
let to: NodeId | typeof GOAL;
let path: NodeId[];
let trailAfter: NodeId[];
if (from === 'home') {
if (t === 'backdo') return null;
const p = side.pieces.find((x) => x.status === 'home');
if (!p) return null;
movers = [p];
const adv = advance('home', THROW_STEPS[t], o.finishRule);
({ to, path } = adv);
trailAfter = to === GOAL ? [] : [...path];
} else {
movers = side.pieces.filter((x) => x.status === 'board' && x.node === from);
if (movers.length === 0) return null;
const trail = movers[0]!.trail;
if (t === 'backdo') {
if (trail.length >= 2) {
to = trail[trail.length - 2]!;
trailAfter = trail.slice(0, -1);
} else if (from === 1) {
to = o.backdoFromDo === 'finish' ? GOAL : 0;
trailAfter = to === GOAL ? [] : [0];
} else {
to = defaultPrev(from);
trailAfter = [to];
}
path = to === GOAL ? [] : [to];
} else {
const adv = advance(from, THROW_STEPS[t], o.finishRule);
({ to, path } = adv);
trailAfter = to === GOAL ? [] : [...trail, ...path];
}
}
const victims: Plan['victims'] = [];
const stackWith: number[] = [];
if (to !== GOAL) {
for (const x of s.sides) {
const here = x.pieces.filter((p) => p.status === 'board' && p.node === to && !(x.id === sideId && movers.includes(p))).map((p) => p.id);
if (here.length === 0) continue;
if (x.id === sideId) stackWith.push(...here);
else victims.push({ side: x.id, ids: here });
}
}
return { throwIndex, throw: t, from, to, path, movers: movers.map((p) => p.id), trailAfter, victims, stackWith };
}
/** Legal moves of the side to move. Equal results are listed once (first index). Order: index, node, 'home' last. */
function legalPlans(s: YutState): Plan[] {
if (s.phase !== 'move') return [];
const side = s.sides[s.turnSide]!;
const sources: (NodeId | 'home')[] = [...new Set(side.pieces.filter((p) => p.status === 'board').map((p) => p.node!))].sort((a, b) => a - b);
if (side.pieces.some((p) => p.status === 'home')) sources.push('home');
const out: Plan[] = [];
const seen = new Set<Throw>();
s.pending.forEach((t, i) => {
if (seen.has(t)) return;
seen.add(t);
for (const from of sources) {
const p = planMove(s, s.turnSide, i, from);
if (p) out.push(p);
}
});
return out;
}
const toLegal = (p: Plan): YutLegalMove => ({
throwIndex: p.throwIndex,
throw: p.throw,
from: p.from,
to: p.to,
path: p.path,
count: p.movers.length,
captures: p.victims.reduce((a, v) => a + v.ids.length, 0),
stacks: p.stackWith.length > 0,
finishes: p.to === GOAL,
});
/** Deterministic auto move (§8.5): capture > finish > stack > most-behind piece with the biggest result > enter > rest. */
function chooseAuto(s: YutState, plans: Plan[]): Plan {
const rule = s.options.finishRule;
const side = s.sides[s.turnSide]!;
const byTie = (a: Plan, b: Plan) =>
a.throwIndex - b.throwIndex || (a.from === 'home' ? 1 : 0) - (b.from === 'home' ? 1 : 0) || (a.from === 'home' ? 0 : a.from) - (b.from === 'home' ? 0 : (b.from as number));
const pick = (list: Plan[], cmp: (a: Plan, b: Plan) => number) => [...list].sort((a, b) => cmp(a, b) || byTie(a, b))[0];
const caps = plans.filter((p) => p.victims.length > 0);
if (caps.length) return pick(caps, (a, b) => toLegal(b).captures - toLegal(a).captures)!;
const fin = plans.filter((p) => p.to === GOAL);
if (fin.length) return pick(fin, (a, b) => b.movers.length - a.movers.length)!;
const stack = plans.filter((p) => p.stackWith.length > 0);
if (stack.length) return pick(stack, () => 0)!;
const distOf = (p: Plan) => pieceDistance(side.pieces.find((x) => x.id === p.movers[0])!, rule);
const fwd = plans.filter((p) => p.from !== 'home' && p.throw !== 'backdo');
if (fwd.length) return pick(fwd, (a, b) => distOf(b) - distOf(a) || THROW_STEPS[b.throw] - THROW_STEPS[a.throw])!;
const enter = plans.filter((p) => p.from === 'home');
if (enter.length) return pick(enter, () => 0)!;
return pick(plans, () => 0)!;
}
/** The action a timeout plays now (THROW in the throw phase, §8.5 move otherwise). */
export function autoAction(s: YutState): Exclude<YutAction, { type: 'TIMEOUT' }> {
if (s.phase !== 'move') return { type: 'THROW' };
const plans = legalPlans(s);
if (plans.length === 0) return { type: 'THROW' }; // unreachable: stuck results are discarded right away
const p = chooseAuto(s, plans);
return { type: 'MOVE', throwIndex: p.throwIndex, from: p.from };
}
// ---------------------------------------------------------------- transitions
function startTurn(s: YutState, sideId: SideId, events: GameEvent[]): void {
const side = s.sides[sideId]!;
side.cursor = memberFrom(side, side.cursor);
s.turnSide = sideId;
s.current = side.members[side.cursor]!;
s.phase = 'throw';
s.throwsOwed = 1;
s.pending = [];
pushLog(s, events, { type: 'turnChanged', side: sideId, player: s.current });
}
function endTurn(s: YutState, events: GameEvent[]): void {
const side = s.sides[s.turnSide]!;
side.cursor = memberFrom(side, side.cursor + 1);
const n = s.sides.length;
for (let k = 1; k <= n; k++) {
const next = s.sides[(s.turnSide + k) % n]!;
if (sideActive(next)) {
startTurn(s, next.id, events);
return;
}
}
finishGame(s, events);
}
function gameShouldEnd(s: YutState): boolean {
const active = s.sides.filter(sideActive).length;
const anyFinished = s.sides.some((x) => x.finishedRank !== null);
return active <= 1 || (anyFinished && (s.options.ranking === 'first' || s.options.mode === 'team'));
}
function finishGame(s: YutState, events: GameEvent[]): void {
const rule = s.options.finishRule;
const entry = (x: SideState): Omit<YutRankEntry, 'rank'> => ({
players: [...x.members],
side: x.id,
finishedPieces: doneCount(x),
remainingDistance: sideDistance(x, rule),
});
const ranked: YutRankEntry[] = [];
const push = (group: SideState[]) => {
const rank = ranked.length + 1;
for (const x of group) ranked.push({ rank, ...entry(x) });
};
for (const x of s.sides.filter((x) => x.finishedRank !== null).sort((a, b) => a.finishedRank! - b.finishedRank!)) push([x]);
const active = s.sides
.filter(sideActive)
.sort((a, b) => doneCount(b) - doneCount(a) || sideDistance(a, rule) - sideDistance(b, rule));
for (let i = 0; i < active.length; ) {
const a = active[i]!;
const group = active.filter((b, j) => j >= i && doneCount(b) === doneCount(a) && sideDistance(b, rule) === sideDistance(a, rule));
push(group);
i += group.length;
}
for (const x of s.sides.filter((x) => x.out).sort((a, b) => b.outSeq! - a.outSeq!)) push([x]);
const first = ranked.filter((r) => r.rank === 1);
const winners = first.flatMap((r) => r.players);
const anyFinished = s.sides.some((x) => x.finishedRank !== null);
const o = s.options;
const top = first[0]!;
let summary: string;
if (anyFinished) {
summary =
o.mode === 'team'
? `${sideName(o, top.side)} 승리! (말 ${o.pieces}개 모두 남)`
: `${sideName(o, top.side)} 1등! (말 ${o.pieces}개 모두 남)${o.ranking === 'all' && s.sides.length > 2 ? ' · 꼴찌까지 순위가 정해졌어요' : ''}`;
} else if (first.length > 1) {
summary = '모두 기권해서 게임이 끝났어요';
} else {
summary =
o.mode === 'team'
? `${sideName(o, 1 - top.side)} 기권으로 ${sideName(o, top.side)} 승리!`
: `다른 사람이 모두 기권해서 ${sideName(o, top.side)} 1등!`;
}
s.phase = 'finished';
s.pending = [];
s.throwsOwed = 0;
s.deadlineAt = null;
s.final = { ranking: ranked, winners, summary, reason: anyFinished ? 'normal' : 'resign', stats: structuredClone(s.stats) };
pushLog(s, events, { type: 'gameOver', winners, summary });
}
/** In the move phase: if no pending result can be used, discard them all and end the turn (§5.3-2). */
function settleMovePhase(s: YutState, events: GameEvent[]): void {
if (s.phase !== 'move') return;
if (s.pending.length === 0) {
endTurn(s, events);
return;
}
if (legalPlans(s).length > 0) return;
pushLog(s, events, { type: 'discarded', side: s.turnSide, player: s.current, throws: [...s.pending] });
s.pending = [];
endTurn(s, events);
}
function doThrow(s: YutState, rng: Rng, events: GameEvent[]): void {
const r = rollSticks(rng, s.options);
s.throwNo += 1;
s.stats.throws[r.result] += 1;
s.lastThrow = { no: s.throwNo, player: s.current, sticks: r.sticks, result: r.result };
s.throwsOwed -= 1;
if (r.result === 'nak') {
pushLog(s, events, { type: 'nak', side: s.turnSide, player: s.current });
} else {
s.pending.push(r.result);
if (r.result === 'yut' || r.result === 'mo') s.throwsOwed += 1;
pushLog(s, events, { type: 'thrown', side: s.turnSide, player: s.current, sticks: r.sticks, result: r.result });
}
if (s.throwsOwed > 0) return;
s.phase = 'move';
settleMovePhase(s, events);
}
function doMove(s: YutState, throwIndex: number, from: NodeId | 'home', events: GameEvent[]): void {
const plan = planMove(s, s.turnSide, throwIndex, from);
if (!plan) throw new Error(`yut: illegal move ${throwIndex} ${from}`);
const side = s.sides[s.turnSide]!;
s.pending.splice(throwIndex, 1);
const movers = side.pieces.filter((p) => plan.movers.includes(p.id));
for (const p of movers) {
if (plan.to === GOAL) Object.assign(p, { status: 'done', node: null, lapped: false, trail: [] });
else Object.assign(p, { status: 'board', node: plan.to, lapped: plan.to === 0, trail: [...plan.trailAfter] });
}
for (const p of side.pieces) if (plan.stackWith.includes(p.id)) Object.assign(p, { lapped: plan.to === 0, trail: [...plan.trailAfter] });
let caught = 0;
for (const v of plan.victims) {
for (const p of s.sides[v.side]!.pieces) if (v.ids.includes(p.id)) Object.assign(p, { status: 'home', node: null, lapped: false, trail: [] });
caught += v.ids.length;
}
s.moveNo += 1;
s.lastMove = {
no: s.moveNo,
side: side.id,
player: s.current,
throw: plan.throw,
from: plan.from,
to: plan.to,
path: plan.path,
count: movers.length,
captured: plan.victims.map((v) => ({ side: v.side, count: v.ids.length })),
stacked: plan.stackWith.length > 0,
};
pushLog(s, events, { type: 'moved', side: side.id, player: s.current, throw: plan.throw, from: plan.from, to: plan.to, path: plan.path, count: movers.length });
if (plan.stackWith.length) pushLog(s, events, { type: 'stacked', side: side.id, node: plan.to, count: movers.length + plan.stackWith.length });
for (const v of plan.victims) pushLog(s, events, { type: 'captured', side: side.id, victimSide: v.side, count: v.ids.length, node: plan.to });
if (caught) s.stats.captures[String(side.id)] = (s.stats.captures[String(side.id)] ?? 0) + 1;
if (plan.to === GOAL) {
pushLog(s, events, { type: 'finishedPieces', side: side.id, count: movers.length });
if (side.pieces.every((p) => p.status === 'done')) {
side.finishedRank = s.nextRank++;
pushLog(s, events, { type: 'sideFinished', side: side.id, rank: side.finishedRank });
if (gameShouldEnd(s)) {
finishGame(s, events);
return;
}
// ranking = all: the finished side drops out; leftover results are lost.
if (s.pending.length) pushLog(s, events, { type: 'discarded', side: side.id, player: s.current, throws: [...s.pending] });
s.pending = [];
endTurn(s, events);
return;
}
}
if (caught && (plan.throw !== 'backdo' || s.options.backdoCaptureBonus)) {
s.phase = 'throw';
s.throwsOwed = 1;
pushLog(s, events, { type: 'bonusThrow', side: side.id, player: s.current });
return;
}
settleMovePhase(s, events);
}
function doConcede(s: YutState, actor: PlayerId, events: GameEvent[]): void {
const side = s.sides[s.sideOf[actor]!]!;
side.conceded.push(actor);
const whole = side.members.every((m) => side.conceded.includes(m));
pushLog(s, events, { type: 'conceded', side: side.id, player: actor, sideOut: whole });
if (!whole) {
// 팀전: a teammate takes over (the turn in progress continues with them).
if (s.current === actor) {
side.cursor = memberFrom(side, side.cursor + 1);
s.current = side.members[side.cursor]!;
}
return;
}
side.out = true;
side.outSeq = ++s.outCount;
for (const p of side.pieces) if (p.status === 'board') Object.assign(p, { status: 'home', node: null, lapped: false, trail: [] });
if (gameShouldEnd(s)) {
finishGame(s, events);
return;
}
if (s.turnSide === side.id) {
s.pending = [];
endTurn(s, events);
}
}
function refreshDeadline(s: YutState, now: number): void {
if (s.phase === 'finished' || s.options.turnSeconds === null) {
s.deadlineAt = null;
return;
}
const away = (s.timeouts[s.current] ?? 0) >= AWAY_AFTER;
s.deadlineAt = now + (away ? AWAY_MS : s.options.turnSeconds * 1000);
}
function emptyStats(n: number): YutStats {
const captures: Record<string, number> = {};
for (let i = 0; i < n; i++) captures[String(i)] = 0;
return { throws: { do: 0, gae: 0, geol: 0, yut: 0, mo: 0, backdo: 0, nak: 0 }, captures };
}
// ---------------------------------------------------------------- definition
export const yut: GameDefinition<YutState, YutAction, YutView, YutOptions> = {
id: 'yut',
nameKo: '윷놀이',
minPlayers: 2,
maxPlayers: 8,
stateVersion: 1,
defaultOptions: yutOptionsSchema.parse({}),
optionsSchema: yutOptionsSchema as unknown as z.ZodType<YutOptions>,
actionSchema: yutActionSchema as unknown as z.ZodType<YutAction>,
playersFor: playerRange,
playerCountError: (o, n) => playerCountError(o, n),
setup({ players, options, rng, now }) {
const err = playerCountError(options, players.length);
if (err) throw new Error(`yut: ${err}`);
const team = options.mode === 'team';
const nSides = team ? 2 : players.length;
const sides: SideState[] = Array.from({ length: nSides }, (_, id) => ({
id,
members: team ? players.filter((_, i) => i % 2 === id) : [players[id]!],
pieces: Array.from({ length: options.pieces }, (_, pid) => ({ id: pid, status: 'home' as const, node: null, lapped: false, trail: [] })),
finishedRank: null,
conceded: [],
out: false,
outSeq: null,
cursor: 0,
}));
const sideOf: Record<PlayerId, SideId> = {};
players.forEach((p, i) => (sideOf[p] = team ? i % 2 : i));
const timeouts: Record<PlayerId, number> = {};
for (const p of players) timeouts[p] = 0;
const s: YutState = {
options,
players: [...players],
sideOf,
sides,
turnSide: 0,
current: players[0]!,
phase: 'throw',
throwsOwed: 1,
pending: [],
lastThrow: null,
lastMove: null,
throwNo: 0,
moveNo: 0,
turnSeq: 0,
deadlineAt: null,
timeouts,
log: [],
stats: emptyStats(nSides),
nextRank: 1,
outCount: 0,
final: null,
};
startTurn(s, rng.int(nSides), []);
refreshDeadline(s, now);
return s;
},
validate(s, actor, a) {
const sideId = s.sideOf[actor];
if (sideId === undefined) return fail('이 게임의 플레이어가 아니에요.');
if (s.phase === 'finished') return fail('게임이 이미 끝났어요.');
const side = s.sides[sideId]!;
if (a.type === 'CONCEDE') {
if (side.conceded.includes(actor)) return fail('이미 기권했어요.');
if (side.finishedRank !== null) return fail('이미 말을 모두 냈어요.');
return ok;
}
if (actor !== s.current) return fail('지금은 다른 사람 차례예요.');
switch (a.type) {
case 'TIMEOUT':
return ok;
case 'THROW':
if (s.phase !== 'throw' || s.throwsOwed <= 0) return fail('지금은 윷을 던질 차례가 아니에요.');
return ok;
case 'MOVE': {
if (s.phase !== 'move') return fail('지금은 말을 움직일 차례가 아니에요. 먼저 윷을 던져 주세요.');
const t = s.pending[a.throwIndex];
if (!t) return fail('그 윷 결과는 없어요.');
if (a.from === 'home') {
if (t === 'backdo') return fail('빽도로는 새 말을 올릴 수 없어요.');
if (!side.pieces.some((p) => p.status === 'home')) return fail('기다리는 말이 없어요.');
} else if (!side.pieces.some((p) => p.status === 'board' && p.node === a.from)) {
return fail('그 말은 움직일 수 없어요.');
}
return planMove(s, sideId, a.throwIndex, a.from) ? ok : fail('그 말은 움직일 수 없어요.');
}
}
return fail('알 수 없는 행동이에요.');
},
apply(prev, actor, a, { rng, now }) {
const s = structuredClone(prev);
const events: GameEvent[] = [];
s.turnSeq += 1;
const act: Exclude<YutAction, { type: 'TIMEOUT' }> = a.type === 'TIMEOUT' ? autoAction(s) : a;
if (a.type === 'TIMEOUT') {
s.timeouts[actor] = (s.timeouts[actor] ?? 0) + 1;
pushLog(s, events, { type: 'timeout', player: actor });
if (s.timeouts[actor] === AWAY_AFTER) pushLog(s, events, { type: 'away', player: actor });
} else if (a.type !== 'CONCEDE') s.timeouts[actor] = 0;
switch (act.type) {
case 'THROW':
doThrow(s, rng, events);
break;
case 'MOVE':
doMove(s, act.throwIndex, act.from, events);
break;
case 'CONCEDE':
doConcede(s, actor, events);
break;
}
refreshDeadline(s, now);
return { state: s, events };
},
view(s, viewer) {
const o = s.options;
const mySide = viewer !== null && s.sideOf[viewer] !== undefined ? s.sideOf[viewer]! : null;
const stacks: YutStackView[] = [];
for (const x of s.sides) {
const byNode = new Map<NodeId, number>();
for (const p of x.pieces) if (p.status === 'board') byNode.set(p.node!, (byNode.get(p.node!) ?? 0) + 1);
for (const [node, count] of byNode) stacks.push({ node, side: x.id, count });
}
stacks.sort((a, b) => a.node - b.node);
const mine = s.phase === 'move' && viewer !== null && viewer === s.current;
const plans = mine ? legalPlans(s) : [];
const auto = mine && plans.length ? chooseAuto(s, plans) : null;
return {
options: o,
team: o.mode === 'team',
me: mySide === null ? null : viewer,
mySide,
phase: s.phase,
sides: s.sides.map((x) => ({
id: x.id,
name: sideName(o, x.id),
members: x.members,
conceded: x.conceded,
out: x.out,
finishedRank: x.finishedRank,
home: x.pieces.filter((p) => p.status === 'home').length,
onBoard: x.pieces.filter((p) => p.status === 'board').length,
done: doneCount(x),
remaining: sideDistance(x, o.finishRule),
})),
stacks,
turnSide: s.turnSide,
current: s.current,
throwsOwed: s.throwsOwed,
pending: s.pending,
lastThrow: s.lastThrow,
lastMove: s.lastMove,
turnSeq: s.turnSeq,
deadlineAt: s.deadlineAt,
away: s.players.filter((p) => (s.timeouts[p] ?? 0) >= AWAY_AFTER),
legalMoves: mine ? plans.map(toLegal) : null,
suggested: auto ? { throwIndex: auto.throwIndex, from: auto.from } : null,
log: s.log,
stats: s.stats,
result: s.final,
};
},
activePlayers(s) {
return s.phase === 'finished' ? [] : [s.current];
},
deadline(s) {
return s.phase === 'finished' ? null : s.deadlineAt;
},
timeoutPlayers(s) {
return s.phase === 'finished' ? [] : [s.current];
},
onTimeout() {
return { type: 'TIMEOUT' };
},
onLeave(s, player) {
const sideId = s.sideOf[player];
if (sideId === undefined || s.phase === 'finished') return null;
const side = s.sides[sideId]!;
if (side.conceded.includes(player) || side.finishedRank !== null) return null;
return { type: 'CONCEDE' };
},
result(s): GameResult | null {
const f = s.final;
if (!f) return null;
const groups = new Map<number, PlayerId[]>();
for (const r of f.ranking) groups.set(r.rank, [...(groups.get(r.rank) ?? []), ...r.players]);
const scores: Record<PlayerId, number> = {};
for (const r of f.ranking) for (const p of r.players) scores[p] = r.finishedPieces;
return {
ranking: [...groups.entries()].sort((a, b) => a[0] - b[0]).map(([, ps]) => ps),
scores,
summary: f.summary,
reason: f.reason,
};
},
/** Test helper: THROW in the throw phase, every distinct legal MOVE in the move phase (CONCEDE is left out). */
legalActions(s, p) {
if (s.phase === 'finished' || p !== s.current) return [];
if (s.phase === 'throw') return [{ type: 'THROW' }];
return legalPlans(s).map((x) => ({ type: 'MOVE' as const, throwIndex: x.throwIndex, from: x.from }));
},
};
/** Throw label with its step count, e.g. "걸(3칸)", "빽도(뒤로 1칸)". */
export function throwLabel(t: Throw): string {
return t === 'backdo' ? `${THROW_KO[t]}(뒤로 1칸)` : `${THROW_KO[t]}(${THROW_STEPS[t]}칸)`;
}

View File

@@ -0,0 +1,731 @@
import { describe, expect, test } from 'bun:test';
import { SeededRng, randomPlayout, seedN, type GameEvent, type Rng } from '@bg/engine';
import {
FLAT_P,
advance,
autoAction,
playerCountError,
rollSticks,
throwProbabilities,
yut,
type NodeId,
type Throw,
type YutAction,
type YutOptions,
type YutState,
} from './index';
// ---------------------------------------------------------------- helpers
/** Scripted RNG: `next()` returns the queued values in order. */
class Script implements Rng {
private vals: number[];
constructor(vals: number[]) {
this.vals = [...vals];
}
next(): number {
const v = this.vals.shift();
if (v === undefined) throw new Error('script exhausted');
return v;
}
int(n: number): number {
return Math.floor(this.next() * n);
}
shuffle<T>(a: readonly T[]): T[] {
return [...a];
}
get left(): number {
return this.vals.length;
}
}
const F = 0; // 배(flat) up
const R = 0.99; // 등(round) up
const STICKS: Record<Throw, number[]> = { do: [R, F, R, R], backdo: [F, R, R, R], gae: [F, F, R, R], geol: [F, F, F, R], yut: [F, F, F, F], mo: [R, R, R, R] };
/** RNG producing the given results; with `nak` on, every throw first consumes the 낙 roll. */
const script = (results: (Throw | 'nak')[], nak = false) =>
new Script(results.flatMap((t) => (t === 'nak' ? [0] : [...(nak ? [R] : []), ...STICKS[t]])));
const PLAYERS = ['A', 'B', 'C', 'D', 'E', 'F', 'G', 'H'];
/** Fresh game with side 0 to move. */
function make(opts: Partial<YutOptions> = {}, n = 2): YutState {
const options = yut.optionsSchema.parse(opts);
const s = yut.setup({ players: PLAYERS.slice(0, n), options, rng: SeededRng.fromSeed(seedN(1)), now: 0 });
s.turnSide = 0;
s.current = s.sides[0]!.members[0]!;
return s;
}
function put(s: YutState, side: number, piece: number, node: NodeId, trail: NodeId[] = [node]): void {
Object.assign(s.sides[side]!.pieces[piece]!, { status: 'board', node, trail, lapped: node === 0 });
}
/** Puts the side to move into the move phase with these results. */
function moving(s: YutState, pending: Throw[]): YutState {
s.phase = 'move';
s.throwsOwed = 0;
s.pending = [...pending];
return s;
}
let clock = 1000;
function actE(s: YutState, p: string, a: YutAction, rng: Rng = new Script([])): { state: YutState; events: GameEvent[] } {
const v = yut.validate(s, p, a);
if (!v.ok) throw new Error(`${p} ${JSON.stringify(a)}: ${v.reason}`);
return yut.apply(s, p, a, { rng, now: (clock += 1000) });
}
const act = (s: YutState, p: string, a: YutAction, rng?: Rng) => actE(s, p, a, rng).state;
const reject = (s: YutState, p: string, a: YutAction) => {
const v = yut.validate(s, p, a);
return v.ok ? null : v.reason;
};
const mv = (throwIndex: number, from: NodeId | 'home'): YutAction => ({ type: 'MOVE', throwIndex, from });
/** Uses the first pending result equal to `t`. */
const use = (s: YutState, t: Throw, from: NodeId | 'home') => act(s, s.current, mv(s.pending.indexOf(t), from));
const piece = (s: YutState, side: number, id: number) => s.sides[side]!.pieces[id]!;
const throwN = (s: YutState, rng: Rng, n = 1) => {
for (let i = 0; i < n; i++) s = act(s, s.current, { type: 'THROW' }, rng);
return s;
};
// ---------------------------------------------------------------- sticks
describe('윷가락 확률', () => {
const TABLE: Record<'realistic' | 'fair', Record<Throw, number>> = {
realistic: { do: 0.1152, backdo: 0.0384, gae: 0.3456, geol: 0.3456, yut: 0.1296, mo: 0.0256 },
fair: { do: 0.1875, backdo: 0.0625, gae: 0.375, geol: 0.25, yut: 0.0625, mo: 0.0625 },
};
test('formula matches the §5.1 table', () => {
for (const m of ['realistic', 'fair'] as const) {
const p = throwProbabilities(FLAT_P[m]);
for (const t of Object.keys(TABLE[m]) as Throw[]) expect(p[t]).toBeCloseTo(TABLE[m][t], 4);
}
});
test.each(['realistic', 'fair'] as const)('%s: 100,000 seeded throws within ±0.5%%p of the table', (stickModel) => {
const rng = SeededRng.fromSeed(seedN(stickModel === 'fair' ? 2 : 1));
const counts: Record<string, number> = {};
const N = 100_000;
for (let i = 0; i < N; i++) {
const r = rollSticks(rng, { stickModel, backdo: true, nakPercent: 0 });
counts[r.result] = (counts[r.result] ?? 0) + 1;
}
for (const t of Object.keys(TABLE[stickModel]) as Throw[]) expect(Math.abs((counts[t] ?? 0) / N - TABLE[stickModel][t])).toBeLessThan(0.005);
}, 30_000);
test('without 빽도 the marked stick is a plain 도; 낙 is drawn first', () => {
expect(rollSticks(new Script(STICKS.backdo), { stickModel: 'realistic', backdo: false, nakPercent: 0 }).result).toBe('do');
expect(rollSticks(new Script(STICKS.backdo), { stickModel: 'realistic', backdo: true, nakPercent: 0 })).toEqual({ sticks: [true, false, false, false], result: 'backdo' });
expect(rollSticks(new Script([0.05]), { stickModel: 'fair', backdo: true, nakPercent: 10 })).toEqual({ sticks: null, result: 'nak' });
expect(rollSticks(new Script([0.1, ...STICKS.mo]), { stickModel: 'fair', backdo: true, nakPercent: 10 }).result).toBe('mo');
});
});
// ---------------------------------------------------------------- throwing & moving
describe('던지기와 이동', () => {
test('윷 → 모 → 걸: throwing stops at 걸, results are used in any order, one piece finishes in one turn (예시 1)', () => {
let s = make();
const rng = script(['yut', 'mo', 'geol']);
s = throwN(s, rng);
expect([s.phase, s.throwsOwed, s.pending]).toEqual(['throw', 1, ['yut']]);
s = throwN(s, rng);
expect([s.phase, s.pending]).toEqual(['throw', ['yut', 'mo']]);
s = throwN(s, rng);
expect([s.phase, s.throwsOwed, s.pending]).toEqual(['move', 0, ['yut', 'mo', 'geol']]);
expect(reject(s, 'A', { type: 'THROW' })).toBe('지금은 윷을 던질 차례가 아니에요.');
s = use(s, 'mo', 'home');
expect(piece(s, 0, 0)).toMatchObject({ status: 'board', node: 5, trail: [1, 2, 3, 4, 5] });
s = use(s, 'geol', 5);
expect(piece(s, 0, 0)).toMatchObject({ node: 22, trail: [1, 2, 3, 4, 5, 20, 21, 22] });
const r = actE(s, 'A', mv(0, 22));
s = r.state;
expect(piece(s, 0, 0)).toMatchObject({ status: 'done', node: null });
expect(r.events.find((e) => e.type === 'moved')).toMatchObject({ from: 22, to: 'goal', path: [27, 28, 0] });
expect(r.events.map((e) => e.type)).toContain('finishedPieces');
expect([s.current, s.phase, s.pending]).toEqual(['B', 'throw', []]);
});
test('passing 모(5) goes straight; stopping on 5 turns into the shortcut', () => {
expect(advance(4, 3, 'pass')).toEqual({ path: [5, 6, 7], to: 7 });
expect(advance(5, 1, 'pass')).toEqual({ path: [20], to: 20 });
expect(advance(10, 2, 'pass')).toEqual({ path: [25, 26], to: 26 });
expect(advance(9, 2, 'pass')).toEqual({ path: [10, 11], to: 11 });
let s = make();
put(s, 0, 0, 4, [1, 2, 3, 4]);
put(s, 0, 1, 5, [1, 2, 3, 4, 5]);
s = moving(s, ['geol', 'do']);
s = use(s, 'geol', 4);
expect(piece(s, 0, 0).node).toBe(7);
s = use(s, 'do', 5);
expect(piece(s, 0, 1).node).toBe(20);
});
test('방(22): through from 21 → 23, through from 26 → 27, starting on 22 → 27 (예시 2)', () => {
expect(advance(21, 2, 'pass').path).toEqual([22, 23]);
expect(advance(26, 2, 'pass').path).toEqual([22, 27]);
expect(advance(22, 1, 'pass').path).toEqual([27]);
expect(advance(20, 3, 'pass')).toEqual({ path: [21, 22, 23], to: 23 });
expect(advance(24, 1, 'pass').to).toBe(15);
});
test('찌모(15) has no shortcut even after stopping there', () => {
expect(advance(15, 1, 'pass').to).toBe(16);
expect(advance(14, 2, 'pass').path).toEqual([15, 16]);
});
test('pass: 28 + 도 stops on 참먹이 (not finished, can be caught); next 도 finishes. reach: finishes at once (예시 4)', () => {
let s = make();
put(s, 0, 0, 28, [27, 28]);
put(s, 1, 0, 19, [19]);
s = use(moving(s, ['do']), 'do', 28);
expect(piece(s, 0, 0)).toMatchObject({ status: 'board', node: 0, lapped: true });
expect(s.current).toBe('B');
// B lands on 0 and catches A's piece, then gets a bonus throw.
s = use(moving(s, ['do']), 'do', 19);
expect(piece(s, 0, 0)).toMatchObject({ status: 'home', node: null, trail: [] });
expect(piece(s, 1, 0)).toMatchObject({ status: 'board', node: 0 });
expect([s.current, s.phase, s.throwsOwed]).toEqual(['B', 'throw', 1]);
// From 0 any forward result finishes.
s = use(moving(s, ['do']), 'do', 0);
expect(piece(s, 1, 0).status).toBe('done');
let r = make({ finishRule: 'reach' });
put(r, 0, 0, 28, [27, 28]);
r = use(moving(r, ['do']), 'do', 28);
expect(piece(r, 0, 0).status).toBe('done');
expect(advance(19, 3, 'reach').to).toBe('goal');
expect(advance(19, 3, 'pass')).toEqual({ path: [0], to: 'goal' });
});
test('빽도 goes back the way it came; after 빽도 to 21 a 개 passes 방 straight to 23', () => {
let s = make();
put(s, 0, 0, 21, [1, 2, 3, 4, 5, 20, 21]);
put(s, 0, 1, 26, [10, 25, 26]);
s = moving(s, ['do', 'do', 'backdo', 'backdo', 'gae']);
s = act(s, 'A', mv(0, 21));
expect(piece(s, 0, 0).node).toBe(22);
s = use(s, 'backdo', 22);
expect(piece(s, 0, 0)).toMatchObject({ node: 21, trail: [1, 2, 3, 4, 5, 20, 21] });
s = use(s, 'do', 26);
expect(piece(s, 0, 1).node).toBe(22);
s = use(s, 'backdo', 22);
expect(piece(s, 0, 1).node).toBe(26);
s = use(s, 'gae', 21);
expect(piece(s, 0, 0).node).toBe(23);
});
test('도 자리 빽도: chammeogi → 참먹이, next 개 finishes; finish → done at once', () => {
let s = make();
s = use(moving(s, ['do', 'backdo']), 'do', 'home');
expect(piece(s, 0, 0)).toMatchObject({ node: 1, trail: [1] });
s = use(s, 'backdo', 1);
expect(piece(s, 0, 0)).toMatchObject({ status: 'board', node: 0, lapped: true, trail: [0] });
s.current = 'A';
s.turnSide = 0;
s = use(moving(s, ['gae']), 'gae', 0);
expect(piece(s, 0, 0).status).toBe('done');
// 빽도 from a 참먹이 piece without a record goes to 19.
let z = make();
put(z, 0, 0, 0, [0]);
z = use(moving(z, ['backdo']), 'backdo', 0);
expect(piece(z, 0, 0)).toMatchObject({ node: 19, lapped: false });
let f = make({ backdoFromDo: 'finish' });
put(f, 0, 0, 1, [1]);
const r = actE(moving(f, ['backdo']), 'A', mv(0, 1));
f = r.state;
expect(piece(f, 0, 0).status).toBe('done');
expect(r.events.map((e) => e.type)).toContain('finishedPieces');
});
test('stuck results: [빽도] with nothing on the board is discarded and the turn passes; [빽도, 개] can use both', () => {
let s = make();
const r = actE(s, 'A', { type: 'THROW' }, script(['backdo']));
s = r.state;
expect(r.events.find((e) => e.type === 'discarded')).toMatchObject({ throws: ['backdo'] });
expect([s.current, s.phase, s.pending]).toEqual(['B', 'throw', []]);
let t = moving(make(), ['backdo', 'gae']);
expect(yut.view(t, 'A').legalMoves!.map((m) => [m.throw, m.from])).toEqual([['gae', 'home']]);
expect(reject(t, 'A', mv(0, 'home'))).toBe('빽도로는 새 말을 올릴 수 없어요.');
t = act(t, 'A', mv(1, 'home'));
expect(piece(t, 0, 0).node).toBe(2);
expect(t.pending).toEqual(['backdo']);
t = act(t, 'A', mv(0, 2));
expect(piece(t, 0, 0).node).toBe(1);
expect(t.current).toBe('B');
});
test('without 빽도 the backdo stick result counts as 도', () => {
let s = make({ backdo: false });
s = act(s, 'A', { type: 'THROW' }, script(['backdo']));
expect(s.pending).toEqual(['do']);
});
test('업기: stacked pieces move, finish and get caught together; one bonus for catching a stack', () => {
let s = make();
put(s, 0, 0, 3, [1, 2, 3]);
s = use(moving(s, ['geol', 'gae', 'yut', 'geol']), 'geol', 'home');
expect(piece(s, 0, 1)).toMatchObject({ node: 3, trail: [1, 2, 3] });
expect(yut.view(s, 'A').stacks).toEqual([{ node: 3, side: 0, count: 2 }]);
s = use(s, 'gae', 3);
expect([piece(s, 0, 0).node, piece(s, 0, 1).node]).toEqual([5, 5]);
s = use(s, 'geol', 5); // 20 21 22
expect([piece(s, 0, 0).node, piece(s, 0, 1).node]).toEqual([22, 22]);
const r = actE(s, 'A', mv(0, 22)); // 윷: 27 28 0 → 남
expect([piece(r.state, 0, 0).status, piece(r.state, 0, 1).status]).toEqual(['done', 'done']);
expect(r.events.find((e) => e.type === 'moved')).toMatchObject({ count: 2 });
let c = make();
put(c, 1, 0, 4, [1, 2, 3, 4]);
put(c, 1, 1, 4, [1, 2, 3, 4]);
put(c, 0, 0, 2, [1, 2]);
const cr = actE(moving(c, ['gae', 'do']), 'A', mv(0, 2));
c = cr.state;
expect([piece(c, 1, 0).status, piece(c, 1, 1).status]).toEqual(['home', 'home']);
expect(cr.events.find((e) => e.type === 'captured')).toMatchObject({ victimSide: 1, count: 2 });
expect([c.phase, c.throwsOwed, c.pending]).toEqual(['throw', 1, ['do']]);
expect(c.stats.captures['0']).toBe(1);
});
test('capture bonus keeps the remaining results; a 윷 on the bonus throw throws again', () => {
let s = make();
put(s, 0, 0, 2, [1, 2]);
put(s, 1, 0, 4, [1, 2, 3, 4]);
s = use(moving(s, ['gae', 'geol']), 'gae', 2);
expect([s.phase, s.pending]).toEqual(['throw', ['geol']]);
const rng = script(['yut', 'do']);
s = throwN(s, rng);
expect([s.phase, s.pending, s.throwsOwed]).toEqual(['throw', ['geol', 'yut'], 1]);
s = throwN(s, rng);
expect([s.phase, s.pending]).toEqual(['move', ['geol', 'yut', 'do']]);
});
test('빽도 capture: bonus only when backdoCaptureBonus is on; the catcher stands on 모 and takes the shortcut (예시 3)', () => {
for (const bonus of [true, false]) {
let s = make({ backdoCaptureBonus: bonus });
put(s, 0, 0, 6, [1, 2, 3, 4, 5, 6]);
put(s, 1, 0, 5, [1, 2, 3, 4, 5]);
s = use(moving(s, ['backdo', 'do']), 'backdo', 6);
expect(piece(s, 1, 0).status).toBe('home');
expect(piece(s, 0, 0).node).toBe(5);
expect(s.phase).toBe(bonus ? 'throw' : 'move');
if (!bonus) {
s = use(s, 'do', 5);
expect(piece(s, 0, 0).node).toBe(20);
}
}
});
test('passing over an opponent does not catch it', () => {
let s = make();
put(s, 0, 0, 1, [1]);
put(s, 1, 0, 2, [1, 2]);
s = use(moving(s, ['geol']), 'geol', 1);
expect(piece(s, 1, 0)).toMatchObject({ status: 'board', node: 2 });
expect(s.current).toBe('B');
});
test('낙 10%: a 낙 gives nothing, the earlier 윷 is kept and the turn goes to moving', () => {
let s = make({ nakPercent: 10 });
const rng = script(['yut', 'nak'], true);
s = throwN(s, rng);
const r = actE(s, 'A', { type: 'THROW' }, rng);
s = r.state;
expect(r.events.map((e) => e.type)).toContain('nak');
expect([s.phase, s.pending, s.lastThrow?.result]).toEqual(['move', ['yut'], 'nak']);
expect(s.stats.throws.nak).toBe(1);
// A 낙 with nothing collected ends the turn.
let t = make({ nakPercent: 10 });
t = act(t, 'A', { type: 'THROW' }, script(['nak'], true));
expect(t.current).toBe('B');
});
test('validation messages', () => {
const s = make();
expect(reject(s, 'B', { type: 'THROW' })).toBe('지금은 다른 사람 차례예요.');
expect(reject(s, 'Z', { type: 'THROW' })).toBe('이 게임의 플레이어가 아니에요.');
expect(reject(s, 'A', mv(0, 'home'))).toBe('지금은 말을 움직일 차례가 아니에요. 먼저 윷을 던져 주세요.');
const m = moving(make(), ['do']);
expect(reject(m, 'A', mv(3, 'home'))).toBe('그 윷 결과는 없어요.');
expect(reject(m, 'A', mv(0, 7))).toBe('그 말은 움직일 수 없어요.');
expect(yut.actionSchema.safeParse({ type: 'TIMEOUT' }).success).toBe(false);
expect(yut.actionSchema.safeParse({ type: 'MOVE', throwIndex: 0, from: 29 }).success).toBe(false);
expect(yut.actionSchema.safeParse({ type: 'MOVE', throwIndex: 0, from: 'home' }).success).toBe(true);
});
});
// ---------------------------------------------------------------- turns, teams, ranking
describe('차례·팀전·순위', () => {
test('팀전 4명: 청1 → 홍1 → 청2 → 홍2, and only the player to move may act', () => {
let s = yut.setup({ players: ['A', 'B', 'C', 'D'], options: yut.optionsSchema.parse({ mode: 'team' }), rng: new Script([0]), now: 0 });
expect(s.sides.map((x) => x.members)).toEqual([
['A', 'C'],
['B', 'D'],
]);
const order: string[] = [];
// A: 개 → 2, B: 걸 → 3, C: 도 → 1, D: 걸 → 3 (업기 with teammate B's piece).
for (const t of ['gae', 'geol', 'do', 'geol'] as Throw[]) {
order.push(s.current);
s = act(s, s.current, { type: 'THROW' }, script([t]));
if (s.current === 'B') {
// 홍1(B) is moving: 청2(C) cannot act.
expect(reject(s, 'C', mv(0, 'home'))).toBe('지금은 다른 사람 차례예요.');
}
s = act(s, s.current, mv(0, 'home'));
}
order.push(s.current);
expect(order).toEqual(['A', 'B', 'C', 'D', 'A']);
expect(yut.view(s, null).stacks).toEqual([
{ node: 1, side: 0, count: 1 },
{ node: 2, side: 0, count: 1 },
{ node: 3, side: 1, count: 2 },
]);
});
test('팀전 6명 seats alternate teams; a team that finishes wins for all its members', () => {
let s = yut.setup({ players: PLAYERS.slice(0, 6), options: yut.optionsSchema.parse({ mode: 'team', pieces: 2 }), rng: new Script([0.6]), now: 0 });
expect(s.sides.map((x) => x.members)).toEqual([
['A', 'C', 'E'],
['B', 'D', 'F'],
]);
expect(s.current).toBe('B');
s.sides[1]!.pieces[0]!.status = 'done';
put(s, 1, 1, 0, [28, 0]);
s = act(moving(s, ['do']), 'B', mv(0, 0));
expect(yut.result(s)).toEqual({
ranking: [
['B', 'D', 'F'],
['A', 'C', 'E'],
],
scores: { B: 2, D: 2, F: 2, A: 0, C: 0, E: 0 },
summary: '홍팀 승리! (말 2개 모두 남)',
reason: 'normal',
});
expect(s.final!.winners).toEqual(['B', 'D', 'F']);
});
test('개인전 3명 ranking=all: the winner drops out of the order and play goes on to the last place', () => {
let s = make({ ranking: 'all', pieces: 2 }, 3);
s.sides[0]!.pieces[0]!.status = 'done';
const done = (side: number, id: number) => Object.assign(piece(s, side, id), { status: 'done', node: null, trail: [] });
put(s, 0, 1, 19, [19]);
s = act(moving(s, ['gae', 'do']), 'A', mv(0, 19));
expect(s.sides[0]!.finishedRank).toBe(1);
expect(s.phase).toBe('throw');
expect(s.current).toBe('B');
s = act(s, 'B', { type: 'THROW' }, script(['gae']));
s = act(s, 'B', mv(0, 'home'));
expect(s.current).toBe('C');
s = act(s, 'C', { type: 'THROW' }, script(['geol']));
s = act(s, 'C', mv(0, 'home'));
expect(s.current).toBe('B'); // A is skipped
// B finishes second → C is last and the game ends.
done(1, 0);
put(s, 1, 1, 28, [28]);
s = act(moving(s, ['geol']), 'B', mv(0, 28));
expect(s.phase).toBe('finished');
expect(yut.result(s)!.ranking).toEqual([['A'], ['B'], ['C']]);
expect(s.final!.summary).toBe('빨강 1등! (말 2개 모두 남) · 꼴찌까지 순위가 정해졌어요');
});
test('ranking=first: the game ends at the first finisher, others ranked by finished pieces then distance (ties shared)', () => {
let s = make({ pieces: 2 }, 4);
s.sides[0]!.pieces[0]!.status = 'done';
put(s, 0, 1, 19, [19]);
s.sides[1]!.pieces[0]!.status = 'done'; // B: 1 done + 1 home (12)
put(s, 2, 0, 15, [15]); // C: 6 + 12 = 18
put(s, 3, 0, 15, [15]); // D: same as C
s = act(moving(s, ['gae']), 'A', mv(0, 19));
const r = s.final!;
expect(r.ranking.map((e) => [e.rank, e.players[0], e.finishedPieces, e.remainingDistance])).toEqual([
[1, 'A', 2, 0],
[2, 'B', 1, 12],
[3, 'C', 0, 18],
[3, 'D', 0, 18],
]);
expect(yut.result(s)!.ranking).toEqual([['A'], ['B'], ['C', 'D']]);
});
test('기권: individual side drops out (pieces leave the board); last one standing wins; team needs every member', () => {
let s = make({}, 3);
put(s, 1, 0, 7, [7]);
s = act(s, 'B', { type: 'CONCEDE' });
expect(s.sides[1]!.out).toBe(true);
expect(piece(s, 1, 0).status).toBe('home');
expect(s.phase).toBe('throw');
expect(s.current).toBe('A');
expect(yut.onLeave!(s, 'B')).toBeNull();
expect(yut.onLeave!(s, 'C')).toEqual({ type: 'CONCEDE' });
s = act(s, 'A', { type: 'CONCEDE' }); // the player to move concedes: turn passes on, then C is alone
expect(s.phase).toBe('finished');
expect(yut.result(s)).toMatchObject({ ranking: [['C'], ['A'], ['B']], reason: 'resign', summary: '다른 사람이 모두 기권해서 초록 1등!' });
let t = yut.setup({ players: ['A', 'B', 'C', 'D'], options: yut.optionsSchema.parse({ mode: 'team' }), rng: new Script([0]), now: 0 });
t = act(t, 'A', { type: 'CONCEDE' });
expect(t.phase).toBe('throw');
expect(t.current).toBe('C'); // the teammate takes over the turn
t = act(t, 'C', { type: 'THROW' }, script(['gae']));
t = act(t, 'C', mv(0, 'home'));
expect(t.current).toBe('B');
t = act(t, 'B', { type: 'THROW' }, script(['geol']));
t = act(t, 'B', mv(0, 'home'));
expect(t.current).toBe('C'); // 청팀 has only C left
t = act(t, 'C', { type: 'CONCEDE' });
expect(yut.result(t)).toMatchObject({ ranking: [['B', 'D'], ['A', 'C']], reason: 'resign', summary: '청팀 기권으로 홍팀 승리!' });
});
test('player counts: 개인전 2~4, 팀전 4·6·8 (개인전 5명·팀전 5명 거부)', () => {
expect(yut.playersFor!(yut.defaultOptions)).toEqual({ min: 2, max: 4 });
expect(yut.playersFor!({ ...yut.defaultOptions, mode: 'team' })).toEqual({ min: 4, max: 8 });
expect(playerCountError(yut.defaultOptions, 5)).toBe('개인전은 2~4명이 할 수 있어요.');
expect(playerCountError({ mode: 'team' }, 5)).toBe('팀전은 4명, 6명, 8명(짝수)으로만 할 수 있어요.');
expect(playerCountError({ mode: 'team' }, 2)).not.toBeNull();
for (const n of [4, 6, 8]) expect(yut.playerCountError!({ ...yut.defaultOptions, mode: 'team' }, n)).toBeNull();
const mk = (mode: 'individual' | 'team', n: number) => () =>
yut.setup({ players: PLAYERS.slice(0, n), options: yut.optionsSchema.parse({ mode }), rng: new Script([0]), now: 0 });
expect(mk('individual', 5)).toThrow();
expect(mk('team', 5)).toThrow();
expect(yut.optionsSchema.safeParse({ pieces: 6 }).success).toBe(false);
expect(yut.optionsSchema.parse({ turnSeconds: null }).turnSeconds).toBeNull();
});
});
// ---------------------------------------------------------------- time
describe('시간 초과', () => {
test('throw phase → automatic throw; move phase prefers a capture (most pieces first)', () => {
let s = make();
s = act(s, 'A', yut.onTimeout(s, 'A'), script(['gae']));
expect([s.phase, s.pending]).toEqual(['move', ['gae']]);
s = act(s, 'A', yut.onTimeout(s, 'A'));
expect(piece(s, 0, 0).node).toBe(2); // only legal move: enter
expect(s.timeouts.A).toBe(2);
let c = make();
put(c, 0, 0, 3, [1, 2, 3]);
put(c, 0, 1, 10, [10]);
put(c, 1, 0, 5, [5]);
put(c, 1, 1, 12, [12]);
put(c, 1, 2, 12, [12]);
c = moving(c, ['gae', 'geol']);
// Only 3 + 개 → 5 catches (10 turns into the shortcut, 3 + 걸 passes 5).
expect(autoAction(c)).toEqual({ type: 'MOVE', throwIndex: 0, from: 3 });
put(c, 0, 1, 9, [9]); // 9 + 걸 → 10, 11, 12: catches two
expect(autoAction(c)).toEqual({ type: 'MOVE', throwIndex: 1, from: 9 });
const after = act(c, 'A', { type: 'TIMEOUT' });
expect([piece(after, 1, 1).status, piece(after, 1, 2).status]).toEqual(['home', 'home']);
});
test('priority after captures: finish > stack > most-behind piece with the biggest result > enter', () => {
const base = () => {
const s = make();
put(s, 0, 0, 19, [19]);
put(s, 0, 1, 2, [1, 2]);
return s;
};
expect(autoAction(moving(base(), ['gae', 'do']))).toEqual({ type: 'MOVE', throwIndex: 0, from: 19 }); // finish
const st = base();
put(st, 0, 0, 17, [17]);
put(st, 0, 2, 4, [4]);
expect(autoAction(moving(st, ['gae', 'do']))).toEqual({ type: 'MOVE', throwIndex: 0, from: 2 }); // 2+개 → 4 stacks
const far = base();
put(far, 0, 0, 17, [17]);
expect(autoAction(moving(far, ['do', 'geol']))).toEqual({ type: 'MOVE', throwIndex: 1, from: 2 }); // behind piece, biggest result
expect(autoAction(moving(make(), ['geol', 'do']))).toEqual({ type: 'MOVE', throwIndex: 0, from: 'home' });
expect(autoAction(make())).toEqual({ type: 'THROW' });
});
test('deadline follows turnSeconds; three timeouts in a row mark the player away (5 s); a real action clears it', () => {
let s = yut.setup({ players: ['A', 'B'], options: yut.optionsSchema.parse({ turnSeconds: 15 }), rng: new Script([0]), now: 100 });
expect(yut.deadline!(s)).toBe(100 + 15_000);
expect(yut.timeoutPlayers!(s)).toEqual(['A']);
const r1 = yut.apply(s, 'A', { type: 'TIMEOUT' }, { rng: script(['gae']), now: 1000 });
expect(r1.state.deadlineAt).toBe(1000 + 15_000);
s = r1.state;
s = yut.apply(s, 'A', { type: 'TIMEOUT' }, { rng: new Script([]), now: 2000 }).state;
expect(s.current).toBe('B');
s = yut.apply(s, 'B', { type: 'THROW' }, { rng: script(['geol']), now: 3000 }).state;
s = yut.apply(s, 'B', mv(0, 'home'), { rng: new Script([]), now: 4000 }).state;
const r = yut.apply(s, 'A', { type: 'TIMEOUT' }, { rng: script(['gae']), now: 5000 });
expect(r.events.map((e) => e.type)).toContain('away');
expect(yut.view(r.state, null).away).toEqual(['A']);
expect(r.state.deadlineAt).toBe(5000 + 5000);
s = yut.apply(r.state, 'A', mv(0, 'home'), { rng: new Script([]), now: 6000 }).state;
expect(s.timeouts.A).toBe(0);
const unlimited = make({ turnSeconds: null });
expect(yut.deadline!(unlimited)).toBeNull();
});
});
// ---------------------------------------------------------------- view
describe('view', () => {
test('same public view for everyone; no RNG or seed anywhere; legal moves only for the player to move', () => {
let s = yut.setup({ players: ['A', 'B'], options: yut.defaultOptions, rng: SeededRng.fromSeed(seedN(5)), now: 0 });
s = moving(s, ['gae', 'backdo']);
put(s, s.turnSide, 0, 3, [1, 2, 3]);
const cur = s.current;
const other = cur === 'A' ? 'B' : 'A';
const strip = (v: ReturnType<typeof yut.view>) => ({ ...v, me: null, mySide: null, legalMoves: null, suggested: null });
expect(strip(yut.view(s, 'A'))).toEqual(strip(yut.view(s, null)));
expect(strip(yut.view(s, 'B'))).toEqual(strip(yut.view(s, null)));
for (const v of [yut.view(s, 'A'), yut.view(s, 'B'), yut.view(s, null)]) expect(JSON.stringify(v)).not.toMatch(/rng|seed/i);
expect(JSON.stringify(s)).not.toMatch(/rng|seed/i);
expect(yut.view(s, other).legalMoves).toBeNull();
expect(yut.view(s, null).legalMoves).toBeNull();
const mine = yut.view(s, cur);
expect(mine.legalMoves!.map((m) => [m.throw, m.from, m.to])).toEqual([
['gae', 3, 5],
['gae', 'home', 2],
['backdo', 3, 2],
]);
expect(mine.suggested).toEqual({ throwIndex: 0, from: 3 });
expect(mine.mySide).toBe(s.sideOf[cur]!);
});
test('legal move badges: 잡기 / 업기 / 날', () => {
const s = make();
put(s, 0, 0, 19, [19]);
put(s, 0, 1, 1, [1]);
put(s, 1, 0, 3, [3]);
const lm = yut.view(moving(s, ['gae']), 'A').legalMoves!;
expect(lm.find((m) => m.from === 19)).toMatchObject({ finishes: true, to: 'goal' });
expect(lm.find((m) => m.from === 1)).toMatchObject({ captures: 1, to: 3 });
expect(lm.find((m) => m.from === 'home')).toMatchObject({ stacks: false, to: 2 });
const st = make();
put(st, 0, 0, 2, [1, 2]);
expect(yut.view(moving(st, ['gae']), 'A').legalMoves!.find((m) => m.from === 'home')).toMatchObject({ stacks: true });
});
});
// ---------------------------------------------------------------- properties
interface Config {
opts: Partial<YutOptions>;
n: number;
}
const CONFIGS: Config[] = [
{ opts: {}, n: 2 },
{ opts: { stickModel: 'fair', backdoFromDo: 'finish' }, n: 3 },
{ opts: { ranking: 'all', nakPercent: 10 }, n: 4 },
{ opts: { mode: 'team', pieces: 3 }, n: 4 },
{ opts: { mode: 'team', pieces: 2, finishRule: 'reach', backdoCaptureBonus: false }, n: 6 },
{ opts: { mode: 'team', pieces: 5, backdo: false, nakPercent: 3 }, n: 8 },
{ opts: { pieces: 5, ranking: 'all', finishRule: 'reach', stickModel: 'fair' }, n: 3 },
{ opts: { pieces: 2, nakPercent: 5, turnSeconds: null }, n: 2 },
];
/** Per-step invariants: piece count, one side per node, trail ends on the node, phase consistency, onTimeout legal. */
function invariant(st: unknown): void {
const s = st as YutState;
const occupied = new Map<number, number>();
for (const x of s.sides) {
if (x.pieces.length !== s.options.pieces) throw new Error('piece count changed');
for (const p of x.pieces) {
if (p.status === 'board') {
if (p.node === null || p.node < 0 || p.node > 28) throw new Error('board piece without node');
if (p.trail.at(-1) !== p.node) throw new Error(`trail ${p.trail} does not end at ${p.node}`);
if (p.node === 0 && !p.lapped) throw new Error('piece on 참먹이 not lapped');
const o = occupied.get(p.node);
if (o !== undefined && o !== x.id) throw new Error(`two sides on node ${p.node}`);
occupied.set(p.node, x.id);
} else if (p.node !== null || p.trail.length) throw new Error('off-board piece keeps a position');
}
const stacked = new Map<number, string>();
for (const p of x.pieces) {
if (p.status !== 'board') continue;
const key = JSON.stringify(p.trail);
if (stacked.has(p.node!) && stacked.get(p.node!) !== key) throw new Error('stack with different trails');
stacked.set(p.node!, key);
}
if (x.finishedRank !== null && x.pieces.some((p) => p.status !== 'done')) throw new Error('finished side with pieces left');
}
if (s.phase === 'finished') {
if (!s.final || s.deadlineAt !== null) throw new Error('finished without result');
return;
}
if (s.phase === 'throw' && s.throwsOwed < 1) throw new Error('throw phase without owed throws');
if (s.phase === 'move' && (s.throwsOwed !== 0 || s.pending.length === 0)) throw new Error('bad move phase');
if (s.phase === 'move' && yut.legalActions!(s, s.current).length === 0) throw new Error('move phase without legal moves');
const side = s.sides[s.turnSide]!;
if (side.finishedRank !== null || side.out || !side.members.includes(s.current) || side.conceded.includes(s.current)) throw new Error('turn on an inactive side');
for (const p of yut.timeoutPlayers!(s)) {
const v = yut.validate(s, p, yut.onTimeout(s, p));
const auto = yut.validate(s, p, autoAction(s));
if (!v.ok || !auto.ok) throw new Error('onTimeout illegal');
}
}
describe('random playouts', () => {
test('1,000 seeds: finish within the step limit, invariants hold, onTimeout always legal, deterministic, replayable', () => {
let steps = 0;
for (let i = 1; i <= 1000; i++) {
const cfg = CONFIGS[i % CONFIGS.length]!;
const options = yut.optionsSchema.parse(cfg.opts);
const players = PLAYERS.slice(0, cfg.n);
const a = randomPlayout(yut, { players, options, seed: seedN(i), maxSteps: 3000, invariant, maxApplyMs: 60 });
if (!a.finished) throw new Error(`seed ${i} did not finish`);
steps += a.steps;
const res = yut.result(a.finalState as YutState)!;
expect(res.ranking.flat().sort()).toEqual([...players].sort());
if (i % 25 === 0) {
const b = randomPlayout(yut, { players, options, seed: seedN(i), maxSteps: 3000, maxApplyMs: 60 });
expect(JSON.stringify(b.trace)).toBe(JSON.stringify(a.trace));
expect(JSON.stringify(b.finalState)).toBe(JSON.stringify(a.finalState));
// Replay the recorded actions from the same seed → identical final state.
const r = SeededRng.fromSeed(seedN(i));
let now = 1_700_000_000_000;
let s = yut.setup({ players, options, rng: r, now });
for (const { actor, action } of a.trace) {
now += 1000;
s = yut.apply(s, actor, action as YutAction, { rng: r, now }).state;
}
expect(JSON.stringify(s)).toBe(JSON.stringify(a.finalState));
}
}
expect(steps).toBeGreaterThan(1000 * 20);
}, 120_000);
test('apply is fast: p99 under 5 ms (the per-step cap above only guards against GC/scheduler spikes)', () => {
const times: number[] = [];
for (let i = 1; i <= 100; i++) {
const rng = SeededRng.fromSeed(seedN(i));
let s = yut.setup({ players: ['A', 'B', 'C', 'D'], options: yut.defaultOptions, rng, now: 0 });
while (s.phase !== 'finished') {
const a = autoAction(s);
const t0 = performance.now();
s = yut.apply(s, s.current, a, { rng, now: 0 }).state;
times.push(performance.now() - t0);
}
}
times.sort((a, b) => a - b);
expect(times[Math.floor(times.length * 0.99)]!).toBeLessThan(5);
}, 30_000);
test('timeout-only games finish too (auto play from every state), and random concessions keep the game consistent', () => {
for (let i = 1; i <= 60; i++) {
const cfg = CONFIGS[i % CONFIGS.length]!;
const options = yut.optionsSchema.parse(cfg.opts);
const players = PLAYERS.slice(0, cfg.n);
const rng = SeededRng.fromSeed(seedN(i + 5000));
let s = yut.setup({ players, options, rng, now: 0 });
let n = 0;
for (; s.phase !== 'finished' && n < 3000; n++) {
const p = yut.timeoutPlayers!(s)[0]!;
// Now and then somebody concedes (any player, any time).
const quitter = players[rng.int(players.length)]!;
const a: YutAction = rng.next() < 0.01 && yut.validate(s, quitter, { type: 'CONCEDE' }).ok ? { type: 'CONCEDE' } : yut.onTimeout(s, p);
const actor = a.type === 'CONCEDE' ? quitter : p;
expect(yut.validate(s, actor, a).ok).toBe(true);
s = yut.apply(s, actor, a, { rng, now: n * 1000 }).state;
invariant(s);
}
expect(s.phase).toBe('finished');
expect(yut.result(s)!.ranking.flat().sort()).toEqual([...players].sort());
}
}, 60_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: 'yut', nameKo: '윷놀이', minPlayers: 2, maxPlayers: 8, minutes: '15~25', level: '쉬움', blurb: '도·개·걸·윷·모! 개인전 또는 팀전', available: true },
];
export const catalogById = (id: string): CatalogEntry | undefined => CATALOG.find((g) => g.id === id);