- 규칙(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>
732 lines
34 KiB
TypeScript
732 lines
34 KiB
TypeScript
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);
|
|
});
|