Compare commits

...

10 Commits

Author SHA1 Message Date
EJClaw
6321ea8c0d M4: 가족 게임 5종 합치기, E2E 계획 정리, 섯다 E2E 재경기 대응, 테스트 제한 60초
- 윷놀이·원카드·숫자 타일·다빈치 코드·요트 다이스 브랜치 병합(등록 목록·E2E 계획 충돌 정리)
- 섯다 E2E: 동점·구사 재경기가 나오면 결과가 나올 때까지 계속 체크
- bun run test: 무작위 1,000판 테스트가 부하 때 5초 기본 제한을 넘던 문제 → --timeout 60000
- 로드맵 M4 결과, README 확인 명령 갱신

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 04:48:19 +09:00
EJClaw
dfddeec8af Merge branch 'm4/davinci-code' into codex/owner/grp_discord_joke-app
# Conflicts:
#	apps/web/src/games/registry.ts
#	apps/web/src/lib/catalog-ui.ts
#	e2e/games.e2e.ts
#	packages/games/src/index.ts
#	packages/shared/src/catalog.ts
2026-10-05 04:40:49 +09:00
EJClaw
dc4e11b64c Merge branch 'm4/rummikub' into codex/owner/grp_discord_joke-app
# Conflicts:
#	apps/web/src/games/registry.ts
#	apps/web/src/lib/catalog-ui.ts
#	e2e/games.e2e.ts
#	packages/games/src/index.ts
#	packages/shared/src/catalog.ts
2026-10-05 04:40:49 +09:00
EJClaw
26b1b0c64c Merge branch 'm4/one-card' into codex/owner/grp_discord_joke-app
# Conflicts:
#	apps/web/src/games/registry.ts
#	apps/web/src/lib/catalog-ui.ts
#	e2e/games.e2e.ts
#	packages/games/src/index.ts
#	packages/shared/src/catalog.ts
2026-10-05 04:40:49 +09:00
EJClaw
54e3c74854 Merge branch 'm4/yut' into codex/owner/grp_discord_joke-app
# Conflicts:
#	apps/web/src/games/registry.ts
#	apps/web/src/lib/catalog-ui.ts
#	packages/games/src/index.ts
#	packages/shared/src/catalog.ts
2026-10-05 04:40:49 +09:00
EJClaw
4c5c3e5707 M4: 원카드 규칙·화면·테스트
- 규칙 모듈 packages/games/src/one-card: 원카드(트럼프 54장, 6명부터 2벌)와 색깔 카드(108장) 두 모드.
  공격·받아치기·3 방어·7 무늬 지정·J/Q/K·조커 기준 카드·여러 장 내기·마지막 카드 제한, 원카드 선언/잡기,
  파산, 드로우 더미 재구성, +4 의심하기(의심한 사람에게만 손패 공개), +2/+4 받아치기, 목표 점수 여러 판,
  시간 초과(자동 먹기·넘기기·받아들이기, 3번 연속이면 5초), 나가기. 모든 옵션에 한국어 메타.
- 테스트: 문서 10절 시나리오 45개 + 모드별 무작위 대국 1,000판(카드 수 보존, 종료, 결정성, 유출, onTimeout 합법)
  + 엔진 randomPlayout(매 단계 유출·직렬화 검사) 18판.
- 화면 apps/web/src/games/one-card: 상대 좌석, 판(더미·바닥·방향·지정 무늬/색·공격 누적), 손패(정렬·힌트·
  두 번 눌러 내기·끌어 놓기·방향키), 원카드 버튼, 무늬/색 고르기, 의심하기, 기록·특수 카드 표.
  색깔 카드는 직접 그린 SVG(색맹용 무늬 패턴과 색 이름 포함).
- 등록: GAMES, 카탈로그, 웹 레지스트리, 아이콘(곧 나와요 목록에서 제외), E2E 계획(PC 1 + 휴대폰 2).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 04:40:21 +09:00
EJClaw
96e406b2c9 M4: 숫자 타일 규칙·화면·테스트
- 규칙 모듈(packages/games/src/rummikub): 106타일, 선 정하기·분배, 그룹/런 판정과 조커 해석,
  서버 초안(setDraft/undoDraft/resetDraft) + 보존 검사(C1~C3), 확정 검증(받침대 1장 이상,
  첫 등록 점수, 재배치), 시간 초과(그대로면 1장, 올바르면 확정/되돌림, 틀리면 벌칙 장수),
  풀 소진 교착 종료, 점수 계산, 여러 판 매치, 나가기·자리 비움, 숨김 정보 view, secretsOf
- 테스트: 문서 10절 시나리오 전부 + 엔진 randomPlayout 25회 + 시드 1,000개 무작위 대국
  (타일 106장 보존, 매 단계 정보 유출·onTimeout 합법성 검사, 결정성)
- 화면(apps/web/src/games/rummikub): 탭으로 고르고 놓을 곳 누르기(◀ 앞 / ▶ 뒤 / 새 묶음),
  받침대 색깔순·숫자순 정렬, 되돌리기·처음으로, 실시간 묶음 검사(초록/빨강 + 이유),
  첫 등록 진행 막대, 도와줘 힌트, 한 장 가져오기·확정, 색+모양 표시 타일(조커 자체 디자인)
- 등록: GAMES, CATALOG, 화면 레지스트리, 아이콘(곧 나올 게임 목록에서 제거)
- E2E: PC 방장 + 휴대폰 친구가 화면으로 10차례(힌트로 내기, 되돌리기, 가져오기) 진행
- @bg/games 패키지에 './rummikub/rules' 내보내기 추가(화면 청크에 zod가 안 들어가게)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 04:34:59 +09:00
EJClaw
2610e37d49 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>
2026-10-05 04:29:46 +09:00
EJClaw
bd081936e0 M4: 다빈치 코드 규칙·화면·테스트
- 규칙 모듈(packages/games/src/davinci-code): 2~4명, 시작 색 고르기(동시 선택·15초),
  정렬 규칙(같은 숫자 검정/흰색 먼저 옵션), 조커 위치 직접 고르기, 맞힘 뒤 계속/멈춤,
  틀리면 가져온 타일 공개, 더미가 빈 뒤 자기 타일 공개 벌칙(옵션), 탈락 순서로 순위.
  숨은 타일은 view에 색·위치·틀린 추리만 보내고 타일 ID(숫자 포함)는 아는 사람에게만 보냄.
  시간 초과는 시스템 전용 timeout 행동으로 처리하고, 자동 추리는 그 사람의 view만으로
  후보를 계산해 결정적으로 고름. 3턴 연속 시간 초과면 마감 5초. 나가면 탈락·타일 공개.
- 테스트: 문서 10절 시나리오 전부, 시간 초과·연결 끊김·기권, 300판 이벤트/뷰 유출 검사,
  시드 1,000판 무작위 대국(타일 보존, 줄 정렬, 결정성, 내장 유출 검사, onTimeout 합법).
- 화면(apps/web/src/games/davinci-code): 상대 줄·더미·내 줄, 숫자 패드(0~11, 조커),
  한 번 더/멈추기, 조커 자리 고르기, 자기 타일 공개, 시작 색 고르기, 추리 기록, 규칙 그림.
- 등록: GAMES, 카탈로그(available), 웹 레지스트리, 아이콘, '곧 나와요'에서 제외.
- E2E: games.e2e.ts에 davinci-code 계획(PC 방장 + 폰 친구, 5턴 실제 진행).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 04:25:50 +09:00
EJClaw
41ab2cf187 M4: 요트 다이스 규칙·화면·테스트
- 규칙 모듈(packages/games/src/yacht): 12칸(기본)·13칸 변형, 풀 하우스 요트 인정,
  추가 요트 보너스와 강제/자유 조커, 상단 보너스, 굴림 3회·고정 검증, 기권,
  시간 초과 시 (안 굴렸으면 굴린 뒤) 최고 점수 칸 자동 기록·희생 순서, 1~8명(혼자 가능)
- 점수 계산은 scoreFor 순수 함수로 분리, view에 빈 칸 미리보기·허용 칸 포함
- 테스트: 문서 10절 시나리오 전부, 시드 1,000개 무작위 대국(점수·합계 일관성,
  onTimeout 합법, 결정성), 기권·시간 초과 섞은 200개 대국
- 화면: SVG 주사위(눌러서 고정, 굴림 애니메이션·움직임 줄이기 존중), 남은 굴림 표시,
  전원 점수표(내 열 강조, 미리보기, 0점 확인), 상단 보너스 진행 막대, 기권
- 등록: GAMES, 카탈로그, 웹 레지스트리, 아이콘(준비 중 목록에서 제거)
- 결과 화면: 1인 게임은 무승부/승리로 표시하지 않음
- E2E: PC 방장 + 휴대폰 친구가 12라운드 전체를 화면으로 진행

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 04:17:15 +09:00
35 changed files with 13671 additions and 7 deletions

View File

@@ -14,11 +14,11 @@ bun run --filter '@bg/web' dev # 웹 :5173 (API/WS는 :3000으로 프록
## 확인 ## 확인
```bash ```bash
bun run typecheck bun run typecheck
bun test # 규칙·서버 통합·방 단위 테스트 bun run test # 규칙·서버 통합·방 단위 테스트 (무작위 대국이 길어 제한 60초)
(cd apps/web && bunx vite build) # 웹 빌드 (cd apps/web && bunx vite build) # 웹 빌드
bun e2e/omok.e2e.ts # 브라우저 두 개로 오목 한 판 (Chrome 필요) bun e2e/omok.e2e.ts # 브라우저 두 개로 오목 한 판 (Chrome 필요)
bun e2e/admin.e2e.ts # 관리자: 슈퍼어드민 로그인 → 규칙 변경·잠금 → 새 방 반영 bun e2e/admin.e2e.ts # 관리자: 슈퍼어드민 로그인 → 규칙 변경·잠금 → 새 방 반영
bun e2e/games.e2e.ts # 오셀로·체스·바둑·장기: 방 만들기 → 친구 입장 → 첫 수 bun e2e/games.e2e.ts [게임id...] # 게임별: 방 만들기 → 친구 입장 → 실제 화면 조작으로 여러 턴(일부는 끝까지)
bun e2e/a11y.e2e.ts # 접근성(axe) 검사 bun e2e/a11y.e2e.ts # 접근성(axe) 검사
bun scripts/loadtest.ts --help # 부하·장애 주입 (docs/reports 참고) bun scripts/loadtest.ts --help # 부하·장애 주입 (docs/reports 참고)
``` ```

View File

@@ -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);
});
});

View File

@@ -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 };

View File

@@ -0,0 +1,956 @@
/** 다빈치 코드 화면 (docs/games/davinci-code.md §9). */
import { useEffect, useRef, useState } from 'react';
import type { DavinciGuess, DavinciOptions, DavinciTileView, DavinciView, PhaseKind, TColor, TileValue } from '@bg/games/davinci-code';
import type { PublicUser } from '@bg/shared/lite';
import { Avatar } from '../../components/ui';
import { TurnTimer } from '../../components/TurnTimer';
import { haptic, sounds } from '../../lib/feedback';
import type { BoardProps, GameUI, OptionsProps } from '../types';
const COLOR_KO: Record<TColor, string> = { black: '검은색', white: '흰색' };
const COLOR_SHORT: Record<TColor, string> = { black: '검정', white: '흰색' };
const NUMBERS = Array.from({ length: 12 }, (_, i) => i);
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 valueKo = (v: TileValue) => (v === 'joker' ? '조커' : String(v));
const faceOf = (v: TileValue) => (v === 'joker' ? '−' : String(v));
const hiddenLeft = (row: DavinciTileView[]) => row.filter((t) => !t.revealed).length;
function totalMsOf(v: DavinciView): number {
if (v.phase === 'chooseStart') return 15_000;
if ((v.idle[v.current] ?? 0) >= 3) return 5_000;
return v.phase === 'guess' ? v.options.turnSeconds * 1000 : 10_000;
}
/** Seats starting with the one after `me` (clockwise); spectators see everyone. */
function opponentsOf(v: DavinciView, me: string | null): string[] {
const i = me ? v.seats.indexOf(me) : -1;
if (i < 0) return v.seats;
return v.seats.map((_, k) => v.seats[(i + 1 + k) % v.seats.length]!).filter((p) => p !== me);
}
/** Colour+number combinations the viewer can already see (for the "impossible guess" warning). */
function visibleCombos(v: DavinciView): Set<string> {
const out = new Set<string>();
for (const row of Object.values(v.rows)) for (const t of row) if (t.n !== undefined) out.add(`${t.color}:${t.n}`);
if (v.pending?.n !== undefined) out.add(`${v.pending.color}:${v.pending.n}`);
return out;
}
function guessLine(g: DavinciGuess, players: Record<string, PublicUser>): string {
return `${nick(players, g.by)} → ${nick(players, g.target)} ${g.index + 1}번째 ${COLOR_SHORT[g.color]} = ${valueKo(g.said)} (${g.correct ? '맞힘' : '틀림'}${g.auto ? ', 시간 초과' : ''})`;
}
const CSS = `
.dv-board { width: 100%; max-width: 980px; max-height: 100%; overflow: auto; display: flex; flex-direction: column; gap: 12px; padding: 2px; }
.dv-opps { display: grid; gap: 10px; }
.dv-player { background: var(--surface); border-radius: var(--radius-lg); padding: 10px 12px; box-shadow: var(--shadow); border: 3px solid transparent; }
.dv-player.turn { border-color: var(--focus); }
.dv-player.out { opacity: .7; }
.dv-head { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; min-height: 32px; }
.dv-name { font-weight: 800; max-width: 12em; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.dv-badge { display: inline-block; border-radius: 999px; padding: 1px 9px; font-weight: 800; font-size: .8rem; background: var(--surface-2); color: var(--text); }
.dv-badge.turn { background: var(--focus); color: #1c1e21; }
.dv-badge.out { background: var(--danger-soft); color: var(--danger); }
.dv-badge.ok { background: var(--success-soft); color: var(--success); }
.dv-row { display: flex; flex-wrap: wrap; gap: 6px; align-items: flex-end; padding-top: 8px; }
.dv-slot { display: flex; flex-direction: column; align-items: center; gap: 2px; }
.dv-pos { font-size: .75rem; color: var(--text-sub); font-variant-numeric: tabular-nums; line-height: 1; }
.dv-tile { --w: 46px; width: var(--w); height: calc(var(--w) * 1.42); border-radius: 8px; display: flex; align-items: center; justify-content: center;
font-weight: 900; font-size: calc(var(--w) * .56); line-height: 1; position: relative; user-select: none; font-variant-numeric: tabular-nums;
box-shadow: 0 3px 0 rgba(0,0,0,.35); transition: transform .15s ease; }
.dv-tile.black { background: linear-gradient(160deg, #42454d, #141519); color: #fff; border: 2px solid color-mix(in srgb, var(--text) 30%, #050505); }
.dv-tile.white { background: linear-gradient(160deg, #ffffff, #e2ded3); color: #15161a; border: 2px solid #9d978a; }
.dv-tile.back.black { background: repeating-linear-gradient(45deg, #2a2c33 0 5px, #1b1c21 5px 10px); }
.dv-tile.back.white { background: repeating-linear-gradient(45deg, #fbf9f4 0 5px, #e6e1d6 5px 10px); }
.dv-tile .q { opacity: .45; font-size: .62em; }
.dv-tile.open::after { content: ''; position: absolute; left: 18%; right: 18%; bottom: 4px; height: 4px; border-radius: 2px; background: #f59e0b; }
.dv-tile.secret { outline: 3px dashed var(--primary); outline-offset: 2px; }
.dv-tile.sm { --w: 30px; }
.dv-tile.lg { --w: 54px; }
.dv-tile.hit { animation: dv-flip .5s ease-out, dv-hit 1.2s ease-out; }
.dv-tile.miss { animation: dv-flip .5s ease-out, dv-miss 1.2s ease-out; }
.dv-tile.new { animation: dv-drop .35s ease-out; }
@keyframes dv-flip { 0% { transform: rotateY(90deg); } 100% { transform: rotateY(0); } }
@keyframes dv-hit { 0%, 60% { box-shadow: 0 0 0 4px #22c55e, 0 0 18px 6px rgba(34,197,94,.7); } 100% { box-shadow: 0 3px 0 rgba(0,0,0,.35); } }
@keyframes dv-miss { 0%, 60% { box-shadow: 0 0 0 4px #ef4444, 0 0 18px 6px rgba(239,68,68,.6); } 100% { box-shadow: 0 3px 0 rgba(0,0,0,.35); } }
@keyframes dv-drop { 0% { transform: translateY(-14px); opacity: .3; } 100% { transform: translateY(0); opacity: 1; } }
.dv-pick { background: none; border: 0; padding: 3px; border-radius: 11px; cursor: pointer; color: inherit; }
.dv-pick:disabled { cursor: default; }
.dv-pick.can { box-shadow: 0 0 0 3px var(--primary); }
.dv-pick.can:hover .dv-tile { transform: translateY(-3px); }
.dv-pick.sel { box-shadow: 0 0 0 4px var(--focus); }
.dv-pick.sel .dv-tile { transform: translateY(-6px); }
.dv-pick:focus-visible { outline: 3px solid var(--focus); outline-offset: 2px; }
.dv-gap { align-self: stretch; min-width: 28px; min-height: 48px; border: 2px dashed var(--primary); background: var(--primary-soft); color: var(--text);
border-radius: 8px; font-weight: 800; cursor: pointer; padding: 0 2px; font-size: .8rem; }
.dv-gap:focus-visible { outline: 3px solid var(--focus); }
.dv-mid { display: grid; grid-template-columns: minmax(0, 260px) minmax(0, 1fr); gap: 12px; align-items: start; }
.dv-center { background: var(--felt); color: #fff; border-radius: var(--radius-lg); padding: 12px; display: flex; flex-direction: column; gap: 10px; align-items: center; }
.dv-piles { display: flex; gap: 12px; justify-content: center; }
.dv-pile { display: flex; flex-direction: column; align-items: center; gap: 4px; background: rgba(0,0,0,.18); border: 2px solid transparent; border-radius: 12px;
padding: 8px 10px; color: #fff; font-weight: 800; min-width: 92px; min-height: 48px; cursor: pointer; font-size: .9rem; }
.dv-pile:disabled { cursor: default; opacity: .8; }
.dv-pile.can { border-color: #fde047; background: rgba(253,224,71,.15); }
.dv-pile:focus-visible { outline: 3px solid var(--focus); outline-offset: 2px; }
.dv-stack { position: relative; width: 44px; height: 50px; }
.dv-stack .dv-tile { position: absolute; left: 0; }
.dv-pending { display: flex; align-items: center; gap: 10px; font-size: .9rem; }
.dv-panel { background: var(--surface); border-radius: var(--radius-lg); box-shadow: var(--shadow); padding: 12px; display: flex; flex-direction: column; gap: 10px; }
.dv-pad { display: grid; grid-template-columns: repeat(4, 1fr); gap: 8px; }
.dv-pad button { min-height: 56px; font-size: 1.35rem; font-weight: 900; border-radius: 12px; border: 2px solid var(--border); background: var(--surface); color: var(--text); cursor: pointer; font-variant-numeric: tabular-nums; }
.dv-pad button.unlikely { opacity: .4; }
.dv-pad button[aria-pressed='true'] { background: var(--primary); color: var(--primary-text); border-color: var(--primary); opacity: 1; }
.dv-pad button:focus-visible { outline: 3px solid var(--focus); outline-offset: 2px; }
.dv-pad .joker { grid-column: 1 / -1; font-size: 1.1rem; }
.dv-big { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; }
.dv-big .btn { min-height: 64px; font-size: 1.15rem; }
.dv-split { display: grid; grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); gap: 8px; }
.dv-split button { min-height: 56px; border-radius: 12px; border: 2px solid var(--border); background: var(--surface); color: var(--text); font-weight: 700; cursor: pointer;
display: flex; align-items: center; justify-content: center; gap: 6px; padding: 4px 8px; }
.dv-split button[aria-pressed='true'] { border-color: var(--primary); background: var(--primary-soft); }
.dv-split button:disabled { opacity: .4; cursor: not-allowed; }
.dv-split button:focus-visible { outline: 3px solid var(--focus); outline-offset: 2px; }
.dv-mini { display: inline-block; width: 12px; height: 17px; border-radius: 3px; vertical-align: middle; }
.dv-mini.black { background: #1b1c21; border: 1px solid #000; }
.dv-mini.white { background: #fff; border: 1px solid #8f8a7e; }
.dv-me { border-color: var(--primary); }
.dv-legend { font-size: .85rem; color: var(--text-sub); display: flex; gap: 6px 16px; flex-wrap: wrap; align-items: center; }
.dv-legend > span { display: inline-flex; align-items: center; gap: 7px; }
.dv-me .dv-slot { gap: 6px; }
.dv-log { list-style: none; margin: 0; padding: 0; display: grid; gap: 3px; max-height: 220px; overflow: auto; font-size: .9rem; }
.dv-log li.hit::before { content: '✓ '; color: var(--success); font-weight: 900; }
.dv-log li.miss::before { content: '✗ '; color: var(--danger); font-weight: 900; }
@media (max-width: 719px) {
.dv-tile { --w: 34px; border-radius: 6px; }
.dv-tile.lg { --w: 37px; }
.dv-row { gap: 4px; }
.dv-mid { grid-template-columns: minmax(0, 1fr); }
.dv-board { max-height: none; overflow: visible; }
}
@media (prefers-reduced-motion: reduce) {
.dv-tile.hit, .dv-tile.miss, .dv-tile.new { animation: none; }
}
`;
// ---------------------------------------------------------------- tiles
type Fx = 'hit' | 'miss' | 'new';
function Tile({ color, n, revealed, mine, size, fx }: { color: TColor; n?: TileValue; revealed: boolean; mine?: boolean; size?: 'sm' | 'lg'; fx?: Fx }) {
const known = n !== undefined;
const cls = ['dv-tile', color, known ? '' : 'back', revealed ? 'open' : '', mine && !revealed ? 'secret' : '', size ?? '', fx ?? ''].filter(Boolean).join(' ');
return (
<span className={cls} aria-hidden>
{known ? faceOf(n) : <span className="q">?</span>}
</span>
);
}
function tileLabel(t: DavinciTileView, pos: number, mine: boolean): string {
const what = t.n === undefined ? '숨김' : t.n === 'joker' ? '조커' : `${t.n}`;
return `${pos + 1}번째, ${COLOR_KO[t.color]} ${what}${t.revealed ? ', 공개됨' : mine ? ', 나만 보임' : ''}${t.wrong.length ? `, 틀린 추리 ${t.wrong.map(valueKo).join('·')}` : ''}`;
}
// ---------------------------------------------------------------- board
interface Sel {
target: string;
index: number;
}
function DavinciBoard({ view: v, me, players, act, pending, deadline }: BoardProps<DavinciView>) {
const amPlayer = !!me && v.seats.includes(me);
const myTurn = amPlayer && v.current === me && !!v.alive[me!] && v.phase !== 'chooseStart' && v.phase !== 'finished';
const [sel, setSel] = useState<Sel | null>(null);
const [val, setVal] = useState<TileValue | null>(null);
const [fx, setFx] = useState<Map<string, Fx>>(new Map());
const [announce, setAnnounce] = useState('');
const prevHist = useRef(v.history.length);
const prevPlaced = useRef(JSON.stringify(v.lastPlaced));
const prevTurn = useRef(`${v.current}|${v.phase === 'chooseStart'}`);
const resetKey = `${v.phase}|${v.current}|${v.history.length}`;
useEffect(() => {
setSel(null);
setVal(null);
}, [resetKey]);
// Effects + screen-reader text for new guesses and newly placed tiles.
useEffect(() => {
const placedNow = JSON.stringify(v.lastPlaced);
const next = new Map<string, Fx>();
if (v.history.length > prevHist.current) {
const g = v.history[v.history.length - 1]!;
if (g.correct) next.set(`${g.target}:${g.index}`, 'hit');
else if (v.lastPlaced && v.lastPlaced.player === g.by && v.lastPlaced.revealed) next.set(`${g.by}:${v.lastPlaced.index}`, 'miss');
setAnnounce(
`${nick(players, g.by)}님이 ${nick(players, g.target)}님 ${g.index + 1}번째 타일을 ${valueKo(g.said)}(으)로 추리해서 ${g.correct ? '맞혔어요' : '틀렸어요'}.`,
);
if (g.correct && g.by === me) haptic(30);
} else if (placedNow !== prevPlaced.current && v.lastPlaced) {
next.set(`${v.lastPlaced.player}:${v.lastPlaced.index}`, v.lastPlaced.revealed ? 'miss' : 'new');
}
prevHist.current = v.history.length;
prevPlaced.current = placedNow;
if (next.size) {
setFx(next);
const t = setTimeout(() => setFx(new Map()), 1300);
return () => clearTimeout(t);
}
return undefined;
}, [v.history.length, JSON.stringify(v.lastPlaced)]); // eslint-disable-line react-hooks/exhaustive-deps
useEffect(() => {
const key = `${v.current}|${v.phase === 'chooseStart'}`;
if (key !== prevTurn.current && v.phase !== 'chooseStart' && v.phase !== 'finished') {
if (v.current === me) {
haptic(40);
sounds.myTurn();
setAnnounce('내 차례예요.');
} else setAnnounce(`${nick(players, v.current)}님 차례예요.`);
}
prevTurn.current = key;
}, [v.current, v.phase]); // eslint-disable-line react-hooks/exhaustive-deps
const canGuess = myTurn && v.phase === 'guess' && !pending;
const pick = (target: string, index: number) => {
if (sel && sel.target === target && sel.index === index) {
setSel(null);
setVal(null);
} else {
setSel({ target, index });
setVal(null);
}
};
return (
<div className="dv-board" role="region" aria-label="다빈치 코드 판">
<style>{CSS}</style>
<div className="dv-opps">
{opponentsOf(v, me).map((p) => (
<PlayerRow key={p} id={p} view={v} me={me} players={players} deadline={deadline} fx={fx} canPick={canGuess && !!v.alive[p]} sel={sel} onPick={pick} />
))}
</div>
<div className="dv-mid">
<Center view={v} me={me} players={players} act={act} pending={pending} myTurn={myTurn} />
<ActionPanel view={v} me={me} players={players} act={act} pending={pending} myTurn={myTurn} sel={sel} val={val} setVal={setVal} onCancel={() => (setSel(null), setVal(null))} />
</div>
{amPlayer && <MyRow view={v} me={me!} players={players} act={act} pending={pending} myTurn={myTurn} deadline={deadline} fx={fx} />}
<p className="sr-only" aria-live="polite">
{announce}
</p>
</div>
);
}
function PlayerHead({ id, view: v, me, players, deadline }: { id: string; view: DavinciView; me: string | null; players: Record<string, PublicUser>; deadline: number | null }) {
const u = userOf(players, id);
const row = v.rows[id] ?? [];
const st = v.stats[id] ?? { guesses: 0, correct: 0 };
const outRank = v.eliminationOrder.indexOf(id);
const isTurn = v.phase !== 'finished' && v.phase !== 'chooseStart' && v.current === id;
const choosing = v.phase === 'chooseStart' && v.alive[id] && v.picked && !v.picked[id];
return (
<div className="dv-head">
<Avatar user={u} size={26} />
<span className="dv-name">
{u.nickname}
{id === me && <span className="muted"> (나)</span>}
</span>
{isTurn && <span className="dv-badge turn">차례</span>}
{v.final?.winner === id && <span className="dv-badge ok">승리</span>}
{!v.alive[id] && <span className="dv-badge out">{v.left[id] ? '나감' : '탈락'}{outRank >= 0 ? ` · ${v.seats.length - outRank}위` : ''}</span>}
{v.alive[id] && (v.idle[id] ?? 0) >= 3 && <span className="dv-badge">자리 비움</span>}
{v.phase === 'chooseStart' && v.alive[id] && <span className={`dv-badge${choosing ? '' : ' ok'}`}>{choosing ? '고르는 중' : '다 골랐어요'}</span>}
<span className="small muted" style={{ marginLeft: 'auto' }}>
{v.phase !== 'chooseStart' && `숨은 타일 ${hiddenLeft(row)}개 · `}맞힘 {st.correct}/{st.guesses}
</span>
{(isTurn || choosing) && <TurnTimer deadline={deadline} totalMs={totalMsOf(v)} mine={id === me} size={30} />}
</div>
);
}
function PlayerRow({
id,
view: v,
me,
players,
deadline,
fx,
canPick,
sel,
onPick,
}: {
id: string;
view: DavinciView;
me: string | null;
players: Record<string, PublicUser>;
deadline: number | null;
fx: Map<string, Fx>;
canPick: boolean;
sel: Sel | null;
onPick: (target: string, index: number) => void;
}) {
const row = v.rows[id] ?? [];
const isTurn = v.phase !== 'finished' && v.phase !== 'chooseStart' && v.current === id;
const name = nick(players, id);
return (
<section className={`dv-player${isTurn ? ' turn' : ''}${v.alive[id] || v.phase === 'finished' ? '' : ' out'}`} aria-label={`${name}님 타일`}>
<PlayerHead id={id} view={v} me={me} players={players} deadline={deadline} />
<div className="dv-row" role="group" aria-label={`${name}님 타일 줄`}>
{row.length === 0 && <span className="small muted">{v.phase === 'chooseStart' ? '시작 타일을 기다리는 중이에요' : '타일이 없어요'}</span>}
{row.map((t, i) => {
const can = canPick && !t.revealed;
const selected = sel?.target === id && sel.index === i;
return (
<div className="dv-slot" key={i}>
<button
type="button"
className={`dv-pick${can ? ' can' : ''}${selected ? ' sel' : ''}`}
disabled={!can}
aria-pressed={can ? selected : undefined}
aria-label={`${name}님 ${tileLabel(t, i, false)}${can ? ', 눌러서 추리' : ''}`}
onClick={() => onPick(id, i)}
>
<Tile color={t.color} n={t.n} revealed={t.revealed} fx={fx.get(`${id}:${i}`)} />
</button>
<span className="dv-pos" aria-hidden>
{i + 1}
</span>
</div>
);
})}
</div>
</section>
);
}
function MyRow({
view: v,
me,
players,
act,
pending,
myTurn,
deadline,
fx,
}: {
view: DavinciView;
me: string;
players: Record<string, PublicUser>;
act: (a: unknown) => void;
pending: boolean;
myTurn: boolean;
deadline: number | null;
fx: Map<string, Fx>;
}) {
const row = v.rows[me] ?? [];
const placing = myTurn && v.phase === 'placeJoker' && !pending;
const revealing = myTurn && v.phase === 'revealOwn' && !pending;
const isTurn = v.phase !== 'finished' && v.phase !== 'chooseStart' && v.current === me;
const gap = (index: number) =>
placing && (
<button key={`gap${index}`} type="button" className="dv-gap" aria-label={`${index + 1}번째 자리에 조커 넣기`} onClick={() => act({ type: 'placeJoker', index })}>
여기
</button>
);
return (
<section className={`dv-player dv-me${isTurn ? ' turn' : ''}${v.alive[me] || v.phase === 'finished' ? '' : ' out'}`} aria-label="내 타일">
<PlayerHead id={me} view={v} me={me} players={players} deadline={deadline} />
<div className="dv-row" role="group" aria-label="내 타일 줄">
{row.length === 0 && <span className="small muted">{v.phase === 'chooseStart' ? '시작 타일 색을 고르면 여기에 놓여요' : '타일이 없어요'}</span>}
{row.map((t, i) => {
const can = revealing && !t.revealed;
return [
gap(i),
<div className="dv-slot" key={i}>
{can ? (
<button type="button" className="dv-pick can" aria-label={`내 ${tileLabel(t, i, true)}, 눌러서 공개`} onClick={() => act({ type: 'revealOwn', index: i })}>
<Tile color={t.color} n={t.n} revealed={t.revealed} mine size="lg" fx={fx.get(`${me}:${i}`)} />
</button>
) : (
<span className="dv-pick" role="img" aria-label={`내 ${tileLabel(t, i, true)}`}>
<Tile color={t.color} n={t.n} revealed={t.revealed} mine size="lg" fx={fx.get(`${me}:${i}`)} />
</span>
)}
<span className="dv-pos" aria-hidden>
{i + 1}
</span>
</div>,
];
})}
{gap(row.length)}
</div>
{row.length > 0 && (
<div className="dv-legend" style={{ marginTop: 6 }}>
<span>
<span className="dv-mini white" style={{ outline: '2px dashed var(--primary)', outlineOffset: 1 }} /> 점선: 상대에게 숨김
</span>
<span>
<span className="dv-mini white" style={{ boxShadow: 'inset 0 -4px 0 #f59e0b' }} /> 아래 노란 줄: 모두에게 공개됨
</span>
</div>
)}
</section>
);
}
function Center({ view: v, me, players, act, pending, myTurn }: { view: DavinciView; me: string | null; players: Record<string, PublicUser>; act: (a: unknown) => void; pending: boolean; myTurn: boolean }) {
const drawing = myTurn && v.phase === 'draw' && !pending;
const total = v.pool.black + v.pool.white;
const last = v.history[v.history.length - 1];
return (
<div className="dv-center" role="group" aria-label="더미">
{v.options.chooseColors ? (
<div className="dv-piles">
{(['black', 'white'] as const).map((c) => {
const can = drawing && v.pool[c] > 0;
return (
<button
key={c}
type="button"
className={`dv-pile${can ? ' can' : ''}`}
disabled={!can}
aria-label={`${COLOR_KO[c]} 타일 가져오기, ${v.pool[c]}개 남음`}
onClick={() => act({ type: 'draw', color: c })}
>
<span className="dv-stack" aria-hidden>
{v.pool[c] > 0 ? (
[0, 1, 2].slice(0, Math.min(3, v.pool[c])).map((k) => (
<span key={k} style={{ position: 'absolute', left: 7 + k * 3, top: 6 - k * 3 }}>
<Tile color={c} revealed={false} size="sm" />
</span>
))
) : (
<span style={{ position: 'absolute', inset: 0, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>없음</span>
)}
</span>
<span>
{COLOR_SHORT[c]} {v.pool[c]}개
</span>
</button>
);
})}
</div>
) : (
<button type="button" className={`dv-pile${drawing ? ' can' : ''}`} disabled={!drawing} aria-label={`타일 가져오기, ${total}개 남음`} onClick={() => act({ type: 'draw' })}>
<span>타일 가져오기</span>
<span className="small">
남은 타일 {total}개 (검정 {v.pool.black} · 흰색 {v.pool.white})
</span>
</button>
)}
{v.pending ? (
<div className="dv-pending">
<Tile color={v.pending.color} n={v.pending.n} revealed={false} mine={v.current === me} />
<span>
{v.current === me ? '내가 가져온 타일' : `${nick(players, v.current)}님이 가져온 타일`}
<br />
<span className="small" style={{ opacity: 0.85 }}>
{v.current === me ? '나만 보여요' : '색만 보여요'}
</span>
</span>
</div>
) : (
v.phase !== 'chooseStart' &&
v.phase !== 'finished' &&
total === 0 && <span className="small">더미가 비었어요. 가져오지 않고 바로 추리해요.</span>
)}
{last && (
<span className="small" style={{ textAlign: 'center' }} aria-hidden>
최근: {guessLine(last, players)}
</span>
)}
</div>
);
}
function ActionPanel({
view: v,
me,
players,
act,
pending,
myTurn,
sel,
val,
setVal,
onCancel,
}: {
view: DavinciView;
me: string | null;
players: Record<string, PublicUser>;
act: (a: unknown) => void;
pending: boolean;
myTurn: boolean;
sel: Sel | null;
val: TileValue | null;
setVal: (v: TileValue | null) => void;
onCancel: () => void;
}) {
if (v.phase === 'finished') {
return (
<div className="dv-panel" aria-live="polite">
<strong>게임이 끝났어요</strong>
<span>{v.final?.summary}</span>
<span className="small muted">모든 타일을 공개했어요.</span>
</div>
);
}
if (v.phase === 'chooseStart') return <StartChooser view={v} me={me} act={act} pending={pending} />;
if (!myTurn) {
const who = nick(players, v.current);
const doing: Record<PhaseKind, string> = {
chooseStart: '',
draw: '타일을 가져오는 중이에요',
guess: '추리하는 중이에요',
afterCorrect: '맞혔어요! 계속할지 고르는 중이에요',
placeJoker: '조커를 넣을 자리를 고르는 중이에요',
revealOwn: '틀려서 자기 타일을 공개하는 중이에요',
finished: '',
};
return (
<div className="dv-panel">
<strong>
{who}님이 {doing[v.phase]}
</strong>
{me && v.seats.includes(me) && !v.alive[me] && <span className="small muted">나는 탈락했어요. 끝까지 구경할 수 있어요.</span>}
<span className="small muted">공개된 숫자와 타일 위치, 추리 기록을 보고 상대의 숫자를 짐작해 보세요.</span>
</div>
);
}
switch (v.phase) {
case 'draw':
return (
<div className="dv-panel">
<strong>더미에서 타일을 하나 가져와 주세요.</strong>
<span className="small muted">
{v.options.chooseColors ? '검정과 흰색 중 원하는 색 더미를 누르세요. ' : ''}가져온 타일의 숫자는 나만 봐요.
</span>
</div>
);
case 'guess':
return sel ? <NumberPad view={v} players={players} act={act} pending={pending} sel={sel} val={val} setVal={setVal} onCancel={onCancel} /> : (
<div className="dv-panel">
<strong>맞힐 상대 타일을 눌러 주세요.</strong>
<span className="small muted">파란 테두리가 있는 숨은 타일만 고를 수 있어요. 틀리면 {v.pending ? '가져온 타일이 공개돼요' : v.options.emptyPoolPenalty === 'reveal-own' ? '내 타일 하나를 공개해야 해요' : '차례가 넘어가요'}.</span>
</div>
);
case 'afterCorrect':
return (
<div className="dv-panel" aria-live="polite">
<strong style={{ color: 'var(--success)', fontSize: '1.15rem' }}>맞혔어요!</strong>
<div className="dv-big">
<button type="button" className="btn btn-primary" disabled={pending} onClick={() => act({ type: 'continue' })}>
한 번 더 맞히기
</button>
<button type="button" className="btn btn-secondary" disabled={pending} onClick={() => act({ type: 'stop' })}>
여기서 멈추기
</button>
</div>
<span className="small muted">
{v.pending
? '멈추면 가져온 타일이 숨겨진 채로 들어가요. 계속하다 틀리면 가져온 타일이 공개돼요.'
: '멈추면 차례가 끝나요. 계속하다 틀리면 벌칙이 있을 수 있어요.'}
</span>
</div>
);
case 'placeJoker':
return (
<div className="dv-panel">
<strong>조커를 넣을 자리를 골라 주세요.</strong>
<span className="small muted">내 줄의 "여기" 버튼 중 하나를 누르세요. 조커는 어느 틈에나 넣을 수 있고, 한 번 놓으면 옮길 수 없어요.</span>
</div>
);
case 'revealOwn':
return (
<div className="dv-panel">
<strong>틀렸어요. 공개할 내 타일을 하나 골라 주세요.</strong>
<span className="small muted">더미가 비어서 가져온 타일 대신 내 숨은 타일 하나를 공개해요. 내 줄에서 파란 테두리 타일을 누르세요.</span>
</div>
);
default:
return null;
}
}
function NumberPad({
view: v,
players,
act,
pending,
sel,
val,
setVal,
onCancel,
}: {
view: DavinciView;
players: Record<string, PublicUser>;
act: (a: unknown) => void;
pending: boolean;
sel: Sel;
val: TileValue | null;
setVal: (v: TileValue | null) => void;
onCancel: () => void;
}) {
const tile = v.rows[sel.target]?.[sel.index];
const padRef = useRef<HTMLDivElement>(null);
useEffect(() => {
padRef.current?.querySelector<HTMLButtonElement>('button')?.focus({ preventScroll: true });
}, [sel.target, sel.index]);
if (!tile) return null;
const seen = visibleCombos(v);
const cands = tile.candidates ? new Set(tile.candidates.map(String)) : null;
const values: TileValue[] = [...NUMBERS, ...(v.options.jokers ? ['joker' as const] : [])];
const impossible = (x: TileValue) => seen.has(`${tile.color}:${x}`) || tile.wrong.includes(x) || (!!cands && !cands.has(String(x)));
const warn = val === null ? null : seen.has(`${tile.color}:${val}`) ? `${COLOR_SHORT[tile.color]} ${valueKo(val)}은(는) 이미 보이는 곳에 있어요.` : tile.wrong.includes(val) ? '이 타일에 이미 틀린 숫자예요.' : cands && !cands.has(String(val)) ? '공개된 정보로 보면 어려운 숫자예요.' : null;
return (
<div
className="dv-panel"
role="group"
aria-label="숫자 고르기"
onKeyDown={(e) => {
if (e.key === 'Escape') onCancel();
}}
>
<strong>
{nick(players, sel.target)}님 {sel.index + 1}번째 타일({COLOR_SHORT[tile.color]})은 몇일까요?
</strong>
{tile.wrong.length > 0 && <span className="small muted">이미 틀린 추리: {tile.wrong.map(valueKo).join(', ')}</span>}
<div className="dv-pad" ref={padRef}>
{values.map((x) => (
<button
key={String(x)}
type="button"
className={`${x === 'joker' ? 'joker' : ''}${(v.options.deductionHelper || seen.has(`${tile.color}:${x}`)) && impossible(x) ? ' unlikely' : ''}`}
aria-pressed={val === x}
aria-label={x === 'joker' ? '조커' : String(x)}
onClick={() => setVal(x)}
>
{x === 'joker' ? '조커 (−)' : x}
</button>
))}
</div>
<span className="small" style={{ minHeight: '1.4em', color: 'var(--warning)' }}>
{warn ? `${warn} 그래도 말할 수 있어요.` : ''}
</span>
<div className="row" style={{ flexWrap: 'nowrap' }}>
<button type="button" className="btn btn-secondary" style={{ flex: 1 }} onClick={onCancel}>
취소
</button>
<button
type="button"
className="btn btn-primary"
style={{ flex: 2 }}
disabled={val === null || pending}
onClick={() => act({ type: 'guess', target: sel.target, index: sel.index, value: val })}
>
{val === null ? '숫자를 고르세요' : `${valueKo(val)}, 이 숫자!`}
</button>
</div>
</div>
);
}
function StartChooser({ view: v, me, act, pending }: { view: DavinciView; me: string | null; act: (a: unknown) => void; pending: boolean }) {
const n = v.startCount;
const lim = v.pickLimits ?? { black: n, white: n };
const fallbackBlack = Math.min(Math.ceil(n / 2), lim.black);
const [black, setBlack] = useState<number>(fallbackBlack);
const done = v.picked ? Object.values(v.picked).filter(Boolean).length : 0;
const total = v.picked ? v.seats.filter((p) => v.alive[p]).length : 0;
if (!me || !v.seats.includes(me) || !v.alive[me]) {
return (
<div className="dv-panel">
<strong>모두 시작 타일 색을 고르는 중이에요 ({done}/{total})</strong>
</div>
);
}
if (v.myPick) {
return (
<div className="dv-panel">
<strong>
검정 {v.myPick.black}개 · 흰색 {v.myPick.white}개를 골랐어요.
</strong>
<span className="small muted">
다른 사람을 기다리는 중이에요 ({done}/{total}). 다 고르면 숫자가 정해져 내 줄에 놓여요.
</span>
</div>
);
}
const okPick = (b: number) => b <= lim.black && n - b <= lim.white;
const chosen = okPick(black) ? black : null;
return (
<div className="dv-panel">
<strong>시작 타일 {n}개의 색을 골라 주세요.</strong>
<span className="small muted">숫자는 고른 색 안에서 무작위로 정해져요. 15초 안에 고르지 않으면 반반으로 정해져요.</span>
<div className="dv-split" role="group" aria-label="시작 타일 색">
{Array.from({ length: n + 1 }, (_, i) => n - i).map((b) => (
<button key={b} type="button" aria-pressed={chosen === b} disabled={!okPick(b)} onClick={() => setBlack(b)} aria-label={`검정 ${b}개 · 흰색 ${n - b}개`}>
<span aria-hidden>
{Array.from({ length: n }, (_, k) => (
<span key={k} className={`dv-mini ${k < b ? 'black' : 'white'}`} style={{ marginRight: 2 }} />
))}
</span>
<span>
검 {b} · 흰 {n - b}
</span>
</button>
))}
</div>
{(lim.black < n || lim.white < n) && (
<span className="small muted">
먼저 고른 사람 때문에 검정은 {lim.black}개, 흰색은 {lim.white}개까지 고를 수 있어요.
</span>
)}
<button type="button" className="btn btn-primary" disabled={chosen === null || pending} onClick={() => act({ type: 'chooseStart', black: chosen, white: n - chosen! })}>
이렇게 받기
</button>
</div>
);
}
// ---------------------------------------------------------------- side
function DavinciSide({ view: v, me, players, deadline }: BoardProps<DavinciView>) {
const running = v.phase !== 'finished';
const ranking = v.final?.ranking ?? null;
return (
<>
<style>{CSS}</style>
<div className="card stack" style={{ gap: 8, padding: 12 }}>
<div className="row" style={{ justifyContent: 'space-between' }}>
<strong>{running ? (v.phase === 'chooseStart' ? '시작 타일 고르기' : `${nick(players, v.current)}님 차례`) : '게임 끝'}</strong>
{running && <TurnTimer deadline={deadline} totalMs={totalMsOf(v)} mine={isMyTurn(v, me)} size={36} />}
</div>
<span className="small muted">{optionsSummary(v.options)}</span>
<ol style={{ listStyle: 'none', margin: 0, padding: 0, display: 'grid', gap: 4 }} aria-label="참가자">
{v.seats.map((p) => {
const st = v.stats[p] ?? { guesses: 0, correct: 0 };
const out = v.eliminationOrder.indexOf(p);
return (
<li key={p} className="row" style={{ gap: 6, flexWrap: 'nowrap', opacity: v.alive[p] || !running ? 1 : 0.6 }}>
<Avatar user={userOf(players, p)} size={24} />
<span style={{ flex: 1, minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', fontWeight: p === me ? 800 : 600 }}>
{nick(players, p)}
{running && v.phase !== 'chooseStart' && v.current === p ? ' · 차례' : ''}
{v.final?.winner === p ? ' · 1위' : out >= 0 ? ` · ${v.left[p] ? '나감' : '탈락'} ${v.seats.length - out}위` : ''}
</span>
<span className="small muted" style={{ whiteSpace: 'nowrap' }}>
숨김 {hiddenLeft(v.rows[p] ?? [])} · 맞힘 {st.correct}/{st.guesses}
</span>
</li>
);
})}
</ol>
</div>
{ranking && (
<div className="card stack" style={{ gap: 4, padding: 12 }}>
<strong>최종 순위</strong>
{ranking.map((g, i) => (
<span key={i}>
{i + 1}위 {g.map((id) => `${nick(players, id)} (맞힘 ${v.stats[id]?.correct ?? 0} / 추리 ${v.stats[id]?.guesses ?? 0})`).join(', ')}
</span>
))}
</div>
)}
<details className="card" open style={{ padding: 12 }}>
<summary style={{ cursor: 'pointer', fontWeight: 700 }}>추리 기록 ({v.history.length})</summary>
{v.history.length === 0 ? (
<p className="small muted" style={{ margin: '8px 0 0' }}>
아직 추리가 없어요.
</p>
) : (
<ol className="dv-log" style={{ marginTop: 8 }} tabIndex={0} aria-label="추리 기록 목록">
{[...v.history].reverse().map((g, i) => (
<li key={v.history.length - i} className={g.correct ? 'hit' : 'miss'}>
{guessLine(g, players)}
</li>
))}
</ol>
)}
</details>
<details className="card" style={{ padding: 12 }}>
<summary style={{ cursor: 'pointer', fontWeight: 700 }}>정렬 규칙</summary>
<SortPicture tieOrder={v.options.tieOrder} />
</details>
</>
);
}
function SortPicture({ tieOrder }: { tieOrder: DavinciOptions['tieOrder'] }) {
const tiles: [TColor, number][] = tieOrder === 'black-left' ? [['black', 2], ['black', 5], ['white', 5], ['white', 9]] : [['black', 2], ['white', 5], ['black', 5], ['white', 9]];
return (
<div className="stack" style={{ gap: 6, marginTop: 8 }}>
<div className="row" style={{ gap: 6 }} role="img" aria-label={`정렬 예: ${tiles.map(([c, n]) => `${COLOR_SHORT[c]} ${n}`).join(', ')}`}>
{tiles.map(([c, n], i) => (
<Tile key={i} color={c} n={n} revealed={false} />
))}
</div>
<span className="small muted">작은 수가 왼쪽이에요. 같은 숫자면 {tieOrder === 'black-left' ? '검정' : '흰색'}이 왼쪽이에요. 새 타일은 서버가 알맞은 자리에 끼워 줘요.</span>
</div>
);
}
// ---------------------------------------------------------------- status
function isMyTurn(v: DavinciView, me: string | null): boolean {
if (!me || !v.seats.includes(me) || v.phase === 'finished' || !v.alive[me]) return false;
if (v.phase === 'chooseStart') return !!v.picked && !v.picked[me];
return v.current === me;
}
function statusText(v: DavinciView, me: string | null, players: Record<string, PublicUser>): string {
if (v.phase === 'finished') return v.final?.summary ?? '게임이 끝났어요';
if (v.phase === 'chooseStart') return isMyTurn(v, me) ? '내 차례예요. 시작 타일 색을 골라 주세요' : '다른 사람이 시작 타일 색을 고르는 중이에요';
if (isMyTurn(v, me)) {
switch (v.phase) {
case 'draw':
return '내 차례예요. 더미에서 타일을 가져와 주세요';
case 'guess':
return '내 차례예요. 상대 타일을 골라 숫자를 맞혀 보세요';
case 'afterCorrect':
return '내 차례예요. 맞혔어요! 한 번 더 할지 멈출지 골라 주세요';
case 'placeJoker':
return '내 차례예요. 조커를 넣을 자리를 골라 주세요';
case 'revealOwn':
return '내 차례예요. 공개할 내 타일을 골라 주세요';
}
}
const out = !!me && v.seats.includes(me) && !v.alive[me] ? '탈락했어요 · ' : '';
return `${out}${nick(players, v.current)}님 차례예요`;
}
// ---------------------------------------------------------------- options
function Seg<T extends string | number | boolean>({ value, options, onChange, label }: { value: T; options: [T, string][]; onChange: (v: T) => void; label: string }) {
return (
<div className="segmented" role="group" aria-label={label}>
{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<DavinciOptions>) {
const [more, setMore] = useState(false);
const set = (patch: Partial<DavinciOptions>) => onChange({ ...o, ...patch });
return (
<div className="stack" style={{ gap: 16 }}>
<Field label="조커">
<Seg label="조커" value={o.jokers} options={[[false, '안 씀'], [true, '씀(검정·흰색 각 1개)']]} onChange={(jokers) => set({ jokers })} />
</Field>
<Field label="시작 타일 수">
<Seg label="시작 타일 수" value={o.startTiles} options={[['auto', '자동(2·3명 4개, 4명 3개)'], [3, '3개'], [4, '4개'], [5, '5개']]} onChange={(startTiles) => set({ startTiles })} />
</Field>
<Field label="추리 제한 시간">
<Seg label="추리 제한 시간" value={o.turnSeconds} options={[[20, '20초'], [30, '30초'], [45, '45초'], [60, '60초']]} onChange={(turnSeconds) => set({ turnSeconds })} />
</Field>
<Field label="가능한 숫자 후보 표시(초보 도움)">
<Seg label="후보 표시" value={o.deductionHelper} options={[[false, '끄기'], [true, '켜기']]} onChange={(deductionHelper) => set({ deductionHelper })} />
</Field>
<button type="button" className="btn btn-ghost" onClick={() => setMore(!more)} aria-expanded={more}>
{more ? '▲ 자세한 설정 닫기' : '▼ 자세한 설정'}
</button>
{more && (
<>
<Field label="같은 숫자일 때 색 순서">
<Seg label="같은 숫자일 때 색 순서" value={o.tieOrder} options={[['black-left', '검정이 왼쪽'], ['white-left', '흰색이 왼쪽']]} onChange={(tieOrder) => set({ tieOrder })} />
</Field>
<Field label="타일을 가져올 때">
<Seg label="타일을 가져올 때" value={o.chooseColors} options={[[true, '색을 골라서'], [false, '무작위로']]} onChange={(chooseColors) => set({ chooseColors })} />
</Field>
<Field label="더미가 빈 뒤 틀렸을 때">
<Seg label="더미가 빈 뒤 틀렸을 때" value={o.emptyPoolPenalty} options={[['reveal-own', '내 숨은 타일 1개 공개'], ['none', '벌칙 없음']]} onChange={(emptyPoolPenalty) => set({ emptyPoolPenalty })} />
</Field>
</>
)}
</div>
);
}
function optionsSummary(o: DavinciOptions): string {
return [
o.jokers ? '조커 사용' : '조커 없음',
o.startTiles === 'auto' ? '시작 타일 자동' : `시작 타일 ${o.startTiles}개`,
o.chooseColors ? '색 골라 가져오기' : '무작위로 가져오기',
`추리 ${o.turnSeconds}초`,
...(o.tieOrder === 'white-left' ? ['같은 숫자는 흰색 먼저'] : []),
...(o.deductionHelper ? ['후보 표시'] : []),
].join(' · ');
}
function Rules({ options }: { options?: DavinciOptions }) {
const o = options;
return (
<div className="stack">
<p>
검은색과 흰색 숫자 타일(0~11)을 작은 수부터 큰 수 순서로 세워 두고, <strong>상대 타일의 숫자를 추리해 맞히는</strong> 게임이에요. 2~4명이 할 수 있어요. 숨은 타일이 모두 드러나면 탈락하고, 마지막까지 숨은
타일이 남은 사람이 이겨요.
</p>
<p>
<strong>준비</strong>: 각자 시작 타일 {o && o.startTiles !== 'auto' ? `${o.startTiles}개` : '(2·3명은 4개, 4명은 3개)'}를 받아요.
{o?.chooseColors === false ? ' 색은 무작위로 정해져요.' : ' 검정과 흰색을 몇 개씩 받을지 직접 골라요.'} 타일 뒷면이 그 타일의 색이라서, 숨은 타일도 색은 모두에게 보이고 숫자만 숨겨져요.
</p>
<p>
<strong>정렬</strong>: 내 줄은 왼쪽에서 오른쪽으로 숫자가 커져요. 같은 숫자면 {o?.tieOrder === 'white-left' ? '흰색' : '검정'}이 왼쪽이에요. 새 타일은 알맞은 자리에 저절로 끼워지고, 이미 놓인 타일의 순서는 바뀌지
않아요.
</p>
<SortPicture tieOrder={o?.tieOrder ?? 'black-left'} />
<p>
<strong>내 차례</strong>: ① 더미에서 타일을 하나 가져와요(숫자는 나만 봐요). ② 상대의 숨은 타일 하나를 눌러 숫자를 말해요.
</p>
<ul>
<li>
<strong>맞히면</strong> 그 타일이 공개돼요. "한 번 더 맞히기"로 계속하거나 "여기서 멈추기"로 끝낼 수 있어요. 멈추면 가져온 타일이 숨겨진 채로 내 줄에 들어가요.
</li>
<li>
<strong>틀리면</strong> 가져온 타일이 공개된 채로 내 줄에 들어가고 차례가 끝나요.
</li>
</ul>
<p>
<strong>더미가 비면</strong> 가져오지 않고 바로 추리해요. 이때 틀리면{' '}
{o?.emptyPoolPenalty === 'none' ? '벌칙 없이 차례만 넘어가요.' : '내 숨은 타일 하나를 골라 공개해야 해요. 마지막 숨은 타일을 공개하면 탈락해요.'}
</p>
{o?.jokers && (
<p>
<strong>조커</strong>: 숫자 대신 "−"가 그려진 타일이에요(검정·흰색 각 1개). 시작 타일에는 들어가지 않아요. 조커를 가져오면 내 줄의 원하는 틈에 직접 넣고, 한 번 놓으면 옮길 수 없어요. 추리할 때
"조커"라고 말할 수도 있어요.
</p>
)}
<p>
이미 공개된 같은 색·같은 숫자를 말하는 것처럼 말이 안 되는 추리도 할 수는 있어요(화면에서 알려 줘요). 오른쪽 <strong>추리 기록</strong>에서 누가 어떤 숫자를 말했는지 다시 볼 수 있어요.
</p>
<p className="muted">
시간 안에 추리하지 않으면 공개된 정보로 가장 가능성 있는 작은 숫자를 자동으로 말해요. 세 번 연속 시간을 넘기면 제한 시간이 5초로 줄어요. 결과 화면에는 각자 맞힌 횟수와 추리 횟수가 나와요.
</p>
</div>
);
}
const ui: GameUI<DavinciView, DavinciOptions> = {
Board: DavinciBoard,
Side: DavinciSide,
statusText,
isMyTurn,
Options,
optionsSummary,
Rules,
};
export default ui;

View File

@@ -0,0 +1,153 @@
/**
* 색깔 카드 artwork (own design, docs/games/one-card.md §9, §12): solid colour field, a white diamond in the
* middle, a colour-blind pattern per colour (빨강 점 · 노랑 줄 · 초록 격자 · 파랑 물결) and the colour name.
*/
import { useId } from 'react';
import type { CardFace, Color } from '@bg/games/one-card';
export const COLOR_HEX: Record<Color, string> = { R: '#d32f2f', Y: '#f4b400', G: '#2e7d32', B: '#1565c0' };
export const COLOR_INK: Record<Color, string> = { R: '#ffffff', Y: '#1f2937', G: '#ffffff', B: '#ffffff' };
export const COLOR_KO: Record<Color, string> = { R: '빨강', Y: '노랑', G: '초록', B: '파랑' };
const PATTERN_KO: Record<Color, string> = { R: '점무늬', Y: '줄무늬', G: '격자무늬', B: '물결무늬' };
function Pattern({ color, id }: { color: Color; id: string }) {
const ink = color === 'Y' ? 'rgba(0,0,0,0.16)' : 'rgba(255,255,255,0.22)';
switch (color) {
case 'R':
return (
<pattern id={id} width="12" height="12" patternUnits="userSpaceOnUse">
<circle cx="6" cy="6" r="2.2" fill={ink} />
</pattern>
);
case 'Y':
return (
<pattern id={id} width="10" height="10" patternUnits="userSpaceOnUse" patternTransform="rotate(45)">
<rect width="4" height="10" fill={ink} />
</pattern>
);
case 'G':
return (
<pattern id={id} width="12" height="12" patternUnits="userSpaceOnUse">
<path d="M0 0H12M0 0V12" stroke={ink} strokeWidth="2.4" fill="none" />
</pattern>
);
case 'B':
return (
<pattern id={id} width="20" height="10" patternUnits="userSpaceOnUse">
<path d="M0 6 Q5 1 10 6 T20 6" stroke={ink} strokeWidth="2.2" fill="none" />
</pattern>
);
}
}
/** Four colour quadrants used by the wild cards. */
function Quadrants({ cx, cy, r }: { cx: number; cy: number; r: number }) {
const q = (dx: number, dy: number, c: Color) => (
<path d={`M${cx} ${cy} L${cx + dx * r} ${cy} A${r} ${r} 0 0 ${dx * dy > 0 ? 1 : 0} ${cx} ${cy + dy * r} Z`} fill={COLOR_HEX[c]} />
);
return (
<g>
{q(1, -1, 'R')}
{q(1, 1, 'B')}
{q(-1, 1, 'G')}
{q(-1, -1, 'Y')}
</g>
);
}
function SkipIcon({ cx, cy, r, color }: { cx: number; cy: number; r: number; color: string }) {
return (
<g stroke={color} strokeWidth={r * 0.28} fill="none" strokeLinecap="round">
<circle cx={cx} cy={cy} r={r} />
<path d={`M${cx - r * 0.7} ${cy + r * 0.7} L${cx + r * 0.7} ${cy - r * 0.7}`} />
</g>
);
}
function ReverseIcon({ cx, cy, s, color }: { cx: number; cy: number; s: number; color: string }) {
return (
<g stroke={color} strokeWidth={s * 0.22} fill="none" strokeLinecap="round" strokeLinejoin="round">
<path d={`M${cx - s} ${cy - s * 0.35} H${cx + s * 0.8} M${cx + s * 0.35} ${cy - s * 0.8} L${cx + s * 0.8} ${cy - s * 0.35} L${cx + s * 0.35} ${cy + s * 0.1}`} />
<path d={`M${cx + s} ${cy + s * 0.35} H${cx - s * 0.8} M${cx - s * 0.35} ${cy + s * 0.8} L${cx - s * 0.8} ${cy + s * 0.35} L${cx - s * 0.35} ${cy - s * 0.1}`} />
</g>
);
}
export function colorCardLabel(c: CardFace): string {
switch (c.kind) {
case 'num':
return `${COLOR_KO[c.color]} ${c.value}`;
case 'skip':
return `${COLOR_KO[c.color]} 건너뛰기`;
case 'reverse':
return `${COLOR_KO[c.color]} 방향 바꾸기`;
case 'draw2':
return `${COLOR_KO[c.color]} +2`;
case 'wild':
return '색 바꾸기';
case 'wild4':
return '색 바꾸기+4';
default:
return '';
}
}
/** Pure artwork (no interaction); size comes from the parent box (viewBox 100 × 140). */
export function ColorCardArt({ card }: { card: CardFace | null }) {
const pid = `occ${useId().replace(/[^a-zA-Z0-9_-]/g, '')}`;
if (!card) {
return (
<svg viewBox="0 0 100 140" width="100%" height="100%" aria-hidden>
<rect x="1.5" y="1.5" width="97" height="137" rx="10" fill="#1f2433" stroke="#f8fafc" strokeWidth="3" />
<rect x="9" y="9" width="82" height="122" rx="7" fill="none" stroke="#475569" strokeWidth="2" />
<Quadrants cx={50} cy={70} r={22} />
<circle cx="50" cy="70" r="9" fill="#1f2433" />
</svg>
);
}
const wild = card.kind === 'wild' || card.kind === 'wild4';
const color: Color | null = 'color' in card ? card.color : null;
const bg = color ? COLOR_HEX[color] : '#1f2433';
const ink = color ? COLOR_INK[color] : '#f8fafc';
const corner = card.kind === 'num' ? String(card.value) : card.kind === 'draw2' ? '+2' : card.kind === 'wild4' ? '+4' : card.kind === 'skip' ? '⊘' : card.kind === 'reverse' ? '⇄' : '★';
const centerInk = color ? COLOR_HEX[color] : '#1f2433';
return (
<svg viewBox="0 0 100 140" width="100%" height="100%" aria-hidden>
{color && (
<defs>
<Pattern color={color} id={pid} />
</defs>
)}
<rect x="1.5" y="1.5" width="97" height="137" rx="10" fill={bg} stroke="#f8fafc" strokeWidth="3" />
{color && <rect x="1.5" y="1.5" width="97" height="137" rx="10" fill={`url(#${pid})`} />}
{/* white diamond (not an oval) */}
<path d="M50 22 L86 70 L50 118 L14 70 Z" fill="#fffef9" stroke={ink} strokeOpacity="0.25" strokeWidth="1.5" />
{wild ? (
<>
<Quadrants cx={50} cy={70} r={card.kind === 'wild4' ? 19 : 24} />
{card.kind === 'wild4' && (
<text x="50" y="79" fontSize="26" fontWeight="900" textAnchor="middle" fill="#fffef9" stroke="#1f2433" strokeWidth="1.5" fontFamily="Pretendard Variable, sans-serif">
+4
</text>
)}
</>
) : card.kind === 'skip' ? (
<SkipIcon cx={50} cy={70} r={17} color={centerInk} />
) : card.kind === 'reverse' ? (
<ReverseIcon cx={50} cy={70} s={19} color={centerInk} />
) : (
<text x="50" y={card.kind === 'draw2' ? 82 : 86} fontSize={card.kind === 'draw2' ? 34 : 46} fontWeight="900" textAnchor="middle" fill={centerInk} fontFamily="Pretendard Variable, sans-serif">
{card.kind === 'draw2' ? '+2' : corner}
</text>
)}
<text x="9" y="25" fontSize="20" fontWeight="900" fill={ink} fontFamily="Pretendard Variable, sans-serif">
{corner}
</text>
<text x="50" y="134" fontSize="12" fontWeight="800" textAnchor="middle" fill={ink} fontFamily="Pretendard Variable, sans-serif">
{color ? COLOR_KO[color] : wild ? '색 바꾸기' : ''}
</text>
</svg>
);
}
export const patternName = (c: Color): string => PATTERN_KO[c];

File diff suppressed because it is too large Load Diff

View File

@@ -9,6 +9,11 @@ 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'),
yacht: () => import('./yacht'),
yut: () => import('./yut'),
'one-card': () => import('./one-card'),
rummikub: () => import('./rummikub'),
'davinci-code': () => import('./davinci-code'),
}; };
const cache = new Map<string, GameUI>(); const cache = new Map<string, GameUI>();

View File

@@ -0,0 +1,823 @@
/** 숫자 타일 화면 (docs/games/rummikub.md §9). Tap a tile to pick it up, then tap where it goes. */
import { useEffect, useMemo, useRef, useState } from 'react';
// Pure tile/set helpers only (no zod): keeps this lazy chunk small.
import { findMeld, normalizeSet, pickDisjoint, setProblem, sortTiles, tileLabel, tileOf, type DraftSet, type JokerAs, type TileColor } from '@bg/games/rummikub/rules';
import type { LogEntry, RummikubOptions, RummikubView } from '@bg/games/rummikub';
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';
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' };
/** Colour + shape mark so colours never have to be told apart by hue alone (§9). */
const MARK: Record<TileColor, string> = { K: '●', R: '♥', B: '◆', O: '★' };
const CSS = `
.rk-root { width: 100%; align-self: stretch; flex: 1; min-height: 0; display: flex; flex-direction: column; gap: 10px; }
.rk-players { display: flex; flex-wrap: wrap; gap: 6px; }
.rk-player { display: flex; align-items: center; gap: 6px; background: var(--surface); color: var(--text); border: 3px solid transparent;
border-radius: 12px; padding: 4px 10px 4px 6px; box-shadow: var(--shadow); font-size: .9rem; min-height: 44px; }
.rk-player.turn { border-color: var(--focus); }
.rk-player.gone { opacity: .55; }
.rk-player .rk-count { font-weight: 800; font-variant-numeric: tabular-nums; }
.rk-badge { display: inline-block; border-radius: 999px; padding: 0 8px; font-weight: 800; font-size: .75rem; line-height: 1.6; }
.rk-table { flex: 1; min-height: 200px; overflow: auto; background: var(--felt); border-radius: var(--radius-lg); padding: 10px;
display: flex; flex-wrap: wrap; align-content: flex-start; align-items: flex-start; gap: 10px 12px; box-shadow: inset 0 2px 10px rgba(0,0,0,.25); }
.rk-table-head { flex-basis: 100%; display: flex; justify-content: space-between; align-items: center; gap: 8px; color: #fff; font-weight: 700; font-size: .92rem; text-shadow: 0 1px 2px rgba(0,0,0,.4); }
.rk-empty { color: rgba(255,255,255,.85); font-size: .95rem; padding: 6px 2px; }
.rk-set { display: flex; flex-wrap: wrap; align-items: center; gap: 3px; padding: 4px; border-radius: 10px; border: 3px solid transparent; background: rgba(0,0,0,.18); max-width: 100%; }
.rk-set.ok { border-color: #86efac; }
.rk-set.bad { border-color: #fca5a5; border-style: dashed; background: rgba(127,29,29,.35); }
.rk-set.changed { animation: rk-flash 1s ease-out 2; }
@keyframes rk-flash { 0% { box-shadow: 0 0 0 5px #fde047; } 100% { box-shadow: 0 0 0 0 rgba(253,224,71,0); } }
.rk-problem { flex-basis: 100%; font-size: .8rem; font-weight: 700; color: #fff; background: #b91c1c; border-radius: 6px; padding: 1px 6px; }
.rk-setinfo { flex-basis: 100%; display: flex; gap: 6px; align-items: center; justify-content: space-between; font-size: .78rem; color: rgba(255,255,255,.9); }
.rk-setinfo button { background: rgba(255,255,255,.15); color: #fff; border: 1px solid rgba(255,255,255,.5); border-radius: 6px; min-height: 28px; padding: 0 6px; cursor: pointer; font-size: .78rem; }
.rk-tile { --w: 44px; --h: 60px; --tc: #111827; position: relative; width: var(--w); height: var(--h); flex: none; padding: 0; border-radius: 8px;
background: linear-gradient(#fffdf7, #f1e7cf); border: 1px solid #c9b98f; box-shadow: 0 2px 0 #b5a172, 0 3px 6px rgba(0,0,0,.25);
display: inline-flex; flex-direction: column; align-items: center; justify-content: center; color: var(--tc); font-weight: 900; line-height: 1;
font-family: inherit; transition: transform .2s ease, box-shadow .2s ease; user-select: none; }
button.rk-tile { cursor: pointer; }
button.rk-tile:disabled { cursor: default; }
.rk-tile[data-c=K] { --tc: #111827; }
.rk-tile[data-c=R] { --tc: #c62828; }
.rk-tile[data-c=B] { --tc: #1d4ed8; }
.rk-tile[data-c=O] { --tc: #a14a00; }
.rk-n { font-size: calc(var(--h) * .44); font-variant-numeric: tabular-nums; letter-spacing: -.02em; }
.rk-m { font-size: calc(var(--h) * .2); margin-top: 3px; }
.rk-jk { display: block; width: calc(var(--w) * .62); height: calc(var(--w) * .62); }
.rk-jkas { position: absolute; right: 2px; top: 1px; font-size: .62rem; font-weight: 800; color: #374151; }
.rk-tile[aria-pressed=true] { transform: translateY(-7px); box-shadow: 0 0 0 3px var(--focus), 0 6px 10px rgba(0,0,0,.3); }
.rk-tile.hint { box-shadow: 0 0 0 3px #a855f7, 0 3px 6px rgba(0,0,0,.25); }
.rk-tile.fresh::after { content: '새'; position: absolute; top: -7px; left: -5px; background: var(--primary); color: var(--primary-text); font-size: .62rem; font-weight: 800; border-radius: 999px; padding: 1px 4px; }
.rk-tile.sm { --w: 38px; --h: 52px; }
.rk-tile.xs { --w: 28px; --h: 38px; box-shadow: 0 1px 0 #b5a172; }
.rk-tile:focus-visible { outline: 3px solid var(--focus); outline-offset: 2px; }
.rk-slot { min-width: 30px; height: 52px; border: 2px dashed rgba(255,255,255,.85); border-radius: 8px; background: rgba(255,255,255,.14); color: #fff;
font-weight: 900; font-size: 1rem; cursor: pointer; padding: 0 4px; }
.rk-slot:hover, .rk-newset:hover { background: rgba(255,255,255,.28); }
.rk-newset { border: 2px dashed rgba(255,255,255,.9); color: #fff; background: rgba(255,255,255,.12); border-radius: 10px; min-height: 60px; padding: 0 16px; font-weight: 800; font-size: 1rem; cursor: pointer; }
.rk-meld { flex-basis: 100%; display: flex; align-items: center; gap: 8px; color: #fff; font-weight: 700; font-size: .9rem; }
.rk-meld progress { flex: 1; height: 12px; accent-color: #facc15; }
.rk-bar { display: grid; grid-template-columns: repeat(6, minmax(0, 1fr)); gap: 6px; }
.rk-bar .btn { min-height: 52px; padding: 0 6px; font-size: .98rem; }
.rk-msg { margin: 0; min-height: 1.4em; text-align: center; font-size: .92rem; }
.rk-rackwrap { display: flex; flex-direction: column; gap: 6px; }
.rk-rackhead { display: flex; align-items: center; justify-content: space-between; gap: 8px; flex-wrap: wrap; }
.rk-rackhead .segmented button { min-height: 40px; }
.rk-rack { background: var(--wood); border-radius: var(--radius-lg); padding: 10px 8px 14px; display: flex; flex-wrap: wrap; gap: 5px; min-height: 84px;
box-shadow: inset 0 -7px 0 var(--wood-line); align-content: flex-start; }
.rk-rack-empty { color: #3b2a0a; font-weight: 700; align-self: center; }
.rk-result { display: grid; gap: 6px; }
.rk-result-row { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
.rk-mini { display: inline-flex; gap: 2px; flex-wrap: wrap; }
@media (max-width: 1099px) {
.rk-table { overflow: visible; min-height: 160px; }
}
@media (max-width: 719px) {
.rk-bar { position: sticky; bottom: 0; z-index: 5; background: var(--bg); padding: 6px 0; grid-template-columns: repeat(3, minmax(0, 1fr)); }
.rk-bar .btn { min-height: 48px; font-size: .95rem; }
.rk-tile.sm { --w: 34px; --h: 47px; }
.rk-slot { height: 47px; min-width: 28px; }
.rk-table { padding: 8px; gap: 8px; }
.rk-player { font-size: .85rem; padding: 3px 8px 3px 4px; }
}
@media (prefers-reduced-motion: reduce) {
.rk-tile { transition: none; }
.rk-set.changed { animation: none; box-shadow: 0 0 0 3px #fde047; }
}
`;
// ---------------------------------------------------------------- tile
function JokerFace() {
// Four coloured quarters + a ring: not a face, not a copy of any commercial joker design.
return (
<svg className="rk-jk" viewBox="0 0 40 40" aria-hidden>
<path d="M20 20 L20 3 A17 17 0 0 1 37 20 Z" fill="#c62828" />
<path d="M20 20 L37 20 A17 17 0 0 1 20 37 Z" fill="#1d4ed8" />
<path d="M20 20 L20 37 A17 17 0 0 1 3 20 Z" fill="#a14a00" />
<path d="M20 20 L3 20 A17 17 0 0 1 20 3 Z" fill="#111827" />
<circle cx="20" cy="20" r="8" fill="#fffdf7" />
<text x="20" y="24" textAnchor="middle" fontSize="11" fontWeight="900" fill="#111827">J</text>
</svg>
);
}
interface TileProps {
id: string;
size?: 'md' | 'sm' | 'xs';
as?: JokerAs;
selected?: boolean;
hint?: boolean;
fresh?: boolean;
onClick?: () => void;
}
function Tile({ id, size = 'md', as, selected, hint, fresh, onClick }: TileProps) {
const t = tileOf(id);
const cls = `rk-tile${size !== 'md' ? ` ${size}` : ''}${hint ? ' hint' : ''}${fresh ? ' fresh' : ''}`;
const body =
t.kind === 'joker' ? (
<>
<JokerFace />
{as && <span className="rk-jkas">{as.n}</span>}
</>
) : (
<>
<span className="rk-n">{t.n}</span>
<span className="rk-m" aria-hidden>
{MARK[t.color]}
</span>
</>
);
const label = `${tileLabel(id, as)}${fresh ? ', 새로 가져온 타일' : ''}`;
const dataC = t.kind === 'num' ? t.color : undefined;
if (!onClick) {
return (
<span className={cls} data-c={dataC} role="img" aria-label={label}>
{body}
</span>
);
}
return (
<button type="button" className={cls} data-c={dataC} aria-pressed={!!selected} aria-label={label} onClick={onClick}>
{body}
</button>
);
}
// ---------------------------------------------------------------- draft editing
/** Keeps a set in a readable order: the server's interpretation when valid, otherwise as placed. */
function arrange(d: DraftSet): DraftSet {
const n = normalizeSet(d);
const tileIds = n ? n.tileIds : d.tileIds;
return d.as ? { tileIds, as: d.as } : { tileIds };
}
type Target = { set: number; at: 'start' | 'end' } | 'new' | 'rack';
/** Moves `sel` to the target; tiles that were on the table at turn start never go back to the rack. */
function moveTiles(sets: DraftSet[], sel: string[], target: Target, onTable0: Set<string>): DraftSet[] | null {
const moving = target === 'rack' ? sel.filter((id) => !onTable0.has(id)) : sel;
if (!moving.length) return null;
const ordered = sortTiles(moving, 'number');
const next: DraftSet[] = sets.map((d) => ({ ...d, tileIds: d.tileIds.filter((id) => !moving.includes(id)) }));
let touched: DraftSet | null = null;
if (target === 'new') {
touched = { tileIds: ordered };
next.push(touched);
} else if (target !== 'rack') {
touched = next[target.set]!;
touched.tileIds = target.at === 'start' ? [...ordered, ...touched.tileIds] : [...touched.tileIds, ...ordered];
}
return next.filter((d) => d.tileIds.length > 0).map((d) => (d === touched ? arrange(d) : d));
}
// ---------------------------------------------------------------- board
function RummikubBoard({ view: v, me, players, act, pending, deadline }: BoardProps<RummikubView>) {
const my = v.my;
const mine = v.phase === 'playing' && !!me && v.current === me;
const [sel, setSel] = useState<string[]>([]);
const [hintIds, setHintIds] = useState<string[]>([]);
const [local, setLocal] = useState<DraftSet[] | null>(null);
const [sortBy, setSortBy] = useState<'color' | 'number'>(() => (localStorage.getItem('rk-sort') === 'number' ? 'number' : 'color'));
const [msg, setMsg] = useState('');
const [confirm, setConfirm] = useState<null | 'draw' | 'commit'>(null);
const [announce, setAnnounce] = useState('');
const wasMine = useRef(false);
const lastLog = useRef<string>('');
const serverSets: DraftSet[] = useMemo(
() => (v.draft ? v.draft.map((d) => (d.as ? { tileIds: d.tileIds, as: d.as } : { tileIds: d.tileIds })) : v.table.map((t) => ({ tileIds: t.tileIds, as: t.kind }))),
[v.draft, v.table],
);
// Optimistic draft until the server answers (cleared when nothing is pending any more).
useEffect(() => {
if (!pending) setLocal(null);
}, [pending, v.turnNo, v.draftEdits]);
const sets = mine && local ? local : serverSets;
const onTable0 = useMemo(() => new Set(v.table.flatMap((t) => t.tileIds)), [v.table]);
const inSets = useMemo(() => new Set(sets.flatMap((d) => d.tileIds)), [sets]);
const rack = useMemo(() => sortTiles((my?.rack ?? []).filter((id) => !inSets.has(id)), sortBy), [my?.rack, inSets, sortBy]);
const fresh = new Set(my?.lastDrawn ?? []);
// Turn change: clear selection and hints, announce.
useEffect(() => {
setSel([]);
setHintIds([]);
setMsg('');
if (mine && !wasMine.current) {
haptic(40);
sounds.myTurn();
setAnnounce('내 차례예요.');
}
wasMine.current = mine;
}, [v.turnNo, mine]); // eslint-disable-line react-hooks/exhaustive-deps
useEffect(() => {
const e = v.log[v.log.length - 1];
if (!e) return;
const key = `${v.match.gameNo}-${e.turn}-${e.kind}`;
if (key === lastLog.current) return;
lastLog.current = key;
if (e.by !== me) setAnnounce(logText(e, players, me));
}, [v.log]); // eslint-disable-line react-hooks/exhaustive-deps
// Drop selected tiles that disappeared (e.g. after undo on the server).
useEffect(() => {
setSel((s) => (s.every((id) => inSets.has(id) || rack.includes(id)) ? s : s.filter((id) => inSets.has(id) || rack.includes(id))));
}, [inSets, rack]);
const send = (next: DraftSet[]) => {
setLocal(next);
act({ type: 'setDraft', sets: next, turn: v.turnNo });
};
const toggle = (id: string) => {
if (!mine) return;
setHintIds([]);
setMsg('');
setSel((s) => (s.includes(id) ? s.filter((x) => x !== id) : [...s, id]));
};
const move = (target: Target) => {
if (!mine || !sel.length) return;
if (!my?.melded && !v.options.initialMeldAllowExtend && sel.some((id) => onTable0.has(id))) {
setMsg('첫 등록 전에는 바닥의 타일을 옮길 수 없어요. 내 받침대 타일만 골라 주세요.');
return;
}
if (target === 'rack' && sel.some((id) => onTable0.has(id))) setMsg('바닥에 있던 타일은 받침대로 가져올 수 없어요.');
else setMsg('');
const next = moveTiles(sets, sel, target, onTable0);
if (!next) return;
setSel([]);
send(next);
};
const toggleKind = (i: number) => {
const d = sets[i]!;
const cur = v.draft?.[i]?.kind ?? 'run';
send(sets.map((x, k) => (k === i ? { tileIds: d.tileIds, as: cur === 'run' ? 'group' : 'run' } : x)));
};
const hint = () => {
const free = rack;
let found: string[][] = [];
if (!my?.melded) found = (findMeld(free, v.options.initialMeldPoints) ?? []).map((m) => m.tileIds);
else found = pickDisjoint(free).sets.slice(0, 1).map((m) => m.tileIds);
if (!found.length) {
setHintIds([]);
setMsg(my?.melded ? '받침대 타일만으로 바로 만들 수 있는 묶음이 없어요. 바닥 묶음에 붙여 보거나 한 장 가져오세요.' : `받침대 타일만으로 ${v.options.initialMeldPoints}점을 만들기 어려워요. 한 장 가져오세요.`);
return;
}
setHintIds(found.flat());
setSel(found[0]!);
setMsg(found.length > 1 ? `보라색 타일로 묶음 ${found.length}개를 만들 수 있어요. 첫 묶음을 골라 두었어요. "새 묶음"을 눌러 보세요.` : '보라색 타일로 묶음을 만들 수 있어요. 골라 두었으니 "새 묶음"을 눌러 보세요.');
};
const commit = () => {
if (my?.check && !my.check.ok && v.options.invalidCommitPenalty === 'penalty3' && my.changed && my.check.fromRack > 0) {
setConfirm('commit');
return;
}
act({ type: 'commit', turn: v.turnNo });
};
const draw = () => {
if (my?.changed) setConfirm('draw');
else act({ type: v.poolCount ? 'draw' : 'pass' });
};
const check = my?.check ?? null;
const viewDraft = v.draft;
const showingDraft = !!viewDraft;
const thinking = v.current && v.current !== me && v.draftEdits > 0;
const curName = v.current ? nick(players, v.current) : '';
const selFromSets = sel.some((id) => inSets.has(id) && !onTable0.has(id));
return (
<div className="rk-root">
<style>{CSS}</style>
<Players v={v} me={me} players={players} deadline={deadline} />
<section className="rk-table" aria-label="바닥">
<div className="rk-table-head">
<span>
{showingDraft && !mine ? `${curName}님이 고민 중인 배치` : mine && my?.changed ? '바닥(확정 전)' : '바닥'}
{thinking && !showingDraft ? ` · ${curName}님이 생각 중…` : ''}
</span>
<span aria-label={`남은 타일 ${v.poolCount}장`}>남은 타일 {v.poolCount}장</span>
</div>
{mine && my && !my.melded && (
<div className="rk-meld">
<span>
첫 등록 {check?.meldPoints ?? 0} / {v.options.initialMeldPoints}점
</span>
<progress max={v.options.initialMeldPoints || 1} value={Math.min(check?.meldPoints ?? 0, v.options.initialMeldPoints)} aria-label="첫 등록 점수" />
</div>
)}
{sets.length === 0 && !(mine && sel.length) && <p className="rk-empty">아직 바닥에 놓인 타일이 없어요.</p>}
{sets.map((d, i) => {
const info = mine || showingDraft ? (local ? null : viewDraft?.[i]) : null;
const n = info ? null : normalizeSet(d);
const ok = info ? info.ok : !!n;
const problem = info ? info.problem : n ? null : setProblem(d);
const jokerAs = info ? info.jokerAs : (n?.jokerAs ?? {});
const tableSet = !mine && !showingDraft ? v.table[i] : undefined;
const changed = !!tableSet && v.lastChanged.includes(tableSet.id);
const label = `묶음 ${i + 1}: ${d.tileIds.map((id) => tileLabel(id, jokerAs[id])).join(', ')}${mine || showingDraft ? (ok ? ' (올바름)' : ` (고쳐야 함: ${problem ?? ''})`) : ''}`;
return (
<div
key={tableSet ? `${tableSet.id}-${changed ? v.turnNo : 0}` : `d${i}`}
role="group"
aria-label={label}
className={`rk-set${mine || showingDraft ? (ok ? ' ok' : ' bad') : ''}${changed ? ' changed' : ''}`}
>
{mine && sel.length > 0 && (
<button type="button" className="rk-slot" aria-label={`묶음 ${i + 1} 앞에 놓기`} onClick={() => move({ set: i, at: 'start' })}>
◀
</button>
)}
{d.tileIds.map((id) => (
<Tile key={id} id={id} size="sm" as={jokerAs[id]} selected={sel.includes(id)} onClick={mine ? () => toggle(id) : undefined} />
))}
{mine && sel.length > 0 && (
<button type="button" className="rk-slot" aria-label={`묶음 ${i + 1} 뒤에 놓기`} onClick={() => move({ set: i, at: 'end' })}>
▶
</button>
)}
{(mine || showingDraft) && !ok && problem && <span className="rk-problem">{problem}</span>}
{mine && info?.ambiguous && (
<span className="rk-setinfo">
<span>{info.kind === 'group' ? '같은 숫자 묶음으로 봐요' : '이어진 숫자로 봐요'}</span>
<button type="button" onClick={() => toggleKind(i)}>
{info.kind === 'group' ? '이어진 숫자로' : '같은 숫자로'}
</button>
</span>
)}
</div>
);
})}
{mine && sel.length > 0 && (
<button type="button" className="rk-newset" onClick={() => move('new')}>
+ 새 묶음
</button>
)}
</section>
{mine && (
<>
<p className="rk-msg" aria-live="polite" style={{ color: msg || (check && !check.ok && my?.changed) ? 'var(--danger)' : 'var(--text-sub)' }}>
{msg ||
(sel.length
? `${sel.length}장 골랐어요. 놓을 곳(◀ ▶ 또는 새 묶음)을 누르세요.`
: my?.changed
? check?.ok
? '좋아요! 확정을 누르면 차례가 끝나요.'
: (check?.reason ?? '')
: '타일을 눌러 고른 뒤 놓을 곳을 누르세요.')}
</p>
<div className="rk-bar" role="group" aria-label="내 차례 행동">
<button type="button" className="btn btn-secondary" disabled={!my?.canUndo || pending} onClick={() => act({ type: 'undoDraft' })}>
되돌리기
</button>
<button type="button" className="btn btn-secondary" disabled={!my?.changed || pending} onClick={() => act({ type: 'resetDraft' })}>
처음으로
</button>
{selFromSets ? (
<button type="button" className="btn btn-soft" onClick={() => move('rack')}>
받침대로
</button>
) : (
<button type="button" className="btn btn-soft" disabled={!v.options.hints} onClick={hint} title={v.options.hints ? '받침대 타일로 바로 낼 수 있는 묶음을 찾아 줘요' : '이 방은 힌트를 쓰지 않아요'}>
도와줘
</button>
)}
<button type="button" className="btn btn-soft" disabled={!sel.length} onClick={() => move('new')}>
새 묶음
</button>
<button type="button" className="btn btn-secondary" disabled={pending} onClick={draw}>
{v.poolCount ? '한 장 가져오기' : '넘기기'}
</button>
<button type="button" className={`btn ${check?.ok ? 'btn-primary' : 'btn-secondary'}`} disabled={!my?.changed || pending} onClick={commit} aria-describedby="rk-commit-help">
확정
</button>
</div>
<span id="rk-commit-help" className="sr-only">
{check?.ok ? '지금 확정할 수 있어요' : (check?.reason ?? '')}
</span>
</>
)}
{v.phase !== 'playing' && v.round && <RoundPanel v={v} me={me} players={players} act={act} pending={pending} deadline={deadline} />}
{my && v.phase === 'playing' && (
<div className="rk-rackwrap">
<div className="rk-rackhead">
<strong>
내 받침대 <span className="muted small">({my.rack.length}장{rack.length !== my.rack.length ? `, ${my.rack.length - rack.length}장은 바닥에` : ''})</span>
</strong>
<div className="segmented" role="group" aria-label="받침대 정렬">
{(
[
['color', '색깔순'],
['number', '숫자순'],
] as const
).map(([k, l]) => (
<button
key={k}
type="button"
aria-pressed={sortBy === k}
onClick={() => {
setSortBy(k);
localStorage.setItem('rk-sort', k);
}}
>
{l}
</button>
))}
</div>
</div>
<div className="rk-rack" role="group" aria-label="내 받침대">
{rack.length === 0 && <span className="rk-rack-empty">{my.rack.length ? '모두 바닥에 놓았어요.' : '타일이 없어요.'}</span>}
{rack.map((id) => (
<Tile key={id} id={id} selected={sel.includes(id)} hint={hintIds.includes(id)} fresh={fresh.has(id)} onClick={mine ? () => toggle(id) : undefined} />
))}
</div>
</div>
)}
<p className="sr-only" aria-live="polite">
{announce}
</p>
{confirm === 'draw' && (
<ConfirmSheet
title="놓은 타일을 되돌리고 한 장 가져올까요?"
body="이번 차례에 바꾼 배치는 모두 처음으로 돌아가요."
confirm={v.poolCount ? '되돌리고 한 장 가져오기' : '되돌리고 넘기기'}
onConfirm={() => act({ type: v.poolCount ? 'draw' : 'pass' })}
onClose={() => setConfirm(null)}
/>
)}
{confirm === 'commit' && (
<ConfirmSheet
title="틀린 묶음이 있어요. 그래도 확정할까요?"
body={`${check?.reason ?? ''} 지금 확정하면 놓은 타일이 돌아오고 3장을 가져와요.`}
confirm="그래도 확정"
danger
onConfirm={() => act({ type: 'commit', turn: v.turnNo })}
onClose={() => setConfirm(null)}
/>
)}
</div>
);
}
function Players({ v, me, players, deadline }: { v: RummikubView; me: string | null; players: Record<string, PublicUser>; deadline: number | null }) {
return (
<div className="rk-players" role="list" aria-label="참가자">
{v.seats.map((s) => {
const turn = v.current === s.id;
const u = userOf(players, s.id);
return (
<div
key={s.id}
role="listitem"
className={`rk-player${turn ? ' turn' : ''}${s.left || !s.inRound ? ' gone' : ''}`}
aria-label={`${u.nickname}${s.id === me ? '(나)' : ''}, 타일 ${s.tiles}장, ${s.melded ? '첫 등록 함' : '첫 등록 전'}${turn ? ', 지금 차례' : ''}${s.left ? ', 나감' : s.away ? ', 자리 비움' : ''}`}
>
<Avatar user={u} size={26} />
<span style={{ maxWidth: 110, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', fontWeight: s.id === me ? 800 : 600 }}>
{u.nickname}
{s.id === me && <span className="muted"> (나)</span>}
</span>
<span className="rk-count" title="받침대 타일 수">
{s.tiles}장
</span>
{!s.melded && s.inRound && (
<span className="rk-badge" style={{ background: 'var(--surface-2)', color: 'var(--text-sub)' }}>
첫 등록 전
</span>
)}
{s.left && (
<span className="rk-badge" style={{ background: 'var(--danger)', color: '#fff' }}>
나감
</span>
)}
{!s.left && s.away && (
<span className="rk-badge" style={{ background: 'var(--warning)', color: '#fff' }}>
자리 비움
</span>
)}
{v.match.total > 1 && <span className="small muted">{s.wins}승</span>}
{turn && <TurnTimer deadline={deadline} totalMs={v.turnMs} mine={s.id === me} size={30} />}
</div>
);
})}
</div>
);
}
function RoundPanel({ v, me, players, act, pending, deadline }: { v: RummikubView; me: string | null; players: Record<string, PublicUser>; act: (a: unknown) => void; pending: boolean; deadline: number | null }) {
const r = v.round!;
const title =
r.endReason === 'rackEmpty'
? `${r.winners.map((w) => nick(players, w)).join(', ')}님이 타일을 모두 내려놓았어요! 다 냈다!`
: r.endReason === 'stalemate'
? '더 낼 수 있는 사람이 없어 끝났어요. 남은 타일 점수가 적은 사람이 이겨요.'
: '판이 끝났어요.';
const order = r.ranking.flat();
return (
<div className="card stack rk-result" style={{ padding: 12 }} aria-live="polite">
<strong>
{v.match.total > 1 ? `${r.gameNo}판 결과 · ` : ''}
{title}
</strong>
{order.map((p) => (
<div key={p} className="rk-result-row">
<strong style={{ minWidth: 70 }}>
{nick(players, p)}
{r.winners.includes(p) ? ' 🏆' : ''}
</strong>
<span style={{ fontWeight: 800, color: r.scores[p]! >= 0 ? 'var(--success)' : 'var(--danger)' }}>
{r.scores[p]! > 0 ? '+' : ''}
{r.scores[p]}점
</span>
<span className="small muted">남은 타일 {r.rackValues[p]}점</span>
<span className="rk-mini" aria-label={`${nick(players, p)}님의 남은 타일`}>
{(r.revealed[p] ?? []).map((id) => (
<Tile key={id} id={id} size="xs" />
))}
</span>
</div>
))}
<span className="small muted">점수는 이 방에서 순위를 정하는 데만 써요.</span>
{v.phase === 'roundEnd' && (
<div className="row" style={{ justifyContent: 'space-between' }}>
{v.my?.needReady ? (
<button type="button" className="btn btn-primary" disabled={pending} onClick={() => act({ type: 'ready' })}>
다음 판 시작
</button>
) : (
<span className="small muted">{me && v.ready.includes(me) ? '다른 사람을 기다리는 중이에요.' : '다음 판을 기다리는 중이에요.'}</span>
)}
<TurnTimer deadline={deadline} totalMs={30_000} mine={!!v.my?.needReady} size={36} />
</div>
)}
</div>
);
}
function logText(e: LogEntry, players: Record<string, PublicUser>, me: string | null): string {
const who = e.by === me ? '내가' : `${nick(players, e.by)}님이`;
switch (e.kind) {
case 'commit':
return `${who} ${e.count}장을 냈어요.`;
case 'timeoutCommit':
return `시간이 다 되어 ${who} 놓아 둔 ${e.count}장이 확정됐어요.`;
case 'draw':
return `${who} 1장을 가져왔어요.`;
case 'timeoutDraw':
return `시간이 다 되어 ${who} ${e.count}장을 가져왔어요.`;
case 'penalty':
return `${who} 묶음을 다 맞추지 못해 ${e.count}장을 가져왔어요.`;
case 'pass':
return `${who} 넘겼어요.`;
case 'left':
return `${nick(players, e.by)}님이 나갔어요.`;
}
}
// ---------------------------------------------------------------- side
function RummikubSide({ view: v, me, players }: BoardProps<RummikubView>) {
const order = [...v.seats].sort((a, b) => b.wins - a.wins || b.total - a.total);
const lastRound = v.opening.length ? Math.max(...v.opening.map((o) => o.round)) : 0;
return (
<>
<style>{CSS}</style>
<div className="card stack" style={{ gap: 6, padding: 12 }}>
<div className="row" style={{ justifyContent: 'space-between' }}>
<strong>{v.phase === 'finished' ? '게임 끝' : v.match.total > 1 ? `${v.match.gameNo}판 / ${v.match.total}판` : '숫자 타일'}</strong>
<span className="small muted">남은 타일 {v.poolCount}장</span>
</div>
<ol style={{ listStyle: 'none', margin: 0, padding: 0, display: 'grid', gap: 4 }}>
{order.map((s) => (
<li key={s.id} className="row" style={{ gap: 6, flexWrap: 'nowrap', opacity: s.left ? 0.55 : 1 }}>
<Avatar user={userOf(players, s.id)} size={24} />
<span style={{ flex: 1, minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', fontWeight: s.id === me ? 800 : 600 }}>
{nick(players, s.id)}
{s.left ? ' · 나감' : ''}
</span>
<span className="small">{s.tiles}장</span>
{v.match.total > 1 && (
<span className="small muted">
{s.wins}승 · {s.total > 0 ? '+' : ''}
{s.total}점
</span>
)}
</li>
))}
</ol>
{lastRound > 0 && v.match.gameNo >= 1 && (
<span className="small muted">
선 정하기:{' '}
{v.opening
.filter((o) => o.round === lastRound)
.map((o) => `${nick(players, o.player)} ${o.n === null ? '조커(다시)' : o.n}`)
.join(' · ')}
{lastRound > 1 ? ` (${lastRound}번째 뽑기)` : ''}
</span>
)}
</div>
{v.log.length > 0 && (
<details className="card" open style={{ padding: 12 }}>
<summary style={{ cursor: 'pointer', fontWeight: 700 }}>기록</summary>
<ol style={{ listStyle: 'none', margin: '8px 0 0', padding: 0, display: 'grid', gap: 2, maxHeight: 180, overflow: 'auto' }}>
{[...v.log].reverse().map((e, i) => (
<li key={`${e.turn}-${i}`} className="small">
{logText(e, players, me)}
</li>
))}
</ol>
</details>
)}
<details className="card wide-open" style={{ padding: 12 }}>
<summary style={{ cursor: 'pointer', fontWeight: 700 }}>규칙 요약</summary>
<Rules options={v.options} />
</details>
</>
);
}
// ---------------------------------------------------------------- status
function isMyTurn(v: RummikubView, me: string | null): boolean {
if (!me) return false;
if (v.phase === 'roundEnd') return !!v.my?.needReady;
return v.phase === 'playing' && v.current === me;
}
function statusText(v: RummikubView, me: string | null, players: Record<string, PublicUser>): string {
if (v.phase === 'finished') return v.result?.summary ?? '게임이 끝났어요';
if (v.phase === 'roundEnd') return v.my?.needReady ? '판이 끝났어요. 다음 판을 시작해 주세요' : '다음 판을 기다리는 중이에요';
if (v.current === me && v.my) {
if (!v.my.melded) return `내 차례예요 · 첫 등록 ${v.my.check?.meldPoints ?? 0}/${v.options.initialMeldPoints}점`;
return '내 차례예요. 타일을 내거나 한 장 가져오세요';
}
if (!v.current) return '잠시만 기다려 주세요';
return `${nick(players, v.current)}님 차례예요${v.draftEdits > 0 ? ' · 생각 중' : ''}`;
}
// ---------------------------------------------------------------- options
function Seg<T extends string | number | boolean>({ value, options, onChange, label }: { value: T; options: [T, string][]; onChange: (v: T) => void; label: string }) {
return (
<div className="segmented" role="group" aria-label={label}>
{options.map(([k, l]) => (
<button key={String(k)} type="button" aria-pressed={value === k} onClick={() => onChange(k)}>
{l}
</button>
))}
</div>
);
}
function Field({ label, help, children }: { label: string; help?: string; children: React.ReactNode }) {
return (
<div className="stack" style={{ gap: 6 }}>
<strong>{label}</strong>
{children}
{help && <span className="small muted">{help}</span>}
</div>
);
}
function Options({ value: o, onChange }: OptionsProps<RummikubOptions>) {
const [more, setMore] = useState(false);
const set = (patch: Partial<RummikubOptions>) => onChange({ ...o, ...patch });
return (
<div className="stack" style={{ gap: 16 }}>
<Field label="첫 등록 최소 점수" help="처음 낼 때는 받침대 타일만으로 만든 묶음의 숫자 합이 이 점수 이상이어야 해요.">
<Seg label="첫 등록 최소 점수" value={o.initialMeldPoints} options={[[0, '없음'], [20, '20점'], [30, '30점(공식)'], [50, '50점']]} onChange={(initialMeldPoints) => set({ initialMeldPoints })} />
</Field>
<Field label="한 차례 제한 시간" help="어르신이나 아이와 할 때는 넉넉하게 골라 주세요.">
<Seg label="한 차례 제한 시간" value={o.turnSeconds} options={[[60, '1분'], [90, '1분 30초'], [120, '2분'], [180, '3분']]} onChange={(turnSeconds) => set({ turnSeconds })} />
</Field>
<Field label="판 수">
<Seg label="판 수" value={o.gamesPerMatch} options={[['1', '1판'], ['players', '인원수만큼'], ['2', '2판'], ['3', '3판'], ['4', '4판']]} onChange={(gamesPerMatch) => set({ gamesPerMatch })} />
</Field>
<Field label='"도와줘" 힌트 버튼'>
<Seg label="힌트 버튼" value={o.hints} options={[[true, '켜기'], [false, '끄기']]} onChange={(hints) => set({ hints })} />
</Field>
<button type="button" className="btn btn-ghost" onClick={() => setMore(!more)} aria-expanded={more}>
{more ? '▲ 자세한 설정 닫기' : '▼ 자세한 설정'}
</button>
{more && (
<>
<Field label="시간이 다 됐을 때 올바르게 놓아 둔 타일">
<Seg label="시간 초과 때 올바른 배치" value={o.timeoutValidDraft} options={[['commit', '자동으로 확정'], ['revert', '되돌리고 한 장 가져오기(공식)']]} onChange={(timeoutValidDraft) => set({ timeoutValidDraft })} />
</Field>
<Field label="시간이 다 됐는데 덜 맞췄을 때 가져올 장수">
<Seg label="시간 초과 벌칙" value={o.invalidTimeoutPenalty} options={[[1, '1장'], [3, '3장(공식)']]} onChange={(invalidTimeoutPenalty) => set({ invalidTimeoutPenalty })} />
</Field>
<Field label="틀린 채로 확정을 누르면">
<Seg label="틀린 확정" value={o.invalidCommitPenalty} options={[['reject', '알려 주고 계속 고치기'], ['penalty3', '되돌리고 3장 가져오기']]} onChange={(invalidCommitPenalty) => set({ invalidCommitPenalty })} />
</Field>
<Field label="받침대에 남은 조커 감점">
<Seg label="조커 감점" value={o.jokerPenalty} options={[[30, '30점(공식)'], [50, '50점(대회)']]} onChange={(jokerPenalty) => set({ jokerPenalty })} />
</Field>
<Field label="첫 등록하는 차례에 바닥 묶음에도 붙이기" help="켜면 첫 등록 점수를 새 묶음으로 채운 뒤, 같은 차례에 바닥 묶음을 고칠 수 있어요.">
<Seg label="첫 등록 차례 재배치" value={o.initialMeldAllowExtend} options={[[false, '안 됨(공식)'], [true, '허용']]} onChange={(initialMeldAllowExtend) => set({ initialMeldAllowExtend })} />
</Field>
<Field label="고민 중인 배치를 다른 사람에게 보이기" help="켜면 바닥에 올려 본 타일이 바로 보여요. 다시 되돌려도 이미 본 타일은 기억에 남아요.">
<Seg label="고민 중인 배치 공개" value={o.showDraftToOthers} options={[[false, '안 보이기'], [true, '보이기']]} onChange={(showDraftToOthers) => set({ showDraftToOthers })} />
</Field>
</>
)}
</div>
);
}
function optionsSummary(o: RummikubOptions): string {
const games = o.gamesPerMatch === 'players' ? '인원수만큼' : `${o.gamesPerMatch}판`;
const sec = o.turnSeconds % 60 ? `${Math.floor(o.turnSeconds / 60)}분 ${o.turnSeconds % 60}초` : `${o.turnSeconds / 60}분`;
return [o.initialMeldPoints ? `첫 등록 ${o.initialMeldPoints}점` : '첫 등록 점수 없음', `한 차례 ${sec}`, games, o.jokerPenalty === 50 ? '조커 50점' : '', o.showDraftToOthers ? '배치 공개' : '']
.filter(Boolean)
.join(' · ');
}
// ---------------------------------------------------------------- rules
function Example({ ids }: { ids: string[] }) {
const n = normalizeSet({ tileIds: ids });
return (
<span className="rk-mini" style={{ verticalAlign: 'middle', margin: '2px 0' }}>
{ids.map((id) => (
<Tile key={id} id={id} size="xs" as={n?.jokerAs[id]} />
))}
</span>
);
}
function Rules({ options }: { options?: RummikubOptions }) {
const o = options;
const meld = o?.initialMeldPoints ?? 30;
return (
<div className="stack">
<style>{CSS}</style>
<p>
1부터 13까지 숫자가 적힌 4가지 색 타일(색마다 모양 표시 ● ♥ ◆ ★)이 두 벌씩 104장, 조커 2장이 있어요. 각자 14장을 받고, 내 받침대의 타일을 가장 먼저 모두 내려놓으면 이겨요.
</p>
<p>
<strong>묶음 두 가지</strong>
</p>
<ul style={{ margin: 0, paddingLeft: 20 }}>
<li>
같은 숫자, 다른 색 3~4장 <Example ids={['K7a', 'R7a', 'B7a']} />
</li>
<li>
같은 색, 이어지는 숫자 3장 이상 <Example ids={['B3a', 'B4a', 'B5a', 'B6a']} /> (13 다음에 1은 올 수 없어요)
</li>
<li>
조커는 아무 타일이나 대신해요 <Example ids={['O9a', 'O10a', 'JK1']} />
</li>
</ul>
<p>
<strong>내 차례</strong>: 타일을 1장 이상 내고 &quot;확정&quot;을 누르거나, &quot;한 장 가져오기&quot;를 눌러요. 타일을 누른 뒤 놓을 곳(◀ 앞, ▶ 뒤, 새 묶음)을 누르면 옮겨져요. 확정할 때 바닥의 모든 묶음이 올바라야 해요.
</p>
<p>
<strong>첫 등록</strong>:{' '}
{meld ? `처음 낼 때는 받침대 타일만으로 만든 새 묶음의 숫자 합이 ${meld}점 이상이어야 해요. 조커는 대신하는 숫자로 세요.` : '이 방은 첫 등록 점수가 없어요.'}{' '}
{o?.initialMeldAllowExtend ? '첫 등록 차례에도 점수를 채운 뒤에는 바닥 묶음을 고칠 수 있어요.' : '첫 등록 전에는 바닥 묶음을 건드릴 수 없어요.'}
</p>
<p>
<strong>바닥 고치기</strong>: 첫 등록 뒤에는 바닥의 묶음을 쪼개고 합치고 옮겨서 내 타일을 끼워 넣을 수 있어요. 예를 들어 빨강 4-5-6-7-8 사이에 내 빨강 6을 넣어 4-5-6과 6-7-8로 나눌 수 있어요. 바닥 타일은 받침대로 가져올 수 없고, 조커를 빼 왔다면 같은 차례에 다시 바닥에 써야 해요.
</p>
<p>
<strong>시간</strong>: 한 차례는 {o ? `${o.turnSeconds}초` : '1분'}예요. 시간이 다 되면{' '}
{o?.timeoutValidDraft === 'revert' ? '놓아 둔 타일을 되돌리고 1장을 가져와요' : '올바르게 놓아 둔 타일은 자동으로 확정돼요'}. 묶음이 틀린 채로 끝나면 처음으로 되돌리고 {o?.invalidTimeoutPenalty ?? 3}장을 가져와요.
</p>
<p>
<strong>점수</strong>: 누가 다 내려놓으면 나머지는 받침대 숫자 합만큼 빼고(조커 {o?.jokerPenalty ?? 30}점), 이긴 사람이 그만큼 더해요. 남은 타일이 없는데 아무도 못 내면 받침대 합이 가장 적은 사람이 이겨요. 점수는 이 방의 순위에만 써요.
</p>
</div>
);
}
const ui: GameUI<RummikubView, RummikubOptions> = {
Board: RummikubBoard,
Side: RummikubSide,
statusText,
isMyTurn,
Options,
optionsSummary,
Rules,
};
export default ui;

View File

@@ -0,0 +1,667 @@
/** 요트 다이스 화면 (docs/games/yacht.md §9). */
import { useEffect, useLayoutEffect, useRef, useState } from 'react';
import type { Category, ScoreEntry, YachtOptions, YachtSheetView, YachtView } from '@bg/games/yacht';
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';
// The client draws from the view only; names and hints live here so the rules module isn't bundled.
const NAME: Record<Category, string> = {
ones: '에이스',
twos: '듀스',
threes: '트레이',
fours: '포',
fives: '파이브',
sixes: '식스',
choice: '초이스',
threeKind: '쓰리 카인드',
fourKind: '포 카인드',
fullHouse: '풀 하우스',
smallStraight: '스몰 스트레이트',
largeStraight: '라지 스트레이트',
yacht: '요트',
};
const UPPER: Category[] = ['ones', 'twos', 'threes', 'fours', 'fives', 'sixes'];
const catName = (c: Category, scoring: YachtOptions['scoring']) => (c === 'choice' && scoring === 'yahtzee' ? '찬스' : NAME[c]);
const ROLL_MS = 600;
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 reducedMotion = () => typeof matchMedia !== 'undefined' && matchMedia('(prefers-reduced-motion: reduce)').matches;
const CSS = `
.yt-wrap { width: 100%; max-width: 1080px; display: flex; flex-direction: column; gap: 12px; }
.yt-tray { display: flex; flex-direction: column; gap: 12px; align-items: stretch; }
.yt-head { display: flex; align-items: center; gap: 8px; justify-content: space-between; flex-wrap: wrap; }
.yt-dice { display: flex; gap: 8px; justify-content: center; padding: 6px 0 18px; }
.yt-die { --s: 64px; width: var(--s); height: var(--s); padding: 0; border-radius: 14px; border: 3px solid #c9c4b8; background: #fffdf8;
box-shadow: 0 3px 0 #b7b1a3, 0 4px 10px rgba(0,0,0,.15); cursor: pointer; position: relative; transition: transform .15s ease, border-color .15s; flex: none; }
.yt-die svg { display: block; width: 100%; height: 100%; }
.yt-die:disabled { cursor: default; }
.yt-die.held { transform: translateY(14px); border-color: var(--primary); box-shadow: 0 0 0 3px var(--primary-soft), 0 3px 8px rgba(0,0,0,.15); }
.yt-die.blank { background: var(--surface-2); border-style: dashed; box-shadow: none; }
.yt-die:focus-visible { outline: 3px solid var(--focus); outline-offset: 3px; }
.yt-lock { position: absolute; left: 50%; bottom: -12px; transform: translateX(-50%); background: var(--primary); color: var(--primary-text);
font-size: .72rem; font-weight: 800; border-radius: 999px; padding: 1px 7px; white-space: nowrap; line-height: 1.4; }
@keyframes yt-tumble { 0% { transform: translateY(-18px) rotate(-30deg) scale(.9); } 40% { transform: translateY(4px) rotate(200deg); }
70% { transform: translateY(-6px) rotate(330deg); } 100% { transform: translateY(0) rotate(360deg); } }
.yt-die.rolling { animation: yt-tumble ${ROLL_MS}ms ease-out both; }
@keyframes yt-pop { 0% { transform: translate(-50%, -50%) scale(.4); opacity: 0; } 60% { transform: translate(-50%, -50%) scale(1.15); opacity: 1; } 100% { transform: translate(-50%, -50%) scale(1); } }
.yt-flash { position: fixed; left: 50%; top: 40%; transform: translate(-50%, -50%); z-index: 30; pointer-events: none; font-size: 4rem; font-weight: 900;
color: #fde047; text-shadow: 0 4px 12px rgba(0,0,0,.55), 0 0 2px #854d0e; animation: yt-pop .5s ease-out both; white-space: nowrap; }
.yt-roll { min-height: 64px; font-size: 1.2rem; }
.yt-sheet { padding: 0; overflow: auto; }
.yt-table { border-collapse: separate; border-spacing: 0; width: 100%; font-variant-numeric: tabular-nums; }
.yt-table th, .yt-table td { height: 38px; padding: 0 6px; border-bottom: 1px solid var(--border); text-align: center; white-space: nowrap; }
.yt-table thead th { position: sticky; top: 0; background: var(--surface); z-index: 2; height: 46px; font-size: .9rem; }
.yt-table th.yt-cat { position: sticky; left: 0; z-index: 1; background: var(--surface); text-align: left; font-weight: 700; padding-left: 12px; min-width: 112px; }
.yt-table thead th.yt-cat { z-index: 3; }
.yt-table td { min-width: 56px; }
.yt-table td.mine { background: var(--primary-soft); min-width: 72px; font-size: 1.08rem; }
.yt-table th.turn { box-shadow: inset 0 -4px 0 var(--focus); }
.yt-table tr.yt-sum th, .yt-table tr.yt-sum td { background: var(--surface-2); font-weight: 700; font-size: .92rem; }
.yt-table tr.yt-sum td.mine { background: var(--primary-soft); }
.yt-table tr.yt-total th, .yt-table tr.yt-total td { font-weight: 900; font-size: 1.1rem; border-bottom: 0; height: 44px; }
.yt-filled { font-weight: 800; }
.yt-ghost { color: var(--text-sub); opacity: .75; font-weight: 600; }
.yt-cell { width: 100%; height: 34px; border-radius: 8px; border: 2px dashed var(--border); background: var(--surface); color: var(--text-sub);
font: inherit; font-weight: 700; cursor: pointer; }
.yt-cell.good { border-style: solid; border-color: var(--success); color: var(--success); background: var(--success-soft); }
.yt-cell.zero { opacity: .6; }
.yt-cell:hover:not(:disabled) { border-color: var(--primary); color: var(--text); }
.yt-cell:disabled { cursor: not-allowed; opacity: .35; }
.yt-cell:focus-visible { outline: 3px solid var(--focus); outline-offset: 1px; }
@keyframes yt-new { from { background: #fde68a; } to { background: transparent; } }
.yt-new { animation: yt-new 1.6s ease-out; border-radius: 6px; }
.yt-bar { height: 8px; border-radius: 4px; background: var(--surface-2); overflow: hidden; }
.yt-bar > span { display: block; height: 100%; background: var(--success); transition: width .3s; }
.yt-pname { display: inline-block; max-width: 72px; overflow: hidden; text-overflow: ellipsis; vertical-align: bottom; }
@media (min-width: 1100px) {
.yt-wrap { display: grid; grid-template-columns: 380px minmax(0, 1fr); align-items: start; max-height: 100%; }
.yt-die { --s: 64px; }
.yt-sheet { max-height: calc(100vh - 64px - 52px - 40px); }
.yt-table th, .yt-table td { height: 34px; }
.yt-cell { height: 30px; }
}
@media (prefers-reduced-motion: reduce) {
.yt-die, .yt-die.held { transition: none; }
.yt-die.rolling, .yt-flash, .yt-new { animation: none; }
}
`;
// ---------------------------------------------------------------- dice
const PIPS: Record<number, [number, number][]> = {
1: [[50, 50]],
2: [[28, 28], [72, 72]],
3: [[26, 26], [50, 50], [74, 74]],
4: [[28, 28], [72, 28], [28, 72], [72, 72]],
5: [[26, 26], [74, 26], [50, 50], [26, 74], [74, 74]],
6: [[28, 24], [72, 24], [28, 50], [72, 50], [28, 76], [72, 76]],
};
function DieFace({ value }: { value: number | null }) {
return (
<svg viewBox="0 0 100 100" aria-hidden>
{value !== null &&
PIPS[value]!.map(([x, y], i) => <circle key={i} cx={x} cy={y} r={value === 1 ? 13 : 10} fill={value === 1 ? '#c81e1e' : '#1c1e21'} />)}
</svg>
);
}
/** Small static die for the rules / last-score line. */
function MiniDie({ value, size = 26 }: { value: number; size?: number }) {
return (
<span style={{ display: 'inline-block', width: size, height: size, borderRadius: size / 4.5, border: '2px solid #c9c4b8', background: '#fffdf8', verticalAlign: 'middle' }}>
<DieFace value={value} />
</span>
);
}
function DiceRow({ dice, size }: { dice: number[]; size?: number }) {
return (
<span style={{ display: 'inline-flex', gap: 3 }} aria-label={`주사위 ${dice.join(', ')}`} role="img">
{dice.map((d, i) => (
<MiniDie key={i} value={d} size={size} />
))}
</span>
);
}
// ---------------------------------------------------------------- board
interface RollAnim {
key: number;
mask: boolean[];
}
function YachtBoard({ view: v, me, players, act, pending, tapConfirm }: BoardProps<YachtView>) {
const myTurn = v.phase === 'playing' && !!me && v.current === me;
const [held, setHeld] = useState<boolean[]>(v.held);
const [anim, setAnim] = useState<RollAnim | null>(null);
const [flick, setFlick] = useState<number[] | null>(null);
const [flash, setFlash] = useState<string | null>(null);
const [announce, setAnnounce] = useState('');
const [confirm, setConfirm] = useState<{ category: Category; points: number } | null>(null);
const prevRoll = useRef(v.rollSeq);
const prevTurn = useRef(v.turnSeq);
const pointer = useRef<string>('');
// Server holds win once our own pending action is answered (optimistic taps in between).
useEffect(() => {
if (!pending) setHeld(v.held);
}, [v.held, v.turnSeq, v.rollSeq, pending]);
// Roll animation, yacht flash and screen-reader text when a new roll arrives.
useLayoutEffect(() => {
if (v.rollSeq <= prevRoll.current || !v.dice) {
prevRoll.current = v.rollSeq;
return;
}
prevRoll.current = v.rollSeq;
const who = v.current === me ? '내가' : `${nick(players, v.current ?? '')}님이`;
setAnnounce(`${who} 굴렸어요: ${v.dice.join(', ')}`);
if (v.dice.every((d) => d === v.dice![0])) {
setFlash('요트!');
haptic([80, 40, 80]);
}
if (reducedMotion()) {
sounds.stone();
return;
}
const mask = v.rollsUsed === 1 ? [true, true, true, true, true] : v.held.map((h) => !h);
setAnim({ key: v.rollSeq, mask });
}, [v.rollSeq]); // eslint-disable-line react-hooks/exhaustive-deps
useEffect(() => {
if (!anim) return;
const t = setInterval(() => setFlick([0, 1, 2, 3, 4].map(() => 1 + Math.floor(Math.random() * 6))), 70);
const end = setTimeout(() => {
setAnim(null);
setFlick(null);
sounds.stone();
}, ROLL_MS);
return () => {
clearInterval(t);
clearTimeout(end);
};
}, [anim]);
useEffect(() => {
if (!flash) return;
const t = setTimeout(() => setFlash(null), 1500);
return () => clearTimeout(t);
}, [flash]);
// Score announcement + my-turn cue on turn changes.
useEffect(() => {
if (v.turnSeq === prevTurn.current) return;
prevTurn.current = v.turnSeq;
setConfirm(null);
const ls = v.lastScore;
if (ls) {
const who = ls.player === me ? '내가' : `${nick(players, ls.player)}님이`;
setAnnounce(`${who} ${catName(ls.category, v.options.scoring)}에 ${ls.points}점을 기록했어요${ls.bonus ? ` (요트 보너스 +${ls.bonus})` : ''}${ls.auto ? ' (시간 초과로 자동 기록)' : ''}.`);
}
if (v.phase === 'playing' && v.current === me) {
haptic(40);
if (document.visibilityState === 'hidden') sounds.myTurn();
}
}, [v.turnSeq]); // eslint-disable-line react-hooks/exhaustive-deps
const canRoll = myTurn && !pending && v.rollsUsed < 3 && !(v.rollsUsed > 0 && held.every(Boolean));
const canHold = myTurn && v.rollsUsed > 0 && v.rollsUsed < 3;
const toggle = (i: number) => {
if (!canHold) return;
const next = held.map((h, j) => (j === i ? !h : h));
setHeld(next);
act({ type: 'hold', held: next });
};
const roll = () => {
if (!canRoll) return;
act({ type: 'roll' });
};
const score = (category: Category, points: number, viaMouse: boolean) => {
if (!myTurn || pending) return;
if (points === 0 || (tapConfirm && !viaMouse)) setConfirm({ category, points });
else act({ type: 'score', category });
};
const shownDice = v.dice ?? null;
const rollLabel =
v.rollsUsed >= 3 ? '점수 칸을 골라 주세요' : v.rollsUsed > 0 && held.every(Boolean) ? '모두 고정했어요' : `🎲 굴리기 (남은 ${v.rollsLeft}번)`;
const curName = v.current ? (v.current === me ? '내' : `${nick(players, v.current)}님`) : '';
return (
<div className="yt-wrap">
<style>{CSS}</style>
<section className="card yt-tray" aria-label="주사위">
<div className="yt-head">
<strong>
{v.phase === 'finished' ? '게임 끝' : `${v.round} / ${v.totalRounds} 라운드`}
</strong>
{v.current && (
<span className="row small" style={{ gap: 6, flexWrap: 'nowrap', minWidth: 0 }}>
<Avatar user={userOf(players, v.current)} size={24} />
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{curName} 차례</span>
<span className="badge" aria-label={`굴린 횟수 ${v.rollsUsed}번`}>
{'●'.repeat(v.rollsUsed)}
{'○'.repeat(3 - v.rollsUsed)}
</span>
</span>
)}
</div>
<div className="yt-dice" role="group" aria-label={canHold ? '주사위. 누르면 고정하거나 풀어요' : '주사위'}>
{[0, 1, 2, 3, 4].map((i) => {
const value = shownDice ? (anim?.mask[i] && flick ? flick[i]! : shownDice[i]!) : null;
const isHeld = !!shownDice && v.rollsUsed > 0 && held[i]!;
const rolling = !!anim && anim.mask[i];
return (
<button
key={`${i}-${rolling ? anim!.key : 's'}`}
type="button"
className={`yt-die${isHeld ? ' held' : ''}${shownDice ? '' : ' blank'}${rolling ? ' rolling' : ''}`}
aria-pressed={canHold ? isHeld : undefined}
aria-label={shownDice ? `주사위 ${i + 1}: ${shownDice[i]}${isHeld ? ', 고정됨' : ''}` : `주사위 ${i + 1}: 아직 안 굴림`}
disabled={!canHold}
onClick={() => toggle(i)}
>
<DieFace value={value} />
{isHeld && <span className="yt-lock">🔒 고정</span>}
</button>
);
})}
</div>
{myTurn ? (
<button className="btn btn-primary btn-block yt-roll" disabled={!canRoll} onClick={roll}>
{rollLabel}
</button>
) : (
<p className="muted small" style={{ textAlign: 'center', minHeight: 24 }}>
{v.phase === 'finished' ? '게임이 끝났어요' : v.current ? `${nick(players, v.current)}님이 굴리는 중이에요` : ''}
</p>
)}
{myTurn && v.rollsUsed > 0 && v.rollsUsed < 3 && <p className="muted small" style={{ textAlign: 'center' }}>남길 주사위를 누르면 고정돼요. 점수표의 칸을 누르면 기록해요.</p>}
{v.lastScore && <LastScore entry={v.lastScore} view={v} me={me} players={players} />}
</section>
<ScoreSheet view={v} me={me} players={players} myTurn={myTurn} pending={pending} onScore={score} pointer={pointer} />
<p className="sr-only" aria-live="polite">
{announce}
</p>
{flash && (
<div className="yt-flash" aria-hidden>
{flash}
</div>
)}
{confirm && (
<ConfirmSheet
title={`${catName(confirm.category, v.options.scoring)}에 ${confirm.points}점 기록할까요?`}
body={confirm.points === 0 ? '조건에 맞지 않아서 0점이 돼요. 한 번 기록한 칸은 바꿀 수 없어요.' : '한 번 기록한 칸은 바꿀 수 없어요.'}
confirm="기록하기"
onConfirm={() => act({ type: 'score', category: confirm.category })}
onClose={() => setConfirm(null)}
/>
)}
</div>
);
}
function LastScore({ entry: e, view: v, me, players }: { entry: ScoreEntry; view: YachtView; me: string | null; players: Record<string, PublicUser> }) {
return (
<p className="small row" style={{ justifyContent: 'center', gap: 6 }}>
<span className="muted">방금:</span>
<strong>{e.player === me ? '나' : nick(players, e.player)}</strong>
<DiceRow dice={e.dice} size={20} />
<span>
→ {catName(e.category, v.options.scoring)} {e.points}점{e.bonus ? ` +${e.bonus}` : ''}
{e.auto ? ' (자동)' : ''}
</span>
</p>
);
}
// ---------------------------------------------------------------- score sheet
function ScoreSheet({
view: v,
me,
players,
myTurn,
pending,
onScore,
pointer,
}: {
view: YachtView;
me: string | null;
players: Record<string, PublicUser>;
myTurn: boolean;
pending: boolean;
onScore: (c: Category, points: number, viaMouse: boolean) => void;
pointer: React.MutableRefObject<string>;
}) {
// My column first (bigger), then everyone else in turn order.
const cols = me && v.order.includes(me) ? [me, ...v.order.filter((p) => p !== me)] : v.order;
const upper = v.categories.filter((c) => UPPER.includes(c));
const lower = v.categories.filter((c) => !UPPER.includes(c));
const ls = v.lastScore;
const yz = v.options.scoring === 'yahtzee';
const cell = (p: string, c: Category) => {
const sh = v.sheets[p]!;
const val = sh.scores[c];
if (val !== undefined) {
const fresh = ls && ls.player === p && ls.category === c;
return (
<span key={fresh ? `n${v.turnSeq}` : 'f'} className={`yt-filled${fresh ? ' yt-new' : ''}`}>
{val}
</span>
);
}
const prev = v.current === p ? v.preview?.[c] : undefined;
if (p === me && myTurn && v.rollsUsed > 0 && prev !== undefined) {
const allowed = v.allowed.includes(c);
const show = v.options.showPreview;
return (
<button
type="button"
className={`yt-cell${show && prev > 0 ? ' good' : ''}${show && prev === 0 ? ' zero' : ''}`}
disabled={!allowed || pending}
aria-label={`${catName(c, v.options.scoring)}에 ${show ? `${prev}점 ` : ''}기록하기${allowed ? '' : ' (지금은 넣을 수 없어요)'}`}
onPointerDown={(e) => (pointer.current = e.pointerType)}
onClick={(e) => {
const viaMouse = e.detail === 0 || pointer.current === 'mouse';
pointer.current = '';
onScore(c, prev, viaMouse);
}}
>
{show ? prev : '기록'}
</button>
);
}
if (prev !== undefined && v.options.showPreview) return <span className="yt-ghost">{prev}</span>;
return null;
};
const header = (
<tr>
<th className="yt-cat" scope="col">
칸
</th>
{cols.map((p) => {
const sh = v.sheets[p]!;
return (
<th key={p} scope="col" className={v.current === p ? 'turn' : undefined} title={nick(players, p)}>
<span className="yt-pname">{p === me ? '나' : nick(players, p)}</span>
{sh.left && <div className="small muted">기권</div>}
{!sh.left && sh.away && <div className="small muted">자리 비움</div>}
</th>
);
})}
</tr>
);
const row = (c: Category) => (
<tr key={c}>
<th className="yt-cat" scope="row">
{catName(c, v.options.scoring)}
</th>
{cols.map((p) => (
<td key={p} className={p === me ? 'mine' : undefined}>
{cell(p, c)}
</td>
))}
</tr>
);
const sumRow = (label: string, f: (s: YachtSheetView) => React.ReactNode, cls = 'yt-sum') => (
<tr className={cls}>
<th className="yt-cat" scope="row">
{label}
</th>
{cols.map((p) => (
<td key={p} className={p === me ? 'mine' : undefined}>
{f(v.sheets[p]!)}
</td>
))}
</tr>
);
return (
<section className="card yt-sheet" aria-label="점수표">
<table className="yt-table">
<thead>{header}</thead>
<tbody>
{upper.map(row)}
{sumRow('소계', (s) => `${s.upper}/${v.upperBonusThreshold}`)}
{sumRow('보너스 +35', (s) => (s.upperBonus ? `+${s.upperBonus}` : <span className="muted small">{`${Math.max(0, v.upperBonusThreshold - s.upper)} 남음`}</span>))}
{lower.map(row)}
{yz && sumRow('요트 보너스', (s) => (s.yahtzeeBonus ? `+${s.yahtzeeBonus}` : '-'))}
{sumRow('총점', (s) => s.total, 'yt-total')}
</tbody>
</table>
</section>
);
}
// ---------------------------------------------------------------- side panel
function YachtSide({ view: v, me, players, act, deadline }: BoardProps<YachtView>) {
const [ask, setAsk] = useState(false);
const mine = me ? v.sheets[me] : undefined;
const total = v.options.turnSeconds > 0 ? v.options.turnSeconds * 1000 : deadline ? Math.max(1000, deadline - v.turnStartedAt) : 60_000;
return (
<>
<section className="card stack" style={{ padding: 12, gap: 8 }} aria-label="참가자">
{v.order.map((p, i) => {
const sh = v.sheets[p]!;
const turn = v.current === p;
return (
<div key={p} className="row" style={{ gap: 10, flexWrap: 'nowrap', padding: '4px 6px', borderRadius: 10, border: `3px solid ${turn ? 'var(--focus)' : 'transparent'}` }}>
<span className="muted small" style={{ width: 16, textAlign: 'right' }}>
{i + 1}
</span>
<Avatar user={userOf(players, p)} size={30} />
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontWeight: 700, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>
{nick(players, p)}
{p === me && <span className="muted small"> (나)</span>}
</div>
<div className="small muted">
{sh.left ? '기권' : sh.away ? '자리 비움' : turn ? '굴리는 중' : `${sh.filled}/${v.totalRounds}칸`}
</div>
</div>
<span style={{ fontWeight: 800, fontSize: '1.3rem', fontVariantNumeric: 'tabular-nums' }} aria-label={`${nick(players, p)} 총점 ${sh.total}점`}>
{sh.total}
</span>
{turn && v.phase === 'playing' && <TurnTimer deadline={deadline} totalMs={total} mine={p === me} size={40} />}
</div>
);
})}
</section>
{mine && !mine.left && (
<section className="card stack" style={{ padding: 12, gap: 6 }} aria-label="상단 보너스">
<div className="row small" style={{ justifyContent: 'space-between' }}>
<strong>상단 보너스</strong>
<span>{mine.upperBonus ? '35점 확보!' : `${v.upperBonusThreshold}까지 ${Math.max(0, v.upperBonusThreshold - mine.upper)}점 남음`}</span>
</div>
<div className="yt-bar" role="progressbar" aria-valuemin={0} aria-valuemax={v.upperBonusThreshold} aria-valuenow={Math.min(mine.upper, v.upperBonusThreshold)} aria-label="상단 합계">
<span style={{ width: `${Math.min(100, (mine.upper / v.upperBonusThreshold) * 100)}%` }} />
</div>
</section>
)}
{v.phase === 'playing' && mine && !mine.left && (
<button className="btn btn-danger btn-sm" onClick={() => setAsk(true)}>
🏳 기권
</button>
)}
{ask && (
<ConfirmSheet
title="기권할까요?"
body="남은 칸은 모두 0점이 되고, 이후 차례에서 빠져요."
confirm="기권하기"
danger
onConfirm={() => act({ type: 'concede' })}
onClose={() => setAsk(false)}
/>
)}
</>
);
}
// ---------------------------------------------------------------- status
function isMyTurn(v: YachtView, me: string | null): boolean {
return v.phase === 'playing' && !!me && v.current === me;
}
function statusText(v: YachtView, me: string | null, players: Record<string, PublicUser>): string {
if (v.phase === 'finished') return v.result?.summary ?? '게임이 끝났어요';
if (me && v.sheets[me]?.left) return '기권했어요. 구경하는 중이에요';
if (isMyTurn(v, me)) {
if (v.rollsUsed === 0) return '내 차례예요. 주사위를 굴려 주세요';
if (v.rollsUsed < 3) return `내 차례예요. 다시 굴리거나(${v.rollsLeft}번 남음) 칸을 골라 주세요`;
return '내 차례예요. 점수를 넣을 칸을 골라 주세요';
}
return v.current ? `${nick(players, v.current)}님 차례예요 · ${v.round}/${v.totalRounds}라운드` : '잠시만 기다려 주세요';
}
// ---------------------------------------------------------------- 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<YachtOptions>) {
const set = (patch: Partial<YachtOptions>) => onChange({ ...o, ...patch });
return (
<div className="stack" style={{ gap: 16 }}>
<Field label="점수 규칙">
<Seg value={o.scoring} options={[['yacht51', '요트(12칸)'], ['yahtzee', '13칸 변형(미국식)']]} onChange={(scoring) => set({ scoring })} />
</Field>
{o.scoring === 'yacht51' ? (
<Field label="5개 모두 같은 눈을 풀 하우스로">
<Seg value={o.fullHouseAllowsYacht ? 'on' : 'off'} options={[['on', '인정'], ['off', '인정 안 함(전통 규칙)']]} onChange={(k) => set({ fullHouseAllowsYacht: k === 'on' })} />
</Field>
) : (
<Field label="요트가 또 나왔을 때">
<Seg value={o.yahtzeeJoker} options={[['forced', '정해진 순서대로(공식)'], ['free', '아무 칸이나']]} onChange={(yahtzeeJoker) => set({ yahtzeeJoker })} />
</Field>
)}
<Field label="한 차례 제한 시간">
<Seg value={o.turnSeconds} options={[[30, '30초'], [60, '60초'], [90, '90초'], [0, '제한 없음']]} onChange={(turnSeconds) => set({ turnSeconds })} />
</Field>
<Field label="점수 미리 보기">
<Seg value={o.showPreview ? 'on' : 'off'} options={[['on', '빈 칸에 보여 주기'], ['off', '보여 주지 않기']]} onChange={(k) => set({ showPreview: k === 'on' })} />
</Field>
</div>
);
}
function optionsSummary(o: YachtOptions): string {
return [
o.scoring === 'yacht51' ? '요트 12칸' : '13칸 변형',
...(o.scoring === 'yacht51' && !o.fullHouseAllowsYacht ? ['풀 하우스에 요트 불인정'] : []),
...(o.scoring === 'yahtzee' ? [o.yahtzeeJoker === 'forced' ? '추가 요트는 정해진 순서' : '추가 요트는 아무 칸'] : []),
o.turnSeconds > 0 ? `한 차례 ${o.turnSeconds}초` : '시간 제한 없음',
...(o.showPreview ? [] : ['미리 보기 끔']),
].join(' · ');
}
// ---------------------------------------------------------------- rules
function Rules({ options }: { options?: YachtOptions }) {
const yz = options?.scoring === 'yahtzee';
const fhYacht = options?.fullHouseAllowsYacht ?? true;
const rows: [string, string, number[]][] = yz
? [
['에이스~식스', '그 눈이 나온 주사위의 합', [6, 6, 6, 2, 1]],
['쓰리 카인드', '같은 눈 3개 이상이면 5개 합', [4, 4, 4, 2, 5]],
['포 카인드', '같은 눈 4개 이상이면 5개 합', [3, 3, 3, 3, 6]],
['풀 하우스', '같은 눈 3개 + 다른 같은 눈 2개면 25점', [2, 2, 5, 5, 5]],
['스몰 스트레이트', '이어지는 눈 4개면 30점', [1, 2, 3, 4, 6]],
['라지 스트레이트', '이어지는 눈 5개면 40점', [2, 3, 4, 5, 6]],
['요트', '5개 모두 같은 눈이면 50점', [5, 5, 5, 5, 5]],
['찬스', '아무 조합이나 5개 합', [6, 5, 4, 4, 2]],
]
: [
['에이스~식스', '그 눈이 나온 주사위의 합', [6, 6, 6, 2, 1]],
['초이스', '아무 조합이나 5개 합', [6, 5, 4, 4, 2]],
['포 카인드', '같은 눈 4개 이상이면 5개 합', [3, 3, 3, 3, 6]],
['풀 하우스', `같은 눈 3개 + 다른 같은 눈 2개면 5개 합${fhYacht ? '(5개 모두 같아도 인정)' : ''}`, [2, 2, 5, 5, 5]],
['스몰 스트레이트', '이어지는 눈 4개면 15점', [1, 2, 3, 4, 6]],
['라지 스트레이트', '이어지는 눈 5개면 30점', [2, 3, 4, 5, 6]],
['요트', '5개 모두 같은 눈이면 50점', [5, 5, 5, 5, 5]],
];
return (
<div className="stack">
<p>
주사위 5개를 한 차례에 <strong>최대 3번</strong> 굴려요. 마음에 드는 주사위는 눌러서 <strong>고정</strong>하고 나머지만 다시 굴릴 수 있어요. 고정은 굴릴 때마다 바꿀 수 있어요.
</p>
<p>
굴린 뒤에는 점수표의 <strong>빈 칸 하나</strong>를 골라 지금 주사위로 점수를 적어요. 조건에 맞지 않는 칸에 넣으면 0점이에요. 한 번 적은 칸은 바꿀 수 없고, 3번 굴린 뒤에는 꼭 적어야 해요.
</p>
<p>
{yz ? '13칸' : '12칸'}을 모두 채우면 끝나고, <strong>총점이 가장 높은 사람</strong>이 이겨요. 혼자서 최고 점수에 도전할 수도 있어요.
</p>
<div className="stack" style={{ gap: 6 }}>
{rows.map(([name, rule, dice]) => (
<div key={name} className="row" style={{ gap: 10, flexWrap: 'nowrap' }}>
<DiceRow dice={dice} size={22} />
<span className="small">
<strong>{name}</strong> · {rule}
</span>
</div>
))}
</div>
<p>
<strong>상단 보너스</strong>: 에이스~식스 6칸의 합이 63점 이상이면 35점을 더 받아요(눈마다 3개씩 모으면 딱 63점이에요).
</p>
{yz && (
<p>
<strong>추가 요트</strong>: 요트 칸에 50점을 적은 뒤 요트가 또 나오면 100점을 더 받아요.{' '}
{options?.yahtzeeJoker === 'free'
? '이때는 아무 빈 칸에나 적을 수 있어요.'
: '이때는 그 눈의 윗칸이 비어 있으면 거기에 먼저 적어야 하고, 윗칸이 차 있으면 아래쪽 칸에 적어요.'}{' '}
그 눈의 윗칸이 이미 차 있으면 풀 하우스 25점, 스트레이트 30·40점을 조건과 상관없이 인정해요.
</p>
)}
<p className="muted">
시간 안에 적지 않으면 지금 주사위로 가장 높은 점수가 나오는 칸에 자동으로 적혀요(아직 안 굴렸으면 먼저 굴려요). 기권하면 남은 칸은 모두 0점이 돼요.
</p>
</div>
);
}
const ui: GameUI<YachtView, YachtOptions> = {
Board: YachtBoard,
Side: YachtSide,
statusText,
isMyTurn,
Options,
optionsSummary,
Rules,
};
export default ui;

View 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;

View File

@@ -8,7 +8,11 @@ export const GAME_ICON: Record<string, string> = {
mahjong: '🀇', mahjong: '🀇',
yut: '🪵', yut: '🪵',
othello: '◐', othello: '◐',
yacht: '🎲',
'one-card': '♣',
rummikub: '🔢',
'davinci-code': '🔐',
}; };
/** 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 = ['마작', '고스톱', '뱅', '할리갈리'];

View File

@@ -562,9 +562,11 @@ function ResultPanel({ room, me, players, onLeave }: { room: RoomView; me: Publi
const seated = room.seats.filter(Boolean).map((s) => s!.user.id); const seated = room.seats.filter(Boolean).map((s) => s!.user.id);
const amSeated = seated.includes(me.id); const amSeated = seated.includes(me.id);
const voted = room.rematchVotes.includes(me.id); const voted = room.rematchVotes.includes(me.id);
const allFirst = result.ranking.length === 1; // Solo games (e.g. 요트 다이스) have one ranked player: neither a draw nor a win.
const won = !allFirst && result.ranking[0]?.includes(me.id); const solo = result.ranking.flat().length === 1;
const lost = !allFirst && !won && result.ranking.some((g) => g.includes(me.id)); const allFirst = !solo && result.ranking.length === 1;
const won = !solo && !allFirst && result.ranking[0]?.includes(me.id);
const lost = !solo && !allFirst && !won && result.ranking.some((g) => g.includes(me.id));
useEffect(() => { useEffect(() => {
if (played.current) return; if (played.current) return;
played.current = true; played.current = true;

View File

@@ -38,6 +38,10 @@
## M4. 가족 게임 ## M4. 가족 게임
- 윷놀이, 원카드(+색깔 카드 모드), 숫자 타일(루미큐브 방식), 다빈치 코드, 요트 다이스. - 윷놀이, 원카드(+색깔 카드 모드), 숫자 타일(루미큐브 방식), 다빈치 코드, 요트 다이스.
- **진행 결과(2026-10-05)**: 다섯 게임 규칙 + 화면 완료. 규칙 테스트 윷놀이 33·원카드 53·숫자 타일 56·다빈치 코드 31·요트 36,
게임마다 무작위 플레이 1,000판(말·카드·타일 보존, 비공개 정보 유출 없음, 모든 상태에서 시간 초과 행동 합법).
윷놀이 팀전(짝수 인원) 검사를 위해 엔진에 `playerCountError(options, count)`를 추가하고 방 시작 시 확인.
E2E: 윷놀이·요트 한 판 끝까지, 원카드 3인 60턴, 숫자 타일·다빈치 코드 여러 턴(PC 방장 + 휴대폰).
## M5. 마작·고스톱 ## M5. 마작·고스톱
- 리치 마작(4인, 3인), 점수 계산기 테스트(역·부 계산 사례 100개 이상), 초보 모드. - 리치 마작(4인, 3인), 점수 계산기 테스트(역·부 계산 사례 100개 이상), 초보 모드.

View File

@@ -19,6 +19,8 @@ mkdirSync(SHOTS, { recursive: true });
interface Plan { interface Plan {
/** Total players including the host (default 2). Extra players join on phones. */ /** Total players including the host (default 2). Extra players join on phones. */
players?: number; players?: number;
/** Host changes room options before starting (inline settings form on PC). */
lobby?: (host: Page) => Promise<void>;
/** Steps run before normal moves (e.g. janggi formation choice). */ /** Steps run before normal moves (e.g. janggi formation choice). */
setup?: (host: Page, guest: Page) => Promise<void>; setup?: (host: Page, guest: Page) => Promise<void>;
/** Board games: key sequences on the grid, in turn order. */ /** Board games: key sequences on the grid, in turn order. */
@@ -53,12 +55,295 @@ 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 });
} }
/** 숫자 타일: whoever has the turn tries "도와줘" → "새 묶음" → "확정"; otherwise takes one tile. */
async function rummikubTurns(pages: Page[], turns: number) {
const stats = { plays: 0, draws: 0, undo: false };
const myTurn = (p: Page) => p.evaluate(() => !!document.querySelector('.turn-status')?.textContent?.includes('내 차례예요'));
const bar = (p: Page) => p.getByRole('group', { name: '내 차례 행동' });
/** Waits until a bar button is enabled / disabled (server answered). */
const barButton = (p: Page, name: string, enabled: boolean) =>
p.waitForFunction(
([n, e]) => [...document.querySelectorAll('.rk-bar button')].some((b) => b.textContent === n && b.hasAttribute('disabled') !== e),
[name, enabled] as const,
{ timeout: 5000 },
);
for (let n = 0; n < turns; n++) {
let mover: Page | null = null;
for (let t = 0; t < 150 && !mover; t++) {
for (const p of pages) if (!mover && (await myTurn(p))) mover = p;
if (!mover) await pages[0]!.waitForTimeout(100);
}
if (!mover) throw new Error(`nobody has the turn (turn ${n + 1})`);
await bar(mover).waitFor();
let played = false;
await bar(mover).getByRole('button', { name: '도와줘' }).click();
const picked = await mover.locator('.rk-rack button[aria-pressed="true"]').count();
if (picked > 0) {
// Tap one picked tile twice (deselect, select again) — the tap-to-select path.
const first = mover.locator('.rk-rack button[aria-pressed="true"]').first();
const label = await first.getAttribute('aria-label');
await first.click();
await mover.locator(`.rk-rack button[aria-label="${label}"]`).first().click();
await bar(mover).getByRole('button', { name: '새 묶음' }).click();
await mover.locator('.rk-set').last().waitFor();
if (!stats.undo) {
// Undo once (back to the turn start), then place again.
await barButton(mover, '되돌리기', true);
await bar(mover).getByRole('button', { name: '되돌리기' }).click();
await barButton(mover, '처음으로', false);
stats.undo = true;
await bar(mover).getByRole('button', { name: '도와줘' }).click();
await bar(mover).getByRole('button', { name: '새 묶음' }).click();
}
const commit = bar(mover).getByRole('button', { name: '확정' });
await mover
.waitForFunction(() => [...document.querySelectorAll('.rk-bar button')].some((b) => b.textContent === '확정' && b.classList.contains('btn-primary') && !b.hasAttribute('disabled')), null, { timeout: 5000 })
.then(
() => (played = true),
() => {},
);
if (played) {
await commit.click();
stats.plays++;
} else {
await bar(mover).getByRole('button', { name: '처음으로' }).click();
await mover.waitForTimeout(300);
}
}
if (!played) {
await bar(mover).getByRole('button', { name: /^(한 장 가져오기|넘기기)$/ }).click();
const sheet = mover.getByRole('button', { name: /^되돌리고/ });
if (await sheet.count()) await sheet.click();
stats.draws++;
}
await mover.waitForFunction(() => !document.querySelector('.turn-status')?.textContent?.includes('내 차례예요'), null, { timeout: 5000 });
}
console.log(` rummikub: ${stats.plays} plays, ${stats.draws} draws, undo ${stats.undo ? 'used' : 'not used'}`);
return stats;
}
/**
* 윷놀이: 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> = {
rummikub: {
// PC host + phone guest, first meld "없음" so hint plays happen early; 10 real turns through the UI.
lobby: async (host) => {
await host.getByRole('group', { name: '첫 등록 최소 점수' }).getByRole('button', { name: '없음' }).click();
await host.getByRole('button', { name: '설정 저장' }).click();
await host.getByText('첫 등록 점수 없음').first().waitFor();
},
drive: async (pages) => {
const stats = await rummikubTurns(pages, 10);
if (stats.plays + stats.draws < 10) throw new Error('not enough turns');
},
},
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`);
},
},
'one-card': {
// 3 players, default 원카드: whoever has the turn plays a highlighted card (tap to lift, tap again to play,
// pick a suit for 7) or draws; the 원카드 button is pressed whenever it lights up. 60 turns or until the game ends.
players: 3,
drive: async (pages) => {
const statusOf = (p: Page) => p.locator('.turn-status').innerText().catch(() => '');
let turns = 0;
let plays = 0;
let draws = 0;
let finished = false;
for (let t = 0; t < 600 && turns < 60; t++) {
if (await pages[0]!.getByRole('dialog', { name: '게임 결과' }).count()) {
finished = true;
break;
}
let acted = false;
for (const p of pages) {
const one = p.locator('.oc-onebtn');
if ((await one.count()) && (await one.isEnabled().catch(() => false))) await one.click({ timeout: 1000 }).catch(() => {});
const before = await statusOf(p);
if (!before.includes('내 차례예요')) continue;
const accept = p.getByRole('button', { name: /^받아들이기/ });
const card = p.locator('button.oc-card[data-legal="true"]').first();
if (await accept.count()) await accept.click();
else if (await card.count()) {
await card.click();
await card.click();
const pick = p.locator('.oc-pick button').first();
if (await pick.waitFor({ timeout: 400 }).then(() => true, () => false)) await pick.click();
plays++;
} else {
const pass = p.getByRole('button', { name: '넘기기' });
if (await pass.count()) await pass.click();
else await p.getByRole('button', { name: /장 먹기$/ }).first().click();
draws++;
}
await p.waitForFunction((s) => document.querySelector('.turn-status')?.textContent?.trim() !== s, before.trim(), { timeout: 5000 }).catch(() => {});
turns++;
acted = true;
break;
}
if (!acted) await pages[0]!.waitForTimeout(150);
}
if (!finished && turns < 30) throw new Error(`only ${turns} turns were played`);
if (finished) await pages[0]!.screenshot({ path: join(SHOTS, 'one-card-3-result-pc.png') });
console.log(` one-card: ${turns} turns (${plays} plays, ${draws} draws)${finished ? ', game finished' : ''}`);
},
},
'davinci-code': {
// 2 players, default options (choose colours): both pick 2+2 start tiles, then several real turns:
// draw from a pile, tap an opponent's hidden tile, pick a number, confirm; after a hit press "여기서 멈추기".
drive: async (pages) => {
for (const p of pages) {
await p.getByRole('button', { name: '검정 2개 · 흰색 2개' }).click();
await p.getByRole('button', { name: '이렇게 받기' }).click();
}
const myTurn = (p: Page) => p.evaluate(() => !!document.querySelector('.turn-status')?.textContent?.includes('내 차례예요'));
for (let turn = 0; turn < 5; turn++) {
let mover: Page | null = null;
for (let t = 0; t < 100 && !mover; t++) {
for (const p of pages) if (!mover && (await myTurn(p))) mover = p;
if (!mover) await pages[0]!.waitForTimeout(100);
}
if (!mover) throw new Error(`turn ${turn + 1}: nobody has the turn`);
const pile = mover.getByRole('button', { name: turn % 2 ? /^흰색 타일 가져오기/ : /^검은색 타일 가져오기/ });
await pile.click();
await mover.getByText('맞힐 상대 타일을 눌러 주세요.').waitFor();
await mover.locator('.dv-opps .dv-pick.can').nth(turn % 3).click();
await mover.getByRole('button', { name: String((turn * 5) % 12), exact: true }).click();
if (turn < 2) await mover.screenshot({ path: join(SHOTS, `davinci-code-3-pad-${mover === pages[0] ? 'pc' : 'phone'}.png`), fullPage: true });
await mover.getByRole('button', { name: /이 숫자!$/ }).click();
// A hit → stop (the drawn tile goes in hidden); a miss ends the turn by itself.
for (let t = 0; t < 60; t++) {
const stop = mover.getByRole('button', { name: '여기서 멈추기' });
if (await stop.isVisible().catch(() => false)) await stop.click().catch(() => {});
if (!(await myTurn(mover))) break;
await mover.waitForTimeout(100);
}
if (await myTurn(mover)) throw new Error(`turn ${turn + 1}: turn did not pass`);
}
// Both screens show the guess log with every guess.
for (const p of pages) await p.getByText('추리 기록 (5)').waitFor();
},
},
yacht: {
// PC host + phone guest play a whole 12-round game through the UI: roll, sometimes hold + re-roll, then
// write the best open category (phone uses touch taps, so the confirm sheet path is exercised too).
drive: async (pages) => {
const myTurn = (p: Page) => p.evaluate(() => !!document.querySelector('.turn-status')?.textContent?.includes('내 차례예요'));
const shot = new Set<string>();
for (let turn = 0; turn < 24; turn++) {
let mover: Page | null = null;
for (let t = 0; t < 150 && !mover; t++) {
for (const p of pages) if (!mover && (await myTurn(p))) mover = p;
if (!mover) await pages[0]!.waitForTimeout(100);
}
if (!mover) throw new Error(`nobody has the turn (turn ${turn + 1})`);
const phone = mover !== pages[0];
const press = (l: ReturnType<Page['locator']>) => (phone ? l.tap() : l.click());
await press(mover.getByRole('button', { name: /^🎲 굴리기/ }));
await mover.locator('.yt-die[aria-pressed]').first().waitFor();
if (turn % 3 !== 2) {
// Hold the first die, check it shows as held, then roll the rest again.
await press(mover.locator('.yt-die').first());
await mover.locator('.yt-die').first().and(mover.locator('[aria-pressed="true"]')).waitFor();
await press(mover.getByRole('button', { name: /^🎲 굴리기 \(남은 2번\)/ }));
await mover.getByRole('button', { name: /^🎲 굴리기 \(남은 1번\)/ }).waitFor();
await mover.waitForTimeout(700); // roll animation
}
const cells = mover.locator('button.yt-cell:not([disabled])');
await cells.first().waitFor();
// Mid-game screenshots of a turn in progress (held die + preview cells), light and dark.
const tag = phone ? 'phone' : 'pc';
if (turn >= 8 && !shot.has(tag)) {
shot.add(tag);
await mover.screenshot({ path: join(SHOTS, `yacht-mid-${tag}.png`), fullPage: phone });
await mover.emulateMedia({ colorScheme: 'dark' });
await mover.waitForTimeout(200);
await mover.screenshot({ path: join(SHOTS, `yacht-mid-${tag}-dark.png`), fullPage: phone });
await mover.emulateMedia({ colorScheme: 'light' });
}
const values = await cells.allInnerTexts();
let best = 0;
values.forEach((v, i) => Number(v) > Number(values[best]) && (best = i));
await press(cells.nth(best));
const confirm = mover.getByRole('button', { name: '기록하기', exact: true });
if (await confirm.isVisible({ timeout: 600 }).catch(() => false)) await confirm.click();
await mover.waitForFunction(() => !document.querySelector('.turn-status')?.textContent?.includes('내 차례예요'), null, { timeout: 5000 });
}
for (const p of pages) await p.getByRole('dialog', { name: '게임 결과' }).waitFor({ timeout: 10_000 });
const sheet = await pages[0]!.locator('.yt-table tr.yt-total').innerText();
if (!/\d/.test(sheet)) throw new Error('no totals on the score sheet');
},
},
seotda: { seotda: {
// 3 players, default 2장 섯다 (1장씩 두 번): everyone checks both betting rounds → showdown. // 3 players, default 2장 섯다 (1장씩 두 번): everyone checks both betting rounds → showdown.
// A tie (or 구사) starts a 재경기 with the same pot, so keep checking until the hand result shows up.
players: 3, players: 3,
drive: async (pages) => { drive: async (pages) => {
await pressActions(pages, /^(체크|콜)/, 6); await pressActions(pages, /^(체크|콜)/, 6);
for (let extra = 0; extra < 30 && !(await pages[0]!.getByText('이번 판 결과').count()); extra++) {
await pressActions(pages, /^(체크|콜)/, 1).catch(() => {});
}
await finishHand(pages); await finishHand(pages);
}, },
}, },
@@ -161,6 +446,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();

View File

@@ -10,7 +10,7 @@
"dev": "bun run --filter '@bg/server' dev & bun run --filter '@bg/web' dev", "dev": "bun run --filter '@bg/server' dev & bun run --filter '@bg/web' dev",
"build": "bun run --filter '@bg/web' build", "build": "bun run --filter '@bg/web' build",
"typecheck": "tsc -p packages/engine && tsc -p packages/shared && tsc -p packages/games && tsc -p apps/server && tsc -p apps/web", "typecheck": "tsc -p packages/engine && tsc -p packages/shared && tsc -p packages/games && tsc -p apps/server && tsc -p apps/web",
"test": "bun test" "test": "bun test --timeout 60000"
}, },
"devDependencies": { "devDependencies": {
"@types/bun": "1.4.2", "@types/bun": "1.4.2",

View File

@@ -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[] };

View File

@@ -5,6 +5,7 @@
"type": "module", "type": "module",
"exports": { "exports": {
".": "./src/index.ts", ".": "./src/index.ts",
"./rummikub/rules": "./src/rummikub/rules.ts",
"./*": "./src/*/index.ts" "./*": "./src/*/index.ts"
}, },
"dependencies": { "dependencies": {

View File

@@ -0,0 +1,634 @@
import { describe, expect, test } from 'bun:test';
import { SeededRng, randomPlayout, seedN, type GameEvent } from '@bg/engine';
import {
allTileIds,
autoAction,
candidatesFor,
davinci,
insertIndex,
sortKey,
tileOf,
type DavinciAction,
type DavinciOptions,
type DavinciState,
type DavinciView,
} from './index';
// ---------------------------------------------------------------- helpers
let clock = 1_000;
const rng = () => SeededRng.fromSeed([7, 7, 7, 7]);
function setup(opts: Partial<DavinciOptions> = {}, players = ['A', 'B'], seed = 1): DavinciState {
return davinci.setup({ players, options: davinci.optionsSchema.parse(opts), rng: SeededRng.fromSeed(seedN(seed)), now: 0 });
}
/**
* A game already in play with the given rows (left → right as written, all hidden), `first` to move
* and the pool fronts as given (rest of the tiles follow in id order).
*/
function rigged(
rows: Record<string, string[]>,
opts: Partial<DavinciOptions> = {},
pool: { black?: string[]; white?: string[] } = {},
first?: string,
): DavinciState {
const players = Object.keys(rows);
const s = setup({ chooseColors: true, ...opts }, players);
const used = new Set([...Object.values(rows).flat(), ...(pool.black ?? []), ...(pool.white ?? [])]);
for (const p of players) s.rows[p] = rows[p]!.map((id) => ({ id, revealed: false, wrong: [] }));
const rest = allTileIds(s.options.jokers).filter((id) => !used.has(id));
s.pool = {
black: [...(pool.black ?? []), ...rest.filter((id) => tileOf(id).color === 'black')],
white: [...(pool.white ?? []), ...rest.filter((id) => tileOf(id).color === 'white')],
};
s.jokerReserve = [];
s.phase = s.pool.black.length + s.pool.white.length ? { kind: 'draw' } : { kind: 'guess' };
s.current = first ?? players[0]!;
return s;
}
function actE(s: DavinciState, p: string, a: DavinciAction): { state: DavinciState; events: GameEvent[] } {
const v = davinci.validate(s, p, a);
if (!v.ok) throw new Error(`${p} ${JSON.stringify(a)}: ${v.reason}`);
return davinci.apply(s, p, a, { rng: rng(), now: (clock += 1000) });
}
const act = (s: DavinciState, p: string, a: DavinciAction) => actE(s, p, a).state;
const reject = (s: DavinciState, p: string, a: DavinciAction) => {
const v = davinci.validate(s, p, a);
return v.ok ? null : v.reason;
};
const ids = (s: DavinciState, p: string) => s.rows[p]!.map((t) => t.id);
const shown = (s: DavinciState, p: string) => s.rows[p]!.map((t) => t.revealed);
const guess = (target: string, index: number, value: number | 'joker'): DavinciAction => ({ type: 'guess', target, index, value });
/** Non-joker tiles of every row are in order; every tile exists exactly once. */
function checkInvariants(st: unknown): void {
const s = st as DavinciState;
const all = [...Object.values(s.rows).flat().map((t) => t.id), ...s.pool.black, ...s.pool.white, ...s.jokerReserve, ...(s.pending ? [s.pending] : [])];
const expected = allTileIds(s.options.jokers);
if (all.length !== expected.length || new Set(all).size !== all.length || !expected.every((id) => all.includes(id))) {
throw new Error(`tiles not conserved: ${all.length}/${expected.length}`);
}
for (const p of s.seats) {
const keys = s.rows[p]!.map((t) => tileOf(t.id)).filter((t) => t.n !== 'joker').map((t) => sortKey(t.color, t.n as number, s.options.tieOrder));
for (let i = 1; i < keys.length; i++) if (keys[i - 1]! >= keys[i]!) throw new Error(`row of ${p} not sorted: ${s.rows[p]!.map((t) => t.id)}`);
if (s.phase.kind !== 'chooseStart' && s.rows[p]!.length > 0) {
const hidden = s.rows[p]!.some((t) => !t.revealed);
if (s.alive[p] !== hidden && !s.left[p]) throw new Error(`alive flag of ${p} does not match hidden tiles`);
}
}
if (s.pool.black.some((id) => tileOf(id).color !== 'black') || s.pool.white.some((id) => tileOf(id).color !== 'white')) throw new Error('pool colour mixed');
}
/** Hidden tiles of others must be `{ color, revealed: false, wrong[, candidates] }` only. */
function checkViewShape(s: DavinciState, v: DavinciView, viewer: string | null): void {
for (const p of s.seats) {
v.rows[p]!.forEach((t, i) => {
const real = s.rows[p]![i]!;
if (t.color !== tileOf(real.id).color || t.revealed !== real.revealed) throw new Error('view row mismatch');
if (s.phase.kind === 'finished' || real.revealed || p === viewer) {
if (t.n !== tileOf(real.id).n) throw new Error('known tile without number');
} else {
const keys = Object.keys(t).sort().join(',');
if (keys !== 'color,revealed,wrong' && keys !== 'candidates,color,revealed,wrong') throw new Error(`hidden tile leaks fields: ${keys}`);
}
});
}
if (v.pending && s.current !== viewer && ('n' in v.pending || 'id' in v.pending)) throw new Error('pending tile leaks');
}
// ---------------------------------------------------------------- setup
describe('셋업 (§4)', () => {
test('2·3인 4개, 4인 3개; 시작 타일에 조커 없음; 같은 시드 = 같은 분배', () => {
for (const [n, k] of [[2, 4], [3, 4], [4, 3]] as const) {
const players = ['A', 'B', 'C', 'D'].slice(0, n);
for (let seed = 1; seed <= 40; seed++) {
const s = setup({ chooseColors: false, jokers: true }, players, seed);
for (const p of players) {
expect(s.rows[p]).toHaveLength(k);
expect(s.rows[p]!.some((t) => tileOf(t.id).n === 'joker')).toBe(false);
}
expect([...s.pool.black, ...s.pool.white].filter((id) => id.endsWith('J')).sort()).toEqual(['bJ', 'wJ']);
checkInvariants(s);
}
}
expect(JSON.stringify(setup({ chooseColors: false }, ['A', 'B', 'C'], 9))).toBe(JSON.stringify(setup({ chooseColors: false }, ['A', 'B', 'C'], 9)));
expect(setup({ startTiles: 5 }, ['A', 'B', 'C', 'D']).startCount).toBe(5);
});
test('chooseColors=on: 동시에 색을 고르고, 다 고르면 분배; 조커는 분배 뒤 더미에 섞임', () => {
let s = setup({ jokers: true }, ['A', 'B', 'C']);
expect(s.phase.kind).toBe('chooseStart');
expect(davinci.activePlayers(s)).toEqual(['A', 'B', 'C']);
for (const p of ['A', 'B', 'C']) s = act(s, p, { type: 'chooseStart', black: 1, white: 3 });
expect(s.phase.kind).toBe('draw');
for (const p of ['A', 'B', 'C']) {
expect(s.rows[p]!.map((t) => tileOf(t.id).color).filter((c) => c === 'black')).toHaveLength(1);
expect(s.rows[p]!.some((t) => t.id.endsWith('J'))).toBe(false);
}
expect(s.pool.black).toContain('bJ');
expect(s.pool.white).toContain('wJ');
checkInvariants(s);
});
});
describe('chooseStart (§8.2)', () => {
test('3인이 모두 흰4 → 12개로 충분', () => {
let s = setup({}, ['A', 'B', 'C']);
for (const p of ['A', 'B', 'C']) s = act(s, p, { type: 'chooseStart', black: 0, white: 4 });
expect(s.pool.white).toHaveLength(0);
expect(s.phase.kind).toBe('draw');
expect(Object.values(s.rows).flat().every((t) => tileOf(t.id).color === 'white')).toBe(true);
});
test('4인이 모두 검3 → 성공, 검정이 0개 남음; 남은 수를 넘는 선택은 거부', () => {
let s = setup({}, ['A', 'B', 'C', 'D']);
for (const p of ['A', 'B', 'C']) s = act(s, p, { type: 'chooseStart', black: 3, white: 0 });
expect(davinci.view(s, 'D').pickLimits).toEqual({ black: 3, white: 12 });
s = act(s, 'D', { type: 'chooseStart', black: 3, white: 0 });
expect(s.pool.black).toHaveLength(0);
expect(davinci.view(s, 'A').pool).toEqual({ black: 0, white: 12 });
expect(reject(s, s.current, { type: 'draw', color: 'black' })).toBe('검은색 타일이 더 이상 없어요.');
let t = setup({ startTiles: 5 }, ['A', 'B', 'C']);
t = act(t, 'A', { type: 'chooseStart', black: 5, white: 0 });
t = act(t, 'B', { type: 'chooseStart', black: 5, white: 0 });
expect(reject(t, 'C', { type: 'chooseStart', black: 3, white: 2 })).toBe('검은색은 2개까지 고를 수 있어요.');
expect(reject(t, 'C', { type: 'chooseStart', black: 2, white: 2 })).toBe('시작 타일 5개를 골라 주세요.');
expect(reject(t, 'A', { type: 'chooseStart', black: 0, white: 5 })).toBe('이미 골랐어요.');
expect(reject(t, 'C', { type: 'draw', color: 'white' })).toBe('먼저 시작 타일을 골라 주세요.');
});
test('다른 사람의 선택은 제출 여부만 보임; 시간 초과는 반반(홀수면 검정 하나 더)', () => {
let s = setup({ startTiles: 3 }, ['A', 'B']);
s = act(s, 'A', { type: 'chooseStart', black: 3, white: 0 });
const vb = davinci.view(s, 'B');
expect(vb.picked).toEqual({ A: true, B: false });
expect(vb.myPick).toBeNull();
expect(JSON.stringify(davinci.view(s, null))).not.toContain('"black":3,"white":0');
expect(davinci.view(s, 'A').myPick).toEqual({ black: 3, white: 0 });
expect(autoAction(s, 'B')).toEqual({ type: 'chooseStart', black: 2, white: 1 });
// The start choice has one shared deadline (15 s), not restarted by each pick.
const fresh = setup({}, ['A', 'B']);
expect(fresh.deadlineAt).toBe(15_000);
expect(davinci.apply(fresh, 'A', { type: 'chooseStart', black: 2, white: 2 }, { rng: rng(), now: 5000 }).state.deadlineAt).toBe(15_000);
// Not enough black left → the rest white.
let t = setup({ startTiles: 5 }, ['A', 'B', 'C']);
t = act(t, 'A', { type: 'chooseStart', black: 5, white: 0 });
t = act(t, 'B', { type: 'chooseStart', black: 5, white: 0 });
expect(autoAction(t, 'C')).toEqual({ type: 'chooseStart', black: 2, white: 3 });
t = act(t, 'C', { type: 'timeout' });
expect(t.rows.C!.map((x) => tileOf(x.id).color).sort()).toEqual(['black', 'black', 'white', 'white', 'white']);
});
});
// ---------------------------------------------------------------- ordering
describe('정렬 규칙 (§5.1)', () => {
function dealt(order: DavinciOptions['tieOrder']) {
let s = setup({ tieOrder: order, startTiles: 3 }, ['A', 'B']);
s.pool.black = ['b5', 'b2', ...s.pool.black.filter((x) => x !== 'b5' && x !== 'b2')];
s.pool.white = ['w5', ...s.pool.white.filter((x) => x !== 'w5')];
s = act(s, 'B', { type: 'chooseStart', black: 0, white: 3 });
s = act(s, 'A', { type: 'chooseStart', black: 2, white: 1 });
return ids(s, 'A');
}
test('[흰5, 검5, 검2] → [검2, 검5, 흰5]; white-left → [검2, 흰5, 검5]', () => {
expect(dealt('black-left')).toEqual(['b2', 'b5', 'w5']);
expect(dealt('white-left')).toEqual(['b2', 'w5', 'b5']);
});
test('insertIndex: 새 타일은 정렬 위치, 조커는 건너뛰고 제자리', () => {
const row = (xs: string[]) => xs.map(tileOf);
expect(insertIndex(row(['b2', 'w5', 'b9']), 'black', 5, 'black-left')).toBe(1);
expect(insertIndex(row(['b2', 'w5', 'b9']), 'black', 5, 'white-left')).toBe(2);
expect(insertIndex(row(['b2', 'w5']), 'white', 0, 'black-left')).toBe(0);
expect(insertIndex(row(['b2', 'w5']), 'white', 11, 'black-left')).toBe(2);
// [2, J, 7] + 5 → left of the joker: [2, 5, J, 7]
expect(insertIndex(row(['b2', 'bJ', 'w7']), 'white', 5, 'black-left')).toBe(1);
expect(insertIndex(row(['bJ', 'w7']), 'white', 9, 'black-left')).toBe(2);
});
test('새 타일은 끼워지고 기존 타일 위치(순서)는 바뀌지 않음', () => {
let s = rigged({ A: ['b1', 'w4', 'b8', 'w10'], B: ['b0', 'w2', 'b6', 'w9'] }, {}, { white: ['w7'] });
s = act(s, 'A', { type: 'draw', color: 'white' });
s = act(s, 'A', guess('B', 0, 3));
expect(ids(s, 'A')).toEqual(['b1', 'w4', 'w7', 'b8', 'w10']);
expect(s.lastPlaced).toEqual({ player: 'A', index: 2, revealed: true });
});
});
// ---------------------------------------------------------------- turns
describe('턴 진행 (§5.2)', () => {
test('맞힘 → 계속 → 틀림: 맞힌 타일은 공개된 채, 이번 턴 타일은 앞면으로 들어감', () => {
let s = rigged({ A: ['b1', 'w4', 'b8', 'w10'], B: ['b0', 'w3', 'b6', 'w11'] }, {}, { white: ['w9'] });
const r = actE(s, 'A', { type: 'draw', color: 'white' });
expect(r.events).toEqual([{ type: 'drawn', player: 'A', color: 'white' }]);
s = r.state;
expect(davinci.view(s, 'B').pending).toEqual({ color: 'white' });
expect(davinci.view(s, 'A').pending).toEqual({ color: 'white', n: 9, id: 'w9' });
s = act(s, 'A', guess('B', 1, 3));
expect(s.phase.kind).toBe('afterCorrect');
expect(shown(s, 'B')).toEqual([false, true, false, false]);
s = act(s, 'A', { type: 'continue' });
expect(s.phase.kind).toBe('guess');
s = act(s, 'A', guess('B', 3, 10));
expect(shown(s, 'B')).toEqual([false, true, false, false]);
expect(ids(s, 'A')).toEqual(['b1', 'w4', 'b8', 'w9', 'w10']);
expect(shown(s, 'A')).toEqual([false, false, false, true, false]);
expect(s.rows.B![3]!.wrong).toEqual([10]);
expect([s.current, s.phase.kind]).toEqual(['B', 'draw']);
expect(s.history.map((h) => [h.by, h.target, h.index, h.said, h.correct])).toEqual([
['A', 'B', 1, 3, true],
['A', 'B', 3, 10, false],
]);
expect(s.stats.A).toEqual({ guesses: 2, correct: 1 });
checkInvariants(s);
});
test('맞힘 → 멈춤: 이번 턴 타일은 숨긴 채로; 상대 view에는 색만', () => {
let s = rigged({ A: ['b1', 'w4', 'b8', 'w10'], B: ['b0', 'w3', 'b6', 'w11'] }, {}, { black: ['b5'] });
s = act(s, 'A', { type: 'draw', color: 'black' });
s = act(s, 'A', guess('B', 0, 0));
const r = actE(s, 'A', { type: 'stop' });
s = r.state;
expect(ids(s, 'A')).toEqual(['b1', 'w4', 'b5', 'b8', 'w10']);
expect(shown(s, 'A')).toEqual([false, false, false, false, false]);
expect(r.events.find((e) => e.type === 'placed')).toEqual({ type: 'placed', player: 'A', index: 2, revealed: false, color: 'black' });
for (const viewer of ['B', null]) {
const v = davinci.view(s, viewer);
expect(v.rows.A![2]).toEqual({ color: 'black', revealed: false, wrong: [] });
expect(JSON.stringify(v)).not.toContain('"b5"');
checkViewShape(s, v, viewer);
}
expect(davinci.view(s, 'A').rows.A![2]).toMatchObject({ n: 5, id: 'b5', revealed: false });
expect(s.current).toBe('B');
});
test('마지막 숨은 타일을 맞히면 즉시 탈락; 2인이면 바로 끝(afterCorrect 없음)', () => {
let s = rigged({ A: ['b1', 'w4', 'b8', 'w10'], B: ['b0', 'w3', 'b6', 'w11'] }, {}, { black: ['b5'] });
for (let i = 0; i < 3; i++) s.rows.B![i]!.revealed = true;
s = act(s, 'A', { type: 'draw', color: 'black' });
const r = actE(s, 'A', guess('B', 3, 11));
s = r.state;
expect(s.phase.kind).toBe('finished');
expect(s.alive.B).toBe(false);
expect(r.events.map((e) => e.type)).toEqual(['guessed', 'revealed', 'eliminated', 'placed', 'gameEnded']);
expect(davinci.result(s)).toMatchObject({ ranking: [['A'], ['B']], reason: 'normal' });
expect(davinci.result(s)!.summary).toContain('숨은 타일 5개가 끝까지 풀리지 않아 이겼어요');
// finalRows: everything is public after the game.
const v = davinci.view(s, 'B');
expect(v.rows.A!.map((t) => t.n)).toEqual([1, 4, 5, 8, 10]);
expect(davinci.secretsOf!(s, 'A').filter((x) => ids(s, 'A').includes(x))).toEqual([]);
expect(davinci.activePlayers(s)).toEqual([]);
expect(davinci.deadline!(s)).toBeNull();
});
test('3인: 한 명이 탈락해도 계속; 순위는 늦게 탈락한 순', () => {
let s = rigged({ A: ['b1', 'w4', 'b8'], B: ['b0', 'w3', 'b6'], C: ['w0', 'b2', 'w5'] }, { startTiles: 3 }, { black: ['b5'] });
for (let i = 0; i < 2; i++) s.rows.B![i]!.revealed = true;
s = act(s, 'A', { type: 'draw', color: 'black' });
s = act(s, 'A', guess('B', 2, 6));
expect([s.alive.B, s.phase.kind]).toEqual([false, 'afterCorrect']);
expect(reject(s, 'A', guess('B', 0, 0))).toBe('지금은 추리할 때가 아니에요.');
s = act(s, 'A', { type: 'continue' });
expect(reject(s, 'A', guess('B', 2, 6))).toBe('이미 탈락한 사람의 타일이에요.');
s.rows.C![0]!.revealed = true;
s.rows.C![1]!.revealed = true;
s = act(s, 'A', guess('C', 2, 5));
expect(s.phase.kind).toBe('finished');
expect(davinci.result(s)!.ranking).toEqual([['A'], ['C'], ['B']]);
});
test('더미가 빈 뒤: draw 생략, 틀리면 revealOwn, 마지막 숨은 타일 공개 시 본인 탈락', () => {
const all = allTileIds(false);
const A = ['b1', 'w4'];
const B = all.filter((x) => !A.includes(x));
let s = rigged({ A, B: B.slice(0, 11), C: B.slice(11) }, {}, {}, 'A');
expect(s.pool.black.length + s.pool.white.length).toBe(0);
expect(s.phase.kind).toBe('guess');
s.rows.A![0]!.revealed = true;
s = act(s, 'A', guess('B', 0, 11));
expect(s.phase.kind).toBe('revealOwn');
expect(reject(s, 'A', { type: 'revealOwn', index: 0 })).toBe('이미 공개된 타일이에요.');
s = act(s, 'A', { type: 'revealOwn', index: 1 });
expect(s.alive.A).toBe(false);
expect(s.eliminationOrder).toEqual(['A']);
expect([s.current, s.phase.kind]).toEqual(['B', 'guess']);
checkInvariants(s);
// 2 players: self-elimination ends the game with the opponent winning.
let t = rigged({ A: ['b1', 'w4'], B: all.filter((x) => x !== 'b1' && x !== 'w4') }, {}, {}, 'A');
t.rows.A![0]!.revealed = true;
t = act(t, 'A', guess('B', 0, 11));
t = act(t, 'A', { type: 'revealOwn', index: 1 });
expect(davinci.result(t)!.ranking).toEqual([['B'], ['A']]);
});
test('emptyPoolPenalty=none: 더미가 빈 뒤 틀려도 벌칙 없이 턴 종료', () => {
const all = allTileIds(false);
let s = rigged({ A: all.slice(0, 12), B: all.slice(12) }, { emptyPoolPenalty: 'none' }, {}, 'A');
s = act(s, 'A', guess('B', 0, 5));
expect([s.current, s.phase.kind]).toEqual(['B', 'guess']);
expect(shown(s, 'A').every((x) => !x)).toBe(true);
});
});
describe('조커 (§5.1, §5.2)', () => {
test('조커를 가져와 틀리면 placeJoker에서 고른 위치에 앞면으로; 이후 정렬은 조커를 옮기지 않음', () => {
let s = rigged({ A: ['b1', 'w4', 'b8', 'w10'], B: ['b0', 'w3', 'b6', 'w11'] }, { jokers: true }, { black: ['bJ', 'b5'], white: ['w6'] });
s = act(s, 'A', { type: 'draw', color: 'black' });
expect(davinci.view(s, 'A').pending).toEqual({ color: 'black', n: 'joker', id: 'bJ' });
s = act(s, 'A', guess('B', 0, 'joker'));
expect(s.phase).toEqual({ kind: 'placeJoker', reveal: true });
expect(reject(s, 'A', { type: 'placeJoker', index: 4 })).toBeNull();
expect(reject(s, 'A', { type: 'placeJoker', index: 5 })).toBe('그 자리에는 놓을 수 없어요.');
s = act(s, 'A', { type: 'placeJoker', index: 2 });
expect(ids(s, 'A')).toEqual(['b1', 'w4', 'bJ', 'b8', 'w10']);
expect(shown(s, 'A')).toEqual([false, false, true, false, false]);
expect(davinci.view(s, 'B').rows.A![2]).toMatchObject({ n: 'joker', revealed: true });
// B's turn, then A draws b5 → it goes left of the joker, which keeps its place.
s = act(s, 'B', { type: 'draw', color: 'white' });
s = act(s, 'B', guess('A', 0, 9));
s = act(s, 'A', { type: 'draw', color: 'black' });
s = act(s, 'A', guess('B', 0, 0));
s = act(s, 'A', { type: 'stop' });
expect(ids(s, 'A')).toEqual(['b1', 'w4', 'b5', 'bJ', 'b8', 'w10']);
checkInvariants(s);
});
test('조커를 가져와 맞히고 멈추면 숨긴 채로 고른 위치에; 시간 초과는 맨 오른쪽', () => {
let s = rigged({ A: ['b1', 'w4'], B: ['b0', 'w3'] }, { jokers: true }, { white: ['wJ'] });
s = act(s, 'A', { type: 'draw', color: 'white' });
s = act(s, 'A', guess('B', 0, 0));
s = act(s, 'A', { type: 'stop' });
expect(s.phase).toEqual({ kind: 'placeJoker', reveal: false });
expect(autoAction(s, 'A')).toEqual({ type: 'placeJoker', index: 2 });
s = act(s, 'A', { type: 'timeout' });
expect(ids(s, 'A')).toEqual(['b1', 'w4', 'wJ']);
expect(shown(s, 'A')).toEqual([false, false, false]);
expect(davinci.view(s, 'B').rows.A![2]).toEqual({ color: 'white', revealed: false, wrong: [] });
});
});
describe('거부 (§8.2)', () => {
test('공개된 타일, 탈락자 타일, 자기 타일, 범위 밖 값, 조커 꺼짐, 차례 아님', () => {
let s = rigged({ A: ['b1', 'w4', 'b8'], B: ['b0', 'w3', 'b6'], C: ['w0', 'b2', 'w5'] }, { startTiles: 3 });
expect(reject(s, 'B', { type: 'draw', color: 'black' })).toBe('지금은 내 차례가 아니에요.');
expect(reject(s, 'A', { type: 'draw' })).toBe('가져올 색을 골라 주세요.');
expect(reject(s, 'A', guess('B', 0, 0))).toBe('지금은 추리할 때가 아니에요.');
s = act(s, 'A', { type: 'draw', color: 'black' });
s.rows.B![1]!.revealed = true;
s.alive.C = false;
for (const t of s.rows.C!) t.revealed = true;
expect(reject(s, 'A', guess('B', 1, 3))).toBe('이미 공개된 타일이에요.');
expect(reject(s, 'A', guess('C', 0, 0))).toBe('이미 탈락한 사람의 타일이에요.');
expect(reject(s, 'A', guess('A', 0, 1))).toBe('자기 타일은 고를 수 없어요.');
expect(reject(s, 'A', guess('B', 3, 1))).toBe('그 자리에는 타일이 없어요.');
expect(reject(s, 'A', guess('Z', 0, 1))).toBe('이 게임의 플레이어가 아니에요.');
for (const bad of [12, -1, 3.5]) expect(reject(s, 'A', guess('B', 0, bad))).toBe('0부터 11 사이 숫자를 골라 주세요.');
expect(reject(s, 'A', guess('B', 0, 'joker'))).toBe('이 방은 조커를 쓰지 않아요.');
expect(reject(s, 'A', { type: 'stop' })).toBe('맞힌 뒤에만 멈출 수 있어요.');
expect(reject(s, 'A', { type: 'continue' })).toBe('맞힌 뒤에만 계속할 수 있어요.');
expect(reject(s, 'C', { type: 'stop' })).toBe('탈락해서 더 할 수 없어요.');
expect(reject(s, 'X', { type: 'stop' })).toBe('이 게임의 플레이어가 아니에요.');
expect(reject(s, 'B', { type: 'timeout' })).toBe('시간 초과 대상이 아니에요.');
// Logically impossible guesses (already visible combination) are still allowed (§5.2).
expect(reject(s, 'A', guess('B', 0, 3))).toBeNull();
// chooseColors=off: no colour choice.
const off = rigged({ A: ['b1'], B: ['b0'] }, { chooseColors: false });
expect(reject(off, 'A', { type: 'draw', color: 'black' })).toBe('이 방은 색을 고르지 않고 무작위로 가져와요.');
expect(reject(off, 'A', { type: 'draw' })).toBeNull();
const pool = off.pool.black.length + off.pool.white.length;
const after = act(off, 'A', { type: 'draw' });
expect(after.pool.black.length + after.pool.white.length).toBe(pool - 1);
expect(after.pending).not.toBeNull();
});
});
// ---------------------------------------------------------------- timeouts
describe('시간 초과·연결 끊김 (§8.5)', () => {
test('draw: 더 많이 남은 색(같으면 검정); afterCorrect: 멈춤; revealOwn: 가장 왼쪽', () => {
const s = rigged({ A: ['b1', 'w4'], B: ['b0', 'w3'] });
expect(autoAction(s, 'A')).toEqual({ type: 'draw', color: 'black' });
s.pool.black.pop();
expect(autoAction(s, 'A')).toEqual({ type: 'draw', color: 'white' });
s.phase = { kind: 'afterCorrect' };
expect(autoAction(s, 'A')).toEqual({ type: 'stop' });
s.phase = { kind: 'revealOwn' };
s.rows.A![0]!.revealed = true;
expect(autoAction(s, 'A')).toEqual({ type: 'revealOwn', index: 1 });
});
test('guess: 숨은 타일이 가장 적은 상대의 가장 왼쪽 타일, 가능한 가장 작은 값', () => {
let s = rigged({ A: ['b0', 'w1', 'b3'], B: ['b2', 'w5', 'b7', 'w9'], C: ['w0', 'b1', 'w8', 'b10'] }, { startTiles: 3 });
s = act(s, 'A', { type: 'draw', color: 'white' }); // w2 (first white left in id order)
s.rows.C![0]!.revealed = true;
s.rows.C![1]!.revealed = true;
// C has the fewest hidden tiles (w8, b10); its leftmost hidden tile is white and right of the revealed b1,
// so it is white 1 or more; w1 and w2 (pending) are mine → 3.
expect(autoAction(s, 'A')).toEqual({ type: 'guess', target: 'C', index: 2, value: 3 });
// Same public state, different hidden numbers → same guess (the auto-guess never reads hidden numbers).
const t = structuredClone(s);
t.rows.C![2]!.id = 'w11';
t.pool.white = t.pool.white.map((x) => (x === 'w11' ? 'w8' : x));
expect(autoAction(t, 'A')).toEqual(autoAction(s, 'A'));
// Wrong guesses are remembered per tile, so repeated timeouts eventually find the number.
s = act(s, 'A', { type: 'timeout' });
expect(s.rows.C![2]!.wrong).toEqual([3]);
expect(s.history.at(-1)).toMatchObject({ by: 'A', auto: true, said: 3, correct: false });
});
test('candidatesFor: 조커가 가능하면 후보에 넣고, 범위는 공개된 숫자 타일 사이', () => {
const v = (n?: number | 'joker', revealed = false, color: 'black' | 'white' = 'black') =>
n === undefined ? { color, revealed: false, wrong: [] } : { color, revealed, n, wrong: [] };
const rows = { me: [v(4, false, 'white')], o: [v(2, true), v(), v('joker', true, 'white'), v(), v(9, true, 'white')] };
expect(candidatesFor(rows, null, 'o', 1, { jokers: false, tieOrder: 'black-left' })).toEqual([3, 4, 5, 6, 7, 8, 9]);
expect(candidatesFor(rows, null, 'o', 3, { jokers: true, tieOrder: 'black-left' })).toEqual([3, 4, 5, 6, 7, 8, 9, 'joker']);
expect(candidatesFor(rows, null, 'o', 3, { jokers: true, tieOrder: 'white-left' })).toEqual([3, 4, 5, 6, 7, 8, 'joker']);
});
test('연결이 끊긴 플레이어: 매번 시간 초과여도 게임이 끝까지 진행; 연속 3턴이면 마감 5초', () => {
for (let seed = 1; seed <= 30; seed++) {
const players = ['A', 'B', 'C'];
let s = davinci.setup({ players, options: davinci.optionsSchema.parse({ jokers: seed % 2 === 0 }), rng: SeededRng.fromSeed(seedN(seed)), now: 0 });
const r = SeededRng.fromSeed(seedN(seed + 500));
const pick = SeededRng.fromSeed(seedN(seed + 900));
let sawShort = false;
for (let step = 0; step < 3000 && s.phase.kind !== 'finished'; step++) {
const p = davinci.timeoutPlayers!(s)[0]!;
// A never acts (always times out); B and C play randomly.
const legal = davinci.legalActions!(s, p);
const a = p === 'A' ? davinci.onTimeout(s, p) : legal[pick.int(legal.length)]!;
expect(davinci.validate(s, p, a)).toEqual({ ok: true });
s = davinci.apply(s, p, a, { rng: r, now: step * 1000 }).state;
checkInvariants(s);
if (s.phase.kind !== 'finished' && s.current === 'A' && s.phase.kind !== 'chooseStart' && s.idle.A! >= 3) {
expect(s.deadlineAt! - s.phaseStartedAt).toBe(5000);
sawShort = true;
}
}
expect(s.phase.kind).toBe('finished');
if (seed === 1) expect(sawShort).toBe(true);
}
});
test('all-AFK game (everyone times out) still ends', () => {
for (let seed = 1; seed <= 20; seed++) {
for (const emptyPoolPenalty of ['reveal-own', 'none'] as const) {
let s = setup({ emptyPoolPenalty, jokers: seed % 2 === 0 }, ['A', 'B', 'C', 'D'].slice(0, 2 + (seed % 3)), seed);
const r = SeededRng.fromSeed(seedN(seed));
let step = 0;
for (; step < 5000 && s.phase.kind !== 'finished'; step++) {
const p = davinci.timeoutPlayers!(s)[0]!;
s = davinci.apply(s, p, davinci.onTimeout(s, p), { rng: r, now: step }).state;
}
expect(s.phase.kind).toBe('finished');
}
}
});
test('기권(나가기): 탈락하고 타일 공개; 차례였으면 다음 사람으로', () => {
let s = rigged({ A: ['b1', 'w4'], B: ['b0', 'w3'], C: ['w0', 'b2'] }, {}, { black: ['b5'] });
s = act(s, 'A', { type: 'draw', color: 'black' });
expect(davinci.onLeave!(s, 'A')).toEqual({ type: 'leave' });
s = act(s, 'A', { type: 'leave' });
expect([s.alive.A, s.current, s.phase.kind]).toEqual([false, 'B', 'draw']);
expect(shown(s, 'A')).toEqual([true, true, true]);
expect(ids(s, 'A')).toEqual(['b1', 'w4', 'b5']);
expect(davinci.onLeave!(s, 'A')).toBeNull();
s = act(s, 'C', { type: 'leave' });
expect(s.phase.kind).toBe('finished');
expect(davinci.result(s)).toMatchObject({ ranking: [['B'], ['C'], ['A']], reason: 'resign' });
checkInvariants(s);
// Leaving during the start choice: the rest are dealt when they have chosen.
let t = setup({}, ['A', 'B', 'C']);
t = act(t, 'A', { type: 'chooseStart', black: 2, white: 2 });
t = act(t, 'B', { type: 'leave' });
expect(t.phase.kind).toBe('chooseStart');
t = act(t, 'C', { type: 'chooseStart', black: 2, white: 2 });
expect(t.phase.kind).toBe('draw');
expect(t.rows.B).toEqual([]);
expect(t.current).not.toBe('B');
checkInvariants(t);
});
});
// ---------------------------------------------------------------- info
describe('정보 유출 (§8.3)', () => {
test('관전자·상대 view와 이벤트에 남의 숨은 숫자·ID·이번 턴 타일 숫자가 없음', () => {
for (let seed = 1; seed <= 300; seed++) {
const n = 2 + (seed % 3);
const players = Array.from({ length: n }, (_, i) => `P${i}`);
const options = davinci.optionsSchema.parse({ jokers: seed % 2 === 0, chooseColors: seed % 3 !== 0, deductionHelper: seed % 4 === 0 });
const r = SeededRng.fromSeed(seedN(seed));
const chooser = SeededRng.fromSeed(seedN(seed + 77));
let s = davinci.setup({ players, options, rng: r, now: 0 });
for (let step = 0; step < 400 && s.phase.kind !== 'finished'; step++) {
const views = new Map([...players, null].map((v) => [v, davinci.view(s, v)] as const));
for (const [viewer, v] of views) checkViewShape(s, v, viewer);
const json = new Map([...views].map(([k, v]) => [k, JSON.stringify(v)]));
for (const owner of players)
for (const secret of davinci.secretsOf!(s, owner))
for (const [viewer, j] of json) if (viewer !== owner && j.includes(JSON.stringify(secret))) throw new Error(`leak ${secret} of ${owner} to ${viewer}`);
const actives = davinci.activePlayers(s);
const actor = actives[chooser.int(actives.length)]!;
const legal = davinci.legalActions!(s, actor);
const a = chooser.int(6) === 0 ? davinci.onTimeout(s, actor) : legal[chooser.int(legal.length)]!;
const out = davinci.apply(s, actor, a, { rng: r, now: step * 1000 });
const hiddenAfter = new Set(Object.values(out.state.rows).flatMap((row) => row.filter((t) => !t.revealed).map((t) => t.id)));
if (out.state.pending) hiddenAfter.add(out.state.pending);
for (const e of out.events) {
const ej = JSON.stringify(e);
if (out.state.phase.kind !== 'finished') for (const id of hiddenAfter) if (ej.includes(JSON.stringify(id))) throw new Error(`event leaks ${id}`);
if (e.type === 'drawn' && Object.keys(e).sort().join() !== 'color,player,type') throw new Error('drawn event leaks');
if (e.type === 'placed' && !e.revealed && 'n' in e) throw new Error('placed event leaks');
}
s = out.state;
}
}
}, 30_000);
});
// ---------------------------------------------------------------- schema
describe('options & schema', () => {
test('every option has Korean metadata; defaults match the doc', () => {
const shape = (davinci.optionsSchema as unknown as { shape: Record<string, { meta(): { title?: string } | undefined }> }).shape;
for (const [k, v] of Object.entries(shape)) expect(v.meta()?.title, k).toBeTruthy();
expect(davinci.defaultOptions).toEqual({
jokers: false,
tieOrder: 'black-left',
startTiles: 'auto',
chooseColors: true,
emptyPoolPenalty: 'reveal-own',
turnSeconds: 30,
deductionHelper: false,
});
expect(davinci.optionsSchema.safeParse({ startTiles: 6 }).success).toBe(false);
expect(davinci.optionsSchema.safeParse({ turnSeconds: 25 }).success).toBe(false);
});
test('client actions only (system actions are not in the schema)', () => {
expect(davinci.actionSchema.safeParse({ type: 'timeout' }).success).toBe(false);
expect(davinci.actionSchema.safeParse({ type: 'leave' }).success).toBe(false);
expect(davinci.actionSchema.safeParse({ type: 'guess', target: 'B', index: 0, value: 'joker' }).success).toBe(true);
expect(davinci.actionSchema.safeParse({ type: 'guess', target: 'B', index: 0, value: '3' }).success).toBe(false);
});
test('deductionHelper: 상대 숨은 타일에만 후보, 끄면 없음', () => {
const s = rigged({ A: ['b1', 'w4'], B: ['b0', 'w3'] }, { deductionHelper: true });
const v = davinci.view(s, 'A');
expect(v.rows.B![0]!.candidates).toEqual([0, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11]);
expect(v.rows.A![0]!.candidates).toBeUndefined();
expect(davinci.view(rigged({ A: ['b1'], B: ['b0'] }), 'A').rows.B![0]!.candidates).toBeUndefined();
});
});
// ---------------------------------------------------------------- properties
describe('random playouts (docs/09 §7)', () => {
const cfg = (i: number): { players: string[]; options: DavinciOptions } => ({
players: Array.from({ length: 2 + (i % 3) }, (_, k) => `P${k}`),
options: davinci.optionsSchema.parse({
jokers: i % 2 === 0,
tieOrder: i % 7 === 0 ? 'white-left' : 'black-left',
startTiles: (['auto', 3, 4, 5] as const)[i % 4],
chooseColors: i % 5 !== 0,
emptyPoolPenalty: i % 6 === 0 ? 'none' : 'reveal-own',
deductionHelper: i % 9 === 0,
}),
});
const invariant = (st: unknown) => {
checkInvariants(st);
const s = st as DavinciState;
// onTimeout is legal for every timed player in every state, and so is the move it stands for.
for (const p of davinci.timeoutPlayers!(s)) {
const v = davinci.validate(s, p, davinci.onTimeout(s, p));
if (!v.ok) throw new Error(`onTimeout illegal: ${v.reason}`);
const c = davinci.validate(s, p, autoAction(s, p));
if (!c.ok) throw new Error(`auto action illegal: ${c.reason}`);
}
};
for (const [from, to] of [[1, 250], [251, 500], [501, 750], [751, 1000]] as const) {
test(`seeds ${from}–${to}: finish, conserve tiles, sorted rows, no leaks, onTimeout legal`, () => {
for (let i = from; i <= to; i++) {
const { players, options } = cfg(i);
const r = randomPlayout(davinci, { players, options, seed: seedN(i), maxSteps: 3000, invariant, maxApplyMs: 50 });
expect(r.finished).toBe(true);
const res = davinci.result(r.finalState as DavinciState)!;
expect(res.ranking.flat().sort()).toEqual([...players].sort());
if (i % 50 === 1) {
const b = randomPlayout(davinci, { players, options, seed: seedN(i), maxSteps: 3000, maxApplyMs: 50 });
expect(JSON.stringify(b.finalState)).toBe(JSON.stringify(r.finalState));
expect(b.trace).toEqual(r.trace);
}
}
}, 60_000);
}
});

View File

@@ -0,0 +1,801 @@
/**
* 다빈치 코드 (docs/games/davinci-code.md).
*
* Secret-id design: tile ids ('b5', 'wJ', ...) encode the number, so they only ever appear in a view
* for tiles the viewer may know (own tiles, revealed tiles, everything after the game ends). Other
* players' hidden tiles are sent as `{ color, revealed: false, wrong }` and addressed by position only.
*/
import { z } from 'zod';
import { fail, nextSeat, ok, type GameDefinition, type GameEvent, type GameResult, type PlayerId, type Rng, type Validation } from '@bg/engine';
export type TColor = 'black' | 'white';
export type TileValue = number | 'joker';
export const COLORS: TColor[] = ['black', 'white'];
export const COLOR_KO: Record<TColor, string> = { black: '검은색', white: '흰색' };
export const MAX_N = 11;
// ---------------------------------------------------------------- options
export const davinciOptionsSchema = z.object({
jokers: z.boolean().default(false).meta({ title: '조커 사용(검정·흰색 각 1개, 숫자 대신 "-")' }),
tieOrder: z
.enum(['black-left', 'white-left'])
.default('black-left')
.meta({ title: '같은 숫자일 때 색 순서', labels: { 'black-left': '검정이 왼쪽', 'white-left': '흰색이 왼쪽' } }),
startTiles: z
.union([z.literal('auto'), z.literal(3), z.literal(4), z.literal(5)])
.default('auto')
.meta({ title: '시작 타일 수', labels: { auto: '자동(2·3명 4개, 4명 3개)', 3: '3개', 4: '4개', 5: '5개' } }),
chooseColors: z.boolean().default(true).meta({ title: '타일을 가져올 때 색 고르기(끄면 무작위)' }),
emptyPoolPenalty: z
.enum(['reveal-own', 'none'])
.default('reveal-own')
.meta({ title: '더미가 빈 뒤 틀렸을 때', labels: { 'reveal-own': '내 숨은 타일 1개 공개', none: '벌칙 없음' } }),
turnSeconds: z
.union([z.literal(20), z.literal(30), z.literal(45), z.literal(60)])
.default(30)
.meta({ title: '추리 제한 시간', labels: { 20: '20초', 30: '30초', 45: '45초', 60: '60초' } }),
deductionHelper: z.boolean().default(false).meta({ title: '가능한 숫자 후보 표시(초보 도움)' }),
});
export type DavinciOptions = z.infer<typeof davinciOptionsSchema>;
// ---------------------------------------------------------------- actions
const colorSchema = z.enum(['black', 'white']);
export const davinciActionSchema = z.discriminatedUnion('type', [
z.object({ type: z.literal('chooseStart'), black: z.number().int().min(0).max(12), white: z.number().int().min(0).max(12) }),
z.object({ type: z.literal('draw'), color: colorSchema.optional() }),
// Value range / integer checks live in validate so the player gets a Korean reason.
z.object({ type: z.literal('guess'), target: z.string().max(64), index: z.number().int().min(0).max(40), value: z.union([z.number(), z.literal('joker')]) }),
z.object({ type: z.literal('continue') }),
z.object({ type: z.literal('stop') }),
z.object({ type: z.literal('placeJoker'), index: z.number().int().min(0).max(40) }),
z.object({ type: z.literal('revealOwn'), index: z.number().int().min(0).max(40) }),
]);
export type DavinciClientAction = z.infer<typeof davinciActionSchema>;
/** Client actions plus system-only actions (timer / leaving), which are not in the schema. */
export type DavinciAction = DavinciClientAction | { type: 'timeout' } | { type: 'leave' };
// ---------------------------------------------------------------- state
export interface RowTile {
id: string;
revealed: boolean;
/** Values already guessed wrong for this tile (public). */
wrong: TileValue[];
}
export type DavinciPhase =
| { kind: 'chooseStart'; picks: Record<PlayerId, { black: number; white: number } | null> }
| { kind: 'draw' }
| { kind: 'guess' }
| { kind: 'afterCorrect' }
| { kind: 'placeJoker'; reveal: boolean }
| { kind: 'revealOwn' }
| { kind: 'finished' };
export type PhaseKind = DavinciPhase['kind'];
export interface DavinciGuess {
by: PlayerId;
target: PlayerId;
/** 0-based position in the target's row at the time of the guess. */
index: number;
color: TColor;
said: TileValue;
correct: boolean;
auto: boolean;
}
export interface DavinciFinal {
ranking: PlayerId[][];
winner: PlayerId;
summary: string;
reason: 'normal' | 'resign';
}
export interface DavinciState {
options: DavinciOptions;
seats: PlayerId[];
startCount: number;
/** Hidden from everyone; drawn from the front. */
pool: { black: string[]; white: string[] };
/** Jokers wait here until the start tiles are dealt (start tiles never contain a joker). */
jokerReserve: string[];
rows: Record<PlayerId, RowTile[]>;
phase: DavinciPhase;
current: PlayerId;
/** This turn's drawn tile (only the drawer knows the number). */
pending: string | null;
alive: Record<PlayerId, boolean>;
left: Record<PlayerId, boolean>;
eliminationOrder: PlayerId[];
stats: Record<PlayerId, { guesses: number; correct: number }>;
history: DavinciGuess[];
lastPlaced: { player: PlayerId; index: number; revealed: boolean } | null;
turnNo: number;
/** Consecutive turns with a timeout; at 3 the deadline drops to 5 s. */
idle: Record<PlayerId, number>;
idleTurn: Record<PlayerId, number>;
phaseStartedAt: number;
deadlineAt: number | null;
seq: number;
final: DavinciFinal | null;
}
// ---------------------------------------------------------------- view
export interface DavinciTileView {
color: TColor;
revealed: boolean;
/** Present only when the viewer may know it (own tile, revealed, or game over). */
n?: TileValue;
id?: string;
wrong: TileValue[];
/** deductionHelper: values still possible from the viewer's information. */
candidates?: TileValue[];
}
export interface DavinciView {
options: DavinciOptions;
me: PlayerId | null;
seats: PlayerId[];
phase: PhaseKind;
current: PlayerId;
startCount: number;
rows: Record<PlayerId, DavinciTileView[]>;
pool: { black: number; white: number };
pending: { color: TColor; n?: TileValue; id?: string } | null;
/** chooseStart: who has submitted (others' choices stay secret). */
picked: Record<PlayerId, boolean> | null;
myPick: { black: number; white: number } | null;
/** chooseStart: how many of each colour I may still take. */
pickLimits: { black: number; white: number } | null;
alive: Record<PlayerId, boolean>;
left: Record<PlayerId, boolean>;
eliminationOrder: PlayerId[];
stats: Record<PlayerId, { guesses: number; correct: number }>;
history: DavinciGuess[];
lastPlaced: DavinciState['lastPlaced'];
idle: Record<PlayerId, number>;
deadlineAt: number | null;
final: DavinciFinal | null;
}
// ---------------------------------------------------------------- tiles
const CHOOSE_START_MS = 15_000;
const SHORT_MS = 10_000;
const IDLE_MS = 5_000;
const IDLE_TURNS = 3;
export function tileOf(id: string): { color: TColor; n: TileValue } {
const color: TColor = id[0] === 'b' ? 'black' : 'white';
const rest = id.slice(1);
return { color, n: rest === 'J' ? 'joker' : Number(rest) };
}
const tileId = (color: TColor, n: TileValue) => `${color[0]}${n === 'joker' ? 'J' : n}`;
export const allTileIds = (jokers: boolean): string[] => [
...COLORS.flatMap((c) => Array.from({ length: MAX_N + 1 }, (_, n) => tileId(c, n))),
...(jokers ? COLORS.map((c) => tileId(c, 'joker')) : []),
];
/** Sort key of a numbered tile (lower = further left). */
export function sortKey(color: TColor, n: number, tieOrder: DavinciOptions['tieOrder']): number {
const first: TColor = tieOrder === 'black-left' ? 'black' : 'white';
return n * 2 + (color === first ? 0 : 1);
}
/** Server-computed slot for a numbered tile: right after the rightmost numbered tile that must precede it (jokers keep their place). */
export function insertIndex(row: readonly { color: TColor; n: TileValue }[], color: TColor, n: number, tieOrder: DavinciOptions['tieOrder']): number {
const k = sortKey(color, n, tieOrder);
let at = 0;
row.forEach((t, i) => {
if (t.n !== 'joker' && sortKey(t.color, t.n, tieOrder) < k) at = i + 1;
});
return at;
}
export function startCountFor(o: DavinciOptions, players: number): number {
return o.startTiles === 'auto' ? (players >= 4 ? 3 : 4) : o.startTiles;
}
const hiddenCount = (row: readonly RowTile[]) => row.reduce((a, t) => a + (t.revealed ? 0 : 1), 0);
const aliveSeats = (s: DavinciState) => s.seats.filter((p) => s.alive[p]);
const isValue = (v: unknown, jokers: boolean): v is TileValue =>
(v === 'joker' && jokers) || (typeof v === 'number' && Number.isInteger(v) && v >= 0 && v <= MAX_N);
// ---------------------------------------------------------------- deduction (public + own info only)
/**
* Values the viewer can still consider for `rows[target][index]`, computed from a view only
* (revealed numbers, the viewer's own numbers, colours, positions and wrong guesses).
* Sound: the true value is always included. Used by the helper and by the timeout auto-guess.
*/
export function candidatesFor(
rows: Record<PlayerId, DavinciTileView[]>,
pending: DavinciView['pending'],
target: PlayerId,
index: number,
o: Pick<DavinciOptions, 'jokers' | 'tieOrder'>,
): TileValue[] {
const row = rows[target] ?? [];
const tile = row[index];
if (!tile || tile.n !== undefined) return [];
const known = new Set<string>();
for (const r of Object.values(rows)) for (const t of r) if (t.n !== undefined) known.add(tileId(t.color, t.n));
if (pending?.n !== undefined) known.add(tileId(pending.color, pending.n));
let lo = -Infinity;
let hi = Infinity;
for (let i = index - 1; i >= 0; i--) {
const t = row[i]!;
if (t.n !== undefined && t.n !== 'joker') {
lo = sortKey(t.color, t.n, o.tieOrder);
break;
}
}
for (let i = index + 1; i < row.length; i++) {
const t = row[i]!;
if (t.n !== undefined && t.n !== 'joker') {
hi = sortKey(t.color, t.n, o.tieOrder);
break;
}
}
const out: TileValue[] = [];
for (let n = 0; n <= MAX_N; n++) {
if (known.has(tileId(tile.color, n)) || tile.wrong.includes(n)) continue;
const k = sortKey(tile.color, n, o.tieOrder);
if (k > lo && k < hi) out.push(n);
}
if (o.jokers && !known.has(tileId(tile.color, 'joker')) && !tile.wrong.includes('joker')) out.push('joker');
return out;
}
// ---------------------------------------------------------------- helpers
function refreshDeadline(s: DavinciState, now: number, phaseChanged: boolean): void {
if (!phaseChanged) return;
s.phaseStartedAt = now;
let ms: number | null;
switch (s.phase.kind) {
case 'finished':
ms = null;
break;
case 'chooseStart':
ms = CHOOSE_START_MS;
break;
case 'guess':
ms = s.options.turnSeconds * 1000;
break;
default:
ms = SHORT_MS;
}
if (ms !== null && s.phase.kind !== 'chooseStart' && (s.idle[s.current] ?? 0) >= IDLE_TURNS) ms = IDLE_MS;
s.deadlineAt = ms === null ? null : now + ms;
}
function insertTile(s: DavinciState, p: PlayerId, id: string, revealed: boolean, events: GameEvent[], at?: number): void {
const row = s.rows[p]!;
const t = tileOf(id);
const index = at ?? (t.n === 'joker' ? row.length : insertIndex(row.map((r) => tileOf(r.id)), t.color, t.n, s.options.tieOrder));
row.splice(index, 0, { id, revealed, wrong: [] });
s.lastPlaced = { player: p, index, revealed };
events.push(revealed ? { type: 'placed', player: p, index, revealed, color: t.color, n: t.n } : { type: 'placed', player: p, index, revealed, color: t.color });
}
function eliminate(s: DavinciState, p: PlayerId, events: GameEvent[]): void {
if (!s.alive[p]) return;
s.alive[p] = false;
s.eliminationOrder.push(p);
events.push({ type: 'eliminated', player: p });
}
/** Ends the game if only one player keeps hidden tiles. Returns true when finished. */
function checkFinish(s: DavinciState, events: GameEvent[], byLeave = false): boolean {
const alive = aliveSeats(s);
if (alive.length > 1) return false;
if (s.pending) {
const owner = s.current;
if (s.rows[owner]) insertTile(s, owner, s.pending, !s.alive[owner], events);
s.pending = null;
}
const winner = alive[0] ?? s.eliminationOrder[s.eliminationOrder.length - 1]!;
const ranking: PlayerId[][] = [[winner], ...[...s.eliminationOrder].reverse().filter((p) => p !== winner).map((p) => [p])];
// Players who never got tiles (left during the start choice) are ranked last, in seat order.
for (const p of s.seats) if (!ranking.some((g) => g.includes(p))) ranking.push([p]);
const left = hiddenCount(s.rows[winner] ?? []);
const others = s.seats.length - 1;
const reason: DavinciFinal['reason'] = byLeave ? 'resign' : 'normal';
const st = s.stats[winner]!;
const summary =
reason === 'resign'
? `마지막 상대가 나가서 게임이 끝났어요. (추리 ${st.guesses}번 중 ${st.correct}번 맞힘)`
: `숨은 타일 ${left}개가 끝까지 풀리지 않아 이겼어요! (상대 ${others}명 탈락 · 추리 ${st.guesses}번 중 ${st.correct}번 맞힘)`;
s.final = { ranking, winner, summary, reason };
s.phase = { kind: 'finished' };
events.push({ type: 'gameEnded', winner });
return true;
}
function endTurn(s: DavinciState, events: GameEvent[]): void {
s.pending = null;
if (checkFinish(s, events)) return;
const i = s.seats.indexOf(s.current);
s.current = s.seats[nextSeat(i, s.seats.length, (j) => !s.alive[s.seats[j]!])]!;
s.turnNo += 1;
s.phase = s.pool.black.length + s.pool.white.length > 0 ? { kind: 'draw' } : { kind: 'guess' };
events.push({ type: 'turn', player: s.current });
}
/** After the start tiles are dealt: sort rows, shuffle the jokers into the pool, start the first turn. */
function afterDeal(s: DavinciState, rng: Rng, events: GameEvent[]): void {
for (const p of s.seats) {
s.rows[p]!.sort((a, b) => {
const x = tileOf(a.id);
const y = tileOf(b.id);
return sortKey(x.color, x.n as number, s.options.tieOrder) - sortKey(y.color, y.n as number, s.options.tieOrder);
});
}
for (const j of s.jokerReserve) {
const pile = s.pool[tileOf(j).color];
pile.splice(rng.int(pile.length + 1), 0, j);
}
s.jokerReserve = [];
if (!s.alive[s.current]) s.current = s.seats[nextSeat(s.seats.indexOf(s.current), s.seats.length, (j) => !s.alive[s.seats[j]!])]!;
s.phase = { kind: 'draw' };
events.push({ type: 'dealt', counts: Object.fromEntries(s.seats.map((p) => [p, s.rows[p]!.length])) });
events.push({ type: 'turn', player: s.current });
}
/** chooseStart: once every remaining player has chosen, deal from the front of each colour pile. */
function dealIfReady(s: DavinciState, rng: Rng, events: GameEvent[]): void {
const ph = s.phase;
if (ph.kind !== 'chooseStart' || !s.seats.every((p) => !s.alive[p] || ph.picks[p])) return;
for (const p of s.seats) {
const pick = ph.picks[p];
if (!pick || !s.alive[p]) continue;
const ids = [...s.pool.black.splice(0, pick.black), ...s.pool.white.splice(0, pick.white)];
s.rows[p] = ids.map((id) => ({ id, revealed: false, wrong: [] }));
}
afterDeal(s, rng, events);
}
function pickLimits(s: DavinciState): { black: number; white: number } {
const lim = { black: s.pool.black.length, white: s.pool.white.length };
if (s.phase.kind === 'chooseStart') {
for (const pick of Object.values(s.phase.picks)) {
if (!pick) continue;
lim.black -= pick.black;
lim.white -= pick.white;
}
}
return lim;
}
/** Timeout choice for the start: as even as possible (odd → one more black), within what is left. */
export function autoPick(s: DavinciState): { black: number; white: number } {
const n = s.startCount;
const lim = pickLimits(s);
let black = Math.min(Math.ceil(n / 2), lim.black);
let white = n - black;
if (white > lim.white) {
white = lim.white;
black = n - white;
}
return { black, white };
}
/**
* Deterministic auto-guess (§8.5) from the player's own view only: the living opponent with the fewest
* hidden tiles (ties: clockwise from me), their leftmost hidden tile, the smallest possible value.
*/
export function autoGuess(s: DavinciState, player: PlayerId): { type: 'guess'; target: PlayerId; index: number; value: TileValue } {
const v = davinci.view(s, player);
const me = v.seats.indexOf(player);
const order = v.seats.map((_, k) => v.seats[(me + 1 + k) % v.seats.length]!).filter((p) => p !== player && v.alive[p]);
let target = order[0]!;
let best = Infinity;
for (const p of order) {
const h = v.rows[p]!.filter((t) => !t.revealed).length;
if (h > 0 && h < best) {
best = h;
target = p;
}
}
const index = v.rows[target]!.findIndex((t) => !t.revealed);
const cands = candidatesFor(v.rows, v.pending, target, index, v.options);
const nums = cands.filter((c): c is number => c !== 'joker');
const value: TileValue = nums.length ? Math.min(...nums) : cands.length ? 'joker' : 0;
return { type: 'guess', target, index, value };
}
/** The concrete action a timeout stands for in the current state (docs §8.5). */
export function autoAction(s: DavinciState, player: PlayerId): DavinciClientAction {
switch (s.phase.kind) {
case 'chooseStart':
return { type: 'chooseStart', ...autoPick(s) };
case 'draw':
if (!s.options.chooseColors) return { type: 'draw' };
return { type: 'draw', color: s.pool.white.length > s.pool.black.length ? 'white' : 'black' };
case 'guess':
return autoGuess(s, player);
case 'afterCorrect':
return { type: 'stop' };
case 'placeJoker':
return { type: 'placeJoker', index: s.rows[player]!.length };
case 'revealOwn':
return { type: 'revealOwn', index: s.rows[player]!.findIndex((t) => !t.revealed) };
case 'finished':
throw new Error('davinci: no auto action after the game');
}
}
function waiting(s: DavinciState): PlayerId[] {
if (s.phase.kind === 'finished') return [];
if (s.phase.kind === 'chooseStart') {
const picks = s.phase.picks;
return s.seats.filter((p) => s.alive[p] && !picks[p]);
}
return [s.current];
}
function validateClient(s: DavinciState, actor: PlayerId, a: DavinciClientAction): Validation {
const o = s.options;
const ph = s.phase;
if (a.type === 'chooseStart') {
if (ph.kind !== 'chooseStart') return fail('지금은 시작 타일을 고를 때가 아니에요.');
if (ph.picks[actor]) return fail('이미 골랐어요.');
if (a.black + a.white !== s.startCount) return fail(`시작 타일 ${s.startCount}개를 골라 주세요.`);
const lim = pickLimits(s);
if (a.black > lim.black) return fail(lim.black === 0 ? '검은색 타일이 더 이상 없어요.' : `검은색은 ${lim.black}개까지 고를 수 있어요.`);
if (a.white > lim.white) return fail(lim.white === 0 ? '흰색 타일이 더 이상 없어요.' : `흰색은 ${lim.white}개까지 고를 수 있어요.`);
return ok;
}
if (ph.kind === 'chooseStart') return fail('먼저 시작 타일을 골라 주세요.');
if (s.current !== actor) return fail('지금은 내 차례가 아니에요.');
switch (a.type) {
case 'draw': {
if (ph.kind !== 'draw') return fail('지금은 타일을 가져올 때가 아니에요.');
if (!o.chooseColors) return a.color ? fail('이 방은 색을 고르지 않고 무작위로 가져와요.') : ok;
if (!a.color) return fail('가져올 색을 골라 주세요.');
if (s.pool[a.color].length === 0) return fail(`${COLOR_KO[a.color]} 타일이 더 이상 없어요.`);
return ok;
}
case 'guess': {
if (ph.kind !== 'guess') return fail('지금은 추리할 때가 아니에요.');
if (a.target === actor) return fail('자기 타일은 고를 수 없어요.');
if (!s.seats.includes(a.target)) return fail('이 게임의 플레이어가 아니에요.');
if (!s.alive[a.target]) return fail('이미 탈락한 사람의 타일이에요.');
const t = s.rows[a.target]![a.index];
if (!t) return fail('그 자리에는 타일이 없어요.');
if (t.revealed) return fail('이미 공개된 타일이에요.');
if (a.value === 'joker' && !o.jokers) return fail('이 방은 조커를 쓰지 않아요.');
if (!isValue(a.value, o.jokers)) return fail('0부터 11 사이 숫자를 골라 주세요.');
return ok;
}
case 'continue':
if (ph.kind !== 'afterCorrect') return fail('맞힌 뒤에만 계속할 수 있어요.');
return aliveSeats(s).some((p) => p !== actor) ? ok : fail('남은 상대가 없어요.');
case 'stop':
return ph.kind === 'afterCorrect' ? ok : fail('맞힌 뒤에만 멈출 수 있어요.');
case 'placeJoker':
if (ph.kind !== 'placeJoker') return fail('지금은 조커를 놓을 때가 아니에요.');
return a.index >= 0 && a.index <= s.rows[actor]!.length ? ok : fail('그 자리에는 놓을 수 없어요.');
case 'revealOwn': {
if (ph.kind !== 'revealOwn') return fail('지금은 내 타일을 공개할 때가 아니에요.');
const t = s.rows[actor]![a.index];
if (!t) return fail('그 자리에는 타일이 없어요.');
return t.revealed ? fail('이미 공개된 타일이에요.') : ok;
}
}
}
function applyClient(s: DavinciState, actor: PlayerId, a: DavinciClientAction, rng: Rng, events: GameEvent[], auto: boolean): void {
switch (a.type) {
case 'chooseStart': {
const ph = s.phase as Extract<DavinciPhase, { kind: 'chooseStart' }>;
ph.picks[actor] = { black: a.black, white: a.white };
events.push({ type: 'picked', player: actor });
dealIfReady(s, rng, events);
break;
}
case 'draw': {
let color = a.color;
if (!color) {
const nb = s.pool.black.length;
color = rng.int(nb + s.pool.white.length) < nb ? 'black' : 'white';
}
s.pending = s.pool[color].shift()!;
s.phase = { kind: 'guess' };
events.push({ type: 'drawn', player: actor, color });
break;
}
case 'guess': {
const t = s.rows[a.target]![a.index]!;
const real = tileOf(t.id);
const correct = real.n === a.value;
s.stats[actor]!.guesses += 1;
s.history.push({ by: actor, target: a.target, index: a.index, color: real.color, said: a.value, correct, auto });
events.push({ type: 'guessed', by: actor, target: a.target, index: a.index, value: a.value, correct });
if (correct) {
t.revealed = true;
s.stats[actor]!.correct += 1;
events.push({ type: 'revealed', player: a.target, index: a.index, color: real.color, n: real.n });
if (hiddenCount(s.rows[a.target]!) === 0) eliminate(s, a.target, events);
if (!checkFinish(s, events)) s.phase = { kind: 'afterCorrect' };
} else {
t.wrong.push(a.value);
if (s.pending) {
if (tileOf(s.pending).n === 'joker') s.phase = { kind: 'placeJoker', reveal: true };
else {
insertTile(s, actor, s.pending, true, events);
endTurn(s, events);
}
} else if (s.options.emptyPoolPenalty === 'reveal-own') s.phase = { kind: 'revealOwn' };
else endTurn(s, events);
}
break;
}
case 'continue':
s.phase = { kind: 'guess' };
break;
case 'stop':
if (s.pending && tileOf(s.pending).n === 'joker') s.phase = { kind: 'placeJoker', reveal: false };
else {
if (s.pending) insertTile(s, actor, s.pending, false, events);
endTurn(s, events);
}
break;
case 'placeJoker': {
const reveal = (s.phase as Extract<DavinciPhase, { kind: 'placeJoker' }>).reveal;
insertTile(s, actor, s.pending!, reveal, events, a.index);
endTurn(s, events);
break;
}
case 'revealOwn': {
const t = s.rows[actor]![a.index]!;
t.revealed = true;
const real = tileOf(t.id);
events.push({ type: 'revealed', player: actor, index: a.index, color: real.color, n: real.n, own: true });
if (hiddenCount(s.rows[actor]!) === 0) eliminate(s, actor, events);
endTurn(s, events);
break;
}
}
}
function applyLeave(s: DavinciState, actor: PlayerId, rng: Rng, events: GameEvent[]): void {
s.left[actor] = true;
events.push({ type: 'left', player: actor });
if (!s.alive[actor]) return;
if (s.phase.kind === 'chooseStart') {
// Leaving before the deal: out without tiles; the others are dealt as soon as they have all chosen.
delete s.phase.picks[actor];
eliminate(s, actor, events);
if (!checkFinish(s, events, true)) dealIfReady(s, rng, events);
return;
}
// Leaving in play: the leaver is out and their tiles are opened.
for (const t of s.rows[actor]!) t.revealed = true;
if (s.current === actor && s.pending) {
insertTile(s, actor, s.pending, true, events);
s.pending = null;
}
eliminate(s, actor, events);
if (checkFinish(s, events, true)) return;
if (s.current === actor) endTurn(s, events);
}
// ---------------------------------------------------------------- definition
export const davinci: GameDefinition<DavinciState, DavinciAction, DavinciView, DavinciOptions> = {
id: 'davinci-code',
nameKo: '다빈치 코드',
minPlayers: 2,
maxPlayers: 4,
stateVersion: 1,
defaultOptions: davinciOptionsSchema.parse({}),
optionsSchema: davinciOptionsSchema as unknown as z.ZodType<DavinciOptions>,
actionSchema: davinciActionSchema as unknown as z.ZodType<DavinciAction>,
setup({ players, options, rng, now }) {
if (players.length < 2 || players.length > 4) throw new Error(`davinci-code: ${players.length} players not allowed`);
const startCount = startCountFor(options, players.length);
const per = <T>(f: (p: PlayerId) => T) => Object.fromEntries(players.map((p) => [p, f(p)])) as Record<PlayerId, T>;
const first = players[rng.int(players.length)]!;
const numbered = allTileIds(false);
const jokers = options.jokers ? COLORS.map((c) => tileId(c, 'joker')) : [];
const s: DavinciState = {
options,
seats: [...players],
startCount,
pool: { black: [], white: [] },
jokerReserve: jokers,
rows: per(() => []),
phase: { kind: 'chooseStart', picks: per(() => null) },
current: first,
pending: null,
alive: per(() => true),
left: per(() => false),
eliminationOrder: [],
stats: per(() => ({ guesses: 0, correct: 0 })),
history: [],
lastPlaced: null,
turnNo: 0,
idle: per(() => 0),
idleTurn: per(() => -1),
phaseStartedAt: now,
deadlineAt: null,
seq: 0,
final: null,
};
const events: GameEvent[] = [];
if (options.chooseColors) {
for (const c of COLORS) s.pool[c] = rng.shuffle(numbered.filter((id) => tileOf(id).color === c));
} else {
const deck = rng.shuffle(numbered);
for (const p of players) s.rows[p] = deck.splice(0, startCount).map((id) => ({ id, revealed: false, wrong: [] }));
for (const c of COLORS) s.pool[c] = deck.filter((id) => tileOf(id).color === c);
afterDeal(s, rng, events);
}
refreshDeadline(s, now, true);
return s;
},
validate(s, actor, a) {
if (!s.seats.includes(actor)) return fail('이 게임의 플레이어가 아니에요.');
if (s.phase.kind === 'finished') return fail('게임이 이미 끝났어요.');
if (a.type === 'leave') return s.left[actor] ? fail('이미 나간 플레이어예요.') : ok;
if (!s.alive[actor]) return fail('탈락해서 더 할 수 없어요.');
if (a.type === 'timeout') return waiting(s).includes(actor) ? ok : fail('시간 초과 대상이 아니에요.');
return validateClient(s, actor, a);
},
apply(prev, actor, a, { rng, now }) {
const s = structuredClone(prev);
const events: GameEvent[] = [];
const before = s.phase.kind;
if (a.type === 'leave') applyLeave(s, actor, rng, events);
else if (a.type === 'timeout') {
const concrete = autoAction(s, actor);
const v = validateClient(s, actor, concrete);
if (!v.ok) throw new Error(`davinci: auto action rejected: ${v.reason}`);
if (s.phase.kind !== 'chooseStart' && s.idleTurn[actor] !== s.turnNo) {
s.idleTurn[actor] = s.turnNo;
s.idle[actor] = (s.idle[actor] ?? 0) + 1;
}
events.push({ type: 'timeout', player: actor });
applyClient(s, actor, concrete, rng, events, true);
} else {
s.idle[actor] = 0;
applyClient(s, actor, a, rng, events, false);
}
s.seq += 1;
// The start choice has one shared deadline; every other step restarts the clock.
refreshDeadline(s, now, !(before === 'chooseStart' && s.phase.kind === 'chooseStart'));
return { state: s, events };
},
view(s, viewer) {
const me = viewer && s.seats.includes(viewer) ? viewer : null;
const over = s.phase.kind === 'finished';
const rows: Record<PlayerId, DavinciTileView[]> = {};
for (const p of s.seats) {
rows[p] = s.rows[p]!.map((t) => {
const known = over || t.revealed || p === me;
if (!known) return { color: tileOf(t.id).color, revealed: false, wrong: [...t.wrong] };
const real = tileOf(t.id);
return { color: real.color, revealed: t.revealed, n: real.n, id: t.id, wrong: [...t.wrong] };
});
}
let pending: DavinciView['pending'] = null;
if (s.pending) {
const real = tileOf(s.pending);
pending = s.current === me ? { color: real.color, n: real.n, id: s.pending } : { color: real.color };
}
if (s.options.deductionHelper && !over) {
for (const p of s.seats) {
if (p === me) continue;
rows[p]!.forEach((t, i) => {
if (t.n === undefined) t.candidates = candidatesFor(rows, pending, p, i, s.options);
});
}
}
const ph = s.phase;
const choosing = ph.kind === 'chooseStart';
return {
options: s.options,
me,
seats: s.seats,
phase: ph.kind,
current: s.current,
startCount: s.startCount,
rows,
pool: { black: s.pool.black.length, white: s.pool.white.length },
pending,
picked: choosing ? Object.fromEntries(s.seats.map((p) => [p, !!ph.picks[p]])) : null,
myPick: choosing && me ? (ph.picks[me] ?? null) : null,
pickLimits: choosing ? pickLimits(s) : null,
alive: s.alive,
left: s.left,
eliminationOrder: s.eliminationOrder,
stats: s.stats,
history: s.history,
lastPlaced: s.lastPlaced,
idle: s.idle,
deadlineAt: s.deadlineAt,
final: s.final,
};
},
activePlayers: waiting,
deadline(s) {
return s.phase.kind === 'finished' ? null : s.deadlineAt;
},
timeoutPlayers: waiting,
onTimeout() {
return { type: 'timeout' };
},
onLeave(s, player) {
if (!s.seats.includes(player) || s.left[player] || s.phase.kind === 'finished') return null;
return { type: 'leave' };
},
result(s): GameResult | null {
if (!s.final) return null;
return {
ranking: s.final.ranking,
summary: s.final.summary,
reason: s.final.reason,
};
},
legalActions(s, p) {
if (!s.seats.includes(p) || !s.alive[p] || s.phase.kind === 'finished') return [];
const out: DavinciAction[] = [];
const ph = s.phase;
if (ph.kind === 'chooseStart') {
if (ph.picks[p]) return [];
const lim = pickLimits(s);
for (let b = 0; b <= s.startCount; b++) {
const w = s.startCount - b;
if (b <= lim.black && w <= lim.white) out.push({ type: 'chooseStart', black: b, white: w });
}
return out;
}
if (s.current !== p) return [];
switch (ph.kind) {
case 'draw':
if (!s.options.chooseColors) return [{ type: 'draw' }];
return COLORS.filter((c) => s.pool[c].length > 0).map((color) => ({ type: 'draw', color }));
case 'guess': {
const values: TileValue[] = [...Array.from({ length: MAX_N + 1 }, (_, n) => n), ...(s.options.jokers ? ['joker' as const] : [])];
for (const target of s.seats) {
if (target === p || !s.alive[target]) continue;
s.rows[target]!.forEach((t, index) => {
if (!t.revealed) for (const value of values) out.push({ type: 'guess', target, index, value });
});
}
return out;
}
case 'afterCorrect':
return [{ type: 'continue' }, { type: 'stop' }];
case 'placeJoker':
return Array.from({ length: s.rows[p]!.length + 1 }, (_, index) => ({ type: 'placeJoker', index }));
case 'revealOwn':
return s.rows[p]!.flatMap((t, index) => (t.revealed ? [] : [{ type: 'revealOwn' as const, index }]));
}
},
/** Ids of `player`'s hidden tiles and pending tile, plus the pool (secret to everyone). */
secretsOf(s, player) {
const pool = [...s.pool.black, ...s.pool.white, ...s.jokerReserve];
if (s.phase.kind === 'finished') return pool;
const own = (s.rows[player] ?? []).filter((t) => !t.revealed).map((t) => t.id);
if (s.current === player && s.pending) own.push(s.pending);
return [...own, ...pool];
},
};

View File

@@ -6,6 +6,11 @@ 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 { yacht } from './yacht';
import { yut } from './yut';
import { oneCard } from './one-card';
import { rummikub } from './rummikub';
import { davinci } from './davinci-code';
export const GAMES: Record<string, AnyGameDefinition> = { export const GAMES: Record<string, AnyGameDefinition> = {
[omok.id]: omok, [omok.id]: omok,
@@ -15,4 +20,9 @@ export const GAMES: Record<string, AnyGameDefinition> = {
[baduk.id]: baduk, [baduk.id]: baduk,
[seotda.id]: seotda, [seotda.id]: seotda,
[poker.id]: poker, [poker.id]: poker,
[yacht.id]: yacht,
[yut.id]: yut,
[oneCard.id]: oneCard,
[rummikub.id]: rummikub,
[davinci.id]: davinci,
}; };

View File

@@ -0,0 +1,100 @@
/** 원카드 카드 정의 (docs/games/one-card.md §3, §8.1). */
export type Suit = 'S' | 'H' | 'D' | 'C';
export type Color = 'R' | 'Y' | 'G' | 'B';
export const SUITS: Suit[] = ['S', 'H', 'D', 'C'];
export const COLORS: Color[] = ['R', 'Y', 'G', 'B'];
/** 1 = A, 11 = J, 12 = Q, 13 = K. */
export type ClassicCard =
| { id: string; kind: 'std'; suit: Suit; rank: number }
| { id: string; kind: 'joker'; joker: 'black' | 'color' };
export type UnoCard =
| { id: string; kind: 'num'; color: Color; value: number }
| { id: string; kind: 'skip' | 'reverse' | 'draw2'; color: Color }
| { id: string; kind: 'wild' | 'wild4' };
export type Card = ClassicCard | UnoCard;
export const SUIT_SYMBOL: Record<Suit, string> = { S: '♠', H: '♥', D: '◆', C: '♣' };
export const SUIT_KO: Record<Suit, string> = { S: '스페이드', H: '하트', D: '다이아', C: '클로버' };
export const COLOR_KO: Record<Color, string> = { R: '빨강', Y: '노랑', G: '초록', B: '파랑' };
const RANK_KO: Record<number, string> = { 1: 'A', 11: 'J', 12: 'Q', 13: 'K' };
export const rankLabel = (r: number): string => RANK_KO[r] ?? String(r);
/** Classic deck(s): ids "S1".."C13", "JB"/"JC"; the second deck adds a "b" suffix. */
export function classicDeck(decks: 1 | 2): ClassicCard[] {
const out: ClassicCard[] = [];
for (let d = 0; d < decks; d++) {
const sfx = d === 0 ? '' : 'b';
for (const suit of SUITS) for (let rank = 1; rank <= 13; rank++) out.push({ id: `${suit}${rank}${sfx}`, kind: 'std', suit, rank });
out.push({ id: `JB${sfx}`, kind: 'joker', joker: 'black' });
out.push({ id: `JC${sfx}`, kind: 'joker', joker: 'color' });
}
return out;
}
/** Colour-card deck, 108 cards: per colour 0 ×1, 1–9 ×2, skip/reverse/+2 ×2; 4 wild, 4 wild +4. */
export function unoDeck(): UnoCard[] {
const out: UnoCard[] = [];
for (const color of COLORS) {
out.push({ id: `${color}0`, kind: 'num', color, value: 0 });
for (const copy of ['a', 'b']) {
for (let value = 1; value <= 9; value++) out.push({ id: `${color}${value}${copy}`, kind: 'num', color, value });
out.push({ id: `${color}S${copy}`, kind: 'skip', color });
out.push({ id: `${color}R${copy}`, kind: 'reverse', color });
out.push({ id: `${color}D${copy}`, kind: 'draw2', color });
}
}
for (let i = 1; i <= 4; i++) out.push({ id: `W${i}`, kind: 'wild' });
for (let i = 1; i <= 4; i++) out.push({ id: `F${i}`, kind: 'wild4' });
return out;
}
export const isStd = (c: Card): c is Extract<ClassicCard, { kind: 'std' }> => c.kind === 'std';
export const isJoker = (c: Card): c is Extract<ClassicCard, { kind: 'joker' }> => c.kind === 'joker';
export const isWild = (c: Card): boolean => c.kind === 'wild' || c.kind === 'wild4';
export const colorOf = (c: Card): Color | null => ('color' in c ? c.color : null);
export const isSpadeAce = (c: Card): boolean => isStd(c) && c.rank === 1 && c.suit === 'S';
/** Korean name, e.g. "♥7", "흑백 조커", "빨강 5", "색 바꾸기+4". */
export function cardName(c: Card): string {
switch (c.kind) {
case 'std':
return `${SUIT_SYMBOL[c.suit]}${rankLabel(c.rank)}`;
case 'joker':
return c.joker === 'black' ? '흑백 조커' : '컬러 조커';
case 'num':
return `${COLOR_KO[c.color]} ${c.value}`;
case 'skip':
return `${COLOR_KO[c.color]} 건너뛰기`;
case 'reverse':
return `${COLOR_KO[c.color]} 방향 바꾸기`;
case 'draw2':
return `${COLOR_KO[c.color]} +2`;
case 'wild':
return '색 바꾸기';
case 'wild4':
return '색 바꾸기+4';
}
}
/** Card points: classic tie-break (number, J/Q/K 10, A 1, joker 20); colour cards per §6. */
export function cardPoints(c: Card): number {
switch (c.kind) {
case 'std':
return c.rank >= 11 ? 10 : c.rank;
case 'joker':
return 20;
case 'num':
return c.value;
case 'skip':
case 'reverse':
case 'draw2':
return 20;
case 'wild':
case 'wild4':
return 50;
}
}
export const handPoints = (cards: readonly Card[]): number => cards.reduce((a, c) => a + cardPoints(c), 0);

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,869 @@
import { describe, expect, test } from 'bun:test';
import { SeededRng, randomPlayout, seedN, type GameEvent, type PlayerId, type RngState } from '@bg/engine';
import {
cardTotal,
classicDeck,
deckSize,
oneCard,
unoDeck,
type Card,
type OneCardAction,
type OneCardOptions,
type OneCardState,
} from './index';
// ---------------------------------------------------------------- helpers
const ALL: Card[] = [...classicDeck(2), ...unoDeck()];
const card = (id: string): Card => {
const c = ALL.find((x) => x.id === id);
if (!c) throw new Error(`no card ${id}`);
return c;
};
const rng = () => SeededRng.fromSeed([7, 7, 7, 7]);
let clock = 1_000_000;
function make(opts: Partial<OneCardOptions> = {}, players = ['A', 'B', 'C'], seed = 1): OneCardState {
const options = oneCard.optionsSchema.parse(opts);
return oneCard.setup({ players, options, rng: SeededRng.fromSeed(seedN(seed)), now: 0 });
}
/** Replaces hands / discard / turn; the rest of the deck becomes the draw pile (unless given). */
function rig(
s: OneCardState,
r: { hands: Record<string, string[]>; discard: string[]; current?: string; drawPile?: string[]; pending?: number },
): OneCardState {
for (const p of s.seats) s.hands[p] = (r.hands[p] ?? []).map(card);
s.discard = r.discard.map(card);
s.current = r.current ?? s.seats[0]!;
s.phase = { kind: 'turn' };
s.pendingAttack = r.pending ?? 0;
s.declaredSuit = null;
s.declaredColor = null;
s.direction = 1;
s.extraTurn = false;
s.oneCardWindow = null;
if (r.drawPile) s.drawPile = r.drawPile.map(card);
else {
const used = new Set([...Object.values(r.hands).flat(), ...r.discard]);
const deck = s.mode === 'classic' ? classicDeck(s.decks) : unoDeck();
s.drawPile = deck.filter((c) => !used.has(c.id));
}
return s;
}
function actE(s: OneCardState, p: string, a: OneCardAction): { state: OneCardState; events: GameEvent[] } {
const v = oneCard.validate(s, p, a);
if (!v.ok) throw new Error(`${p} ${JSON.stringify(a)}: ${v.reason}`);
return oneCard.apply(s, p, a, { rng: rng(), now: (clock += 1000) });
}
const act = (s: OneCardState, p: string, a: OneCardAction) => actE(s, p, a).state;
const play = (s: OneCardState, p: string, ids: string[], extra: Partial<Extract<OneCardAction, { type: 'play' }>> = {}) =>
act(s, p, { type: 'play', cardIds: ids, ...extra });
const reject = (s: OneCardState, p: string, a: OneCardAction): string | null => {
const v = oneCard.validate(s, p, a);
return v.ok ? null : v.reason;
};
const ids = (s: OneCardState, p: string) => s.hands[p]!.map((c) => c.id);
const topId = (s: OneCardState) => s.discard[s.discard.length - 1]!.id;
// ---------------------------------------------------------------- setup
describe('셋업 (§4)', () => {
test('same seed → same hands and start card; 7 cards each; classic start card is a plain number', () => {
const a = make({}, ['A', 'B', 'C'], 5);
const b = make({}, ['A', 'B', 'C'], 5);
expect(JSON.stringify(a)).toBe(JSON.stringify(b));
expect(JSON.stringify(make({}, ['A', 'B', 'C'], 6).hands)).not.toBe(JSON.stringify(a.hands));
for (const p of a.seats) expect(a.hands[p]).toHaveLength(7);
expect(cardTotal(a)).toBe(54);
});
test('special start cards (2/A/3/7/J/Q/K/joker) go to the bottom in order until a plain number shows', () => {
let sawSpecial = 0;
for (let seed = 1; seed <= 300; seed++) {
const players = ['A', 'B', 'C'];
const s = make({}, players, seed);
// Replay the RNG: first player, then the shuffle (§8.4).
const r = SeededRng.fromSeed(seedN(seed));
const starter = players[r.int(3)]!;
const deck = r.shuffle(classicDeck(1));
expect(s.roundStarter).toBe(starter);
const rest = deck.slice(21);
const firstPlain = rest.findIndex((c) => c.kind === 'std' && [4, 5, 6, 8, 9, 10].includes(c.rank));
const specials = rest.slice(0, firstPlain);
expect(s.discard).toEqual([rest[firstPlain]!]);
expect(s.drawPile.slice(s.drawPile.length - specials.length)).toEqual(specials);
for (const c of specials) expect(c.kind === 'joker' || [1, 2, 3, 7, 11, 12, 13].includes((c as { rank: number }).rank)).toBe(true);
if (specials.length) sawSpecial++;
}
expect(sawSpecial).toBeGreaterThan(50);
});
test('deck count: auto uses 2 decks from 6 players; 1 deck is upgraded when it cannot deal', () => {
expect(make({}, ['A', 'B', 'C', 'D', 'E']).decks).toBe(1);
expect(make({}, ['A', 'B', 'C', 'D', 'E', 'F']).decks).toBe(2);
expect(cardTotal(make({}, 'ABCDEF'.split('')))).toBe(108);
expect(make({ deckCount: 2 }, ['A', 'B']).decks).toBe(2);
expect(make({ deckCount: 1 }, 'ABCDEFG'.split('')).decks).toBe(2);
expect(make({ deckCount: 1, handSize: 5 }, 'ABCDEFG'.split('')).decks).toBe(1);
});
test('player range per mode; classic refuses 10 players', () => {
expect(oneCard.playersFor!(oneCard.defaultOptions)).toEqual({ min: 2, max: 9 });
expect(oneCard.playersFor!({ ...oneCard.defaultOptions, mode: 'uno-style' })).toEqual({ min: 2, max: 10 });
expect(() => make({}, 'ABCDEFGHIJ'.split(''))).toThrow();
expect(make({ mode: 'uno-style' }, 'ABCDEFGHIJ'.split('')).seats).toHaveLength(10);
});
});
// ---------------------------------------------------------------- classic attacks
describe('공격과 방어 (§5.2 A)', () => {
test('♥2 (2장) → ♥A 받아치기 → 누적 5장; the next player draws exactly 5 and the turn ends', () => {
let s = rig(make(), { hands: { A: ['H2', 'C4'], B: ['H1', 'D9'], C: ['S9', 'S10'] }, discard: ['H5'] });
s = play(s, 'A', ['H2']);
expect(s.pendingAttack).toBe(2);
expect(s.current).toBe('B');
s = play(s, 'B', ['H1']);
expect(s.pendingAttack).toBe(5);
expect(s.current).toBe('C');
s = act(s, 'C', { type: 'draw' });
expect(s.hands.C).toHaveLength(7);
expect(s.pendingAttack).toBe(0);
expect(s.current).toBe('A');
expect(topId(s)).toBe('H1');
});
test('♥2 위에 ♣A는 거부, ♣2는 허용', () => {
const s = rig(make(), { hands: { A: ['H2', 'C4'], B: ['C1', 'C2', 'D9'] }, discard: ['H5'] });
const t = play(s, 'A', ['H2']);
expect(reject(t, 'B', { type: 'play', cardIds: ['C1'] })).toBe('공격을 받고 있어요. 막을 카드를 내거나 카드를 먹으세요.');
expect(play(t, 'B', ['C2']).pendingAttack).toBe(4);
});
test('♠A: ♥A 거부, 흑백 조커 허용; 흑백 조커 위 컬러 조커 허용, 컬러 조커 위 흑백 조커 거부', () => {
let s = rig(make(), { hands: { A: ['S1', 'C4'], B: ['H1', 'JB', 'D9'], C: ['JC', 'S9'] }, discard: ['S5'] });
s = play(s, 'A', ['S1']);
expect(s.pendingAttack).toBe(5);
expect(reject(s, 'B', { type: 'play', cardIds: ['H1'] })).not.toBeNull();
s = play(s, 'B', ['JB']);
expect(s.pendingAttack).toBe(10);
s = play(s, 'C', ['JC']);
expect(s.pendingAttack).toBe(17);
const t = rig(make(), { hands: { A: ['JC', 'C4'], B: ['JB', 'D9'] }, discard: ['S5'] });
const u = play(t, 'A', ['JC']);
expect(reject(u, 'B', { type: 'play', cardIds: ['JB'] })).not.toBeNull();
});
test('two decks: the same attack card answers itself (♠A on ♠A, 흑백 조커 on 흑백 조커)', () => {
let s = rig(make({ deckCount: 2 }), { hands: { A: ['S1', 'C4'], B: ['S1b', 'D9'], C: ['JB', 'JBb', 'S9'] }, discard: ['S5'] });
s = play(s, 'A', ['S1']);
s = play(s, 'B', ['S1b']);
expect(s.pendingAttack).toBe(10);
s = play(s, 'C', ['JB']);
s.current = 'C';
s.phase = { kind: 'turn' };
expect(play(s, 'C', ['JBb']).pendingAttack).toBe(20);
});
test('threeDefense: ♦A 공격에 ♦3 → 누적 0, 다음 사람은 ♦3 기준 일반 턴; ♣3 거부; 조커 공격에 3 거부', () => {
let s = rig(make(), { hands: { A: ['D1', 'C4'], B: ['D3', 'C3', 'S9'], C: ['D7', 'S8', 'H3'] }, discard: ['D5'] });
s = play(s, 'A', ['D1']);
expect(reject(s, 'B', { type: 'play', cardIds: ['C3'] })).not.toBeNull();
s = play(s, 'B', ['D3']);
expect(s.pendingAttack).toBe(0);
expect(s.current).toBe('C');
expect(s.log.some((e) => e.t === 'blocked')).toBe(true);
expect(reject(s, 'C', { type: 'play', cardIds: ['S8'] })).toBe('같은 무늬나 같은 숫자 카드만 낼 수 있어요.');
expect(reject(s, 'C', { type: 'play', cardIds: ['H3'] })).toBeNull();
expect(play(s, 'C', ['D7'], { chooseSuit: 'S' }).declaredSuit).toBe('S');
const j = play(rig(make(), { hands: { A: ['JB', 'C4'], B: ['S3', 'H3'] }, discard: ['S5'] }), 'A', ['JB']);
expect(reject(j, 'B', { type: 'play', cardIds: ['S3'] })).not.toBeNull();
const off = play(rig(make({ threeDefense: 'off' }), { hands: { A: ['D1', 'C4'], B: ['D3', 'S9'] }, discard: ['D5'] }), 'A', ['D1']);
expect(reject(off, 'B', { type: 'play', cardIds: ['D3'] })).not.toBeNull();
});
});
// ---------------------------------------------------------------- classic specials
describe('특수 카드 (§5.2 B·C)', () => {
test('7 + ♠ 지정: 다음 사람은 ♠ 카드나 7만; 바닥 7의 원래 무늬(♥)는 안 됨', () => {
let s = rig(make(), { hands: { A: ['H7', 'C4'], B: ['H5', 'C7', 'S9', 'H9'] }, discard: ['H8'] });
expect(reject(s, 'A', { type: 'play', cardIds: ['H7'] })).toBe('무늬를 골라 주세요.');
s = play(s, 'A', ['H7'], { chooseSuit: 'S' });
expect(s.declaredSuit).toBe('S');
expect(reject(s, 'B', { type: 'play', cardIds: ['H5'] })).toBe('♠ 무늬나 7만 낼 수 있어요.');
expect(reject(s, 'B', { type: 'play', cardIds: ['H9'] })).not.toBeNull();
expect(reject(s, 'B', { type: 'play', cardIds: ['C7'], chooseSuit: 'H' })).toBeNull();
const t = play(s, 'B', ['S9']);
expect(t.declaredSuit).toBeNull();
expect(oneCard.view(s, 'B').my!.legalCardIds.sort()).toEqual(['C7', 'S9']);
});
test('J in 2 players → same player again; Q in 2 players (default) → opponent; as-skip → again', () => {
let s = rig(make({}, ['A', 'B']), { hands: { A: ['H11', 'H12', 'C4'], B: ['S9', 'S10'] }, discard: ['H5'] });
s = play(s, 'A', ['H11']);
expect(s.current).toBe('A');
s = play(s, 'A', ['H12']);
expect(s.current).toBe('B');
const q = play(rig(make({ qInTwoPlayer: 'as-skip' }, ['A', 'B']), { hands: { A: ['H12', 'C4'], B: ['S9'] }, discard: ['H5'] }), 'A', ['H12']);
expect(q.current).toBe('A');
});
test('J skips one, Q reverses with 3+ players', () => {
let s = rig(make({}, ['A', 'B', 'C', 'D']), { hands: { A: ['H11', 'C4'], B: ['S9'], C: ['H12', 'S2'], D: ['S10'] }, discard: ['H5'] });
s = play(s, 'A', ['H11']);
expect(s.current).toBe('C');
s = play(s, 'C', ['H12']);
expect(s.direction).toBe(-1);
expect(s.current).toBe('B');
});
test('K extra-turn: nothing playable on the extra turn → draws 1; K as the last card → out at once', () => {
let s = rig(make({ voluntaryDraw: false }, ['A', 'B']), { hands: { A: ['H13', 'C4', 'D9'], B: ['S9', 'S10'] }, discard: ['H5'] });
s = play(s, 'A', ['H13']);
expect(s.current).toBe('A');
expect(s.extraTurn).toBe(true);
expect(oneCard.view(s, 'A').my!.legalCardIds).toEqual([]);
s = act(s, 'A', { type: 'draw' });
expect(s.hands.A).toHaveLength(3);
expect(s.current).toBe('B');
const k = play(rig(make({}, ['A', 'B']), { hands: { A: ['H13'], B: ['S9', 'S10'] }, discard: ['H5'] }), 'A', ['H13']);
expect(k.phase.kind).toBe('finished');
expect(oneCard.result(k)!.ranking[0]).toEqual(['A']);
});
test('K skip-two: back to the player with 3 people, two skipped with 4', () => {
const three = play(rig(make({ kEffect: 'skip-two' }), { hands: { A: ['H13', 'C4'], B: ['S9'], C: ['S8'] }, discard: ['H5'] }), 'A', ['H13']);
expect(three.current).toBe('A');
const four = play(rig(make({ kEffect: 'skip-two' }, ['A', 'B', 'C', 'D']), { hands: { A: ['H13', 'C4'], B: ['S9'], C: ['S8'], D: ['S7'] }, discard: ['H5'] }), 'A', ['H13']);
expect(four.current).toBe('D');
});
test('afterJoker: previous → judged by the card under the joker; any → anything', () => {
let s = rig(make(), { hands: { A: ['JB', 'C4'], B: ['C2', 'D9', 'H9', 'S5'], C: ['S8'] }, discard: ['H5'] });
s = play(s, 'A', ['JB']);
s = act(s, 'B', { type: 'draw' });
s.current = 'B';
s.phase = { kind: 'turn' };
expect(oneCard.view(s, 'B').reference?.id).toBe('H5');
expect(reject(s, 'B', { type: 'play', cardIds: ['D9'] })).not.toBeNull();
expect(reject(s, 'B', { type: 'play', cardIds: ['H9'] })).toBeNull();
expect(reject(s, 'B', { type: 'play', cardIds: ['S5'] })).toBeNull();
let t = rig(make({ afterJoker: 'any' }), { hands: { A: ['JB', 'C4'], B: ['D9', 'H9'] }, discard: ['H5'] });
t = play(t, 'A', ['JB']);
t.pendingAttack = 0;
expect(reject(t, 'B', { type: 'play', cardIds: ['D9'] })).toBeNull();
});
test('multiPlay: same rank only; 2+2 = 4 attack; J+J skips two; Q+Q keeps the direction; K+K one extra turn', () => {
const base = (hands: Record<string, string[]>) => rig(make({ multiPlay: true }, ['A', 'B', 'C', 'D']), { hands, discard: ['H5'] });
const off = rig(make(), { hands: { A: ['H2', 'S2', 'C4'] }, discard: ['H5'] });
expect(reject(off, 'A', { type: 'play', cardIds: ['H2', 'S2'] })).toBe('한 번에 한 장만 낼 수 있어요.');
const two = play(base({ A: ['H2', 'S2', 'C4'], B: ['S9'], C: ['S8'], D: ['S7'] }), 'A', ['H2', 'S2']);
expect(two.pendingAttack).toBe(4);
expect(topId(two)).toBe('S2');
expect(reject(base({ A: ['H2', 'S3', 'C4'] }), 'A', { type: 'play', cardIds: ['H2', 'S3'] })).toBe('같은 숫자 카드만 함께 낼 수 있어요.');
expect(reject(base({ A: ['S2', 'H2', 'C4'] }), 'A', { type: 'play', cardIds: ['S2', 'H2'] })).not.toBeNull();
expect(play(base({ A: ['H11', 'S11', 'C4'], B: ['S9'], C: ['S8'], D: ['S7'] }), 'A', ['H11', 'S11']).current).toBe('D');
const qq = play(base({ A: ['H12', 'S12', 'C4'], B: ['S9'], C: ['S8'], D: ['S7'] }), 'A', ['H12', 'S12']);
expect(qq.direction).toBe(1);
expect(qq.current).toBe('B');
const kk = play(base({ A: ['H13', 'S13', 'S4', 'C5'], B: ['S9'], C: ['S8'], D: ['S7'] }), 'A', ['H13', 'S13']);
expect(kk.current).toBe('A');
expect(play(kk, 'A', ['S4']).current).toBe('B');
});
test('lastCardRule = joker: [조커, ♥5] → ♥5 leaves the joker, which may not be played as the last card', () => {
let s = rig(make({}, ['A', 'B']), { hands: { A: ['JB', 'H5'], B: ['H9', 'S9', 'C9'] }, discard: ['H8'] });
s = play(s, 'A', ['H5'], { declareOneCard: true });
s = play(s, 'B', ['H9']);
expect(reject(s, 'A', { type: 'play', cardIds: ['JB'] })).toBe('마지막 카드로 조커는 낼 수 없어요.');
expect(oneCard.view(s, 'A').my!.legalCardIds).toEqual([]);
expect(oneCard.view(s, 'A').my!.canDraw).toBe(true);
const attack = rig(make({ lastCardRule: 'attack' }, ['A', 'B']), { hands: { A: ['H2'], B: ['S9'] }, discard: ['H8'] });
expect(reject(attack, 'A', { type: 'play', cardIds: ['H2'] })).toBe('마지막 카드로 공격 카드(2·A·조커)는 낼 수 없어요.');
const special = rig(make({ lastCardRule: 'special' }, ['A', 'B']), { hands: { A: ['H11'], B: ['S9'] }, discard: ['H8'] });
expect(reject(special, 'A', { type: 'play', cardIds: ['H11'] })).toBe('마지막 카드로 특수 카드는 낼 수 없어요.');
const none = rig(make({ lastCardRule: 'none' }, ['A', 'B']), { hands: { A: ['JC'], B: ['S9'] }, discard: ['H8'] });
expect(play(none, 'A', ['JC']).phase.kind).toBe('finished');
});
test('voluntaryDraw off: drawing is refused while a card fits; playAfterDraw on: only the drawn card, or pass', () => {
const s = rig(make({ voluntaryDraw: false }), { hands: { A: ['H9', 'C4'] }, discard: ['H5'] });
expect(reject(s, 'A', { type: 'draw' })).toBe('낼 수 있는 카드가 있으면 먹을 수 없어요.');
let t = rig(make({ playAfterDraw: 'on' }), { hands: { A: ['C4', 'C9'], B: ['S9'] }, discard: ['H5'], drawPile: ['H10', 'S4'] });
t = act(t, 'A', { type: 'draw' });
expect(t.phase).toEqual({ kind: 'afterDraw', drawnCardId: 'H10' });
expect(reject(t, 'A', { type: 'play', cardIds: ['C4'] })).toBe('방금 먹은 카드만 낼 수 있어요.');
expect(reject(t, 'A', { type: 'draw' })).not.toBeNull();
expect(play(t, 'A', ['H10']).current).toBe('B');
expect(act(t, 'A', { type: 'pass' }).current).toBe('B');
// Classic default: no play after drawing.
const d = act(rig(make(), { hands: { A: ['C4', 'C9'], B: ['S9'] }, discard: ['H5'], drawPile: ['H10'] }), 'A', { type: 'draw' });
expect(d.current).toBe('B');
});
});
// ---------------------------------------------------------------- one card
describe('원카드 선언 (§5.3)', () => {
const setupOne = (opts: Partial<OneCardOptions> = {}) =>
rig(make(opts), { hands: { A: ['H5', 'C9'], B: ['S9', 'S10'], C: ['D9', 'D10'] }, discard: ['H8'] });
test('caught: B presses first → A takes the penalty (1장) and the window closes', () => {
let s = play(setupOne(), 'A', ['H5']);
expect(s.oneCardWindow).toMatchObject({ player: 'A', declared: false });
expect(oneCard.activePlayers(s).sort()).toEqual(['A', 'B', 'C']);
const out = actE(s, 'B', { type: 'declareOneCard' });
s = out.state;
expect(s.hands.A).toHaveLength(2);
expect(s.oneCardWindow).toBeNull();
expect(out.events).toContainEqual({ type: 'oneCardCaught', catcher: 'B', target: 'A', penalty: 1 });
expect(s.current).toBe('B');
});
test('A presses first → safe; B is ignored afterwards', () => {
let s = play(setupOne(), 'A', ['H5']);
s = act(s, 'A', { type: 'declareOneCard' });
expect(s.oneCardWindow).toMatchObject({ declared: true });
const before = JSON.stringify(s);
expect(reject(s, 'B', { type: 'declareOneCard' })).toBe('이미 원카드를 외쳤어요.');
expect(JSON.stringify(s)).toBe(before);
});
test('play with declareOneCard: true cannot be caught; autoDeclare does it for everyone', () => {
const s = play(setupOne(), 'A', ['H5'], { declareOneCard: true });
expect(reject(s, 'B', { type: 'declareOneCard' })).not.toBeNull();
const auto = play(setupOne({ autoDeclare: true }), 'A', ['H5']);
expect(auto.oneCardWindow?.declared).toBe(true);
});
test('classic window lifetime and the colour-card rule (closes on the next player’s first action)', () => {
let s = rig(make({}, ['A', 'B', 'C']), { hands: { A: ['H5', 'C9'], B: ['H9', 'S10', 'S4'], C: ['H10', 'D10', 'S3'] }, discard: ['H8'] });
s = play(s, 'A', ['H5']);
s = play(s, 'B', ['H9']);
s = play(s, 'C', ['H10']);
expect(s.oneCardWindow?.player).toBe('A');
s = act(s, 'A', { type: 'draw' });
expect(s.oneCardWindow).toBeNull();
let u = rig(make({ mode: 'uno-style' }, ['A', 'B', 'C']), { hands: { A: ['R5a', 'G9a'], B: ['R9a', 'B1a', 'B2a'], C: ['Y1a'] }, discard: ['R8a'] });
u = play(u, 'A', ['R5a']);
expect(u.oneCardWindow?.player).toBe('A');
u = play(u, 'B', ['R9a']);
expect(u.oneCardWindow).toBeNull();
expect(reject(u, 'C', { type: 'declareOneCard' })).not.toBeNull();
});
test('colour cards: being caught costs 2', () => {
let u = rig(make({ mode: 'uno-style' }, ['A', 'B']), { hands: { A: ['R5a', 'G9a'], B: ['R9a', 'B1a'] }, discard: ['R8a'] });
u = play(u, 'A', ['R5a']);
u = act(u, 'B', { type: 'declareOneCard' });
expect(u.hands.A).toHaveLength(3);
});
});
// ---------------------------------------------------------------- bankrupt & draw pile
describe('파산과 드로우 더미 (§5.4, §5.5)', () => {
const fifteen = ['C3', 'C4', 'C5', 'C6', 'C7', 'C8', 'C9', 'C10', 'C11', 'C12', 'C13', 'D3', 'D4', 'D6', 'D8'];
test('15장에서 5장을 먹어 20장 → 즉시 파산; cards go under the draw pile; 2 players → opponent wins', () => {
let s = rig(make({}, ['A', 'B']), { hands: { A: ['JB', 'S9'], B: fifteen }, discard: ['S5'] });
s = play(s, 'A', ['JB']);
const pileBefore = s.drawPile.length;
const out = actE(s, 'B', { type: 'draw' });
s = out.state;
expect(s.status.B).toBe('bankrupt');
expect(s.hands.B).toEqual([]);
expect(s.drawPile).toHaveLength(pileBefore + 15);
expect(s.drawPile.slice(-20).map((c) => c.id).slice(0, 15)).toEqual(fifteen);
expect(out.events.some((e) => e.type === 'bankrupt')).toBe(true);
expect(s.phase.kind).toBe('finished');
expect(oneCard.result(s)!.ranking).toEqual([['A'], ['B']]);
expect(cardTotal(s)).toBe(54);
});
test('3 players: the bankrupt player leaves the order and the next one plays a normal turn', () => {
let s = rig(make({}, ['A', 'B', 'C']), { hands: { A: ['JB', 'S9'], B: fifteen, C: ['S10', 'H4'] }, discard: ['S5'] });
s = play(s, 'A', ['JB']);
s = act(s, 'B', { type: 'draw' });
expect(s.status.B).toBe('bankrupt');
expect(s.current).toBe('C');
expect(s.pendingAttack).toBe(0);
s = play(s, 'C', ['S10']);
expect(s.current).toBe('A');
expect(oneCard.view(s, null).seats.find((x) => x.id === 'B')).toMatchObject({ status: 'bankrupt', cards: 0 });
});
test('draw pile empty: the discard pile (minus the top) is shuffled back; still short → draw what exists', () => {
let s = rig(make(), { hands: { A: ['C4'], B: ['C9'], C: ['D9'] }, discard: ['S3', 'S4', 'S5', 'S6', 'S8', 'S9', 'S10', 'S11', 'S12', 'JC'], drawPile: [], pending: 7 });
s.current = 'B';
const out = actE(s, 'B', { type: 'draw' });
s = out.state;
expect(s.hands.B).toHaveLength(8);
expect(s.discard.map((c) => c.id)).toEqual(['S12', 'JC']);
expect(s.drawPile).toHaveLength(1);
expect(out.events.some((e) => e.type === 'reshuffled')).toBe(true);
let t = rig(make(), { hands: { A: ['C4'], B: ['C9'], C: ['D9'] }, discard: ['S3', 'S4', 'S5', 'JC'], drawPile: [], pending: 7 });
t.current = 'B';
t = act(t, 'B', { type: 'draw' });
// The joker on top and the card under it (what decides the next play) stay; only ♠3·♠4 come back.
expect(t.discard.map((c) => c.id)).toEqual(['S5', 'JC']);
expect(t.hands.B).toHaveLength(3);
expect(t.pendingAttack).toBe(0);
expect(t.current).toBe('C');
});
});
// ---------------------------------------------------------------- colour cards
describe('색깔 카드 (§5.6)', () => {
const uno = (opts: Partial<OneCardOptions> = {}, players = ['A', 'B', 'C']) => make({ mode: 'uno-style', ...opts }, players);
test('108 cards: 4 colours × 25 + 8 wild', () => {
const d = unoDeck();
expect(d).toHaveLength(108);
expect(new Set(d.map((c) => c.id)).size).toBe(108);
expect(d.filter((c) => c.kind === 'wild' || c.kind === 'wild4')).toHaveLength(8);
expect(d.filter((c) => 'color' in c && c.color === 'R')).toHaveLength(25);
});
test('colour or symbol match; wild needs a colour', () => {
const s = rig(uno(), { hands: { A: ['G5a', 'B7a', 'RSa', 'W1', 'Y2a'] }, discard: ['R5a'] });
expect(oneCard.view(s, 'A').my!.legalCardIds.sort()).toEqual(['G5a', 'RSa', 'W1']);
expect(reject(s, 'A', { type: 'play', cardIds: ['W1'] })).toBe('색을 골라 주세요.');
const t = play(s, 'A', ['W1'], { chooseColor: 'Y' });
expect(t.declaredColor).toBe('Y');
expect(oneCard.view(t, 'B').currentColor).toBe('Y');
});
test('strict: red 3 in hand → 색 바꾸기+4 refused; no red, only blue +2 → allowed', () => {
const s = rig(uno(), { hands: { A: ['R3a', 'F1', 'G1a'] }, discard: ['R5a'] });
expect(reject(s, 'A', { type: 'play', cardIds: ['F1'], chooseColor: 'G' })).toBe('지금 색 카드가 손에 있으면 색 바꾸기+4를 낼 수 없어요.');
let t = rig(uno(), { hands: { A: ['BDa', 'F1', 'G1a'], B: ['Y1a', 'Y2a'], C: ['Y3a'] }, discard: ['R5a'] });
t = play(t, 'A', ['F1'], { chooseColor: 'B' });
expect(t.hands.B).toHaveLength(6);
expect(t.current).toBe('C');
expect(t.declaredColor).toBe('B');
});
test('challenge: violation → the player of +4 draws 4; fine → the challenger draws 6; the hand is shown to the challenger only', () => {
let s = rig(uno({ unoWild4Rule: 'challenge' }), { hands: { A: ['R3a', 'F1', 'G1a'], B: ['Y1a', 'Y2a'], C: ['Y3a'] }, discard: ['R5a'] });
s = play(s, 'A', ['F1'], { chooseColor: 'G' });
expect(s.phase.kind).toBe('wild4Response');
expect(oneCard.activePlayers(s)).toEqual(['B']);
expect(JSON.stringify(oneCard.view(s, 'B'))).not.toContain('illegal');
s = act(s, 'B', { type: 'challengeWild4' });
expect(s.hands.A).toHaveLength(6);
expect(s.hands.B).toHaveLength(2);
expect(s.current).toBe('B');
const vb = oneCard.view(s, 'B');
expect(vb.reveal).toMatchObject({ by: 'A', illegal: true });
expect(vb.reveal!.cards).toHaveLength(2);
expect(oneCard.view(s, 'C').reveal).toBeNull();
expect(oneCard.view(s, 'A').reveal).toBeNull();
expect(oneCard.view(s, null).reveal).toBeNull();
// The revealed cards carry no ids, so nobody learns which physical card is where.
for (const c of s.hands.A!) expect(JSON.stringify(oneCard.view(s, 'B'))).not.toContain(`"${c.id}"`);
let t = rig(uno({ unoWild4Rule: 'challenge' }), { hands: { A: ['B3a', 'F1', 'G1a'], B: ['Y1a', 'Y2a'], C: ['Y3a'] }, discard: ['R5a'] });
t = play(t, 'A', ['F1'], { chooseColor: 'G' });
t = act(t, 'B', { type: 'challengeWild4' });
expect(t.hands.B).toHaveLength(8);
expect(t.hands.A).toHaveLength(2);
expect(t.current).toBe('C');
expect(oneCard.view(t, 'B').reveal).toMatchObject({ illegal: false });
let u = rig(uno({ unoWild4Rule: 'challenge' }), { hands: { A: ['B3a', 'F1', 'G1a'], B: ['Y1a', 'Y2a'], C: ['Y3a'] }, discard: ['R5a'] });
u = play(u, 'A', ['F1'], { chooseColor: 'G' });
u = act(u, 'B', { type: 'acceptWild4' });
expect(u.hands.B).toHaveLength(6);
expect(u.current).toBe('C');
});
test('2 players: 방향 바꾸기 works as 건너뛰기; +2 makes the opponent draw 2 and lose the turn', () => {
const s = rig(uno({}, ['A', 'B']), { hands: { A: ['RRa', 'RDa', 'G1a'], B: ['Y1a', 'Y2a'] }, discard: ['R5a'] });
const r = play(s, 'A', ['RRa']);
expect(r.current).toBe('A');
const d = play(r, 'A', ['RDa']);
expect(d.hands.B).toHaveLength(4);
expect(d.current).toBe('A');
});
test('3 players: reverse turns the direction; skip skips one', () => {
const s = rig(uno(), { hands: { A: ['RRa', 'G1a'], B: ['Y1a'], C: ['RSa', 'Y3a'] }, discard: ['R5a'] });
const r = play(s, 'A', ['RRa']);
expect(r.direction).toBe(-1);
expect(r.current).toBe('C');
expect(play(r, 'C', ['RSa']).current).toBe('A');
});
test('start card +2: the first player draws 2 and loses the turn; skip / reverse / wild starts', () => {
const seen = new Set<string>();
for (let seed = 1; seed <= 3000 && seen.size < 4; seed++) {
const s = make({ mode: 'uno-style' }, ['A', 'B', 'C'], seed);
const start = s.discard[0]!;
expect(s.discard).toHaveLength(1);
expect(start.kind).not.toBe('wild4');
const first = s.roundStarter;
const i = s.seats.indexOf(first);
if (start.kind === 'draw2' && !seen.has('draw2')) {
seen.add('draw2');
expect(s.hands[first]).toHaveLength(9);
expect(s.current).toBe(s.seats[(i + 1) % 3]!);
}
if (start.kind === 'skip' && !seen.has('skip')) {
seen.add('skip');
expect(s.current).toBe(s.seats[(i + 1) % 3]!);
}
if (start.kind === 'reverse' && !seen.has('reverse')) {
seen.add('reverse');
expect(s.direction).toBe(-1);
expect(s.current).toBe(s.seats[(i + 2) % 3]!);
}
if (start.kind === 'wild' && !seen.has('wild')) {
seen.add('wild');
expect(s.current).toBe(first);
// The first player may follow with any colour.
expect(oneCard.view(s, first).my!.legalCardIds).toHaveLength(7);
}
}
expect(seen.size).toBe(4);
});
test('stacking: +2 on +2 adds up; +4 only on +4; the next player takes the total', () => {
let s = rig(uno({ unoStacking: true }), { hands: { A: ['RDa', 'G1a'], B: ['BDa', 'Y2a'], C: ['RDb', 'F1', 'Y3a'] }, discard: ['R5a'] });
s = play(s, 'A', ['RDa']);
expect(s.current).toBe('B');
s = play(s, 'B', ['BDa']);
expect(s.pendingAttack).toBe(4);
s = play(s, 'C', ['F1'], { chooseColor: 'Y' });
expect(s.pendingAttack).toBe(8);
expect(reject(s, 'A', { type: 'play', cardIds: ['G1a'] })).not.toBeNull();
s = act(s, 'A', { type: 'draw' });
expect(s.hands.A).toHaveLength(9);
expect(s.current).toBe('B');
});
test('scoring: B=[7, +2], C=[색 바꾸기], D=[0, 3] → the winner gets 80', () => {
let s = rig(make({ mode: 'uno-style' }, ['A', 'B', 'C', 'D']), { hands: { A: ['R5a'], B: ['R7a', 'BDa'], C: ['W1'], D: ['G0', 'Y3a'] }, discard: ['R2a'] });
s = play(s, 'A', ['R5a']);
const r = oneCard.result(s)!;
expect(r.scores).toEqual({ A: 80, B: 0, C: 0, D: 0 });
// Fewer cards first, then lower card points: C (1장) → D (2장, 3점) → B (2장, 27점).
expect(r.ranking).toEqual([['A'], ['C'], ['D'], ['B']]);
expect(s.final!.players.find((x) => x.player === 'A')).toMatchObject({ rank: 1, points: 80, cardsLeft: 0 });
expect(r.summary).toContain('80점');
});
test('going out on +2 still makes the next player draw (counted in the points)', () => {
let s = rig(make({ mode: 'uno-style' }, ['A', 'B']), { hands: { A: ['RDa'], B: ['R7a'] }, discard: ['R2a'] });
s = play(s, 'A', ['RDa']);
expect(s.hands.B).toHaveLength(3);
expect(oneCard.result(s)!.scores!.A).toBe(7 + s.hands.B!.slice(1).reduce((a, c) => a + (c.kind === 'num' ? c.value : c.kind === 'wild' || c.kind === 'wild4' ? 50 : 20), 0));
});
test('target score: rounds continue until someone reaches it', () => {
let s = rig(make({ mode: 'uno-style', unoTargetScore: 200 }, ['A', 'B']), { hands: { A: ['R5a'], B: ['W1', 'W2'] }, discard: ['R2a'] });
s = play(s, 'A', ['R5a']);
expect(s.phase.kind).toBe('roundEnd');
expect(s.scores.A).toBe(100);
expect(oneCard.result(s)).toBeNull();
expect(oneCard.deadline!(s)).toBe(s.turnStartedAt + 8000);
s = act(s, 'A', { type: 'ready' });
expect(reject(s, 'A', { type: 'ready' })).not.toBeNull();
s = act(s, 'B', { type: 'timeout' });
expect(s.round).toBe(2);
expect(s.phase.kind).not.toBe('roundEnd');
expect(cardTotal(s)).toBe(108);
s = rig(s, { hands: { A: ['W3'], B: ['W1', 'W2'] }, discard: ['R2a'] });
s = play(s, 'A', ['W3'], { chooseColor: 'R' });
expect(s.phase.kind).toBe('finished');
expect(oneCard.result(s)!.scores).toEqual({ A: 200, B: 0 });
expect(oneCard.result(s)!.summary).toContain('목표 200점');
});
});
// ---------------------------------------------------------------- timers, leaving
describe('시간 초과, 연결 끊김, 나가기 (§8.5)', () => {
test('timeout while attacked → takes the attack; afterDraw → pass; wild4Response → accept', () => {
let s = rig(make(), { hands: { A: ['H2', 'C4'], B: ['S2', 'S9'], C: ['D9'] }, discard: ['H5'] });
s = play(s, 'A', ['H2']);
expect(oneCard.onTimeout(s, 'B')).toEqual({ type: 'timeout' });
s = act(s, 'B', oneCard.onTimeout(s, 'B'));
expect(s.hands.B).toHaveLength(4);
expect(s.current).toBe('C');
let t = rig(make({ playAfterDraw: 'on' }), { hands: { A: ['C4', 'C9'], B: ['S9'] }, discard: ['H5'], drawPile: ['H10', 'S4'] });
t = act(t, 'A', { type: 'draw' });
t = act(t, 'A', { type: 'timeout' });
expect(t.current).toBe('B');
expect(t.hands.A).toHaveLength(3);
let u = rig(make({ mode: 'uno-style', unoWild4Rule: 'challenge' }), { hands: { A: ['F1', 'G1a'], B: ['Y1a'], C: ['Y3a'] }, discard: ['R5a'] });
u = play(u, 'A', ['F1'], { chooseColor: 'G' });
expect(oneCard.deadline!(u)).toBe(u.turnStartedAt + 10_000);
u = act(u, 'B', { type: 'timeout' });
expect(u.hands.B).toHaveLength(5);
});
test('normal-turn timeout draws one card and never plays one, even with a playable card', () => {
let s = rig(make({ playAfterDraw: 'on', voluntaryDraw: false }), { hands: { A: ['H9', 'C4'], B: ['S9'] }, discard: ['H5'], drawPile: ['H10'] });
s = act(s, 'A', { type: 'timeout' });
expect(ids(s, 'A').sort()).toEqual(['C4', 'H10', 'H9']);
expect(s.current).toBe('B');
});
test('a disconnected player keeps being auto-drawn on the normal timer; after 3 in a row the limit is 5 s', () => {
let s = make({}, ['A', 'B']);
const victim = s.current;
const other = s.seats.find((p) => p !== victim)!;
for (let k = 0; k < 3; k++) {
expect(oneCard.deadline!(s)).toBe(s.turnStartedAt + 20_000);
s = act(s, victim, { type: 'timeout' });
// The opponent plays on (draws or plays).
if (s.current === other) {
const legal = oneCard.legalActions!(s, other).filter((a) => a.type === 'draw');
s = act(s, other, legal[0] ?? { type: 'timeout' });
}
if (s.oneCardWindow) s.oneCardWindow = null;
}
expect(s.away[victim]).toBe(true);
expect(s.current).toBe(victim);
expect(oneCard.deadline!(s)).toBe(s.turnStartedAt + 5000);
expect(oneCard.view(s, null).seats.find((x) => x.id === victim)!.away).toBe(true);
s = act(s, victim, { type: 'draw' });
expect(s.away[victim]).toBe(false);
});
test('leaving: the current player leaves → next player; 2 players → the other one wins', () => {
let s = rig(make(), { hands: { A: ['H2', 'C4'], B: ['S2', 'S9'], C: ['D9'] }, discard: ['H5'] });
expect(oneCard.onLeave!(s, 'A')).toEqual({ type: 'leave' });
s = act(s, 'A', { type: 'leave' });
expect(s.status.A).toBe('left');
expect(s.current).toBe('B');
expect(cardTotal(s)).toBe(54);
expect(oneCard.onLeave!(s, 'A')).toBeNull();
s = act(s, 'B', { type: 'leave' });
expect(s.phase.kind).toBe('finished');
expect(oneCard.result(s)!.ranking).toEqual([['C'], ['B'], ['A']]);
});
});
// ---------------------------------------------------------------- validation & leaks
describe('검증과 정보 유출 (§8.3, §10)', () => {
test('not my turn / card not in hand / resent packet are refused and change nothing', () => {
const s = rig(make(), { hands: { A: ['H9', 'C4'], B: ['H10', 'S9'] }, discard: ['H5'] });
const before = JSON.stringify(s);
expect(reject(s, 'B', { type: 'play', cardIds: ['H10'] })).toBe('지금은 내 차례가 아니에요.');
expect(reject(s, 'A', { type: 'play', cardIds: ['H10'] })).toBe('손에 없는 카드예요.');
expect(reject(s, 'A', { type: 'play', cardIds: ['H9', 'H9'] })).not.toBeNull();
expect(reject(s, 'Z', { type: 'draw' })).toBe('이 게임의 플레이어가 아니에요.');
expect(JSON.stringify(s)).toBe(before);
const t = play(s, 'A', ['H9']);
expect(reject(t, 'A', { type: 'play', cardIds: ['H9'] })).not.toBeNull();
t.current = 'A';
expect(reject(t, 'A', { type: 'play', cardIds: ['H9'] })).toBe('손에 없는 카드예요.');
});
test('views: opponents and spectators see counts only; cardsDrawn events carry counts only', () => {
let s = make({}, ['A', 'B', 'C'], 9);
const chooser = SeededRng.fromSeed([1, 2, 3, 4]);
for (let k = 0; k < 120 && !oneCard.result(s); k++) {
for (const viewer of [...s.seats, null]) {
const json = JSON.stringify(oneCard.view(s, viewer));
for (const owner of s.seats) if (owner !== viewer) for (const c of s.hands[owner]!) expect(json).not.toContain(`"${c.id}"`);
for (const c of s.drawPile) expect(json).not.toContain(`"${c.id}"`);
}
const actives = oneCard.activePlayers(s);
const p = actives[chooser.int(actives.length)]!;
const legal = oneCard.legalActions!(s, p);
const out = oneCard.apply(s, p, legal[chooser.int(legal.length)]!, { rng: rng(), now: k * 1000 });
for (const e of out.events) {
if (e.type === 'cardsDrawn') expect(Object.keys(e).sort()).toEqual(['count', 'player', 'type', 'why']);
const json = JSON.stringify(e);
for (const owner of s.seats) for (const c of out.state.hands[owner]!) expect(json).not.toContain(`"${c.id}"`);
}
s = out.state;
}
const v = oneCard.view(s, 'A');
expect(v.seats.every((x) => typeof x.cards === 'number')).toBe(true);
expect(oneCard.view(s, null).my).toBeNull();
});
test('options: defaults, Korean metadata on every option, mode-dependent defaults resolved in the view', () => {
expect(oneCard.defaultOptions).toMatchObject({ mode: 'classic', handSize: 7, attack2: 2, attackA: 3, attackSpadeA: 5, attackBlackJoker: 5, attackColorJoker: 7, turnSeconds: 20 });
const shape = (oneCard.optionsSchema as unknown as { shape: Record<string, { meta(): { title?: string } | undefined }> }).shape;
for (const [k, v] of Object.entries(shape)) expect(v.meta()?.title, k).toBeTruthy();
const c = oneCard.view(make(), 'A');
expect([c.penalty, c.bankruptLimit, c.playAfterDraw]).toEqual([1, 20, false]);
const u = oneCard.view(make({ mode: 'uno-style' }), 'A');
expect([u.penalty, u.bankruptLimit, u.playAfterDraw]).toEqual([2, null, true]);
expect(oneCard.optionsSchema.safeParse({ attackColorJoker: 14 }).success).toBe(false);
});
test('client actions only (system actions are not in the schema)', () => {
expect(oneCard.actionSchema.safeParse({ type: 'timeout' }).success).toBe(false);
expect(oneCard.actionSchema.safeParse({ type: 'leave' }).success).toBe(false);
expect(oneCard.actionSchema.safeParse({ type: 'play', cardIds: ['H9'], chooseSuit: 'S' }).success).toBe(true);
expect(oneCard.actionSchema.safeParse({ type: 'play', cardIds: [] }).success).toBe(false);
});
});
// ---------------------------------------------------------------- properties
const TOKEN = /"[^"\\]*"/g;
/** Checks the given viewers (default: every player and a spectator) against every player's secrets. */
function checkLeaks(s: OneCardState, players: PlayerId[], viewers: (PlayerId | null)[] = [...players, null]): void {
// Card ids are plain strings, so every JSON string token of a view is compared with the secret ids.
const owners = new Map<string, PlayerId[]>();
for (const owner of players)
for (const secret of oneCard.secretsOf!(s, owner)) {
const k = `"${secret}"`;
const list = owners.get(k);
if (list) list.push(owner);
else owners.set(k, [owner]);
}
for (const viewer of viewers)
for (const tok of JSON.stringify(oneCard.view(s, viewer)).match(TOKEN) ?? []) {
const of = owners.get(tok);
if (of && of.some((o) => o !== viewer)) throw new Error(`leak ${tok} of ${of.join(',')} to ${viewer}`);
}
}
/** Events go to everyone: no card id that is hidden after the action may appear in them. */
function checkEvents(events: GameEvent[], s: OneCardState): void {
const json = JSON.stringify(events);
const hidden = new Set([...s.drawPile, ...s.seats.flatMap((p) => s.hands[p]!)].map((c) => `"${c.id}"`));
for (const tok of json.match(TOKEN) ?? []) if (hidden.has(tok)) throw new Error(`event leaks ${tok}`);
}
function invariants(s: OneCardState): void {
if (cardTotal(s) !== deckSize(s)) throw new Error(`cards not conserved: ${cardTotal(s)} vs ${deckSize(s)}`);
const seen = new Set<string>();
for (const c of [...s.drawPile, ...s.discard, ...s.seats.flatMap((p) => s.hands[p]!)]) {
if (seen.has(c.id)) throw new Error(`duplicate card ${c.id}`);
seen.add(c.id);
}
if (s.pendingAttack < 0) throw new Error('negative attack');
for (const p of s.seats) if (s.status[p] !== 'playing' && s.hands[p]!.length > 0 && s.phase.kind !== 'roundEnd') throw new Error(`${p} out of play but holds cards`);
if (s.oneCardWindow && s.hands[s.oneCardWindow.player]!.length !== 1) throw new Error('one-card window without exactly one card');
if ((s.phase.kind === 'turn' || s.phase.kind === 'afterDraw') && s.status[s.current] !== 'playing') throw new Error('current player is not playing');
}
interface FastReport {
steps: number;
finished: boolean;
trace: string;
final: OneCardState;
}
/**
* Same chooser and action selection as `randomPlayout`, with cheaper checks so 1,000 games per mode stay fast:
* invariants and onTimeout legality every step; view / event leaks (rotating viewer) and JSON round trip on a schedule.
*/
function fastPlayout(players: PlayerId[], options: OneCardOptions, seed: RngState, maxSteps: number): FastReport {
const r = SeededRng.fromSeed(seed);
const chooser = SeededRng.fromSeed([seed[3], seed[2], seed[1], seed[0] ^ 0x9e3779b9]);
let now = 1_700_000_000_000;
let s = oneCard.setup({ players, options, rng: r, now });
const trace: string[] = [];
for (let step = 0; step < maxSteps; step++) {
invariants(s);
// A spectator and one rotating player every 10th step; everyone at the end.
if (step % 10 === 0) checkLeaks(s, players, [null, players[(step / 10) % players.length]!]);
if (step % 50 === 0 && JSON.stringify(JSON.parse(JSON.stringify(s))) !== JSON.stringify(s)) throw new Error('not JSON round-trippable');
for (const p of oneCard.timeoutPlayers!(s)) {
const v = oneCard.validate(s, p, oneCard.onTimeout(s, p));
if (!v.ok) throw new Error(`onTimeout rejected for ${p}: ${v.reason}`);
}
if (oneCard.result(s)) {
checkLeaks(s, players);
return { steps: step, finished: true, trace: trace.join(';'), final: s };
}
const actives = oneCard.activePlayers(s);
if (!actives.length) throw new Error('no active players');
const actor = actives[chooser.int(actives.length)]!;
const legal = oneCard.legalActions!(s, actor);
const action = legal.length > 0 ? legal[chooser.int(legal.length)]! : oneCard.onTimeout(s, actor);
const v = oneCard.validate(s, actor, action);
if (!v.ok) throw new Error(`generated action rejected: ${v.reason} ${JSON.stringify(action)}`);
now += 1000;
const out = oneCard.apply(s, actor, action, { rng: r, now });
// Events never carry card ids at all (played cards are sent as faces); a full check every 10th step.
if (JSON.stringify(out.events).includes('"id"')) throw new Error('event carries a card id');
if (step % 10 === 5) checkEvents(out.events, out.state);
s = out.state;
trace.push(`${actor}:${JSON.stringify(action)}`);
}
return { steps: maxSteps, finished: false, trace: trace.join(';'), final: s };
}
const PRESETS: Record<'classic' | 'uno-style', Partial<OneCardOptions>[]> = {
classic: [
{},
{ voluntaryDraw: false, multiPlay: true },
{ kEffect: 'skip-two', qInTwoPlayer: 'as-skip', lastCardRule: 'special', afterJoker: 'any', threeDefense: 'off' },
{ endCondition: 'last-standing', bankruptLimit: 15, handSize: 5, deckCount: 2, playAfterDraw: 'on' },
{ multiPlay: true, lastCardRule: 'attack', bankruptLimit: 'none', autoDeclare: true, attackColorJoker: 13, attackSpadeA: 10 },
],
'uno-style': [
{},
{ voluntaryDraw: false, unoWild4Rule: 'challenge' },
{ unoStacking: true, unoWild4Rule: 'challenge', bankruptLimit: 25, voluntaryDraw: false },
{ endCondition: 'last-standing', unoWild4Rule: 'free', bankruptLimit: 18, playAfterDraw: 'off', handSize: 5 },
{ unoTargetScore: 200, voluntaryDraw: false, bankruptLimit: 20, autoDeclare: true },
],
};
describe('random playouts (1,000 seeds per mode)', () => {
for (const mode of ['classic', 'uno-style'] as const) {
test(`${mode}: 1,000 seeded games finish, cards conserved, no leaks, onTimeout always legal, deterministic`, () => {
const max = mode === 'classic' ? 9 : 10;
let finished = 0;
for (let i = 1; i <= 1000; i++) {
const n = 2 + (i % (max - 1));
const players = Array.from({ length: n }, (_, k) => `P${k}`);
const options = oneCard.optionsSchema.parse({ mode, ...PRESETS[mode][i % PRESETS[mode].length]! });
const a = fastPlayout(players, options, seedN(i), 30_000);
if (!a.finished) throw new Error(`seed ${i} (${n} players) did not finish in 30,000 steps`);
finished++;
const res = oneCard.result(a.final)!;
expect(res.ranking.flat().sort()).toEqual([...players].sort());
if (i % 25 === 0) {
const b = fastPlayout(players, options, seedN(i), 30_000);
expect(b.trace).toBe(a.trace);
expect(JSON.stringify(b.final)).toBe(JSON.stringify(a.final));
}
}
expect(finished).toBe(1000);
}, 120_000);
}
test.each([
['classic', 2, {}],
['classic', 3, { multiPlay: true, voluntaryDraw: false }],
['classic', 6, { endCondition: 'last-standing', voluntaryDraw: false }],
['uno-style', 2, {}],
['uno-style', 3, { unoStacking: true, unoWild4Rule: 'challenge', voluntaryDraw: false }],
['uno-style', 4, { unoTargetScore: 200, voluntaryDraw: false }],
] as const)('randomPlayout with the engine’s per-step leak / round-trip checks: %s %i players', (mode, n, opts) => {
const players = Array.from({ length: n }, (_, k) => `P${k}`);
const options = oneCard.optionsSchema.parse({ mode, ...opts });
const conserve = (st: unknown) => invariants(st as OneCardState);
for (let i = 1; i <= 3; i++) {
const a = randomPlayout(oneCard, { players, options, seed: seedN(1000 + i), maxSteps: 30_000, invariant: conserve, maxApplyMs: 30 });
expect(a.finished).toBe(true);
if (i === 1) {
const b = randomPlayout(oneCard, { players, options, seed: seedN(1000 + i), maxSteps: 30_000, maxApplyMs: 30 });
expect(JSON.stringify(b.trace)).toBe(JSON.stringify(a.trace));
}
}
}, 60_000);
});

View File

@@ -0,0 +1,165 @@
/** Card legality for both modes (docs/games/one-card.md §5.2, §5.6). Pure helpers over the state. */
import type { PlayerId } from '@bg/engine';
import { COLOR_KO, SUIT_SYMBOL, colorOf, isJoker, isSpadeAce, isStd, rankLabel, type Card, type Color } from './cards';
import type { OneCardOptions, OneCardState } from './types';
export const resolvedPenalty = (o: OneCardOptions): number => (o.oneCardPenalty === 'auto' ? (o.mode === 'classic' ? 1 : 2) : o.oneCardPenalty);
export const resolvedBankrupt = (o: OneCardOptions): number | null =>
o.bankruptLimit === 'auto' ? (o.mode === 'classic' ? 20 : null) : o.bankruptLimit === 'none' ? null : o.bankruptLimit;
export const resolvedPlayAfterDraw = (o: OneCardOptions): boolean => (o.playAfterDraw === 'auto' ? o.mode === 'uno-style' : o.playAfterDraw === 'on');
export const top = (s: OneCardState): Card => s.discard[s.discard.length - 1]!;
/** Classic: the first non-joker from the top (the start card is never a joker). */
export function referenceCard(s: OneCardState): Card {
for (let i = s.discard.length - 1; i >= 0; i--) if (!isJoker(s.discard[i]!)) return s.discard[i]!;
return top(s);
}
/** Attack size of one classic card (0 = not an attack card). */
export function attackOf(o: OneCardOptions, c: Card): number {
if (c.kind === 'joker') return c.joker === 'black' ? o.attackBlackJoker : o.attackColorJoker;
if (c.kind !== 'std') return 0;
if (c.rank === 2) return o.attack2;
if (c.rank === 1) return c.suit === 'S' ? o.attackSpadeA : o.attackA;
return 0;
}
/** §5.2 A: may `c` be played on top of the attack card `t`? */
export function canCounter(t: Card, c: Card, twoDecks: boolean): boolean {
if (t.kind === 'joker') {
if (c.kind !== 'joker') return false;
if (t.joker === 'black') return c.joker === 'color' || twoDecks;
return twoDecks && c.joker === 'color';
}
if (!isStd(t)) return false;
if (isJoker(c)) return true;
if (!isStd(c)) return false;
if (t.rank === 2) return c.rank === 2 || (c.rank === 1 && c.suit === t.suit);
if (t.rank === 1 && t.suit !== 'S') return c.rank === 1;
if (isSpadeAce(t)) return twoDecks && isSpadeAce(c);
return false;
}
/** Same-suit 3 on a 2 / A attack (threeDefense). */
export function canBlockWithThree(o: OneCardOptions, t: Card, c: Card): boolean {
return o.threeDefense === 'same-suit' && isStd(t) && (t.rank === 1 || t.rank === 2) && isStd(c) && c.rank === 3 && c.suit === t.suit;
}
/** Classic normal turn (§5.2 B). */
export function classicMatches(s: OneCardState, c: Card): boolean {
if (isJoker(c)) return true;
if (!isStd(c)) return false;
const t = top(s);
if (isJoker(t) && s.options.afterJoker === 'any') return true;
const ref = referenceCard(s);
if (!isStd(ref)) return true;
if (s.declaredSuit) return c.suit === s.declaredSuit || c.rank === 7;
return c.suit === ref.suit || c.rank === ref.rank;
}
/** §5.2 D: cards that may not be the last card(s) of a hand. */
export function forbiddenAsLast(o: OneCardOptions, c: Card): boolean {
switch (o.lastCardRule) {
case 'none':
return false;
case 'joker':
return isJoker(c);
case 'attack':
return isJoker(c) || (isStd(c) && (c.rank === 1 || c.rank === 2));
case 'special':
return isJoker(c) || (isStd(c) && [1, 2, 3, 7, 11, 12, 13].includes(c.rank));
}
}
/** Colour cards: the colour to follow (null when a starting wild left it open). */
export function currentColor(s: OneCardState): Color | null {
return s.declaredColor ?? colorOf(top(s));
}
function unoSymbolMatch(t: Card, c: Card): boolean {
if (t.kind === 'num' && c.kind === 'num') return t.value === c.value;
return (t.kind === 'skip' || t.kind === 'reverse' || t.kind === 'draw2') && t.kind === c.kind;
}
/** Colour cards normal turn (§5.6), including the strict +4 rule against `hand`. */
export function unoMatches(s: OneCardState, c: Card, hand: readonly Card[]): boolean {
const color = currentColor(s);
if (c.kind === 'wild') return true;
if (c.kind === 'wild4') {
if (s.options.unoWild4Rule !== 'strict' || color === null) return true;
return !hand.some((h) => h.id !== c.id && colorOf(h) === color);
}
if (color === null) return true;
return colorOf(c) === color || unoSymbolMatch(top(s), c);
}
/** Colour cards stacking (§5.6 unoStacking): +2 on +2, +4 on +2 or +4. */
export function unoCanStack(t: Card, c: Card): boolean {
if (t.kind === 'draw2') return c.kind === 'draw2' || c.kind === 'wild4';
if (t.kind === 'wild4') return c.kind === 'wild4';
return false;
}
/**
* May `c` start a play by `p` right now (phase turn / afterDraw / wild4Response stacking)?
* The last-card rule is checked against a single-card play here; multi-play callers re-check.
*/
export function canStartWith(s: OneCardState, p: PlayerId, c: Card): boolean {
const hand = s.hands[p] ?? [];
if (s.mode === 'classic') {
let ok: boolean;
if (s.pendingAttack > 0) ok = canCounter(top(s), c, s.decks === 2) || canBlockWithThree(s.options, top(s), c);
else ok = classicMatches(s, c);
if (!ok) return false;
if (hand.length === 1 && forbiddenAsLast(s.options, c)) return false;
return true;
}
if (s.pendingAttack > 0) return s.options.unoStacking && unoCanStack(top(s), c);
return unoMatches(s, c, hand);
}
/** Card ids `p` may play now (first card of a play), respecting phase. */
export function legalCardIds(s: OneCardState, p: PlayerId): string[] {
if (s.current !== p && !(s.phase.kind === 'wild4Response' && s.phase.target === p)) return [];
const hand = s.hands[p] ?? [];
switch (s.phase.kind) {
case 'turn':
if (s.current !== p) return [];
return hand.filter((c) => canStartWith(s, p, c)).map((c) => c.id);
case 'afterDraw': {
const id = s.phase.drawnCardId;
const c = hand.find((x) => x.id === id);
return c && canStartWith(s, p, c) ? [id] : [];
}
case 'wild4Response':
if (s.phase.target !== p || !s.options.unoStacking) return [];
return hand.filter((c) => c.kind === 'wild4').map((c) => c.id);
default:
return [];
}
}
/** Beginner hint for why a card fits (§9 "힌트"). */
export function hintFor(s: OneCardState, c: Card): string {
const t = top(s);
if (s.mode === 'classic') {
if (s.pendingAttack > 0) {
if (canBlockWithThree(s.options, t, c) && !canCounter(t, c, s.decks === 2)) return '같은 무늬 3으로 공격을 막아요';
return '공격을 받아쳐요';
}
if (isJoker(c)) return '조커는 언제든 낼 수 있어요';
if (isJoker(t) && s.options.afterJoker === 'any') return '조커 다음이라 아무 카드나 돼요';
const ref = referenceCard(s);
if (!isStd(c) || !isStd(ref)) return '';
if (s.declaredSuit) return c.suit === s.declaredSuit ? `지정된 ${SUIT_SYMBOL[s.declaredSuit]} 무늬예요` : '7은 7 위에 낼 수 있어요';
if (c.suit === ref.suit) return `${SUIT_SYMBOL[c.suit]} 무늬가 같아요`;
return `${rankLabel(c.rank)} 숫자가 같아요`;
}
if (s.pendingAttack > 0) return '받아쳐서 다음 사람에게 넘겨요';
if (c.kind === 'wild' || c.kind === 'wild4') return '색을 바꾸는 카드예요';
const color = currentColor(s);
if (color === null) return '아무 색이나 낼 수 있어요';
if (colorOf(c) === color) return `${COLOR_KO[color]} 색이 같아요`;
return '숫자·기호가 같아요';
}

View File

@@ -0,0 +1,263 @@
/** 원카드 options, actions, state and view types (docs/games/one-card.md §2, §8). */
import { z } from 'zod';
import type { PlayerId } from '@bg/engine';
import type { Card, Color, Suit } from './cards';
const lit = <T extends string | number>(...vals: T[]) =>
z.union(vals.map((v) => z.literal(v)) as unknown as [z.ZodLiteral<T>, z.ZodLiteral<T>, ...z.ZodLiteral<T>[]]);
export const oneCardOptionsSchema = z.object({
mode: z
.enum(['classic', 'uno-style'])
.default('classic')
.meta({ title: '게임 모드', labels: { classic: '원카드(트럼프)', 'uno-style': '색깔 카드' } }),
handSize: lit(5, 6, 7).default(7).meta({ title: '처음 받는 카드 수', labels: { 5: '5장', 6: '6장', 7: '7장' } }),
deckCount: lit<'auto' | 1 | 2>('auto', 1, 2)
.default('auto')
.meta({ title: '(원카드) 덱 수', labels: { auto: '자동(6명부터 2벌)', 1: '1벌', 2: '2벌' } }),
attack2: lit(1, 2, 3).default(2).meta({ title: '(원카드) 2 공격 장수', labels: { 1: '1장', 2: '2장', 3: '3장' } }),
attackA: lit(2, 3, 4, 5).default(3).meta({ title: '(원카드) A 공격 장수(♠ 제외)', labels: { 2: '2장', 3: '3장', 4: '4장', 5: '5장' } }),
attackSpadeA: z.number().int().min(3).max(10).default(5).meta({ title: '(원카드) ♠A 공격 장수(3~10)' }),
attackBlackJoker: z.number().int().min(5).max(10).default(5).meta({ title: '(원카드) 흑백 조커 공격 장수(5~10)' }),
attackColorJoker: z.number().int().min(5).max(13).default(7).meta({ title: '(원카드) 컬러 조커 공격 장수(5~13)' }),
threeDefense: z
.enum(['off', 'same-suit'])
.default('same-suit')
.meta({ title: '(원카드) 3으로 막기', labels: { off: '안 씀', 'same-suit': '같은 무늬 3으로 2·A 공격 막기' } }),
kEffect: z
.enum(['extra-turn', 'skip-two', 'none'])
.default('extra-turn')
.meta({ title: '(원카드) K 효과', labels: { 'extra-turn': '한 번 더', 'skip-two': '두 명 건너뛰기', none: '효과 없음' } }),
qInTwoPlayer: z
.enum(['no-effect', 'as-skip'])
.default('no-effect')
.meta({ title: '(원카드) 2명일 때 Q', labels: { 'no-effect': '효과 없음', 'as-skip': 'J처럼 한 번 더' } }),
lastCardRule: z
.enum(['none', 'joker', 'attack', 'special'])
.default('joker')
.meta({
title: '(원카드) 마지막 카드로 낼 수 없는 카드',
labels: { none: '제한 없음', joker: '조커', attack: '공격 카드(2·A·조커)', special: '특수 카드 모두' },
}),
afterJoker: z
.enum(['previous', 'any'])
.default('previous')
.meta({ title: '(원카드) 조커 다음에 낼 카드', labels: { previous: '조커 밑 카드 기준', any: '아무 카드' } }),
multiPlay: z.boolean().default(false).meta({ title: '(원카드) 같은 숫자 여러 장 한꺼번에 내기' }),
oneCardPenalty: lit<'auto' | 1 | 2 | 3>('auto', 1, 2, 3)
.default('auto')
.meta({ title: '원카드를 못 외치고 잡히면 먹는 장수', labels: { auto: '모드 기본(원카드 1장, 색깔 카드 2장)', 1: '1장', 2: '2장', 3: '3장' } }),
autoDeclare: z.boolean().default(false).meta({ title: '1장 남으면 자동으로 원카드 외치기' }),
bankruptLimit: lit<'auto' | 15 | 18 | 20 | 25 | 'none'>('auto', 15, 18, 20, 25, 'none')
.default('auto')
.meta({
title: '파산 장수(이만큼 쌓이면 탈락)',
labels: { auto: '모드 기본(원카드 20장, 색깔 카드 없음)', 15: '15장', 18: '18장', 20: '20장', 25: '25장', none: '파산 없음' },
}),
voluntaryDraw: z.boolean().default(true).meta({ title: '낼 카드가 있어도 1장 먹고 넘기기' }),
playAfterDraw: lit('auto', 'on', 'off')
.default('auto')
.meta({ title: '먹은 카드를 바로 내기', labels: { auto: '모드 기본(원카드 안 됨, 색깔 카드 됨)', on: '바로 낼 수 있음', off: '안 됨' } }),
endCondition: z
.enum(['first-out', 'last-standing'])
.default('first-out')
.meta({ title: '끝나는 때', labels: { 'first-out': '1등이 나오면', 'last-standing': '꼴찌가 정해질 때까지' } }),
turnSeconds: lit(10, 15, 20, 30, 60)
.default(20)
.meta({ title: '한 턴 제한 시간', labels: { 10: '10초', 15: '15초', 20: '20초', 30: '30초', 60: '60초' } }),
unoStacking: z.boolean().default(false).meta({ title: '(색깔 카드) +2·+4 받아치기' }),
unoWild4Rule: z
.enum(['strict', 'challenge', 'free'])
.default('strict')
.meta({
title: '(색깔 카드) 색 바꾸기+4 내는 조건',
labels: { strict: '지금 색 카드가 없을 때만', challenge: '아무 때나(다음 사람이 의심하기 가능)', free: '제한 없음' },
}),
unoTargetScore: lit(0, 200, 500)
.default(0)
.meta({ title: '(색깔 카드) 목표 점수(여러 판)', labels: { 0: '없음(한 판)', 200: '200점', 500: '500점' } }),
});
export type OneCardOptions = z.infer<typeof oneCardOptionsSchema>;
export type OneCardMode = OneCardOptions['mode'];
export const oneCardActionSchema = z.discriminatedUnion('type', [
z.object({
type: z.literal('play'),
cardIds: z.array(z.string().max(8)).min(1).max(8),
chooseSuit: z.enum(['S', 'H', 'D', 'C']).optional(),
chooseColor: z.enum(['R', 'Y', 'G', 'B']).optional(),
declareOneCard: z.boolean().optional(),
}),
z.object({ type: z.literal('draw') }),
z.object({ type: z.literal('pass') }),
z.object({ type: z.literal('declareOneCard') }),
z.object({ type: z.literal('challengeWild4') }),
z.object({ type: z.literal('acceptWild4') }),
z.object({ type: z.literal('ready') }),
]);
/** Client actions plus system-only ones (timer, leaving) that are not in the schema. */
export type OneCardAction = z.infer<typeof oneCardActionSchema> | { type: 'timeout' } | { type: 'leave' };
export type PlayAction = Extract<OneCardAction, { type: 'play' }>;
export type PlayerStatus = 'playing' | 'out' | 'bankrupt' | 'left';
export type Phase =
| { kind: 'turn' }
| { kind: 'afterDraw'; drawnCardId: string }
/** `illegal`: the +4 was played while holding a card of `prevColor` (secret until challenged). */
| { kind: 'wild4Response'; by: PlayerId; target: PlayerId; prevColor: Color | null; illegal: boolean }
| { kind: 'roundEnd' }
| { kind: 'finished' };
/** Public log line (structured; the screen turns it into Korean with nicknames). */
export type LogEntry =
/** Played cards without ids: ids are reused after a reshuffle and must not hint at hidden cards. */
| { seq: number; t: 'play'; p: PlayerId; cards: CardFace[]; suit?: Suit; color?: Color }
| { seq: number; t: 'draw'; p: PlayerId; n: number; why: 'normal' | 'attack' | 'penalty' | 'draw2' | 'wild4' | 'challenge' | 'start' }
| { seq: number; t: 'pass'; p: PlayerId }
| { seq: number; t: 'blocked'; p: PlayerId }
| { seq: number; t: 'declare'; p: PlayerId }
| { seq: number; t: 'caught'; p: PlayerId; by: PlayerId; n: number }
| { seq: number; t: 'bankrupt'; p: PlayerId }
| { seq: number; t: 'out'; p: PlayerId; place: number }
| { seq: number; t: 'left'; p: PlayerId }
| { seq: number; t: 'timeout'; p: PlayerId }
| { seq: number; t: 'reshuffle'; n: number }
| { seq: number; t: 'challenge'; p: PlayerId; by: PlayerId; illegal: boolean }
| { seq: number; t: 'skip'; p: PlayerId }
| { seq: number; t: 'reverse' }
| { seq: number; t: 'round'; round: number; winner: PlayerId | null; points: number };
/** A card without its id (shown to the +4 challenger, so leak checks on ids stay meaningful). */
export type CardFace = Card extends infer C ? (C extends Card ? Omit<C, 'id'> : never) : never;
export interface PlayerResult {
player: PlayerId;
rank: number;
status: PlayerStatus;
cardsLeft: number;
/** Classic: remaining card points (lower is better). Colour cards: points won this game. */
points: number;
}
export interface OneCardFinal {
ranking: PlayerId[][];
players: PlayerResult[];
scores?: Record<PlayerId, number>;
summary: string;
}
export interface RoundRecord {
round: number;
winner: PlayerId | null;
points: number;
}
export interface OneCardState {
mode: OneCardMode;
options: OneCardOptions;
/** Decks actually in use (classic). */
decks: 1 | 2;
seats: PlayerId[];
hands: Record<PlayerId, Card[]>;
/** Secret; [0] is the top. */
drawPile: Card[];
/** Public; the last card is the top. */
discard: Card[];
declaredSuit: Suit | null;
declaredColor: Color | null;
direction: 1 | -1;
current: PlayerId;
phase: Phase;
pendingAttack: number;
extraTurn: boolean;
oneCardWindow: { player: PlayerId; declared: boolean; openedSeq: number } | null;
status: Record<PlayerId, PlayerStatus>;
finishOrder: PlayerId[];
bankruptOrder: PlayerId[];
leftOrder: PlayerId[];
turnStartedAt: number;
/** Applied actions. */
seq: number;
/** Turn counter (changes whenever a new turn starts). */
turnNo: number;
timeoutsInRow: Record<PlayerId, number>;
away: Record<PlayerId, boolean>;
/** Colour-card multi-game scoring (§6). */
round: number;
roundStarter: PlayerId;
scores: Record<PlayerId, number>;
rounds: RoundRecord[];
ready: PlayerId[];
/** Hand shown to the challenger after a +4 challenge (only in that player's view). */
reveal: { to: PlayerId; by: PlayerId; cards: CardFace[]; illegal: boolean; seq: number } | null;
log: LogEntry[];
final: OneCardFinal | null;
}
// ---------------------------------------------------------------- view
export interface SeatView {
id: PlayerId;
cards: number;
status: PlayerStatus;
isTurn: boolean;
away: boolean;
/** One-card window open for this player (declared = safe). */
oneCard: 'open' | 'declared' | null;
score: number;
ready: boolean;
}
export interface OneCardView {
mode: OneCardMode;
options: OneCardOptions;
decks: 1 | 2;
penalty: number;
bankruptLimit: number | null;
playAfterDraw: boolean;
me: PlayerId | null;
seats: SeatView[];
current: PlayerId | null;
direction: 1 | -1;
phase: Phase['kind'];
turnNo: number;
extraTurn: boolean;
/** Last few discards, top last. */
recent: Card[];
/** Classic: the card below jokers that decides what may follow (afterJoker = previous). */
reference: Card | null;
declaredSuit: Suit | null;
declaredColor: Color | null;
/** Colour cards: colour to follow (null = any). */
currentColor: Color | null;
pendingAttack: number;
drawPileCount: number;
discardCount: number;
oneCardWindow: { player: PlayerId; declared: boolean } | null;
wild4: { by: PlayerId; target: PlayerId } | null;
my: {
hand: Card[];
/** Cards that may be played now (first card of a play). */
legalCardIds: string[];
/** Why each legal card fits (beginner hint). */
hints: Record<string, string>;
canDraw: boolean;
/** Cards a draw takes now. */
drawCount: number;
canPass: boolean;
drawnCardId: string | null;
canDeclare: boolean;
canChallenge: boolean;
canReady: boolean;
/** Must follow the multi-play rule (same rank). */
multiPlay: boolean;
} | null;
reveal: { by: PlayerId; cards: CardFace[]; illegal: boolean } | null;
turnStartedAt: number;
deadlineTotalMs: number;
round: number;
rounds: RoundRecord[];
log: LogEntry[];
result: OneCardFinal | null;
}

View File

@@ -0,0 +1,832 @@
/** 숫자 타일 (루미큐브 방식, docs/games/rummikub.md). Scores are room-only result points (§6, §12). */
import { z } from 'zod';
import { fail, ok, type GameDefinition, type GameEvent, type GameResult, type PlayerId, type Rng, type Validation } from '@bg/engine';
import {
ALL_TILE_IDS,
arrangementKey,
extendSet,
findMeld,
isJoker,
pickDisjoint,
normalizeSet,
rackValue,
setProblem,
tileOf,
type DraftSet,
type JokerAs,
type NormalSet,
type TileColor,
} from './rules';
export * from './rules';
const lit = <T extends number>(...vals: T[]) =>
z.union(vals.map((v) => z.literal(v)) as unknown as [z.ZodLiteral<T>, z.ZodLiteral<T>, ...z.ZodLiteral<T>[]]);
export const rummikubOptionsSchema = z.object({
initialMeldPoints: lit(0, 20, 30, 50)
.default(30)
.meta({ title: '첫 등록 최소 점수', labels: { 0: '없음', 20: '20점', 30: '30점(공식)', 50: '50점' } }),
turnSeconds: lit(60, 90, 120, 180)
.default(60)
.meta({ title: '한 차례 제한 시간', labels: { 60: '1분(공식)', 90: '1분 30초', 120: '2분', 180: '3분' } }),
timeoutValidDraft: z
.enum(['commit', 'revert'])
.default('commit')
.meta({ title: '시간이 다 됐을 때 올바르게 놓아 둔 타일', labels: { commit: '자동으로 확정', revert: '되돌리고 한 장 가져오기(공식)' } }),
invalidTimeoutPenalty: lit(1, 3)
.default(3)
.meta({ title: '시간이 다 됐는데 덜 맞췄을 때 가져올 장수', labels: { 1: '1장', 3: '3장(공식)' } }),
invalidCommitPenalty: z
.enum(['reject', 'penalty3'])
.default('reject')
.meta({ title: '틀린 채로 확정을 누르면', labels: { reject: '알려 주고 계속 고치기', penalty3: '되돌리고 3장 가져오기' } }),
jokerPenalty: lit(30, 50)
.default(30)
.meta({ title: '받침대에 남은 조커 감점', labels: { 30: '30점(공식)', 50: '50점(대회)' } }),
initialMeldAllowExtend: z.boolean().default(false).meta({ title: '첫 등록하는 차례에 바닥 묶음에도 붙이기 허용' }),
showDraftToOthers: z.boolean().default(false).meta({ title: '고민 중인 배치를 다른 사람에게 보이기(되돌려도 이미 본 타일은 남아요)' }),
gamesPerMatch: z
.enum(['1', 'players', '2', '3', '4'])
.default('1')
.meta({ title: '판 수', labels: { 1: '1판', players: '인원수만큼(공식)', 2: '2판', 3: '3판', 4: '4판' } }),
hints: z.boolean().default(true).meta({ title: '"도와줘" 힌트 버튼' }),
});
export type RummikubOptions = z.infer<typeof rummikubOptionsSchema>;
const draftSetSchema = z.object({
tileIds: z.array(z.string().max(4)).min(1).max(106),
as: z.enum(['run', 'group']).optional(),
});
export const rummikubActionSchema = z.discriminatedUnion('type', [
z.object({ type: z.literal('setDraft'), sets: z.array(draftSetSchema).max(106), turn: z.number().int().optional() }),
z.object({ type: z.literal('undoDraft') }),
z.object({ type: z.literal('resetDraft') }),
z.object({ type: z.literal('commit'), turn: z.number().int().optional() }),
z.object({ type: z.literal('draw') }),
z.object({ type: z.literal('pass') }),
z.object({ type: z.literal('ready') }),
]);
/** Client actions plus the system-only ones (timer, leaving) that are not in the schema. */
export type RummikubAction = z.infer<typeof rummikubActionSchema> | { type: 'timeout' } | { type: 'leave' };
// ---------------------------------------------------------------- state
export interface TableSet {
/** Kept across commits for the set with the biggest overlap (animation continuity). */
id: string;
tileIds: string[];
kind: 'run' | 'group';
jokerAs: Record<string, JokerAs>;
}
export type MoveKind = 'commit' | 'draw' | 'pass' | 'timeoutCommit' | 'timeoutDraw' | 'penalty' | 'left';
export interface LogEntry {
turn: number;
by: PlayerId;
kind: MoveKind;
/** Tiles placed (commit) or drawn. */
count: number;
}
export interface RoundResult {
gameNo: number;
endReason: 'rackEmpty' | 'stalemate' | 'abandoned';
winners: PlayerId[];
scores: Record<PlayerId, number>;
rackValues: Record<PlayerId, number>;
/** Racks revealed for counting at the end of the round (public from then on). */
revealed: Record<PlayerId, string[]>;
/** Rank groups for this round, best first. */
ranking: PlayerId[][];
}
export interface OpeningDraw {
round: number;
player: PlayerId;
/** No tile ids here: the tiles go back into the bag and are shuffled again. */
color: TileColor | null;
n: number | null;
}
export interface RummikubState {
options: RummikubOptions;
/** Seat order = turn order (clockwise). */
seats: PlayerId[];
left: PlayerId[];
/** Players dealt into the current round. */
roundPlayers: PlayerId[];
pool: string[];
racks: Record<PlayerId, string[]>;
table: TableSet[];
nextSetId: number;
hasMelded: Record<PlayerId, boolean>;
current: PlayerId;
turnNo: number;
turnStartedAt: number;
draft: { sets: DraftSet[]; history: DraftSet[][]; edits: number };
timeoutsInRow: Record<PlayerId, number>;
consecutivePasses: number;
/** Tiles each player drew at the end of their last turn (shown only to them). */
lastDrawn: Record<PlayerId, string[]>;
/** Set ids that changed in the last commit (1 s highlight on screen). */
lastChanged: string[];
log: LogEntry[];
phase: 'playing' | 'roundEnd' | 'finished';
roundEndAt: number | null;
ready: PlayerId[];
opening: OpeningDraw[];
round: RoundResult | null;
match: { gameNo: number; total: number; wins: Record<PlayerId, number>; totals: Record<PlayerId, number> };
final: { ranking: PlayerId[][]; scores: Record<PlayerId, number>; summary: string; reason: 'normal' | 'abandoned' } | null;
}
// ---------------------------------------------------------------- view
export interface DraftSetView {
tileIds: string[];
as?: 'run' | 'group';
ok: boolean;
/** Short reason when not valid. */
problem: string | null;
kind: 'run' | 'group' | null;
jokerAs: Record<string, JokerAs>;
points: number;
/** Both readings possible ([빨강5, 조커, 조커]) — the screen offers a toggle. */
ambiguous: boolean;
}
export interface SeatView {
id: PlayerId;
tiles: number;
melded: boolean;
left: boolean;
away: boolean;
wins: number;
total: number;
inRound: boolean;
}
export interface TurnCheck {
ok: boolean;
reason: string | null;
invalidIdx: number[];
/** Points of new rack-only sets (first meld progress). */
meldPoints: number;
fromRack: number;
}
export interface RummikubView {
phase: RummikubState['phase'];
options: RummikubOptions;
seats: SeatView[];
current: PlayerId | null;
turnNo: number;
turnStartedAt: number;
turnMs: number;
poolCount: number;
table: TableSet[];
lastChanged: string[];
log: LogEntry[];
/** Number of edits the current player made this turn (shown as "생각 중"). */
draftEdits: number;
/** Full draft: the current player always, others only with showDraftToOthers. */
draft: DraftSetView[] | null;
me: PlayerId | null;
my: {
rack: string[];
lastDrawn: string[];
melded: boolean;
canUndo: boolean;
changed: boolean;
check: TurnCheck | null;
needReady: boolean;
} | null;
opening: OpeningDraw[];
round: RoundResult | null;
match: RummikubState['match'];
roundEndAt: number | null;
ready: PlayerId[];
result: RummikubState['final'];
}
// ---------------------------------------------------------------- helpers
const AWAY_MS = 3000;
const ROUND_END_MS = 30_000;
const HISTORY_MAX = 100;
const activeSeats = (s: RummikubState): PlayerId[] => s.roundPlayers.filter((p) => !s.left.includes(p));
const startSets = (s: RummikubState): DraftSet[] => s.table.map((t) => ({ tileIds: [...t.tileIds], as: t.kind }));
const tableTiles = (s: RummikubState): Set<string> => new Set(s.table.flatMap((t) => t.tileIds));
const draftChanged = (s: RummikubState): boolean => arrangementKey(s.draft.sets) !== arrangementKey(s.table);
const cleanSet = (d: DraftSet): DraftSet => (d.as ? { tileIds: [...d.tileIds], as: d.as } : { tileIds: [...d.tileIds] });
function totalGames(o: RummikubOptions, players: number): number {
return o.gamesPerMatch === 'players' ? players : Number(o.gamesPerMatch);
}
/** C1–C3 (§8.2): tiles come from the start table or my rack, no duplicates, table tiles stay, first-meld rule. */
function conserve(s: RummikubState, p: PlayerId, sets: readonly DraftSet[]): string | null {
const start = tableTiles(s);
const rack = new Set(s.racks[p] ?? []);
const seen = new Set<string>();
for (const d of sets) {
if (d.tileIds.length === 0) return '빈 묶음이 있어요.';
for (const id of d.tileIds) {
if (seen.has(id)) return '같은 타일이 두 번 들어 있어요.';
seen.add(id);
if (!start.has(id) && !rack.has(id)) return '내 받침대나 바닥에 없는 타일이에요.';
}
}
for (const id of start) if (!seen.has(id)) return '바닥의 타일은 내 받침대로 가져올 수 없어요.';
if (!s.hasMelded[p] && !s.options.initialMeldAllowExtend) {
const keys = new Set(sets.map((d) => [...d.tileIds].sort().join(',')));
for (const t of s.table) if (!keys.has([...t.tileIds].sort().join(','))) return '첫 등록 전에는 바닥의 타일을 옮길 수 없어요.';
}
return null;
}
type TurnOutcome = { ok: true; normalized: NormalSet[]; fromRack: string[]; meldPoints: number } | { ok: false; reason: string; invalidIdx: number[]; meldPoints: number; fromRack: string[] };
/** Turn commit validation (§8.2 validateTurn), shared by commit, timeout and the live check in the view. */
function validateTurn(s: RummikubState, p: PlayerId, sets: readonly DraftSet[], pre?: (NormalSet | null)[]): TurnOutcome {
const start = tableTiles(s);
const fromRack = sets.flatMap((d) => d.tileIds).filter((id) => !start.has(id));
const normalized = pre ?? sets.map(normalizeSet);
const newOnly = normalized.filter((n, i) => n && sets[i]!.tileIds.every((id) => !start.has(id))) as NormalSet[];
const meldPoints = newOnly.reduce((a, n) => a + n.points, 0);
const bad = (reason: string, invalidIdx: number[] = []): TurnOutcome => ({ ok: false, reason, invalidIdx, meldPoints, fromRack });
const c = conserve(s, p, sets);
if (c) return bad(c);
if (fromRack.length === 0) return bad('받침대에서 타일을 1개 이상 내야 해요.');
const invalidIdx = normalized.flatMap((n, i) => (n ? [] : [i]));
if (invalidIdx.length) return bad('빨간 표시된 묶음을 고쳐 주세요.', invalidIdx);
if (!s.hasMelded[p]) {
const min = s.options.initialMeldPoints;
if (meldPoints < min) return bad(`첫 등록은 ${min}점 이상이어야 해요 (지금 ${meldPoints}점).`);
}
return { ok: true, normalized: normalized as NormalSet[], fromRack, meldPoints };
}
/** New table from normalized sets; ids are inherited from the old set with the biggest overlap. */
function buildTable(s: RummikubState, normalized: NormalSet[]): { table: TableSet[]; changed: string[] } {
const taken = new Set<string>();
const changed: string[] = [];
const table = normalized.map((n) => {
let best: TableSet | null = null;
let bestOverlap = 0;
for (const t of s.table) {
if (taken.has(t.id)) continue;
const ov = t.tileIds.filter((id) => n.tileIds.includes(id)).length;
if (ov > bestOverlap) {
best = t;
bestOverlap = ov;
}
}
let id: string;
if (best) {
taken.add(best.id);
id = best.id;
if (best.tileIds.join(',') !== n.tileIds.join(',')) changed.push(id);
} else {
id = `s${s.nextSetId++}`;
changed.push(id);
}
return { id, tileIds: n.tileIds, kind: n.kind, jokerAs: n.jokerAs };
});
return { table, changed };
}
function drawTiles(s: RummikubState, p: PlayerId, count: number): string[] {
const got = s.pool.splice(0, Math.min(count, s.pool.length));
s.racks[p]!.push(...got);
return got;
}
function pushLog(s: RummikubState, by: PlayerId, kind: MoveKind, count: number): void {
s.log.push({ turn: s.turnNo, by, kind, count });
if (s.log.length > 12) s.log.splice(0, s.log.length - 12);
}
function startTurn(s: RummikubState, p: PlayerId, now: number): void {
s.current = p;
s.turnNo += 1;
s.turnStartedAt = now;
s.draft = { sets: startSets(s), history: [], edits: 0 };
}
function nextPlayer(s: RummikubState, from: PlayerId): PlayerId {
const order = s.roundPlayers;
const i = order.indexOf(from);
for (let k = 1; k <= order.length; k++) {
const p = order[(i + k) % order.length]!;
if (!s.left.includes(p)) return p;
}
return from;
}
/** Turn ended without placing tiles: draw `count` (if the pool has any) or count it as a pass (§5.8). */
function endWithoutPlay(s: RummikubState, p: PlayerId, count: number, kind: MoveKind, now: number, events: GameEvent[]): void {
s.draft = { sets: startSets(s), history: [], edits: 0 };
const got = drawTiles(s, p, count);
s.lastDrawn[p] = got;
s.lastChanged = [];
if (got.length > 0) {
pushLog(s, p, kind, got.length);
events.push({ type: 'drew', player: p, count: got.length, penalty: kind === 'penalty' });
} else {
pushLog(s, p, 'pass', 0);
events.push({ type: 'passed', player: p });
s.consecutivePasses += 1;
if (s.consecutivePasses >= activeSeats(s).length) {
endRound(s, 'stalemate', null, now, events);
return;
}
}
startTurn(s, nextPlayer(s, p), now);
}
function commitTurn(s: RummikubState, p: PlayerId, out: Extract<TurnOutcome, { ok: true }>, kind: MoveKind, now: number, events: GameEvent[]): void {
const { table, changed } = buildTable(s, out.normalized);
s.table = table;
const placed = new Set(out.fromRack);
s.racks[p] = s.racks[p]!.filter((id) => !placed.has(id));
s.hasMelded[p] = true;
s.consecutivePasses = 0;
s.lastDrawn[p] = [];
s.lastChanged = changed;
pushLog(s, p, kind, out.fromRack.length);
events.push({ type: 'committed', player: p, count: out.fromRack.length, changed });
if (s.racks[p]!.length === 0) endRound(s, 'rackEmpty', p, now, events);
else startTurn(s, nextPlayer(s, p), now);
}
function rankByScore(players: PlayerId[], winners: PlayerId[], scores: Record<PlayerId, number>): PlayerId[][] {
const rest = players.filter((p) => !winners.includes(p)).sort((a, b) => scores[b]! - scores[a]!);
const groups: PlayerId[][] = winners.length ? [[...winners]] : [];
for (const p of rest) {
const last = groups[groups.length - 1];
if (last && !winners.includes(last[0]!) && scores[last[0]!] === scores[p]) last.push(p);
else groups.push([p]);
}
return groups;
}
/** §6 scoring. */
export function scoreRound(
racks: Record<PlayerId, string[]>,
players: PlayerId[],
reason: 'rackEmpty' | 'stalemate',
jokerPenalty: number,
eligible: PlayerId[] = players,
): { winners: PlayerId[]; scores: Record<PlayerId, number>; rackValues: Record<PlayerId, number> } {
const rackValues: Record<PlayerId, number> = {};
for (const p of players) rackValues[p] = rackValue(racks[p] ?? [], jokerPenalty);
const scores: Record<PlayerId, number> = {};
let winners: PlayerId[];
if (reason === 'rackEmpty') {
winners = players.filter((p) => (racks[p] ?? []).length === 0).slice(0, 1);
} else {
const pool = eligible.length ? eligible : players;
const minV = Math.min(...pool.map((p) => rackValues[p]!));
let low = pool.filter((p) => rackValues[p] === minV);
const minN = Math.min(...low.map((p) => (racks[p] ?? []).length));
low = low.filter((p) => (racks[p] ?? []).length === minN);
winners = low;
}
const base = reason === 'rackEmpty' ? 0 : rackValues[winners[0]!]!;
let sum = 0;
for (const p of players) {
if (winners.includes(p)) continue;
scores[p] = -(rackValues[p]! - base);
sum += rackValues[p]! - base;
}
for (const w of winners) scores[w] = Math.floor(sum / winners.length);
return { winners, scores, rackValues };
}
function endRound(s: RummikubState, reason: 'rackEmpty' | 'stalemate', by: PlayerId | null, now: number, events: GameEvent[]): void {
const players = s.roundPlayers;
const eligible = activeSeats(s);
const { winners, scores, rackValues } = scoreRound(s.racks, players, reason, s.options.jokerPenalty, eligible);
if (by && !winners.includes(by)) throw new Error('rack-empty winner mismatch');
for (const p of players) s.match.totals[p] = (s.match.totals[p] ?? 0) + scores[p]!;
for (const w of winners) s.match.wins[w] = (s.match.wins[w] ?? 0) + 1;
const revealed: Record<PlayerId, string[]> = {};
for (const p of players) revealed[p] = [...s.racks[p]!];
s.round = { gameNo: s.match.gameNo, endReason: reason, winners, scores, rackValues, revealed, ranking: rankByScore(players, winners, scores) };
s.draft = { sets: startSets(s), history: [], edits: 0 };
events.push({ type: 'roundEnded', reason, winners, scores });
if (s.match.gameNo >= s.match.total || eligible.length < 2) finishMatch(s, eligible.length < 2 ? 'abandoned' : 'normal');
else {
s.phase = 'roundEnd';
s.roundEndAt = now;
s.ready = [...s.left];
}
}
function finishMatch(s: RummikubState, reason: 'normal' | 'abandoned'): void {
s.phase = 'finished';
s.roundEndAt = null;
const present = s.seats.filter((p) => !s.left.includes(p));
const { wins, totals } = s.match;
const sorted = [...present].sort((a, b) => (wins[b] ?? 0) - (wins[a] ?? 0) || (totals[b] ?? 0) - (totals[a] ?? 0));
const ranking: PlayerId[][] = [];
for (const p of sorted) {
const last = ranking[ranking.length - 1];
if (last && (wins[last[0]!] ?? 0) === (wins[p] ?? 0) && (totals[last[0]!] ?? 0) === (totals[p] ?? 0)) last.push(p);
else ranking.push([p]);
}
const gone = s.seats.filter((p) => s.left.includes(p));
if (gone.length) ranking.push(gone);
const scores: Record<PlayerId, number> = {};
for (const p of s.seats) scores[p] = totals[p] ?? 0;
const top = ranking[0] ?? [];
const topScore = scores[top[0]!] ?? 0;
const pts = `${topScore >= 0 ? '+' : ''}${topScore}점`;
const who = top.length > 1 ? '공동 1위' : '1위';
let summary: string;
if (reason === 'abandoned') summary = '다른 참가자가 모두 나가서 끝났어요';
else if (s.match.total > 1) summary = `${s.match.gameNo}판 끝 · ${who} ${wins[top[0]!] ?? 0}승, 합계 ${pts}`;
else if (s.round?.endReason === 'rackEmpty') summary = `타일을 모두 내려놓았어요! 다 냈다! (${who} ${pts})`;
else summary = `더 낼 수 있는 사람이 없어 끝났어요. 남은 타일 점수가 가장 적은 ${who} (${pts})`;
s.final = { ranking, scores, summary, reason };
}
/** §4: highest single draw starts (jokers and ties draw again), then everything is reshuffled and dealt. */
function setupRound(s: RummikubState, rng: Rng, now: number): void {
const players = s.seats.filter((p) => !s.left.includes(p));
s.roundPlayers = players;
const opening: OpeningDraw[] = [];
let cands = [...players];
let first = cands[0]!;
for (let round = 1; round <= 50; round++) {
const bag = rng.shuffle(ALL_TILE_IDS);
let k = 0;
const val = new Map<PlayerId, number>();
for (const p of cands) {
// A joker has no number: that player draws again (§4).
for (;;) {
const t = tileOf(bag[k++]!);
opening.push({ round, player: p, color: t.kind === 'num' ? t.color : null, n: t.kind === 'num' ? t.n : null });
if (t.kind === 'num') {
val.set(p, t.n);
break;
}
}
}
const max = Math.max(...val.values());
const top = cands.filter((p) => val.get(p) === max);
first = top[0]!;
if (top.length === 1) break;
cands = top;
}
const bag = rng.shuffle(ALL_TILE_IDS);
s.racks = {};
players.forEach((p, i) => {
s.racks[p] = bag.slice(i * 14, i * 14 + 14);
});
s.pool = bag.slice(players.length * 14);
s.table = [];
s.nextSetId = 1;
s.hasMelded = Object.fromEntries(players.map((p) => [p, s.options.initialMeldPoints === 0]));
s.consecutivePasses = 0;
s.lastDrawn = Object.fromEntries(players.map((p) => [p, []]));
s.lastChanged = [];
s.log = [];
s.opening = opening;
s.phase = 'playing';
s.roundEndAt = null;
s.ready = [];
s.match.gameNo += 1;
s.turnNo = 0;
startTurn(s, first, now);
}
function describeSet(d: DraftSet, n: NormalSet | null): DraftSetView {
// Only one number tile + jokers can be read both as a run and as a group.
const ambiguous = !!n && d.tileIds.length <= 4 && d.tileIds.filter((id) => !isJoker(id)).length === 1;
return {
tileIds: d.tileIds,
...(d.as ? { as: d.as } : {}),
ok: !!n,
problem: n ? null : setProblem(d),
kind: n?.kind ?? null,
jokerAs: n?.jokerAs ?? {},
points: n?.points ?? 0,
ambiguous,
};
}
// ---------------------------------------------------------------- definition
export const rummikub: GameDefinition<RummikubState, RummikubAction, RummikubView, RummikubOptions> = {
id: 'rummikub',
nameKo: '숫자 타일',
minPlayers: 2,
maxPlayers: 4,
stateVersion: 1,
defaultOptions: rummikubOptionsSchema.parse({}),
optionsSchema: rummikubOptionsSchema,
actionSchema: rummikubActionSchema as z.ZodType<RummikubAction>,
setup({ players, options, rng, now }) {
const s: RummikubState = {
options,
seats: [...players],
left: [],
roundPlayers: [],
pool: [],
racks: {},
table: [],
nextSetId: 1,
hasMelded: {},
current: players[0]!,
turnNo: 0,
turnStartedAt: now,
draft: { sets: [], history: [], edits: 0 },
timeoutsInRow: Object.fromEntries(players.map((p) => [p, 0])),
consecutivePasses: 0,
lastDrawn: {},
lastChanged: [],
log: [],
phase: 'playing',
roundEndAt: null,
ready: [],
opening: [],
round: null,
match: {
gameNo: 0,
total: totalGames(options, players.length),
wins: Object.fromEntries(players.map((p) => [p, 0])),
totals: Object.fromEntries(players.map((p) => [p, 0])),
},
final: null,
};
setupRound(s, rng, now);
return s;
},
validate(s, actor, a): Validation {
if (!s.seats.includes(actor)) return fail('이 게임의 플레이어가 아니에요.');
if (s.phase === 'finished') return fail('게임이 이미 끝났어요.');
if (a.type === 'leave') return s.left.includes(actor) ? fail('이미 나간 플레이어예요.') : ok;
if (s.left.includes(actor)) return fail('방을 나간 플레이어예요.');
if (s.phase === 'roundEnd') {
if (a.type !== 'ready' && a.type !== 'timeout') return fail('다음 판을 기다리는 중이에요.');
return s.ready.includes(actor) ? fail('이미 준비했어요.') : ok;
}
if (a.type === 'ready') return fail('지금은 할 수 없어요.');
if (actor !== s.current) return fail('지금은 내 차례가 아니에요.');
if ((a.type === 'setDraft' || a.type === 'commit') && a.turn !== undefined && a.turn !== s.turnNo) return fail('지난 차례의 요청이라 무시했어요.');
switch (a.type) {
case 'setDraft': {
const err = conserve(s, actor, a.sets);
return err ? fail(err) : ok;
}
case 'undoDraft':
return s.draft.history.length ? ok : fail('되돌릴 것이 없어요.');
case 'resetDraft':
return ok;
case 'commit': {
const r = validateTurn(s, actor, s.draft.sets);
if (r.ok) return ok;
if (s.options.invalidCommitPenalty === 'reject' || !draftChanged(s) || r.fromRack.length === 0) return fail(r.reason);
return ok;
}
case 'draw':
return ok;
case 'pass':
return s.pool.length ? fail('가져올 타일이 남아 있으면 넘길 수 없어요. 한 장 가져와 주세요.') : ok;
case 'timeout':
return ok;
}
},
apply(prev, actor, a, { rng, now }) {
// The state is plain JSON by contract; this is much faster than structuredClone.
const s = JSON.parse(JSON.stringify(prev)) as RummikubState;
const events: GameEvent[] = [];
if (a.type !== 'timeout' && a.type !== 'leave') s.timeoutsInRow[actor] = 0;
if (a.type === 'leave') {
s.left.push(actor);
events.push({ type: 'left', player: actor });
if (s.phase === 'roundEnd') {
if (!s.ready.includes(actor)) s.ready.push(actor);
if (s.seats.filter((p) => !s.left.includes(p)).length < 2) finishMatch(s, 'abandoned');
else if (s.seats.every((p) => s.ready.includes(p))) setupRound(s, rng, now);
} else if (activeSeats(s).length < 2) {
// Nobody left to play against: the match ends without scoring this round.
s.draft = { sets: startSets(s), history: [], edits: 0 };
finishMatch(s, 'abandoned');
} else if (s.current === actor) {
pushLog(s, actor, 'left', 0);
startTurn(s, nextPlayer(s, actor), now);
}
return { state: s, events };
}
if (s.phase === 'roundEnd') {
s.ready.push(actor);
if (s.seats.every((p) => s.ready.includes(p))) setupRound(s, rng, now);
return { state: s, events };
}
switch (a.type) {
case 'setDraft':
s.draft.history.push(s.draft.sets);
if (s.draft.history.length > HISTORY_MAX) s.draft.history.shift();
s.draft.sets = a.sets.map(cleanSet);
s.draft.edits += 1;
break;
case 'undoDraft':
s.draft.sets = s.draft.history.pop()!;
s.draft.edits += 1;
break;
case 'resetDraft':
if (draftChanged(s)) {
s.draft.history.push(s.draft.sets);
if (s.draft.history.length > HISTORY_MAX) s.draft.history.shift();
}
s.draft.sets = startSets(s);
s.draft.edits += 1;
break;
case 'commit': {
const r = validateTurn(s, actor, s.draft.sets);
if (r.ok) commitTurn(s, actor, r, 'commit', now, events);
else endWithoutPlay(s, actor, 3, 'penalty', now, events);
break;
}
case 'draw':
case 'pass':
endWithoutPlay(s, actor, 1, 'draw', now, events);
break;
case 'timeout': {
s.timeoutsInRow[actor] = (s.timeoutsInRow[actor] ?? 0) + 1;
events.push({ type: 'timeout', player: actor });
if (!draftChanged(s)) {
endWithoutPlay(s, actor, 1, 'timeoutDraw', now, events);
break;
}
const r = validateTurn(s, actor, s.draft.sets);
if (r.ok) {
if (s.options.timeoutValidDraft === 'commit') commitTurn(s, actor, r, 'timeoutCommit', now, events);
else endWithoutPlay(s, actor, 1, 'timeoutDraw', now, events);
} else if (r.fromRack.length === 0 && r.invalidIdx.length === 0) {
// Only rearranged (or nothing from the rack): put it back, no penalty.
endWithoutPlay(s, actor, 1, 'timeoutDraw', now, events);
} else endWithoutPlay(s, actor, s.options.invalidTimeoutPenalty, 'penalty', now, events);
break;
}
}
return { state: s, events };
},
view(s, viewer) {
const me = viewer && s.seats.includes(viewer) ? viewer : null;
const playing = s.phase === 'playing';
const isCur = playing && me === s.current;
const draftVisible = playing && (isCur || s.options.showDraftToOthers);
const norms = draftVisible ? s.draft.sets.map(normalizeSet) : [];
let check: TurnCheck | null = null;
if (isCur) {
const r = validateTurn(s, me, s.draft.sets, norms);
check = r.ok
? { ok: true, reason: null, invalidIdx: [], meldPoints: r.meldPoints, fromRack: r.fromRack.length }
: { ok: false, reason: r.reason, invalidIdx: r.invalidIdx, meldPoints: r.meldPoints, fromRack: r.fromRack.length };
}
return {
phase: s.phase,
options: s.options,
seats: s.seats.map((id) => ({
id,
tiles: (s.racks[id] ?? []).length,
melded: !!s.hasMelded[id],
left: s.left.includes(id),
away: (s.timeoutsInRow[id] ?? 0) >= 3,
wins: s.match.wins[id] ?? 0,
total: s.match.totals[id] ?? 0,
inRound: s.roundPlayers.includes(id),
})),
current: playing ? s.current : null,
turnNo: s.turnNo,
turnStartedAt: s.turnStartedAt,
turnMs: (s.timeoutsInRow[s.current] ?? 0) >= 3 ? AWAY_MS : s.options.turnSeconds * 1000,
poolCount: s.pool.length,
table: s.table,
lastChanged: s.lastChanged,
log: s.log,
draftEdits: playing ? s.draft.edits : 0,
draft: draftVisible ? s.draft.sets.map((d, i) => describeSet(d, norms[i] ?? null)) : null,
me,
my: me
? {
rack: s.racks[me] ?? [],
lastDrawn: s.lastDrawn[me] ?? [],
melded: !!s.hasMelded[me],
canUndo: isCur && s.draft.history.length > 0,
changed: isCur && draftChanged(s),
check,
needReady: s.phase === 'roundEnd' && !s.ready.includes(me) && !s.left.includes(me),
}
: null,
opening: s.opening,
// Revealed racks of the previous round are dropped once new tiles are dealt (same ids are back in play).
round: s.round && playing ? { ...s.round, revealed: {} } : s.round,
match: s.match,
roundEndAt: s.roundEndAt,
ready: s.ready,
result: s.final,
};
},
activePlayers(s) {
if (s.phase === 'playing') return [s.current];
if (s.phase === 'roundEnd') return s.seats.filter((p) => !s.ready.includes(p) && !s.left.includes(p));
return [];
},
deadline(s) {
if (s.phase === 'playing') return s.turnStartedAt + ((s.timeoutsInRow[s.current] ?? 0) >= 3 ? AWAY_MS : s.options.turnSeconds * 1000);
if (s.phase === 'roundEnd') return (s.roundEndAt ?? 0) + ROUND_END_MS;
return null;
},
onTimeout() {
return { type: 'timeout' };
},
onLeave(s, player) {
if (!s.seats.includes(player) || s.left.includes(player) || s.phase === 'finished') return null;
return { type: 'leave' };
},
result(s): GameResult | null {
if (!s.final) return null;
return { ranking: s.final.ranking, scores: s.final.scores, summary: s.final.summary, reason: s.final.reason };
},
/** Test helper: draw / play rack-only sets / extend a table set / an invalid try, then commit or undo. */
legalActions(s, p) {
if (!s.seats.includes(p) || s.left.includes(p) || s.phase === 'finished') return [];
if (s.phase === 'roundEnd') return s.ready.includes(p) ? [] : [{ type: 'ready' }];
if (p !== s.current) return [];
const acts: RummikubAction[] = [];
if (draftChanged(s)) {
const r = validateTurn(s, p, s.draft.sets);
if (r.ok) acts.push(s.draft.edits % 4 === 3 ? { type: 'undoDraft' } : { type: 'commit' });
else {
acts.push({ type: 'resetDraft' }, { type: 'draw' });
if (s.options.invalidCommitPenalty === 'penalty3' && r.fromRack.length > 0) acts.push({ type: 'commit' });
}
return acts;
}
const drawAct: RummikubAction = s.pool.length ? { type: 'draw' } : { type: 'pass' };
const rack = s.racks[p]!;
const base = startSets(s);
if (!s.hasMelded[p]) {
const meld = findMeld(rack, s.options.initialMeldPoints);
if (meld) acts.push({ type: 'setDraft', sets: [...base, ...meld.map((m) => ({ tileIds: m.tileIds }))] });
} else {
// Everything playable at once: disjoint rack sets, then single tiles added to any set that accepts them.
const all: { tileIds: string[]; kind: 'run' | 'group'; jokerAs: Record<string, JokerAs> }[] = [...s.table, ...pickDisjoint(rack).sets];
const used = new Set(all.flatMap((d) => d.tileIds));
let firstExt: DraftSet[] | null = null;
for (const id of rack) {
if (used.has(id)) continue;
for (let i = 0; i < all.length; i++) {
const ext = extendSet(all[i]!, id);
if (!ext) continue;
if (!firstExt && i < base.length) firstExt = base.map((d, k) => (k === i ? { tileIds: ext.tileIds } : d));
all[i] = ext;
used.add(id);
break;
}
}
if (all.length !== base.length || firstExt) acts.push({ type: 'setDraft', sets: all.map((d) => ({ tileIds: d.tileIds })) });
// Rearrangement only: one rack tile added to an existing table set.
if (firstExt) acts.push({ type: 'setDraft', sets: firstExt });
}
if (!acts.length || s.turnNo % 3 === 0) acts.push(drawAct);
// An unfinished try (single tile) now and then, to exercise invalid commit / timeout paths.
if (rack.length && s.turnNo % 7 === 0) acts.push({ type: 'setDraft', sets: [...base, { tileIds: [rack[0]!] }] });
return acts;
},
/** Hidden tiles of `player` (rack minus tiles already shown in a visible draft) plus the pool. */
secretsOf(s, player) {
if (s.phase !== 'playing') return [...s.pool];
let rack = s.racks[player] ?? [];
if (s.options.showDraftToOthers && player === s.current) {
const shown = new Set(s.draft.sets.flatMap((d) => d.tileIds));
rack = rack.filter((id) => !shown.has(id));
}
return [...rack, ...s.pool];
},
};
/** Total number of tiles in a state (invariant: always 106). */
export function tileCount(s: RummikubState): number {
return s.pool.length + s.table.reduce((a, t) => a + t.tileIds.length, 0) + Object.values(s.racks).reduce((a, r) => a + r.length, 0);
}

View File

@@ -0,0 +1,279 @@
/**
* 숫자 타일 (docs/games/rummikub.md §3, §5.1, §5.5): tile ids, set validation / joker interpretation,
* scoring helpers and simple "what can I play from my rack" search (hint button + random playouts).
* Pure functions without zod so the screen can import them cheaply.
*/
export type TileColor = 'K' | 'R' | 'B' | 'O';
export type Tile = { id: string; kind: 'num'; color: TileColor; n: number } | { id: string; kind: 'joker' };
export type NumTile = Extract<Tile, { kind: 'num' }>;
export const COLORS: TileColor[] = ['K', 'R', 'B', 'O'];
export const COLOR_KO: Record<TileColor, string> = { K: '검정', R: '빨강', B: '파랑', O: '주황' };
/** 106 ids: `R7a`, `R7b`, ... and `JK1`, `JK2` (§3). */
export const ALL_TILE_IDS: string[] = (() => {
const out: string[] = [];
for (const c of COLORS) for (let n = 1; n <= 13; n++) for (const copy of ['a', 'b']) out.push(`${c}${n}${copy}`);
out.push('JK1', 'JK2');
return out;
})();
const TILES = new Map<string, Tile>(
ALL_TILE_IDS.map((id): [string, Tile] =>
id.startsWith('JK') ? [id, { id, kind: 'joker' }] : [id, { id, kind: 'num', color: id[0] as TileColor, n: Number(id.slice(1, -1)) }],
),
);
export const isTileId = (id: string): boolean => TILES.has(id);
export function tileOf(id: string): Tile {
const t = TILES.get(id);
if (!t) throw new Error(`unknown tile ${id}`);
return t;
}
export const isJoker = (id: string): boolean => id === 'JK1' || id === 'JK2';
export interface DraftSet {
tileIds: string[];
/** Hint for sets that could be read both ways, e.g. [빨강5, 조커, 조커] (§5.5). */
as?: 'run' | 'group';
}
export interface JokerAs {
/** null = one of the missing colours of a group (not fixed). */
color: TileColor | null;
n: number;
}
export interface NormalSet {
/** Display order (runs ascending). */
tileIds: string[];
kind: 'run' | 'group';
jokerAs: Record<string, JokerAs>;
points: number;
}
function asGroup(ts: Tile[]): NormalSet | null {
if (ts.length < 3 || ts.length > 4) return null;
const nums = ts.filter((t): t is NumTile => t.kind === 'num');
if (nums.length === 0) return null;
const n = nums[0]!.n;
if (nums.some((t) => t.n !== n)) return null;
if (new Set(nums.map((t) => t.color)).size !== nums.length) return null;
const jokerAs: Record<string, JokerAs> = {};
for (const t of ts) if (t.kind === 'joker') jokerAs[t.id] = { color: null, n };
// Numbers first in colour order, jokers last.
const order = [...nums].sort((a, b) => COLORS.indexOf(a.color) - COLORS.indexOf(b.color)).map((t) => t.id);
for (const t of ts) if (t.kind === 'joker') order.push(t.id);
return { tileIds: order, kind: 'group', jokerAs, points: n * ts.length };
}
function runFrom(ts: Tile[], start: number, color: TileColor): NormalSet {
const jokerAs: Record<string, JokerAs> = {};
let points = 0;
ts.forEach((t, k) => {
points += start + k;
if (t.kind === 'joker') jokerAs[t.id] = { color, n: start + k };
});
return { tileIds: ts.map((t) => t.id), kind: 'run', jokerAs, points };
}
function asRun(ts: Tile[]): NormalSet | null {
const len = ts.length;
if (len < 3 || len > 13) return null;
const nums = ts.filter((t): t is NumTile => t.kind === 'num');
if (nums.length === 0) return null;
const color = nums[0]!.color;
if (nums.some((t) => t.color !== color)) return null;
// (a) the order the client sent, if it is a valid run as it stands.
const first = ts.findIndex((t) => t.kind === 'num');
const start = nums[0]!.n - first;
if (start >= 1 && start + len - 1 <= 13 && ts.every((t, k) => t.kind === 'joker' || t.n === start + k)) return runFrom(ts, start, color);
// (b) sort, fill gaps with jokers, extra jokers go up (towards 13) first, then down.
const sorted = [...nums].sort((a, b) => a.n - b.n);
for (let i = 1; i < sorted.length; i++) if (sorted[i]!.n === sorted[i - 1]!.n) return null;
const min = sorted[0]!.n;
const max = sorted[sorted.length - 1]!.n;
const jokers = ts.filter((t) => t.kind === 'joker');
const gaps = max - min + 1 - sorted.length;
if (gaps > jokers.length) return null;
const extra = jokers.length - gaps;
const up = Math.min(extra, 13 - max);
const down = extra - up;
if (min - down < 1) return null;
const lo = min - down;
const hi = max + up;
const out: Tile[] = [];
let j = 0;
let k = 0;
for (let n = lo; n <= hi; n++) {
if (sorted[k]?.n === n) out.push(sorted[k++]!);
else out.push(jokers[j++]!);
}
return runFrom(out, lo, color);
}
/** Validates one set and decides what its jokers stand for (§5.5, §8.2). null = not a valid set. */
export function normalizeSet(d: DraftSet): NormalSet | null {
const ids = d.tileIds;
if (ids.length < 3 || ids.length > 13) return null;
const ts: Tile[] = [];
for (const id of ids) {
const t = TILES.get(id);
if (!t || ts.includes(t)) return null;
ts.push(t);
}
const g = asGroup(ts);
const r = asRun(ts);
if (g && r) return d.as === 'group' ? g : r;
return g ?? r;
}
/** Short Korean reason why a set is not valid (null when valid) — live red/green feedback (§9). */
export function setProblem(d: DraftSet): string | null {
const ids = d.tileIds;
if (ids.length === 0) return null;
if (ids.length < 3) return '3장 이상이어야 해요';
if (normalizeSet(d)) return null;
const ts = ids.filter(isTileId).map(tileOf);
const nums = ts.filter((t): t is NumTile => t.kind === 'num');
if (nums.length === 0) return '숫자 타일이 1장 이상 있어야 해요';
const sameN = nums.every((t) => t.n === nums[0]!.n);
const sameC = nums.every((t) => t.color === nums[0]!.color);
if (sameN && nums.length > 1) {
if (new Set(nums.map((t) => t.color)).size !== nums.length) return '같은 색이 두 개예요';
if (ids.length > 4) return '같은 숫자 묶음은 4장까지예요';
}
if (sameC) {
if (new Set(nums.map((t) => t.n)).size !== nums.length) return '같은 숫자가 두 번 있어요';
if (ids.length > 13) return '13장보다 길 수 없어요';
if (nums.some((t) => t.n === 13) && nums.some((t) => t.n === 1)) return '13 다음에 1은 올 수 없어요';
return '숫자가 이어지지 않아요';
}
return '같은 색 연속 숫자나, 같은 숫자 다른 색이어야 해요';
}
/** Score of a tile left on the rack at the end (§6). */
export const tileValue = (id: string, jokerPenalty: number): number => {
const t = tileOf(id);
return t.kind === 'joker' ? jokerPenalty : t.n;
};
export const rackValue = (ids: readonly string[], jokerPenalty: number): number => ids.reduce((a, id) => a + tileValue(id, jokerPenalty), 0);
/** Order-insensitive key of an arrangement (each set as a tile set). */
export function arrangementKey(sets: readonly { tileIds: readonly string[] }[]): string {
return sets
.filter((s) => s.tileIds.length > 0)
.map((s) => [...s.tileIds].sort().join(','))
.sort()
.join('|');
}
/** Rack sort orders (§9): by colour then number, or number then colour; jokers last. */
export function sortTiles(ids: readonly string[], by: 'color' | 'number'): string[] {
const key = (id: string): number => {
const t = tileOf(id);
if (t.kind === 'joker') return 10_000 + Number(id.slice(2));
const c = COLORS.indexOf(t.color);
return by === 'color' ? c * 100 + t.n * 2 + (id.endsWith('b') ? 1 : 0) : t.n * 10 + c + (id.endsWith('b') ? 0.5 : 0);
};
return [...ids].sort((a, b) => key(a) - key(b));
}
/** Korean label of a tile for screen readers, e.g. "빨강 7", "조커". */
export function tileLabel(id: string, as?: JokerAs): string {
const t = tileOf(id);
if (t.kind === 'joker') return as ? `조커(${as.color ? `${COLOR_KO[as.color]} ` : ''}${as.n})` : '조커';
return `${COLOR_KO[t.color]} ${t.n}`;
}
/**
* Candidate sets made only from rack tiles (not disjoint): groups of 3–4 colours, maximal runs, and a few
* joker completions. No table rearrangement search (§9 hint: bounded work).
*/
export function findRackSets(rack: readonly string[]): NormalSet[] {
const nums = rack.map(tileOf).filter((t): t is NumTile => t.kind === 'num');
const jokers = rack.filter(isJoker);
const out: NormalSet[] = [];
const push = (ids: string[]) => {
const n = normalizeSet({ tileIds: ids });
if (n) out.push(n);
};
// Groups
for (let n = 1; n <= 13; n++) {
const byColor = new Map<TileColor, string>();
for (const t of nums) if (t.n === n && !byColor.has(t.color)) byColor.set(t.color, t.id);
const ids = [...byColor.values()];
if (ids.length >= 3) push(ids);
else if (ids.length === 2 && jokers.length > 0) push([...ids, jokers[0]!]);
}
// Runs: from every start that has no tile right below it, extend upwards, filling holes with jokers.
for (const c of COLORS) {
const at = new Map<number, string>();
for (const t of nums) if (t.color === c && !at.has(t.n)) at.set(t.n, t.id);
for (const [start] of [...at].sort((a, b) => a[0] - b[0])) {
if (at.has(start - 1)) continue;
const ids: string[] = [];
let jUsed = 0;
let best: string[] | null = null;
for (let n = start; n <= 13; n++) {
const id = at.get(n);
if (id) ids.push(id);
else if (jUsed < jokers.length) ids.push(jokers[jUsed++]!);
else break;
if (ids.length >= 3 && !isJoker(ids[ids.length - 1]!)) best = [...ids];
}
if (best) push(best);
else if (jokers.length > 0 && at.has(start + 1) && start + 2 <= 13) push([at.get(start)!, at.get(start + 1)!, jokers[0]!]);
}
}
return out;
}
/** Disjoint rack-only sets, greedy by points; stops as soon as `minPoints` is reached. */
export function pickDisjoint(rack: readonly string[], minPoints = Infinity): { sets: NormalSet[]; points: number } {
const cands = findRackSets(rack).sort((a, b) => b.points - a.points);
const used = new Set<string>();
const sets: NormalSet[] = [];
let points = 0;
for (const c of cands) {
if (c.tileIds.some((id) => used.has(id))) continue;
sets.push(c);
for (const id of c.tileIds) used.add(id);
points += c.points;
if (points >= minPoints) break;
}
return { sets, points };
}
/** Disjoint rack-only sets reaching `minPoints` (first meld), or null. */
export function findMeld(rack: readonly string[], minPoints: number): NormalSet[] | null {
const r = pickDisjoint(rack, minPoints);
return r.sets.length && r.points >= minPoints ? r.sets : null;
}
/** Adds one tile to a valid set at the matching end (run) or as a missing colour (group); null if it does not fit. */
export function extendSet(set: Pick<NormalSet, 'tileIds' | 'kind' | 'jokerAs'>, id: string): NormalSet | null {
const t = tileOf(id);
const ids = set.tileIds;
const ts = ids.map(tileOf);
let cand: string[] | null = null;
if (set.kind === 'group') {
if (ids.length >= 4) return null;
if (t.kind === 'num') {
const n = ts.find((x): x is NumTile => x.kind === 'num')?.n;
if (n !== t.n || ts.some((x) => x.kind === 'num' && x.color === t.color)) return null;
}
cand = [...ids, id];
} else {
if (ids.length >= 13) return null;
const val = (x: Tile) => (x.kind === 'num' ? x.n : (set.jokerAs[x.id]?.n ?? 0));
const lo = val(ts[0]!);
const hi = val(ts[ts.length - 1]!);
if (t.kind === 'joker') cand = hi < 13 ? [...ids, id] : lo > 1 ? [id, ...ids] : null;
else if (!ts.some((x) => x.kind === 'num' && x.color === t.color)) return null;
else if (t.n === hi + 1) cand = [...ids, id];
else if (t.n === lo - 1) cand = [id, ...ids];
}
return cand ? normalizeSet({ tileIds: cand, as: set.kind }) : null;
}

View File

@@ -0,0 +1,752 @@
import { describe, expect, test } from 'bun:test';
import { SeededRng, randomPlayout, seedN, type GameEvent, type PlayerId } from '@bg/engine';
import {
ALL_TILE_IDS,
findMeld,
normalizeSet,
rummikub,
scoreRound,
setProblem,
sortTiles,
tileCount,
type DraftSet,
type RummikubAction,
type RummikubOptions,
type RummikubState,
} from './index';
// ---------------------------------------------------------------- helpers
let clock = 1_000_000;
const RNG = () => SeededRng.fromSeed([7, 7, 7, 7]);
function mk(opts: Partial<RummikubOptions> = {}, players = ['A', 'B'], seed = 1): RummikubState {
const options = rummikub.optionsSchema.parse(opts);
return rummikub.setup({ players, options, rng: SeededRng.fromSeed(seedN(seed)), now: clock });
}
/**
* Puts the game into a hand-made position: given racks and table sets, everything else goes to the pool
* (in id order), `current` has the turn with a fresh draft.
*/
function rig(
s: RummikubState,
o: { racks: Record<PlayerId, string[]>; table?: string[][]; current?: PlayerId; melded?: PlayerId[]; pool?: string[] },
): RummikubState {
const table = o.table ?? [];
const used = new Set([...Object.values(o.racks).flat(), ...table.flat()]);
s.racks = Object.fromEntries(s.roundPlayers.map((p) => [p, [...(o.racks[p] ?? [])]]));
s.table = table.map((ids, i) => {
const n = normalizeSet({ tileIds: ids });
if (!n) throw new Error(`rig: invalid table set ${ids}`);
return { id: `s${i + 1}`, tileIds: n.tileIds, kind: n.kind, jokerAs: n.jokerAs };
});
s.nextSetId = table.length + 1;
const rest = ALL_TILE_IDS.filter((id) => !used.has(id));
s.pool = o.pool ? o.pool : rest;
if (o.pool) {
// The given pool must hold every tile that is in no rack and not on the table (106 in total).
const extra = rest.filter((id) => !o.pool!.includes(id));
if (extra.length) throw new Error('rig: pool must contain every unused tile');
}
s.current = o.current ?? s.roundPlayers[0]!;
s.hasMelded = Object.fromEntries(s.roundPlayers.map((p) => [p, (o.melded ?? []).includes(p)]));
s.draft = { sets: s.table.map((t) => ({ tileIds: [...t.tileIds], as: t.kind })), history: [], edits: 0 };
s.consecutivePasses = 0;
expect(tileCount(s)).toBe(106);
return s;
}
function act(s: RummikubState, who: PlayerId, a: RummikubAction): RummikubState {
return actE(s, who, a).state;
}
function actE(s: RummikubState, who: PlayerId, a: RummikubAction): { state: RummikubState; events: GameEvent[] } {
const v = rummikub.validate(s, who, a);
if (!v.ok) throw new Error(`${who} ${JSON.stringify(a)} rejected: ${v.reason}`);
clock += 1000;
const out = rummikub.apply(s, who, a, { rng: RNG(), now: clock });
expect(tileCount(out.state)).toBe(106);
return out;
}
function reason(s: RummikubState, who: PlayerId, a: RummikubAction): string {
const v = rummikub.validate(s, who, a);
if (v.ok) throw new Error(`expected rejection of ${JSON.stringify(a)}`);
return v.reason;
}
const tableSets = (s: RummikubState): DraftSet[] => s.table.map((t) => ({ tileIds: [...t.tileIds] }));
const setsOf = (s: RummikubState) => s.table.map((t) => t.tileIds);
/** Sets the draft and commits in one go. */
const play = (s: RummikubState, who: PlayerId, sets: string[][]) => act(act(s, who, { type: 'setDraft', sets: sets.map((tileIds) => ({ tileIds })) }), who, { type: 'commit' });
// ---------------------------------------------------------------- setup
describe('셋업 (§4)', () => {
test('106 tiles: 104 numbers + 2 jokers, ids R7a/R7b/JK1/JK2', () => {
expect(ALL_TILE_IDS).toHaveLength(106);
expect(new Set(ALL_TILE_IDS).size).toBe(106);
expect(ALL_TILE_IDS.filter((id) => id.startsWith('JK'))).toEqual(['JK1', 'JK2']);
expect(ALL_TILE_IDS).toContain('R7a');
expect(ALL_TILE_IDS).toContain('O13b');
});
test.each([
[2, 78],
[3, 64],
[4, 50],
])('%i players: 14 tiles each, pool %i', (n, pool) => {
const players = ['A', 'B', 'C', 'D'].slice(0, n);
const s = mk({}, players, 3);
for (const p of players) expect(s.racks[p]).toHaveLength(14);
expect(s.pool).toHaveLength(pool);
expect(tileCount(s)).toBe(106);
expect(new Set([...s.pool, ...Object.values(s.racks).flat()]).size).toBe(106);
expect(s.table).toEqual([]);
});
test('same seed → same deal and first player; the opening draw picks the highest number', () => {
const a = mk({}, ['A', 'B', 'C'], 42);
const b = mk({}, ['A', 'B', 'C'], 42);
expect(JSON.stringify(a)).toBe(JSON.stringify(b));
const lastRound = Math.max(...a.opening.map((o) => o.round));
const final = a.opening.filter((o) => o.round === lastRound && o.n !== null);
const max = Math.max(...final.map((o) => o.n!));
expect(final.filter((o) => o.n === max).map((o) => o.player)).toEqual([a.current]);
// Different seeds give different deals.
expect(JSON.stringify(mk({}, ['A', 'B', 'C'], 43).racks)).not.toBe(JSON.stringify(a.racks));
});
test('turn order is the seat order starting from the first player', () => {
const s = mk({}, ['A', 'B', 'C', 'D'], 5);
const order = [s.current];
let t = s;
for (let i = 0; i < 4; i++) {
t = act(t, t.current, { type: 'draw' });
order.push(t.current);
}
const i0 = ['A', 'B', 'C', 'D'].indexOf(order[0]!);
expect(order).toEqual([0, 1, 2, 3, 4].map((k) => ['A', 'B', 'C', 'D'][(i0 + k) % 4]!));
});
});
// ---------------------------------------------------------------- sets
describe('세트 판정 (§5.1)', () => {
const ok = (ids: string[], as?: 'run' | 'group') => normalizeSet(as ? { tileIds: ids, as } : { tileIds: ids });
test('invalid sets from the checklist', () => {
expect(ok(['R12a', 'R13a', 'R1a'])).toBeNull();
expect(setProblem({ tileIds: ['R12a', 'R13a', 'R1a'] })).toBe('13 다음에 1은 올 수 없어요');
expect(ok(['B5a', 'R5a', 'B5b'])).toBeNull();
expect(setProblem({ tileIds: ['B5a', 'R5a', 'B5b'] })).toBe('같은 색이 두 개예요');
expect(ok(['B5a', 'R5a', 'K5a', 'O5a', 'JK1'])).toBeNull();
expect(setProblem({ tileIds: ['B5a', 'R5a', 'K5a', 'O5a', 'JK1'] })).toBe('같은 숫자 묶음은 4장까지예요');
expect(ok(['R1a', 'R2a'])).toBeNull();
expect(setProblem({ tileIds: ['R1a', 'R2a'] })).toBe('3장 이상이어야 해요');
expect(ok(['R3a', 'R5a', 'R6a'])).toBeNull();
expect(setProblem({ tileIds: ['R3a', 'R5a', 'R6a'] })).toBe('숫자가 이어지지 않아요');
expect(ok(['R3a', 'B4a', 'R5a'])).toBeNull();
expect(ok(['R3a', 'R3b', 'R4a'])).toBeNull();
expect(ok(['JK1', 'JK2', 'R5a', 'R5a'])).toBeNull(); // duplicate id
});
test('valid groups and runs, points', () => {
expect(ok(['R7a', 'B7a', 'K7a'])).toMatchObject({ kind: 'group', points: 21 });
expect(ok(['R7a', 'B7a', 'K7a', 'O7b'])).toMatchObject({ kind: 'group', points: 28 });
expect(ok(['B3a', 'B4a', 'B5a', 'B6a'])).toMatchObject({ kind: 'run', points: 18, tileIds: ['B3a', 'B4a', 'B5a', 'B6a'] });
// Sent in any order: sorted ascending.
expect(ok(['B6a', 'B4a', 'B5a'])).toMatchObject({ kind: 'run', tileIds: ['B4a', 'B5a', 'B6a'] });
const all13 = Array.from({ length: 13 }, (_, i) => `O${i + 1}a`);
expect(ok(all13)).toMatchObject({ kind: 'run', points: 91 });
});
test('jokers: gap filling, two jokers in one set, interpretation (§5.5)', () => {
expect(ok(['K9a', 'K11a', 'JK1'])).toMatchObject({ kind: 'run', tileIds: ['K9a', 'JK1', 'K11a'], jokerAs: { JK1: { color: 'K', n: 10 } } });
expect(ok(['K5a', 'JK1', 'JK2'])).toMatchObject({ kind: 'run', points: 18 });
expect(ok(['K5a', 'JK1', 'JK2'], 'group')).toMatchObject({ kind: 'group', points: 15, jokerAs: { JK1: { color: null, n: 5 } } });
expect(ok(['R7a', 'B7a', 'JK2'])).toMatchObject({ kind: 'group', points: 21 });
// [조커, 검정9, 검정10] in this order = 8-9-10; [검정9, 검정10, 조커] = 9-10-11.
expect(ok(['JK1', 'K9a', 'K10a'])).toMatchObject({ points: 27, jokerAs: { JK1: { n: 8 } } });
expect(ok(['K9a', 'K10a', 'JK1'])).toMatchObject({ points: 30, jokerAs: { JK1: { n: 11 } } });
// [빨강12, 빨강13, 조커] cannot go up: 11-12-13.
expect(ok(['R12a', 'R13a', 'JK1'])).toMatchObject({ kind: 'run', tileIds: ['JK1', 'R12a', 'R13a'], points: 36, jokerAs: { JK1: { color: 'R', n: 11 } } });
// Jokers cannot stand in for 0 or 14.
expect(ok(['JK1', 'R1a', 'R2a'])).toMatchObject({ points: 6, tileIds: ['R1a', 'R2a', 'JK1'] });
});
test('rack sort by colour / number keeps jokers last', () => {
expect(sortTiles(['JK1', 'R3a', 'K5a', 'K2a', 'B3a'], 'color')).toEqual(['K2a', 'K5a', 'R3a', 'B3a', 'JK1']);
expect(sortTiles(['JK1', 'R3a', 'K5a', 'K2a', 'B3a'], 'number')).toEqual(['K2a', 'R3a', 'B3a', 'K5a', 'JK1']);
});
});
// ---------------------------------------------------------------- first meld
describe('첫 등록 (§5.3)', () => {
test('30 points from rack tiles only; 21 points is refused with the current total', () => {
let s = rig(mk(), { racks: { A: ['R10a', 'R11a', 'R12a', 'K5a'], B: ['B1a', 'B2a', 'B3a', 'K5b', 'R5a', 'O5a'] }, current: 'B' });
s = act(s, 'B', { type: 'setDraft', sets: [{ tileIds: ['B1a', 'B2a', 'B3a'] }, { tileIds: ['K5b', 'R5a', 'O5a'] }] });
expect(reason(s, 'B', { type: 'commit' })).toBe('첫 등록은 30점 이상이어야 해요 (지금 21점).');
expect(rummikub.view(s, 'B').my!.check).toMatchObject({ ok: false, meldPoints: 21 });
s = act(s, 'B', { type: 'draw' });
expect(s.table).toEqual([]);
s = play(s, 'A', [['R10a', 'R11a', 'R12a']]);
expect(s.hasMelded.A).toBe(true);
expect(s.racks.A).toEqual(['K5a']);
});
test('joker position decides the first meld: [조커, 검정9, 검정10] = 27 refused, [검정9, 검정10, 조커] = 30 accepted', () => {
let s = rig(mk(), { racks: { A: ['JK1', 'K9a', 'K10a', 'R1a'], B: ['B1a'] } });
s = act(s, 'A', { type: 'setDraft', sets: [{ tileIds: ['JK1', 'K9a', 'K10a'] }] });
expect(reason(s, 'A', { type: 'commit' })).toBe('첫 등록은 30점 이상이어야 해요 (지금 27점).');
s = act(s, 'A', { type: 'setDraft', sets: [{ tileIds: ['K9a', 'K10a', 'JK1'] }] });
s = act(s, 'A', { type: 'commit' });
expect(s.table[0]!.jokerAs).toEqual({ JK1: { color: 'K', n: 11 } });
});
test('before the first meld: touching or extending table sets is refused at setDraft (C3)', () => {
const s = rig(mk(), { racks: { A: ['R3a', 'K10a', 'B10a', 'O10a'], B: ['B1a'] }, table: [['R4a', 'R5a', 'R6a', 'R7a']], melded: ['B'] });
// Extend the table run with a rack tile.
expect(reason(s, 'A', { type: 'setDraft', sets: [{ tileIds: ['R3a', 'R4a', 'R5a', 'R6a', 'R7a'] }] })).toBe('첫 등록 전에는 바닥의 타일을 옮길 수 없어요.');
// Split the table run.
expect(reason(s, 'A', { type: 'setDraft', sets: [{ tileIds: ['R4a', 'R5a'] }, { tileIds: ['R6a', 'R7a'] }] })).toBe('첫 등록 전에는 바닥의 타일을 옮길 수 없어요.');
// A new set next to the untouched table set is fine.
const t = play(s, 'A', [['R4a', 'R5a', 'R6a', 'R7a'], ['K10a', 'B10a', 'O10a']]);
expect(t.hasMelded.A).toBe(true);
});
test('initialMeldAllowExtend: 30 from new sets first, then extending in the same turn is allowed', () => {
const base = { racks: { A: ['R3a', 'K10a', 'B10a', 'O10a', 'R8a'], B: ['B1a'] }, table: [['R4a', 'R5a', 'R6a', 'R7a']], melded: ['B'] };
let s = rig(mk({ initialMeldAllowExtend: true }), base);
s = act(s, 'A', { type: 'setDraft', sets: [{ tileIds: ['R3a', 'R4a', 'R5a', 'R6a', 'R7a', 'R8a'] }] });
expect(reason(s, 'A', { type: 'commit' })).toBe('첫 등록은 30점 이상이어야 해요 (지금 0점).');
s = play(s, 'A', [['R3a', 'R4a', 'R5a', 'R6a', 'R7a', 'R8a'], ['K10a', 'B10a', 'O10a']]);
expect(s.racks.A).toEqual([]);
});
test('initialMeldPoints 0 = no first-meld rule at all', () => {
const s = mk({ initialMeldPoints: 0 });
expect(Object.values(s.hasMelded).every(Boolean)).toBe(true);
});
});
// ---------------------------------------------------------------- rearrangement
describe('재배치와 조커 회수 (§5.4, §5.5)', () => {
test('table [빨강4..8] + rack 빨강6 → [4,5,6] [6,7,8]', () => {
let s = rig(mk(), { racks: { A: ['R6b', 'K1a'], B: ['B1a'] }, table: [['R4a', 'R5a', 'R6a', 'R7a', 'R8a']], melded: ['A'] });
s = play(s, 'A', [['R4a', 'R5a', 'R6a'], ['R6b', 'R7a', 'R8a']]);
expect(setsOf(s)).toEqual([['R4a', 'R5a', 'R6a'], ['R6b', 'R7a', 'R8a']]);
expect(s.racks.A).toEqual(['K1a']);
// The first set keeps its id (biggest overlap), the second is new; both are highlighted as changed.
expect(s.table.map((t) => t.id)).toEqual(['s1', 's2']);
expect(s.lastChanged.sort()).toEqual(['s1', 's2']);
});
test('official examples: add 파랑3 to 파랑4-5-6, 4th colour to a group, take 파랑4 from a 4-group', () => {
let s = rig(mk(), {
racks: { A: ['B3a', 'O8a', 'B3b', 'B5b', 'B6b', 'K1a'], B: ['B1a'] },
table: [['B4a', 'B5a', 'B6a'], ['R8a', 'K8a', 'B8a'], ['R4a', 'K4a', 'B4b', 'O4a']],
melded: ['A'],
});
s = play(s, 'A', [['B3a', 'B4a', 'B5a', 'B6a'], ['R8a', 'K8a', 'B8a', 'O8a'], ['R4a', 'K4a', 'O4a'], ['B3b', 'B4b', 'B5b', 'B6b']]);
expect(s.racks.A).toEqual(['K1a']);
expect(s.table).toHaveLength(4);
});
test('joker retrieval: [파랑5, 조커, 파랑7] + [파랑6, 검정3, 빨강3] → [파랑5,6,7] [검정3, 빨강3, 조커]', () => {
let s = rig(mk(), { racks: { A: ['B6a', 'K3a', 'R3a', 'O1a'], B: ['B1a'] }, table: [['B5a', 'JK1', 'B7a']], melded: ['A'] });
// Taking the joker back to the rack is refused (C2).
expect(reason(s, 'A', { type: 'setDraft', sets: [{ tileIds: ['B5a', 'B6a', 'B7a'] }] })).toBe('바닥의 타일은 내 받침대로 가져올 수 없어요.');
s = play(s, 'A', [['B5a', 'B6a', 'B7a'], ['K3a', 'R3a', 'JK1']]);
expect(setsOf(s)).toEqual([['B5a', 'B6a', 'B7a'], ['K3a', 'R3a', 'JK1']]);
expect(s.table[1]!.jokerAs).toEqual({ JK1: { color: null, n: 3 } });
expect(s.racks.A).toEqual(['O1a']);
});
test('rearranging without a rack tile is refused; leftover single tiles are refused', () => {
let s = rig(mk(), { racks: { A: ['K1a', 'K2a'], B: ['B1a'] }, table: [['R4a', 'R5a', 'R6a', 'R7a', 'R8a', 'R9a']], melded: ['A'] });
s = act(s, 'A', { type: 'setDraft', sets: [{ tileIds: ['R4a', 'R5a', 'R6a'] }, { tileIds: ['R7a', 'R8a', 'R9a'] }] });
expect(reason(s, 'A', { type: 'commit' })).toBe('받침대에서 타일을 1개 이상 내야 해요.');
s = act(s, 'A', { type: 'setDraft', sets: [{ tileIds: ['R4a', 'R5a', 'R6a'] }, { tileIds: ['R7a', 'R8a', 'R9a'] }, { tileIds: ['K1a', 'K2a'] }] });
expect(reason(s, 'A', { type: 'commit' })).toBe('빨간 표시된 묶음을 고쳐 주세요.');
expect(rummikub.view(s, 'A').my!.check!.invalidIdx).toEqual([2]);
expect(rummikub.view(s, 'A').draft![2]).toMatchObject({ ok: false, problem: '3장 이상이어야 해요' });
});
test('cheating attempts: foreign tiles, duplicates, table tiles into the rack, empty sets', () => {
const s = rig(mk(), { racks: { A: ['K1a', 'K2a', 'K3a'], B: ['B1a', 'B2a', 'B3a'] }, table: [['R4a', 'R5a', 'R6a']], melded: ['A', 'B'] });
const base = tableSets(s);
expect(reason(s, 'A', { type: 'setDraft', sets: [...base, { tileIds: ['B1a', 'B2a', 'B3a'] }] })).toBe('내 받침대나 바닥에 없는 타일이에요.');
expect(reason(s, 'A', { type: 'setDraft', sets: [...base, { tileIds: ['O9a', 'O10a', 'O11a'] }] })).toBe('내 받침대나 바닥에 없는 타일이에요.');
expect(reason(s, 'A', { type: 'setDraft', sets: [...base, { tileIds: ['XX1', 'K2a', 'K3a'] }] })).toBe('내 받침대나 바닥에 없는 타일이에요.');
expect(reason(s, 'A', { type: 'setDraft', sets: [...base, { tileIds: ['K1a', 'K1a', 'K2a'] }] })).toBe('같은 타일이 두 번 들어 있어요.');
expect(reason(s, 'A', { type: 'setDraft', sets: [...base, { tileIds: ['K1a', 'K2a', 'K3a', 'R4a'] }] })).toBe('같은 타일이 두 번 들어 있어요.');
expect(reason(s, 'A', { type: 'setDraft', sets: [{ tileIds: ['R4a', 'R5a'] }, { tileIds: ['K1a', 'K2a', 'K3a'] }] })).toBe('바닥의 타일은 내 받침대로 가져올 수 없어요.');
expect(rummikub.actionSchema.safeParse({ type: 'setDraft', sets: [{ tileIds: [] }] }).success).toBe(false);
expect(reason(s, 'B', { type: 'setDraft', sets: base })).toBe('지금은 내 차례가 아니에요.');
expect(reason(s, 'Z', { type: 'draw' })).toBe('이 게임의 플레이어가 아니에요.');
// A valid commit puts exactly the played tiles on the table.
const t = play(s, 'A', [...base.map((d) => d.tileIds), ['K1a', 'K2a', 'K3a']]);
expect(t.racks.A).toEqual([]);
});
});
// ---------------------------------------------------------------- draft / undo
describe('초안과 되돌리기 (§5.6)', () => {
test('5 setDraft → 2 undo → the 3rd draft; reset → turn start; survives a save/reload', () => {
let s = rig(mk(), { racks: { A: ['K1a', 'K2a', 'K3a', 'K4a', 'K5a'], B: ['B1a'] }, table: [['R4a', 'R5a', 'R6a']], melded: ['A'] });
const base = tableSets(s);
const drafts = [1, 2, 3, 4, 5].map((k) => [...base, { tileIds: ['K1a', 'K2a', 'K3a', 'K4a', 'K5a'].slice(0, k) }]);
for (const d of drafts) s = act(s, 'A', { type: 'setDraft', sets: d });
s = act(s, 'A', { type: 'undoDraft' });
s = act(s, 'A', { type: 'undoDraft' });
expect(s.draft.sets).toEqual(drafts[2]!);
// Reconnect = the persisted state is reloaded; the draft is still there.
const reloaded = JSON.parse(JSON.stringify(s)) as RummikubState;
expect(rummikub.view(reloaded, 'A').draft!.map((d) => d.tileIds)).toEqual(drafts[2]!.map((d) => d.tileIds));
s = act(s, 'A', { type: 'resetDraft' });
expect(s.draft.sets.map((d) => d.tileIds)).toEqual(base.map((d) => d.tileIds));
expect(rummikub.view(s, 'A').my!.changed).toBe(false);
// Reset can be undone too.
s = act(s, 'A', { type: 'undoDraft' });
expect(s.draft.sets).toEqual(drafts[2]!);
for (let i = 0; i < 3; i++) s = act(s, 'A', { type: 'undoDraft' });
expect(reason(s, 'A', { type: 'undoDraft' })).toBe('되돌릴 것이 없어요.');
});
test('history keeps at most 100 steps', () => {
let s = rig(mk(), { racks: { A: ['K1a', 'K2a'], B: ['B1a'] }, melded: ['A'] });
for (let i = 0; i < 130; i++) s = act(s, 'A', { type: 'setDraft', sets: [{ tileIds: [i % 2 ? 'K1a' : 'K2a'] }] });
expect(s.draft.history).toHaveLength(100);
});
test('draw with a changed draft throws the draft away and draws one', () => {
let s = rig(mk(), { racks: { A: ['K1a', 'K2a', 'K3a'], B: ['B1a'] }, melded: ['A'] });
s = act(s, 'A', { type: 'setDraft', sets: [{ tileIds: ['K1a', 'K2a', 'K3a'] }] });
s = act(s, 'A', { type: 'draw' });
expect(s.table).toEqual([]);
expect(s.racks.A).toHaveLength(4);
expect(s.current).toBe('B');
});
test('invalidCommitPenalty=penalty3: an invalid commit reverts, draws 3 and ends the turn', () => {
let s = rig(mk({ invalidCommitPenalty: 'penalty3' }), { racks: { A: ['K1a', 'K2a'], B: ['B1a'] }, melded: ['A'] });
expect(reason(s, 'A', { type: 'commit' })).toBe('받침대에서 타일을 1개 이상 내야 해요.');
s = act(s, 'A', { type: 'setDraft', sets: [{ tileIds: ['K1a', 'K2a'] }] });
s = act(s, 'A', { type: 'commit' });
expect(s.table).toEqual([]);
expect(s.racks.A).toHaveLength(5);
expect(s.current).toBe('B');
expect(s.log.at(-1)).toMatchObject({ by: 'A', kind: 'penalty', count: 3 });
});
});
// ---------------------------------------------------------------- timeouts
describe('시간 초과 (§5.7, §8.5)', () => {
const pos = (opts: Partial<RummikubOptions> = {}, pool?: string[]) => {
const s = mk(opts);
const racks = { A: ['K1a', 'K2a', 'K3a', 'O9a'], B: ['B1a'] };
const table = [['R4a', 'R5a', 'R6a']];
if (pool) {
// Park everything except `pool` in B's rack so the pool has exactly these tiles.
const used = new Set([...racks.A, ...table.flat(), ...pool]);
racks.B = ALL_TILE_IDS.filter((id) => !used.has(id));
}
return rig(s, { racks, table, melded: ['A'], ...(pool ? { pool } : {}) });
};
test('timeout is system-only and legal for the current player', () => {
const s = pos();
expect(rummikub.actionSchema.safeParse({ type: 'timeout' }).success).toBe(false);
expect(rummikub.actionSchema.safeParse({ type: 'leave' }).success).toBe(false);
expect(rummikub.validate(s, 'A', rummikub.onTimeout(s, 'A')).ok).toBe(true);
expect(rummikub.deadline!(s)).toBe(s.turnStartedAt + 60_000);
});
test('invalid draft: table and rack restored, 3 tiles drawn; with only 2 in the pool, 2', () => {
let s = pos();
const rackBefore = [...s.racks.A!];
s = act(s, 'A', { type: 'setDraft', sets: [{ tileIds: ['R4a', 'R5a'] }, { tileIds: ['R6a', 'K1a'] }] });
s = act(s, 'A', { type: 'timeout' });
expect(setsOf(s)).toEqual([['R4a', 'R5a', 'R6a']]);
expect(s.racks.A!.slice(0, 4)).toEqual(rackBefore);
expect(s.racks.A).toHaveLength(7);
expect(s.lastDrawn.A).toHaveLength(3);
expect(s.current).toBe('B');
let t = pos({}, ['O13a', 'O13b']);
t = act(t, 'A', { type: 'setDraft', sets: [...tableSets(t), { tileIds: ['K1a'] }] });
t = act(t, 'A', { type: 'timeout' });
expect(t.racks.A).toHaveLength(6);
expect(t.pool).toEqual([]);
});
test('invalidTimeoutPenalty=1 draws only one', () => {
let s = pos({ invalidTimeoutPenalty: 1 });
s = act(s, 'A', { type: 'setDraft', sets: [...tableSets(s), { tileIds: ['K1a'] }] });
s = act(s, 'A', { type: 'timeout' });
expect(s.racks.A).toHaveLength(5);
});
test('valid draft: auto-commit (default) or revert + draw 1; no change: draw 1', () => {
let s = pos();
s = act(s, 'A', { type: 'setDraft', sets: [...tableSets(s), { tileIds: ['K1a', 'K2a', 'K3a'] }] });
s = act(s, 'A', { type: 'timeout' });
expect(setsOf(s)).toEqual([['R4a', 'R5a', 'R6a'], ['K1a', 'K2a', 'K3a']]);
expect(s.racks.A).toEqual(['O9a']);
expect(s.log.at(-1)).toMatchObject({ kind: 'timeoutCommit', count: 3 });
let r = pos({ timeoutValidDraft: 'revert' });
r = act(r, 'A', { type: 'setDraft', sets: [...tableSets(r), { tileIds: ['K1a', 'K2a', 'K3a'] }] });
r = act(r, 'A', { type: 'timeout' });
expect(setsOf(r)).toEqual([['R4a', 'R5a', 'R6a']]);
expect(r.racks.A).toHaveLength(5);
let u = pos();
u = act(u, 'A', { type: 'timeout' });
expect(u.racks.A).toHaveLength(5);
expect(u.log.at(-1)).toMatchObject({ kind: 'timeoutDraw', count: 1 });
});
test('3 timeouts in a row → away: the deadline becomes short; acting again resets it', () => {
let s = mk({}, ['A', 'B'], 2);
const p = s.current;
for (let i = 0; i < 3; i++) {
s = act(s, p, { type: 'timeout' });
s = act(s, s.current, { type: 'draw' });
}
expect(rummikub.view(s, null).seats.find((x) => x.id === p)!.away).toBe(true);
expect(rummikub.deadline!(s)).toBe(s.turnStartedAt + 3000);
s = act(s, p, { type: 'draw' });
expect(s.timeoutsInRow[p]).toBe(0);
});
});
// ---------------------------------------------------------------- scoring / end
describe('점수와 끝 (§6, §5.8)', () => {
test('rack emptied: 4-player example B -15, C 조커 -30, D -15, A +60', () => {
const r = scoreRound({ A: [], B: ['R3a', 'K12a'], C: ['JK1'], D: ['R1a', 'B1a', 'O13a'] }, ['A', 'B', 'C', 'D'], 'rackEmpty', 30);
expect(r.winners).toEqual(['A']);
expect(r.scores).toEqual({ A: 60, B: -15, C: -30, D: -15 });
expect(scoreRound({ A: [], B: ['JK1'] }, ['A', 'B'], 'rackEmpty', 50).scores).toEqual({ A: 50, B: -50 });
});
test('stalemate example: A=5, B=12, C=30 → A +32, B -7, C -25; ties: fewer tiles, then shared', () => {
const r = scoreRound({ A: ['R5a'], B: ['R12a'], C: ['R10a', 'B10a', 'K10a'] }, ['A', 'B', 'C'], 'stalemate', 30);
expect(r.winners).toEqual(['A']);
expect(r.scores).toEqual({ A: 32, B: -7, C: -25 });
const t = scoreRound({ A: ['R5a'], B: ['R2a', 'R3a'], C: ['R10a'] }, ['A', 'B', 'C'], 'stalemate', 30);
expect(t.winners).toEqual(['A']);
const sh = scoreRound({ A: ['R5a'], B: ['B5a'], C: ['R10a'] }, ['A', 'B', 'C'], 'stalemate', 30);
expect(sh.winners).toEqual(['A', 'B']);
expect(sh.scores).toEqual({ A: 2, B: 2, C: -5 });
});
test('emptying the rack ends the game with ranking, scores and a Korean summary', () => {
let s = rig(mk({}, ['A', 'B', 'C', 'D']), {
racks: { A: ['K1a', 'K2a', 'K3a'], B: ['R3a', 'K12a'], C: ['JK1'], D: ['R1a', 'B1a', 'O13a'] },
melded: ['A'],
});
s = play(s, 'A', [['K1a', 'K2a', 'K3a']]);
expect(s.phase).toBe('finished');
const res = rummikub.result(s)!;
expect(res.ranking).toEqual([['A'], ['B', 'D'], ['C']]);
expect(res.scores).toEqual({ A: 60, B: -15, C: -30, D: -15 });
expect(res.summary).toContain('+60점');
expect(res.reason).toBe('normal');
expect(rummikub.view(s, 'B').round!.revealed.C).toEqual(['JK1']);
expect(reason(s, 'B', { type: 'draw' })).toBe('게임이 이미 끝났어요.');
});
test('stalemate: empty pool, 3 players pass in a row → end; a commit in between resets the count', () => {
const s0 = mk({}, ['A', 'B', 'C']);
// Empty pool: everything else is parked in C's rack.
const racks: Record<string, string[]> = { A: ['K1a', 'K2a', 'K3a', 'R5a'], B: ['R12a'] };
const used = new Set(Object.values(racks).flat());
racks.C = ALL_TILE_IDS.filter((id) => !used.has(id));
let s = rig(s0, { racks, melded: ['A', 'B', 'C'], pool: [] });
s = act(s, 'A', { type: 'pass' });
s = act(s, 'B', { type: 'pass' });
expect(s.consecutivePasses).toBe(2);
s = play(s, 'C', [['O11a', 'O12a', 'O13a']]);
expect(s.consecutivePasses).toBe(0);
s = act(s, 'A', { type: 'draw' }); // empty pool: draw = pass
s = act(s, 'B', { type: 'pass' });
expect(s.phase).toBe('playing');
s = act(s, 'C', { type: 'timeout' }); // a timeout without playing counts too
expect(s.phase).toBe('finished');
expect(s.round!.endReason).toBe('stalemate');
// A = 1+2+3+5 = 11 < B = 12.
expect(s.round!.winners).toEqual(['A']);
expect(s.round!.scores.A! + s.round!.scores.B! + s.round!.scores.C!).toBe(0);
});
test('pass is refused while the pool has tiles', () => {
const s = mk();
expect(reason(s, s.current, { type: 'pass' })).toBe('가져올 타일이 남아 있으면 넘길 수 없어요. 한 장 가져와 주세요.');
});
test('gamesPerMatch=2: round end → ready → new deal; wins then totals decide', () => {
let s = rig(mk({ gamesPerMatch: '2' }), { racks: { A: ['K1a', 'K2a', 'K3a'], B: ['R10a', 'R11a'] }, melded: ['A'] });
s = play(s, 'A', [['K1a', 'K2a', 'K3a']]);
expect(s.phase).toBe('roundEnd');
expect(rummikub.result(s)).toBeNull();
expect(s.match).toMatchObject({ gameNo: 1, total: 2, wins: { A: 1, B: 0 }, totals: { A: 21, B: -21 } });
expect(rummikub.activePlayers(s).sort()).toEqual(['A', 'B']);
expect(rummikub.deadline!(s)).toBe(s.roundEndAt! + 30_000);
expect(reason(s, 'A', { type: 'draw' })).toBe('다음 판을 기다리는 중이에요.');
s = act(s, 'A', { type: 'ready' });
expect(reason(s, 'A', { type: 'ready' })).toBe('이미 준비했어요.');
s = act(s, 'B', { type: 'timeout' });
expect(s.phase).toBe('playing');
expect(s.match.gameNo).toBe(2);
expect(s.table).toEqual([]);
expect(Object.values(s.racks).map((r) => r.length)).toEqual([14, 14]);
expect(rummikub.view(s, 'A').round!.revealed).toEqual({});
s = rig(s, { racks: { A: ['O5a'], B: ['B1a', 'B2a', 'B3a'] }, current: 'B', melded: ['B'] });
s = play(s, 'B', [['B1a', 'B2a', 'B3a']]);
expect(s.phase).toBe('finished');
// 1 win each; totals A 21-5 = 16, B -21+5 = -16 → A first.
expect(rummikub.result(s)!.ranking).toEqual([['A'], ['B']]);
expect(rummikub.result(s)!.summary).toContain('2판 끝');
});
test('gamesPerMatch=players uses the number of players', () => {
expect(mk({ gamesPerMatch: 'players' }, ['A', 'B', 'C']).match.total).toBe(3);
});
});
// ---------------------------------------------------------------- leaving
describe('나가기', () => {
test('3 players: the current player leaves → the next one plays; left players are skipped', () => {
let s = mk({}, ['A', 'B', 'C'], 4);
const p = s.current;
expect(rummikub.onLeave!(s, p)).toEqual({ type: 'leave' });
s = act(s, p, { type: 'leave' });
expect(s.current).not.toBe(p);
for (let i = 0; i < 4; i++) {
expect(s.current).not.toBe(p);
s = act(s, s.current, { type: 'draw' });
}
expect(reason(s, p, { type: 'draw' })).toBe('방을 나간 플레이어예요.');
expect(rummikub.onLeave!(s, p)).toBeNull();
});
test('2 players: one leaves → finished, the other ranks first', () => {
let s = mk({}, ['A', 'B']);
s = act(s, 'B', { type: 'leave' });
expect(s.phase).toBe('finished');
expect(rummikub.result(s)).toMatchObject({ ranking: [['A'], ['B']], reason: 'abandoned' });
});
});
// ---------------------------------------------------------------- information
describe('정보 공개 (§8.3)', () => {
test('opponents and spectators see counts only; the draft is hidden unless showDraftToOthers', () => {
let s = rig(mk(), { racks: { A: ['K1a', 'K2a', 'K3a', 'O7a'], B: ['B1a', 'B9b'] }, table: [['R4a', 'R5a', 'R6a']], melded: ['A'] });
s = act(s, 'A', { type: 'setDraft', sets: [...tableSets(s), { tileIds: ['K1a', 'K2a', 'K3a'] }] });
for (const viewer of ['B', null]) {
const v = rummikub.view(s, viewer);
const json = JSON.stringify(v);
for (const id of [...s.racks.A!, ...s.pool, ...(viewer ? [] : s.racks.B!)]) expect(json.includes(`"${id}"`)).toBe(false);
expect(v.draft).toBeNull();
expect(v.draftEdits).toBe(1);
expect(v.seats.find((x) => x.id === 'A')!.tiles).toBe(4);
expect(v.poolCount).toBe(s.pool.length);
}
expect(rummikub.view(s, 'B').my!.rack).toEqual(['B1a', 'B9b']);
const own = rummikub.view(s, 'A');
expect(own.draft!.map((d) => d.ok)).toEqual([true, true]);
expect(own.my!.check).toMatchObject({ ok: true, fromRack: 3 });
let t = rig(mk({ showDraftToOthers: true }), { racks: { A: ['K1a', 'K2a', 'K3a', 'O7a'], B: ['B1a'] }, melded: ['A'] });
t = act(t, 'A', { type: 'setDraft', sets: [{ tileIds: ['K1a', 'K2a', 'K3a'] }] });
const vb = rummikub.view(t, 'B');
expect(vb.draft!.map((d) => d.tileIds)).toEqual([['K1a', 'K2a', 'K3a']]);
expect(JSON.stringify(vb).includes('"O7a"')).toBe(false);
expect(rummikub.secretsOf!(t, 'A')).not.toContain('K1a');
});
test('draw events carry no tile; only the drawer sees the new tile', () => {
const s = mk();
const p = s.current;
const out = actE(s, p, { type: 'draw' });
const drawn = out.state.lastDrawn[p]!;
expect(drawn).toHaveLength(1);
expect(JSON.stringify(out.events).includes(drawn[0]!)).toBe(false);
expect(out.events).toEqual([{ type: 'drew', player: p, count: 1, penalty: false }]);
expect(rummikub.view(out.state, p).my!.lastDrawn).toEqual(drawn);
const other = s.seats.find((x) => x !== p)!;
expect(JSON.stringify(rummikub.view(out.state, other)).includes(`"${drawn[0]}"`)).toBe(false);
});
test('the opening draw is shown without tile ids (they are shuffled back)', () => {
const s = mk({}, ['A', 'B', 'C']);
expect(s.opening.length).toBeGreaterThanOrEqual(3);
expect(JSON.stringify(s.opening)).not.toMatch(/[KRBO]\d+[ab]/);
});
});
// ---------------------------------------------------------------- concurrency
describe('동시성', () => {
test('the same commit twice: the second is "not your turn"', () => {
let s = rig(mk(), { racks: { A: ['K1a', 'K2a', 'K3a', 'R1a'], B: ['B1a'] }, melded: ['A'] });
s = act(s, 'A', { type: 'setDraft', sets: [{ tileIds: ['K1a', 'K2a', 'K3a'] }] });
const commit = { type: 'commit' } as const;
s = act(s, 'A', commit);
expect(reason(s, 'A', commit)).toBe('지금은 내 차례가 아니에요.');
});
test('a setDraft from an earlier turn is refused (turn number)', () => {
let s = rig(mk(), { racks: { A: ['K1a', 'K2a', 'K3a', 'R1a'], B: ['B1a'] }, melded: ['A'] });
const turn = s.turnNo;
s = act(s, 'A', { type: 'draw' });
expect(reason(s, 'A', { type: 'setDraft', sets: [{ tileIds: ['K1a'] }], turn })).toBe('지금은 내 차례가 아니에요.');
s = act(s, 'B', { type: 'draw' });
expect(reason(s, 'A', { type: 'setDraft', sets: [{ tileIds: ['K1a'] }], turn })).toBe('지난 차례의 요청이라 무시했어요.');
expect(rummikub.validate(s, 'A', { type: 'setDraft', sets: [{ tileIds: ['K1a'] }], turn: s.turnNo }).ok).toBe(true);
});
});
// ---------------------------------------------------------------- options / hints
describe('옵션과 힌트', () => {
test('every option has a Korean title; defaults follow the doc', () => {
const shape = (rummikub.optionsSchema as unknown as { shape: Record<string, { meta(): { title?: string } | undefined }> }).shape;
for (const [k, v] of Object.entries(shape)) expect(v.meta()?.title, k).toBeTruthy();
expect(rummikub.defaultOptions).toEqual({
initialMeldPoints: 30,
turnSeconds: 60,
timeoutValidDraft: 'commit',
invalidTimeoutPenalty: 3,
invalidCommitPenalty: 'reject',
jokerPenalty: 30,
initialMeldAllowExtend: false,
showDraftToOthers: false,
gamesPerMatch: '1',
hints: true,
});
expect(rummikub.optionsSchema.safeParse({ initialMeldPoints: 25 }).success).toBe(false);
});
test('findMeld finds a 30+ point combination from the rack only', () => {
const m = findMeld(['R10a', 'R11a', 'R12a', 'K1a', 'B5a'], 30)!;
expect(m.map((x) => x.tileIds)).toEqual([['R10a', 'R11a', 'R12a']]);
expect(findMeld(['B1a', 'B2a', 'B3a', 'K5a', 'R5a', 'O5a'], 30)).toBeNull();
});
});
// ---------------------------------------------------------------- properties
const CONFIGS: Partial<RummikubOptions>[] = [
{},
{ initialMeldPoints: 0, showDraftToOthers: true },
{ initialMeldPoints: 50, invalidCommitPenalty: 'penalty3', jokerPenalty: 50 },
{ timeoutValidDraft: 'revert', invalidTimeoutPenalty: 1, initialMeldAllowExtend: true },
{ gamesPerMatch: '2', initialMeldPoints: 20 },
];
/** Every tile exactly once across pool, racks and table. */
let checkedTable: unknown = null;
function conserved(st: unknown): void {
const s = st as RummikubState;
const all = [...s.pool, ...s.table.flatMap((t) => t.tileIds), ...Object.values(s.racks).flat()];
if (all.length !== 106 || new Set(all).size !== 106) throw new Error(`tiles not conserved: ${all.length}/${new Set(all).size}`);
// The committed table is always made of valid sets (checked once per turn; it only changes between turns).
const key = `${s.match.gameNo}:${s.turnNo}`;
if (checkedTable === key) return;
checkedTable = key;
for (const t of s.table) if (!normalizeSet({ tileIds: t.tileIds, as: t.kind })) throw new Error(`invalid table set ${t.tileIds}`);
}
describe('random playouts (docs/09 §7)', () => {
test('engine randomPlayout (round trip, checkNoLeak via secretsOf, determinism) across options', () => {
for (let i = 0; i < 25; i++) {
const options = rummikub.optionsSchema.parse(CONFIGS[i % CONFIGS.length]);
const players = ['P0', 'P1', 'P2', 'P3'].slice(0, 2 + (i % 3));
const a = randomPlayout(rummikub, { players, options, seed: seedN(9000 + i), maxSteps: 4000, invariant: conserved, maxApplyMs: 30 });
expect(a.finished).toBe(true);
if (i < 5) {
const b = randomPlayout(rummikub, { players, options, seed: seedN(9000 + i), maxSteps: 4000, maxApplyMs: 30 });
expect(JSON.stringify(b.finalState)).toBe(JSON.stringify(a.finalState));
}
}
}, 60_000);
/**
* 1,000 seeds, same checks as randomPlayout with each view computed once per step (fast leak check),
* plus: onTimeout legal for every waiting player at every step, and 1 in 8 moves is a timeout.
*/
const TILE_RE = /"(?:[KRBO]\d{1,2}[ab]|JK[12])"/g;
function run(seed: number): { state: RummikubState; steps: number } {
const options = rummikub.optionsSchema.parse(CONFIGS[seed % CONFIGS.length]);
const players = ['P0', 'P1', 'P2', 'P3'].slice(0, 2 + (seed % 3));
const rng = SeededRng.fromSeed(seedN(seed));
const chooser = SeededRng.fromSeed(seedN(seed + 500_000));
let now = 1_700_000_000_000;
let s = rummikub.setup({ players, options, rng, now });
for (let step = 0; step < 4000; step++) {
// JSON round trip is checked every step in the engine playouts above (apply itself clones through JSON); here every 10th step.
if (step % 10 === 0) {
const json = JSON.stringify(s);
if (JSON.stringify(JSON.parse(json)) !== json) throw new Error('not JSON round-trippable');
}
conserved(s);
// Leak check: which hidden tile belongs to whom; every viewer (and a spectator) is checked once.
const pool = new Set(s.pool);
const owner = new Map<string, PlayerId>();
for (const p of players) for (const id of rummikub.secretsOf!(s, p)) owner.set(id, pool.has(id) ? '*pool' : p);
for (const viewer of [...players, null]) {
const ids = JSON.stringify(rummikub.view(s, viewer)).match(TILE_RE) ?? [];
for (const q of ids) {
const o = owner.get(q.slice(1, -1));
if (o && o !== viewer) throw new Error(`seed ${seed} step ${step}: ${viewer} sees ${q} of ${o}`);
}
}
if (rummikub.result(s)) return { state: s, steps: step };
const waiting = rummikub.activePlayers(s);
if (!waiting.length) throw new Error('no active players');
for (const p of waiting) {
const v = rummikub.validate(s, p, rummikub.onTimeout(s, p));
if (!v.ok) throw new Error(`seed ${seed}: onTimeout illegal: ${v.reason}`);
}
const actor = waiting[chooser.int(waiting.length)]!;
const legal = rummikub.legalActions!(s, actor);
const a = legal.length && chooser.int(8) !== 0 ? legal[chooser.int(legal.length)]! : rummikub.onTimeout(s, actor);
const v = rummikub.validate(s, actor, a);
if (!v.ok) throw new Error(`seed ${seed}: generated action rejected: ${v.reason} ${JSON.stringify(a)}`);
now += 1000;
const t0 = performance.now();
const out = rummikub.apply(s, actor, a, { rng, now });
const ms = performance.now() - t0;
if (ms > 30) throw new Error(`apply took ${ms.toFixed(1)}ms`);
for (const e of out.events) {
const ids = JSON.stringify(e).match(TILE_RE) ?? [];
for (const q of ids) if (!out.state.table.some((t) => t.tileIds.includes(q.slice(1, -1)))) throw new Error(`event leaks ${q}`);
}
s = out.state;
}
throw new Error(`seed ${seed}: not finished within 4000 steps`);
}
for (let chunk = 0; chunk < 10; chunk++) {
test(`1,000 seeded playouts: seeds ${chunk * 100 + 1}–${chunk * 100 + 100}`, () => {
const ends: Record<string, number> = {};
for (let seed = chunk * 100 + 1; seed <= chunk * 100 + 100; seed++) {
const { state } = run(seed);
expect(state.phase).toBe('finished');
const res = rummikub.result(state)!;
expect(res.ranking.flat().sort()).toEqual([...state.seats].sort());
const k = state.round?.endReason ?? 'none';
ends[k] = (ends[k] ?? 0) + 1;
if (seed % 100 === 0) expect(JSON.stringify(run(seed).state)).toBe(JSON.stringify(state));
}
expect(ends.stalemate ?? 0).toBeGreaterThan(0);
}, 60_000);
}
});

View File

@@ -0,0 +1,479 @@
/** 요트 다이스 (docs/games/yacht.md). No hidden information: every viewer gets the same view. */
import { z } from 'zod';
import { fail, ok, type GameDefinition, type GameEvent, type GameResult, type PlayerId } from '@bg/engine';
import {
CATEGORIES,
UPPER_BONUS_THRESHOLD,
allowedCategories,
autoCategory,
categoryName,
scoreFor,
totals,
upperOf,
type Category,
type Sheet,
type SheetTotals,
} from './scoring';
export * from './scoring';
const lit = <T extends number>(...vals: T[]) =>
z.union(vals.map((v) => z.literal(v)) as unknown as [z.ZodLiteral<T>, z.ZodLiteral<T>, ...z.ZodLiteral<T>[]]);
export const yachtOptionsSchema = z.object({
scoring: z
.enum(['yacht51', 'yahtzee'])
.default('yacht51')
.meta({ title: '점수 규칙', labels: { yacht51: '요트(12칸)', yahtzee: '13칸 변형(미국식)' } }),
fullHouseAllowsYacht: z.boolean().default(true).meta({ title: '12칸 규칙: 5개 모두 같은 눈도 풀 하우스로 인정' }),
yahtzeeJoker: z
.enum(['forced', 'free'])
.default('forced')
.meta({ title: '13칸 변형: 요트가 또 나왔을 때', labels: { forced: '정해진 순서대로 넣기(공식)', free: '아무 칸이나 고르기' } }),
turnSeconds: lit(30, 60, 90, 0)
.default(60)
.meta({ title: '한 차례 제한 시간', labels: { 30: '30초', 60: '60초', 90: '90초', 0: '제한 없음' } }),
showPreview: z.boolean().default(true).meta({ title: '빈 칸에 지금 넣을 때 점수 미리 보여 주기' }),
});
export type YachtOptions = z.infer<typeof yachtOptionsSchema>;
const categoryEnum = z.enum([
'ones',
'twos',
'threes',
'fours',
'fives',
'sixes',
'choice',
'threeKind',
'fourKind',
'fullHouse',
'smallStraight',
'largeStraight',
'yacht',
]);
export const yachtActionSchema = z.discriminatedUnion('type', [
z.object({ type: z.literal('roll') }),
z.object({ type: z.literal('hold'), held: z.array(z.boolean()).length(5) }),
z.object({ type: z.literal('score'), category: categoryEnum }),
z.object({ type: z.literal('concede') }),
]);
/** Client actions plus the system-only timeout action (not in the schema). */
export type YachtAction = z.infer<typeof yachtActionSchema> | { type: 'timeout' };
export const DICE = 5;
export const MAX_ROLLS = 3;
/** Consecutive automatic turns before a player is shown as "자리 비움" (§8.5). */
export const AWAY_AFTER = 3;
export interface ScoreEntry {
player: PlayerId;
category: Category;
points: number;
bonus: number;
dice: number[];
auto: boolean;
}
export interface YachtState {
options: YachtOptions;
/** Turn order (shuffled at setup). */
order: PlayerId[];
/** Index into `order` of the player to move. */
current: number;
round: number;
totalRounds: number;
/** null before the first roll of a turn. */
dice: number[] | null;
held: boolean[];
rollsUsed: number;
/** Increases on every roll (animation key). */
rollSeq: number;
sheets: Record<PlayerId, Sheet>;
/** Players who conceded / left (their open categories were set to 0). */
left: PlayerId[];
phase: 'playing' | 'finished';
turnSeq: number;
turnStartedAt: number;
deadlineAt: number | null;
autoStreak: Record<PlayerId, number>;
lastScore: ScoreEntry | null;
}
export interface YachtSheetView extends SheetTotals {
scores: Partial<Record<Category, number>>;
filled: number;
left: boolean;
away: boolean;
}
export interface YachtRankRow extends SheetTotals {
rank: number;
player: PlayerId;
conceded: boolean;
yachts: number;
}
export interface YachtResult {
ranking: YachtRankRow[];
winners: PlayerId[];
sheets: Record<PlayerId, Partial<Record<Category, number>>>;
summary: string;
reason: GameResult['reason'];
}
export interface YachtView {
options: YachtOptions;
categories: Category[];
order: PlayerId[];
/** Player to move, null once finished. */
current: PlayerId | null;
round: number;
totalRounds: number;
dice: number[] | null;
held: boolean[];
rollsUsed: number;
rollsLeft: number;
rollSeq: number;
turnSeq: number;
canRoll: boolean;
canHold: boolean;
/** Categories the current dice may be written into (empty before the first roll). */
allowed: Category[];
/** Points per open category for the current dice (current player always; everyone if `showPreview`). */
preview: Partial<Record<Category, number>> | null;
/** Yacht bonus that scoring now would add (13칸 변형). */
previewBonus: number;
sheets: Record<PlayerId, YachtSheetView>;
phase: YachtState['phase'];
turnStartedAt: number;
deadlineAt: number | null;
lastScore: ScoreEntry | null;
upperBonusThreshold: number;
result: YachtResult | null;
}
const rulesOf = (o: YachtOptions) => ({ scoring: o.scoring, fullHouseAllowsYacht: o.fullHouseAllowsYacht, yahtzeeJoker: o.yahtzeeJoker });
const cats = (s: YachtState) => CATEGORIES[s.options.scoring];
const filledCount = (s: YachtState, p: PlayerId) => cats(s).filter((c) => s.sheets[p]!.scores[c] !== undefined).length;
const sheetFull = (s: YachtState, p: PlayerId) => filledCount(s, p) === s.totalRounds;
const currentPlayer = (s: YachtState): PlayerId | null => (s.phase === 'playing' ? s.order[s.current]! : null);
const deadlineFor = (o: YachtOptions, now: number) => (o.turnSeconds > 0 ? now + o.turnSeconds * 1000 : null);
const noHolds = () => [false, false, false, false, false];
const yachtCount = (s: YachtState, p: PlayerId) => {
const sh = s.sheets[p]!;
return (sh.scores.yacht === 50 ? 1 : 0) + sh.yahtzeeBonus / 100;
};
function rollDice(s: YachtState, rng: { int(n: number): number }, events: GameEvent[], player: PlayerId): void {
const first = s.rollsUsed === 0;
const dice = s.dice ?? [1, 1, 1, 1, 1];
if (first) s.held = noHolds();
for (let i = 0; i < DICE; i++) if (first || !s.held[i]) dice[i] = 1 + rng.int(6);
s.dice = dice;
s.rollsUsed++;
s.rollSeq++;
events.push({ type: 'rolled', player, dice: [...dice], held: [...s.held], rollsUsed: s.rollsUsed });
}
/** Next player who still has open categories (skips conceded players); finishes the game if none. */
function advance(s: YachtState, now: number, events: GameEvent[]): void {
s.dice = null;
s.held = noHolds();
s.rollsUsed = 0;
const n = s.order.length;
const active = s.order.filter((p) => !s.left.includes(p));
// A multi-player game ends when at most one player is still in (§8.6: everybody conceded → end now).
const tooFew = n > 1 ? active.length <= 1 : active.length === 0;
let next = -1;
if (!tooFew) {
for (let step = 1; step <= n; step++) {
const i = (s.current + step) % n;
const p = s.order[i]!;
if (!s.left.includes(p) && !sheetFull(s, p)) {
next = i;
break;
}
}
}
if (next < 0) {
s.phase = 'finished';
s.deadlineAt = null;
events.push({ type: 'gameOver' });
return;
}
s.current = next;
s.turnSeq++;
s.turnStartedAt = now;
s.deadlineAt = deadlineFor(s.options, now);
const round = filledCount(s, s.order[next]!) + 1;
if (round !== s.round) {
s.round = round;
events.push({ type: 'roundChanged', round });
}
events.push({ type: 'turnChanged', player: s.order[next]! });
}
function writeScore(s: YachtState, player: PlayerId, category: Category, auto: boolean, now: number, events: GameEvent[]): void {
const sheet = s.sheets[player]!;
const dice = s.dice!;
const before = totals(sheet);
const out = scoreFor(dice, category, sheet, rulesOf(s.options));
sheet.scores[category] = out.points;
sheet.yahtzeeBonus += out.yahtzeeBonus;
s.lastScore = { player, category, points: out.points, bonus: out.yahtzeeBonus, dice: [...dice], auto };
events.push({ type: 'scored', player, category, points: out.points, bonus: out.yahtzeeBonus, joker: out.joker, auto });
if (before.upperBonus === 0 && totals(sheet).upperBonus > 0) events.push({ type: 'upperBonus', player });
advance(s, now, events);
}
function rankRows(s: YachtState): YachtRankRow[] {
const rows = s.order.map((p) => ({ player: p, conceded: s.left.includes(p), yachts: yachtCount(s, p), ...totals(s.sheets[p]!) }));
// Conceded players rank below everyone who played on (then by total).
rows.sort((a, b) => Number(a.conceded) - Number(b.conceded) || b.total - a.total);
const out: YachtRankRow[] = [];
rows.forEach((r, i) => {
const prev = out[i - 1];
const tied = prev && prev.total === r.total && prev.conceded === r.conceded;
out.push({ ...r, rank: tied ? prev.rank : i + 1 });
});
return out;
}
function buildResult(s: YachtState): YachtResult {
const ranking = rankRows(s);
const winners = ranking.filter((r) => r.rank === 1).map((r) => r.player);
const top = ranking[0]!;
const yachtNote = (r: YachtRankRow) => (r.yachts > 0 ? ` (요트 ${r.yachts}회)` : '');
const allLeft = s.order.every((p) => s.left.includes(p));
let summary: string;
if (s.order.length === 1) {
summary = allLeft ? `기권으로 끝났어요. 총점 ${top.total}점` : `총점 ${top.total}점${yachtNote(top)}`;
} else if (winners.length > 1) {
summary = `공동 1위 ${winners.length}명, ${top.total}점`;
} else {
const second = ranking[1]!;
summary = `1위 ${top.total}점${yachtNote(top)} · 2위 ${second.total}점`;
if (ranking.some((r) => r.conceded) && s.order.filter((p) => !s.left.includes(p)).length <= 1) summary = `다른 사람이 모두 기권했어요. ${summary}`;
}
const sheets: YachtResult['sheets'] = {};
for (const p of s.order) sheets[p] = { ...s.sheets[p]!.scores };
const anyLeft = s.left.length > 0;
const reason: GameResult['reason'] = allLeft ? 'abandoned' : anyLeft && s.order.some((p) => !s.left.includes(p) && !sheetFull(s, p)) ? 'resign' : 'normal';
return { ranking, winners, sheets, summary, reason };
}
function concede(s: YachtState, player: PlayerId, now: number, events: GameEvent[]): void {
const sheet = s.sheets[player]!;
for (const c of cats(s)) if (sheet.scores[c] === undefined) sheet.scores[c] = 0;
s.left.push(player);
events.push({ type: 'conceded', player });
const wasCurrent = s.order[s.current] === player;
const active = s.order.filter((p) => !s.left.includes(p));
if (wasCurrent || (s.order.length > 1 ? active.length <= 1 : active.length === 0)) advance(s, now, events);
}
export const yacht: GameDefinition<YachtState, YachtAction, YachtView, YachtOptions> = {
id: 'yacht',
nameKo: '요트 다이스',
minPlayers: 1,
maxPlayers: 8,
stateVersion: 1,
defaultOptions: yachtOptionsSchema.parse({}),
optionsSchema: yachtOptionsSchema,
actionSchema: yachtActionSchema as z.ZodType<YachtAction>,
setup({ players, options, rng, now }) {
if (players.length < 1 || players.length > 8) throw new Error('yacht: 1~8 players');
const order = rng.shuffle(players);
const sheets: Record<PlayerId, Sheet> = {};
const autoStreak: Record<PlayerId, number> = {};
for (const p of players) {
sheets[p] = { scores: {}, yahtzeeBonus: 0 };
autoStreak[p] = 0;
}
return {
options,
order,
current: 0,
round: 1,
totalRounds: CATEGORIES[options.scoring].length,
dice: null,
held: noHolds(),
rollsUsed: 0,
rollSeq: 0,
sheets,
left: [],
phase: 'playing',
turnSeq: 1,
turnStartedAt: now,
deadlineAt: deadlineFor(options, now),
autoStreak,
lastScore: null,
};
},
validate(s, actor, a) {
if (!s.sheets[actor]) return fail('이 게임의 플레이어가 아니에요.');
if (s.phase !== 'playing') return fail('게임이 이미 끝났어요.');
if (s.left.includes(actor)) return fail('이미 기권했어요.');
if (a.type === 'concede') return ok;
if (s.order[s.current] !== actor) return fail('지금은 내 차례가 아니에요.');
switch (a.type) {
case 'roll':
if (s.rollsUsed >= MAX_ROLLS) return fail('이번 차례에는 더 굴릴 수 없어요.');
if (s.rollsUsed > 0 && s.held.every(Boolean)) return fail('굴릴 주사위를 하나 이상 풀어 주세요.');
return ok;
case 'hold':
if (!Array.isArray(a.held) || a.held.length !== DICE || a.held.some((h) => typeof h !== 'boolean')) return fail('잘못된 요청이에요.');
if (s.rollsUsed === 0) return fail('먼저 주사위를 굴려 주세요.');
if (s.rollsUsed >= MAX_ROLLS) return fail('더 굴릴 수 없어서 고정할 필요가 없어요.');
return ok;
case 'score': {
if (s.rollsUsed === 0 || !s.dice) return fail('먼저 주사위를 굴려 주세요.');
if (!cats(s).includes(a.category)) return fail('이 규칙에서는 쓰지 않는 칸이에요.');
const sheet = s.sheets[actor]!;
if (sheet.scores[a.category] !== undefined) return fail('이미 기록한 칸이에요.');
const allowed = allowedCategories(s.dice, sheet, rulesOf(s.options));
if (!allowed.includes(a.category)) {
const own = upperOf(s.dice[0]!);
return fail(
allowed.length === 1 && allowed[0] === own
? `이 요트는 ${categoryName(own, s.options.scoring)} 칸에 먼저 넣어야 해요.`
: '이 요트는 아래쪽 칸에 먼저 넣어야 해요.',
);
}
return ok;
}
case 'timeout':
return ok;
}
},
apply(prev, actor, a, { rng, now }) {
const s = structuredClone(prev);
const events: GameEvent[] = [];
if (a.type !== 'timeout' && a.type !== 'concede') s.autoStreak[actor] = 0;
switch (a.type) {
case 'roll':
if (s.rollsUsed >= MAX_ROLLS) throw new Error('no rolls left');
rollDice(s, rng, events, actor);
break;
case 'hold':
if (s.rollsUsed === 0) throw new Error('hold before roll');
s.held = [...a.held];
events.push({ type: 'holdChanged', player: actor, held: [...s.held] });
break;
case 'score':
if (!s.dice || s.sheets[actor]!.scores[a.category] !== undefined) throw new Error('illegal score');
writeScore(s, actor, a.category, false, now, events);
break;
case 'timeout': {
s.autoStreak[actor] = (s.autoStreak[actor] ?? 0) + 1;
if (s.rollsUsed === 0) rollDice(s, rng, events, actor);
const c = autoCategory(s.dice!, s.sheets[actor]!, rulesOf(s.options));
events.push({ type: 'timeoutAuto', player: actor });
writeScore(s, actor, c, true, now, events);
break;
}
case 'concede':
concede(s, actor, now, events);
break;
}
return { state: s, events };
},
view(s, viewer) {
const cur = currentPlayer(s);
const rules = rulesOf(s.options);
const curSheet = cur ? s.sheets[cur]! : null;
const rolled = !!s.dice && s.rollsUsed > 0 && !!curSheet;
const allowed = rolled ? allowedCategories(s.dice!, curSheet!, rules) : [];
let preview: YachtView['preview'] = null;
let previewBonus = 0;
if (rolled && (s.options.showPreview || viewer === cur)) {
preview = {};
for (const c of cats(s)) {
if (curSheet!.scores[c] !== undefined) continue;
const o = scoreFor(s.dice!, c, curSheet!, rules);
preview[c] = o.points;
previewBonus = Math.max(previewBonus, o.yahtzeeBonus);
}
}
const sheets: YachtView['sheets'] = {};
for (const p of s.order) {
const sh = s.sheets[p]!;
sheets[p] = { scores: sh.scores, filled: filledCount(s, p), left: s.left.includes(p), away: (s.autoStreak[p] ?? 0) >= AWAY_AFTER, ...totals(sh) };
}
return {
options: s.options,
categories: [...cats(s)],
order: s.order,
current: cur,
round: s.round,
totalRounds: s.totalRounds,
dice: s.dice,
held: s.held,
rollsUsed: s.rollsUsed,
rollsLeft: MAX_ROLLS - s.rollsUsed,
rollSeq: s.rollSeq,
turnSeq: s.turnSeq,
canRoll: !!cur && s.rollsUsed < MAX_ROLLS && !(s.rollsUsed > 0 && s.held.every(Boolean)),
canHold: !!cur && s.rollsUsed > 0 && s.rollsUsed < MAX_ROLLS,
allowed,
preview,
previewBonus,
sheets,
phase: s.phase,
turnStartedAt: s.turnStartedAt,
deadlineAt: s.deadlineAt,
lastScore: s.lastScore,
upperBonusThreshold: UPPER_BONUS_THRESHOLD,
result: s.phase === 'finished' ? buildResult(s) : null,
};
},
activePlayers(s) {
const cur = currentPlayer(s);
return cur ? [cur] : [];
},
deadline(s) {
return s.phase === 'playing' ? s.deadlineAt : null;
},
onTimeout() {
return { type: 'timeout' };
},
onLeave(s, player) {
if (s.phase !== 'playing' || !s.sheets[player] || s.left.includes(player)) return null;
return { type: 'concede' };
},
result(s): GameResult | null {
if (s.phase !== 'finished') return null;
const r = buildResult(s);
const groups = new Map<number, PlayerId[]>();
for (const row of r.ranking) groups.set(row.rank, [...(groups.get(row.rank) ?? []), row.player]);
const scores: Record<PlayerId, number> = {};
for (const row of r.ranking) scores[row.player] = row.total;
return { ranking: [...groups.values()], scores, summary: r.summary, reason: r.reason };
},
/** Test helper: every hold pattern, roll and score for the current player (concede left out). */
legalActions(s, p) {
if (s.phase !== 'playing' || s.order[s.current] !== p) return [];
const out: YachtAction[] = [];
if (s.rollsUsed < MAX_ROLLS && !(s.rollsUsed > 0 && s.held.every(Boolean))) out.push({ type: 'roll' });
if (s.rollsUsed > 0 && s.rollsUsed < MAX_ROLLS) {
for (let m = 0; m < 32; m++) {
const held = [0, 1, 2, 3, 4].map((i) => ((m >> i) & 1) === 1);
if (held.some((h, i) => h !== s.held[i])) out.push({ type: 'hold', held });
}
}
if (s.rollsUsed > 0 && s.dice) for (const c of allowedCategories(s.dice, s.sheets[p]!, rulesOf(s.options))) out.push({ type: 'score', category: c });
return out;
},
};

View File

@@ -0,0 +1,211 @@
/** 요트 다이스 점수 계산 (docs/games/yacht.md §6). Shared by the server rules and the client preview. */
export type Category =
| 'ones'
| 'twos'
| 'threes'
| 'fours'
| 'fives'
| 'sixes'
| 'choice'
| 'threeKind'
| 'fourKind'
| 'fullHouse'
| 'smallStraight'
| 'largeStraight'
| 'yacht';
export type Scoring = 'yacht51' | 'yahtzee';
export type JokerRule = 'forced' | 'free';
export interface ScoringRules {
scoring: Scoring;
fullHouseAllowsYacht: boolean;
yahtzeeJoker: JokerRule;
}
export interface Sheet {
scores: Partial<Record<Category, number>>;
/** `yahtzee` mode: accumulated yacht bonus (multiples of 100). */
yahtzeeBonus: number;
}
export const UPPER: readonly Category[] = ['ones', 'twos', 'threes', 'fours', 'fives', 'sixes'];
/** Table order of §6.1 / §6.2 (also the tie-break order for automatic scoring). */
export const CATEGORIES: Record<Scoring, readonly Category[]> = {
yacht51: [...UPPER, 'choice', 'fourKind', 'fullHouse', 'smallStraight', 'largeStraight', 'yacht'],
yahtzee: [...UPPER, 'threeKind', 'fourKind', 'fullHouse', 'smallStraight', 'largeStraight', 'yacht', 'choice'],
};
/** §8.5: category given up first when every open category scores 0. */
export const SACRIFICE_ORDER: readonly Category[] = [
'ones',
'yacht',
'largeStraight',
'smallStraight',
'fullHouse',
'fourKind',
'threeKind',
'twos',
'threes',
'fours',
'fives',
'sixes',
'choice',
];
export const UPPER_BONUS_THRESHOLD = 63;
export const UPPER_BONUS = 35;
export const YAHTZEE_BONUS = 100;
const NAME: Record<Category, string> = {
ones: '에이스',
twos: '듀스',
threes: '트레이',
fours: '포',
fives: '파이브',
sixes: '식스',
choice: '초이스',
threeKind: '쓰리 카인드',
fourKind: '포 카인드',
fullHouse: '풀 하우스',
smallStraight: '스몰 스트레이트',
largeStraight: '라지 스트레이트',
yacht: '요트',
};
export function categoryName(c: Category, scoring: Scoring = 'yacht51'): string {
return c === 'choice' && scoring === 'yahtzee' ? '찬스' : NAME[c];
}
export const isUpper = (c: Category): boolean => UPPER.includes(c);
/** Face value of an upper category (1..6), else 0. */
export const faceOf = (c: Category): number => UPPER.indexOf(c) + 1;
export const upperOf = (face: number): Category => UPPER[face - 1]!;
const sum = (dice: readonly number[]) => dice.reduce((a, b) => a + b, 0);
function counts(dice: readonly number[]): number[] {
const c = [0, 0, 0, 0, 0, 0, 0];
for (const d of dice) c[d]!++;
return c;
}
const hasRun = (c: number[], from: number, len: number) => {
for (let f = from; f < from + len; f++) if (!c[f]) return false;
return true;
};
export const isYacht = (dice: readonly number[]): boolean => dice.length === 5 && dice.every((d) => d === dice[0]);
/** Plain category score, no joker. */
export function baseScore(dice: readonly number[], c: Category, rules: ScoringRules): number {
const cnt = counts(dice);
const max = Math.max(...cnt);
const total = sum(dice);
const y51 = rules.scoring === 'yacht51';
switch (c) {
case 'ones':
case 'twos':
case 'threes':
case 'fours':
case 'fives':
case 'sixes': {
const f = faceOf(c);
return cnt[f]! * f;
}
case 'choice':
return total;
case 'threeKind':
return max >= 3 ? total : 0;
case 'fourKind':
return max >= 4 ? total : 0;
case 'fullHouse': {
const two = cnt.includes(3) && cnt.includes(2);
const ok = two || (max === 5 && y51 && rules.fullHouseAllowsYacht);
return ok ? (y51 ? total : 25) : 0;
}
case 'smallStraight':
return hasRun(cnt, 1, 4) || hasRun(cnt, 2, 4) || hasRun(cnt, 3, 4) ? (y51 ? 15 : 30) : 0;
case 'largeStraight':
return hasRun(cnt, 1, 5) || hasRun(cnt, 2, 5) ? (y51 ? 30 : 40) : 0;
case 'yacht':
return max === 5 ? 50 : 0;
}
}
export interface ScoreOutcome {
points: number;
/** `yahtzee` joker rule changed the score (fixed full house / straight values). */
joker: boolean;
/** `yahtzee` extra-yacht bonus earned by this entry (0 or 100). */
yahtzeeBonus: number;
}
/** True when a `yahtzee`-mode extra yacht may use the joker scores (§6.2). */
export function jokerActive(dice: readonly number[], sheet: Sheet, rules: ScoringRules): boolean {
if (rules.scoring !== 'yahtzee' || !isYacht(dice) || sheet.scores.yacht === undefined) return false;
return sheet.scores[upperOf(dice[0]!)] !== undefined;
}
/** §8.2: `scoreFor(dice, category, sheet, options)`. */
export function scoreFor(dice: readonly number[], c: Category, sheet: Sheet, rules: ScoringRules): ScoreOutcome {
const base = baseScore(dice, c, rules);
if (rules.scoring !== 'yahtzee') return { points: base, joker: false, yahtzeeBonus: 0 };
const bonus = isYacht(dice) && sheet.scores.yacht === 50 && c !== 'yacht' ? YAHTZEE_BONUS : 0;
if (jokerActive(dice, sheet, rules)) {
const fixed: Partial<Record<Category, number>> = { fullHouse: 25, smallStraight: 30, largeStraight: 40 };
const f = fixed[c];
if (f !== undefined) return { points: f, joker: f !== base, yahtzeeBonus: bonus };
}
return { points: base, joker: false, yahtzeeBonus: bonus };
}
/** Open categories the dice may be written into (applies the `forced` joker order). */
export function allowedCategories(dice: readonly number[], sheet: Sheet, rules: ScoringRules): Category[] {
const open = CATEGORIES[rules.scoring].filter((c) => sheet.scores[c] === undefined);
if (rules.scoring !== 'yahtzee' || rules.yahtzeeJoker !== 'forced' || !isYacht(dice) || sheet.scores.yacht === undefined) return open;
const own = upperOf(dice[0]!);
if (sheet.scores[own] === undefined) return [own];
const lower = open.filter((c) => !isUpper(c));
return lower.length ? lower : open;
}
export interface SheetTotals {
upper: number;
upperBonus: number;
lower: number;
yahtzeeBonus: number;
total: number;
}
export function totals(sheet: Sheet): SheetTotals {
let upper = 0;
let lower = 0;
for (const [c, v] of Object.entries(sheet.scores) as [Category, number][]) {
if (isUpper(c)) upper += v;
else lower += v;
}
const upperBonus = upper >= UPPER_BONUS_THRESHOLD ? UPPER_BONUS : 0;
return { upper, upperBonus, lower, yahtzeeBonus: sheet.yahtzeeBonus, total: upper + upperBonus + lower + sheet.yahtzeeBonus };
}
/**
* §8.5 automatic choice: best-scoring allowed category (table order on ties);
* if every allowed category scores 0, the first open one in the sacrifice order.
*/
export function autoCategory(dice: readonly number[], sheet: Sheet, rules: ScoringRules): Category {
const allowed = allowedCategories(dice, sheet, rules);
let best: Category | null = null;
let bestPts = 0;
for (const c of allowed) {
const p = scoreFor(dice, c, sheet, rules).points;
if (p > bestPts) {
best = c;
bestPts = p;
}
}
if (best) return best;
const sacrifice = SACRIFICE_ORDER.find((c) => allowed.includes(c));
if (!sacrifice) throw new Error('no open category');
return sacrifice;
}

View File

@@ -0,0 +1,573 @@
import { describe, expect, test } from 'bun:test';
import { SeededRng, randomPlayout, seedN, type GameEvent } from '@bg/engine';
import {
CATEGORIES,
autoCategory,
baseScore,
scoreFor,
totals,
yacht,
type Category,
type ScoringRules,
type Sheet,
type YachtAction,
type YachtOptions,
type YachtState,
} from './index';
// ---------------------------------------------------------------- helpers
const Y51: ScoringRules = { scoring: 'yacht51', fullHouseAllowsYacht: true, yahtzeeJoker: 'forced' };
const YZ: ScoringRules = { scoring: 'yahtzee', fullHouseAllowsYacht: true, yahtzeeJoker: 'forced' };
const empty = (): Sheet => ({ scores: {}, yahtzeeBonus: 0 });
const all = (dice: number[], r: ScoringRules = Y51, sheet: Sheet = empty()) =>
Object.fromEntries(CATEGORIES[r.scoring].map((c) => [c, scoreFor(dice, c, sheet, r).points])) as Record<Category, number>;
let clock = 1_000;
const rng = () => SeededRng.fromSeed([3, 1, 4, 1]);
/** Fresh game with turn order exactly `players` (seeds are searched until the shuffle keeps it). */
function make(opts: Partial<YachtOptions> = {}, players = ['A', 'B', 'C']): YachtState {
const options = yacht.optionsSchema.parse(opts);
for (let seed = 1; seed < 2000; seed++) {
const s = yacht.setup({ players, options, rng: SeededRng.fromSeed(seedN(seed)), now: 0 });
if (s.order.join() === players.join()) return s;
}
throw new Error('no seed keeps the order');
}
function actE(s: YachtState, p: string, a: YachtAction, r = rng()): { state: YachtState; events: GameEvent[] } {
const v = yacht.validate(s, p, a);
if (!v.ok) throw new Error(`${p} ${JSON.stringify(a)}: ${v.reason}`);
return yacht.apply(s, p, a, { rng: r, now: (clock += 1000) });
}
const act = (s: YachtState, p: string, a: YachtAction, r?: SeededRng) => actE(s, p, a, r).state;
const reject = (s: YachtState, p: string, a: YachtAction) => {
const v = yacht.validate(s, p, a);
return v.ok ? null : v.reason;
};
/** Rolls once, then forces the dice (tests set exact dice). */
function rolled(s: YachtState, p: string, dice: number[]): YachtState {
const t = s.rollsUsed === 0 ? act(s, p, { type: 'roll' }) : s;
t.dice = [...dice];
return t;
}
const cur = (s: YachtState) => s.order[s.current]!;
// ---------------------------------------------------------------- scoring
describe('yacht51 scoring (§6.1, §10)', () => {
test('[6,6,6,6,2] matches the doc example', () => {
const r = all([6, 6, 6, 6, 2]);
expect(r).toMatchObject({ sixes: 24, twos: 2, choice: 26, fourKind: 26, fullHouse: 0, smallStraight: 0, largeStraight: 0, yacht: 0, ones: 0 });
});
test('[3,3,5,5,5]: full house 21, choice 21, threes 6, fives 15', () => {
expect(all([3, 3, 5, 5, 5])).toMatchObject({ fullHouse: 21, choice: 21, threes: 6, fives: 15, fourKind: 0, yacht: 0 });
});
test('straights: [1,2,3,4,6] small 15 / large 0, [2,3,4,5,6] small 15 / large 30', () => {
expect(all([1, 2, 3, 4, 6])).toMatchObject({ smallStraight: 15, largeStraight: 0 });
expect(all([6, 3, 2, 5, 4])).toMatchObject({ smallStraight: 15, largeStraight: 30 });
expect(all([1, 2, 3, 4, 5])).toMatchObject({ smallStraight: 15, largeStraight: 30 });
expect(all([3, 4, 5, 6, 6])).toMatchObject({ smallStraight: 15, largeStraight: 0 });
expect(all([1, 2, 3, 5, 6])).toMatchObject({ smallStraight: 0, largeStraight: 0 });
expect(all([1, 1, 2, 3, 4])).toMatchObject({ smallStraight: 15 });
});
test('[4,4,4,4,4]: yacht 50, four of a kind 20, fours 20; full house depends on the option', () => {
expect(all([4, 4, 4, 4, 4])).toMatchObject({ yacht: 50, fourKind: 20, fullHouse: 20, fours: 20, choice: 20 });
expect(all([4, 4, 4, 4, 4], { ...Y51, fullHouseAllowsYacht: false })).toMatchObject({ fullHouse: 0, yacht: 50 });
});
test('upper categories sum only that face; full house needs 3 + 2', () => {
expect(all([1, 1, 1, 2, 3])).toMatchObject({ ones: 3, twos: 2, threes: 3, fours: 0, fullHouse: 0 });
expect(all([2, 2, 2, 2, 3])).toMatchObject({ fourKind: 11, fullHouse: 0, twos: 8 });
expect(all([6, 6, 6, 5, 5])).toMatchObject({ fullHouse: 28 });
});
test('upper bonus: 62 → 0, 63 → 35', () => {
const s62: Sheet = { scores: { ones: 2, twos: 6, threes: 9, fours: 12, fives: 15, sixes: 18 }, yahtzeeBonus: 0 };
expect(totals(s62)).toMatchObject({ upper: 62, upperBonus: 0, total: 62 });
const s63: Sheet = { scores: { ...s62.scores, ones: 3, choice: 20 }, yahtzeeBonus: 0 };
expect(totals(s63)).toMatchObject({ upper: 63, upperBonus: 35, lower: 20, total: 118 });
});
test('theoretical maximum 325 (323 without yacht full house)', () => {
const best = (rules: ScoringRules) => {
const sh = empty();
for (const c of CATEGORIES.yacht51) {
let m = 0;
for (let a = 1; a <= 6; a++)
for (let b = 1; b <= 6; b++)
for (let c2 = 1; c2 <= 6; c2++)
for (let d = 1; d <= 6; d++) for (let e = 1; e <= 6; e++) m = Math.max(m, baseScore([a, b, c2, d, e], c, rules));
sh.scores[c] = m;
}
return totals(sh).total;
};
expect(best(Y51)).toBe(325);
expect(best({ ...Y51, fullHouseAllowsYacht: false })).toBe(323);
});
});
describe('yahtzee (13칸 변형) scoring (§6.2)', () => {
test('fixed values and three of a kind / chance', () => {
expect(all([3, 3, 5, 5, 5], YZ)).toMatchObject({ fullHouse: 25, threeKind: 21, fourKind: 0, choice: 21 });
expect(all([1, 2, 3, 4, 6], YZ)).toMatchObject({ smallStraight: 30, largeStraight: 0 });
expect(all([2, 3, 4, 5, 6], YZ)).toMatchObject({ smallStraight: 30, largeStraight: 40 });
// A first yacht is not a full house in this variant.
expect(all([4, 4, 4, 4, 4], YZ)).toMatchObject({ yacht: 50, fullHouse: 0, threeKind: 20, fourKind: 20 });
});
test('yacht bonus +100 only when the yacht box holds 50', () => {
const with50: Sheet = { scores: { yacht: 50 }, yahtzeeBonus: 0 };
expect(scoreFor([5, 5, 5, 5, 5], 'fives', with50, YZ)).toEqual({ points: 25, joker: false, yahtzeeBonus: 100 });
const with0: Sheet = { scores: { yacht: 0 }, yahtzeeBonus: 0 };
expect(scoreFor([5, 5, 5, 5, 5], 'fives', with0, YZ).yahtzeeBonus).toBe(0);
});
test('automatic choice: best score, table order on ties, sacrifice order when all are 0', () => {
expect(autoCategory([6, 6, 6, 6, 2], empty(), Y51)).toBe('choice'); // 26 = fourKind 26 → choice is higher in the table
expect(autoCategory([5, 5, 5, 5, 5], empty(), Y51)).toBe('yacht');
const onlyZero: Sheet = { scores: { twos: 4, threes: 9, fours: 8, fives: 10, sixes: 12, choice: 20, fourKind: 0, fullHouse: 0, smallStraight: 15, largeStraight: 30 }, yahtzeeBonus: 0 };
// [2,2,3,4,6]: ones 0, yacht 0 → sacrifice order picks ones first.
expect(autoCategory([2, 2, 3, 4, 6], onlyZero, Y51)).toBe('ones');
expect(autoCategory([2, 2, 3, 4, 6], { ...onlyZero, scores: { ...onlyZero.scores, ones: 0 } }, Y51)).toBe('yacht');
});
});
// ---------------------------------------------------------------- turn flow
describe('turn flow and validation (§5, §8.2, §10)', () => {
test('before the first roll: SCORE and SET_HOLD are rejected; not my turn is rejected', () => {
const s = make();
expect(reject(s, 'A', { type: 'score', category: 'choice' })).toBe('먼저 주사위를 굴려 주세요.');
expect(reject(s, 'A', { type: 'hold', held: [true, false, false, false, false] })).toBe('먼저 주사위를 굴려 주세요.');
expect(reject(s, 'B', { type: 'roll' })).toBe('지금은 내 차례가 아니에요.');
expect(reject(s, 'Z', { type: 'roll' })).toBe('이 게임의 플레이어가 아니에요.');
expect(reject(s, 'A', { type: 'roll' })).toBeNull();
});
test('roll limit: a 4th roll is rejected, holds are rejected after the 3rd roll', () => {
let s = make();
for (let i = 0; i < 3; i++) s = act(s, 'A', { type: 'roll' });
expect(s.rollsUsed).toBe(3);
expect(reject(s, 'A', { type: 'roll' })).toBe('이번 차례에는 더 굴릴 수 없어요.');
expect(reject(s, 'A', { type: 'hold', held: [true, true, true, true, true] })).not.toBeNull();
expect(yacht.view(s, 'A').canRoll).toBe(false);
});
test('all five held → roll rejected', () => {
let s = act(make(), 'A', { type: 'roll' });
s = act(s, 'A', { type: 'hold', held: [true, true, true, true, true] });
expect(reject(s, 'A', { type: 'roll' })).toBe('굴릴 주사위를 하나 이상 풀어 주세요.');
expect(yacht.view(s, 'B').canRoll).toBe(false);
});
test('the first roll ignores holds; held dice keep their value; released dice roll again', () => {
let s = act(make(), 'A', { type: 'roll' });
s.dice = [1, 2, 3, 4, 5];
s = act(s, 'A', { type: 'hold', held: [true, false, true, false, true] });
expect(s.held).toEqual([true, false, true, false, true]);
// Pick an rng whose next values would change die 1 (index 0) if it rolled.
s = act(s, 'A', { type: 'roll' }, SeededRng.fromSeed(seedN(5)));
expect([s.dice![0], s.dice![2], s.dice![4]]).toEqual([1, 3, 5]);
// 3rd roll: release die 0 → it is rolled again (consumes the first rng value).
const before = s.dice!.slice();
s = act(s, 'A', { type: 'hold', held: [false, true, true, true, true] });
const r = SeededRng.fromSeed(seedN(9));
const expected = 1 + SeededRng.fromSeed(seedN(9)).int(6);
s = act(s, 'A', { type: 'roll' }, r);
expect(s.dice![0]).toBe(expected);
expect(s.dice!.slice(1)).toEqual(before.slice(1));
});
test('dice are rolled index 0 → 4 with rng.int(6) (replay order)', () => {
const s = make();
const r1 = SeededRng.fromSeed(seedN(42));
const ref = SeededRng.fromSeed(seedN(42));
const out = act(s, 'A', { type: 'roll' }, r1);
expect(out.dice).toEqual([0, 1, 2, 3, 4].map(() => 1 + ref.int(6)));
});
test('filled categories are rejected; a zero-scoring category is allowed', () => {
let s = rolled(make(), 'A', [1, 2, 3, 4, 6]);
s = act(s, 'A', { type: 'score', category: 'yacht' });
expect(s.sheets.A!.scores.yacht).toBe(0);
expect(cur(s)).toBe('B');
s = rolled(s, 'B', [2, 2, 2, 2, 2]);
s = act(s, 'B', { type: 'score', category: 'twos' });
s = rolled(s, 'C', [3, 3, 3, 3, 3]);
s = act(s, 'C', { type: 'score', category: 'yacht' });
s = rolled(s, 'A', [5, 5, 5, 5, 5]);
expect(reject(s, 'A', { type: 'score', category: 'yacht' })).toBe('이미 기록한 칸이에요.');
expect(reject(s, 'A', { type: 'score', category: 'threeKind' })).toBe('이 규칙에서는 쓰지 않는 칸이에요.');
expect(s.round).toBe(2);
});
test('scoring ends the turn and resets dice/holds; events', () => {
let s = rolled(make(), 'A', [6, 6, 6, 6, 2]);
s = act(s, 'A', { type: 'hold', held: [true, true, true, true, false] });
const out = actE(s, 'A', { type: 'score', category: 'sixes' });
expect(out.events.map((e) => e.type)).toEqual(['scored', 'turnChanged']);
expect(out.events[0]).toMatchObject({ player: 'A', category: 'sixes', points: 24, bonus: 0 });
expect(out.state).toMatchObject({ dice: null, rollsUsed: 0, held: [false, false, false, false, false] });
});
test('upper bonus event fires once when the upper total reaches 63', () => {
let s = make({}, ['A']);
s.sheets.A!.scores = { ones: 3, twos: 6, threes: 9, fours: 12, fives: 15 };
s = rolled(s, 'A', [6, 6, 6, 1, 1]);
const out = actE(s, 'A', { type: 'score', category: 'sixes' });
expect(out.events.map((e) => e.type)).toContain('upperBonus');
expect(yacht.view(out.state, 'A').sheets.A).toMatchObject({ upper: 63, upperBonus: 35 });
});
test('12 rounds × 3 players end automatically; ties share the rank', () => {
let s = make();
const plan = CATEGORIES.yacht51;
for (let round = 0; round < 12; round++) {
for (const p of ['A', 'B', 'C']) {
expect(cur(s)).toBe(p);
expect(s.round).toBe(round + 1);
// A and B mirror each other; C always rolls ones.
s = rolled(s, p, p === 'C' ? [1, 1, 2, 2, 3] : [6, 6, 6, 6, 6]);
s = act(s, p, { type: 'score', category: plan[round]! });
}
}
expect(s.phase).toBe('finished');
const r = yacht.result(s)!;
expect(r.ranking).toEqual([['A', 'B'], ['C']]);
expect(r.reason).toBe('normal');
expect(r.scores!.A).toBe(r.scores!.B);
expect(r.summary).toContain('공동 1위');
expect(yacht.activePlayers(s)).toEqual([]);
expect(reject(s, 'A', { type: 'roll' })).toBe('게임이 이미 끝났어요.');
const v = yacht.view(s, null).result!;
expect(v.ranking.map((x) => x.rank)).toEqual([1, 1, 3]);
expect(v.winners).toEqual(['A', 'B']);
});
test('solo: 12 rounds in a row, result is the total', () => {
let s = make({}, ['S']);
for (let round = 0; round < 12; round++) {
expect(cur(s)).toBe('S');
s = rolled(s, 'S', [5, 5, 5, 5, 5]);
s = act(s, 'S', { type: 'score', category: CATEGORIES.yacht51[round]! });
}
expect(s.phase).toBe('finished');
const r = yacht.result(s)!;
expect(r.ranking).toEqual([['S']]);
expect(r.scores).toEqual({ S: 25 + 25 + 25 + 25 + 50 }); // fives, choice, 4K, full house (yacht counts), yacht
expect(r.summary).toContain('총점');
expect(r.summary).toContain('요트 1회');
});
});
describe('yahtzee joker and bonus in play (§6.2, §10)', () => {
test('second yacht after a 50 → +100', () => {
let s = make({ scoring: 'yahtzee' }, ['A']);
s = rolled(s, 'A', [3, 3, 3, 3, 3]);
s = act(s, 'A', { type: 'score', category: 'yacht' });
s = rolled(s, 'A', [2, 2, 2, 2, 2]);
const out = actE(s, 'A', { type: 'score', category: 'twos' });
expect(out.events[0]).toMatchObject({ points: 10, bonus: 100 });
expect(yacht.view(out.state, 'A').sheets.A).toMatchObject({ yahtzeeBonus: 100, total: 160 });
});
test('yacht box 0 → no bonus', () => {
let s = make({ scoring: 'yahtzee' }, ['A']);
s = rolled(s, 'A', [1, 2, 3, 4, 6]);
s = act(s, 'A', { type: 'score', category: 'yacht' });
s = rolled(s, 'A', [2, 2, 2, 2, 2]);
s = act(s, 'A', { type: 'score', category: 'twos' });
expect(s.sheets.A!.yahtzeeBonus).toBe(0);
});
test('forced joker: own upper box first, then lower boxes (large straight 40), then any upper box at 0', () => {
let s = make({ scoring: 'yahtzee', yahtzeeJoker: 'forced' }, ['A']);
s.sheets.A!.scores = { yacht: 50 };
s = rolled(s, 'A', [5, 5, 5, 5, 5]);
expect(reject(s, 'A', { type: 'score', category: 'largeStraight' })).toBe('이 요트는 파이브 칸에 먼저 넣어야 해요.');
expect(reject(s, 'A', { type: 'score', category: 'choice' })).not.toBeNull();
expect(yacht.view(s, 'A').allowed).toEqual(['fives']);
expect(reject(s, 'A', { type: 'score', category: 'fives' })).toBeNull();
s.sheets.A!.scores = { yacht: 50, fives: 15 };
expect(reject(s, 'A', { type: 'score', category: 'ones' })).toBe('이 요트는 아래쪽 칸에 먼저 넣어야 해요.');
const out = actE(s, 'A', { type: 'score', category: 'largeStraight' });
expect(out.events[0]).toMatchObject({ points: 40, bonus: 100, joker: true });
let t = make({ scoring: 'yahtzee', yahtzeeJoker: 'forced' }, ['A']);
t.sheets.A!.scores = { yacht: 0, fives: 15, threeKind: 10, fourKind: 0, fullHouse: 25, smallStraight: 30, largeStraight: 40, choice: 20 };
t = rolled(t, 'A', [5, 5, 5, 5, 5]);
expect(yacht.view(t, 'A').allowed).toEqual(['ones', 'twos', 'threes', 'fours', 'sixes']);
const z = actE(t, 'A', { type: 'score', category: 'twos' });
expect(z.events[0]).toMatchObject({ points: 0, bonus: 0 });
});
test('free joker: any open box; fixed scores only when the own upper box is filled', () => {
let s = make({ scoring: 'yahtzee', yahtzeeJoker: 'free' }, ['A']);
s.sheets.A!.scores = { yacht: 50 };
s = rolled(s, 'A', [4, 4, 4, 4, 4]);
expect(reject(s, 'A', { type: 'score', category: 'fullHouse' })).toBeNull();
expect(yacht.view(s, 'A').preview).toMatchObject({ fullHouse: 0, largeStraight: 0, fours: 20 });
s.sheets.A!.scores = { yacht: 50, fours: 16 };
expect(yacht.view(s, 'A').preview).toMatchObject({ fullHouse: 25, smallStraight: 30, largeStraight: 40, ones: 0 });
expect(yacht.view(s, 'A').previewBonus).toBe(100);
});
});
// ---------------------------------------------------------------- timeout, leave, concede
describe('timeout, leaving and conceding (§8.5, §10)', () => {
test('timeout without a roll: rolls, then scores the best category', () => {
const s = make();
expect(yacht.onTimeout(s, 'A')).toEqual({ type: 'timeout' });
expect(yacht.actionSchema.safeParse({ type: 'timeout' }).success).toBe(false);
const out = actE(s, 'A', yacht.onTimeout(s, 'A'));
const types = out.events.map((e) => e.type);
expect(types.slice(0, 3)).toEqual(['rolled', 'timeoutAuto', 'scored']);
const dice = (out.events[0] as unknown as { dice: number[] }).dice;
const scored = out.events[2] as unknown as { category: Category; points: number; auto: boolean };
expect(scored.category).toBe(autoCategory(dice, { scores: {}, yahtzeeBonus: 0 }, Y51));
expect(scored.auto).toBe(true);
expect(cur(out.state)).toBe('B');
expect(out.state.autoStreak.A).toBe(1);
});
test('timeout when every open category scores 0 → sacrifice order', () => {
let s = make({}, ['A']);
s.sheets.A!.scores = { twos: 4, threes: 9, fours: 8, fives: 10, sixes: 12, choice: 20, fourKind: 0, fullHouse: 0, smallStraight: 15, largeStraight: 30 };
s = rolled(s, 'A', [2, 2, 3, 4, 6]);
s = act(s, 'A', { type: 'timeout' });
expect(s.sheets.A!.scores.ones).toBe(0);
expect(s.sheets.A!.scores.yacht).toBeUndefined();
});
test('deadline is per turn (not reset by rolls); 0 = no limit', () => {
let s = yacht.setup({ players: ['A', 'B'], options: yacht.optionsSchema.parse({ turnSeconds: 30 }), rng: rng(), now: 1000 });
expect(yacht.deadline!(s)).toBe(31_000);
s = yacht.apply(s, cur(s), { type: 'roll' }, { rng: rng(), now: 5000 }).state;
expect(yacht.deadline!(s)).toBe(31_000);
s = yacht.apply(s, cur(s), { type: 'score', category: 'choice' }, { rng: rng(), now: 9000 }).state;
expect(yacht.deadline!(s)).toBe(39_000);
const n = yacht.setup({ players: ['A'], options: yacht.optionsSchema.parse({ turnSeconds: 0 }), rng: rng(), now: 0 });
expect(yacht.deadline!(n)).toBeNull();
});
test('a disconnected player is auto-played via timeout without blocking others; 3 autos → away', () => {
let s = make();
for (let i = 0; i < 3; i++) {
s = act(s, 'A', yacht.onTimeout(s, 'A'));
s = act(rolled(s, 'B', [1, 2, 3, 4, 5]), 'B', { type: 'score', category: CATEGORIES.yacht51[11 - i]! });
s = act(rolled(s, 'C', [1, 2, 3, 4, 5]), 'C', { type: 'score', category: CATEGORIES.yacht51[11 - i]! });
}
expect(yacht.view(s, 'B').sheets.A!.away).toBe(true);
expect(s.round).toBe(4);
s = act(s, 'A', { type: 'roll' });
expect(yacht.view(s, 'B').sheets.A!.away).toBe(false);
});
test('concede: open categories become 0, the player is skipped, and still appears in the result', () => {
let s = make();
s = act(rolled(s, 'A', [6, 6, 6, 6, 6]), 'A', { type: 'score', category: 'yacht' });
// B concedes during their own turn → C moves next.
expect(yacht.onLeave!(s, 'B')).toEqual({ type: 'concede' });
const out = actE(s, 'B', { type: 'concede' });
s = out.state;
expect(out.events.map((e) => e.type)).toContain('conceded');
expect(Object.values(s.sheets.B!.scores)).toEqual(Array(12).fill(0));
expect(cur(s)).toBe('C');
expect(reject(s, 'B', { type: 'roll' })).toBe('이미 기권했어요.');
expect(yacht.onLeave!(s, 'B')).toBeNull();
// Play to the end with A and C.
while (s.phase === 'playing') {
const p = cur(s);
expect(p).not.toBe('B');
s = act(s, p, yacht.onTimeout(s, p));
}
const r = yacht.result(s)!;
expect(r.ranking.flat().sort()).toEqual(['A', 'B', 'C']);
expect(r.ranking.at(-1)).toEqual(['B']);
expect(r.scores!.B).toBe(0);
});
test('a non-current player may concede; when only one player remains the game ends', () => {
let s = make({}, ['A', 'B']);
s = act(s, 'B', { type: 'concede' });
expect(s.phase).toBe('finished');
const r = yacht.result(s)!;
expect(r.ranking).toEqual([['A'], ['B']]);
expect(r.reason).toBe('resign');
expect(r.summary).toContain('기권');
});
test('solo concede ends the game at once', () => {
let s = make({}, ['S']);
s = act(s, 'S', { type: 'concede' });
expect(s.phase).toBe('finished');
expect(yacht.result(s)).toMatchObject({ ranking: [['S']], reason: 'abandoned' });
});
});
// ---------------------------------------------------------------- view, options
describe('view and options', () => {
test('preview for the current player always; for others only with showPreview', () => {
let s = rolled(make({ showPreview: false }), 'A', [6, 6, 6, 6, 2]);
expect(yacht.view(s, 'A').preview).toMatchObject({ sixes: 24, fourKind: 26, fullHouse: 0 });
expect(yacht.view(s, 'B').preview).toBeNull();
expect(yacht.view(s, null).preview).toBeNull();
s = rolled(make(), 'A', [6, 6, 6, 6, 2]);
expect(yacht.view(s, null).preview).toMatchObject({ sixes: 24 });
expect(yacht.view(make(), 'A').preview).toBeNull();
});
test('no RNG / hidden state in the view; same seed + actions → same dice', () => {
const run = () => {
const r = SeededRng.fromSeed(seedN(77));
let s = yacht.setup({ players: ['A', 'B'], options: yacht.defaultOptions, rng: r, now: 0 });
const seen: number[][] = [];
for (let i = 0; i < 6; i++) {
s = yacht.apply(s, cur(s), { type: 'roll' }, { rng: r, now: i }).state;
seen.push(s.dice!.slice());
s = yacht.apply(s, cur(s), { type: 'timeout' }, { rng: r, now: i }).state;
}
return { seen, view: JSON.stringify(yacht.view(s, 'A')) };
};
const a = run();
expect(run()).toEqual(a);
expect(a.view).not.toMatch(/rng|seed/i);
// No hidden information: with showPreview every viewer gets the same view.
const r = rolled(make(), 'A', [1, 2, 3, 4, 5]);
expect(JSON.stringify(yacht.view(r, 'B'))).toBe(JSON.stringify(yacht.view(r, 'A')));
expect(JSON.stringify(yacht.view(r, null))).toBe(JSON.stringify(yacht.view(r, 'A')));
});
test('schema defaults, Korean metadata, client-only actions', () => {
expect(yacht.defaultOptions).toEqual({ scoring: 'yacht51', fullHouseAllowsYacht: true, yahtzeeJoker: 'forced', turnSeconds: 60, showPreview: true });
const shape = (yacht.optionsSchema as unknown as { shape: Record<string, { meta(): { title?: string } | undefined }> }).shape;
for (const [k, v] of Object.entries(shape)) expect(v.meta()?.title, k).toBeTruthy();
// Trademark note (§12): no "Yahtzee"/"야찌" in any visible title or label.
const visible = Object.values(shape).map((v) => {
const m = v.meta() as { title?: string; labels?: Record<string, string> };
return [m.title, ...Object.values(m.labels ?? {})].join(' ');
});
expect(visible.join(' ')).not.toMatch(/yahtzee|야찌/i);
expect(yacht.optionsSchema.safeParse({ turnSeconds: 45 }).success).toBe(false);
expect(yacht.actionSchema.safeParse({ type: 'hold', held: [true, false] }).success).toBe(false);
expect(yacht.actionSchema.safeParse({ type: 'score', category: 'nope' }).success).toBe(false);
expect(yacht.actionSchema.safeParse({ type: 'score', category: 'yacht' }).success).toBe(true);
expect(yacht.minPlayers).toBe(1);
expect(yacht.maxPlayers).toBe(8);
expect(() => yacht.setup({ players: 'ABCDEFGHI'.split(''), options: yacht.defaultOptions, rng: rng(), now: 0 })).toThrow();
expect(make({ scoring: 'yahtzee' }).totalRounds).toBe(13);
});
});
// ---------------------------------------------------------------- properties
describe('random playouts (docs/09 §7)', () => {
/** Checks every transition: scored values match scoreFor on the previous dice, totals add up, onTimeout is legal. */
function checker() {
let prev: YachtState | null = null;
return (st: unknown) => {
const s = st as YachtState;
const cats = CATEGORIES[s.options.scoring];
for (const p of s.order) {
const sh = s.sheets[p]!;
for (const k of Object.keys(sh.scores)) if (!cats.includes(k as Category)) throw new Error(`foreign category ${k}`);
const t = totals(sh);
if (t.total !== t.upper + t.upperBonus + t.lower + t.yahtzeeBonus) throw new Error('totals');
if (Object.values(sh.scores).some((v) => v < 0 || v > 50)) throw new Error('score out of range');
if (prev) {
const before = prev.sheets[p]!;
const added = cats.filter((c) => before.scores[c] === undefined && sh.scores[c] !== undefined);
if (added.length > 0 && !s.left.includes(p)) {
if (added.length !== 1) throw new Error('more than one category filled');
const c = added[0]!;
const rules = { scoring: s.options.scoring, fullHouseAllowsYacht: s.options.fullHouseAllowsYacht, yahtzeeJoker: s.options.yahtzeeJoker };
const dice = prev.dice ?? s.lastScore!.dice;
const exp = scoreFor(dice, c, before, rules);
if (sh.scores[c] !== exp.points || sh.yahtzeeBonus - before.yahtzeeBonus !== exp.yahtzeeBonus) throw new Error(`score mismatch ${c}`);
}
}
}
if (s.phase === 'playing') {
if (s.rollsUsed < 0 || s.rollsUsed > 3) throw new Error('rollsUsed');
if ((s.dice === null) !== (s.rollsUsed === 0)) throw new Error('dice / rollsUsed mismatch');
if (s.dice && s.dice.some((d) => !Number.isInteger(d) || d < 1 || d > 6)) throw new Error('die value');
if (s.round < 1 || s.round > s.totalRounds) throw new Error('round');
for (const p of yacht.activePlayers(s)) {
const a = yacht.onTimeout(s, p);
const v = yacht.validate(s, p, a);
if (!v.ok) throw new Error(`onTimeout illegal: ${v.reason}`);
yacht.apply(s, p, a, { rng: SeededRng.fromSeed([1, 2, 3, 4]), now: 0 });
}
} else {
const r = yacht.result(s)!;
// Normal end: every sheet is full. Early end only when at most one player is still in.
const stillIn = s.order.filter((p) => !s.left.includes(p));
if (r.reason === 'normal') {
for (const p of s.order) if (Object.keys(s.sheets[p]!.scores).length !== s.totalRounds) throw new Error('unfinished sheet');
} else if (stillIn.length > 1) throw new Error('ended early with players left');
for (const p of s.order) if (r.scores![p] !== totals(s.sheets[p]!).total) throw new Error('result total');
}
prev = s;
};
}
test('1,000 seeds: finish, scores consistent, onTimeout legal, deterministic', () => {
for (let i = 1; i <= 1000; i++) {
const n = i % 10 === 0 ? 8 : 1 + (i % 4);
const players = Array.from({ length: n }, (_, k) => `P${k}`);
const options = yacht.optionsSchema.parse({
scoring: i % 3 === 0 ? 'yahtzee' : 'yacht51',
fullHouseAllowsYacht: i % 2 === 0,
yahtzeeJoker: i % 6 === 0 ? 'free' : 'forced',
});
const a = randomPlayout(yacht, { players, options, seed: seedN(i), maxSteps: 20_000, invariant: checker() });
if (!a.finished) throw new Error(`seed ${i} did not finish`);
const r = yacht.result(a.finalState as YachtState)!;
expect(r.ranking.flat().sort()).toEqual([...players].sort());
if (i % 25 === 1) {
const b = randomPlayout(yacht, { players, options, seed: seedN(i), maxSteps: 20_000 });
expect(JSON.stringify(b.finalState)).toBe(JSON.stringify(a.finalState));
}
}
}, 60_000);
test('200 seeds with random concedes and timeouts', () => {
for (let i = 1; i <= 200; i++) {
const players = Array.from({ length: 1 + (i % 5) }, (_, k) => `P${k}`);
const r = SeededRng.fromSeed(seedN(i));
const chooser = SeededRng.fromSeed(seedN(i + 999));
const check = checker();
let s = yacht.setup({ players, options: yacht.optionsSchema.parse({ scoring: i % 2 ? 'yahtzee' : 'yacht51' }), rng: r, now: 0 });
let steps = 0;
while (s.phase === 'playing') {
if (++steps > 5000) throw new Error('too long');
check(s);
const roll = chooser.int(100);
let actor = cur(s);
let a: YachtAction;
if (roll < 2) {
const alive = players.filter((p) => !s.left.includes(p));
actor = alive[chooser.int(alive.length)]!;
a = { type: 'concede' };
} else if (roll < 12) a = yacht.onTimeout(s, actor);
else {
const legal = yacht.legalActions!(s, actor);
a = legal[chooser.int(legal.length)]!;
}
const v = yacht.validate(s, actor, a);
if (!v.ok) throw new Error(`rejected ${JSON.stringify(a)}: ${v.reason}`);
s = yacht.apply(s, actor, a, { rng: r, now: steps * 1000 }).state;
}
check(s);
expect(yacht.result(s)!.ranking.flat().sort()).toEqual([...players].sort());
}
}, 30_000);
});

View File

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

View File

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

View File

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

View File

@@ -18,6 +18,11 @@ 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: 'yacht', nameKo: '요트 다이스', minPlayers: 1, maxPlayers: 8, minutes: '10~30', level: '쉬움', blurb: '주사위 다섯 개로 족보 만들기 · 혼자서도 OK', available: true },
{ id: 'yut', nameKo: '윷놀이', minPlayers: 2, maxPlayers: 8, minutes: '15~25', level: '쉬움', blurb: '도·개·걸·윷·모! 개인전 또는 팀전', available: true },
{ id: 'one-card', nameKo: '원카드', minPlayers: 2, maxPlayers: 10, minutes: '10~15', level: '쉬움', blurb: '같은 무늬·숫자를 내고 공격 카드로 먹이기! · 색깔 카드 모드', available: true },
{ id: 'rummikub', nameKo: '숫자 타일', minPlayers: 2, maxPlayers: 4, minutes: '30~60', level: '보통', blurb: '같은 숫자, 이어지는 숫자로 묶어 먼저 다 내려놓기!', available: true },
{ id: 'davinci-code', nameKo: '다빈치 코드', minPlayers: 2, maxPlayers: 4, minutes: '10~15', 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);