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:
@@ -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[] };
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
152
packages/games/src/yut/board.ts
Normal file
152
packages/games/src/yut/board.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
833
packages/games/src/yut/index.ts
Normal file
833
packages/games/src/yut/index.ts
Normal 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]}칸)`;
|
||||
}
|
||||
731
packages/games/src/yut/yut.test.ts
Normal file
731
packages/games/src/yut/yut.test.ts
Normal 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);
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user