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:
@@ -182,3 +182,47 @@ describe('Room isolation and lobby', () => {
|
|||||||
expect(room.seatOf('B')).toBe(-1);
|
expect(room.seatOf('B')).toBe(-1);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('윷놀이 room rules', () => {
|
||||||
|
const yutGames = () => ({ yut: GAMES.yut! });
|
||||||
|
|
||||||
|
test('team mode follows the game range to 8 seats and refuses odd counts via playerCountError', () => {
|
||||||
|
const { room, conn } = harness(yutGames());
|
||||||
|
room.join(conn('A'), 'player');
|
||||||
|
expect(room.configure('A', { maxPlayers: 4 })).toBeNull();
|
||||||
|
expect(room.configure('A', { options: { ...GAMES.yut!.defaultOptions, mode: 'team' } })).toBeNull();
|
||||||
|
expect(room.seats).toHaveLength(8);
|
||||||
|
for (const u of ['B', 'C', 'D', 'E']) room.join(conn(u), 'player');
|
||||||
|
expect(room.start('A')).toBe('팀전은 4명, 6명, 8명(짝수)으로만 할 수 있어요.');
|
||||||
|
room.join(conn('F'), 'player');
|
||||||
|
expect(room.start('A')).toBeNull();
|
||||||
|
room.clearTimer();
|
||||||
|
expect((room.game!.state as any).sides.map((s: any) => s.members)).toEqual([
|
||||||
|
['A', 'C', 'E'],
|
||||||
|
['B', 'D', 'F'],
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a disconnected player is auto-played after the grace period; reconnecting gets the current view', () => {
|
||||||
|
const { room, clock, conn } = harness(yutGames());
|
||||||
|
const conns = { A: conn('A'), B: conn('B') };
|
||||||
|
room.join(conns.A, 'player');
|
||||||
|
room.join(conns.B, 'player');
|
||||||
|
room.configure('A', { options: { ...GAMES.yut!.defaultOptions, turnSeconds: null } });
|
||||||
|
expect(room.start('A')).toBeNull();
|
||||||
|
room.clearTimer();
|
||||||
|
const cur = (room.game!.state as any).current as 'A' | 'B';
|
||||||
|
room.disconnect(conns[cur]);
|
||||||
|
room.clearTimer();
|
||||||
|
expect(room.effectiveDeadline()).toBe(clock.now() + 60_000);
|
||||||
|
clock.advance(60_000);
|
||||||
|
room.onTimer();
|
||||||
|
room.clearTimer();
|
||||||
|
expect((room.game!.state as any).turnSeq).toBe(1);
|
||||||
|
const back = conn(cur);
|
||||||
|
room.join(back, 'player');
|
||||||
|
room.clearTimer();
|
||||||
|
const st = back.msgs.find((m) => m.t === 'state') as any;
|
||||||
|
expect(st.view.turnSeq).toBe((room.game!.state as any).turnSeq);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -484,6 +484,8 @@ export class Room {
|
|||||||
if (lk.paths.length) next.options = def.optionsSchema.parse(applyLocks(next.options, lk.defaults, lk.paths));
|
if (lk.paths.length) next.options = def.optionsSchema.parse(applyLocks(next.options, lk.defaults, lk.paths));
|
||||||
const range = this.rangeFor(next.gameId, next.options);
|
const range = this.rangeFor(next.gameId, next.options);
|
||||||
if (patch.maxPlayers !== undefined) next.maxPlayers = patch.maxPlayers;
|
if (patch.maxPlayers !== undefined) next.maxPlayers = patch.maxPlayers;
|
||||||
|
// A room that had every seat its game allows keeps doing so when an option changes the range (e.g. 윷놀이 팀전 8명).
|
||||||
|
else if (next.gameId === this.config.gameId && this.config.maxPlayers === this.playerRange().max) next.maxPlayers = range.max;
|
||||||
next.maxPlayers = Math.min(Math.max(next.maxPlayers, range.min), range.max);
|
next.maxPlayers = Math.min(Math.max(next.maxPlayers, range.min), range.max);
|
||||||
if (next.maxPlayers < this.seatedIds().length) return `이미 ${this.seatedIds().length}명이 앉아 있어요.`;
|
if (next.maxPlayers < this.seatedIds().length) return `이미 ${this.seatedIds().length}명이 앉아 있어요.`;
|
||||||
for (const k of ['visibility', 'allowSpectators', 'chatEnabled', 'chatFilter'] as const) {
|
for (const k of ['visibility', 'allowSpectators', 'chatEnabled', 'chatFilter'] as const) {
|
||||||
@@ -598,6 +600,8 @@ export class Room {
|
|||||||
const lk = this.locked(def.id);
|
const lk = this.locked(def.id);
|
||||||
const parsed = def.optionsSchema.safeParse(lk.paths.length ? applyLocks(this.config.options, lk.defaults, lk.paths) : this.config.options);
|
const parsed = def.optionsSchema.safeParse(lk.paths.length ? applyLocks(this.config.options, lk.defaults, lk.paths) : this.config.options);
|
||||||
if (!parsed.success) return '설정 값이 올바르지 않아요.';
|
if (!parsed.success) return '설정 값이 올바르지 않아요.';
|
||||||
|
const countErr = def.playerCountError?.(parsed.data, players.length);
|
||||||
|
if (countErr) return countErr;
|
||||||
const eff = this.deps.settings?.game(def.id);
|
const eff = this.deps.settings?.game(def.id);
|
||||||
if (eff && !eff.enabled) return '관리자가 이 게임을 잠시 막아 두었어요.';
|
if (eff && !eff.enabled) return '관리자가 이 게임을 잠시 막아 두었어요.';
|
||||||
this.config = { ...this.config, options: parsed.data };
|
this.config = { ...this.config, options: parsed.data };
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ export const GAME_UI: Record<string, () => Promise<{ default: GameUI }>> = {
|
|||||||
baduk: () => import('./baduk'),
|
baduk: () => import('./baduk'),
|
||||||
seotda: () => import('./seotda'),
|
seotda: () => import('./seotda'),
|
||||||
poker: () => import('./poker'),
|
poker: () => import('./poker'),
|
||||||
|
yut: () => import('./yut'),
|
||||||
};
|
};
|
||||||
|
|
||||||
const cache = new Map<string, GameUI>();
|
const cache = new Map<string, GameUI>();
|
||||||
|
|||||||
866
apps/web/src/games/yut/index.tsx
Normal file
866
apps/web/src/games/yut/index.tsx
Normal file
@@ -0,0 +1,866 @@
|
|||||||
|
/** 윷놀이 화면 (docs/games/yut.md §9). Board, sticks and pieces are drawn here (SVG/CSS only). */
|
||||||
|
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
import type { Throw, YutLegalMove, YutOptions, YutSideView, YutView } from '@bg/games/yut';
|
||||||
|
import type { PublicUser } from '@bg/shared/lite';
|
||||||
|
import { Avatar, ConfirmSheet } from '../../components/ui';
|
||||||
|
import { TurnTimer } from '../../components/TurnTimer';
|
||||||
|
import { haptic, sounds } from '../../lib/feedback';
|
||||||
|
import type { BoardProps, GameUI, OptionsProps } from '../types';
|
||||||
|
|
||||||
|
type From = number | 'home';
|
||||||
|
type GameEvent = YutView['log'][number];
|
||||||
|
|
||||||
|
// The rules module is imported for types only (no zod in this chunk); these small tables mirror packages/games/src/yut/board.ts.
|
||||||
|
const NODE_NAMES = [
|
||||||
|
'참먹이', '도', '개', '걸', '윷', '모', '뒷도', '뒷개', '뒷걸', '뒷윷',
|
||||||
|
'뒷모', '찌도', '찌개', '찌걸', '찌윷', '찌모', '날도', '날개', '날걸', '날윷',
|
||||||
|
'앞모도', '앞모개', '방', '속윷', '속모', '뒷모도', '뒷모개', '사려', '안찌',
|
||||||
|
];
|
||||||
|
const THROW_KO: Record<Throw, string> = { do: '도', gae: '개', geol: '걸', yut: '윷', mo: '모', backdo: '빽도' };
|
||||||
|
const STEPS: Record<Throw, number> = { do: 1, gae: 2, geol: 3, yut: 4, mo: 5, backdo: -1 };
|
||||||
|
const stepText = (t: Throw) => (t === 'backdo' ? '뒤로 1칸' : `${STEPS[t]}칸`);
|
||||||
|
const BIG = new Set([0, 5, 10, 15, 22]);
|
||||||
|
const BIG_LABELS: [number, string][] = [[0, '출발'], [5, '모'], [10, '뒷모'], [15, '찌모'], [22, '방']];
|
||||||
|
|
||||||
|
// Board geometry (viewBox 0..100): 참먹이 bottom-right, 모 top-right, 뒷모 top-left, 찌모 bottom-left; pieces go anticlockwise.
|
||||||
|
const LO = 8;
|
||||||
|
const HI = 92;
|
||||||
|
const GAP = (HI - LO) / 5;
|
||||||
|
const POS: [number, number][] = (() => {
|
||||||
|
const p: [number, number][] = [];
|
||||||
|
p[0] = [HI, HI];
|
||||||
|
for (let i = 1; i <= 4; i++) p[i] = [HI, HI - GAP * i];
|
||||||
|
p[5] = [HI, LO];
|
||||||
|
for (let i = 6; i <= 9; i++) p[i] = [HI - GAP * (i - 5), LO];
|
||||||
|
p[10] = [LO, LO];
|
||||||
|
for (let i = 11; i <= 14; i++) p[i] = [LO, LO + GAP * (i - 10)];
|
||||||
|
p[15] = [LO, HI];
|
||||||
|
for (let i = 16; i <= 19; i++) p[i] = [LO + GAP * (i - 15), HI];
|
||||||
|
const lerp = (a: [number, number], b: [number, number], t: number): [number, number] => [a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t];
|
||||||
|
[20, 21, 22, 23, 24].forEach((n, k) => (p[n] = lerp(p[5]!, p[15]!, (k + 1) / 6)));
|
||||||
|
[25, 26].forEach((n, k) => (p[n] = lerp(p[10]!, p[0]!, (k + 1) / 6)));
|
||||||
|
[27, 28].forEach((n, k) => (p[n] = lerp(p[10]!, p[0]!, (k + 4) / 6)));
|
||||||
|
return p;
|
||||||
|
})();
|
||||||
|
/** Where pieces come from / go to (just outside 참먹이). */
|
||||||
|
const HOME_PT: [number, number] = [HI + 4, HI + 7];
|
||||||
|
const LINES: [number, number][] = [
|
||||||
|
...Array.from({ length: 20 }, (_, i) => [i, (i + 1) % 20] as [number, number]),
|
||||||
|
[5, 20], [20, 21], [21, 22], [22, 23], [23, 24], [24, 15],
|
||||||
|
[10, 25], [25, 26], [26, 22], [22, 27], [27, 28], [28, 0],
|
||||||
|
];
|
||||||
|
const R_NODE = 5.1;
|
||||||
|
const R_BIG = 6.9;
|
||||||
|
|
||||||
|
// Side look: colour + shape (colour-blind safe: every side also has its own shape).
|
||||||
|
const SOLO_COLORS = ['#dc2626', '#2563eb', '#16a34a', '#eab308'];
|
||||||
|
const TEAM_COLORS = ['#2563eb', '#dc2626'];
|
||||||
|
const SHAPES = ['circle', 'triangle', 'square', 'star'] as const;
|
||||||
|
const SHAPE_KO = { circle: '동그라미', triangle: '세모', square: '네모', star: '별' } as const;
|
||||||
|
const sideColor = (team: boolean, side: number) => (team ? TEAM_COLORS : SOLO_COLORS)[side] ?? '#888';
|
||||||
|
const sideShape = (side: number) => SHAPES[side % SHAPES.length]!;
|
||||||
|
|
||||||
|
const nick = (players: Record<string, PublicUser>, id: string) => players[id]?.nickname ?? '알 수 없음';
|
||||||
|
const userOf = (players: Record<string, PublicUser>, id: string): PublicUser => players[id] ?? { id, nickname: '알 수 없음', avatar: null, kind: 'guest' };
|
||||||
|
const nodeName = (n: number | 'home' | 'goal') => (n === 'home' ? '대기' : n === 'goal' ? '남' : NODE_NAMES[n]!);
|
||||||
|
|
||||||
|
const reducedMotion = () => typeof window !== 'undefined' && window.matchMedia?.('(prefers-reduced-motion: reduce)').matches;
|
||||||
|
|
||||||
|
const CSS = `
|
||||||
|
.yut-wrap { width: 100%; display: grid; gap: 14px; align-items: start; }
|
||||||
|
.yut-board-box { width: 100%; max-width: min(100%, 640px); margin: 0 auto; }
|
||||||
|
.yut-board { width: 100%; display: block; border-radius: 18px; box-shadow: var(--shadow); touch-action: manipulation; }
|
||||||
|
.yut-ctrl { display: flex; flex-direction: column; gap: 12px; min-width: 0; }
|
||||||
|
@media (min-width: 1100px) {
|
||||||
|
.yut-wrap { grid-template-columns: minmax(0, 1fr) 300px; align-items: center; }
|
||||||
|
.yut-board-box { max-width: min(100%, calc(100vh - 64px - 52px - 40px)); }
|
||||||
|
}
|
||||||
|
.yut-chips { display: flex; flex-wrap: wrap; gap: 8px; }
|
||||||
|
.yut-chip { min-width: 64px; min-height: 64px; border-radius: 14px; border: 3px solid var(--border); background: var(--surface); color: var(--text);
|
||||||
|
display: inline-flex; flex-direction: column; align-items: center; justify-content: center; line-height: 1.1; cursor: pointer; padding: 4px 8px; font-weight: 800; }
|
||||||
|
.yut-chip b { font-size: 1.5rem; }
|
||||||
|
.yut-chip small { font-size: .8rem; font-weight: 700; color: var(--text-sub); }
|
||||||
|
.yut-chip[aria-pressed='true'] { border-color: var(--primary); background: var(--primary-soft); }
|
||||||
|
.yut-chip.back b { color: var(--danger); }
|
||||||
|
.yut-chip:disabled { cursor: default; opacity: .85; }
|
||||||
|
.yut-throw { min-height: 72px; font-size: 1.35rem; }
|
||||||
|
.yut-tray { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
|
||||||
|
.yut-tray-btn { min-height: 56px; border-radius: 14px; border: 3px dashed var(--border); background: var(--surface); color: var(--text); padding: 4px 12px;
|
||||||
|
display: inline-flex; align-items: center; gap: 8px; font-weight: 700; cursor: pointer; }
|
||||||
|
.yut-tray-btn.can { border-style: solid; border-color: var(--focus); animation: yut-glow 1.1s ease-in-out infinite; }
|
||||||
|
.yut-tray-btn.sel { border-style: solid; border-color: var(--primary); background: var(--primary-soft); animation: none; }
|
||||||
|
.yut-tray-btn:disabled { cursor: default; }
|
||||||
|
.yut-sticks-box { display: flex; align-items: center; gap: 12px; min-height: 84px; }
|
||||||
|
.yut-sticks { width: 120px; height: 80px; flex: none; overflow: visible; }
|
||||||
|
.yut-result { font-size: 2rem; font-weight: 900; min-width: 3ch; }
|
||||||
|
.yut-toss .yut-stick { transform-box: fill-box; transform-origin: center; animation: yut-toss .8s cubic-bezier(.25,.7,.35,1) both; animation-delay: var(--d, 0ms); }
|
||||||
|
@keyframes yut-toss { 0% { transform: translateY(0) rotate(0deg); } 45% { transform: translateY(-34px) rotate(260deg); } 75% { transform: translateY(2px) rotate(350deg); } 100% { transform: translateY(0) rotate(360deg); } }
|
||||||
|
@keyframes yut-glow { 0%, 100% { box-shadow: 0 0 0 0 rgba(245, 158, 11, .0); } 50% { box-shadow: 0 0 0 5px rgba(245, 158, 11, .35); } }
|
||||||
|
@keyframes yut-pulse { 0%, 100% { opacity: .35; } 50% { opacity: 1; } }
|
||||||
|
@keyframes yut-hop { 0% { transform: translateY(0); } 50% { transform: translateY(-5px); } 100% { transform: translateY(0); } }
|
||||||
|
@keyframes yut-pop { from { transform: scale(.4); opacity: 0; } to { transform: scale(1); opacity: 1; } }
|
||||||
|
.yut-can { animation: yut-pulse 1s ease-in-out infinite; }
|
||||||
|
.yut-hop { transform-box: fill-box; transform-origin: center bottom; animation: yut-hop .17s ease-out; }
|
||||||
|
.yut-pop { transform-box: fill-box; transform-origin: center; animation: yut-pop .25s ease-out both; }
|
||||||
|
.yut-hit { cursor: pointer; outline: none; }
|
||||||
|
.yut-hit .yut-focus { opacity: 0; }
|
||||||
|
.yut-hit:focus-visible .yut-focus { opacity: 1; }
|
||||||
|
.yut-log { margin: 0; padding: 0; list-style: none; display: flex; flex-direction: column; gap: 4px; max-height: 150px; overflow: auto; font-size: .92rem; }
|
||||||
|
.yut-side-card { padding: 10px 12px; border: 3px solid transparent; transition: border-color .2s; }
|
||||||
|
.yut-side-card.turn { border-color: var(--primary); }
|
||||||
|
.yut-badge { display: inline-block; border-radius: 999px; padding: 0 8px; font-weight: 800; font-size: .78rem; color: #fff; }
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.yut-toss .yut-stick, .yut-can, .yut-hop, .yut-pop, .yut-tray-btn.can { animation: none; }
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- pieces & sticks
|
||||||
|
|
||||||
|
function Token({ side, team, x, y, r, count = 1 }: { side: number; team: boolean; x: number; y: number; r: number; count?: number }) {
|
||||||
|
const color = sideColor(team, side);
|
||||||
|
const shape = sideShape(side);
|
||||||
|
const common = { fill: color, stroke: '#fff', strokeWidth: r * 0.16, strokeLinejoin: 'round' as const };
|
||||||
|
let body: React.ReactNode;
|
||||||
|
if (shape === 'circle') body = <circle cx={0} cy={0} r={r} {...common} />;
|
||||||
|
else if (shape === 'square') body = <rect x={-r * 0.86} y={-r * 0.86} width={r * 1.72} height={r * 1.72} rx={r * 0.2} {...common} />;
|
||||||
|
else if (shape === 'triangle') body = <polygon points={`0,${-r * 1.05} ${r * 1.02},${r * 0.75} ${-r * 1.02},${r * 0.75}`} {...common} />;
|
||||||
|
else {
|
||||||
|
const pts = Array.from({ length: 10 }, (_, i) => {
|
||||||
|
const a = -Math.PI / 2 + (i * Math.PI) / 5;
|
||||||
|
const rr = i % 2 === 0 ? r * 1.1 : r * 0.5;
|
||||||
|
return `${(Math.cos(a) * rr).toFixed(2)},${(Math.sin(a) * rr).toFixed(2)}`;
|
||||||
|
});
|
||||||
|
body = <polygon points={pts.join(' ')} {...common} />;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<g transform={`translate(${x} ${y})`}>
|
||||||
|
<g transform={`translate(${r * 0.08} ${r * 0.14})`} opacity={0.3}>
|
||||||
|
<circle r={r * 0.95} fill="#000" />
|
||||||
|
</g>
|
||||||
|
{body}
|
||||||
|
{count > 1 && (
|
||||||
|
<g transform={`translate(${r * 0.75} ${-r * 0.75})`}>
|
||||||
|
<circle r={r * 0.62} fill="#111" stroke="#fff" strokeWidth={r * 0.1} />
|
||||||
|
<text textAnchor="middle" dominantBaseline="central" fontSize={r * 0.82} fontWeight={900} fill="#fff">
|
||||||
|
{count}
|
||||||
|
</text>
|
||||||
|
</g>
|
||||||
|
)}
|
||||||
|
</g>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function TokenIcon({ side, team, size = 22 }: { side: number; team: boolean; size?: number }) {
|
||||||
|
return (
|
||||||
|
<svg width={size} height={size} viewBox="-6 -6 12 12" aria-hidden style={{ flex: 'none' }}>
|
||||||
|
<Token side={side} team={team} x={0} y={0} r={4.4} />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One 윷가락: 배(flat side, light) or 등(round side, dark with marks). Stick 0 carries the 빽도 mark on its flat side. */
|
||||||
|
function Stick({ x, flat, marked, delay }: { x: number; flat: boolean; marked: boolean; delay: number }) {
|
||||||
|
return (
|
||||||
|
<g className="yut-stick" style={{ ['--d' as string]: `${delay}ms` }}>
|
||||||
|
<rect x={x} y={8} width={18} height={64} rx={9} fill={flat ? '#f6e4b8' : '#a86b2d'} stroke={flat ? '#9a7432' : '#5c3713'} strokeWidth={2} />
|
||||||
|
{flat ? (
|
||||||
|
<>
|
||||||
|
<line x1={x + 9} y1={16} x2={x + 9} y2={64} stroke="#d9bd83" strokeWidth={1.5} />
|
||||||
|
{marked && (
|
||||||
|
<text x={x + 9} y={41} textAnchor="middle" dominantBaseline="central" fontSize={12} fontWeight={900} fill="#c81e1e">
|
||||||
|
빽
|
||||||
|
</text>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
[20, 32, 44, 56].map((y) => <line key={y} x1={x + 5} y1={y} x2={x + 13} y2={y} stroke="#f6e4b8" strokeWidth={2} strokeLinecap="round" opacity={0.7} />)
|
||||||
|
)}
|
||||||
|
</g>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Sticks({ view: v, players }: { view: YutView; players: Record<string, PublicUser> }) {
|
||||||
|
const lt = v.lastThrow;
|
||||||
|
const [tossing, setTossing] = useState(false);
|
||||||
|
const seen = useRef(lt?.no ?? 0);
|
||||||
|
useEffect(() => {
|
||||||
|
if (!lt || lt.no === seen.current) return;
|
||||||
|
seen.current = lt.no;
|
||||||
|
if (reducedMotion()) return;
|
||||||
|
setTossing(true);
|
||||||
|
const t = setTimeout(() => setTossing(false), 820);
|
||||||
|
return () => clearTimeout(t);
|
||||||
|
}, [lt?.no]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
const sticks = lt?.sticks ?? [false, true, false, true];
|
||||||
|
const label = !lt ? '' : tossing ? '…' : lt.result === 'nak' ? '낙!' : `${THROW_KO[lt.result]}!`;
|
||||||
|
return (
|
||||||
|
<div className="yut-sticks-box">
|
||||||
|
<svg className={`yut-sticks${tossing ? ' yut-toss' : ''}`} viewBox="0 0 120 80" role="img" aria-label={lt ? `${nick(players, lt.player)}님이 던진 윷: ${lt.result === 'nak' ? '낙' : THROW_KO[lt.result]}` : '윷가락 네 개'}>
|
||||||
|
{lt?.result === 'nak' ? (
|
||||||
|
<g opacity={0.45} transform="rotate(-18 60 40)">
|
||||||
|
{[0, 1, 2, 3].map((i) => (
|
||||||
|
<Stick key={i} x={8 + i * 27} flat={i % 2 === 0} marked={false} delay={i * 40} />
|
||||||
|
))}
|
||||||
|
</g>
|
||||||
|
) : (
|
||||||
|
sticks.map((f, i) => <Stick key={i} x={8 + i * 27} flat={f} marked={i === 0 && v.options.backdo} delay={i * 40} />)
|
||||||
|
)}
|
||||||
|
</svg>
|
||||||
|
<div>
|
||||||
|
<div className="yut-result" aria-hidden>
|
||||||
|
{label}
|
||||||
|
</div>
|
||||||
|
{lt && !tossing && <div className="small muted">{nick(players, lt.player)}님</div>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- board
|
||||||
|
|
||||||
|
interface Anim {
|
||||||
|
no: number;
|
||||||
|
pts: [number, number][];
|
||||||
|
i: number;
|
||||||
|
side: number;
|
||||||
|
count: number;
|
||||||
|
to: number | 'goal';
|
||||||
|
caught: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
function YutBoard({ view: v, me, players, act, pending, deadline }: BoardProps<YutView>) {
|
||||||
|
const myTurn = v.phase !== 'finished' && v.current === me;
|
||||||
|
const moves = useMemo(() => (myTurn && v.phase === 'move' ? (v.legalMoves ?? []) : []), [myTurn, v.phase, v.legalMoves]);
|
||||||
|
const [selIdx, setSelIdx] = useState<number | null>(null);
|
||||||
|
const [selFrom, setSelFrom] = useState<From | null>(null);
|
||||||
|
const [announce, setAnnounce] = useState('');
|
||||||
|
const [anim, setAnim] = useState<Anim | null>(null);
|
||||||
|
const [flash, setFlash] = useState<{ text: string; at: [number, number]; key: number } | null>(null);
|
||||||
|
const lastMoveNo = useRef(v.lastMove?.no ?? 0);
|
||||||
|
const lastThrowNo = useRef(v.lastThrow?.no ?? 0);
|
||||||
|
const prevCurrent = useRef(v.current);
|
||||||
|
|
||||||
|
const kinds = [...new Set(v.pending)];
|
||||||
|
// Reset the selection whenever the state moves on; pick the result automatically when there is only one kind.
|
||||||
|
useEffect(() => {
|
||||||
|
setSelFrom(null);
|
||||||
|
if (myTurn && v.phase === 'move' && kinds.length === 1) setSelIdx(v.pending.indexOf(kinds[0]!));
|
||||||
|
else setSelIdx(null);
|
||||||
|
}, [v.turnSeq]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
||||||
|
const selThrow = selIdx !== null ? (v.pending[selIdx] ?? null) : null;
|
||||||
|
const forThrow = selThrow ? moves.filter((m) => m.throw === selThrow) : [];
|
||||||
|
const movable = new Set<From>(forThrow.map((m) => m.from));
|
||||||
|
const chosen: YutLegalMove | null = selFrom !== null ? (forThrow.find((m) => m.from === selFrom) ?? null) : null;
|
||||||
|
|
||||||
|
// Throw / move feedback: sounds, screen-reader text, hop animation along the path.
|
||||||
|
useEffect(() => {
|
||||||
|
const lt = v.lastThrow;
|
||||||
|
if (lt && lt.no !== lastThrowNo.current) {
|
||||||
|
lastThrowNo.current = lt.no;
|
||||||
|
const who = lt.player === me ? '내가' : `${nick(players, lt.player)}님이`;
|
||||||
|
setAnnounce(lt.result === 'nak' ? `${who} 던진 윷이 판 밖으로 떨어졌어요(낙).` : `${who} ${THROW_KO[lt.result]}를 던졌어요.`);
|
||||||
|
sounds.stone();
|
||||||
|
}
|
||||||
|
}, [v.lastThrow?.no]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const m = v.lastMove;
|
||||||
|
if (!m || m.no === lastMoveNo.current) return;
|
||||||
|
lastMoveNo.current = m.no;
|
||||||
|
const caught = m.captured.reduce((a, c) => a + c.count, 0);
|
||||||
|
const end = m.to === 'goal' ? HOME_PT : POS[m.to]!;
|
||||||
|
const parts = [`${nick(players, m.player)}님이 ${THROW_KO[m.throw]}로 ${nodeName(m.from)}에서 ${nodeName(m.to)}${m.to === 'goal' ? '' : ' 자리로'} 움직였어요${m.count > 1 ? `(${m.count}개 함께)` : ''}.`];
|
||||||
|
if (caught) parts.push(`말 ${caught}개를 잡았어요!`);
|
||||||
|
if (m.stacked) parts.push('업었어요.');
|
||||||
|
if (m.to === 'goal') parts.push(`말 ${m.count}개가 났어요.`);
|
||||||
|
setAnnounce(parts.join(' '));
|
||||||
|
const text = caught ? '잡았다!' : m.to === 'goal' ? '났다!' : m.stacked ? '업었다!' : null;
|
||||||
|
if (text) setFlash({ text, at: end, key: m.no });
|
||||||
|
if (caught) haptic([40, 30, 40]);
|
||||||
|
if (reducedMotion()) return;
|
||||||
|
const pts: [number, number][] = [m.from === 'home' ? HOME_PT : POS[m.from]!, ...m.path.map((n) => POS[n]!)];
|
||||||
|
if (m.to === 'goal') pts.push(HOME_PT);
|
||||||
|
setAnim({ no: m.no, pts, i: 0, side: m.side, count: m.count, to: m.to, caught: caught > 0 });
|
||||||
|
}, [v.lastMove?.no]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!anim) return;
|
||||||
|
if (anim.i >= anim.pts.length - 1) {
|
||||||
|
const t = setTimeout(() => setAnim(null), 120);
|
||||||
|
return () => clearTimeout(t);
|
||||||
|
}
|
||||||
|
const t = setTimeout(() => setAnim((a) => (a && a.no === anim.no ? { ...a, i: a.i + 1 } : a)), 170);
|
||||||
|
return () => clearTimeout(t);
|
||||||
|
}, [anim]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!flash) return;
|
||||||
|
const t = setTimeout(() => setFlash(null), 1100);
|
||||||
|
return () => clearTimeout(t);
|
||||||
|
}, [flash]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (v.current !== prevCurrent.current && v.current === me && v.phase !== 'finished') {
|
||||||
|
haptic(40);
|
||||||
|
sounds.myTurn();
|
||||||
|
}
|
||||||
|
prevCurrent.current = v.current;
|
||||||
|
}, [v.current]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
||||||
|
const doMove = (m: YutLegalMove) => {
|
||||||
|
act({ type: 'MOVE', throwIndex: m.throwIndex, from: m.from });
|
||||||
|
setSelFrom(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
/** First tap shows where it lands, second tap (or the destination / 이동하기) confirms. */
|
||||||
|
const tapSource = (from: From) => {
|
||||||
|
if (!myTurn || pending) return;
|
||||||
|
if (!selThrow) {
|
||||||
|
setAnnounce('먼저 아래에서 쓸 윷 결과를 골라 주세요.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!movable.has(from)) {
|
||||||
|
setAnnounce(selThrow === 'backdo' && from === 'home' ? '빽도로는 새 말을 올릴 수 없어요.' : '그 말은 지금 움직일 수 없어요.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (selFrom === from && chosen) doMove(chosen);
|
||||||
|
else {
|
||||||
|
setSelFrom(from);
|
||||||
|
const m = forThrow.find((x) => x.from === from)!;
|
||||||
|
setAnnounce(`${nodeName(m.to)}${m.to === 'goal' ? '' : ' 자리'}로 가요. ${badge(m) ?? ''} 한 번 더 누르면 움직여요.`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const keyAct = (fn: () => void) => (e: React.KeyboardEvent) => {
|
||||||
|
if (e.key === 'Enter' || e.key === ' ') {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation(); // keep the chat's global "Enter focuses chat" shortcut from stealing focus
|
||||||
|
fn();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Stacks to draw (the arriving stack is drawn by the animation until it lands).
|
||||||
|
const stacks = v.stacks.filter((st) => !(anim && anim.to === st.node && st.side === anim.side));
|
||||||
|
const previewPath = chosen ? [chosen.from === 'home' ? HOME_PT : POS[chosen.from]!, ...chosen.path.map((n) => POS[n]!)] : null;
|
||||||
|
const destPt = chosen ? (chosen.to === 'goal' ? HOME_PT : POS[chosen.to]!) : null;
|
||||||
|
const mySideView = v.mySide !== null ? v.sides[v.mySide] : null;
|
||||||
|
const lone = moves.length === 1 ? moves[0]! : null;
|
||||||
|
const totalMs = v.away.includes(v.current) ? 5000 : (v.options.turnSeconds ?? 0) * 1000;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="yut-wrap">
|
||||||
|
<style>{CSS}</style>
|
||||||
|
<div className="yut-board-box">
|
||||||
|
<svg className="yut-board" viewBox="0 0 100 100" role="group" aria-label={`윷판. 말은 오른쪽 아래 참먹이에서 출발해 시계 반대 방향으로 돌아요.${chosen ? ` 고른 말은 ${nodeName(chosen.to)}로 가요.` : ''}`} style={{ background: 'var(--wood)' }}>
|
||||||
|
<rect x={1.5} y={1.5} width={97} height={97} rx={6} fill="none" stroke="var(--wood-line)" strokeWidth={0.6} opacity={0.6} />
|
||||||
|
{LINES.map(([a, b]) => (
|
||||||
|
<line key={`${a}-${b}`} x1={POS[a]![0]} y1={POS[a]![1]} x2={POS[b]![0]} y2={POS[b]![1]} stroke="var(--wood-line)" strokeWidth={0.9} strokeLinecap="round" opacity={0.75} />
|
||||||
|
))}
|
||||||
|
{previewPath && (
|
||||||
|
<polyline points={previewPath.map((p) => p.join(',')).join(' ')} fill="none" stroke="var(--primary)" strokeWidth={1.6} strokeDasharray="2.2 1.8" strokeLinecap="round" strokeLinejoin="round" />
|
||||||
|
)}
|
||||||
|
{POS.map(([x, y], n) => (
|
||||||
|
<g key={n}>
|
||||||
|
<circle cx={x} cy={y} r={BIG.has(n) ? R_BIG : R_NODE} fill="var(--surface)" stroke="var(--wood-line)" strokeWidth={BIG.has(n) ? 1 : 0.7} />
|
||||||
|
{BIG.has(n) && <circle cx={x} cy={y} r={(BIG.has(n) ? R_BIG : R_NODE) - 1.6} fill="none" stroke="var(--wood-line)" strokeWidth={0.4} opacity={0.6} />}
|
||||||
|
</g>
|
||||||
|
))}
|
||||||
|
{BIG_LABELS.map(([n, t]) => (
|
||||||
|
<text key={`l${n}`} x={POS[n]![0]} y={POS[n]![1]} textAnchor="middle" dominantBaseline="central" fontSize={n === 0 ? 2.9 : 3.2} fontWeight={800} fill="var(--wood-line)" opacity={0.9}>
|
||||||
|
{t}
|
||||||
|
</text>
|
||||||
|
))}
|
||||||
|
{/* direction hint at the start */}
|
||||||
|
<path d={`M ${HI - 4.5} ${HI - 9} L ${HI - 4.5} ${HI - 14} M ${HI - 6.3} ${HI - 12} L ${HI - 4.5} ${HI - 14.2} L ${HI - 2.7} ${HI - 12}`} stroke="var(--wood-line)" strokeWidth={0.7} fill="none" strokeLinecap="round" opacity={0.8} />
|
||||||
|
{stacks.map((st) => {
|
||||||
|
const [x, y] = POS[st.node]!;
|
||||||
|
const mine = v.mySide === st.side;
|
||||||
|
const can = myTurn && mine && movable.has(st.node);
|
||||||
|
const sel = mine && selFrom === st.node;
|
||||||
|
const sideName = v.sides[st.side]!.name;
|
||||||
|
return (
|
||||||
|
<g key={`s${st.node}`}>
|
||||||
|
{(can || sel) && <circle cx={x} cy={y} r={R_BIG + 0.6} fill="none" stroke={sel ? 'var(--primary)' : 'var(--focus)'} strokeWidth={1.3} className={sel ? undefined : 'yut-can'} />}
|
||||||
|
<Token side={st.side} team={v.team} x={x} y={y} r={BIG.has(st.node) ? 4.6 : 4} count={st.count} />
|
||||||
|
{mine && myTurn && v.phase === 'move' && (
|
||||||
|
<g
|
||||||
|
className="yut-hit"
|
||||||
|
role="button"
|
||||||
|
tabIndex={can ? 0 : -1}
|
||||||
|
aria-disabled={!can}
|
||||||
|
aria-pressed={sel}
|
||||||
|
aria-label={`${sideName} 말 ${st.count}개 (${NODE_NAMES[st.node]} 자리) ${sel ? '— 한 번 더 누르면 움직여요' : '움직이기'}`}
|
||||||
|
onClick={() => tapSource(st.node)}
|
||||||
|
onKeyDown={keyAct(() => tapSource(st.node))}
|
||||||
|
>
|
||||||
|
<circle cx={x} cy={y} r={R_BIG + 1} fill="transparent" />
|
||||||
|
<circle className="yut-focus" cx={x} cy={y} r={R_BIG + 1.6} fill="none" stroke="var(--focus)" strokeWidth={1} />
|
||||||
|
</g>
|
||||||
|
)}
|
||||||
|
</g>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{chosen && destPt && (
|
||||||
|
<g
|
||||||
|
className="yut-hit"
|
||||||
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
|
aria-label={`${nodeName(chosen.to)}${chosen.to === 'goal' ? '' : ' 자리'}로 이동하기`}
|
||||||
|
onClick={() => !pending && doMove(chosen)}
|
||||||
|
onKeyDown={keyAct(() => !pending && doMove(chosen))}
|
||||||
|
>
|
||||||
|
<circle cx={destPt[0]} cy={destPt[1]} r={R_BIG + 0.8} fill="rgba(37, 99, 235, 0.18)" stroke={chosen.captures ? 'var(--danger)' : 'var(--primary)'} strokeWidth={1.4} strokeDasharray={chosen.captures ? undefined : '2 1.4'} />
|
||||||
|
<circle className="yut-focus" cx={destPt[0]} cy={destPt[1]} r={R_BIG + 2} fill="none" stroke="var(--focus)" strokeWidth={1} />
|
||||||
|
{badge(chosen) && (
|
||||||
|
<g transform={`translate(${Math.min(88, Math.max(12, destPt[0]))} ${Math.max(6, destPt[1] - R_BIG - 4.5)})`}>
|
||||||
|
<rect x={-8} y={-3.2} width={16} height={6.4} rx={3.2} fill={chosen.captures ? 'var(--danger)' : chosen.finishes ? 'var(--success)' : 'var(--primary)'} />
|
||||||
|
<text textAnchor="middle" dominantBaseline="central" fontSize={3.6} fontWeight={900} fill="#fff">
|
||||||
|
{badge(chosen)}
|
||||||
|
</text>
|
||||||
|
</g>
|
||||||
|
)}
|
||||||
|
</g>
|
||||||
|
)}
|
||||||
|
{anim && (
|
||||||
|
<g key={`${anim.no}-${anim.i}`} className="yut-hop">
|
||||||
|
<Token side={anim.side} team={v.team} x={anim.pts[anim.i]![0]} y={anim.pts[anim.i]![1]} r={4.4} count={anim.count} />
|
||||||
|
</g>
|
||||||
|
)}
|
||||||
|
{flash && !anim && (
|
||||||
|
<g key={flash.key} className="yut-pop">
|
||||||
|
<text x={Math.min(84, Math.max(16, flash.at[0]))} y={Math.max(8, flash.at[1] - 9)} textAnchor="middle" fontSize={6} fontWeight={900} fill="#fde047" stroke="#7c2d12" strokeWidth={0.8} paintOrder="stroke">
|
||||||
|
{flash.text}
|
||||||
|
</text>
|
||||||
|
</g>
|
||||||
|
)}
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="yut-ctrl">
|
||||||
|
<div className="row" style={{ justifyContent: 'space-between', flexWrap: 'nowrap' }}>
|
||||||
|
<Sticks view={v} players={players} />
|
||||||
|
{v.phase !== 'finished' && <TurnTimer deadline={deadline} totalMs={Math.max(1000, totalMs)} mine={myTurn} size={44} />}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="stack" style={{ gap: 6 }}>
|
||||||
|
<strong className="small">모아 둔 결과{v.current !== me && v.phase !== 'finished' ? ` · ${nick(players, v.current)}님` : ''}</strong>
|
||||||
|
{v.pending.length === 0 ? (
|
||||||
|
<span className="muted small">{v.phase === 'throw' ? '윷을 던지면 여기에 모여요.' : '없음'}</span>
|
||||||
|
) : (
|
||||||
|
<div className="yut-chips" role="group" aria-label="모아 둔 윷 결과">
|
||||||
|
{v.pending.map((t, i) => {
|
||||||
|
const usable = moves.some((m) => m.throw === t);
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={i}
|
||||||
|
type="button"
|
||||||
|
className={`yut-chip${t === 'backdo' ? ' back' : ''}`}
|
||||||
|
aria-pressed={selIdx === i}
|
||||||
|
aria-label={`${THROW_KO[t]}, ${t === 'backdo' ? '뒤로 1칸' : `앞으로 ${STEPS[t]}칸`}`}
|
||||||
|
disabled={!myTurn || v.phase !== 'move' || !usable || pending}
|
||||||
|
onClick={() => {
|
||||||
|
setSelIdx(i);
|
||||||
|
setSelFrom(null);
|
||||||
|
const n = moves.filter((m) => m.throw === t).length;
|
||||||
|
setAnnounce(`${THROW_KO[t]}를 골랐어요. 움직일 수 있는 말이 ${n}가지예요.`);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<b>{THROW_KO[t]}</b>
|
||||||
|
<small>{stepText(t)}</small>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{myTurn && v.phase === 'throw' && (
|
||||||
|
<button className="btn btn-primary btn-block yut-throw" disabled={pending} onClick={() => act({ type: 'THROW' })}>
|
||||||
|
{v.pending.length || v.lastThrow?.player === me ? '🥢 한 번 더 던지기' : '🥢 윷 던지기'}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{myTurn && v.phase === 'move' && (
|
||||||
|
<div className="stack" style={{ gap: 8 }}>
|
||||||
|
{chosen ? (
|
||||||
|
<div className="row" style={{ flexWrap: 'nowrap' }}>
|
||||||
|
<button className="btn btn-primary" style={{ flex: 2 }} disabled={pending} onClick={() => doMove(chosen)}>
|
||||||
|
이동하기 · {nodeName(chosen.from)} → {nodeName(chosen.to)}
|
||||||
|
{badge(chosen) ? ` (${badge(chosen)})` : ''}
|
||||||
|
</button>
|
||||||
|
<button className="btn btn-secondary" style={{ flex: 1 }} onClick={() => setSelFrom(null)}>
|
||||||
|
취소
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="small muted" style={{ margin: 0 }}>
|
||||||
|
{selThrow ? '반짝이는 말을 누르면 갈 자리가 보여요. 한 번 더 누르면 움직여요.' : '쓸 결과를 먼저 골라 주세요.'}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{lone && !chosen && (
|
||||||
|
<button className="btn btn-success btn-block" disabled={pending} onClick={() => doMove(lone)}>
|
||||||
|
자동 이동 · {THROW_KO[lone.throw]}로 {nodeName(lone.from)} → {nodeName(lone.to)}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{mySideView && (
|
||||||
|
<div className="yut-tray" aria-label="내 편 말">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`yut-tray-btn${myTurn && movable.has('home') ? (selFrom === 'home' ? ' sel' : ' can') : ''}`}
|
||||||
|
disabled={!myTurn || v.phase !== 'move' || mySideView.home === 0 || pending}
|
||||||
|
aria-pressed={selFrom === 'home'}
|
||||||
|
aria-label={`대기 말 ${mySideView.home}개${myTurn && movable.has('home') ? (selFrom === 'home' ? ' — 한 번 더 누르면 올려요' : ' — 새 말 올리기') : ''}`}
|
||||||
|
onClick={() => tapSource('home')}
|
||||||
|
>
|
||||||
|
<TokenIcon side={mySideView.id} team={v.team} />
|
||||||
|
대기 {mySideView.home}개{myTurn && movable.has('home') ? ' · 새 말 올리기' : ''}
|
||||||
|
</button>
|
||||||
|
<span className="small" aria-label={`난 말 ${mySideView.done}개`}>
|
||||||
|
🏁 난 말 <strong>{mySideView.done}</strong>/{v.options.pieces}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<p className="sr-only" aria-live="polite">
|
||||||
|
{announce}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function badge(m: YutLegalMove): string | null {
|
||||||
|
if (m.captures) return '잡기!';
|
||||||
|
if (m.finishes) return '날!';
|
||||||
|
if (m.stacks) return '업기';
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- side panel
|
||||||
|
|
||||||
|
function SideCard({ side, view: v, players, me, deadline }: { side: YutSideView; view: YutView; players: Record<string, PublicUser>; me: string | null; deadline: number | null }) {
|
||||||
|
const turn = v.phase !== 'finished' && v.turnSide === side.id;
|
||||||
|
const shape = SHAPE_KO[sideShape(side.id)];
|
||||||
|
const status = side.finishedRank !== null ? `${side.finishedRank}등` : side.out ? '기권' : null;
|
||||||
|
return (
|
||||||
|
<div className={`card yut-side-card${turn ? ' turn' : ''}`} aria-label={`${side.name}(${shape}) 대기 ${side.home}개, 판 위 ${side.onBoard}개, 남 ${side.done}개${status ? `, ${status}` : ''}${turn ? ', 차례' : ''}`}>
|
||||||
|
<div className="row" style={{ gap: 8, flexWrap: 'nowrap' }}>
|
||||||
|
<TokenIcon side={side.id} team={v.team} size={26} />
|
||||||
|
<strong style={{ flex: 1, minWidth: 0 }}>
|
||||||
|
{side.name}
|
||||||
|
{v.mySide === side.id && <span className="muted small"> (우리 편)</span>}
|
||||||
|
</strong>
|
||||||
|
{status && (
|
||||||
|
<span className="yut-badge" style={{ background: side.finishedRank !== null ? 'var(--success)' : 'var(--text-sub)' }}>
|
||||||
|
{status}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<span className="small" style={{ fontVariantNumeric: 'tabular-nums', whiteSpace: 'nowrap' }} aria-hidden>
|
||||||
|
대기 {side.home} · 판 {side.onBoard} · 🏁 {side.done}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="stack" style={{ gap: 4, marginTop: 6 }}>
|
||||||
|
{side.members.map((id) => {
|
||||||
|
const u = userOf(players, id);
|
||||||
|
const cur = turn && v.current === id;
|
||||||
|
return (
|
||||||
|
<div key={id} className="row" style={{ gap: 6, flexWrap: 'nowrap', opacity: side.conceded.includes(id) ? 0.55 : 1 }}>
|
||||||
|
<Avatar user={u} size={24} />
|
||||||
|
<span style={{ flex: 1, minWidth: 0, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis', fontWeight: cur ? 800 : 500 }}>
|
||||||
|
{u.nickname}
|
||||||
|
{id === me && <span className="muted small"> (나)</span>}
|
||||||
|
{cur && <span className="small" style={{ color: 'var(--primary)' }}> · {v.phase === 'throw' ? '던지는 중' : '옮기는 중'}</span>}
|
||||||
|
</span>
|
||||||
|
{side.conceded.includes(id) && <span className="yut-badge" style={{ background: 'var(--text-sub)' }}>기권</span>}
|
||||||
|
{v.away.includes(id) && !side.conceded.includes(id) && <span className="yut-badge" style={{ background: 'var(--warning)' }}>자리 비움</span>}
|
||||||
|
{cur && <TurnTimer deadline={deadline} totalMs={Math.max(1000, v.away.includes(id) ? 5000 : (v.options.turnSeconds ?? 0) * 1000)} mine={id === me} size={30} />}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function logLine(e: GameEvent, v: YutView, players: Record<string, PublicUser>): string | null {
|
||||||
|
const who = typeof e.player === 'string' ? nick(players, e.player) : '';
|
||||||
|
const sn = (s: unknown) => v.sides[s as number]?.name ?? '';
|
||||||
|
switch (e.type) {
|
||||||
|
case 'thrown':
|
||||||
|
return `${who}: ${THROW_KO[e.result as Throw]}`;
|
||||||
|
case 'nak':
|
||||||
|
return `${who}: 낙! (무효)`;
|
||||||
|
case 'moved':
|
||||||
|
return `${who}: ${THROW_KO[e.throw as Throw]}로 ${nodeName(e.from as From)} → ${nodeName(e.to as number | 'goal')}${(e.count as number) > 1 ? ` (${e.count}개)` : ''}`;
|
||||||
|
case 'captured':
|
||||||
|
return `${sn(e.side)}이 ${sn(e.victimSide)} 말 ${e.count}개를 잡았어요`;
|
||||||
|
case 'stacked':
|
||||||
|
return `${sn(e.side)} 말 ${e.count}개가 업혔어요`;
|
||||||
|
case 'finishedPieces':
|
||||||
|
return `${sn(e.side)} 말 ${e.count}개가 났어요`;
|
||||||
|
case 'discarded':
|
||||||
|
return `쓸 곳이 없는 ${(e.throws as Throw[]).map((t) => THROW_KO[t]).join('·')}는 버렸어요`;
|
||||||
|
case 'bonusThrow':
|
||||||
|
return `${who}: 잡아서 한 번 더 던져요`;
|
||||||
|
case 'sideFinished':
|
||||||
|
return `${sn(e.side)} ${e.rank}등!`;
|
||||||
|
case 'conceded':
|
||||||
|
return `${who}님이 기권했어요`;
|
||||||
|
case 'timeout':
|
||||||
|
return `${who}님 시간 초과 · 자동으로 했어요`;
|
||||||
|
case 'away':
|
||||||
|
return `${who}님 자리 비움`;
|
||||||
|
case 'gameOver':
|
||||||
|
return String(e.summary ?? '게임이 끝났어요');
|
||||||
|
default:
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function YutSide({ view: v, me, players, act, deadline }: BoardProps<YutView>) {
|
||||||
|
const [confirm, setConfirm] = useState(false);
|
||||||
|
const ref = useRef<HTMLOListElement>(null);
|
||||||
|
const lines = v.log.map((e) => logLine(e, v, players)).filter((x): x is string => !!x);
|
||||||
|
useEffect(() => {
|
||||||
|
ref.current?.scrollTo({ top: ref.current.scrollHeight });
|
||||||
|
}, [v.turnSeq]);
|
||||||
|
const mySide = v.mySide !== null ? v.sides[v.mySide] : null;
|
||||||
|
const canConcede = v.phase !== 'finished' && !!me && !!mySide && !mySide.conceded.includes(me) && mySide.finishedRank === null;
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{v.sides.map((x) => (
|
||||||
|
<SideCard key={x.id} side={x} view={v} players={players} me={me} deadline={deadline} />
|
||||||
|
))}
|
||||||
|
{v.log.length > 0 && (
|
||||||
|
<details className="card wide-open" open style={{ padding: 12 }}>
|
||||||
|
<summary style={{ cursor: 'pointer', fontWeight: 700 }}>진행 기록</summary>
|
||||||
|
<ol ref={ref} className="yut-log" style={{ marginTop: 8 }}>
|
||||||
|
{lines.slice(-24).map((l, i) => (
|
||||||
|
<li key={`${v.turnSeq}-${i}`}>{l}</li>
|
||||||
|
))}
|
||||||
|
</ol>
|
||||||
|
</details>
|
||||||
|
)}
|
||||||
|
{canConcede && (
|
||||||
|
<button className="btn btn-danger btn-sm" onClick={() => setConfirm(true)}>
|
||||||
|
🏳 기권
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{confirm && (
|
||||||
|
<ConfirmSheet
|
||||||
|
title="기권할까요?"
|
||||||
|
body={v.team ? '팀원이 모두 기권하면 우리 팀이 져요. 남은 팀원이 이어서 해요.' : '기권하면 내 말이 판에서 빠지고 꼴찌가 돼요.'}
|
||||||
|
confirm="기권하기"
|
||||||
|
danger
|
||||||
|
onConfirm={() => act({ type: 'CONCEDE' })}
|
||||||
|
onClose={() => setConfirm(false)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- status
|
||||||
|
|
||||||
|
function statusText(v: YutView, me: string | null, players: Record<string, PublicUser>): string {
|
||||||
|
if (v.phase === 'finished') return v.result?.summary ?? '게임이 끝났어요';
|
||||||
|
const cur = nick(players, v.current);
|
||||||
|
if (v.current === me) {
|
||||||
|
if (v.phase === 'throw') return v.pending.length || v.lastThrow?.player === me ? '내 차례예요. 한 번 더 던져요' : '내 차례예요. 윷을 던져 주세요';
|
||||||
|
return '내 차례예요. 결과를 골라 말을 움직여 주세요';
|
||||||
|
}
|
||||||
|
if (v.team && v.mySide === v.turnSide) return `${cur}님(우리 팀) 차례예요`;
|
||||||
|
return `${cur}님 차례예요`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- options
|
||||||
|
|
||||||
|
function Seg<T extends string | number>({ value, options, onChange }: { value: T; options: [T, string][]; onChange: (v: T) => void }) {
|
||||||
|
return (
|
||||||
|
<div className="segmented">
|
||||||
|
{options.map(([k, l]) => (
|
||||||
|
<button key={String(k)} type="button" aria-pressed={value === k} onClick={() => onChange(k)}>
|
||||||
|
{l}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Field({ label, children }: { label: string; children: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<div className="stack" style={{ gap: 6 }}>
|
||||||
|
<strong>{label}</strong>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Options({ value: o, onChange }: OptionsProps<YutOptions>) {
|
||||||
|
const [more, setMore] = useState(false);
|
||||||
|
const set = (patch: Partial<YutOptions>) => onChange({ ...o, ...patch });
|
||||||
|
const onOff = (b: boolean) => (b ? 'on' : 'off');
|
||||||
|
return (
|
||||||
|
<div className="stack" style={{ gap: 16 }}>
|
||||||
|
<Field label="경기 방식">
|
||||||
|
<Seg value={o.mode} options={[['individual', '개인전(2~4명)'], ['team', '팀전(4·6·8명)']]} onChange={(mode) => set({ mode })} />
|
||||||
|
{o.mode === 'team' && <span className="small muted">자리 순서대로 청팀·홍팀이 번갈아 앉아요(1·3·5·7번째 청팀). 자리를 바꿔 팀을 맞춰 주세요.</span>}
|
||||||
|
</Field>
|
||||||
|
<Field label="한 편의 말 수">
|
||||||
|
<Seg value={o.pieces} options={[[2, '2개'], [3, '3개'], [4, '4개'], [5, '5개']]} onChange={(pieces) => set({ pieces })} />
|
||||||
|
</Field>
|
||||||
|
<Field label="행동 제한 시간">
|
||||||
|
<Seg
|
||||||
|
value={o.turnSeconds === null ? 'none' : String(o.turnSeconds)}
|
||||||
|
options={[['15', '15초'], ['30', '30초'], ['60', '60초'], ['none', '무제한']]}
|
||||||
|
onChange={(k) => set({ turnSeconds: k === 'none' ? null : (Number(k) as 15 | 30 | 60) })}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<button type="button" className="btn btn-ghost" onClick={() => setMore(!more)} aria-expanded={more}>
|
||||||
|
{more ? '▲ 우리 집 규칙 닫기' : '▼ 우리 집 규칙(자세한 설정)'}
|
||||||
|
</button>
|
||||||
|
{more && (
|
||||||
|
<>
|
||||||
|
<Field label="빽도">
|
||||||
|
<Seg value={onOff(o.backdo)} options={[['on', '빽도 쓰기'], ['off', '안 씀(그냥 도)']]} onChange={(k) => set({ backdo: k === 'on' })} />
|
||||||
|
</Field>
|
||||||
|
{o.backdo && (
|
||||||
|
<>
|
||||||
|
<Field label="도 자리 말이 빽도를 받으면">
|
||||||
|
<Seg value={o.backdoFromDo} options={[['chammeogi', '참먹이로(다음에 남)'], ['finish', '바로 남']]} onChange={(backdoFromDo) => set({ backdoFromDo })} />
|
||||||
|
</Field>
|
||||||
|
<Field label="빽도로 잡았을 때">
|
||||||
|
<Seg value={onOff(o.backdoCaptureBonus)} options={[['on', '한 번 더 던지기'], ['off', '더 안 던짐']]} onChange={(k) => set({ backdoCaptureBonus: k === 'on' })} />
|
||||||
|
</Field>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<Field label="날(완주) 판정">
|
||||||
|
<Seg value={o.finishRule} options={[['pass', '참먹이를 지나야 남'], ['reach', '참먹이에 도착하면 남']]} onChange={(finishRule) => set({ finishRule })} />
|
||||||
|
</Field>
|
||||||
|
<Field label="윷가락 확률">
|
||||||
|
<Seg value={o.stickModel} options={[['realistic', '실제 윷처럼'], ['fair', '앞뒤 반반']]} onChange={(stickModel) => set({ stickModel })} />
|
||||||
|
</Field>
|
||||||
|
<Field label="낙(윷이 판 밖으로)">
|
||||||
|
<Seg value={o.nakPercent} options={[[0, '없음'], [3, '3%'], [5, '5%'], [10, '10%']]} onChange={(nakPercent) => set({ nakPercent })} />
|
||||||
|
</Field>
|
||||||
|
{o.mode === 'individual' && (
|
||||||
|
<Field label="3~4명일 때 1등이 나오면">
|
||||||
|
<Seg value={o.ranking} options={[['first', '바로 끝'], ['all', '꼴찌까지 계속']]} onChange={(ranking) => set({ ranking })} />
|
||||||
|
</Field>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function optionsSummary(o: YutOptions): string {
|
||||||
|
return [
|
||||||
|
o.mode === 'team' ? '팀전' : '개인전',
|
||||||
|
`말 ${o.pieces}개`,
|
||||||
|
o.backdo ? '빽도 있음' : '빽도 없음',
|
||||||
|
o.finishRule === 'pass' ? '참먹이 지나야 남' : '참먹이 도착하면 남',
|
||||||
|
...(o.nakPercent ? [`낙 ${o.nakPercent}%`] : []),
|
||||||
|
o.turnSeconds === null ? '시간 무제한' : `${o.turnSeconds}초`,
|
||||||
|
].join(' · ');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- rules
|
||||||
|
|
||||||
|
function MiniBoard() {
|
||||||
|
return (
|
||||||
|
<svg viewBox="0 0 100 100" style={{ width: '100%', maxWidth: 240, display: 'block', margin: '0 auto', background: 'var(--wood)', borderRadius: 12 }} role="img" aria-label="윷판 그림: 모서리와 방에 멈추면 안쪽 지름길로 꺾여요">
|
||||||
|
{LINES.map(([a, b]) => (
|
||||||
|
<line key={`${a}-${b}`} x1={POS[a]![0]} y1={POS[a]![1]} x2={POS[b]![0]} y2={POS[b]![1]} stroke="var(--wood-line)" strokeWidth={0.9} opacity={0.7} />
|
||||||
|
))}
|
||||||
|
<polyline points={[5, 20, 21, 22, 27, 28, 0].map((n) => POS[n]!.join(',')).join(' ')} fill="none" stroke="var(--primary)" strokeWidth={2} strokeDasharray="3 2" />
|
||||||
|
{POS.map(([x, y], n) => (
|
||||||
|
<circle key={n} cx={x} cy={y} r={BIG.has(n) ? 6 : 4.2} fill="var(--surface)" stroke="var(--wood-line)" strokeWidth={0.7} />
|
||||||
|
))}
|
||||||
|
{[
|
||||||
|
[0, '출발'],
|
||||||
|
[5, '모'],
|
||||||
|
[10, '뒷모'],
|
||||||
|
[15, '찌모'],
|
||||||
|
[22, '방'],
|
||||||
|
].map(([n, t]) => (
|
||||||
|
<text key={n} x={POS[n as number]![0]} y={POS[n as number]![1]} textAnchor="middle" dominantBaseline="central" fontSize={3.4} fontWeight={800} fill="var(--text)">
|
||||||
|
{t}
|
||||||
|
</text>
|
||||||
|
))}
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function StickRow({ flats, label }: { flats: boolean[]; label: string }) {
|
||||||
|
return (
|
||||||
|
<figure style={{ margin: 0, textAlign: 'center' }}>
|
||||||
|
<svg viewBox="0 0 120 80" style={{ width: 84, height: 56 }} aria-hidden>
|
||||||
|
{flats.map((f, i) => (
|
||||||
|
<Stick key={i} x={8 + i * 27} flat={f} marked={i === 0} delay={0} />
|
||||||
|
))}
|
||||||
|
</svg>
|
||||||
|
<figcaption className="small">
|
||||||
|
<strong>{label}</strong>
|
||||||
|
</figcaption>
|
||||||
|
</figure>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Rules({ options }: { options?: YutOptions }) {
|
||||||
|
const o = options;
|
||||||
|
return (
|
||||||
|
<div className="stack">
|
||||||
|
<p>
|
||||||
|
윷가락 네 개를 던져 나온 만큼 말을 움직여, <strong>내 말을 모두 먼저 판 밖으로 내보내면(날)</strong> 이겨요. 말은 오른쪽 아래 <strong>참먹이</strong>에서 출발해 시계 반대 방향으로 돌아요.
|
||||||
|
{o?.mode === 'team' ? ' 팀전은 청팀·홍팀이 번갈아 던지고, 팀 안에서는 자리 순서대로 돌아가요.' : ''}
|
||||||
|
</p>
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(96px, 1fr))', gap: 8 }}>
|
||||||
|
<StickRow flats={[false, true, false, false]} label="도 · 1칸" />
|
||||||
|
<StickRow flats={[true, true, false, false]} label="개 · 2칸" />
|
||||||
|
<StickRow flats={[true, true, true, false]} label="걸 · 3칸" />
|
||||||
|
<StickRow flats={[true, true, true, true]} label="윷 · 4칸 + 한 번 더" />
|
||||||
|
<StickRow flats={[false, false, false, false]} label="모 · 5칸 + 한 번 더" />
|
||||||
|
{o?.backdo !== false && <StickRow flats={[true, false, false, false]} label="빽도 · 뒤로 1칸" />}
|
||||||
|
</div>
|
||||||
|
<p className="small muted">밝은 면이 배(평평한 면), 어두운 면이 등이에요. 배가 위인 가락 수로 결과가 정해져요. '빽' 표시가 있는 가락 하나만 배가 위면 빽도예요.</p>
|
||||||
|
<p>
|
||||||
|
<strong>윷·모</strong>가 나오면 한 번 더 던져요. 모아 둔 결과는 원하는 순서로, 원하는 말에 하나씩 써요. 결과 칩을 누르고 → 반짝이는 말을 누르면 갈 자리가 보이고 → 한 번 더 누르면 움직여요.
|
||||||
|
</p>
|
||||||
|
<MiniBoard />
|
||||||
|
<p>
|
||||||
|
<strong>지름길</strong>: 모서리(모·뒷모)나 가운데 방에 <strong>멈췄다가</strong> 다음에 출발하면 안쪽 대각선으로 꺾어요. 지나가기만 하면 그대로 직진해요. 방에서 출발하면 언제나 참먹이 쪽으로 가요.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<strong>업기</strong>: 내 말이 있는 자리에 멈추면 함께 업혀서 같이 움직여요. <strong>잡기</strong>: 상대 말이 있는 자리에 멈추면 그 말들을 처음으로 돌려보내고 한 번 더 던져요
|
||||||
|
{o && !o.backdoCaptureBonus ? '(빽도로 잡을 때는 빼고)' : ''}. 지나가기만 해서는 잡지 않아요.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<strong>날</strong>: {o?.finishRule === 'reach' ? '한 바퀴 돈 말이 참먹이에 도착하면 바로 나요.' : '한 바퀴 돈 말이 참먹이를 지나가야 나요. 참먹이에 딱 멈추면 아직 판 위라서 잡힐 수 있어요.'}
|
||||||
|
</p>
|
||||||
|
{o?.backdo !== false && (
|
||||||
|
<p>
|
||||||
|
<strong>빽도</strong>: 말이 왔던 길로 한 칸 돌아가요. 판 위에 말이 없으면 빽도는 버려져요.{' '}
|
||||||
|
{o?.backdoFromDo === 'finish' ? '도 자리에 있는 말이 빽도를 받으면 바로 나요.' : '도 자리에 있는 말이 빽도를 받으면 참먹이로 가고, 다음 전진에서 바로 나요.'}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{!!o?.nakPercent && <p>낙: {o.nakPercent}% 확률로 윷가락이 판 밖으로 떨어져 그 던지기는 무효가 돼요(그 전에 모아 둔 결과는 남아요).</p>}
|
||||||
|
<p className="muted small">시간 안에 하지 않으면 자동으로 던지거나, 잡기 → 내기 → 업기 → 뒤처진 말 순서로 골라 움직여요. 세 번 연속 시간을 넘기면 '자리 비움'이 되고 제한 시간이 5초로 줄어요.</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const ui: GameUI<YutView, YutOptions> = {
|
||||||
|
Board: YutBoard,
|
||||||
|
Side: YutSide,
|
||||||
|
statusText,
|
||||||
|
isMyTurn: (v, me) => v.phase !== 'finished' && !!me && v.current === me,
|
||||||
|
Options,
|
||||||
|
optionsSummary,
|
||||||
|
Rules,
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ui;
|
||||||
@@ -11,4 +11,4 @@ export const GAME_ICON: Record<string, string> = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
/** Shown on the home screen so users know what's coming (docs/12-roadmap.md). */
|
/** Shown on the home screen so users know what's coming (docs/12-roadmap.md). */
|
||||||
export const UPCOMING = ['윷놀이', '원카드', '숫자 타일', '다빈치 코드', '요트 다이스', '마작', '고스톱', '뱅', '할리갈리'];
|
export const UPCOMING = ['원카드', '숫자 타일', '다빈치 코드', '요트 다이스', '마작', '고스톱', '뱅', '할리갈리'];
|
||||||
|
|||||||
@@ -25,6 +25,8 @@ interface Plan {
|
|||||||
moves?: string[][];
|
moves?: string[][];
|
||||||
/** Card games: custom driver over all pages (host first). */
|
/** Card games: custom driver over all pages (host first). */
|
||||||
drive?: (pages: Page[]) => Promise<void>;
|
drive?: (pages: Page[]) => Promise<void>;
|
||||||
|
/** Host changes game options in the lobby before starting. */
|
||||||
|
lobby?: (host: Page) => Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Card games: whoever has the turn presses the first available safe action (check/call/…) until `rounds` actions were taken. */
|
/** Card games: whoever has the turn presses the first available safe action (check/call/…) until `rounds` actions were taken. */
|
||||||
@@ -53,7 +55,73 @@ async function finishHand(pages: Page[]) {
|
|||||||
for (const p of pages) await p.getByText('이번 판 결과').first().waitFor({ timeout: 10_000 });
|
for (const p of pages) await p.getByText('이번 판 결과').first().waitFor({ timeout: 10_000 });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 윷놀이: one UI step for whoever can act on `p` — press the throw button, or pick a result chip →
|
||||||
|
* a glowing piece (or the waiting-piece tray) → "이동하기" (the two-step move confirmation).
|
||||||
|
*/
|
||||||
|
async function yutStep(p: Page): Promise<boolean> {
|
||||||
|
const throwBtn = p.getByRole('button', { name: /윷 던지기|한 번 더 던지기/ });
|
||||||
|
if ((await throwBtn.count()) && (await throwBtn.isEnabled().catch(() => false))) {
|
||||||
|
return throwBtn.click({ timeout: 1500 }).then(() => true, () => false);
|
||||||
|
}
|
||||||
|
const chip = p.locator('.yut-chip:not([disabled])').first();
|
||||||
|
if (!(await chip.count())) return false;
|
||||||
|
try {
|
||||||
|
await chip.click({ timeout: 1500 });
|
||||||
|
const src = p.locator('g.yut-hit[tabindex="0"], .yut-tray-btn.can').first();
|
||||||
|
await src.waitFor({ timeout: 2000 });
|
||||||
|
// Keyboard: Enter on a piece shows where it lands, Enter again on the same piece moves it.
|
||||||
|
const h = await src.elementHandle({ timeout: 1000 });
|
||||||
|
await h!.focus();
|
||||||
|
await p.keyboard.press('Enter');
|
||||||
|
await p.getByRole('button', { name: /^이동하기/ }).waitFor({ timeout: 1500 });
|
||||||
|
await h!.focus();
|
||||||
|
await p.keyboard.press('Enter');
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const PLANS: Record<string, Plan> = {
|
const PLANS: Record<string, Plan> = {
|
||||||
|
yut: {
|
||||||
|
// 말 2개로 줄여서 PC 방장 + 휴대폰 친구가 실제 화면 조작으로 끝까지 둔다(중간 화면도 저장).
|
||||||
|
lobby: async (host) => {
|
||||||
|
await host.getByRole('button', { name: '2개', exact: true }).click();
|
||||||
|
await host.getByRole('button', { name: '설정 저장' }).click();
|
||||||
|
await host.getByText(/말 2개/).first().waitFor();
|
||||||
|
},
|
||||||
|
drive: async (pages) => {
|
||||||
|
const [host, guest] = pages as [Page, Page];
|
||||||
|
let actions = 0;
|
||||||
|
let shot = false;
|
||||||
|
const until = Date.now() + 300_000;
|
||||||
|
const over = () => host.getByText(/이겼어요|아쉽게 졌어요/).count();
|
||||||
|
while (Date.now() < until && !(await over())) {
|
||||||
|
let acted = false;
|
||||||
|
for (const p of pages) {
|
||||||
|
if (await yutStep(p)) {
|
||||||
|
acted = true;
|
||||||
|
actions++;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (actions >= 16 && !shot) {
|
||||||
|
shot = true;
|
||||||
|
await host.waitForTimeout(400);
|
||||||
|
await host.screenshot({ path: join(SHOTS, 'yut-mid-pc.png') });
|
||||||
|
await guest.screenshot({ path: join(SHOTS, 'yut-mid-phone.png'), fullPage: true });
|
||||||
|
await guest.emulateMedia({ colorScheme: 'dark' });
|
||||||
|
await guest.screenshot({ path: join(SHOTS, 'yut-mid-phone-dark.png') });
|
||||||
|
await guest.emulateMedia({ colorScheme: 'light' });
|
||||||
|
}
|
||||||
|
if (!acted) await host.waitForTimeout(120);
|
||||||
|
}
|
||||||
|
if (!(await over())) throw new Error(`game did not finish (${actions} UI actions)`);
|
||||||
|
await guest.getByText(/이겼어요|아쉽게 졌어요/).waitFor();
|
||||||
|
console.log(` yut: finished after ${actions} UI actions`);
|
||||||
|
},
|
||||||
|
},
|
||||||
seotda: {
|
seotda: {
|
||||||
// 3 players, default 2장 섯다 (1장씩 두 번): everyone checks both betting rounds → showdown.
|
// 3 players, default 2장 섯다 (1장씩 두 번): everyone checks both betting rounds → showdown.
|
||||||
players: 3,
|
players: 3,
|
||||||
@@ -161,6 +229,7 @@ for (const id of games) {
|
|||||||
await g.getByRole('button', { name: '이 이름으로 입장하기' }).click();
|
await g.getByRole('button', { name: '이 이름으로 입장하기' }).click();
|
||||||
await host.getByLabel('자리').getByText(nick, { exact: true }).waitFor();
|
await host.getByLabel('자리').getByText(nick, { exact: true }).waitFor();
|
||||||
}
|
}
|
||||||
|
if (plan.lobby) await plan.lobby(host);
|
||||||
await host.screenshot({ path: join(SHOTS, `${id}-0-lobby.png`) });
|
await host.screenshot({ path: join(SHOTS, `${id}-0-lobby.png`) });
|
||||||
await host.getByRole('button', { name: '▶ 게임 시작' }).click();
|
await host.getByRole('button', { name: '▶ 게임 시작' }).click();
|
||||||
await host.getByRole('button', { name: '시작하기' }).click();
|
await host.getByRole('button', { name: '시작하기' }).click();
|
||||||
|
|||||||
@@ -47,6 +47,8 @@ export interface GameDefinition<S, A, V, O> {
|
|||||||
optionsSchema: ZodType<O>;
|
optionsSchema: ZodType<O>;
|
||||||
actionSchema: ZodType<A>;
|
actionSchema: ZodType<A>;
|
||||||
playersFor?(options: O): { min: number; max: number };
|
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;
|
setup(ctx: SetupContext<O>): S;
|
||||||
validate(state: S, actor: PlayerId, action: A): Validation;
|
validate(state: S, actor: PlayerId, action: A): Validation;
|
||||||
apply(state: S, actor: PlayerId, action: A, ctx: ApplyContext): { state: S; events: GameEvent[] };
|
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 { baduk } from './baduk';
|
||||||
import { seotda } from './seotda';
|
import { seotda } from './seotda';
|
||||||
import { poker } from './poker';
|
import { poker } from './poker';
|
||||||
|
import { yut } from './yut';
|
||||||
|
|
||||||
export const GAMES: Record<string, AnyGameDefinition> = {
|
export const GAMES: Record<string, AnyGameDefinition> = {
|
||||||
[omok.id]: omok,
|
[omok.id]: omok,
|
||||||
@@ -15,4 +16,5 @@ export const GAMES: Record<string, AnyGameDefinition> = {
|
|||||||
[baduk.id]: baduk,
|
[baduk.id]: baduk,
|
||||||
[seotda.id]: seotda,
|
[seotda.id]: seotda,
|
||||||
[poker.id]: poker,
|
[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: '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: '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: '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);
|
export const catalogById = (id: string): CatalogEntry | undefined => CATALOG.find((g) => g.id === id);
|
||||||
|
|||||||
Reference in New Issue
Block a user