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(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 = { 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 = {}, 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> = { 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 = {}; 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) => ({ ...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; 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(); 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(); 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); });